Memberships and invitations API
Memberships and invitations are project-scoped. Roles are viewer, operator, admin. 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: viewer. Returns:
[ { "id": "...", "user_id": "...", "project_id": "...", "role": "operator", "created_at": "...", "updated_at": "..." }]Add membership
Section titled “Add membership”POST /api/v1/projects/{slug}/membershipsRole: admin. CSRF-protected. Body adds an existing user to the project (use the invitations flow below to add a user who has not signed up yet).
Change role
Section titled “Change role”PATCH /api/v1/projects/{slug}/memberships/{membership_id}Role: admin. CSRF-protected.
{"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. 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.
Mint an invitation
Section titled “Mint an invitation”POST /api/v1/projects/{slug}/invitationsRole: admin. CSRF-protected.
{ "role": "operator", "ttl_days": 7}ttl_days defaults to 7 and is bounded by the server-side min/max (typically 1..30). Response includes the single-use accept URL and the expiry timestamp. If the project has an active email notification channel, the invitation link is auto-emailed; otherwise the dashboard / API returns the link for out-of-band delivery.
List pending invitations
Section titled “List pending invitations”GET /api/v1/projects/{slug}/invitationsRole: admin. Returns InvitationPublic rows for all non-accepted, non-expired invitations.
Revoke a pending invitation
Section titled “Revoke a pending invitation”DELETE /api/v1/projects/{slug}/invitations/{invitation_id}Role: admin. CSRF-protected.
Preview an invitation (public)
Section titled “Preview an invitation (public)”POST /api/v1/invitations/previewBody: {"token": "<single-use-token>"}No auth. Returns the invited email, role, and project name so the invitee's accept page can render context before they submit a password.
Accept an invitation (public)
Section titled “Accept an invitation (public)”POST /api/v1/invitations/acceptNo auth. CSRF-protected via origin check.
{ "token": "<single-use-token>", "display_name": "Alice", "password": "min-12-chars"}Password is bounded 12..200 chars and is hashed with argon2id (see password policy). The token is single-use; subsequent uses return 404 with error: "not_found" and the message invalid_or_expired, indistinguishable from an unknown token.