Memberships and invitations API
Memberships and invitations are project-scoped. Roles are viewer, auditor, operator, admin (see RBAC for what each holds). There is no owner role; admin is the highest tier and the last-admin protection kicks in on demotion or removal.
Memberships
Section titled “Memberships”List members
Section titled “List members”GET /api/v1/projects/{slug}/membershipsRole: admin (project admin or global admin). Returns:
[ { "id": "...", "user_id": "...", "project_id": "...", "user_email": "alice@example.com", "user_display_name": "Alice", "role": "operator", "created_at": "..." }]Add membership
Section titled “Add membership”POST /api/v1/projects/{slug}/membershipsRole: admin. CSRF-protected. Requires fresh MFA, so an API key is rejected (401); use a browser session. Body adds an existing user to the project (use the invitations flow below to add a user who has not signed up yet):
{"user_id": "...", "role": "operator"}Returns 201 with the new membership row. An unknown or inactive user gives 404 not_found; an existing membership or an unknown role gives 409 conflict.
Change role
Section titled “Change role”PATCH /api/v1/projects/{slug}/memberships/{membership_id}Role: admin. CSRF-protected. Requires fresh MFA, so an API key is rejected (401); use a browser session.
{"role": "admin"}Refuses with 409 conflict if it would leave zero admins on the project; the message says to promote another member to admin first.
Remove membership
Section titled “Remove membership”DELETE /api/v1/projects/{slug}/memberships/{membership_id}Role: admin. CSRF-protected. Requires fresh MFA, so an API key is rejected (401); use a browser session. Removing the membership takes effect on the next project authorization check, so the user's existing global browser sessions can no longer access this project. It does not revoke those sessions or sign the user out of other projects.
Invitations
Section titled “Invitations”The invitations router has two halves: an admin half scoped to the project, and a public half (no auth) for the invitee's accept flow. The admin half is session-cookie only: its tag has no API-key scope mapping, so every API key gets 403, including on the list.
Mint an invitation
Section titled “Mint an invitation”POST /api/v1/projects/{slug}/invitationsRole: admin. CSRF-protected. Requires fresh MFA, so an API key is rejected (401); use a browser session.
{ "email": "alice@example.com", "role": "operator", "ttl_days": 7}ttl_days defaults to 7 and must be 1 to 30 (fixed, not configurable). Inviting an email that already belongs to a member of the project returns 409 conflict. Response (201): invitation (an InvitationPublic row including expires_at), the plaintext token (shown once), accept_url_path (/invite#token=..., a relative path), and email_sent. If the project has an active email notification channel, the invitation link is auto-emailed and email_sent is true; when no email channel exists or the send fails it is false and the admin relays the token out of band.
List pending invitations
Section titled “List pending invitations”GET /api/v1/projects/{slug}/invitationsRole: admin. Returns InvitationPublic rows for all non-accepted, non-revoked, non-expired invitations, newest first.
Revoke a pending invitation
Section titled “Revoke a pending invitation”DELETE /api/v1/projects/{slug}/invitations/{invitation_id}Role: admin. CSRF-protected. Requires fresh MFA, so an API key is rejected (401); use a browser session. An unknown id, or one belonging to another project, returns 404 not_found; an invitation that is no longer pending (accepted, revoked or expired) returns 409 conflict.
Preview an invitation (public)
Section titled “Preview an invitation (public)”POST /api/v1/invitations/previewBody: {"token": "<single-use-token>"}No auth: the token in the JSON body is the only guard, plus the 30/min per-IP invitation bucket shared with accept. Returns email, role, project_slug, project_name and expires_at so the invitee's accept page can render context before they submit a password; the response carries Cache-Control: no-store. An unknown, expired, revoked or already-accepted token returns 404 not_found with the message invalid_or_expired.
Accept an invitation (public)
Section titled “Accept an invitation (public)”POST /api/v1/invitations/acceptNo auth and no CSRF check: the single-use token in the JSON body is the only guard, plus the 30/min per-IP invitation bucket shared with preview.
{ "token": "<single-use-token>", "display_name": "Alice", "password": "min-12-chars"}Password is bounded 12..200 chars, must satisfy the password policy, and is hashed with argon2id. A policy failure here escapes as HTTP 500 internal_error rather than a validation envelope. Success returns 201 with {"user_id", "project_slug", "role"}; if a user with the invited email already exists the call returns 409 conflict. The token is single-use; subsequent uses return 404 with error: "not_found" and the message invalid_or_expired, indistinguishable from an unknown token.