AUTH4D-70 project members and invitations

Project owners and admins share a project with other console developers through the management API on {AUTH_ORIGIN}. The routes are implemented in apps/api/src/modules/members and use the AUTH4D-55 schemas in packages/contracts (ProjectMember*, ProjectInvitation*). Storage is memberships (migration 0004) and project_invitations (migration 0008). Responses are JSON with Cache-Control: no-store. Errors use {"error":{"code","message","request_id"}}.

Authorization

Each project route is checked in this order:

  1. Management token (401). The AUTH4D-17 verifier: a control-plane management token with the route’s scope, a live ControlPlaneSessionAuthority session and a live control-plane user. Customer-realm tokens never authorize these routes.
  2. Path shape (404). Tenant (ten_*), membership (mem_*) and invitation (inv_*) IDs must match their contract formats.
  3. Per-actor budget (429). 120 requests per actor per minute (managementRequestsPerActor).
  4. Live membership (403 forbidden). Unknown projects and other developers’ projects look the same.
  5. Active project for writes (409 project_suspended).
  6. Role rules (403 insufficient_role). Checked per operation. Denied mutations are written to the project audit log with outcome denied.

Roles are the existing project roles: owner, admin and viewer.

Route (under /management/tenants/{tenantId}) Scope Who
GET /members tenant:read any member
PATCH /members/{membershipId} tenant:write owner (see rules)
DELETE /members/{membershipId} tenant:write owner; admin for viewers; anyone for self
GET /invitations tenant:read owner, admin
POST /invitations tenant:write owner (any role); admin (viewer only)
DELETE /invitations/{invitationId} tenant:write owner (any); admin (viewer invitations only)

POST /management/invitations/accept needs tenant:write and any signed-in console user.

The rules:

  • Owners and admins can invite. Only owners can invite admins.
  • Only owners can make or remove admins, and only owners can change an owner’s role. Admins can remove viewers. Any member can remove themselves (leave the project).
  • The owner role can never be granted through these routes (role is admin or viewer).
  • The last owner can never be removed or demoted (409 last_owner).

The role and last-owner checks are repeated inside the D1 batch that applies the change. The target’s current role, the actor’s current role and the owner count are re-evaluated in the same transaction, so concurrent changes cannot leave a project without an owner. A change that lost such a race returns 409 last_owner, 409 membership_changed, or 403 if the actor’s own role changed.

DELETE routes require Content-Type: application/json and the body {}.

Members

GET /members

{
  "members": [
    {
      "membershipId": "mem_0f8c0f0d6a2b4e8c9d0e1f2a3b4c5d6e",
      "userId": "usr_8c1d2e3f4a5b6c7d8e9f0a1b",
      "role": "owner",
      "email": "lead@studio.example",
      "createdAt": "2026-10-05T08:00:00.000Z"
    }
  ]
}

email is the member’s verified console address. The list is ordered by createdAt and holds at most 1000 members.

PATCH /members/{membershipId}

Body: {"role":"admin"} or {"role":"viewer"}. Returns {"member":{...}}. Setting the current role again is a no-op: it writes nothing and adds no audit event. Demoting an owner changes both memberships.role and project_role.

DELETE /members/{membershipId}

Returns {"member":{...}} with the removed membership. The member’s tenant-local anchor users row is deleted with the membership, so it never appears as an end user. Invitations that the member sent and that are still stored with them as invited_by are deleted by the foreign-key cascade, so a removed developer’s pending invitations stop working. Access ends immediately because every management request re-reads the live membership.

Invitations

POST /invitations

Body: CreateProjectInvitationRequestSchema, {"email":"dev@studio.example","role":"viewer"}. The address is trimmed and lowercased.

Result Response
New invitation, email queued 201 {"invitation":{...}}
Same address and role already pending 200 with the same invitation
Same address pending with another role 409 invitation_pending
The address is already a member of this project 409 already_member
A budget is exhausted 429 rate_limited
Email is not configured, or the invitation email could not be queued 503 email_unavailable
An expired, still open invitation for the address exists it is revoked, then 201

The response is identical whether or not the address already has a console account.

The invitation is idempotent by value. Repeating a request while the invitation is pending returns the existing invitation and sends no further email. There is no idempotencyKey field, because the AUTH4D-55 request schema is strict.

Invitation budgets, in addition to the per-actor request budget:

Budget Limit Key
Per inviter 20 per hour control-plane shard, hashed actor id
Per project 50 per day project shard
Per recipient address 5 per day control-plane shard, AUTH_PRIVACY_KEY HMAC of the email
Accept attempts per actor 10 per hour control-plane shard, hashed actor id

