spec/groups
↓ .md

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 config
  • listMembers(): 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 lives
  • contract — Contract address
  • function — Function name. Must have signature f(address) → bool
  • indexer — (Optional) HTTP endpoint for full member list

Verification flow:

  1. Server encodes function(signerAddress) as calldata
  2. Sends eth_call to the contract on the specified chain via server-side RPC
  3. 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 member entries, include references, and a resolver
  • 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.yml change
  • Governance / voting — Out of scope. Groups are just membership lists.