Stores hashed invite tokens with expiry, checks seat limits at accept time, invalidates unused invites on revoke, and binds the granted role at accept rather than encoding it in the URL.
Package status: reference context ready for human review. The contract and test scenarios are complete, but no claim is made that an adopting implementation has passed them.
Decision
Organization invites are random tokens stored only as a hash. The URL carries the raw token once; the database never stores it recoverably. Each invite expires at expires_at and is single-use unless the profile sets a bounded max_accepts, still limited by seats. Seat availability is checked at accept, not only at create. Revocation moves unused tokens to revoked. The granted role is the role on the invite row at accept, never a role= query parameter. See token-states.yaml.
Scope
- Create, accept, expire, revoke, and seat enforcement for organization onboarding.
- Hashing, expiry, and single-use versus bounded multi-use.
- Role binding at accept.
- Join after a successful accept.
Outside this block
- Last-owner leave and ownership transfer.
- SSO group provisioning.
- Plan changes except as the source of
seat_limit.
Contract
- Persist
token_hashonly; lookup hashes the presented token and compares in constant time. - Accept after
expires_ator in staterevokedreturnsinvite_invalidand creates no membership. - Single-use invites enter
acceptedon first success; a second presentation returnsinvite_consumed. - Accept re-reads
seat_limitand active count in one transaction; if no seat remains, returnseat_limit_reachedand leave an unconsumed single-use token issued. - Membership role equals
invite.role_at_accept; a URLrolequery is ignored. - Revoke sets unused invites to
revokedimmediately; existing memberships are unchanged.
Implementation
Generate tokens with a CSPRNG. Log invite id and actor, never the raw token. Rate-limit accept by IP and token hash. Authenticate the acceptor, then apply state and seat checks.
Failure handling
If hash configuration rotates, verify using the algorithm id on the row. Concurrent accepts: one winner, the other seat_limit_reached. Failed email delivery leaves the row issued. Default single-use consumes only when a new membership is written.
Verification
Cover expired, revoked, reused, over-seat, and role-query tampering. Fixture hashes are placeholders, not secrets. Execute the packaged scenarios against the invite API and accept page.
The executable-looking examples in this package are fixtures and acceptance contracts. Run
python tools/validate.py from the collection root to check package structure and metadata; then
implement and execute the scenarios in the target repository.
Adoption assumptions
- Names and numeric values in
example.yamlare an adoption profile, not universal defaults. - The adopting team must map actors, data classes, error vocabulary, and ownership to its system.
References
- Role in the invite URL is a project decision: the granted role is taken from the hashed invite row at accept, not from client-supplied query parameters.