The invitation and its success audit event commit in one D1 batch. The insert fails, and the batch rolls back, if the inviter lost the role in the meantime. The email is then queued through the encrypted outbox (below). If queueing fails, the invitation is revoked (audited with reason delivery_failed) and the API returns 503, so a retry starts over.

{
  "invitation": {
    "tenantId": "ten_7f3a9c2e4b6d8f10a1c3e5f7",
    "invitationId": "inv_3b9d2f6a8c0e4a1b9c7d5e3f1a2b4c6d",
    "email": "dev@studio.example",
    "role": "viewer",
    "status": "pending",
    "invitedBy": "usr_8c1d2e3f4a5b6c7d8e9f0a1b",
    "createdAt": "2026-10-05T08:00:00.000Z",
    "expiresAt": "2026-10-12T08:00:00.000Z"
  }
}

GET /invitations

Returns {"invitations":[...]} with every invitation in the project, newest first (at most 1000). status is pending, accepted, revoked or expired, computed from the stored timestamps. Viewers get 403, because pending addresses are only visible to members who can manage them.

DELETE /invitations/{invitationId}

Revokes a pending or expired invitation and returns it with status: "revoked". Revoking an already revoked invitation is a no-op that returns it unchanged. An accepted invitation returns 409 invitation_accepted. An invitation from another project returns 404.

POST /management/invitations/accept

Body: {"token":"<43 base64url characters>"}. The token is only ever in the request body, never in a URL query.

The invitation is accepted only if all of these hold:

  • the token’s SHA-256 hash matches a stored invitation;
  • the invitation is not accepted, revoked or expired, and the project is active;
  • the signed-in console user has a verified email identity equal to the invited address.

The invitation is then consumed, and the tenant-local anchor users row, the membership and the success audit event are written in one D1 batch. Every statement in the batch repeats these conditions, so a replayed or concurrent accept changes nothing.

Success returns 200 AcceptProjectInvitationResponseSchema:

{
  "tenant": {
    "tenantId": "ten_7f3a9c2e4b6d8f10a1c3e5f7",
    "displayName": "Starfall Arena",
    "status": "active",
    "createdAt": "2026-09-01T10:00:00.000Z"
  },
  "membershipId": "mem_0f8c0f0d6a2b4e8c9d0e1f2a3b4c5d6e",
  "role": "viewer"
}

Unknown, malformed, used, revoked and expired tokens, a suspended project, and an email that does not match all return the same 400 invalid_invitation (“The invitation is invalid or has expired.”). Failures that match a stored invitation are audited in its project with outcome denied and a reason (already_accepted, revoked, expired, project_suspended or email_mismatch). A user whose address matches but who is already a member gets 409 already_member.

Invitation email

The email is a control-plane email: the outbox row uses the control-plane tenant and the console client (CONTROL_PLANE_CONSOLE_CLIENT_ID), with template project-invitation. The recipient and the accept link are only in the AES-GCM encrypted payload (AUTH_ENCRYPTION_KEY_RING). The outbox recipient_email column holds redacted, and the queue message carries only ciphertext. The outbox row expires with the invitation.

  • Subject: always auth4.dev project invitation. The project name never appears in the subject.
  • Body: the project name (control and bidi characters removed, at most 80 characters, HTML-escaped), the role, the expiry date and the accept link.
  • Link: {CONSOLE_ORIGIN}/invitations/accept#token=<token>. The token is in the URL fragment, so it never reaches a server log. The console page should read the fragment, remove it from the address bar, and POST the token to the accept route through its backend-for-frontend.

Audit events

Every mutation writes an audit event in the project. Metadata never contains email addresses or tokens.

Event Target Metadata
management.member.invited invitation (or tenant) actor_role, project_role; reason when denied
management.invitation.revoked invitation actor_role, project_role; reason when denied/delivery_failed
management.invitation.accepted invitation project_role; reason when denied
management.member.role_updated membership actor_role, previous_project_role, project_role, self
management.member.removed membership actor_role, previous_project_role, self; reason when denied

Tests

  • pnpm test runs tests/integration/auth4d-70.members.test.ts through the full API composition. It covers each role rule, the last-owner invariant (including concurrent demotions), single-use, revoked and expired tokens, email mismatch, replayed and concurrent accepts, rate limits, account non-disclosure and cross-project isolation.
  • pnpm --filter @auth4/email test runs packages/email/src/invitation-email.test.ts.

Source: docs/api-spec/auth4d-70.md