Appearance
Invitations HTTP API
This is the reference for the /v1/domains/{id}/invitations HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, outbox event, and the closed Problem.code taxonomy. The surface ships four public operations — CreateInvitation, ListInvitations, GetInvitation, and RevokeInvitation — plus an internal JIT acceptance fork the OIDC sign-in callback consumes to flip a pending Invitation to accepted atomically with the User upsert (no public route — see ../../contexts/identity/idp.md#jit-acceptance-fork). The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded- context narrative covering the lifecycle states (pending → accepted / revoked / expired), the partial-unique pending invariant, and the per-Domain pseudonym contract see ../../contexts/identity/invitations.md.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/domains/{id}/invitations | CreateInvitation | domain:<id>#manage | invitation.create | InvitationCreated | 8 KiB |
| GET | /v1/domains/{id}/invitations | ListInvitations | domain:<id>#read | invitation.list (page-level row carrying item_count + status + authz_errors) | (none) | n/a |
| GET | /v1/domains/{id}/invitations/{invitationId} | GetInvitation | domain:<id>#read | invitation.read | (none) | n/a |
| DELETE | /v1/domains/{id}/invitations/{invitationId} | RevokeInvitation | domain:<id>#manage | invitation.revoke | InvitationRevoked | n/a |
| (internal) | OIDC sign-in callback | AcceptDuringSignIn | (callback already authenticated; no public route) | invitation.accept | InvitationAccepted (carries the denormalised tuple_objects array; the relation-tuples projector consumes the same event and writes one SpiceDB tuple per entry) | n/a |
body_cap = 8 KiBreferences theMaxInvitationRequestBodyBytes-style enforcement applied before the JSON decoder runs; an over-cap body surfaces as413 request_body_too_large.ListInvitations.limitquery parameter is clamped at the handler to[1, 200]with default50.ListInvitations.cursoris opaque, HMAC-signed by the server; a tampered cursor surfaces as400 invalid_cursor.GetInvitationandRevokeInvitationcollapse a cross-Domain match (a row that exists, but in a different Domain than the path Domain) into the same404 invitation_not_foundposture as a truly missing row so neither endpoint can be used as a cross-Domain enumeration oracle.RevokeInvitationis idempotent on the already-revoked terminal state — a second revoke returns204 No Contentwithout invoking the mutator. The other terminal states surface as409 invitation_already_accepted/409 invitation_already_expired.- The internal JIT acceptance fork is consumed by the OIDC sign-in callback — there is no public HTTP route. The aggregate flips
pending → acceptedinside the same transaction as the User upsert, and the outbox row chain (a singleInvitationAcceptedrow carrying the denormalisedtuple_objectsarray) lands atomically with theUserCreated/UserSignedInevents. The downstream relation-tuples projector consumes that same event (no per-tuple outbox row exists) and writes one SpiceDB tuple pertuple_objectsentry.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| All | id (path) | string (uuid) | yes | Domain UUIDv7. Non-zero. Malformed → 400 invalid_domain_id. |
| GetInvitation / RevokeInvitation | invitationId (path) | string (uuid) | yes | Invitation UUIDv7. Non-zero. Malformed → 400 invalid_invitation_id. |
| ListInvitations | status (query) | string | no | Closed set {pending, accepted, revoked, expired, all}, default all. Out-of-set → 400 invalid_status. |
| ListInvitations | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| ListInvitations | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor. |
Schemas
InvitationCreateRequest
Body for POST /v1/domains/{id}/invitations. The handler authorises the call against the parent Domain's manage ReBAC relation BEFORE invoking the service so an unauthorised caller never produces an InvitationCreated outbox row. The plaintext external_subject is intentionally NOT echoed in the response — only the per-Domain pseudonym is — so a server-side log of the response body never carries raw IdP-side PII.
| Field | Type | Required | Notes |
|---|---|---|---|
external_subject | string | yes | minLength=1, maxLength=255. IdP-side subject identifier (typically the OIDC sub claim or operator-known login name). Whitespace-only is rejected by the aggregate. |
ttl_seconds | integer | no | minimum=60, maximum=604800, default=86400. Lifetime in seconds applied to created_at to derive expires_at. Out-of-range → 400 invalid_ttl. |
initial_tuples | array<InitialTuple> | no | maxItems=32. Optional bounded list of relation tuples to write atomically when the invitation is accepted. Exceeding the cap → 422 too_many_initial_tuples. |
InvitationResponse
Hydrated projection of an Invitation aggregate. The shape is shared by CreateInvitation, GetInvitation, and ListInvitations. Plaintext external_subject is intentionally absent — the wire shape only carries the per-Domain pseudonym so a read-only caller never observes raw IdP-side PII. Lifecycle-stamp pointer fields are populated only when the underlying aggregate carries the matching timestamp; encoders elide them under the omitempty contract.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Invitation identifier (UUIDv7). |
domain_id | string (uuid) | yes | Owning Domain identifier (UUIDv7). |
external_subject_pseudonym | string (^[0-9a-f]{64}$) | yes | Per-Domain pseudonym of the IdP-side external_subject (32 bytes, lowercase hex). The plaintext form is never echoed on the invitation read surface. |
status | InvitationStatus | yes | Closed-set lifecycle status. |
expires_at | string (date-time) | yes | Wall-clock timestamp the invitation transitions to expired if no Accept / Revoke fires. Derived from created_at + ttl_seconds. |
created_at | string (date-time) | yes | Aggregate creation timestamp (UTC). |
accepted_at | string (date-time) (nullable) | no | Populated only when status == accepted. |
accepted_user_id | string (uuid) (nullable) | no | Identifier of the User aggregate the OIDC sign-in callback resolved when accepting the invitation. Populated only when status == accepted. |
revoked_at | string (date-time) (nullable) | no | Populated only when status == revoked. |
expired_at | string (date-time) (nullable) | no | Populated only when status == expired. |
initial_tuples | array<InitialTuple> | no | maxItems=32. Bounded list of relation tuples staged on the invitation, returned verbatim from the persistence layer so the operator can preview which tuples will land on Accept. |
InvitationList
Page of invitations returned by GET /v1/domains/{id}/invitations. The window is computed by the persistence layer in (created_at DESC, id DESC) order; per-row visibility is layered on top so the items array is the subset the caller is authorised to see.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<InvitationResponse> | yes | Invitations in the current page. |
next_cursor | string (nullable) | no | Opaque HMAC-signed continuation. Absent or null at end-of-stream. |
InitialTuple
Relation tuple consumed atomically when the Invitation is accepted. The aggregate stores the tuples as a bounded jsonb array on plexsphere.invitations.initial_tuples. The handler validates the prefix BEFORE the aggregate constructor so an out-of-scope object surfaces as 422 invitation_object_out_of_scope rather than a generic invalid_body.
| Field | Type | Required | Notes |
|---|---|---|---|
relation | string | yes | minLength=1. SpiceDB relation the tuple grants on object (e.g. member, viewer). Whitespace-only is rejected by the aggregate. |
object | string | yes | minLength=1. SpiceDB object reference. Allowed prefix set is closed: domain:<this-domain-id> / project:<uuid> / group:<uuid>. Cross-Domain or platform-scoped objects → 422 invitation_object_out_of_scope. |
caveat_context | object | no | Optional CEL caveat context forwarded verbatim to SpiceDB on accept. The aggregate enforces JSON-encodable lossless round-trip — a value that fails the round-trip → 422 invalid_caveat_context. |
InvitationStatus
Closed-set lifecycle status enum. pending is the initial state; accepted, revoked, and expired are absorbing terminal states. The four values mirror the plexsphere.invitations.status CHECK constraint and the aggregate's Status enum.
| Value | Meaning |
|---|---|
pending | Initial state. The row holds the partial-unique pending slot for (domain_id, external_subject) until it transitions to a terminal state. |
accepted | Terminal. Reached via the JIT acceptance fork during OIDC sign-in. Pairs with accepted_at + accepted_user_id. |
revoked | Terminal. Reached via DELETE /v1/domains/{id}/invitations/{invitationId}. Pairs with revoked_at. |
expired | Terminal. Reached via the platform-scheduled expiry sweeper that bulk-stamps elapsed pending rows. Pairs with expired_at. |
Initial-tuple validation
The handler runs three orthogonal checks against the request body's initial_tuples array before the service is invoked. Every rejection surfaces with a precise Problem.code so the operator can branch on the offending invariant.
| Invariant | Cap / rule | Problem code | HTTP |
|---|---|---|---|
| Length | len(initial_tuples) <= 32 | too_many_initial_tuples | 422 |
| Object scope | object MUST start with domain:<this-id> (the path Domain), project:<uuid>, or group:<uuid>; cross-Domain or platform objects are rejected | invitation_object_out_of_scope | 422 |
| Caveat shape | caveat_context MUST round-trip through JSON encode → decode without losing information | invalid_caveat_context | 422 |
The aggregate constructor enforces the same invariants; the handler- side pre-check exists so the wire taxonomy stays bounded — without the pre-check a 33-entry list would surface as a generic invalid_body rather than the precise too_many_initial_tuples.
ReBAC contract
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| CreateInvitation | manage (BEFORE service.Create) | resolved principal | domain:<id> | 403 PermissionDenied + audit row relation=invitation.create, outcome=permission_denied. NO InvitationCreated outbox row. |
| ListInvitations | read (BEFORE persistence read) | resolved principal | domain:<id> | 403 PermissionDenied + audit row relation=invitation.list, outcome=permission_denied. Existence side-channel closed. |
| GetInvitation | read (BEFORE persistence read) | resolved principal | domain:<id> | 403 PermissionDenied + audit row relation=invitation.read, outcome=permission_denied. Existence side-channel closed. |
| RevokeInvitation | manage (BEFORE service.Revoke) | resolved principal | domain:<id> | 403 PermissionDenied + audit row relation=invitation.revoke, outcome=permission_denied. NO InvitationRevoked outbox row. |
| AcceptDuringSignIn (internal) | (no ReBAC call — the OIDC sign-in callback is the trust boundary) | resolved User principal | invitation:<uuid> (in-tx CAS UPDATE) | n/a — the aggregate's terminal-state guard surfaces ErrAlready{Accepted,Expired,Revoked} to the callback. |
The 403 body on this surface is a PermissionDenied schema (NOT a Problem with code: permission_denied) — established by the ReBAC platform contract and reused here verbatim. The richer body carries reason, relation_path, the missing relation, and the correlation_id.
Error taxonomy
The closed Problem.code set this surface emits, exactly as defined in internal/transport/http/v1/invitations/errors.go. The Origin column names the layer (aggregate / repo / service / handler / transport) so a future maintainer knows where to grep when the code changes.
| HTTP status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_domain_id | handler | Path {id} was not a non-zero UUID. |
| 400 | invalid_invitation_id | handler | Path {invitationId} was not a non-zero UUID. |
| 400 | invalid_body | handler / service | Body was not valid JSON, carried unknown fields, was missing external_subject, or the service surfaced ErrEmptyInvitationCreate / aggregate ErrInvariant (non-tuple branch). |
| 400 | invalid_ttl | handler | ttl_seconds outside [60, 604800]. |
| 400 | invalid_status | handler | List status query parameter outside {pending, accepted, revoked, expired, all}. |
| 400 | invalid_limit | handler | List limit query parameter outside [1, 200]. |
| 400 | invalid_cursor | handler | List cursor was tampered or malformed. |
| 401 | unauthenticated | handler | No resolved principal. |
| 403 | (PermissionDenied) | transport | ReBAC denied the operation (separate schema, not Problem). |
| 404 | invitation_not_found | repo / handler | Aggregate not present, OR row exists in a different Domain than the path Domain (cross-Domain match collapses into the same response so neither Get nor Revoke can be used as a cross-Domain enumeration oracle). The repo also surfaces this code on a Create against a missing parent Domain (ErrForeignKeyMissing). |
| 409 | invitation_already_pending | repo (typed *InvitationAlreadyPendingError) | A pending Invitation already exists for this (domain_id, external_subject) pair. The 409 detail carries the existing row's id. |
| 409 | invitation_already_accepted | service / aggregate | Revoke targeted a row already in accepted state. |
| 409 | invitation_already_expired | service / aggregate | Revoke targeted a row already in expired state. |
| 413 | request_body_too_large | handler | Body exceeded the 8 KiB invitation ceiling. |
| 422 | too_many_initial_tuples | handler / aggregate | initial_tuples length exceeded 32. |
| 422 | invitation_object_out_of_scope | handler / aggregate | An initial_tuples[].object is outside the closed prefix set {domain:<this-id>, project:<uuid>, group:<uuid>}. |
| 422 | invalid_caveat_context | handler / aggregate | An initial_tuples[].caveat_context failed JSON-encodable lossless round-trip. |
| 500 | internal | handler | Unexpected error (detail is generic; the underlying error text NEVER leaks to the wire). |
Every Problem detail on this surface carries the (REQ-xxx, PX-0029) trailer so reviewers can grep production logs back to the originating requirement.
Audit & outbox contract
Every successful mutation writes exactly one audit row AND exactly one outbox event in the same transaction. The internal JIT acceptance fork emits a single InvitationAccepted outbox row whose payload carries the denormalised tuple_objects array; the downstream relation-tuples projector reads that array off the event and writes one SpiceDB tuple per entry — there is no per-tuple outbox event. List denials and the per-row visibility filter share a single page-level row carrying item_count + status + authz_errors. The mutator paths (Create, Revoke) ALSO emit through the service-layer sink, so the transport-layer rows are a complementary signal that captures the request-level rejection cases the service never reaches.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| CreateInvitation | success | invitation.create | granted (transport) / success (service) | InvitationCreated |
| CreateInvitation | 403 | invitation.create | permission_denied | (none) |
| CreateInvitation | 4xx invariant (invalid_body, invalid_ttl, too_many_initial_tuples, invitation_object_out_of_scope, invalid_caveat_context) | invitation.create (CaveatContext.fields names the offending field) | invariant_violation | (none) |
| CreateInvitation | 409 already_pending | invitation.create (CaveatContext.fields=["external_subject"]) | conflict | (none) |
| CreateInvitation | 413 body_too_large | invitation.create (CaveatContext.fields=["body"]) | invariant_violation | (none) |
| CreateInvitation | 500 | invitation.create | internal_error | (none) |
| ListInvitations | success | invitation.list (page-level row carrying item_count, status, optional authz_errors) | granted (transport) / success (service) | (none) |
| ListInvitations | 403 | invitation.list | permission_denied | (none) |
| ListInvitations | 4xx invariant (invalid_status, invalid_limit, invalid_cursor) | invitation.list (CaveatContext.fields names the offending field) | invariant_violation | (none) |
| GetInvitation | success | invitation.read (CaveatContext.invitation_id,.domain_id) | granted (transport) / success (service) | (none) |
| GetInvitation | 403 | invitation.read | permission_denied | (none) |
| GetInvitation | 404 (missing or cross-Domain) | invitation.read (CaveatContext.invitation_id, optional.reason=cross_domain) | not_found | (none) |
| GetInvitation | 500 | invitation.read | internal_error | (none) |
| RevokeInvitation | success | invitation.revoke | granted (transport) / success (service) | InvitationRevoked |
| RevokeInvitation | 204 idempotent (already-revoked) | invitation.revoke (CaveatContext.already_revoked=true) | granted | (none) — the mutator never runs on the idempotent path |
| RevokeInvitation | 403 | invitation.revoke | permission_denied | (none) |
| RevokeInvitation | 404 (missing or cross-Domain) | invitation.revoke (CaveatContext.invitation_id, optional.reason=cross_domain) | not_found | (none) |
| RevokeInvitation | 409 already_accepted / already_expired | invitation.revoke | conflict | (none) |
| RevokeInvitation | 500 | invitation.revoke | internal_error | (none) |
| AcceptDuringSignIn (internal) | success | invitation.accept (CaveatContext.invitation_id,.domain_id,.accepted_user_id) | success | InvitationAccepted (single row; payload's tuple_objects array is consumed by the relation-tuples projector to write one SpiceDB tuple per entry — no per-tuple outbox event) |
| AcceptDuringSignIn (internal) | failure (terminal-state guard, callback rollback) | invitation.accept | failure | (none — the transaction rolls back) |
| ExpirePending (sweeper) | success | invitation.expire (single sweep-level row carrying item_count) | success | one InvitationExpired per transitioned row |
The service-layer audit emission uses success / failure for the outcome vocabulary (mirroring the canonical audit.Sink); the transport-layer rows use granted / permission_denied / invariant_violation / conflict / not_found / internal_error so dashboards can pivot on the richer transport vocabulary while the service rows anchor the long-tail forensic trail.
Worked example — happy-path create + sign-in acceptance
The four-step trace below shows a typical end-to-end invitation lifecycle: an operator creates an Invitation, the invitee signs in via OIDC, and the JIT acceptance fork lands the User upsert + the relation-tuple writes in the same transaction as the InvitationAccepted outbox row.
Operator stages the Invitation:
bashcurl -X POST \ -H 'Content-Type: application/json' \ -H 'X-Correlation-Id: 0190a8b8-a0c0-7a0a-8a0a-cccccccccccc' \ -d '{ "external_subject": "ada@example.com", "ttl_seconds": 86400, "initial_tuples": [ { "relation": "member", "object": "project:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa" } ] }' \ https://api.plexsphere.example/v1/domains/0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1/invitationsThe Create transaction lands one outbox row alongside the new pending Invitation aggregate:
InvitationCreated(carryinginvitation_id,domain_id,external_subject_pseudonym,expires_at,initial_tuples).
The invitee signs in via the OIDC IdP. The sign-in callback resolves the IdP
subclaim, runs the JIT User upsert, finds the matching pending Invitation by(domain_id, external_subject), and invokesAcceptDuringSignIn— all inside one pgx transaction.The acceptance transaction lands the following outbox row chain:
UserCreated(orUserSignedIn, depending on whether the User was just upserted).InvitationAccepted(carryinginvitation_id,domain_id,accepted_user_id,accepted_at, and the denormalisedtuple_objectsarray — for the example above the array contains exactly one entry,{relation: "member", object: "project:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa"}).The downstream relation-tuples projector consumes the
InvitationAcceptedevent, readstuple_objectsoff the payload, and writes one SpiceDB tuple per entry (in the example above:relation=member, object=project:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa, subject=user:<accepted_user_id>). There is NO per-tupleRelationTupleEnqueuedoutbox row — the projector's input is the singleInvitationAcceptedevent.Both outbox rows share the same
transaction_id(pg_current_xact_id()) so consumers can order strictly by commit time. A failure at any step rolls the entire transaction back so the row is never left half-accepted.
Cross-references
./identities.md— sibling reference for the/v1/domains/{id}/identitiesHTTP surface (List + Get with auditor reveal) introduced by the same feature.../../contexts/identity/invitations.md— bounded-context narrative for the Invitation aggregate (lifecycle states, partial-unique pending invariant, per-Domain pseudonym contract, JIT acceptance fork, expiry sweeper).../../contexts/identity/idp.md#jit-acceptance-fork— OIDC sign-in callback narrative covering the JIT acceptance fork that consumes pending Invitations atomically with the User upsert.../../how-to/identity/invite-an-operator.md— operator how-to: curl recipes for create / list / get / revoke plus the troubleshooting playbooks for409 invitation_already_pending,409 invitation_already_accepted, and the cross-Domain 404 posture.../../contexts/identity/rebac.md— relation graph behinddomain#manage,domain#read, and the per-InitialTupleSpiceDB writes.../api/index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate.../../../internal/transport/http/v1/invitations/— handler package; closedProblem.codetaxonomy lives inerrors.go; service-port + Pseudonymiser bindings inwiring.go.../../../internal/identity/invitations/services/— application service and the typed sentinels (ErrInvitationAlreadyPending,ErrInvitationAlreadyAccepted,ErrInvitationAlreadyExpired,ErrEmptyInvitationCreate).