{"openapi":"3.0.0","paths":{"/v1/auth/signup":{"post":{"operationId":"AuthenticationController_signup","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"200":{"description":"Token response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponseDto"}}}},"400":{"description":"Validation failed"}},"summary":"Create a new account and sign in","tags":["authentication"]}},"/v1/auth/signin":{"post":{"description":"Every failed attempt answers `401 INVALID_CREDENTIALS`, whatever went wrong: unknown identifier, wrong password, an account with no password credential, an account locked out through a different identifier, an account an operator has blocked, and a wrong password against a suspended or deactivated account. The responses are deliberately identical in body, timing, DB round trips and Redis ops, so signin is not an account-existence oracle. See core/block/CLAUDE.md.","operationId":"AuthenticationController_signin","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInDto"}}}},"responses":{"200":{"description":"Token response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponseDto"}}}},"400":{"description":"Validation failed"},"401":{"description":"Invalid credentials (`INVALID_CREDENTIALS`). Returned for every failed attempt, including a correct password offered while the account is locked out or blocked, and a wrong password against a suspended or deactivated account. The body never says which."},"403":{"description":"The account is suspended (`ACCOUNT_SUSPENDED`) or deactivated (`ACCOUNT_DEACTIVATED`). Only reachable once the caller has proven the password — a wrong password against such an account is a 401."},"429":{"description":"Too many attempts for this identifier (`TOO_MANY_ATTEMPTS`). Two counters produce it, both keyed purely by the submitted identifier: a burst limit of 5 attempts per minute, and a 15-minute lock after 4 failed attempts. Both fire on the same attempt for a registered and an unregistered identifier."}},"summary":"Sign in with email/username and password","tags":["authentication"]}},"/v1/auth/refresh":{"post":{"operationId":"AuthenticationController_refresh","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshDto"}}}},"responses":{"200":{"description":"Token response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponseDto"}}}},"400":{"description":"Validation failed or invalid token"}},"summary":"Refresh access token using refresh token","tags":["authentication"]}},"/v1/auth/logout":{"post":{"operationId":"AuthenticationController_logout","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutDto"}}}},"responses":{"204":{"description":"Logout successful (no content)"},"400":{"description":"Validation failed or invalid token"}},"summary":"Logout by revoking refresh token","tags":["authentication"]}},"/v1/auth/logout-current":{"post":{"operationId":"AuthenticationController_logoutCurrent","parameters":[],"responses":{"204":{"description":"Logout successful (no content)"},"401":{"description":"Missing or invalid access token"}},"security":[{"bearer":[]}],"summary":"Revoke the server-side session tied to the caller’s current access token","tags":["authentication"]}},"/v1/auth/password/request-reset":{"post":{"operationId":"PasswordController_requestPasswordReset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestPasswordResetDto"}}}},"responses":{"204":{"description":"Reset request accepted. Identical whether or not the email matched an account — in status, body, cost, and in what the server is left doing afterwards (see PasswordService.requestPasswordReset). Accepted is not delivered: if the address matched an account the mail was handed to the broker, but a broker outage is logged server-side and still answers 204, so never treat this status as proof an email was sent."}},"summary":"Request a password reset email. Neither the response nor its timing reveals whether the email matched an account.","tags":["authentication"]}},"/v1/auth/password/reset":{"post":{"operationId":"PasswordController_resetPassword","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordDto"}}}},"responses":{"204":{"description":"Password reset; all active sessions revoked."}},"summary":"Reset password using the email-delivered reset token.","tags":["authentication"]}},"/v1/auth/oauth/{provider}/start":{"get":{"operationId":"OAuthController_start","parameters":[{"name":"provider","required":true,"in":"path","description":"OAuth provider id. Providers are gated on env-var configuration; unconfigured providers 400 with UNKNOWN_OAUTH_PROVIDER.","schema":{"enum":["apple","google","github"],"type":"string"}},{"name":"return_to","required":true,"in":"query","schema":{"type":"string"},"description":"Absolute URL the user lands on after a successful signin. Must match the `OAUTH_RETURN_TO_ORIGINS` allowlist (CSV, wildcards supported as `https://*.productcraft.co`)."}],"responses":{"302":{"description":"Redirect to provider authorize URL."},"400":{"description":"Invalid `return_to` or unknown provider."}},"summary":"Begin a federated sign-in flow; 302s to the provider authorize URL.","tags":["OAuth Sign-In"]}},"/v1/auth/oauth/{provider}/callback":{"post":{"operationId":"OAuthController_callback","parameters":[{"name":"provider","required":true,"in":"path","schema":{"enum":["apple","google","github"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallbackBodyDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthCallbackResultDto"}}}},"400":{"description":"Unknown / expired state, provider state mismatch, or unknown provider id."},"401":{"description":"Provider token verification failed, nonce replayed, or downstream identity could not be resolved."}},"summary":"Complete a federated sign-in. auth-ui proxies the provider redirect (query params for Google/GitHub, form_post body for Apple) into this endpoint and uses the returned `tokens` to set the auth_token cookie.","tags":["OAuth Sign-In"]}},"/v1/introspect":{"get":{"operationId":"IntrospectController_introspect","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectResponseDto"}}}}},"summary":"Introspect current user with workspace list","tags":["introspect"]}},"/v1/introspect/workspaces/{workspace_id}":{"get":{"operationId":"IntrospectController_introspectWorkspace","parameters":[{"name":"workspace_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectWorkspaceResponseDto"}}}}},"summary":"Introspect caller's membership + effective policy + enabled services in a workspace","tags":["introspect"]}},"/v1/introspect/api-key":{"post":{"operationId":"IntrospectController_introspectApiKey","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectApiKeyResponseDto"}}}}},"summary":"Resolve a Platform API Key (PAK) to its workspace + IAM-style policy. Downstream services use this with a 60s LRU cache and evaluate the policy via @repo/authz-nestjs evaluatePolicy().","tags":["introspect"]}},"/v1/workspaces":{"post":{"operationId":"WorkspaceController_createWorkspace","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponseDto"}}}},"403":{"description":"Caller is a PAK (`pcft_live_*`). Workspace creation requires a human-issued session."}},"summary":"Create a new workspace. Cookie/JWT only — PAKs are workspace-scoped credentials and cannot create new workspaces.","tags":["workspaces"]},"get":{"operationId":"WorkspaceController_listWorkspaces","parameters":[{"name":"limit","required":true,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorkspacesResponseDto"}}}}},"summary":"List workspaces the current user belongs to","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}":{"get":{"operationId":"WorkspaceController_getWorkspace","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponseDto"}}}}},"summary":"Get workspace details","tags":["workspaces"]},"patch":{"operationId":"WorkspaceController_updateWorkspace","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceDto"}}}},"responses":{"200":{"description":"Workspace updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponseDto"}}}},"400":{"description":"Invalid slug or display name."},"404":{"description":"Workspace not found."},"409":{"description":"Slug already taken by another workspace."}},"summary":"Update workspace display name, slug, or settings. Slug renames broadcast a `workspace.renamed` event so denormalised consumers (waitlist-api) sync.","tags":["workspaces"]},"delete":{"operationId":"WorkspaceController_deleteWorkspace","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Workspace deleted."},"403":{"description":"Caller is a PAK (`pcft_live_*`). Workspace deletion requires a human-issued session."}},"summary":"Delete a workspace (requires workspace.delete). Cookie/JWT only — irreversible workspace destruction needs a human-attributed audit row.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/avatar":{"post":{"operationId":"WorkspaceController_setAvatar","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"PNG, JPEG, or WEBP image, ≤2 MB. Square images look best."}},"required":["file"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceAvatarUrlResponseDto"}}}},"403":{"description":"Caller is not a member of this workspace, or lacks workspace.settings.update."}},"summary":"Upload (or replace) the workspace identity avatar. Gated by `workspace.settings.update` (owner + admin). PNG/JPEG/WEBP only, ≤2 MB, magic-byte sniffed. SVG is rejected (stored-XSS).","tags":["workspaces"]},"delete":{"operationId":"WorkspaceController_removeAvatar","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Avatar cleared."}},"summary":"Clear the workspace avatar and delete the stored object. Gated by `workspace.settings.update`.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/members":{"get":{"operationId":"WorkspaceController_listMembers","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."},{"name":"q","required":false,"in":"query","schema":{"type":"string"},"description":"Case-insensitive substring matched against the member’s display name, username OR primary email. `%` and `_` are matched literally."},{"name":"role","required":false,"in":"query","schema":{"type":"string"},"description":"Exact role name the membership is bound to — `owner`, `admin`, `member`, or a custom role’s name. Composes with `q` and with the cursor."}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorkspaceMembersResponseDto"}}}}},"summary":"List workspace members, oldest join first. Narrow with `?q=` (name / username / email substring) and/or `?role=`. Cursor-paginated.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/members/{account_id}/role":{"patch":{"operationId":"WorkspaceController_updateMemberRole","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"account_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleResponseDto"}}}}},"summary":"Update a member's role. Body: { roleId } or { role: \"owner\"|\"admin\"|\"member\" }.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/members/{account_id}":{"delete":{"operationId":"WorkspaceController_removeMember","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"account_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Member removed."}},"summary":"Remove a member from a workspace (or self-leave)","tags":["workspaces"]}},"/v1/workspaces/invites/accept":{"post":{"operationId":"WorkspaceController_acceptInvite","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptWorkspaceInviteDto"}}}},"responses":{"201":{"description":"The joined workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponseDto"}}}}},"summary":"Accept a workspace invite by code. Cookie/JWT only — invite acceptance binds a human identity to a workspace.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/invites":{"post":{"operationId":"WorkspaceController_createInvite","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInviteDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInviteResponseDto"}}}}},"summary":"Create a workspace invite link. Cookie/JWT only — invites bring new humans into the workspace.","tags":["workspaces"]},"get":{"operationId":"WorkspaceController_listInvites","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorkspaceInvitesResponseDto"}}}}},"summary":"List workspace invites, newest first. Cursor-paginated.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/invites/{invite_id}":{"delete":{"operationId":"WorkspaceController_revokeInvite","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"invite_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Invite revoked."}},"summary":"Revoke a workspace invite. Cookie/JWT only.","tags":["workspaces"]}},"/v1/workspaces/{workspace_slug}/roles":{"get":{"operationId":"WorkspaceRoleController_list","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."}],"responses":{"200":{"description":"Page of roles with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorkspaceRolesResponseDto"}}}},"400":{"description":"Malformed `cursor`."}},"summary":"List roles in a workspace. System roles first, then alphabetical. 20/page (max 100), cursor-paginated.","tags":["workspace-roles"]},"post":{"operationId":"WorkspaceRoleController_create","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponseDto"}}}}},"summary":"Create a new custom role. Cookie/JWT only — role authoring is a human-admin surface.","tags":["workspace-roles"]}},"/v1/workspaces/{workspace_slug}/roles/{role_id}":{"get":{"operationId":"WorkspaceRoleController_get","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponseDto"}}}}},"summary":"Get a role + its permissions","tags":["workspace-roles"]},"patch":{"operationId":"WorkspaceRoleController_update","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponseDto"}}}}},"summary":"Update a role or replace its permission set. Cookie/JWT only — role authoring is a human-admin surface.","tags":["workspace-roles"]},"delete":{"operationId":"WorkspaceRoleController_delete","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Custom role deleted."}},"summary":"Delete a custom role (fails if it has members). Cookie/JWT only — role authoring is a human-admin surface.","tags":["workspace-roles"]}},"/v1/workspaces/{workspace_slug}/policies":{"get":{"operationId":"WorkspacePolicyController_list","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."}],"responses":{"200":{"description":"Page of managed policies with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListManagedPoliciesResponseDto"}}}},"400":{"description":"Malformed `cursor`."}},"summary":"List managed policies in a workspace, alphabetically. 20/page (max 100), cursor-paginated.","tags":["workspace-policies"]},"post":{"operationId":"WorkspacePolicyController_create","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspacePolicyDto"}}}},"responses":{"201":{"description":"Policy created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManagedPolicyResponseDto"}}}},"409":{"description":"Name already in use."}},"summary":"Create a managed policy. Cookie/JWT only — policy authoring is a human-admin surface.","tags":["workspace-policies"]}},"/v1/workspaces/{workspace_slug}/policies/actions/catalog":{"get":{"operationId":"WorkspacePolicyController_catalog","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceActionCatalogResponseDto"}}}}},"summary":"List every known workspace action (catalog of statement actions).","tags":["workspace-policies"]}},"/v1/workspaces/{workspace_slug}/policies/{policy_id}":{"get":{"operationId":"WorkspacePolicyController_get","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"policy_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Policy detail. `binding_count.{pak,role}` reports how many things are currently bound — surface this in delete-confirm UX.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManagedPolicyDetailResponseDto"}}}},"404":{"description":"Policy not found in this workspace."}},"summary":"Get a managed policy by id","tags":["workspace-policies"]},"patch":{"operationId":"WorkspacePolicyController_update","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"policy_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspacePolicyDto"}}}},"responses":{"200":{"description":"Updated policy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManagedPolicyDetailResponseDto"}}}},"400":{"description":"Invalid policy shape, name, or condition block."},"403":{"description":"Caller cannot grant one or more requested actions."},"404":{"description":"Policy not found in this workspace."},"409":{"description":"Name already in use."}},"summary":"Update a managed policy. Cookie/JWT only — policy authoring is a human-admin surface.","tags":["workspace-policies"]},"delete":{"operationId":"WorkspacePolicyController_delete","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"policy_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Policy deleted."},"404":{"description":"Policy not found in this workspace."}},"summary":"Delete a managed policy. Cascades: API keys lose the binding (deny-all if no other bindings remain); roles fall back to their inline policy column. Cookie/JWT only.","tags":["workspace-policies"]}},"/v1/workspaces/{workspace_slug}/services":{"get":{"operationId":"WorkspaceServiceController_list","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorkspaceServicesResponseDto"}}}}},"summary":"List services activated on this workspace. Complete — one row per product, not paginated.","tags":["workspace-services"]}},"/v1/workspaces/{workspace_slug}/services/{service}":{"post":{"operationId":"WorkspaceServiceController_enable","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"service","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnableWorkspaceServiceDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceServiceStateDto"}}}}},"summary":"Enable (or re-enable) a service for this workspace","tags":["workspace-services"]},"delete":{"operationId":"WorkspaceServiceController_disable","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"service","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Service disabled. Settings preserved for re-enable."}},"summary":"Disable a service (settings are retained)","tags":["workspace-services"]}},"/v1/workspaces/{workspace_slug}/services/{service}/settings":{"patch":{"operationId":"WorkspaceServiceController_updateSettings","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"service","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"description":"Replaces the per-service settings jsonb entirely. The body is the settings object itself (no wrapper). Service-specific shape — e.g. mail tracks default sender, social tracks ranking weights.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"example":{"default_sender":"noreply@acme.com"}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceServiceStateDto"}}}}},"summary":"Replace the settings jsonb for an active service","tags":["workspace-services"]}},"/v1/workspaces/{workspace_slug}/api-keys":{"get":{"operationId":"PlatformApiKeyController_list","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100. Sending it (at any value) also opts the response into the `{data, pagination}` envelope."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page. Sending it also opts the response into the `{data, pagination}` envelope."}],"responses":{"200":{"description":"Two shapes, selected by the request — see `oneOf`.\n\n- **`?limit=` or `?cursor=` present** → the standard `{data, pagination}` envelope with `next_cursor` + `has_more`. Use this; it is the only shape that pages.\n- **neither present** → the legacy bare JSON array of every PAK in the workspace (capped at 1000). DEPRECATED as of 2026-09 and retained only so `@productcraft/platform-auth` <= 0.0.12 clients, which cannot send either query param, keep working. It will be removed once those clients are off it; new integrations must send `?limit=`.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ListPlatformApiKeysResponseDto"},{"type":"array","items":{"$ref":"#/components/schemas/PublicApiKeyDto"}}]}}}},"400":{"description":"Malformed `cursor`."}},"security":[{"bearer":[]}],"summary":"List PAKs in this workspace, newest first. Plaintext tokens are NOT returned — only prefixes + metadata. Pass `?limit=`/`?cursor=` to get the standard cursor-paginated `{data, pagination}` envelope (20/page, max 100); callers that pass neither still get the legacy bare JSON array.","tags":["platform-api-keys"]},"post":{"operationId":"PlatformApiKeyController_create","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePakDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintPakResponseDto"}}}},"403":{"description":"Caller is a PAK (`pcft_live_*`). Mint requires a human-issued session."}},"security":[{"bearer":[]}],"summary":"Mint a new PAK. The plaintext `token` is returned ONCE — store it now. The minter can grant at most their own effective permissions. Cookie/JWT only — PAKs cannot mint other PAKs.","tags":["platform-api-keys"]}},"/v1/workspaces/{workspace_slug}/api-keys/{id}/rotate":{"post":{"operationId":"PlatformApiKeyController_rotate","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotatePakDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotatePakResponseDto"}}}},"403":{"description":"Caller is a PAK (`pcft_live_*`), or cannot grant one or more actions in the bindings being carried across."},"404":{"description":"PAK not found in this workspace."},"409":{"description":"The key is revoked (`PAK_REVOKED`), already expired (`PAK_EXPIRED`), or has no policy bindings to carry across."}},"security":[{"bearer":[]}],"summary":"Mint a replacement for an existing PAK, carrying its name, description and policy bindings over. The outgoing key keeps working for `grace_hours` (24 by default) so a fleet can roll over without downtime. The plaintext `token` is returned ONCE. Cookie/JWT only — a PAK cannot rotate a PAK, for the same reason it cannot mint one.","tags":["platform-api-keys"]}},"/v1/workspaces/{workspace_slug}/api-keys/{id}/bindings":{"patch":{"operationId":"PlatformApiKeyController_setBindings","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetBindingsDto"}}}},"responses":{"200":{"description":"Updated PAK with new bindings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiKeyDto"}}}},"400":{"description":"One of: bound > 25 policies; merged > 200 statements; one of the policy ids does not exist or lives in a different workspace."},"403":{"description":"Caller cannot grant one or more actions in the merged statement set."},"404":{"description":"PAK not found in this workspace."},"409":{"description":"Empty bindings — revoke the PAK instead of leaving it unbound."}},"security":[{"bearer":[]}],"summary":"Replace the managed-policy bindings on a PAK. Re-runs caller-narrowing on the merged union of statements.","tags":["platform-api-keys"]}},"/v1/workspaces/{workspace_slug}/api-keys/{id}":{"get":{"operationId":"PlatformApiKeyController_get","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiKeyDto"}}}}},"security":[{"bearer":[]}],"summary":"Get a single PAK by id.","tags":["platform-api-keys"]},"patch":{"operationId":"PlatformApiKeyController_update","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePakDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiKeyDto"}}}}},"security":[{"bearer":[]}],"summary":"Update a PAK’s name or description (does not touch bindings).","tags":["platform-api-keys"]},"delete":{"operationId":"PlatformApiKeyController_revoke","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"force","required":false,"in":"query","description":"When `true`, publish a `pak:revoked` Redis fan-out so subscribed services evict their caches immediately instead of waiting out the 60s TTL.","schema":{"type":"boolean"}}],"responses":{"204":{"description":"PAK revoked."}},"security":[{"bearer":[]}],"summary":"Revoke a PAK. Default behaviour: introspect returns null on the next upstream call; downstream services fail-closed within the 60s cache TTL window. Pass `?force=true` to additionally publish a Redis fan-out that evicts the matching cache entry across every PAK-consuming service immediately — use it when rotating a leaked PAK and the 60s window matters.","tags":["platform-api-keys"]}},"/v1/workspaces/{workspace_slug}/audit-logs":{"get":{"operationId":"WorkspaceAuditController_listAuditLogs","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."},{"name":"action","required":false,"in":"query","schema":{"type":"string"},"description":"Exact-match filter on the action identifier (e.g. `workspace.member.removed`)."},{"name":"actor_id","required":false,"in":"query","schema":{"type":"string","format":"uuid"},"description":"Exact-match filter on the acting account UUID. System rows have a null actor and are excluded by this filter."},{"name":"since","required":false,"in":"query","schema":{"type":"string","format":"date-time"},"description":"Inclusive lower bound on `created_at`. ISO-8601 date (`2026-05-01`, midnight UTC) or date-time (`2026-05-01T09:00:00Z`). Composes with `action` / `actor_id` / `until` and with the cursor."},{"name":"until","required":false,"in":"query","schema":{"type":"string","format":"date-time"},"description":"Inclusive upper bound on `created_at`. Same formats as `since`. Note a bare date resolves to midnight UTC, so `until=2026-05-01` excludes that day’s later entries."}],"responses":{"200":{"description":"Page of audit entries with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorkspaceAuditLogsResponseDto"}}}},"400":{"description":"Malformed `cursor`, `since`, or `until`."}},"summary":"List workspace audit log entries, newest first. Filter by `?action=`, `?actor_id=`, and/or the `?since=` / `?until=` date range. 20/page (max 100), cursor-paginated.","tags":["workspace-audit-logs"]}},"/v1/workspaces/{workspace_slug}/audit-logs/export":{"get":{"description":"The response is streamed: rows are walked in keyset batches and written as they are read, so neither the service nor the client holds the whole log. This is the SOC 2 / GDPR export the platform docs promise. Hard cap of 1,000,000 rows per call; a wider request is refused with 413 before any body is written, so a truncated file can never masquerade as a complete one.","operationId":"WorkspaceAuditController_exportAuditLogs","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"type":"string","enum":["ndjson","csv"]},"description":"Defaults to `ndjson`."},{"name":"action","required":false,"in":"query","schema":{"type":"string"}},{"name":"actor_id","required":false,"in":"query","schema":{"type":"string"}},{"name":"since","required":false,"in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","required":false,"in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Streamed export. `application/x-ndjson` (one JSON object per line) or `text/csv` (RFC 4180, header row first)."},"400":{"description":"Unknown `format`, or malformed `since` / `until`."},"403":{"description":"Caller lacks `workspace.audit.read`."},"404":{"description":"Workspace not found."},"413":{"description":"More than 1,000,000 rows match. Narrow the window with `since` / `until` and retry."}},"summary":"Stream the workspace audit log as NDJSON or CSV, applying the same `?action=` / `?actor_id=` / `?since=` / `?until=` filters as the list. Newest first. Reading the export is itself audited.","tags":["workspace-audit-logs"]}},"/v1/workspaces/{workspace_slug}/audit-feed":{"get":{"description":"Audit timeline for workspace-scoped events — workspace, member, role, invite, service-activation, PAK and OAuth-consent mutations, plus successful sign-ins and completed password resets. Per-app Auth rows are NOT merged in: they live in another database with no cursor that spans both, and they are served by `GET /v1/apps/:appId/audit-logs` (paginated), `.../audit-logs/export` (streamed) and the per-app audit sink. The `sources` field reports that explicitly rather than implying a pending follow-up.","operationId":"WorkspaceAuditFeedController_listFeed","parameters":[{"name":"workspace_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"since","required":false,"in":"query","schema":{"type":"string","format":"date-time"},"description":"ISO-8601 inclusive lower bound on `timestamp`. Items older than `since` are not returned."},{"name":"until","required":false,"in":"query","schema":{"type":"string","format":"date-time"},"description":"ISO-8601 inclusive upper bound on `timestamp`. Composes with `since`, `action`, `actor_id` and with the cursor. A bare date resolves to midnight UTC, so `until=2026-05-01` excludes that day’s later entries."},{"name":"action","required":false,"in":"query","schema":{"type":"string"},"description":"Exact-match filter on the action identifier (e.g. `workspace.member.removed`). A partial string matches nothing."},{"name":"actor_id","required":false,"in":"query","schema":{"type":"string","format":"uuid"},"description":"Exact-match filter on the acting account UUID. Must be a well-formed UUID — anything else is a 400, not an empty page. System rows have a null actor and are excluded by this filter."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous response's `pagination.next_cursor`. Resumes the feed at the same merge boundary."},{"name":"limit","required":false,"in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"description":"Per-page cap. Clamped to 1..500; default 100."},{"name":"sources","required":false,"in":"query","schema":{"type":"string"},"description":"Comma-separated subset of sources to include (e.g. `workspace_audit,heimdall_audit`). Omitted = all enabled sources."}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditFeedResponseDto"}}}},"400":{"description":"Invalid query params — `actor_id` is not a well-formed UUID, or `cursor` is not a cursor this endpoint minted. `since`/`until` that do not parse as ISO-8601 are ignored rather than rejected, and `sources` silently drops unknown names; an empty result set after those filters still returns 200."},"403":{"description":"The caller is signed in but lacks `workspace.audit.read` for this workspace."},"404":{"description":"The `:workspaceSlug` path param does not resolve to a workspace the caller can see."}},"summary":"Unified workspace audit feed (cursor-paginated)","tags":["workspace-audit-logs"]}},"/v1/account":{"delete":{"operationId":"AccountController_deleteAccount","parameters":[],"responses":{"204":{"description":"Account deleted. All sessions revoked, and any linked Sign in with Apple grant is queued for revocation with Apple. The revocation is durable and retried in the background, so a 204 does not depend on Apple being reachable."},"403":{"description":"The session lacks the required action for self-account deletion (defence-in-depth — the route is guarded but the cookie may be present without action access)."},"404":{"description":"No account row exists for the session principal. Should never fire for a valid session — surfaced for SDKs."}},"summary":"Delete the calling account. Auto-promotes the oldest admin (or oldest non-self member) to owner in any workspace where the departing account is the sole owner. Linked provider grants that require server-side revocation (Sign in with Apple) are revoked with the provider.","tags":["account"]}},"/v1/me":{"get":{"operationId":"MeController_getMe","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeProfileDto"}}}},"401":{"description":"No session."},"404":{"description":"No account row for the session principal. Should never fire for a valid session — surfaced for SDKs."}},"security":[{"bearer":[]}],"summary":"Read the caller's account profile.","tags":["me"]}},"/v1/me/profile":{"patch":{"operationId":"MeController_updateProfile","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeProfileDto"}}}},"400":{"description":"Validation failed (e.g. displayName outside 1-128 chars)."},"401":{"description":"No session."},"403":{"description":"The caller authenticated with a non-human principal (e.g. PAK). Profile is per-human; only the human owner can mutate their own row."}},"security":[{"bearer":[]}],"summary":"Update the caller's profile. Currently only `displayName` is editable. An empty body is a no-op that returns the current profile. Cookie/JWT only — this is a human-self-service surface, not a PAK-mutation surface.","tags":["me"]}},"/v1/me/avatar":{"post":{"operationId":"MeController_setAvatar","parameters":[],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"PNG, JPEG, or WEBP image, ≤2 MB."}},"required":["file"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarUrlResponseDto"}}}},"400":{"description":"Invalid file: wrong mimetype, oversized, or content does not match the declared type."},"401":{"description":"No session."}},"security":[{"bearer":[]}],"summary":"Upload (or replace) the caller's avatar. PNG/JPEG/WEBP only — SVG is rejected because user-uploaded SVG from a productcraft.co origin is a stored-XSS vector. ≤2 MB. Magic bytes are sniffed; a mislabeled file is rejected.","tags":["me"]},"delete":{"operationId":"MeController_removeAvatar","parameters":[],"responses":{"204":{"description":"Avatar removed (or already absent)."},"401":{"description":"No session."}},"security":[{"bearer":[]}],"summary":"Clear the caller's avatar and delete the stored object.","tags":["me"]}},"/v1/session":{"get":{"operationId":"SessionController_getSessions","parameters":[],"responses":{"200":{"description":"Active sessions","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionDto"}}}}},"401":{"description":"Unauthorized"}},"security":[{"bearer":[]}],"summary":"List active sessions","tags":["session"]}},"/v1/session/{id}":{"delete":{"operationId":"SessionController_revokeSession","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"`revoked` is false when the session was already revoked, so repeating the call is safe."},"401":{"description":"Unauthorized"},"404":{"description":"No such session, or it belongs to somebody else — deliberately the same response, so this cannot be used to probe which session ids exist."}},"security":[{"bearer":[]}],"summary":"Revoke one of the caller's own sessions. Takes effect immediately — the refresh token stops working straight away, not at the end of its window.","tags":["session"]}},"/v1/verification/verify":{"post":{"operationId":"VerificationController_verify","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyCodeDto"}}}},"responses":{"200":{"description":"Email verified successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackResponseDto"}}}},"400":{"description":"Invalid or expired verification code"}},"summary":"Verify an email code","tags":["verification"]}},"/v1/verification/resend":{"post":{"operationId":"VerificationController_resend","parameters":[],"responses":{"200":{"description":"Verification email sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackResponseDto"}}}},"401":{"description":"Unauthorized"}},"security":[{"bearer":[]}],"summary":"Resend verification email","tags":["verification"]}},"/.well-known/jwks.json":{"get":{"operationId":"JwksController_jwks","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JwksResponseDto"}}}}},"summary":"Public JWKS for verifying platform-issued JWTs (issuer `https://api.auth.productcraft.co`).","tags":["jwks"]}},"/.well-known/oauth-authorization-server":{"get":{"description":"Discovery document for third-party clients (Claude Desktop, Cursor, the ProductCraft MCP server). PKCE with `S256` is mandatory; no other code-challenge method is advertised or accepted. `scopes_supported` lists **presets**, not scopes: authority is the policy document on the grant (`authority_model`).","operationId":"AuthorizationServerMetadataController_metadata","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthorizationServerMetadataDto"}}}}},"summary":"OAuth 2.1 authorization server metadata (RFC 8414)","tags":["oauth-authorization-server"]}},"/v1/oauth/register":{"post":{"description":"Public endpoint, rate-limited per source IP. Every client registered here is marked **unverified** and stays that way; the consent screen shows the badge. `client_secret` and `registration_access_token` are returned once and stored only as hashes.","operationId":"OAuthRegistrationController_register","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientRegistrationRequestDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientRegistrationResponseDto"}}}},"400":{"description":"`invalid_redirect_uri` or `invalid_client_metadata` (RFC 7591 §3.2.2)."},"429":{"description":"Too many registrations from this IP."},"503":{"description":"`temporarily_unavailable` — the rate-limit backend is unreachable, so registration fails closed."}},"summary":"Register an OAuth client dynamically (RFC 7591)","tags":["oauth-authorization-server"]}},"/v1/oauth/register/{client_id}":{"get":{"description":"Requires `Authorization: Bearer <registration_access_token>`. The token addresses exactly one registration; presenting it for a different `client_id` fails.","operationId":"OAuthRegistrationController_read","parameters":[{"name":"client_id","required":true,"in":"path","description":"The `client_id` to read.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMetadataDto"}}}},"401":{"description":"`invalid_client` — bad registration access token."}},"summary":"Read a client registration (RFC 7592)","tags":["oauth-authorization-server"]},"put":{"description":"Requires `Authorization: Bearer <registration_access_token>`. Only display metadata, redirect URIs and the scope ceiling can change — a client cannot promote itself to verified, nor change its authentication method.","operationId":"OAuthRegistrationController_update","parameters":[{"name":"client_id","required":true,"in":"path","description":"The `client_id` to update.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientRegistrationRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMetadataDto"}}}},"401":{"description":"`invalid_client` — bad registration access token."}},"summary":"Update a client registration (RFC 7592)","tags":["oauth-authorization-server"]}},"/v1/oauth/authorize":{"get":{"description":"Requires PKCE with `code_challenge_method=S256`; `plain` and a missing challenge are both rejected. `redirect_uri` must match a registered URI exactly, with only the RFC 8252 §7.3 loopback port relaxation. Responds 302 either to sign-in (no session) or to the consent screen.","operationId":"OAuthAuthorizeController_authorize","parameters":[{"name":"resource","required":false,"in":"query","description":"RFC 8707 resource indicator. Becomes the access token `aud`.","schema":{}},{"name":"state","required":false,"in":"query","schema":{}},{"name":"code_challenge_method","required":true,"in":"query","schema":{"example":"S256"}},{"name":"code_challenge","required":true,"in":"query","schema":{}},{"name":"scope","required":true,"in":"query","description":"Space-delimited preset names (`readonly`, `mail`, `support`, `full`). A preset resolves to a policy template the human narrows on the consent screen; it is never authority in itself.","schema":{"example":"mail"}},{"name":"redirect_uri","required":true,"in":"query","schema":{}},{"name":"client_id","required":true,"in":"query","schema":{}},{"name":"response_type","required":true,"in":"query","schema":{"example":"code"}}],"responses":{"302":{"description":"Redirect to sign-in or to the consent screen."},"400":{"description":"Rendered `{ error, error_description }` when the failure is in `client_id` or `redirect_uri` — those are never bounced to an unvalidated URI."}},"summary":"OAuth 2.1 authorization endpoint","tags":["oauth-authorization-server"]}},"/v1/oauth/consent/{request_id}":{"get":{"description":"Backs the consent screen (task 002). Readable only by the signed-in human the request was created for; anyone else gets the same 404 a nonexistent request gets.","operationId":"OAuthAuthorizeController_consentRequest","parameters":[{"name":"request_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentRequestDto"}}}},"404":{"description":"No such pending request for this human."},"409":{"description":"Request already used."},"410":{"description":"Request expired."}},"summary":"Read a pending consent request","tags":["oauth-authorization-server"]},"post":{"description":"Approving mints a single-use authorization code bound to the client, the redirect URI, the PKCE challenge, the chosen workspace and the approved policy. The policy is narrowed against the human's own authority through the same `assertCallerCanGrant` the workspace-role and PAK lanes use; a grant that exceeds the granter is refused, never trimmed. Denying returns a redirect carrying `error=access_denied`.\n\nCSRF: the session must be presented as an `Authorization: Bearer` header. The ambient `auth_token` cookie is not accepted on its own, and a request whose `Sec-Fetch-Site` / `Origin` says a browser on another origin is driving it is refused — cookie policy cannot be the only CSRF control on the endpoint that mints a code.","operationId":"OAuthAuthorizeController_decide","parameters":[{"name":"request_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentDecisionDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentDecisionResponseDto"}}}},"400":{"description":"Malformed policy, empty policy, or too many statements."},"403":{"description":"Refused before authentication with `CROSS_ORIGIN_REJECTED` when the request carries no bearer or a browser on another origin is driving it. Otherwise: not a member of the chosen workspace, or the approved policy exceeds the human’s own authority — `details.refused_statements` names which statements and why. Also `ACCOUNT_SUSPENDED` / `ACCOUNT_DEACTIVATED` when the account is no longer active, and `OAUTH_GRANT_ADMIN_REVOKED` when a workspace administrator revoked this connection and has not lifted it."},"404":{"description":"No such pending request for this human."},"409":{"description":"Request already used."}},"summary":"Record the human’s consent decision","tags":["oauth-authorization-server"]}},"/v1/oauth/connections":{"get":{"description":"The user-facing half of revocation: which third-party clients hold a grant, in which workspace, carrying which policy.","operationId":"OAuthAuthorizeController_connections","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."}],"responses":{"200":{"description":"Page of connections, newest first, with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectedClientListDto"}}}},"400":{"description":"Malformed `cursor`."}},"summary":"List clients this human has connected","tags":["oauth-authorization-server"]}},"/v1/oauth/connections/{grant_id}":{"delete":{"description":"Revokes the whole grant family — the grant, its authorization codes, its refresh tokens and every live access token — in one transaction.","operationId":"OAuthAuthorizeController_disconnect","parameters":[{"name":"grant_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Disconnected."},"404":{"description":"No such connection for this human."}},"summary":"Disconnect a client","tags":["oauth-authorization-server"]}},"/v1/oauth/workspaces/{workspace_id}/connections":{"get":{"description":"The workspace-side half of revocation, for owners and admins: every grant made against this workspace, by any member, including grants made by people who have since left. Requires `workspace.connection.read`.","operationId":"OAuthAuthorizeController_workspaceConnections","parameters":[{"name":"workspace_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."}],"responses":{"200":{"description":"Page of connections, newest first, with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectedClientListDto"}}}},"400":{"description":"Malformed `cursor`."},"403":{"description":"Not a member, or lacks `workspace.connection.read`."}},"summary":"List every client connected to a workspace","tags":["oauth-authorization-server"]}},"/v1/oauth/workspaces/{workspace_id}/connections/{grant_id}":{"delete":{"description":"Revokes the whole grant family for a grant made by any member of this workspace — the lever a workspace owner needs when an employee leaves and their MCP client is still running. Requires `workspace.connection.revoke`.","operationId":"OAuthAuthorizeController_disconnectWorkspaceConnection","parameters":[{"name":"workspace_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"grant_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Disconnected."},"403":{"description":"Not a member, or lacks `workspace.connection.revoke`."},"404":{"description":"No such connection in this workspace."}},"summary":"Disconnect a client on behalf of the workspace","tags":["oauth-authorization-server"]}},"/v1/oauth/workspaces/{workspace_id}/connections/{grant_id}/restore":{"post":{"description":"A workspace revocation is durable: the member re-running the connect flow does not undo it, so an administrator has to. This clears the block only — every token minted under the old grant stays revoked and the member still has to approve a fresh consent screen. Requires `workspace.connection.revoke`.","operationId":"OAuthAuthorizeController_restoreWorkspaceConnection","parameters":[{"name":"workspace_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"grant_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Revocation lifted."},"403":{"description":"Not a member, or lacks `workspace.connection.revoke`."},"404":{"description":"No such connection in this workspace."},"409":{"description":"The connection was not revoked by an administrator — there is nothing to lift."}},"summary":"Lift a workspace revocation so the member may reconnect","tags":["oauth-authorization-server"]}},"/v1/oauth/token":{"post":{"description":"Accepts `authorization_code` (with a mandatory `code_verifier`) and `refresh_token`. Both credentials are single-use: replaying a code or a refresh token issues nothing and revokes the entire grant family, so anything already minted from that grant stops working immediately.","operationId":"OAuthTokenController_token","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponseDto"}}}},"400":{"description":"`invalid_request`, `invalid_grant`, `invalid_scope`, `invalid_target`, or `unsupported_grant_type`."},"401":{"description":"`invalid_client` — client authentication failed."}},"summary":"OAuth 2.1 token endpoint","tags":["oauth-authorization-server"]}},"/v1/oauth/revoke":{"post":{"description":"Revoking a refresh token takes the whole grant family with it. Always answers 200, including for an unknown token — otherwise the endpoint would be a token-validity oracle.","operationId":"OAuthTokenController_revoke","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevocationRequestDto"}}}},"responses":{"200":{"description":"Revoked, or the token was already unknown."},"401":{"description":"`invalid_client` — client authentication failed."}},"summary":"Revoke a token (RFC 7009)","tags":["oauth-authorization-server"]}},"/v1/oauth/introspect":{"post":{"description":"Client-authenticated, and a client may only introspect its own tokens. `policy` is the ceiling the human approved; `effective_policy` is that intersected with the human's current workspace policy, resolved live on every call with no cache, so a demotion or an offboarding lands on the next call rather than at token expiry.","operationId":"OAuthTokenController_introspect","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectionRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectionResponseDto"}}}},"401":{"description":"`invalid_client` — client authentication failed."}},"summary":"Introspect an access token (RFC 7662)","tags":["oauth-authorization-server"]}},"/v1/oauth/delegated/authorize":{"post":{"description":"Returns the live effective-policy decision for a single `(action, resource)` pair. Evaluated per call against the grant’s policy AND the authorizing human’s current workspace policy — never from a decision cached at consent time. The delegated access token is presented as the bearer.","operationId":"OAuthDelegatedAuthorizationController_authorize","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DelegatedAuthorizationRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DelegatedAuthorizationResponseDto"}}}},"401":{"description":"Missing or malformed `Authorization` header."}},"summary":"Decide one action for a delegated access token","tags":["oauth-authorization-server"]}},"/v1/oauth/delegated/authorize-batch":{"post":{"description":"Evaluates a list of actions against the same live effective policy as `POST /authorize`, in one pass. Exists so an MCP server can filter a several-hundred-tool catalog down to what the session actually permits without a round trip per tool. It is a **listing** aid: a tool invocation is still decided on its own, per call.","operationId":"OAuthDelegatedAuthorizationController_authorizeBatch","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DelegatedAuthorizationBatchRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DelegatedAuthorizationBatchResponseDto"}}}},"400":{"description":"Empty or oversized `actions` list."},"401":{"description":"Missing or malformed `Authorization` header."}},"summary":"Decide many actions for a delegated access token","tags":["oauth-authorization-server"]}},"/v1/oauth/delegated/downstream-assertion":{"post":{"description":"Exchanges a delegated access token for a ~60s assertion audienced at ONE named service, so that service can verify the caller offline without calling back (`docs/mcp.md` §5b). The assertion carries identity — account, workspace, client, grant — and deliberately no policy: the target resolves the delegated effective policy per request through `/v1/introspect`, so a revoked grant or a demoted human is refused on the next call rather than at this assertion’s expiry.","operationId":"OAuthDelegatedAuthorizationController_downstreamAssertion","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownstreamAssertionRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownstreamAssertionResponseDto"}}}},"400":{"description":"The requested `audience` is not a service we issue assertions for."},"401":{"description":"Missing or malformed `Authorization` header, or the token/grant behind it is no longer live."}},"summary":"Mint a short-lived assertion for one downstream service","tags":["oauth-authorization-server"]}},"/v1/oauth/delegated/authorize-assertion":{"post":{"description":"The REST-service counterpart of `POST /authorize`. A service holds only the short-lived assertion, never the delegated access token, so it asks here. Evaluated against the grant’s consented policy AND the authorizing human’s current workspace policy, live and uncached — a revoked grant, a demoted human or an offboarded one is refused on the next call.","operationId":"OAuthDelegatedAuthorizationController_authorizeAssertion","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssertionAuthorizationRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DelegatedAuthorizationResponseDto"}}}},"401":{"description":"Missing or malformed `Authorization` header."}},"summary":"Decide one action for a downstream assertion","tags":["oauth-authorization-server"]}}},"info":{"title":"Platform Auth API","description":"PlatformUser authentication and workspace administration for ProductCraft. Owns sign-in, sign-up, password reset, workspaces, members, invites, workspace roles, workspace-service toggles, and the introspect endpoint every other backend uses to resolve permissions.","version":"1.0.0","contact":{}},"tags":[],"servers":[],"components":{"securitySchemes":{"bearer":{"scheme":"bearer","bearerFormat":"JWT","type":"http"}},"schemas":{"SignUpDto":{"type":"object","properties":{"email":{"type":"string","description":"User email address","example":"user@example.com"},"username":{"type":"string","description":"Unique username (letters, numbers, dot, underscore, hyphen)","example":"john_doe"},"password":{"type":"string","description":"User password (min 8 characters)","example":"MySecurePassword123"},"display_name":{"type":"string","description":"Display name","example":"John Doe"},"session_duration":{"type":"object","description":"Session duration: \"short\" (24h), \"long\" (90d), or integer seconds (3600–7776000). Defaults to 30 days.","example":"long"}},"required":["email","username","password"]},"TokenResponseDto":{"type":"object","properties":{"access_token":{"type":"string","description":"Signed JWT, audience-bound to the resource indicator and carrying the workspace, the preset name and the client id. It carries no policy — authority is resolved per request from the grant row and the human’s live policy."},"token_type":{"type":"string","example":"Bearer"},"expires_in":{"type":"number","description":"Seconds until the access token expires (≤ 900).","example":900},"refresh_token":{"type":"string","description":"Rotating refresh token. The one presented is consumed; replaying it revokes the whole grant family."},"scope":{"type":"string","description":"Preset name(s) the grant started from. Present for RFC 6749 §5.1 compliance; it is not the authority — `policy` is.","example":"mail"},"policy":{"description":"The policy this token carries. Effective authority is this intersected with the authorizing human's current workspace policy, re-evaluated on every call.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"workspace_id":{"type":"string","format":"uuid","description":"Workspace the grant is scoped to."}},"required":["access_token","token_type","expires_in","refresh_token","scope","policy","workspace_id"]},"SignInDto":{"type":"object","properties":{"identifier":{"type":"string","description":"Email or username","example":"user@example.com"},"password":{"type":"string","description":"User password","example":"MySecurePassword123"},"session_duration":{"type":"object","description":"Session duration: \"short\" (24h), \"long\" (90d), or integer seconds (3600–7776000). Defaults to 30 days.","example":"short"}},"required":["identifier","password"]},"RefreshDto":{"type":"object","properties":{"refresh_token":{"type":"string","description":"Refresh token (JWT)","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}},"required":["refresh_token"]},"LogoutDto":{"type":"object","properties":{"refresh_token":{"type":"string","description":"Refresh token (JWT)","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}},"required":["refresh_token"]},"RequestPasswordResetDto":{"type":"object","properties":{"email":{"type":"string","description":"Email address to send reset token","example":"user@example.com"}},"required":["email"]},"ResetPasswordDto":{"type":"object","properties":{"token":{"type":"string","description":"6-digit reset token sent via email","example":"123456"},"new_password":{"type":"string","description":"New password (min 8 characters)","example":"MyNewPassword123"}},"required":["token","new_password"]},"CallbackBodyDto":{"type":"object","properties":{"state":{"type":"string","description":"Opaque state token returned by the IdP, minted at `/start`. Single-use; binds the callback to a specific browser session.","maxLength":512},"id_token":{"type":"string","description":"JWT id_token from the IdP (OIDC providers; Apple form_post). Optional — either `idToken` or `code` must be supplied.","maxLength":8192},"code":{"type":"string","description":"OAuth authorization code returned by the IdP. Optional — either `idToken` or `code` must be supplied.","maxLength":2048},"user":{"type":"string","description":"Apple-only first-signin payload: JSON-encoded `{ name, email }`. Apple returns this only the first time a user signs in. Not signed — treated as untrusted input by the controller's payload parser.","maxLength":4096}},"required":["state"]},"OAuthCallbackResultDto":{"type":"object","properties":{"tokens":{"description":"Issued PlatformUser session tokens. auth-ui sets the auth_token cookie from `accessToken` and 302s the browser to `return_to`.","allOf":[{"$ref":"#/components/schemas/TokenResponseDto"}]},"return_to":{"type":"string","description":"Original `return_to` the user was sent off with. auth-ui redirects here after writing the cookie."}},"required":["tokens","return_to"]},"IntrospectPrincipalDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"sub":{"type":"string","format":"uuid"},"email":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}},"required":["id","sub","email","name"]},"IntrospectAccountDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","nullable":true},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"status":{"type":"string","description":"Account status (e.g. `active`, `suspended`)."},"email_verified_at":{"type":"string","nullable":true,"format":"date-time","description":"Timestamp when this account verified its primary email, or null if unverified."},"avatar_url":{"type":"string","nullable":true,"description":"Public HTTPS URL of the uploaded avatar, or null when no avatar is set."}},"required":["id","email","username","display_name","status","email_verified_at","avatar_url"]},"IntrospectWorkspaceListItemDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"display_name":{"type":"string"},"role":{"type":"string","description":"Caller’s role name in this workspace.","example":"owner"},"services":{"description":"Enabled services on this workspace.","example":["mail","waitlist"],"type":"array","items":{"type":"string"}},"avatar_url":{"type":"string","nullable":true,"description":"Workspace identity avatar (public HTTPS URL), or null when none is set. Console workspace switcher reads this."}},"required":["id","slug","display_name","role","services","avatar_url"]},"IntrospectResponseDto":{"type":"object","properties":{"is_authenticated":{"type":"boolean"},"principal":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/IntrospectPrincipalDto"}]},"account":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/IntrospectAccountDto"}]},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/IntrospectWorkspaceListItemDto"}}},"required":["is_authenticated"]},"IntrospectRoleDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"PolicyStatementDto":{"type":"object","properties":{"effect":{"type":"string","description":"Whether the statement allows or denies the listed actions on the listed resources.","enum":["allow","deny"],"example":"allow"},"actions":{"description":"Action strings the statement applies to. Wildcards (e.g. `social.*`, `*.read`, `*`) supported per `actionMatches` semantics.","example":["social.read","social.list"],"minItems":1,"type":"array","items":{"type":"string"}},"resources":{"description":"Resource URNs the statement applies to. Use `[\"*\"]` for workspace-wide. Per-resource scoping (e.g. `pcft:agora:community/<uuid>`) is supported by the PAK lane today; the workspace-role authoring API restricts this to `[\"*\"]` for now (see `tasks/platform-auth-api/001`).","example":["*"],"minItems":1,"type":"array","items":{"type":"string"}}},"required":["effect","actions","resources"]},"IntrospectWorkspaceResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"Canonical workspace UUID. Only present for members — non-members get a uniform low-information response.","format":"uuid"},"is_member":{"type":"boolean"},"role":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/IntrospectRoleDto"}]},"policy":{"description":"Effective IAM-style policy for this caller in this workspace.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"services":{"description":"Enabled services on this workspace.","example":["mail","waitlist"],"type":"array","items":{"type":"string"}}},"required":["is_member","role","policy","services"]},"IntrospectApiKeyResponseDto":{"type":"object","properties":{"is_valid":{"type":"boolean"},"workspace_id":{"type":"string","format":"uuid"},"policy":{"description":"Merged effective policy across every managed policy bound to this PAK.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"created_by":{"type":"string","nullable":true,"format":"uuid"},"api_key_id":{"type":"string","format":"uuid"}},"required":["is_valid","workspace_id","policy","created_by","api_key_id"]},"CreateWorkspaceDto":{"type":"object","properties":{"slug":{"type":"string","description":"URL-friendly slug. Lowercase alphanumerics, separated by hyphens. Used in URLs and as a stable handle.","example":"acme-corp","minLength":2,"maxLength":64},"display_name":{"type":"string","description":"Display name shown in console and on receipts.","example":"Acme Corp","minLength":1,"maxLength":128}},"required":["slug","display_name"]},"WorkspaceResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","example":"acme-corp"},"display_name":{"type":"string","example":"Acme Corp"},"created_by":{"type":"string","format":"uuid"},"status":{"type":"string","description":"Workspace status (e.g. `active`).","example":"active"},"settings":{"type":"object","description":"Free-form workspace settings blob."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","slug","display_name","created_by","status","settings","created_at","updated_at"]},"WorkspaceListItemDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","example":"acme-corp"},"display_name":{"type":"string","example":"Acme Corp"},"created_by":{"type":"string","format":"uuid"},"status":{"type":"string","description":"Workspace status (e.g. `active`).","example":"active"},"settings":{"type":"object","description":"Free-form workspace settings blob."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"member_role_id":{"type":"string","nullable":true,"format":"uuid","description":"Caller’s workspace_role.id for this workspace."},"member_role":{"type":"string","nullable":true,"description":"Caller’s role name in this workspace.","example":"owner"}},"required":["id","slug","display_name","created_by","status","settings","created_at","updated_at","member_role_id","member_role"]},"PaginationDto":{"type":"object","properties":{"next_cursor":{"type":"string","nullable":true,"example":null},"has_more":{"type":"boolean","example":false}},"required":["next_cursor","has_more"]},"ListWorkspacesResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceListItemDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"UpdateWorkspaceDto":{"type":"object","properties":{"display_name":{"type":"string","minLength":1,"maxLength":128},"slug":{"type":"string","description":"New URL-friendly slug. Same constraints as the create form. Renames are broadcast on the workspace events queue so denormalised consumers (waitlist-api, future product surfaces) can sync. Old URLs continue to resolve for one rename cycle.","minLength":2,"maxLength":64,"example":"acme"},"settings":{"type":"object","description":"Free-form workspace settings blob."}}},"WorkspaceAvatarUrlResponseDto":{"type":"object","properties":{"avatar_url":{"type":"string"}},"required":["avatar_url"]},"WorkspaceMemberResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"account_id":{"type":"string","format":"uuid"},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"primary_email":{"type":"string","nullable":true},"avatar_url":{"type":"string","nullable":true,"description":"Presigned URL for the member's uploaded avatar, or null when they have none. Short-lived — mint a fresh one by re-reading this endpoint rather than caching the URL."},"role_id":{"type":"string","format":"uuid"},"role":{"type":"string"},"role_is_system":{"type":"boolean"},"joined_at":{"type":"string","format":"date-time"}},"required":["id","account_id","username","display_name","primary_email","avatar_url","role_id","role","role_is_system","joined_at"]},"ListWorkspaceMembersResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"UpdateMemberRoleDto":{"type":"object","properties":{"role_id":{"type":"string","description":"UUID of the role to assign. Use this to target custom roles.","example":"11111111-1111-4111-8111-111111111111"},"role":{"type":"string","description":"System role shortcut: 'owner' | 'admin' | 'member'. Either `roleId` or `role` must be supplied.","enum":["owner","admin","member"]}}},"UpdateMemberRoleResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"account_id":{"type":"string","format":"uuid"},"role_id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","nullable":true,"format":"uuid"},"joined_at":{"type":"string","format":"date-time"}},"required":["id","workspace_id","account_id","role_id","invited_by","joined_at"]},"AcceptWorkspaceInviteDto":{"type":"object","properties":{"code":{"type":"string","description":"Invite code from the invite email / link.","example":"inv_…"}},"required":["code"]},"CreateWorkspaceInviteDto":{"type":"object","properties":{"email":{"type":"string","description":"Optional invitee email. When set, the invite is locked to that address.","example":"alice@example.com"},"role_id":{"type":"string","description":"UUID of the role to grant on accept. Use to target custom roles."},"role":{"type":"string","description":"System role shortcut: 'owner' | 'admin' | 'member'.","enum":["owner","admin","member"]},"max_uses":{"type":"number","description":"Maximum number of accepts. Defaults to 1. Use higher values for \"team link\" invites.","example":1,"minimum":1,"maximum":10000},"expires_in_hours":{"type":"number","description":"Hours until the invite expires. Defaults to 168 (7 days).","example":168,"minimum":1,"maximum":8760}}},"WorkspaceInviteResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"code":{"type":"string","description":"Single-use invite code embedded in the invite URL."},"email":{"type":"string","nullable":true,"format":"email"},"role_id":{"type":"string","nullable":true,"format":"uuid"},"max_uses":{"type":"number","nullable":true},"use_count":{"type":"number"},"expires_at":{"type":"string","nullable":true,"format":"date-time"},"revoked_at":{"type":"string","nullable":true,"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid"}},"required":["id","workspace_id","code","email","role_id","max_uses","use_count","expires_at","revoked_at","created_at","created_by"]},"WorkspaceInviteListItemDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"code":{"type":"string","description":"Single-use invite code embedded in the invite URL."},"email":{"type":"string","nullable":true,"format":"email"},"role_id":{"type":"string","nullable":true,"format":"uuid"},"max_uses":{"type":"number","nullable":true},"use_count":{"type":"number"},"expires_at":{"type":"string","nullable":true,"format":"date-time"},"revoked_at":{"type":"string","nullable":true,"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid"},"role_name":{"type":"string","nullable":true}},"required":["id","workspace_id","code","email","role_id","max_uses","use_count","expires_at","revoked_at","created_at","created_by","role_name"]},"ListWorkspaceInvitesResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInviteListItemDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"WorkspaceRoleResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Role name. System role names are `owner`/`admin`/`member`."},"description":{"type":"string","nullable":true},"is_system":{"type":"boolean","description":"True for built-in system roles (owner/admin/member); false for custom roles."},"policy":{"description":"Effective IAM-style policy. When `policy_id` is set this is the bound managed policy; otherwise it is the inline policy column.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"policy_id":{"type":"string","nullable":true,"format":"uuid","description":"Managed policy ID if the role is bound to one, else null."}},"required":["id","name","description","is_system","policy","policy_id"]},"ListWorkspaceRolesResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"CreateWorkspaceRoleDto":{"type":"object","properties":{"name":{"type":"string","description":"Role name. Free-form; not URL-significant.","example":"BillingAdmin","minLength":1,"maxLength":64},"description":{"type":"string","description":"Optional human-readable role description.","maxLength":256},"policy":{"description":"IAM-style policy granting the role its permissions. Same wire shape as PAK policies. Omit or pass `[]` for a no-permissions role.","example":[{"effect":"allow","actions":["mail.*.read"],"resources":["*"]}],"type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"policy_id":{"type":"string","description":"Optional managed-policy id to bind the role to. When set, the role's effective policy comes from the bound managed policy and `policy` (inline) must be omitted.","nullable":true,"example":"11111111-1111-1111-1111-111111111111"}},"required":["name"]},"UpdateWorkspaceRoleDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"type":"string","maxLength":256},"policy":{"description":"Replaces the role policy if provided. Omit to leave policy unchanged.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"policy_id":{"type":"string","description":"Bind / unbind the role to a managed policy. UUID → bind, null → unbind, omit → leave unchanged.","nullable":true}}},"ManagedPolicyResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"mail-readonly"},"description":{"type":"string","nullable":true},"policy":{"description":"IAM-style policy statements bound to this managed policy.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"created_by":{"type":"string","nullable":true,"format":"uuid","description":"Account that authored the row. Only revealed to callers with `workspace.audit.read`; plain members see null."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","workspace_id","name","description","policy","created_by","created_at","updated_at"]},"ListManagedPoliciesResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ManagedPolicyResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"WorkspaceActionCatalogResponseDto":{"type":"object","properties":{"actions":{"description":"Every known workspace action across all enabled service catalogs.","type":"array","items":{"type":"string"}}},"required":["actions"]},"PolicyBindingCountDto":{"type":"object","properties":{"pak":{"type":"number","description":"Number of platform API keys bound to this policy."},"role":{"type":"number","description":"Number of workspace roles bound to this policy."}},"required":["pak","role"]},"ManagedPolicyDetailResponseDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"mail-readonly"},"description":{"type":"string","nullable":true},"policy":{"description":"IAM-style policy statements bound to this managed policy.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"created_by":{"type":"string","nullable":true,"format":"uuid","description":"Account that authored the row. Only revealed to callers with `workspace.audit.read`; plain members see null."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"binding_count":{"description":"Counts of currently-bound consumers — surface this in delete-confirm UX so the operator sees cascade impact.","allOf":[{"$ref":"#/components/schemas/PolicyBindingCountDto"}]}},"required":["id","workspace_id","name","description","policy","created_by","created_at","updated_at","binding_count"]},"CreateWorkspacePolicyDto":{"type":"object","properties":{"name":{"type":"string","description":"Policy name (1-64 chars, alphanumeric + space/underscore/hyphen). Unique per workspace.","example":"mail-readonly","minLength":1,"maxLength":64},"description":{"type":"string","description":"Optional human description. Pass `null` to clear an existing description on PATCH.","nullable":true,"example":"Read-only access to all Mail resources"},"policy":{"description":"IAM-style policy. Empty array means \"no permissions\" — useful as a placeholder before granting any actions.","example":[{"effect":"allow","actions":["mail.read","mail.list"],"resources":["*"]}],"type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}}},"required":["name","policy"]},"UpdateWorkspacePolicyDto":{"type":"object","properties":{"name":{"type":"string","description":"Policy name (1-64 chars, alphanumeric + space/underscore/hyphen). Unique per workspace.","example":"mail-readonly","minLength":1,"maxLength":64},"description":{"type":"string","description":"Optional human description. Pass `null` to clear an existing description on PATCH.","nullable":true,"example":"Read-only access to all Mail resources"},"policy":{"description":"IAM-style policy. Empty array means \"no permissions\" — useful as a placeholder before granting any actions.","example":[{"effect":"allow","actions":["mail.read","mail.list"],"resources":["*"]}],"type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}}}},"WorkspaceServiceStateDto":{"type":"object","properties":{"workspace_id":{"type":"string","format":"uuid"},"service":{"type":"string","description":"Service identifier (e.g. `mail`, `waitlist`, `social`, `auth`).","example":"mail"},"enabled":{"type":"boolean"},"enabled_at":{"type":"string","nullable":true,"format":"date-time"},"enabled_by":{"type":"string","nullable":true,"format":"uuid"},"disabled_at":{"type":"string","nullable":true,"format":"date-time"},"disabled_by":{"type":"string","nullable":true,"format":"uuid"},"settings":{"type":"object","description":"Per-service settings blob (service-specific shape)."}},"required":["workspace_id","service","enabled","enabled_at","enabled_by","disabled_at","disabled_by","settings"]},"ListWorkspaceServicesResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceServiceStateDto"}}},"required":["data"]},"EnableWorkspaceServiceDto":{"type":"object","properties":{"settings":{"type":"object","description":"Optional per-service settings blob captured at enable time."}}},"BoundPolicySummaryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"mail-readonly"},"description":{"type":"string","nullable":true}},"required":["id","name","description"]},"PublicApiKeyDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"created_by":{"type":"string","nullable":true,"format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"token_prefix":{"type":"string","description":"The first ~14 chars of the token (prefix). Plaintext token is never returned after mint.","example":"pcft_live_abc123"},"policies":{"description":"Managed policies currently bound to this PAK.","type":"array","items":{"$ref":"#/components/schemas/BoundPolicySummaryDto"}},"last_used_at":{"type":"string","nullable":true,"format":"date-time"},"revoked_at":{"type":"string","nullable":true,"format":"date-time"},"expires_at":{"type":"string","nullable":true,"format":"date-time","description":"When this key stops introspecting. `null` means it never expires."},"rotated_from":{"type":"string","nullable":true,"format":"uuid","description":"Id of the key this one replaced, when it was minted by a rotation."},"status":{"type":"string","enum":["active","expired","revoked"],"description":"Lifecycle state at the time of the request. Only `active` keys introspect. `revoked` beats `expired` — revocation is a deliberate act by a named actor and the row keeps saying so."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","workspace_id","created_by","name","description","token_prefix","policies","last_used_at","revoked_at","expires_at","rotated_from","status","created_at","updated_at"]},"ListPlatformApiKeysResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicApiKeyDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"CreatePakDto":{"type":"object","properties":{"name":{"type":"string","description":"Human-readable PAK name (shown in the keys list).","example":"Production social client","minLength":1,"maxLength":120},"description":{"type":"string","description":"Optional description (e.g. which service uses the key).","maxLength":500},"policy_ids":{"description":"IDs of managed policies (workspace_policy rows) to bind to this PAK. Bind one or more — the PAK's effective policy is the union of all bound statements.","example":["11111111-1111-1111-1111-111111111111"],"type":"array","items":{"type":"string"}},"expires_in_days":{"type":"number","description":"Lifetime of the key in days. Omit or send `null` for a key that never expires — the historical behaviour, what every PAK minted before this field existed holds, and still the default. Enforced at introspection: past `expires_at` the key resolves to 401 exactly like a revoked one.","enum":[30,90,365],"nullable":true,"example":90}},"required":["name","policy_ids"]},"MintPakResponseDto":{"type":"object","properties":{"token":{"type":"string","description":"The full plaintext PAK token. Returned ONCE — store it now; it cannot be retrieved later.","example":"pcft_live_abc123XYZ..."},"record":{"$ref":"#/components/schemas/PublicApiKeyDto"}},"required":["token","record"]},"RotatePakDto":{"type":"object","properties":{"grace_hours":{"type":"number","description":"How long the OUTGOING key keeps working, in hours. `0` kills it immediately. Capped at 168 (7 days) — rotation answers \"this key may be compromised\", so an unbounded overlap would defeat the point. Rotation can only ever shorten a key: if the old one was already due to expire sooner, that earlier instant stands.","minimum":0,"maximum":168,"default":24,"example":24}}},"RotatePakResponseDto":{"type":"object","properties":{"token":{"type":"string","description":"The full plaintext PAK token. Returned ONCE — store it now; it cannot be retrieved later.","example":"pcft_live_abc123XYZ..."},"record":{"$ref":"#/components/schemas/PublicApiKeyDto"},"rotated_from":{"type":"string","format":"uuid","description":"Id of the key this one replaces."},"previous_key_expires_at":{"type":"string","format":"date-time","description":"When the replaced key stops introspecting. Until then BOTH keys work, so a fleet can roll over without downtime. Downstream services cache introspection for 60s, so treat this instant as \"+ up to a minute\" when planning a cutover."}},"required":["token","record","rotated_from","previous_key_expires_at"]},"SetBindingsDto":{"type":"object","properties":{"policy_ids":{"description":"Replacement set of managed-policy IDs. Empty array is rejected — revoke the PAK to disable it.","type":"array","items":{"type":"string"}}},"required":["policy_ids"]},"UpdatePakDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","nullable":true,"maxLength":500}}},"WorkspaceAuditLogEntryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"},"actor_id":{"type":"string","nullable":true,"description":"Account UUID of the actor, or null for system actions."},"actor_type":{"type":"string","description":"Actor classification.","enum":["platform_user","system","pat"],"example":"platform_user"},"action":{"type":"string","description":"Action identifier (e.g. `workspace.member.removed`).","example":"workspace.member.removed"},"resource":{"type":"string","description":"Resource type the action targeted.","example":"workspace_membership"},"resource_id":{"type":"string","nullable":true,"format":"uuid"},"ip":{"type":"string","nullable":true,"description":"Originating request IP, when available."},"metadata":{"type":"object","description":"Free-form metadata attached to the audit row (jsonb)."},"created_at":{"type":"string","format":"date-time"}},"required":["id","workspace_id","actor_id","actor_type","action","resource","resource_id","ip","metadata","created_at"]},"ListWorkspaceAuditLogsResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceAuditLogEntryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"FeedItemDto":{"type":"object","properties":{"source":{"type":"string","description":"Which audit table this row came from.","enum":["workspace_audit","heimdall_audit","agora_moderation"],"example":"workspace_audit"},"timestamp":{"type":"string","format":"date-time"},"actor_id":{"type":"string","nullable":true,"format":"uuid"},"actor_type":{"type":"string","description":"Actor classification — one of 'platform_user' | 'end_user' | 'm2m' | 'system' | 'pat' | 'api_key'.","example":"platform_user"},"action":{"type":"string","example":"workspace.member.removed"},"resource":{"type":"string","example":"workspace_membership"},"resource_id":{"type":"string","nullable":true,"format":"uuid"},"metadata":{"type":"object","description":"Free-form metadata (jsonb)."},"source_ref":{"type":"string","description":"Stable per-source identifier. Used by the cursor to resume from the same point on the next page.","example":"4e5f5c9e-b4ee-4401-9275-283feb66c178"}},"required":["source","timestamp","actor_id","actor_type","action","resource","resource_id","metadata","source_ref"]},"SourceDiagnosticDto":{"type":"object","properties":{"source":{"type":"string","enum":["workspace_audit","heimdall_audit","agora_moderation"]},"included":{"type":"boolean"},"count":{"type":"number"},"error":{"type":"string"}},"required":["source","included","count"]},"AuditFeedResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FeedItemDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"},"sources":{"description":"Per-source diagnostics so the UI / customer can see which sources contributed to this page and which are stubbed pending the cross-service fanout.","type":"array","items":{"$ref":"#/components/schemas/SourceDiagnosticDto"}}},"required":["data","pagination","sources"]},"MeProfileDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","nullable":true},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"status":{"type":"string","description":"Account status (e.g. `active`, `suspended`)."},"email_verified_at":{"type":"string","nullable":true,"format":"date-time","description":"Timestamp when this account verified its primary email, or null if unverified."},"avatar_url":{"type":"string","nullable":true,"description":"Short-lived (15 min) presigned GET URL for the avatar object in private object storage. NULL when no avatar has been set; the frontend then renders initials. The API mints a fresh URL on every response, so a stable bookmark of this value is not meaningful."}},"required":["id","email","username","display_name","status","email_verified_at","avatar_url"]},"UpdateProfileDto":{"type":"object","properties":{"display_name":{"type":"string","description":"New display name. 1-128 chars. Optional — empty PATCH is a no-op.","minLength":1,"maxLength":128}}},"AvatarUrlResponseDto":{"type":"object","properties":{"avatar_url":{"type":"string"}},"required":["avatar_url"]},"SessionDto":{"type":"object","properties":{"id":{"type":"string","description":"Session identifier","example":"sess_01HZY2G3A7M8Q9W4E6R2K1"},"account_id":{"type":"string","description":"Owning account identifier","example":"acc_01HZY2F2B6N7M8Q9W4E6R2"},"ip":{"type":"string","description":"Client IP address","nullable":true,"example":"203.0.113.42"},"user_agent":{"type":"string","description":"HTTP User-Agent string","nullable":true,"example":"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"},"created_at":{"type":"string","description":"Creation timestamp (ISO 8601)","format":"date-time","example":"2025-09-20T12:34:56.000Z"},"expires_at":{"type":"string","description":"Expiration timestamp (ISO 8601)","format":"date-time","example":"2025-09-21T12:34:56.000Z"},"last_used_at":{"type":"string","description":"Last time this session was used (ISO 8601) or null","nullable":true,"format":"date-time","example":"2025-09-20T18:00:00.000Z"}},"required":["id","account_id","ip","user_agent","created_at","expires_at","last_used_at"]},"VerifyCodeDto":{"type":"object","properties":{"code":{"type":"string","description":"Verification code from the email link.","example":"a1b2c3d4…"}},"required":["code"]},"FeedbackResponseDto":{"type":"object","properties":{"message":{"type":"string","description":"Human-readable message","example":"Email verified successfully"}},"required":["message"]},"JwkDto":{"type":"object","properties":{"kty":{"type":"string","description":"Key type (e.g. `RSA`).","example":"RSA"},"alg":{"type":"string","description":"Signing algorithm (e.g. `RS256`).","example":"RS256"},"kid":{"type":"string","description":"Key ID for matching the kid header on a JWT.","example":"k_2026_05_01"},"use":{"type":"string","description":"Key usage. Always `sig` for this endpoint.","example":"sig"}},"required":["kty","alg","kid","use"]},"JwksResponseDto":{"type":"object","properties":{"keys":{"description":"Array of public JWKs. Token verifiers iterate these by `kid` to find the matching signer.","type":"array","items":{"$ref":"#/components/schemas/JwkDto"}}},"required":["keys"]},"AuthorizationServerMetadataDto":{"type":"object","properties":{"issuer":{"type":"string","description":"Issuer identifier. Matches the `iss` claim on every access token this server mints.","example":"https://api.platform-auth.productcraft.co"},"authorization_endpoint":{"type":"string","description":"RFC 6749 authorization endpoint."},"token_endpoint":{"type":"string","description":"RFC 6749 token endpoint."},"registration_endpoint":{"type":"string","description":"RFC 7591 dynamic client registration endpoint."},"revocation_endpoint":{"type":"string","description":"RFC 7009 token revocation endpoint."},"introspection_endpoint":{"type":"string","description":"RFC 7662 token introspection endpoint."},"jwks_uri":{"type":"string","description":"JWKS the access-token signature verifies against."},"response_types_supported":{"description":"Only `code` — the implicit and password grants do not exist here.","example":["code"],"type":"array","items":{"type":"string"}},"grant_types_supported":{"example":["authorization_code","refresh_token"],"type":"array","items":{"type":"string"}},"code_challenge_methods_supported":{"description":"S256 only. `plain` is rejected, and an authorization request without a challenge is rejected.","example":["S256"],"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"example":["none","client_secret_basic","client_secret_post"],"type":"array","items":{"type":"string"}},"scopes_supported":{"description":"Preset names accepted in the `scope` parameter. A preset is a policy *template*, never authority in itself — see `authority_model`. Ordinary OAuth scopes do not exist on this server.","example":["readonly","mail","support","full"],"type":"array","items":{"type":"string"}},"scope_descriptions":{"type":"object","additionalProperties":{"type":"string"},"description":"Plain-language description per preset. Non-standard, published so a client can render the same wording the consent screen uses."},"authority_model":{"type":"string","description":"Non-standard. Declares that authority on this server is an IAM policy document stored on the grant, intersected per request with the authorizing human’s current workspace policy — so a demotion or an offboarding takes effect on the next call rather than at token expiry. The `scope` parameter only names a preset the human then narrows.","example":"policy"},"resource_indicators_supported":{"description":"RFC 8707 resource indicators this server will mint an audience for.","type":"array","items":{"type":"string"}},"service_documentation":{"type":"string","description":"Human-readable documentation for this authorization server."}},"required":["issuer","authorization_endpoint","token_endpoint","registration_endpoint","revocation_endpoint","introspection_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","token_endpoint_auth_methods_supported","scopes_supported","scope_descriptions","authority_model","resource_indicators_supported","service_documentation"]},"ClientRegistrationRequestDto":{"type":"object","properties":{"redirect_uris":{"description":"Exact redirect URIs. Matched byte-for-byte at `/authorize`; the only relaxation is the RFC 8252 §7.3 loopback port. `https` anywhere, `http` on `127.0.0.1` / `[::1]` only, or a reverse-DNS private-use scheme. No wildcards, no fragments.","example":["http://127.0.0.1:33418/callback"],"type":"array","items":{"type":"string"}},"client_name":{"type":"string","description":"Display name shown on the consent screen next to an \"unverified\" badge. Control characters are stripped; the client cannot supply any other consent-screen copy.","maxLength":120,"example":"Claude Desktop"},"grant_types":{"description":"Must include `authorization_code`. `refresh_token` is the only other supported value.","example":["authorization_code","refresh_token"],"type":"array","items":{"type":"string"}},"response_types":{"description":"Only `code` is supported.","example":["code"],"type":"array","items":{"type":"string"}},"token_endpoint_auth_method":{"type":"string","description":"`none` for a public client (PKCE only), or `client_secret_basic` / `client_secret_post` for a confidential client. Defaults to `none`.","example":"none"},"scope":{"type":"string","description":"Space-delimited ceiling of scopes this client may ever request. Defaults to the full published scope list; the human still approves each one.","example":"workspace:read mail:read mail:send"},"client_uri":{"type":"string","nullable":true,"description":"Absolute https homepage for the client."},"logo_uri":{"type":"string","nullable":true,"description":"Absolute https logo URL. Stored but deliberately never rendered on the consent screen — a client-supplied image is client-supplied display text by another name."}},"required":["redirect_uris","client_name"]},"ClientRegistrationResponseDto":{"type":"object","properties":{"client_id":{"type":"string","example":"pcft_cli_2f5c..."},"client_id_issued_at":{"type":"number","description":"Unix seconds at which the client_id was issued."},"client_secret_expires_at":{"type":"number","description":"0 — the client secret does not expire on its own.","example":0},"registration_client_uri":{"type":"string","description":"Endpoint the registration access token is used against."},"redirect_uris":{"type":"array","items":{"type":"string"}},"client_name":{"type":"string"},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_method":{"type":"string"},"scope":{"type":"string"},"is_verified":{"type":"boolean","description":"Always false for a dynamically registered client. The consent screen shows this to the human.","example":false},"client_secret":{"type":"string","nullable":true,"description":"Returned exactly once, and only for confidential clients. Stored hashed; it cannot be retrieved again."},"registration_access_token":{"type":"string","description":"Bearer token that reads and updates THIS registration and nothing else. Returned once."}},"required":["client_id","client_id_issued_at","client_secret_expires_at","registration_client_uri","redirect_uris","client_name","grant_types","response_types","token_endpoint_auth_method","scope","is_verified","client_secret","registration_access_token"]},"ClientMetadataDto":{"type":"object","properties":{"client_id":{"type":"string","example":"pcft_cli_2f5c..."},"client_id_issued_at":{"type":"number","description":"Unix seconds at which the client_id was issued."},"client_secret_expires_at":{"type":"number","description":"0 — the client secret does not expire on its own.","example":0},"registration_client_uri":{"type":"string","description":"Endpoint the registration access token is used against."},"redirect_uris":{"type":"array","items":{"type":"string"}},"client_name":{"type":"string"},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_method":{"type":"string"},"scope":{"type":"string"},"is_verified":{"type":"boolean","description":"Always false for a dynamically registered client. The consent screen shows this to the human.","example":false}},"required":["client_id","client_id_issued_at","client_secret_expires_at","registration_client_uri","redirect_uris","client_name","grant_types","response_types","token_endpoint_auth_method","scope","is_verified"]},"ConsentPresetDto":{"type":"object","properties":{"preset":{"type":"string","description":"Preset name the client asked for. A preset is a policy *template*, never authority in itself.","example":"mail","enum":["readonly","mail","support","full"]},"description":{"type":"string","description":"Server-authored plain-language wording. The client cannot influence it.","example":"Read and send email from your verified domains."}},"required":["preset","description"]},"ConsentWorkspaceDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"display_name":{"type":"string"},"role":{"type":"string","description":"Caller's role in this workspace.","example":"owner"}},"required":["id","slug","display_name","role"]},"ConsentRequestDto":{"type":"object","properties":{"request_id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"client_name":{"type":"string","description":"Display name the client registered with."},"client_uri":{"type":"string","nullable":true},"is_verified":{"type":"boolean","description":"False for every dynamically registered client. The screen must show this prominently."},"presets":{"description":"Presets the client requested. The consent screen renders these in one line each.","type":"array","items":{"$ref":"#/components/schemas/ConsentPresetDto"}},"requested_policy":{"description":"The policy the presets resolve to — the starting point the human narrows. Authority is whatever they approve.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"escalation_actions":{"description":"Escalation actions (credential minting, role/policy editing, m2m issuance) the requested policy would grant. Always empty for a preset; the consent screen must surface any entry here in its own section, not among ordinary permissions.","example":[],"type":"array","items":{"type":"string"}},"resource":{"type":"string","description":"RFC 8707 resource the token will be audience-bound to."},"workspaces":{"description":"Workspaces the human may pick from. Access is scoped to exactly one.","type":"array","items":{"$ref":"#/components/schemas/ConsentWorkspaceDto"}},"expires_at":{"type":"string","format":"date-time"}},"required":["request_id","client_id","client_name","client_uri","is_verified","presets","requested_policy","escalation_actions","resource","workspaces","expires_at"]},"ConsentDecisionDto":{"type":"object","properties":{"approved":{"type":"boolean","description":"True to approve, false to deny."},"workspace_id":{"type":"string","format":"uuid","description":"Workspace the grant is scoped to. Required when approving."},"policy":{"description":"The policy the human approved. Defaults to the policy the requested presets resolve to. May narrow it, and may add an escalation action a preset withheld — but never beyond the human's own authority: a grant that exceeds the granter is refused with 403 and the response names the refused statements.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}}},"required":["approved"]},"ConsentDecisionResponseDto":{"type":"object","properties":{"redirect_to":{"type":"string","description":"Absolute URL the browser must be sent to. Always the redirect URI captured when the request was created."}},"required":["redirect_to"]},"ConnectedClientDto":{"type":"object","properties":{"grant_id":{"type":"string","format":"uuid"},"client_id":{"type":"string"},"client_name":{"type":"string"},"is_verified":{"type":"boolean"},"account_id":{"type":"string","format":"uuid","description":"The human who consented to this grant."},"workspace_id":{"type":"string","format":"uuid"},"preset":{"type":"string","nullable":true,"description":"Preset the grant started from, when it started from one.","example":"mail"},"policy":{"description":"Policy the human approved. Effective authority is this intersected with the human's current workspace policy, per request.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"resource":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"revoked_at":{"type":"string","nullable":true,"format":"date-time"}},"required":["grant_id","client_id","client_name","is_verified","account_id","workspace_id","preset","policy","resource","created_at","revoked_at"]},"ConnectedClientListDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConnectedClientDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"TokenRequestDto":{"type":"object","properties":{"grant_type":{"type":"string","description":"`authorization_code` or `refresh_token`.","example":"authorization_code"},"code":{"type":"string","description":"The single-use authorization code. `authorization_code` grant only."},"code_verifier":{"type":"string","description":"PKCE verifier. Required on the `authorization_code` grant — there is no non-PKCE path."},"redirect_uri":{"type":"string","description":"Must be byte-identical to the `redirect_uri` used at `/authorize`. `authorization_code` grant only."},"refresh_token":{"type":"string","description":"The rotating refresh token. `refresh_token` grant only."},"client_id":{"type":"string","description":"Client identifier. Required for public clients; confidential clients may instead use HTTP Basic."},"client_secret":{"type":"string","description":"Client secret for `client_secret_post` authentication."},"scope":{"type":"string","description":"Narrowed preset name(s) on the `refresh_token` grant. A preset the original grant did not carry is rejected; narrowing keeps every deny and only removes actions, so it can never widen."},"resource":{"type":"string","description":"RFC 8707 resource indicator. Must match the one the grant was created for."}},"required":["grant_type"]},"RevocationRequestDto":{"type":"object","properties":{"token":{"type":"string","description":"The access or refresh token to revoke."},"token_type_hint":{"type":"string","description":"`access_token` or `refresh_token`. Advisory only."},"client_id":{"type":"string"},"client_secret":{"type":"string"}},"required":["token"]},"IntrospectionRequestDto":{"type":"object","properties":{"token":{"type":"string","description":"The access token to introspect."},"client_id":{"type":"string"},"client_secret":{"type":"string"}},"required":["token"]},"IntrospectionResponseDto":{"type":"object","properties":{"active":{"type":"boolean","description":"False for an unknown, expired, revoked, or foreign token — the four are deliberately indistinguishable."},"scope":{"type":"string","description":"Preset name(s) the grant started from. Not authority."},"client_id":{"type":"string"},"sub":{"type":"string","format":"uuid","description":"The human who consented."},"workspace_id":{"type":"string","format":"uuid"},"aud":{"type":"string","description":"Resource indicator the token is bound to."},"exp":{"type":"number","description":"Expiry, unix seconds."},"policy":{"description":"The policy stored on the token — the ceiling the human approved at consent.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}},"effective_policy":{"description":"Effective authority: the token's policy intersected with the human's CURRENT workspace policy, recomputed on every call with no cache. An action the human's role loses disappears here without anyone revoking the token; a human who left the workspace makes the token report `active: false`. This is a report — the authorization decision itself re-evaluates both policies per (action, resource) cell.","type":"array","items":{"$ref":"#/components/schemas/PolicyStatementDto"}}},"required":["active"]},"DelegatedAuthorizationRequestDto":{"type":"object","properties":{"action":{"type":"string","description":"The action to decide, in `<service>.<resource>.<verb>` form — the same catalogue the workspace-role and PAK lanes evaluate against.","example":"mail.message.send","maxLength":200},"resource":{"type":"string","description":"Resource URN the action targets. Omitted means `*`, which the human’s URN-narrowed denies still constrain.","example":"pcft:envoi:domain/8f14e45f-ceea-467a-9c1a-2b8a4f2a11c3","maxLength":500}},"required":["action"]},"DelegatedAuthorizationResponseDto":{"type":"object","properties":{"allowed":{"type":"boolean","description":"Whether the effective policy — the grant’s policy AND the authorizing human’s current workspace policy — allows this action right now."},"reason":{"type":"string","description":"Machine-readable outcome: `allowed`, `denied_by_effective_policy`, `token_inactive`, `token_expired`, `grant_revoked`, `granter_not_a_member`, or `granter_account_inactive` (the authorizing human’s account is suspended or deactivated).","example":"allowed"}},"required":["allowed","reason"]},"DelegatedAuthorizationBatchRequestDto":{"type":"object","properties":{"actions":{"description":"Actions to decide, in `<service>.<resource>.<verb>` form. Duplicates are evaluated once and echoed per entry.","example":["mail.send","mail.list"],"maxItems":500,"type":"array","items":{"type":"string"}},"resource":{"type":"string","description":"Resource URN every action is decided against. Omitted means `*`, which the human’s URN-narrowed denies still constrain.","example":"pcft:envoi:domain/8f14e45f-ceea-467a-9c1a-2b8a4f2a11c3","maxLength":500}},"required":["actions"]},"DelegatedActionDecisionDto":{"type":"object","properties":{"action":{"type":"string","description":"The action that was decided.","example":"mail.send"},"allowed":{"type":"boolean","description":"Whether the effective policy allows this action at the requested resource right now."}},"required":["action","allowed"]},"DelegatedAuthorizationBatchResponseDto":{"type":"object","properties":{"decisions":{"description":"One decision per requested action, in the order they were requested.","type":"array","items":{"$ref":"#/components/schemas/DelegatedActionDecisionDto"}},"reason":{"type":"string","description":"Machine-readable outcome for the token as a whole: `evaluated`, `token_inactive`, `token_expired`, `grant_revoked`, `granter_not_a_member`, or `granter_account_inactive`. Anything but `evaluated` means every decision is `false`.","example":"evaluated"}},"required":["decisions","reason"]},"DownstreamAssertionRequestDto":{"type":"object","properties":{"audience":{"type":"string","description":"Hostname of the service the assertion is for, e.g. `api.mail.productcraft.co`. Must be one we issue for; anything else is 400. This is an allowlist and not a free-text audience, because the audience string is what separates this family from the session lane.","example":"api.mail.productcraft.co"}},"required":["audience"]},"DownstreamAssertionResponseDto":{"type":"object","properties":{"assertion":{"type":"string","description":"The signed assertion. Verifies offline against the platform JWKS. Carries identity only — no policy."},"expires_in":{"type":"number","description":"Seconds until it expires. Deliberately short.","example":60},"audience":{"type":"string","description":"The audience it was minted for, echoed back.","example":"api.mail.productcraft.co"}},"required":["assertion","expires_in","audience"]},"AssertionAuthorizationRequestDto":{"type":"object","properties":{"action":{"type":"string","description":"Action to decide, `<service>.<resource>.<verb>`."},"resource":{"type":"string","description":"URN the action targets. Omitted means `*`, the strict reading."}},"required":["action"]}}}}