repo.box Spec: Groups
Overview
Groups are named collections of identities. The system asks one question: is identity X in group Y?
Groups don't grant permissions directly — they're just lists. The permissions layer maps groups to actions.
Core Interface
Three operations:
interface GroupResolver {
isMember(group: string, address: string): Promise<boolean>;
listMembers(group: string): Promise<string[]>; // May not be available for all resolvers
}
Mutation (add/remove) is only available for static groups and happens by editing .repobox/config.yml.
isMember() is the only required method. listMembers() is best-effort.
Resolver Types
1. Static (local list)
Members listed directly in .repobox/config.yml. No external calls.
[group "core-team"]
member = evm:0xAAA...
member = evm:0xBBB...
member = evm:0xCCC...
isMember(): O(1) lookup from parsed configlistMembers(): returns the full list- Mutation: edit the config file
2. Onchain (trustless verification)
Membership checked by calling a smart contract function on a specific chain. The contract must implement:
function <name>(address) external view returns (bool)
Any function that takes a single address and returns bool qualifies.
[group "token-holders"]
resolver = onchain
chain = 8453
contract = 0xDDD...
function = isMember
indexer = https://api.example.com/groups/token-holders
Config fields:
chain— Chain ID (EIP-155) where the contract livescontract— Contract addressfunction— Function name. Must have signaturef(address) → boolindexer— (Optional) HTTP endpoint for full member list
Verification flow:
- Server encodes
function(signerAddress)as calldata - Sends
eth_callto the contract on the specified chain via server-side RPC - Decodes bool result
Trustless: the membership check is verified onchain. No trust in third parties.
Full list is trustful: Since you can't efficiently enumerate all addresses that return true from an onchain function, the optional indexer URL provides the full list. This is used for display/admin only, never for access control decisions.
If no indexer is configured, listMembers() returns only static members (if the group also has include or member entries).
RPC is server-side: Users and CLIs never need their own RPC node. repo.box maintains RPC connections per chain and handles all onchain calls.
Caching: Results cached with configurable TTL per group to avoid excessive RPC calls. Default: 5 minutes.
[group "token-holders"]
resolver = onchain
chain = 8453
contract = 0xDDD...
function = isMember
cache-ttl = 300
What about multi-param functions (e.g. Hats Protocol)?
Hats uses isWearerOfHat(uint256 hatId, address account) → bool which doesn't fit the f(address) → bool interface.
Solution: Deploy a tiny wrapper contract that hardcodes the extra params:
contract HatsGroupCheck {
IHats public immutable hats;
uint256 public immutable hatId;
constructor(address _hats, uint256 _hatId) {
hats = IHats(_hats);
hatId = _hatId;
}
function isMember(address account) external view returns (bool) {
return hats.isWearerOfHat(hatId, account);
}
}
This keeps our interface dead simple — one function signature, no ABI complexity on the server side. The ecosystem can compose whatever logic they want behind f(address) → bool.
3. HTTP (external API)
Membership checked via HTTP call to an external endpoint.
[group "company"]
resolver = http
url = https://api.example.com/groups/company
The endpoint must implement:
GET <url>/members/:address → { "member": true | false }
GET <url>/members → ["evm:0xAAA...", "evm:0xBBB..."]
- First route: membership check (required)
- Second route: full list (optional, for display/admin)
Trustful: the server trusts the API response. Use for internal systems, indexers, or any source you control.
Recursive Groups
Groups can include other groups. Membership cascades.
[group "frontend-team"]
member = evm:0xAAA...
member = evm:0xBBB...
[group "backend-team"]
member = evm:0xCCC...
[group "core-team"]
include = frontend-team
include = backend-team
member = evm:0xDDD...
isMember("core-team", 0xAAA) → checks direct members first, then recurses into frontend-team and backend-team. Returns true because 0xAAA is in frontend-team.
Composition Rules
- A group can have any combination of: direct
memberentries,includereferences, and aresolver - Membership is the union of all sources (direct members ∪ included groups ∪ resolver)
- Included groups can themselves have resolvers — an onchain group can be included in a static group
listMembers()flattens recursively
Safety
- Cycle detection: mandatory at config parse time. If A includes B and B includes A → config is invalid, server rejects it.
- Max depth: 5 levels. Config with deeper nesting is rejected.
- Resolver timeouts: if an included group's resolver is slow/unreachable, the membership check for that branch fails closed (not a member).
Server-Side API
The server exposes group operations for CLI and web UI:
GET /api/repos/:owner/:repo/groups → list all groups
GET /api/repos/:owner/:repo/groups/:group → group info + members
GET /api/repos/:owner/:repo/groups/:group/check/:addr → { "member": bool }
The CLI uses the check endpoint for pre-flight access verification before pushing.
What Groups Does NOT Cover
- What can a group do? — That's permissions (03-permissions.md)
- Who created this group? — The person who committed the
.repobox/config.ymlchange - Governance / voting — Out of scope. Groups are just membership lists.