0030. Team management with single-use invitation capabilities
- Status: Proposed for owner review
- Date: 2026-10-27
- Extends: ADR 0017, ADR 0024, ADR 0026
Observable acceptance criteria
Section titled “Observable acceptance criteria”Workspace administrators list effective members and invite colleagues with an explicit workspace role. Organization owners/admins additionally manage organization members. The inviter copies a private 48-hour link; creating or rotating a link never consults whether its email is registered. A new colleague follows it, supplies the invited email, name and password, then signs in. An existing account must sign in with the matching email before accepting. Expired, revoked, already-used, wrong-email and wrong-session capabilities have one public failure. A reload shows persisted membership and role; non-administrators cannot call administration APIs. Role changes and removal take effect on the next request. Concurrent administrator demotions leave an administrator. Admins filter and page workspace audit entries; everyone can read the enforced role matrix and update their account name/password or revoke other sessions.
Decisions
Section titled “Decisions”The explicit implementation brief substitutes for a separate feature issue. Its base SHA 1ed268f takes precedence over the older shared brief. No schema migration is needed: Better Auth’s existing invitation and verification tables already provide the identity and expiring-capability storage. Migration number 0010 remains unused.
A verification row shares the invitation’s UUID; its identifier is team: plus SHA-256
of a 256-bit injected random token. Its value holds canonical workspace UUID and role,
separated by | (empty fields for organization-only invitations). These fields cannot
contain the separator. This avoids parsing unrelated verification values as JSON.
The invitation row remains the source of email, organization, expiry and lifecycle.
The existing global identity grants/inventory stay unchanged. Tenant memberships,
audit and events still use scoped transactions and their existing composite keys/RLS.
The narrowly named acceptance capability looks up a digest, then sets only the tenant
scope of the matched invitation. It never accepts an organization/workspace from the
anonymous caller. Acceptance rechecks the inviter’s current administrative authority.
All team writes serialize on the organization identity row, and recheck current membership after acquiring that lock. Workspace administrators retain workspace-only RLS context; only actual organization owners/admins receive organization context. A database-trigger regression observes this context during a real membership write. Acceptance locks organization before invitation, rechecks pending status/expiry/email under lock, creates credentials if appropriate, adds memberships, consumes the capability and records audit in one transaction. A transaction advisory lock serializes account creation for the same email across organizations. Password hashing runs only after a valid capability and account-creation need have been established, so unknown-token probes cannot trigger expensive hashing. The injected hasher is local CPU/crypto, never a network operation. Only the accepting session switches active organization; other sessions retain their selected organization. Workspace writes append a thin domain event in that transaction. Organization membership affects access in every workspace: organization-only changes append one event per affected workspace, with a safe scope flag, in the same transaction. No arbitrary workspace stands in for the organization.
An organization owner/admin is an effective administrator in every workspace. Those inherited grants cannot be edited from the workspace list. Organization admins cannot remove/demote an owner; owners can change to admin/member provided another effective organization administrator remains. Granting ownership otherwise than by the transfer of the amendment below is not introduced. Accepting an invitation never overwrites an existing membership or upgrades its role. Removing organization membership cascades its workspace memberships through existing foreign keys. Every request resolves live access; there is no role cookie cache.
The default closed-signup handler blocks Better Auth invitation/member mutation and organization-directory routes (including leave), avoiding UUID-only invitation acceptance and guard bypasses. The explicitly open test/embedding configuration retains the legacy organization plugin behavior. Only the protected first-run operator flow creates organizations in production. Team writes require the configured same-origin browser Origin, in addition to session authorization. API clients must send that origin with their session cookie.
The token lives only in component memory and the invitation URL fragment; it is never sent in a request URL, query key, server action state or log. It is displayed once to the inviter and cleared on scope change/reload; resend replaces it. The acceptance page clears the fragment on success. No screenshots or traces capture a private link. Token possession is the installer’s out-of-band delivery proof for a newly created account. Existing accounts still require password sign-in, so a forwarded link cannot take over credentials. The invitation link remains available to copy.
WORKSPACE_PERMISSIONS is consumed by reservation authorization and /roles; the UI
translates operation/role identifiers from that returned matrix. It documents current
workspace operations rather than promising future rate, finance or country-pack rules.
The audit viewer shows stored actor/subject identifiers, action and localized time,
without enriching entries with personal data. Filtering uses exact identifiers/actions.
Pagination currently uses bounded numeric offsets (50 rows/page, maximum 100,000), an
explicit temporary deviation from ADR 0016’s opaque cursors; concurrent inserts can
shift page boundaries. A stable keyset cursor is a follow-up before large audit exports.
Public and UI contracts
Section titled “Public and UI contracts”API revision 1.6.0 adds team.list, invite, resend, revoke, changeRole, remove,
audit, accept and roles; generated SDK/query factories share those operations.
The handwritten SDK exposes them under client.team, using the per-user transport.
The response TeamResult has explicit member/invitation/audit arrays, privilege flag,
one-time link/expiry, joined workspace and next-page offset; unrelated fields are empty.
These identity-aware routes can accept a valid session with no current workspace.
Factory embeddings may omit the team gateway, in which case its operations deny access;
production always wires it. The permission matrix is public reference data.
Settings routes are /w/:workspace/settings/{users,audit,roles,account}; users and audit
navigation entries require workspace admin. Direct denied reads show a permission state.
The role reference includes property performance reports. Its reports row and the core
report authorization use the same permission matrix: admin, revenue manager and accounting.
/invitations#token supports new-account registration and inline existing-account sign-in.
Missing or malformed invitation fragments have the same unusable-link guidance. Inline
sign-in/sign-out retain the invitation URL; wrong-account users can switch accounts
without copying a token into persistent state. All browser consumers share one Better Auth
client store so inline sign-in/sign-out immediately updates the acceptance form and shell
in the same document; browser cross-tab storage events alone do not provide that guarantee.
Account changes use Better Auth’s existing guarded identity endpoints and do not introduce
a second credential implementation. New screens use shared accessible controls, IT/EN
catalogs, recoverable errors, loading and empty states. Browser drafts are memory-only.
Deliberately outside this change
Section titled “Deliberately outside this change”Password reset, email-address changes, custom roles, workspace creation screens, organization creation/switching screens, API keys (ADR 0040), audit export/retention and tamper-evident audit chains remain future work. Last activity is the latest stored session update, not a promise of per-request activity tracking. Invitation lists are bounded to the 100 most recent entries. Human screen-reader review remains a release task in addition to automated axe/keyboard checks.
Verification
Section titled “Verification”The branch integrates origin/main at 8e2a345 (front-desk review fixes, reports and
Vercel packaging).
VITEST_MAX_WORKERS=1 pnpm quality passes 172 test files and 2,842 tests; eight optional
upstream Channex drift tests remain skipped. PostgreSQL 17 and PgBouncer integration
tests use real processes. One worker avoids concurrent role-bootstrap catalog updates
in the shared local harness. No coverage thresholds, source inclusions or test deadlines
were changed. Global coverage is 97.13% statements, 93.07% branches, 96.06% functions
and 97.96% lines; every package gate passes. Fourteen team integration cases cover
capability lifecycle, concurrent reuse,
live authorization, organization/workspace isolation, last-administrator races, audit
filters/pagination, transaction rollback, and persistent account/session updates.
The production build passes both browser scenarios (front desk and team management),
with no retries: fresh-account acceptance, wrong-account sign-out and inline existing-account
sign-in, front-desk property access with denied admin writes, live role updates, and
account persistence. The team scenario records 17 axe audits with no violations or page
errors and 16 EN/IT screenshots at desktop/mobile widths, reviewed in
/tmp/guestcraft-users/screens. Tables scroll within their containers on narrow screens.
The command palette tests use focused workflows and synthetic paste input; assertions
and normal deadlines remain intact.
The web production build, Docker runtime image build and Docker Compose smoke checks (health, API docs and setup) pass. REUSE and generated OpenAPI/SDK consistency checks pass. Public revision 1.6.0 artifacts are regenerated after the main merge. Temporary credentials and database/bootstrap output remain outside version control and screenshots. These are local verification results; GitHub CI and owner review are separate merge gates.
Amendment 2026-10-31: organization ownership transfer
Section titled “Amendment 2026-10-31: organization ownership transfer”An organization owner transfers the ownership to another organization administrator:
POST /v1/members/{member_id}/ownership_transfer (team.transferOwnership), browser-only with the
instance Origin, If-Match of the target’s organization access ETag and a body
{ confirmation } that must equal the organization’s current name after trimming. Under the
organization lock of every team write, the actor must hold owner (auth.forbidden), the target
must be a member of the organization (team.member_not_found) who holds admin and not owner,
and the name must match (team.invalid, 422); a stale ETag answers team.precondition_failed.
The target’s admin becomes owner and the actor’s owner becomes admin, so both remain
administrators and the last-administrator rule never fires. One organization-level audit entry,
organization.ownership_transferred with { previous_owner, next_owner } as identifiers, and one
domain event per workspace are written in the same transaction. Settings → Users offers the
transfer only to the owner, on administrators who are not owners, in a confirmation dialog that
asks for the organization’s name. Workspaces have no owner of their own: they belong to the
organization (ADR 0017), so this is the only ownership to transfer. Like other organization-level
entries, the transfer’s audit entry has no workspace and does not appear in a workspace’s Settings
→ Activity. Evidence: packages/auth/test/integration/team.integration.test.ts on PostgreSQL
(success, persistence, refusals of a non-owner, a plain member, the owner as target, a wrong name,
a stale ETag and another organization’s member) and the team screen tests.
Since the same date, workspace audit entries carry actor_type (user, system or api_key) and
api_key_id: an action taken with an API key is shown as the key, created by its creator
(ADR 0040).
Amendment 2026-11-01: keyset paging and export of Activity
Section titled “Amendment 2026-11-01: keyset paging and export of Activity”The temporary offset deviation above is superseded. Workspace Activity (team.audit) pages like
organization Activity (ADR 0049): 50 entries per page, ordered by occurred_at then id
descending, with an opaque ADR 0016 cursor that binds the workspace, the exact filters and the
order. The next page starts strictly after the last entry’s (occurred_at, id), with
occurred_at kept to the microsecond, so equal timestamps are neither repeated nor skipped and
an entry written between two requests does not shift later pages. The browser is authenticated
before the cursor is decoded; a foreign, changed-filter or malformed cursor answers
cursor.invalid. The contract stays additive (ADR 0016, API 6.11.0): the keyset cursor travels
as keyset_cursor, answered as next_keyset_cursor with has_more, because the existing
cursor parameter and next_cursor member are numeric offsets that 6.6.0 clients read. Those
two are deprecated and keep their meaning and 100,000 bound; a request with both cursors is
refused (request.invalid). They are removed only in a future major revision. Migration 0062 adds the indexes
(workspace_id, occurred_at desc, id desc) and (organization_id, occurred_at desc, id desc)
that serve the reads and exports of both scopes. Both scopes run one shared query in packages/db/src/auth/activity.ts.
Audit export, listed above as future work, now exists for both scopes, as CSV or JSON
(team.auditExportCsv/Json, organizations.auditExportCsv/Json), with the list’s filters
and authorization. from and until are required and at most 366 days apart. The selection is
read in one statement, so from one snapshot, with a bound of 10,001 rows. The same statement
counts the selection’s bytes before returning any entry: each entry’s data text, its quotes
counted twice for CSV, plus its action, subject and an allowance of 512 bytes for its other
fields, an upper bound of what the file holds. More than 10,000 entries or 20 MiB answer
activity.export_too_large (422, with max_entries and max_bytes) instead of a truncated file.
The bounded entries are then streamed, 200 per chunk, never built as one string. An export is
synchronous and bounded, so it stores no file (no FileStore artifact, expiry or download
count): it is read, recorded and sent in one request. The
export holds exactly the fields the list shows, through csvCell’s formula guard for CSV, and
never the stored IP address. In the transaction of its read, each export records
audit.exported with only its format, count and filters (identifiers, actions and instants),
never the exported entries. Because an export changes no domain state, it publishes no domain
event. Exports read the stored values, as the list does: ADR 0052’s pseudonymization rewrites an
entry’s details in place (pseudonymize_details, marking pseudonymized_at), so both show the
digest from then on, never the original. Tamper-evident chains remain future work. Evidence: packages/server/test/integration/activity.integration.test.ts on
PostgreSQL, the API unit tests in packages/api/src/routes/activity.unit.test.ts and the
Activity screen tests.