{"openapi":"3.0.0","paths":{"/{app_slug}/v1/.well-known/jwks.json":{"get":{"description":"Returns the JSON Web Key Set used to sign EndUser and M2M access tokens for this app. Cached at the edge for 1 hour; verifiers should re-fetch on `kid` miss.","operationId":"ConsumerJwksController_getJwks","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JwksResponseDto"}}}},"404":{"description":"App slug does not match an app in this workspace."}},"summary":"Per-app JWKS","tags":["Consumer · Discovery"]}},"/{app_slug}/v1/.well-known/openid-configuration":{"get":{"description":"Returns `issuer`, `jwks_uri`, `token_endpoint`, `introspection_endpoint`, `userinfo_endpoint` (= `/me`), and the supported grant + signing options. Use this from SDKs / verifiers instead of hardcoding paths.","operationId":"ConsumerJwksController_getDiscovery","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenIdConfigurationDto"}}}},"404":{"description":"App slug does not match an app in this workspace."}},"summary":"OAuth 2.0 / OIDC discovery document (subset)","tags":["Consumer · Discovery"]}},"/{app_slug}/v1/auth/signup":{"post":{"description":"Create an EndUser account inside the app and immediately mint a session. The supplied email becomes the primary email contact (unverified at signup; the customer triggers verification via /auth/request-verification or /auth/send-verification-email). Subject to the app-configured signup policy (signup enabled, password policy, default role, optional `signup_requires_pak`).","operationId":"ConsumerAuthController_signup","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSignupDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}}},"summary":"Sign up an EndUser","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/signin":{"post":{"description":"Authenticate with username or verified primary email + password. When the account has no enabled MFA factors, returns a fresh access + refresh token pair (`ConsumerTokenResponseDto` shape). When the account has any enabled factor, returns an MFA challenge instead (`ConsumerMfaChallengeResponseDto` shape, `mfa_required: true`). In that case no session has been created yet — complete the challenge via `POST /auth/mfa/verify` (or `/auth/mfa/recover`) to receive the token pair.","operationId":"ConsumerAuthController_signin","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSigninDto"}}}},"responses":{"200":{"description":"Either a normal token response or an MFA challenge — distinguish by `mfa_required`.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ConsumerTokenResponseDto"},{"$ref":"#/components/schemas/ConsumerMfaChallengeResponseDto"}]}}}}},"summary":"Sign in an EndUser","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/verify":{"post":{"description":"Submit the `mfa_token` from a `mfa_required: true` signin response, plus a current TOTP code from the user's authenticator app. Mints session + tokens with `amr: [\"pwd\",\"totp\"]` and `mfa_at` set to now. The challenge is single-use; 5 wrong codes locks it.","operationId":"ConsumerAuthController_mfaVerify","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaVerifyDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"401":{"description":"Invalid or expired challenge, or wrong code."},"429":{"description":"Too many failed attempts against this challenge."}},"summary":"Complete an MFA challenge with a TOTP code","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/recover":{"post":{"description":"Same flow as `/auth/mfa/verify` but consumes a single-use recovery code (16 hex chars; hyphens optional). Mints tokens with `amr: [\"pwd\",\"recovery_code\"]`. After successful recovery, the user should regenerate their recovery codes via `/me/mfa/recovery-codes/regenerate`.","operationId":"ConsumerAuthController_mfaRecover","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaRecoverDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"401":{"description":"Invalid or expired challenge, or wrong recovery code."},"429":{"description":"Too many failed attempts against this challenge."}},"summary":"Complete an MFA challenge with a recovery code","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/refresh":{"post":{"description":"Rotates the refresh token on every call — the previous refresh token is revoked, and re-using it triggers session revocation.","operationId":"ConsumerAuthController_refresh","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRefreshDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}}},"summary":"Exchange a refresh token for a new access token","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/logout":{"post":{"description":"Destroys the session that owns the refresh token. The matching access token continues to verify until its TTL expires.","operationId":"ConsumerAuthController_logout","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerLogoutDto"}}}},"responses":{"204":{"description":""}},"summary":"Revoke a refresh token","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/request-verification":{"post":{"description":"Mints a fresh 6-digit verification code bound to the email or phone contact named in the body. Body must contain exactly one of `email` / `phone`. PAK-required — `Authorization: Bearer <pcft_live_*>` carrying `heimdall.user.verify.create` narrowed to this app's URN. Public mint flows are refused with 401 — any anonymous mint endpoint is a spam-forwarder vector.\n\nReturns `{ code, expires_at }` for delivery via the customer's own SMTP / SMS provider. Returns `{}` (no code) when the contact doesn't match an account in this app or is already verified — uniform shape prevents enumeration.","operationId":"ConsumerAuthController_requestVerification","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRequestVerificationDto"}}}},"responses":{"201":{"description":"`{ code, expires_at }` when the contact exists and is unverified; `{}` otherwise.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerCodeIssueResponseDto"}}}},"400":{"description":"Provide exactly one of `email` or `phone`."},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks heimdall.user.verify.create on this app."}},"security":[{"bearer":[]}],"summary":"Mint a contact-verification code (PAK-required)","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/send-verification-email":{"post":{"description":"Mints a fresh 6-digit verification code AND dispatches it to the supplied email address via the workspace's verified Envoi sender, in one call. Email-only — the customer integrates SMS delivery themselves through `request-verification`.\n\nPAK-required — distinct permission from the bare mint so customers can enable mint without enabling outbound Envoi traffic. Surfaces typed precondition errors instead of the silent fail-closed of a fire-and-forget mailer:\n\n  * 412 ENVOI_NOT_ENABLED — `notifications_via_envoi` is false\n  * 412 ENVOI_SENDER_NOT_CONFIGURED\n  * 412 ENVOI_TEMPLATE_NOT_CONFIGURED\n  * 412 ENVOI_APP_NOT_BOUND_TO_WORKSPACE\n  * 503 ENVOI_DISPATCH_FAILED — RabbitMQ publish failure\n  * 503 ENVOI_NOT_WIRED — heimdall has no mailbox client\n\nOn success returns `{ expires_at }`. The plaintext code is NOT returned. Returns `{}` when the contact is missing or already verified (silent — same enumeration defence as `request-verification`).","operationId":"ConsumerAuthController_sendVerificationEmail","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSendVerificationEmailDto"}}}},"responses":{"201":{"description":"`{ expires_at }` on success; `{}` on no-match.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerCodeDispatchResponseDto"}}}},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks heimdall.user.verify.send-email on this app."},"412":{"description":"Envoi precondition not met (see error code in the response body)."},"503":{"description":"Envoi dispatch failed or client not wired."}},"security":[{"bearer":[]}],"summary":"Mint + dispatch a verification email via Envoi (PAK-required)","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/verify":{"post":{"description":"Consumes a 6-digit verification code (single-use) and flips the bound contact's `verified_at`. Public — anyone holding the code can submit it; that is the whole point of the verification flow. The server figures out which contact + account from the code value itself.\n\nRenamed from /verify-email — the same endpoint now serves both email and phone verifications.","operationId":"ConsumerAuthController_verify","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerVerifyDto"}}}},"responses":{"200":{"description":"Contact verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerVerifyResponseDto"}}}},"410":{"description":"Code invalid, expired, locked, or already consumed."}},"summary":"Submit a verification code","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/request-password-reset":{"post":{"description":"Mints a fresh 6-digit password-reset code for the EndUser owning the named contact. Body must contain exactly one of `email` / `phone` — the contact must be verified (otherwise an attacker who registered an unverified contact could push a reset on another user's account, were the per-app email uniqueness ever breached). The reset itself is account-scoped (revokes all sessions, replaces password) — the contact only acts as the lookup key.\n\nPAK-required: `heimdall.user.password-reset.create`. Returns `{ code, expires_at }` on success, `{}` on no-match (uniform shape prevents enumeration).\n\nRenamed from /auth/request-reset.","operationId":"ConsumerAuthController_requestPasswordReset","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRequestPasswordResetDto"}}}},"responses":{"201":{"description":"`{ code, expires_at }` when the contact matches a verified account; `{}` otherwise.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerCodeIssueResponseDto"}}}},"400":{"description":"Provide exactly one of `email` or `phone`."},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks heimdall.user.password-reset.create on this app."}},"security":[{"bearer":[]}],"summary":"Mint a password-reset code (PAK-required)","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/send-password-reset-email":{"post":{"description":"Same shape as `send-verification-email` but for the reset slot. PAK perm: `heimdall.user.password-reset.send-email`. Same loud precondition errors. Email-only.","operationId":"ConsumerAuthController_sendPasswordResetEmail","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSendPasswordResetEmailDto"}}}},"responses":{"201":{"description":"`{ expires_at }` on success; `{}` on no-match.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerCodeDispatchResponseDto"}}}},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks heimdall.user.password-reset.send-email on this app."},"412":{"description":"Envoi precondition not met (see error code in the response body)."},"503":{"description":"Envoi dispatch failed or client not wired."}},"security":[{"bearer":[]}],"summary":"Mint + dispatch a password-reset email via Envoi (PAK-required)","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/reset-password":{"post":{"description":"Consumes the 6-digit reset code (single-use). On success the password is updated and every active session for the user is revoked.","operationId":"ConsumerAuthController_resetPassword","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerResetPasswordDto"}}}},"responses":{"204":{"description":"Password updated."},"410":{"description":"Code invalid, expired, locked, or already consumed."}},"summary":"Submit a reset code + new password","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/switch-tenant":{"post":{"description":"Re-issues the EndUser's access + refresh tokens with the new `org_id` + `org_role` claims, after verifying the caller holds a tenant_membership for the target tenant. The session row is updated to the new tenant so subsequent refreshes preserve the context. Refresh-token rotation runs as usual; the previous refresh token enters the standard 60-second grace window.","operationId":"ConsumerAuthController_switchTenant","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSwitchTenantDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Switch the active tenant for this session","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/me/tenants":{"get":{"description":"Returns each tenant the caller is a member of, including the tenant slug + display name and the member's role. Powers a tenant-switcher UI on the customer's product.","operationId":"ConsumerAuthController_listMyTenants","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyTenantsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List the EndUser's tenant memberships in this app","tags":["Consumer · Auth"]}},"/{app_slug}/v1/me":{"get":{"description":"Returns the EndUser identity, app membership, and role name. The flat `permissions[]` array is intentionally NOT included — permissions resolve from the role on demand. Call `GET /me/permissions` for the flat list when the client needs it for UI gating.","operationId":"ConsumerMeController_getProfile","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeProfileResponseDto"}}}},"404":{"description":"Account not found."}},"security":[{"bearer":[]}],"summary":"Get the signed-in EndUser profile","tags":["Consumer · Me"]},"patch":{"description":"Currently only the display name can be changed.","operationId":"ConsumerMeController_updateProfile","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeDto"}}}},"responses":{"200":{"description":"Updated account row."}},"security":[{"bearer":[]}],"summary":"Update the signed-in EndUser profile","tags":["Consumer · Me"]},"delete":{"description":"Removes the EndUser, all sessions, and all related records inside this app. Cannot be undone.","operationId":"ConsumerMeController_deleteAccount","parameters":[],"responses":{"204":{"description":"Account deleted."}},"security":[{"bearer":[]}],"summary":"Permanently delete the signed-in EndUser account","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/permissions":{"get":{"description":"Returns the union of the app-level role permissions and the tenant-level role permissions (when the token carries an `org_id`). Resolved per-request through a 60-second LRU — role-permission edits propagate within ~1 minute. Use this instead of decoding a `permissions[]` claim from the JWT, which is no longer included. M2M tokens cannot call this — use `POST /:appSlug/v1/oauth/introspect` to inspect M2M scopes.","operationId":"ConsumerMeController_getPermissions","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MePermissionsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Resolve the signed-in EndUser's effective permissions","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/change-password":{"post":{"description":"Verifies `current_password`, sets the new hash, and revokes every OTHER active session for the account (the calling session is preserved). 401 if the current password is wrong or the account has no password credential — admin-provisioned accounts without an initial password should use the reset flow instead.","operationId":"ConsumerMeController_changePassword","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordDto"}}}},"responses":{"204":{"description":"Password updated."},"401":{"description":"Current password incorrect or no credential."}},"security":[{"bearer":[]}],"summary":"Change the password of the signed-in EndUser","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/sessions":{"get":{"description":"Each session includes a `current` flag identifying the one belonging to the bearer token used for this request.","operationId":"ConsumerMeController_listSessions","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeSessionsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List all active sessions for the EndUser","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/sessions/{id}":{"delete":{"description":"Sign the EndUser out of one device. The session's refresh token is destroyed; its access token expires on its TTL. Requires the `session.revoke` permission when the app's `enforce_app_permissions` flag is on; otherwise (the default) any valid token can call this. Self-revoke (revoking your own session as a member with no perms) is something to enforce separately if needed; this guard does not exempt the principal from the role check.","operationId":"ConsumerMeController_revokeSession","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Session revoked."}},"security":[{"bearer":[]}],"summary":"Revoke a session","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/activity":{"get":{"description":"Login events, password changes, and other security-relevant records, scoped to this EndUser.","operationId":"ConsumerMeController_getActivity","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeActivityResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Recent account activity","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/contacts":{"get":{"operationId":"ConsumerMeController_listContacts","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeContactsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List the EndUser's contacts","tags":["Consumer · Me"]},"post":{"description":"Adds an email or phone contact to the EndUser. The contact starts unverified and non-primary. Verification is a separate flow — call /auth/request-verification with the contact value and the customer's PAK to mint a code, then submit via /auth/verify.","operationId":"ConsumerMeController_addContact","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddContactDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeContactDto"}}}}},"security":[{"bearer":[]}],"summary":"Add a new contact (unverified, non-primary)","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/contacts/{id}":{"delete":{"operationId":"ConsumerMeController_deleteContact","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Contact deleted."}},"security":[{"bearer":[]}],"summary":"Delete a contact","tags":["Consumer · Me"]}},"/{app_slug}/v1/me/contacts/{id}/promote":{"post":{"description":"Demotes the previous primary of the same type. Refused (409) when the target contact is unverified — promoting an unverified contact would let an attacker who registered the contact bypass verification by routing reset codes through it.","operationId":"ConsumerMeController_promoteContact","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Contact promoted."}},"security":[{"bearer":[]}],"summary":"Promote a verified contact to primary","tags":["Consumer · Me"]}},"/{app_slug}/v1/verify":{"post":{"description":"Validates the JWT against the app's JWKS keys and returns `{ valid, principal }`. Use this when you need the full claims; use `/authorize` for a yes/no permission check.","operationId":"ConsumerVerifyController_verify","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyBody"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyResponseDto"}}}}},"summary":"Verify a token signature + return its principal","tags":["Consumer · Verify"]}},"/{app_slug}/v1/authorize":{"post":{"description":"Returns `{ authorized: boolean }`. Pass `permission` (string) for a single check, or `permissions` (array) when ALL must be held.","operationId":"ConsumerVerifyController_authorize","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthorizeBody"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthorizeResponseDto"}}}}},"summary":"Verify a token + check one or more permissions","tags":["Consumer · Verify"]}},"/{app_slug}/v1/authorize/batch":{"post":{"description":"Runs N permission checks against the same token in one call. Each result reports the missing permissions (if any). Useful when a UI needs to flag many actions at once.","operationId":"ConsumerVerifyController_authorizeBatch","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthorizeBatchBody"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthorizeBatchResponseDto"}}}}},"summary":"Multi-permission authorization in a single round-trip","tags":["Consumer · Verify"]}},"/{app_slug}/v1/oauth/token":{"post":{"description":"Standard OAuth 2.0 client_credentials grant. Returns an app-scoped access token whose `scopes[]` claim is the union of the client's configured scopes. Use for service-to-service calls.","operationId":"ConsumerOAuthController_clientCredentials","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientCredentialsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenResponseDto"}}}}},"summary":"Exchange client_credentials for an access token","tags":["Consumer · OAuth (M2M)"]}},"/{app_slug}/v1/oauth/introspect":{"post":{"description":"OAuth 2.0 token introspection. Send the access token via `Authorization: Bearer <token>` and receive either `{ active: false }` (invalid / expired / wrong app / revoked session) or the active claim set including `scopes` (M2M) or `role` (EndUser), `exp`, `iat`, `iss`, `aid`. The body `token` field is optional and, if present, must match the bearer — RFC 7662 allows a separate `token` body field but we deliberately refuse cross-token introspection so a stolen token can't be probed against another active credential. The endpoint accepts both M2M and EndUser tokens — use it as the canonical way for a service to know what its own token can do, instead of decoding the JWT by hand. Returns 401 when the bearer is missing.","operationId":"ConsumerOAuthController_introspect","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Introspect the bearer access token (RFC 7662)","tags":["Consumer · OAuth (M2M)"]}},"/{app_slug}/v1/admin/users":{"post":{"description":"Create an account in this app. Distinct from the public `/auth/signup`: no session is issued, no password is required (when omitted, the customer triggers a password-reset to finish onboarding), and the primary email contact starts unverified — the customer is responsible for kicking off the verification flow via `/auth/request-verification` or `/auth/send-verification-email`. The `roleName` defaults to the app's `signup_default_role`.","operationId":"ConsumerAdminController_createUser","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCreateUserDto"}}}},"responses":{"201":{"description":"Created end-user record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminCreatedUserDto"}}}},"400":{"description":"Invalid email or unknown role."},"409":{"description":"Email or username already in use."}},"security":[{"bearer":[]}],"summary":"Provision a new end-user","tags":["Consumer · Admin"]},"get":{"description":"Cursor-paginated. Supports the same `status` and `search` filters as the heimdall-admin equivalent. Use the cursor in subsequent calls — Heimdall does not expose offset pagination.","operationId":"ConsumerAdminController_listUsers","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","schema":{"type":"string"}},{"name":"search","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminEndUserListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List end-users in this app","tags":["Consumer · Admin"]}},"/{app_slug}/v1/admin/users/{user_id}":{"get":{"operationId":"ConsumerAdminController_getUser","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminEndUserDto"}}}},"404":{"description":"End-user not found in this app."}},"security":[{"bearer":[]}],"summary":"Read one end-user","tags":["Consumer · Admin"]},"patch":{"description":"Email update promotes an existing contact to primary (not for creating new contacts — that goes through the per-contact flow).","operationId":"ConsumerAdminController_updateUser","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminEndUserDto"}}}}},"security":[{"bearer":[]}],"summary":"Update an end-user's display name or primary email","tags":["Consumer · Admin"]},"delete":{"description":"Removes the account, its sessions, contacts, and tenant memberships. Audit rows are preserved.","operationId":"ConsumerAdminController_deleteUser","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"End-user deleted."}},"security":[{"bearer":[]}],"summary":"Permanently delete an end-user","tags":["Consumer · Admin"]}},"/{app_slug}/v1/admin/users/{user_id}/status":{"patch":{"description":"Setting status to `suspended` or `deactivated` also revokes all active sessions for the user (same behaviour as the heimdall-admin equivalent).","operationId":"ConsumerAdminController_updateStatus","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUpdateStatusDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminEndUserDto"}}}}},"security":[{"bearer":[]}],"summary":"Change an end-user status (active / suspended / deactivated)","tags":["Consumer · Admin"]}},"/{app_slug}/v1/admin/users/{user_id}/role":{"patch":{"description":"Sets `app_membership.role_id` for the target user. For M2M callers the assignable role is bounded by the token's scopes (the guard already enforced `role.assign`); the role's own permission set is NOT additionally narrowed against the M2M scopes because M2M scopes are typically broader than per-user role perms by design (a backend cron with `user.list` + `role.assign` SHOULD be able to promote a user into a role carrying any per-app perm). EndUser admins are bounded by their own role's perms transitively — they couldn't authenticate as an admin without holding the perm.","operationId":"ConsumerAdminController_updateRole","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUpdateRoleDto"}}}},"responses":{"200":{"description":"Membership updated."}},"security":[{"bearer":[]}],"summary":"Assign an end-user a role","tags":["Consumer · Admin"]}},"/{app_slug}/v1/admin/roles":{"post":{"description":"Creates a role with no permissions bound yet. Use `PUT /:roleName/permissions` to attach a permission set after creation — that route also runs caller-narrowing so an EndUser admin can't grant verbs they don't themselves hold.","operationId":"ConsumerAdminRolesController_createRole","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleDto"}}}},"responses":{"201":{"description":"Role created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRoleResponseDto"}}}},"409":{"description":"Role name already exists in this app."}},"security":[{"bearer":[]}],"summary":"Create a role","tags":["Consumer · Admin · Roles"]},"get":{"description":"Cursor-paginated by `created_at`. Use the returned `next_cursor` in subsequent calls — offset pagination is not exposed.","operationId":"ConsumerAdminRolesController_listRoles","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRoleListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List roles in this app","tags":["Consumer · Admin · Roles"]}},"/{app_slug}/v1/admin/roles/{role_name}":{"get":{"description":"Returns the role plus the flat `permissions[]` array currently bound. Use this to drive a \"configure role\" UI in the customer product without having to call `/permissions` separately.","operationId":"ConsumerAdminRolesController_getRole","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRoleWithPermissionsResponseDto"}}}},"404":{"description":"Role not found in this app."}},"security":[{"bearer":[]}],"summary":"Read one role with its permission set","tags":["Consumer · Admin · Roles"]},"patch":{"description":"Renames are intentionally NOT supported — the role name is part of every issued EndUser JWT, so changing it would silently break live sessions. Create a new role and migrate users instead.","operationId":"ConsumerAdminRolesController_updateRole","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRoleDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerRoleResponseDto"}}}},"404":{"description":"Role not found in this app."}},"security":[{"bearer":[]}],"summary":"Update a role's description","tags":["Consumer · Admin · Roles"]},"delete":{"description":"System roles (`owner`, `admin`, `member`) cannot be deleted — attempts return 403. Roles currently assigned to any member return 409 `ROLE_IN_USE`; reassign the affected members first.","operationId":"ConsumerAdminRolesController_deleteRole","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Role deleted."},"403":{"description":"Cannot delete a system role."},"409":{"description":"Role is in use by at least one member."}},"security":[{"bearer":[]}],"summary":"Delete a custom role","tags":["Consumer · Admin · Roles"]}},"/{app_slug}/v1/admin/roles/{role_name}/permissions":{"put":{"description":"Destructive — the role's permission set is replaced wholesale with the supplied array. Caller-narrowed: the caller must hold every permission in the request, otherwise 403 `Cannot grant actions you don't have: ...`. For EndUser callers the caller-held set is their app-membership role's effective permissions. For M2M callers the held set is the token's `scopes[]` claim — the M2M cannot grant a role any verb that wasn't configured on the M2M credential. Unknown permission strings return 400.","operationId":"ConsumerAdminRolesController_setPermissions","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPermissionsDto"}}}},"responses":{"200":{"description":"Permissions updated (empty body)."},"400":{"description":"Unknown permission(s) in the request."},"403":{"description":"Caller is missing one or more permissions they're trying to grant."},"404":{"description":"Role not found in this app."}},"security":[{"bearer":[]}],"summary":"Replace a role's permission set","tags":["Consumer · Admin · Roles"]}},"/{app_slug}/v1/admin/permissions":{"post":{"description":"Inserts a `<resource>.<action>` row in the per-app permission catalogue. The resource and action segments must each match `^[a-z][a-z0-9_-]{1,47}$`. Conflicts with a system permission name return 409. Customers usually then bind the new permission to a role via `PUT /admin/roles/:roleName/permissions`.","operationId":"ConsumerAdminPermissionsController_createPermission","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePermissionDto"}}}},"responses":{"201":{"description":"Permission created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminPermissionDto"}}}},"409":{"description":"Conflicts with a system permission or already exists in this app."}},"security":[{"bearer":[]}],"summary":"Create a custom permission","tags":["Consumer · Admin · Permissions"]},"get":{"description":"Returns the union of system permissions (shared across every app — `is_system: true` in the response) and any custom entries this app has defined (`is_system: false`). Useful for driving a role-editor UI that picks from the full catalogue.","operationId":"ConsumerAdminPermissionsController_listPermissions","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerAdminPermissionListEntryDto"}}}}}},"security":[{"bearer":[]}],"summary":"List all permissions visible to this app","tags":["Consumer · Admin · Permissions"]}},"/{app_slug}/v1/admin/permissions/{permission_key}":{"delete":{"description":"The `permissionKey` path parameter is the literal `<resource>.<action>` string. System permissions cannot be deleted — attempts return 403. Removing a permission also removes every `role_permission` row that bound it (cascade); role lookups dropped of that perm on the next request.","operationId":"ConsumerAdminPermissionsController_deletePermission","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"permission_key","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Permission deleted."},"400":{"description":"permissionKey must be in `resource.action` format."},"403":{"description":"Cannot delete system permissions."},"404":{"description":"Permission not found in this app."}},"security":[{"bearer":[]}],"summary":"Delete a custom permission","tags":["Consumer · Admin · Permissions"]}},"/{app_slug}/v1/users/{user_id}/factors":{"get":{"operationId":"ConsumerAdminFactorsController_list","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactorListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List a user's MFA factors","tags":["Consumer · Admin · MFA"]},"post":{"description":"Creates the factor in the enabled state and returns the cleartext secret + otpauth URI + recovery codes in the response. Intended for synthetic-account-style flows where the caller bundles credentials and ships them out once.","operationId":"ConsumerAdminFactorsController_provision","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartEnrollmentDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminProvisionFactorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Provision an MFA factor on behalf of a user","tags":["Consumer · Admin · MFA"]}},"/{app_slug}/v1/users/{user_id}/factors/{factor_id}":{"delete":{"operationId":"ConsumerAdminFactorsController_disable","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"factor_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Factor disabled."}},"security":[{"bearer":[]}],"summary":"Disable a user's MFA factor","tags":["Consumer · Admin · MFA"]}},"/{app_slug}/v1/me/mfa/factors":{"get":{"description":"Includes factors in any state (pending enrollment + enabled). Disabled factors are not listed.","operationId":"ConsumerMfaController_list","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactorListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List the authenticated EndUser's MFA factors","tags":["Consumer · MFA"]},"post":{"description":"Creates a pending factor and returns the type-specific enrollment payload (for TOTP: the otpauth URI, QR data URL, and shared secret). The factor stays disabled until `/factors/:id/verify` is called with two consecutive valid codes.","operationId":"ConsumerMfaController_startEnrollment","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartEnrollmentDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartEnrollmentResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Begin enrollment of a new MFA factor","tags":["Consumer · MFA"]}},"/{app_slug}/v1/me/mfa/factors/{id}/verify":{"post":{"description":"For TOTP: submit two codes from consecutive 30-second windows. On success the factor flips to enabled, recovery codes are minted (returned once), and prior recovery codes for the account are invalidated. Subsequent calls with the same `:id` fail with 400.","operationId":"ConsumerMfaController_confirmEnrollment","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmEnrollmentDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmEnrollmentResponseDto"}}}},"400":{"description":"Invalid or repeated codes."}},"security":[{"bearer":[]}],"summary":"Confirm enrollment of a pending factor","tags":["Consumer · MFA"]}},"/{app_slug}/v1/me/mfa/factors/{id}":{"delete":{"description":"Soft-disable. The row is preserved (with `disabled_at` set) for audit. Has no effect on existing sessions — their `amr` claim still reflects whatever satisfied sign-in at the time. Disabling the only enabled factor removes the MFA requirement on future sign-ins.","operationId":"ConsumerMfaController_disable","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Factor disabled."}},"security":[{"bearer":[]}],"summary":"Disable an MFA factor","tags":["Consumer · MFA"]}},"/{app_slug}/v1/me/mfa/recovery-codes/regenerate":{"post":{"description":"Mints a fresh set of single-use recovery codes and invalidates every prior code for the account. The new codes are returned once — store them now.","operationId":"ConsumerMfaController_regenerateRecoveryCodes","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryCodesResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Regenerate the recovery-code set","tags":["Consumer · MFA"]}},"/{app_slug}/v1/me/mfa/step-up":{"post":{"description":"Verify a fresh code (TOTP or recovery) against any enabled factor on the account, and update the session's `auth_methods` + `mfa_at`. Call `/auth/refresh` after this to mint a token whose claims reflect the new state. Customers reading `mfa_at` from access tokens to gate sensitive actions should require a `/step-up` + refresh cycle when the timestamp is older than their policy window. 5 consecutive wrong codes lock step-up on this session for 15 minutes.","operationId":"ConsumerMfaController_stepUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpResponseDto"}}}},"401":{"description":"Code did not match any enabled factor or recovery code."},"429":{"description":"Step-up locked on this session after too many failures."}},"security":[{"bearer":[]}],"summary":"Bump this session's MFA recency","tags":["Consumer · MFA"]}},"/{app_slug}/v1/auth/oauth/exchange":{"post":{"operationId":"ConsumerIdpExchangeController_exchange","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExchangeRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExchangeResponseDto"}}}},"401":{"description":"Code is unknown, expired, or already redeemed."},"403":{"description":"Code does not belong to this `appSlug`."}},"summary":"Exchange a `heimdall_code` (received on the web-flow callback redirect) for Heimdall access + refresh tokens. Single-use.","tags":["Consumer · OAuth Sign-In"]}},"/{app_slug}/v1/auth/oauth/{provider}":{"post":{"description":"Submit the provider-issued ID token from a native client (iOS ASAuthorizationController, Google Sign-In for iOS / Android). Backend verifies the token signature, issuer, audience (against this app's configured native client ids), and nonce binding; consumes the nonce server-side to defeat in-window replay; resolves or creates the Heimdall account; mints access + refresh tokens with `amr=[\"oauth\", \"<provider>\"]`. Same response shape as `/auth/signin`.","operationId":"ConsumerIdpController_nativeSignIn","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","description":"OAuth provider id. Currently: apple.","schema":{"enum":["apple"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdpNativeSigninDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdpTokenResponseDto"}}}},"401":{"description":"ID token verification failed (bad signature / issuer / audience / nonce / expired)."},"409":{"description":"Email collides with an existing account. Response carries an error `code` of `link_required` (when the app's policy is `confirm`) or `account_exists_with_different_provider` (when `reject`). Sign in with the original method to link."}},"summary":"Sign in / sign up with a provider ID token (native flow)","tags":["Consumer · OAuth Sign-In"]}},"/{app_slug}/v1/auth/oauth/{provider}/authorize":{"get":{"operationId":"ConsumerIdpWebController_authorize","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple"],"type":"string"}},{"name":"return_to","required":true,"in":"query","schema":{"type":"string"},"description":"Absolute URL the customer wants the user redirected to after sign-in. Its origin MUST be in the app's `allowed_redirect_origins`. The full URL (including query + hash) is preserved; we append `?heimdall_code=<code>`."}],"responses":{"302":{"description":"Redirect to provider authorize URL."},"400":{"description":"Missing/invalid `return_to`, origin not allowed, or web flow disabled (no `allowed_redirect_origins` configured)."}},"summary":"Start the web redirect flow. 302s to the upstream provider with a server-generated state + nonce.","tags":["Consumer · OAuth Sign-In"]}},"/{app_slug}/v1/auth/oauth/{provider}/callback":{"post":{"operationId":"ConsumerIdpWebController_callback","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallbackBodyDto"}}}},"responses":{"303":{"description":"Redirect to return_to."},"400":{"description":"Unknown / expired state, or state↔provider mismatch."},"401":{"description":"ID token verification failed (signature / issuer / audience / nonce)."},"409":{"description":"Email collides with an existing account. Returned via redirect with `?heimdall_error=link_required` or `?heimdall_error=account_exists_with_different_provider`."}},"summary":"Provider callback (Apple uses `response_mode=form_post`). Verifies the ID token, mints Heimdall tokens, stashes them behind a single-use code, 303s to `<return_to>?heimdall_code=…`.","tags":["Consumer · OAuth Sign-In"]}},"/v1/apps/invites/accept":{"post":{"operationId":"AppController_acceptInvite","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptInviteDto"}}}},"responses":{"200":{"description":"App joined.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppResponseDto"}}}}},"summary":"Accept an app invite by code","tags":["apps"]}},"/v1/apps":{"get":{"operationId":"AppController_listMyApps","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"workspace_id","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppListResponseDto"}}}}},"summary":"List apps the current PlatformUser is a member of. Pass `workspaceId` to scope the result to a single workspace — required from the per-workspace console page so cross-tenant memberships do not leak.","tags":["apps"]},"post":{"operationId":"AppController_createApp","parameters":[{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. Provisioning a new app mints JWKS keys + system roles + permissions; a retry without an idempotency key would create a second app the customer can't tell apart. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAppDto"}}}},"responses":{"201":{"description":"App created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppResponseDto"}}}},"400":{"description":"Validation failure (slug, display name, or workspaceId)."},"403":{"description":"Caller email not verified, or caller lacks `heimdall.create` on the target workspace."},"409":{"description":"Slug already taken."}},"summary":"Create a new Heimdall app owned by the caller","tags":["apps"]}},"/v1/apps/{app_id}":{"get":{"operationId":"AppController_getApp","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppResponseDto"}}}}},"summary":"Get app details","tags":["apps"]},"patch":{"operationId":"AppController_updateApp","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAppDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppResponseDto"}}}}},"summary":"Update app display name, slug, and metadata","tags":["apps"]},"delete":{"operationId":"AppController_deleteApp","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"App deleted."},"403":{"description":"Caller lacks `heimdall.delete`."},"404":{"description":"App not found or caller is not a member."}},"summary":"Delete an app permanently","tags":["apps"]}},"/v1/apps/{app_id}/status":{"patch":{"operationId":"AppController_updateAppStatus","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAppStatusDto"}}}},"responses":{"200":{"description":"Status updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppResponseDto"}}}},"400":{"description":"Invalid status transition (e.g. archived → active)."},"403":{"description":"Caller lacks `heimdall.update`."},"404":{"description":"App not found or caller is not a member."}},"summary":"Update app status (active, suspended, archived)","tags":["apps"]}},"/v1/apps/{app_id}/members":{"get":{"operationId":"AppController_listMembers","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppMemberListResponseDto"}}}}},"summary":"List app members (PlatformUser view of EndUser memberships)","tags":["apps"]}},"/v1/apps/{app_id}/members/{account_id}":{"delete":{"operationId":"AppController_removeMember","parameters":[{"name":"app_id","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 the app","tags":["apps"]}},"/v1/apps/{app_id}/invites":{"post":{"operationId":"AppController_createInvite","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInviteDto"}}}},"responses":{"201":{"description":"Invite created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppInviteResponseDto"}}}},"400":{"description":"Validation failure or unknown role slug."},"403":{"description":"Caller email not verified, or lacks `heimdall.assign`."},"404":{"description":"App not found or caller is not a member."}},"summary":"Create an app invite","tags":["apps"]},"get":{"operationId":"AppController_listInvites","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppInviteListResponseDto"}}}}},"summary":"List app invites","tags":["apps"]}},"/v1/apps/{app_id}/invites/{invite_id}":{"delete":{"operationId":"AppController_revokeInvite","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"invite_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Invite revoked."}},"summary":"Revoke an app invite","tags":["apps"]}},"/v1/apps/{app_id}/auth-config":{"get":{"operationId":"AuthConfigController_getConfig","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthConfigResponseDto"}}}}},"summary":"Get the per-app auth config (signup, signin, password policy, sessions).","tags":["apps"]},"patch":{"operationId":"AuthConfigController_updateConfig","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAuthConfigDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthConfigResponseDto"}}}}},"summary":"Update the per-app auth config. Only fields you pass are changed.","tags":["apps"]}},"/v1/apps/{app_id}/auth-config/providers":{"get":{"operationId":"AuthConfigProviderController_list","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProviderListEntryDto"}}}}}},"summary":"List federated sign-in providers Heimdall supports + their config status for this app.","tags":["apps"]}},"/v1/apps/{app_id}/auth-config/providers/{provider}":{"get":{"operationId":"AuthConfigProviderController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple"],"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderDetailDto"}}}},"400":{"description":"Unknown provider id."},"404":{"description":"Provider not configured for this app."}},"summary":"Get the config for one (app, provider). Secrets are redacted — `private_key_present: true` is the only acknowledgement that a private key is on file.","tags":["apps"]},"put":{"operationId":"AuthConfigProviderController_upsert","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertProviderConfigDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderDetailDto"}}}},"400":{"description":"Unknown provider or malformed config."}},"summary":"Write or replace the config for an (app, provider). Validates the body via the provider impl's `parseConfig` before encrypting; malformed config 400s. Default `enabled=false`.","tags":["apps"]},"delete":{"operationId":"AuthConfigProviderController_delete","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple"],"type":"string"}}],"responses":{"204":{"description":"Deleted (or already absent)."},"400":{"description":"Unknown provider id."}},"summary":"Remove the config for an (app, provider). Existing identity rows (people who already signed in with this provider) stay; they just lose the ability to sign in again until config is re-uploaded.","tags":["apps"]}},"/v1/apps/{app_id}/roles":{"post":{"operationId":"RoleController_createRole","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleDto"}}}},"responses":{"201":{"description":"Role created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleResponseDto"}}}}},"summary":"Create a new role (no permissions yet — set them via PUT /:roleName/permissions)","tags":["roles"]},"get":{"operationId":"RoleController_listRoles","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated role list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleListResponseDto"}}}}},"summary":"List all roles in an app","tags":["roles"]}},"/v1/apps/{app_id}/roles/permissions":{"get":{"operationId":"RoleController_listPermissions","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppPermissionCatalogEntryDto"}}}}}},"summary":"List all available permissions","tags":["roles"]}},"/v1/apps/{app_id}/roles/{role_name}":{"get":{"operationId":"RoleController_getRole","parameters":[{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleWithPermissionsResponseDto"}}}}},"summary":"Get role details with permissions","tags":["roles"]},"patch":{"operationId":"RoleController_updateRole","parameters":[{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRoleDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleResponseDto"}}}}},"summary":"Update role name or description","tags":["roles"]},"delete":{"operationId":"RoleController_deleteRole","parameters":[{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Role deleted."}},"summary":"Delete a custom role","tags":["roles"]}},"/v1/apps/{app_id}/roles/{role_name}/permissions":{"put":{"operationId":"RoleController_setPermissions","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"role_name","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPermissionsDto"}}}},"responses":{"200":{"description":"Permissions updated (empty body)."}},"summary":"Set permissions for a role (caller-narrowed: caller must hold every requested permission)","tags":["roles"]}},"/v1/apps/{app_id}/roles/assign":{"post":{"operationId":"RoleController_assignRole","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleDto"}}}},"responses":{"200":{"description":"Membership updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppMembershipResponseDto"}}}}},"summary":"Assign a role to a user","tags":["roles"]}},"/v1/apps/{app_id}/permissions":{"post":{"operationId":"PermissionController_createPermission","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePermissionDto"}}}},"responses":{"201":{"description":"Permission created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PermissionDto"}}}}},"summary":"Create a custom permission","tags":["permissions"]},"get":{"operationId":"PermissionController_listPermissions","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Flat list — system + per-app permissions.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PermissionDto"}}}}}},"summary":"List all permissions (system + custom)","tags":["permissions"]}},"/v1/apps/{app_id}/permissions/enforcement":{"get":{"description":"Returns the deduplicated set of permission strings that any handler in this heimdall-api build gates via `@RequireAppPermission`. Drives the console \"Enforced\" badge — a customer who binds `user.delete` to a role can see whether any route in this deploy actually checks for it. App-agnostic in shape; the path parameter is included for URN symmetry with the rest of the per-app surface, not to narrow the response.","operationId":"PermissionController_listEnforcedPermissions","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnforcedPermissionsResponseDto"}}}}},"summary":"List permissions that at least one Consumer-API route enforces","tags":["permissions"]}},"/v1/apps/{app_id}/permissions/{permission_key}":{"delete":{"operationId":"PermissionController_deletePermission","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"permission_key","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Permission deleted."}},"summary":"Delete a custom permission","tags":["permissions"]}},"/v1/apps/{app_id}/credentials":{"post":{"operationId":"M2mController_createClient","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Optional Stripe-style idempotency key. Same key + same body → cached response. Same key + different body → 409 IDEMPOTENCY_KEY_REUSE. 24h TTL. Non-replayable artefacts (plaintext M2M secret) make this strongly recommended for IaC retries.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateM2mClientDto"}}}},"responses":{"201":{"description":"M2M client created. Plaintext secret is returned ONCE in this response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/M2mClientCreateResponseDto"}}}},"400":{"description":"Validation failure (missing or invalid name)."},"403":{"description":"Caller email not verified, or lacks `heimdall.create`."},"404":{"description":"App not found or caller is not a member."}},"summary":"Create a new M2M client","tags":["credentials"]},"get":{"operationId":"M2mController_listClients","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/M2mClientListResponseDto"}}}}},"summary":"List M2M clients","tags":["credentials"]}},"/v1/apps/{app_id}/credentials/{client_id}":{"get":{"operationId":"M2mController_getClient","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"client_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/M2mClientDetailResponseDto"}}}}},"summary":"Get M2M client details","tags":["credentials"]},"patch":{"operationId":"M2mController_updateClient","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"client_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateM2mClientDto"}}}},"responses":{"200":{"description":"M2M client status updated (empty body)."}},"summary":"Update M2M client status","tags":["credentials"]},"delete":{"operationId":"M2mController_deleteClient","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"client_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"M2M client deleted."},"403":{"description":"Caller lacks `heimdall.delete`."},"404":{"description":"M2M client not found in this app."}},"summary":"Delete an M2M client","tags":["credentials"]}},"/v1/apps/{app_id}/credentials/{client_id}/rotate":{"post":{"operationId":"M2mController_rotateSecret","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"client_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"New secret generated and returned ONCE.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/M2mRotateSecretResponseDto"}}}},"403":{"description":"Caller lacks `heimdall.rotate-secret`."},"404":{"description":"M2M client not found in this app."}},"summary":"Rotate M2M client secret","tags":["credentials"]}},"/v1/apps/{app_id}/credentials/{client_id}/scopes":{"put":{"operationId":"M2mController_setScopes","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"client_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetScopesDto"}}}},"responses":{"200":{"description":"Scopes updated (empty body)."}},"summary":"Set scopes for an M2M client","tags":["credentials"]}},"/v1/apps/{app_id}/audit-logs":{"get":{"operationId":"AppAuditController_getAuditLogs","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"action","required":false,"in":"query","schema":{"type":"string"}},{"name":"actor_id","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Page of audit entries with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditLogListResponseDto"}}}},"403":{"description":"Caller lacks `heimdall.audit.read`."},"404":{"description":"App not found or caller is not a member."}},"summary":"Append-only audit log for this app. Filter by `?action=` and/or `?actor_id=`. 50/page (max 200), cursor-paginated.","tags":["app-audit"]}},"/v1/apps/{app_id}/end-users":{"get":{"operationId":"EndUserController_listEndUsers","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","schema":{"type":"string"}},{"name":"search","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserListResponseDto"}}}}},"summary":"List end-users in this app (cursor-paginated).","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}":{"get":{"operationId":"EndUserController_getEndUser","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserSummaryDto"}}}}},"summary":"Get one end-user.","tags":["end-users"]},"patch":{"operationId":"EndUserController_updateEndUser","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEndUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserSummaryDto"}}}}},"summary":"Update end-user display name or primary email.","tags":["end-users"]},"delete":{"operationId":"EndUserController_deleteEndUser","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"End-user deleted."}},"summary":"Delete an end-user permanently.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/status":{"patch":{"operationId":"EndUserController_updateStatus","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEndUserStatusDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserSummaryDto"}}}}},"summary":"Change end-user status (active / suspended / deactivated).","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/role":{"patch":{"operationId":"EndUserController_updateRole","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEndUserRoleDto"}}}},"responses":{"200":{"description":"Membership updated."}},"summary":"Assign an end-user a role.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/sessions/revoke-all":{"post":{"operationId":"EndUserController_revokeAllSessions","parameters":[{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Sessions revoked."}},"summary":"Revoke every active session for an end-user.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/resend-verification":{"post":{"operationId":"EndUserController_resendVerification","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssuedVerificationCodeResponseDto"}}}}},"summary":"Resend verification to the primary email contact.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/bulk-revoke-sessions":{"post":{"operationId":"EndUserController_bulkRevokeSessions","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkRevokeSessionsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkAffectedUsersResponseDto"}}}}},"summary":"Bulk-revoke sessions across a selection.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/bulk-update-status":{"post":{"operationId":"EndUserController_bulkUpdateStatus","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkUpdateStatusDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkAffectedUsersResponseDto"}}}}},"summary":"Bulk-update status across a selection.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/bulk-update-role":{"post":{"operationId":"EndUserController_bulkUpdateRole","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkUpdateRoleDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkAffectedUsersResponseDto"}}}}},"summary":"Bulk-assign a role across a selection.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/bulk-delete":{"post":{"operationId":"EndUserController_bulkDelete","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkDeleteDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkAffectedUsersResponseDto"}}}}},"summary":"Bulk-delete accounts across a selection.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/contacts":{"get":{"operationId":"EndUserController_listContacts","parameters":[{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountContactListResponseDto"}}}}},"summary":"List an end-user's contacts.","tags":["end-users"]},"post":{"operationId":"EndUserController_addContact","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddContactDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountContactDto"}}}}},"summary":"Add a contact for an end-user (unverified).","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/contacts/{contact_id}":{"delete":{"operationId":"EndUserController_deleteContact","parameters":[{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"contact_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Contact deleted."}},"summary":"Delete a contact from an end-user.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/contacts/{contact_id}/promote":{"post":{"operationId":"EndUserController_promoteContact","parameters":[{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"contact_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Contact promoted."}},"summary":"Promote a verified contact to primary.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/{user_id}/contacts/{contact_id}/resend-verification":{"post":{"operationId":"EndUserController_resendContactVerification","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"contact_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssuedVerificationCodeResponseDto"}}}}},"summary":"Reissue a verification code for a contact.","tags":["end-users"]}},"/v1/apps/{app_id}/api-keys":{"post":{"operationId":"ApiKeyController_createApiKey","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. The plaintext API key is returned exactly once; an IaC retry without this header creates a second key the customer never sees the plaintext for. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyDto"}}}},"responses":{"201":{"description":"New API key with raw secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponseDto"}}}}},"summary":"Mint a new hdk_live_* API key with an explicit permission scope. The raw key is returned ONCE.","tags":["api-keys"]},"get":{"operationId":"ApiKeyController_listApiKeys","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"List of API keys (raw secret omitted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyListResponseDto"}}}}},"summary":"List API keys for an app (no raw secret).","tags":["api-keys"]}},"/v1/apps/{app_id}/api-keys/{key_id}":{"delete":{"operationId":"ApiKeyController_deleteApiKey","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"key_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"API key deleted."}},"summary":"Revoke (delete) an API key.","tags":["api-keys"]}},"/v1/stats/me":{"get":{"description":"Returns total apps the caller belongs to, end-users across those apps, and active API keys across those apps. Pass `workspaceId` to scope the counts to a single workspace — required from the dashboard so cross-tenant memberships do not bleed into the totals.","operationId":"StatsController_getMyStats","parameters":[{"name":"workspace_id","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Aggregate counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformUserStatsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Aggregate counts for the signed-in PlatformUser","tags":["Platform · Stats"]}},"/v1/apps/{app_id}/notification-settings":{"get":{"operationId":"NotificationSettingsController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Settings row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppNotificationSettingsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Read the per-app notification settings. Absent row reads as defaults (notifications_via_envoi: false, every template slot null).","tags":["app-notification-settings"]},"patch":{"operationId":"NotificationSettingsController_update","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAppNotificationSettingsDto"}}}},"responses":{"200":{"description":"Updated settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppNotificationSettingsResponseDto"}}}},"400":{"description":"Invalid payload."}},"security":[{"bearer":[]}],"summary":"Update the per-app notification settings. Each field is independently optional; null clears that slot.","tags":["app-notification-settings"]}},"/{app_slug}/v1/tenants":{"post":{"operationId":"ConsumerTenantsController_create","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantDto"}}}},"responses":{"201":{"description":"Tenant created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTenantResponseDto"}}}},"400":{"description":"Invalid display_name or slug."},"401":{"description":"PAK missing."},"403":{"description":"PAK policy denies action."},"404":{"description":"App not found."},"409":{"description":"Slug already taken in this app."}},"security":[{"bearer":[]}],"summary":"Create a tenant inside this app. Returns the new tenant with a `display_id` (`tnt_<12hex>`). PAK must hold heimdall.tenant.create.","tags":["Consumer · Tenants"]},"get":{"operationId":"ConsumerTenantsController_list","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"default":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","schema":{"enum":["active","suspended","archived"],"type":"string"}}],"responses":{"200":{"description":"List page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTenantListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List tenants in this app, paginated by created_at desc. PAK must hold heimdall.tenant.read.","tags":["Consumer · Tenants"]}},"/{app_slug}/v1/tenants/{tenant_id}":{"get":{"operationId":"ConsumerTenantsController_get","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTenantResponseDto"}}}},"404":{"description":"Tenant not found."}},"security":[{"bearer":[]}],"summary":"Fetch a single tenant by its UUID or `tnt_<12hex>` display id. PAK must hold heimdall.tenant.read.","tags":["Consumer · Tenants"]},"patch":{"operationId":"ConsumerTenantsController_update","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTenantDto"}}}},"responses":{"200":{"description":"Updated tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTenantResponseDto"}}}},"400":{"description":"Invalid patch."},"404":{"description":"Tenant not found."},"409":{"description":"Slug collision."}},"security":[{"bearer":[]}],"summary":"Patch a tenant. Any subset of {display_name, slug, status, metadata}. PAK must hold heimdall.tenant.update.","tags":["Consumer · Tenants"]},"delete":{"operationId":"ConsumerTenantsController_delete","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted."},"404":{"description":"Tenant not found."}},"security":[{"bearer":[]}],"summary":"Delete a tenant. Cascades to tenant_membership rows; account rows are unaffected. PAK must hold heimdall.tenant.delete.","tags":["Consumer · Tenants"]}},"/{app_slug}/v1/tenants/{tenant_id}/members":{"get":{"operationId":"ConsumerTenantsController_listMembers","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"default":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"List page of members.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTenantMembershipListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List members of a tenant. Cursor-paginated by joined_at desc. PAK must hold heimdall.tenant.read.","tags":["Consumer · Tenants"]},"post":{"operationId":"ConsumerTenantsController_addMember","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMemberDto"}}}},"responses":{"201":{"description":"Membership row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAddMemberResponseDto"}}}},"400":{"description":"Invalid body (missing accountId/email or unknown role)."},"404":{"description":"Tenant or account not found."},"409":{"description":"Account is already a member of this tenant."}},"security":[{"bearer":[]}],"summary":"Add a member to a tenant. Pass `accountId` for an existing user, or `email` (+ optional `display_name`/`username`) to auto-create the account inline. PAK must hold heimdall.tenant.members.add.","tags":["Consumer · Tenants"]}},"/{app_slug}/v1/tenants/{tenant_id}/members/{account_id}":{"patch":{"operationId":"ConsumerTenantsController_updateMemberRole","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","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":"Updated membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTenantMembershipResponseDto"}}}},"400":{"description":"Unknown role."},"404":{"description":"Tenant or membership not found."}},"security":[{"bearer":[]}],"summary":"Change a member's role. PAK must hold heimdall.tenant.members.update-role.","tags":["Consumer · Tenants"]},"delete":{"operationId":"ConsumerTenantsController_removeMember","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"account_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Removed."},"404":{"description":"Tenant or membership not found."}},"security":[{"bearer":[]}],"summary":"Remove a member from a tenant. The underlying account row stays in the app — only the tenant_membership row is deleted. PAK must hold heimdall.tenant.members.remove.","tags":["Consumer · Tenants"]}},"/v1/apps/{app_id}/tenants":{"post":{"operationId":"HeimdallTenantsController_create","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. Tenant creation mints a fresh `tnt_<...>` public id that downstream audit + webhook fan-out reference; a retry without this header creates a second tenant your IaC can't consolidate. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantDto"}}}},"responses":{"201":{"description":"Created tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Create a tenant in this app. Heimdall-admin lane; requires heimdall.tenant.create on the app URN.","tags":["Heimdall · Tenants"]},"get":{"operationId":"HeimdallTenantsController_list","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"default":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","schema":{"enum":["active","suspended","archived"],"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List tenants in this app, paginated by created_at desc.","tags":["Heimdall · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}":{"get":{"operationId":"HeimdallTenantsController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Get a single tenant by id or display id.","tags":["Heimdall · Tenants"]},"patch":{"operationId":"HeimdallTenantsController_update","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTenantDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Update a tenant (display_name, slug, status, metadata).","tags":["Heimdall · Tenants"]},"delete":{"operationId":"HeimdallTenantsController_delete","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Tenant deleted."}},"security":[{"bearer":[]}],"summary":"Delete a tenant.","tags":["Heimdall · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}/members":{"get":{"operationId":"HeimdallTenantsController_listMembers","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"default":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List members of a tenant.","tags":["Heimdall · Tenants"]},"post":{"operationId":"HeimdallTenantsController_addMember","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMemberDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMemberResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Add a member to a tenant.","tags":["Heimdall · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}/members/{account_id}":{"patch":{"operationId":"HeimdallTenantsController_updateMemberRole","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","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/TenantMembershipResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Change a member's role inside a tenant.","tags":["Heimdall · Tenants"]},"delete":{"operationId":"HeimdallTenantsController_removeMember","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"account_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Membership removed."}},"security":[{"bearer":[]}],"summary":"Remove a member from a tenant.","tags":["Heimdall · Tenants"]}},"/v1/apps/{app_id}/webhook":{"get":{"operationId":"AppOutboundWebhookController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Config row, or null if no webhook is configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookConfigResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Read the per-app outbound webhook config (or null if none). Plaintext secret is NOT returned — only the last-4 hint. Use rotate-secret to get a fresh plaintext.","tags":["app-outbound-webhooks"]},"put":{"operationId":"AppOutboundWebhookController_upsert","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertAppWebhookDto"}}}},"responses":{"200":{"description":"Upserted config + plaintext secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookConfigWithSecretResponseDto"}}}},"400":{"description":"Invalid URL or event type."}},"security":[{"bearer":[]}],"summary":"Create or replace the outbound webhook for this app. Returns the plaintext signing secret ONCE — store it now. Updates clear any auto-disabled state.","tags":["app-outbound-webhooks"]},"delete":{"operationId":"AppOutboundWebhookController_delete","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Webhook configuration deleted."}},"security":[{"bearer":[]}],"summary":"Remove the outbound webhook config for this app. No more events fire after this returns.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/rotate-secret":{"post":{"operationId":"AppOutboundWebhookController_rotateSecret","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Rotated config + new plaintext.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookConfigWithSecretResponseDto"}}}},"404":{"description":"No webhook configured."}},"security":[{"bearer":[]}],"summary":"Rotate the signing secret. Returns the new plaintext ONCE; the old secret stops working immediately.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/attempts":{"get":{"operationId":"AppOutboundWebhookController_listAttempts","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"List of recent attempts (no pagination — limit-capped).","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppWebhookDeliveryAttemptDto"}}}}}},"security":[{"bearer":[]}],"summary":"Recent webhook delivery attempts for this app. Includes status code, latency, response body (truncated to 1KB), and error if any.","tags":["app-outbound-webhooks"]}}},"info":{"title":"Heimdall API","description":"Multi-tenant authentication for ProductCraft customers. The Consumer API (`/{appSlug}/v1/*`) is what external apps integrate to sign in their EndUsers; the Heimdall-admin Platform API (`/v1/*`) is for managing the Heimdall apps themselves and is signed in via PlatformUser tokens.","version":"1.0.0","contact":{}},"tags":[],"servers":[],"components":{"securitySchemes":{"bearer":{"scheme":"bearer","bearerFormat":"JWT","type":"http"}},"schemas":{"JwkDto":{"type":"object","properties":{"kid":{"type":"string","example":"rs256-moc6k64k-b2214480"},"kty":{"type":"string","example":"RSA"},"alg":{"type":"string","example":"RS256"},"use":{"type":"string","enum":["sig"],"example":"sig"},"n":{"type":"string","description":"RSA modulus (base64url)"},"e":{"type":"string","description":"RSA exponent (base64url)","example":"AQAB"}},"required":["kid","kty","alg","use","n","e"]},"JwksResponseDto":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/JwkDto"}}},"required":["keys"]},"OpenIdConfigurationDto":{"type":"object","properties":{"issuer":{"type":"string","example":"heimdall","description":"Token `iss` claim. Verifier libraries should accept this literal — we don't issue tokens with a URL-shaped issuer because tokens are per-app, not per-host."},"jwks_uri":{"type":"string","example":"https://api.heimdall.productcraft.co/<slug>/v1/.well-known/jwks.json"},"token_endpoint":{"type":"string","example":"https://api.heimdall.productcraft.co/<slug>/v1/oauth/token"},"introspection_endpoint":{"type":"string","example":"https://api.heimdall.productcraft.co/<slug>/v1/oauth/introspect"},"userinfo_endpoint":{"type":"string","example":"https://api.heimdall.productcraft.co/<slug>/v1/me","description":"OIDC userinfo equivalent — returns the signed-in EndUser profile."},"heimdall_verify_endpoint":{"type":"string","description":"Heimdall-specific verify endpoint. Not part of standard OIDC.","example":"https://api.heimdall.productcraft.co/<slug>/v1/verify"},"heimdall_authorize_endpoint":{"type":"string","description":"Heimdall-specific authorize endpoint. Not part of standard OIDC.","example":"https://api.heimdall.productcraft.co/<slug>/v1/authorize"},"heimdall_admin_users_endpoint":{"type":"string","description":"Customer-product admin lane root. M2M-callable user CRUD.","example":"https://api.heimdall.productcraft.co/<slug>/v1/admin/users"},"grant_types_supported":{"example":["client_credentials"],"type":"array","items":{"type":"string"}},"response_types_supported":{"example":["token"],"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"example":["client_secret_post"],"type":"array","items":{"type":"string"}},"id_token_signing_alg_values_supported":{"example":["RS256"],"type":"array","items":{"type":"string"}},"subject_types_supported":{"example":["public"],"type":"array","items":{"type":"string"}}},"required":["issuer","jwks_uri","token_endpoint","introspection_endpoint","userinfo_endpoint","heimdall_verify_endpoint","heimdall_authorize_endpoint","heimdall_admin_users_endpoint","grant_types_supported","response_types_supported","token_endpoint_auth_methods_supported","id_token_signing_alg_values_supported","subject_types_supported"]},"ConsumerMfaChallengeFactorDto":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","description":"Factor type — currently `totp`; widens as new factor types ship.","example":"totp"},"label":{"type":"string","nullable":true}},"required":["id","type","label"]},"ConsumerMfaChallengeResponseDto":{"type":"object","properties":{"mfa_required":{"type":"boolean","description":"Always `true` on this response. Distinguishes it from the normal token response shape.","example":true},"mfa_token":{"type":"string","description":"Single-use challenge id. Pass back as `mfa_token` on `/auth/mfa/verify` or `/auth/mfa/recover` to complete the sign-in. 5-minute TTL."},"expires_at":{"type":"string","format":"date-time","description":"When the challenge expires (ISO 8601, UTC)."},"factors":{"description":"Factors the user can satisfy this challenge with (any-of).","type":"array","items":{"$ref":"#/components/schemas/ConsumerMfaChallengeFactorDto"}}},"required":["mfa_required","mfa_token","expires_at","factors"]},"ConsumerSignupDto":{"type":"object","properties":{"username":{"type":"string","description":"Unique username inside the app. 3+ chars.","example":"jane_doe"},"email":{"type":"string","description":"EndUser email address. Becomes the primary email contact and the default verification target.","example":"jane@example.com"},"password":{"type":"string","description":"Password. Must satisfy the app-configured policy (default: 8+ chars).","example":"CorrectHorseBatteryStaple"},"display_name":{"type":"string","description":"Optional display name shown back to the user.","example":"Jane Doe"}},"required":["username","email","password"]},"ConsumerTokenResponseDto":{"type":"object","properties":{"access_token":{"type":"string","description":"Short-lived JWT signed with the app-scoped JWKS key. Carries sub, role, permissions[], type=end_user."},"refresh_token":{"type":"string","description":"Long-lived opaque-ish JWT used to mint a new access token via /auth/refresh. Rotated on use."},"token_type":{"type":"string","example":"Bearer"},"expires_in":{"type":"number","description":"Access-token lifetime in seconds.","example":3600}},"required":["access_token","refresh_token","token_type","expires_in"]},"ConsumerSigninDto":{"type":"object","properties":{"identifier":{"type":"string","description":"Username or verified primary email — either accepted. Secondary or unverified emails do not authenticate.","example":"jane_doe"},"password":{"type":"string","description":"EndUser password."},"tenant_id":{"type":"string","description":"Optional tenant UUID. When set, the issued JWT carries `tid` + `trole` claims and the user must already be a member of that tenant — non-membership returns 403."}},"required":["identifier","password"]},"ConsumerMfaVerifyDto":{"type":"object","properties":{"mfa_token":{"type":"string","description":"The `mfa_token` returned by `/auth/signin`. UUID."},"code":{"type":"string","description":"A current TOTP code (6 digits).","example":"123456"}},"required":["mfa_token","code"]},"ConsumerMfaRecoverDto":{"type":"object","properties":{"mfa_token":{"type":"string","description":"The `mfa_token` returned by `/auth/signin`. UUID."},"recovery_code":{"type":"string","description":"A single-use recovery code (16 hex chars, hyphens optional). Consumes the code on success — the user should regenerate the set afterwards via `/me/mfa/recovery-codes/regenerate`.","example":"1a2b-3c4d-5e6f-7890"}},"required":["mfa_token","recovery_code"]},"ConsumerRefreshDto":{"type":"object","properties":{"refresh_token":{"type":"string","description":"Refresh token returned from signin/signup/previous refresh. Each refresh rotates the token; the previous one is revoked."}},"required":["refresh_token"]},"ConsumerLogoutDto":{"type":"object","properties":{"refresh_token":{"type":"string","description":"Refresh token to revoke. The matching session is destroyed; the access token continues to verify until its TTL expires."}},"required":["refresh_token"]},"ConsumerRequestVerificationDto":{"type":"object","properties":{"email":{"type":"string","description":"Email contact value to target. Set this OR `phone`, not both.","example":"jane@example.com"},"phone":{"type":"string","description":"Phone contact value (any format the customer normalised; recommended E.164). Set this OR `email`, not both.","example":"+15551234567"}}},"ConsumerCodeIssueResponseDto":{"type":"object","properties":{"code":{"type":"string","description":"Plaintext 6-digit code. Returned when the contact matches an EndUser in this app and the precondition is satisfied (contact unverified, for verification; contact verified, for reset). Otherwise the response is `{}` (no code) — same shape on both branches to prevent enumeration.","example":"123456"},"expires_at":{"type":"string","description":"Code expiry, ISO 8601. Codes live for 10 minutes.","example":"2026-05-09T17:30:00.000Z"}},"required":["code","expires_at"]},"ConsumerSendVerificationEmailDto":{"type":"object","properties":{"email":{"type":"string","description":"Email contact value to send the verification code to.","example":"jane@example.com"}},"required":["email"]},"ConsumerCodeDispatchResponseDto":{"type":"object","properties":{"expires_at":{"type":"string","description":"Code expiry, ISO 8601. The plaintext code is NOT returned — Envoi has dispatched it.","example":"2026-05-09T17:30:00.000Z"}},"required":["expires_at"]},"ConsumerVerifyDto":{"type":"object","properties":{"code":{"type":"string","description":"6-digit numeric verification code. The server resolves which contact the code is for from the code value itself.","example":"123456"}},"required":["code"]},"ConsumerVerifyResponseDto":{"type":"object","properties":{"account_id":{"type":"string","description":"EndUser account id whose contact was verified."},"contact_id":{"type":"string","description":"Account_contact id that just flipped to verified."},"type":{"type":"string","description":"Contact type: `email` or `phone`.","example":"email"},"value":{"type":"string","description":"Normalised contact value.","example":"jane@example.com"},"verified_at":{"type":"string","description":"Verification timestamp, ISO 8601."}},"required":["account_id","contact_id","type","value","verified_at"]},"ConsumerRequestPasswordResetDto":{"type":"object","properties":{"email":{"type":"string","description":"Email contact value to target. Set this OR `phone`, not both.","example":"jane@example.com"},"phone":{"type":"string","description":"Phone contact value (any format the customer normalised; recommended E.164). Set this OR `email`, not both.","example":"+15551234567"}}},"ConsumerSendPasswordResetEmailDto":{"type":"object","properties":{"email":{"type":"string","description":"Email contact value to send the password-reset code to.","example":"jane@example.com"}},"required":["email"]},"ConsumerResetPasswordDto":{"type":"object","properties":{"code":{"type":"string","description":"6-digit numeric code from the password-reset message.","example":"123456"},"new_password":{"type":"string","description":"New password. Must satisfy the app-configured policy."}},"required":["code","new_password"]},"ConsumerSwitchTenantDto":{"type":"object","properties":{"tenant_id":{"type":"string","description":"Tenant UUID to switch the EndUser's session into. Caller must already hold a tenant_membership row for this tenant in the app, else 403."}},"required":["tenant_id"]},"TenantMembershipForMeDto":{"type":"object","properties":{"tenant_id":{"type":"string"},"display_id":{"type":"string","example":"tnt_3a9f2b1c0d4e"},"tenant_slug":{"type":"string"},"display_name":{"type":"string"},"role_name":{"type":"string"},"joined_at":{"type":"string","format":"date-time"}},"required":["tenant_id","display_id","tenant_slug","display_name","role_name","joined_at"]},"MyTenantsResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TenantMembershipForMeDto"}}},"required":["data"]},"MeProfileResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"EndUser account id (UUID)."},"username":{"type":"string","description":"Username inside the app.","example":"ada"},"display_name":{"type":"string","nullable":true,"example":"Ada Lovelace"},"role":{"type":"string","description":"App-level role name. Resolves per-request from the JWT.","example":"member","nullable":true},"joined_at":{"type":"string","description":"When the EndUser joined the app.","format":"date-time"},"created_at":{"type":"string","description":"When the account row was created.","format":"date-time"},"email":{"type":"string","nullable":true,"description":"Primary email contact value. `null` if the user has no email contact.","example":"ada@example.com"},"email_verified_at":{"type":"string","nullable":true,"format":"date-time","description":"When the primary email was verified. `null` until verification completes."}},"required":["id","username","display_name","role","joined_at","created_at","email","email_verified_at"]},"MePermissionsResponseDto":{"type":"object","properties":{"role":{"type":"string","description":"App-level role name from the EndUser's token. `null` when unset.","example":"admin","nullable":true},"org_role":{"type":"string","description":"Tenant-scoped role name from the token when signed into a tenant. `null` otherwise.","example":"editor","nullable":true},"permissions":{"description":"Sorted union of the app-level role permissions and the tenant-scoped role permissions when applicable.","example":["user.read","user.list"],"type":"array","items":{"type":"string"}}},"required":["role","org_role","permissions"]},"UpdateMeDto":{"type":"object","properties":{"display_name":{"type":"string","description":"New display name. Pass an empty string to clear it; omit to leave unchanged.","example":"Ada Lovelace","minLength":0,"maxLength":120}}},"ChangePasswordDto":{"type":"object","properties":{"current_password":{"type":"string","description":"The current password — required even when changing."},"new_password":{"type":"string","description":"The new password. Must be at least 8 chars; the app may enforce a stricter policy."}},"required":["current_password","new_password"]},"MeSessionDto":{"type":"object","properties":{"id":{"type":"string"},"ip":{"type":"string","nullable":true},"user_agent":{"type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"},"last_used_at":{"format":"date-time","type":"string","nullable":true},"expires_at":{"format":"date-time","type":"string"},"is_current":{"type":"boolean","description":"True iff this is the session backing the current request."}},"required":["id","ip","user_agent","created_at","last_used_at","expires_at","is_current"]},"MeSessionsResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MeSessionDto"}},"pagination":{"type":"object","description":"{ next_cursor, has_more } — pagination fixed to a single page."}},"required":["data","pagination"]},"MeActivityEntryDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"action":{"type":"string","example":"auth.session.created"},"resource_type":{"type":"string","nullable":true},"resource_id":{"type":"string","nullable":true},"actor_id":{"type":"string","nullable":true},"details":{"type":"object","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","app_id","action","resource_type","resource_id","actor_id","details","created_at"]},"MeActivityResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MeActivityEntryDto"}},"pagination":{"type":"object"}},"required":["data","pagination"]},"MeContactDto":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["email","phone"]},"value":{"type":"string"},"is_primary":{"type":"boolean"},"verified_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","type","value","is_primary","verified_at","created_at"]},"MeContactsResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MeContactDto"}}},"required":["data"]},"AddContactDto":{"type":"object","properties":{"type":{"type":"string","enum":["email","phone"],"example":"email"},"value":{"type":"string","description":"Email or phone value (E.164 recommended for phone)"},"is_primary":{"type":"boolean","description":"Whether to flag the new contact as primary for its type. Demotes the previous primary of the same type. Default false."}},"required":["type","value"]},"VerifyBody":{"type":"object","properties":{"token":{"type":"string","description":"EndUser or M2M JWT to verify against the app JWKS."}},"required":["token"]},"VerifyPrincipalDto":{"type":"object","properties":{"sub":{"type":"string","description":"Subject — EndUser account id or M2M client id."},"aid":{"type":"string","description":"App UUID this token is bound to."},"role":{"type":"string","description":"App-level role name when EndUser."},"permissions":{"description":"Permission claim — only set on M2M tokens.","type":"array","items":{"type":"string"}},"type":{"type":"string","description":"Principal type, e.g. `end_user` or `m2m`."}},"required":["sub","aid","type"]},"VerifyResponseDto":{"type":"object","properties":{"valid":{"type":"boolean","description":"True iff the signature, issuer, audience, and session are all valid."},"error":{"type":"string","description":"Failure code on `valid: false` — e.g. `TOKEN_INVALID`, `TOKEN_EXPIRED`, `TOKEN_REVOKED`, `ACCOUNT_SUSPENDED`."},"principal":{"$ref":"#/components/schemas/VerifyPrincipalDto"}},"required":["valid"]},"AuthorizeBody":{"type":"object","properties":{"token":{"type":"string","description":"JWT to verify and authorize."},"permission":{"type":"string","description":"Single permission to check, e.g. 'user.read'. Use this OR `permissions`."},"permissions":{"description":"Multiple permissions; the token must hold ALL of them.","type":"array","items":{"type":"string"}}},"required":["token"]},"AuthorizeResponseDto":{"type":"object","properties":{"authorized":{"type":"boolean"},"error":{"type":"string","description":"Failure code on `authorized: false`."},"missing_permissions":{"description":"Permissions the token does not hold.","type":"array","items":{"type":"string"}}},"required":["authorized"]},"AuthorizeBatchEntry":{"type":"object","properties":{"permissions":{"description":"Permissions for this check; ALL must be held.","type":"array","items":{"type":"string"}}},"required":["permissions"]},"AuthorizeBatchBody":{"type":"object","properties":{"token":{"type":"string","description":"JWT to verify and authorize against each check."},"checks":{"description":"List of checks. Each returns { authorized, missing_permissions }.","type":"array","items":{"$ref":"#/components/schemas/AuthorizeBatchEntry"}}},"required":["token","checks"]},"AuthorizeBatchResultDto":{"type":"object","properties":{"authorized":{"type":"boolean"},"missing_permissions":{"type":"array","items":{"type":"string"}}},"required":["authorized","missing_permissions"]},"AuthorizeBatchResponseDto":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/AuthorizeBatchResultDto"}}},"required":["results"]},"ClientCredentialsDto":{"type":"object","properties":{"grant_type":{"type":"string","description":"OAuth grant type. Only 'client_credentials' is supported.","example":"client_credentials"},"client_id":{"type":"string","description":"M2M client identifier (m2m_*).","example":"m2m_a1b2c3d4e5f6..."},"client_secret":{"type":"string","description":"M2M client secret (issued at client creation, never returned again)."}},"required":["grant_type","client_id","client_secret"]},"OAuthTokenResponseDto":{"type":"object","properties":{"access_token":{"type":"string","description":"Access token (RS256 JWT signed with the app JWKS key)."},"token_type":{"type":"string","example":"Bearer"},"expires_in":{"type":"number","description":"Access-token lifetime in seconds.","example":3600},"scope":{"type":"string","description":"Space-separated scopes, RFC 6749.","example":"user.read user.list"}},"required":["access_token","token_type","expires_in","scope"]},"IntrospectDto":{"type":"object","properties":{"token":{"type":"string","description":"The access token to introspect. Optional — when omitted the endpoint introspects the `Authorization: Bearer` token. When present, it must match the bearer (we refuse cross-token introspection so a stolen token cannot be probed against a different active credential)."},"token_type_hint":{"type":"string","description":"Optional `token_type_hint` per RFC 7662. We only issue access tokens at this endpoint so the only meaningful value is `access_token`; the field is accepted for client-library compatibility.","example":"access_token"}}},"IntrospectResponseDto":{"type":"object","properties":{"active":{"type":"boolean","description":"Per RFC 7662: `true` if the token is currently active for this app, `false` otherwise. An inactive response is the default for any token we can't validate — invalid signature, expired, revoked session, wrong app — and intentionally carries no other claims so an attacker can't probe the token's shape.","example":true},"sub":{"type":"string","example":"m2m_a1b2c3d4...","description":"Subject claim: M2M client id or EndUser account id."},"client_id":{"type":"string","example":"cli_a1b2c3...","description":"OAuth `client_id` (M2M only)."},"type":{"type":"string","enum":["m2m","end_user"]},"scopes":{"example":["user.read","user.list"],"description":"Flat scope list (M2M only).","type":"array","items":{"type":"string"}},"scope":{"type":"string","example":"user.read user.list","description":"Space-separated `scope` string per OAuth 2.0 RFC 6749 — alias of `scopes` joined with \" \"."},"exp":{"type":"number","example":1746906606,"description":"Token expiry as Unix timestamp."},"iat":{"type":"number","example":1746903006,"description":"Token issued-at Unix timestamp."},"iss":{"type":"string","example":"heimdall","description":"Issuer."},"aid":{"type":"string","example":"app-uuid","description":"App UUID this token is bound to."}},"required":["active"]},"AdminCreateUserDto":{"type":"object","properties":{"email":{"type":"string","description":"Primary email — becomes the account's primary email contact (unverified)."},"username":{"type":"string","description":"Username inside the app. Derived from the email local-part if omitted."},"display_name":{"type":"string","description":"Human-readable display name."},"password":{"type":"string","description":"Optional initial password. When omitted, the account has no credential — the customer triggers the password-reset flow to onboard the user."},"role_name":{"type":"string","description":"Role to assign. Defaults to the app's `signup_default_role`."}},"required":["email"]},"ConsumerAdminCreatedUserDto":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"email":{"type":"string"},"email_verified":{"type":"boolean"},"role":{"type":"string","nullable":true},"status":{"type":"string"},"created_at":{"format":"date-time","type":"string"}},"required":["id","username","display_name","email","email_verified","role","status","created_at"]},"ConsumerAdminEndUserDto":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"status":{"type":"string"},"role":{"type":"string","nullable":true},"joined_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"},"email":{"type":"string","nullable":true},"email_verified_at":{"format":"date-time","type":"string","nullable":true},"active_session_count":{"type":"number"},"last_used_at":{"type":"string","format":"date-time","nullable":true}},"required":["id","username","display_name","status","role","joined_at","created_at","email","email_verified_at","active_session_count","last_used_at"]},"PaginationDto":{"type":"object","properties":{"next_cursor":{"type":"string","description":"Opaque cursor for the next page. `null` when no more pages are available.","nullable":true,"example":"eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wMVQwMDowMDowMFoifQ"},"has_more":{"type":"boolean","description":"True iff another page exists; if true, pass `next_cursor` on the next request.","example":false}},"required":["next_cursor","has_more"]},"ConsumerAdminEndUserListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerAdminEndUserDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"AdminUpdateUserDto":{"type":"object","properties":{"display_name":{"type":"string","description":"New display name. Empty string clears."},"email":{"type":"string","description":"New primary email address. Replaces the current primary email contact (still unverified after the change)."}}},"AdminUpdateStatusDto":{"type":"object","properties":{"status":{"type":"string","enum":["active","suspended","deactivated"]}},"required":["status"]},"AdminUpdateRoleDto":{"type":"object","properties":{"role_name":{"type":"string","description":"Target role name (must already exist in this app)."}},"required":["role_name"]},"CreateRoleDto":{"type":"object","properties":{"name":{"type":"string","example":"editor"},"description":{"type":"string","example":"Can edit content"}},"required":["name"]},"ConsumerRoleResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_system":{"type":"boolean"},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","app_id","name","description","is_system","created_at","updated_at"]},"ConsumerRoleListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerRoleResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"ConsumerRolePermissionEntryDto":{"type":"object","properties":{"id":{"type":"string"},"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"},"description":{"type":"string","nullable":true}},"required":["id","resource","action","description"]},"ConsumerRoleWithPermissionsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_system":{"type":"boolean"},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"},"permissions":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerRolePermissionEntryDto"}}},"required":["id","app_id","name","description","is_system","created_at","updated_at","permissions"]},"UpdateRoleDto":{"type":"object","properties":{"description":{"type":"string","example":"Updated description"}}},"SetPermissionsDto":{"type":"object","properties":{"permissions":{"description":"Array of permission strings in resource.action format","example":["user.read","role.create"],"type":"array","items":{"type":"string"}}},"required":["permissions"]},"CreatePermissionDto":{"type":"object","properties":{"resource":{"type":"string","example":"project","description":"Resource name"},"action":{"type":"string","example":"read","description":"Action name"},"description":{"type":"string","example":"View projects"}},"required":["resource","action"]},"ConsumerAdminPermissionDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string","description":"App scope. `null` for system permissions.","nullable":true},"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"},"description":{"type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","app_id","resource","action","description","created_at"]},"ConsumerAdminPermissionListEntryDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string","description":"App scope. `null` for system permissions.","nullable":true},"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"},"description":{"type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"},"is_system":{"type":"boolean","description":"True iff the row is a system (shared) permission."}},"required":["id","app_id","resource","action","description","created_at","is_system"]},"PublicFactorDto":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","description":"Factor type. Currently `totp` is the only supported value; future factor types (webauthn, etc.) will extend this list without a breaking change to the response shape.","example":"totp"},"label":{"type":"string","nullable":true},"enabled":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"enabled_at":{"type":"string","format":"date-time","nullable":true}},"required":["id","type","label","enabled","created_at","enabled_at"]},"FactorListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicFactorDto"}}},"required":["data"]},"StartEnrollmentDto":{"type":"object","properties":{"type":{"type":"string","enum":["totp"],"example":"totp"},"label":{"type":"string","description":"Optional human-readable label shown in the authenticator app and on the factor list (e.g. \"iPhone 15\").","maxLength":64}},"required":["type"]},"AdminProvisionFactorResponseDto":{"type":"object","properties":{"factor":{"$ref":"#/components/schemas/PublicFactorDto"},"enrollment":{"type":"object"},"recovery_codes":{"description":"Cleartext single-use recovery codes minted alongside the factor.","type":"array","items":{"type":"string"}}},"required":["factor","enrollment","recovery_codes"]},"TotpEnrollmentDataDto":{"type":"object","properties":{"otpauth_uri":{"type":"string","example":"otpauth://totp/dispute.markets:ada?secret=..."},"secret":{"type":"string","description":"Base32-encoded shared secret. Customers should not surface this in plaintext; the URI + QR are the intended delivery vehicles."},"qr_data_url":{"type":"string","description":"data:image/png;base64,... PNG QR encoding of the otpauth URI."},"algorithm":{"type":"string","example":"SHA1"},"digits":{"type":"number","example":6},"period":{"type":"number","example":30}},"required":["otpauth_uri","secret","qr_data_url","algorithm","digits","period"]},"StartEnrollmentResponseDto":{"type":"object","properties":{"factor":{"$ref":"#/components/schemas/PublicFactorDto"},"enrollment":{"description":"Type-specific enrollment payload. Shape depends on `factor.type` — for TOTP this is the TotpEnrollmentData shape shown.","allOf":[{"$ref":"#/components/schemas/TotpEnrollmentDataDto"}]}},"required":["factor","enrollment"]},"ConfirmEnrollmentDto":{"type":"object","properties":{"codes":{"description":"Two codes from consecutive 30-second windows of the authenticator app. The second must come from a different period than the first.","example":["123456","654321"],"minItems":2,"maxItems":2,"type":"array","items":{"type":"string"}}},"required":["codes"]},"ConfirmEnrollmentResponseDto":{"type":"object","properties":{"factor":{"$ref":"#/components/schemas/PublicFactorDto"},"recovery_codes":{"description":"Single-use recovery codes for this account. Shown once — store them now.","example":["a1b2-c3d4-e5f6-7890","1234-5678-9abc-def0"],"type":"array","items":{"type":"string"}}},"required":["factor","recovery_codes"]},"RecoveryCodesResponseDto":{"type":"object","properties":{"recovery_codes":{"type":"array","items":{"type":"string"}}},"required":["recovery_codes"]},"StepUpDto":{"type":"object","properties":{"code":{"type":"string","description":"TOTP code (6 digits) OR a recovery code (16 hex chars, hyphenated or not).","example":"123456"}},"required":["code"]},"StepUpResponseDto":{"type":"object","properties":{"amr":{"example":["pwd","totp"],"type":"array","items":{"type":"string"}},"mfa_at":{"type":"string","format":"date-time"}},"required":["amr","mfa_at"]},"ExchangeRequestDto":{"type":"object","properties":{"code":{"type":"string","description":"The opaque `heimdall_code` the customer received on the OAuth callback `return_to`. Single-use; expires after 60 seconds.","minLength":16,"maxLength":256}},"required":["code"]},"ExchangeResponseDto":{"type":"object","properties":{"access_token":{"type":"string"},"refresh_token":{"type":"string"},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"number","description":"Lifetime of the access token in seconds."}},"required":["access_token","refresh_token","token_type","expires_in"]},"IdpNativeUserHintDto":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"}}},"IdpNativeSigninDto":{"type":"object","properties":{"id_token":{"type":"string","description":"Provider-issued ID token (JWT). For Apple, the value returned by `ASAuthorizationAppleIDCredential.identityToken` after UTF-8 decoding the data.","minLength":16,"maxLength":8192},"nonce":{"type":"string","description":"Raw nonce the client generated and SHA-256-hashed into the authorize request. Backend recomputes the hash and compares to the token's `nonce` claim. Must be at least 32 bytes of entropy (base64url-encoded ~43 chars).","minLength":16,"maxLength":256},"user":{"description":"Apple-specific first-signin payload: { name, email }. Persist for display only — the verified JWT is the source of truth.","allOf":[{"$ref":"#/components/schemas/IdpNativeUserHintDto"}]}},"required":["id_token","nonce"]},"IdpTokenResponseDto":{"type":"object","properties":{"access_token":{"type":"string"},"refresh_token":{"type":"string"},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"number","description":"Lifetime of the access token in seconds."}},"required":["access_token","refresh_token","token_type","expires_in"]},"CallbackBodyDto":{"type":"object","properties":{}},"AcceptInviteDto":{"type":"object","properties":{"code":{"type":"string","description":"Invite code from the invite email / link.","example":"inv_x7H2k…"}},"required":["code"]},"AppResponseDto":{"type":"object","properties":{"id":{"type":"string"},"slug":{"type":"string","example":"acme-app"},"display_name":{"type":"string","example":"Acme"},"workspace_id":{"type":"string","description":"Owning workspace UUID."},"status":{"type":"string","enum":["active","suspended","archived"]},"created_by":{"type":"string","description":"Account that created the app."},"metadata":{"type":"object"},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","slug","display_name","workspace_id","status","created_by","metadata","created_at","updated_at"]},"AppListItemDto":{"type":"object","properties":{"id":{"type":"string"},"slug":{"type":"string","example":"acme-app"},"display_name":{"type":"string","example":"Acme"},"workspace_id":{"type":"string","description":"Owning workspace UUID."},"status":{"type":"string","enum":["active","suspended","archived"]},"created_by":{"type":"string","description":"Account that created the app."},"metadata":{"type":"object"},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"},"role_id":{"type":"string","description":"The current PlatformUser's app-membership role id."}},"required":["id","slug","display_name","workspace_id","status","created_by","metadata","created_at","updated_at","role_id"]},"AppListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AppListItemDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"CreateAppDto":{"type":"object","properties":{"slug":{"type":"string","description":"URL-friendly app slug (lowercase letters, digits, hyphens).","example":"acme-app","minLength":3,"maxLength":64},"display_name":{"type":"string","description":"Display name shown in the console.","example":"Acme","minLength":1,"maxLength":120},"workspace_id":{"type":"string","description":"UUID of the platform workspace that will own the new app. The caller must hold `heimdall.create` on this workspace.","example":"835015dc-7bde-4a8a-b306-c066d4733b90"}},"required":["slug","display_name","workspace_id"]},"UpdateAppDto":{"type":"object","properties":{"display_name":{"type":"string","description":"New display name","example":"Acme Corp"},"slug":{"type":"string","description":"Rename the app's slug. The old slug remains resolvable for 30 days as a JWKS-URL alias so existing customer-side verifiers don't break instantly. Same shape as the create-slug: lowercase alphanumeric + hyphens, 3–48 chars.","example":"acme-corp"},"metadata":{"type":"object","description":"Arbitrary metadata attached to the app","example":{"plan":"pro","region":"us-east-1"}}}},"UpdateAppStatusDto":{"type":"object","properties":{"status":{"type":"string","description":"New app status","enum":["active","suspended","archived"],"example":"suspended"}},"required":["status"]},"AppMemberSummaryDto":{"type":"object","properties":{"id":{"type":"string"},"account_id":{"type":"string"},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"role_id":{"type":"string"},"role_name":{"type":"string"},"joined_at":{"format":"date-time","type":"string"},"primary_email":{"type":"string","nullable":true}},"required":["id","account_id","username","display_name","role_id","role_name","joined_at","primary_email"]},"AppMemberListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AppMemberSummaryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"CreateInviteDto":{"type":"object","properties":{"role":{"type":"string","description":"Role slug to grant on accept (e.g. `admin`, `member`).","example":"member"},"email":{"type":"string","description":"Optional invitee email. If set, the invite is locked to that address.","example":"alice@example.com"},"max_uses":{"type":"number","description":"Maximum number of times this invite can be accepted. Defaults to 1.","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}},"required":["role"]},"AppInviteResponseDto":{"type":"object","properties":{"id":{"type":"string"},"code":{"type":"string","description":"Invite code (used to accept)."},"role":{"type":"string","example":"member"},"email":{"type":"string","nullable":true},"max_uses":{"type":"number","nullable":true},"use_count":{"type":"number"},"expires_at":{"format":"date-time","type":"string","nullable":true},"revoked_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","code","role","email","max_uses","use_count","expires_at","revoked_at","created_at"]},"AppInviteListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AppInviteResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"AuthConfigResponseDto":{"type":"object","properties":{"signup_enabled":{"type":"boolean"},"signin_enabled":{"type":"boolean"},"signup_allowed_email_domains":{"type":"array","items":{"type":"string"}},"signup_default_role":{"type":"string","example":"member"},"session_duration_minutes":{"type":"number","example":10080},"max_failed_login_attempts":{"type":"number","example":5},"password_min_length":{"type":"number","example":12},"password_require_uppercase":{"type":"boolean"},"password_require_number":{"type":"boolean"},"password_require_symbol":{"type":"boolean"},"max_sessions":{"type":"number","example":5},"enforce_app_permissions":{"type":"boolean"},"oauth_link_policy":{"type":"string","enum":["auto","confirm","reject"]},"allowed_redirect_origins":{"type":"array","items":{"type":"string"}}},"required":["signup_enabled","signin_enabled","signup_allowed_email_domains","signup_default_role","session_duration_minutes","max_failed_login_attempts","password_min_length","password_require_uppercase","password_require_number","password_require_symbol","max_sessions","enforce_app_permissions","oauth_link_policy","allowed_redirect_origins"]},"UpdateAuthConfigDto":{"type":"object","properties":{"signup_enabled":{"type":"boolean","description":"Whether new EndUsers can sign up."},"signin_enabled":{"type":"boolean","description":"Whether existing EndUsers can sign in."},"signup_allowed_email_domains":{"description":"Optional allowlist of email domains for signup. Empty array = any domain.","example":["acme.com","acme.io"],"type":"array","items":{"type":"string"}},"signup_default_role":{"type":"string","description":"Role slug newly-signed-up EndUsers receive (e.g. `member`).","example":"member"},"session_duration_minutes":{"type":"number","description":"Session lifetime in minutes (1 minute to 30 days).","example":10080,"minimum":1,"maximum":43200},"max_failed_login_attempts":{"type":"number","description":"Failed-login attempts before the EndUser account is locked.","example":5,"minimum":1,"maximum":100},"password_min_length":{"type":"number","description":"Minimum password length (NIST minimum is 8).","example":12,"minimum":8,"maximum":128},"password_require_uppercase":{"type":"boolean","description":"Require an uppercase letter in passwords."},"password_require_number":{"type":"boolean","description":"Require a digit in passwords."},"password_require_symbol":{"type":"boolean","description":"Require a symbol in passwords."},"max_sessions":{"type":"number","description":"Maximum concurrent sessions per EndUser. Oldest is evicted on overflow.","example":5,"minimum":1,"maximum":50},"enforce_app_permissions":{"type":"boolean","description":"When true, Consumer-API routes annotated with `@RequireAppPermission(perm)` enforce the requested perm against the EndUser's role-resolved permission set. When false (default), permission checks are bypassed — any valid token can call any annotated route. Flip on once you've authored roles + permissions for this app and want them to actually mean something."},"oauth_link_policy":{"type":"string","description":"How to handle a federated sign-in (Apple, Google, …) that lands on a verified email already belonging to an account in this app. `confirm` (default) refuses to auto-link and surfaces `link_required` so the user signs in with their original method. `auto` silently links when the provider claims the email is verified AND the email is not a private relay. `reject` refuses outright. Private-relay emails never auto-link regardless.","enum":["auto","confirm","reject"]},"allowed_redirect_origins":{"description":"Allowed origins for the `return_to` parameter on the OAuth web redirect callback. Exact origin match (scheme + host + port); no paths, no wildcards. Empty array disables the web redirect flow for this app. Prevents the callback from acting as an open-redirect.","example":["https://app.example.com"],"type":"array","items":{"type":"string"}}}},"ProviderListEntryDto":{"type":"object","properties":{"provider":{"type":"string","example":"apple"},"display_name":{"type":"string","example":"Apple"},"configured":{"type":"boolean","description":"True if there is a config row for this (app, provider)."},"enabled":{"type":"boolean","description":"True only when configured AND the row is enabled. Sign-in attempts against an unconfigured / disabled provider return 503."},"updated_at":{"type":"string","nullable":true}},"required":["provider","display_name","configured","enabled"]},"ProviderDetailDto":{"type":"object","properties":{"provider":{"type":"string"},"enabled":{"type":"boolean"},"config":{"type":"object","description":"Customer-safe view of the stored config. Public identifiers and metadata only — secrets (Apple private key, Google client secret, …) NEVER appear in the response. `private_key_present: true` is the only acknowledgement that a key is on file."},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["provider","enabled","config","created_at","updated_at"]},"UpsertProviderConfigDto":{"type":"object","properties":{"config":{"type":"object","description":"Provider-specific raw config blob. Shape is validated by the provider impl's `parseConfig`. For Apple: { service_id, bundle_ids: string[], team_id, key_id, private_key_pem }."},"enabled":{"type":"boolean","description":"Whether this provider is live on the sign-in surface. Default false — write the config first, flip to true once you have verified end-to-end in a test app."}},"required":["config"]},"RoleResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"Role UUID."},"app_id":{"type":"string","description":"App scope (UUID)."},"name":{"type":"string","description":"Role name slug.","example":"admin"},"description":{"type":"string","description":"Free-text description.","nullable":true},"is_system":{"type":"boolean","description":"Whether this is a system role (owner / admin / member)."},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","app_id","name","description","is_system","created_at","updated_at"]},"RoleListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RoleResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"AppPermissionCatalogEntryDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string","description":"App scope. `null` for system permissions.","nullable":true},"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"},"description":{"type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"},"is_system":{"type":"boolean","description":"True iff the row is a system (shared) permission."}},"required":["id","app_id","resource","action","description","created_at","is_system"]},"RolePermissionEntryDto":{"type":"object","properties":{"id":{"type":"string"},"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"},"description":{"type":"string","nullable":true}},"required":["id","resource","action","description"]},"RoleWithPermissionsResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"Role UUID."},"app_id":{"type":"string","description":"App scope (UUID)."},"name":{"type":"string","description":"Role name slug.","example":"admin"},"description":{"type":"string","description":"Free-text description.","nullable":true},"is_system":{"type":"boolean","description":"Whether this is a system role (owner / admin / member)."},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"},"permissions":{"type":"array","items":{"$ref":"#/components/schemas/RolePermissionEntryDto"}}},"required":["id","app_id","name","description","is_system","created_at","updated_at","permissions"]},"AssignRoleDto":{"type":"object","properties":{"account_id":{"type":"string","description":"Account ID of the user"},"role":{"type":"string","description":"Role name to assign","example":"admin"}},"required":["account_id","role"]},"AppMembershipResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string","description":"App UUID."},"account_id":{"type":"string","description":"Account UUID."},"role_id":{"type":"string","description":"Role UUID."},"joined_at":{"format":"date-time","type":"string"}},"required":["id","app_id","account_id","role_id","joined_at"]},"PermissionDto":{"type":"object","properties":{"id":{"type":"string","description":"Permission row UUID."},"app_id":{"type":"string","description":"App scope. `null` for system permissions.","nullable":true},"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"},"description":{"type":"string","description":"Free-text description of what this permission allows.","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","app_id","resource","action","description","created_at"]},"EnforcedPermissionsResponseDto":{"type":"object","properties":{"data":{"description":"Deduplicated set of `<resource>.<action>` permission strings that at least one Consumer-API route gates via `@RequireAppPermission`. App-agnostic — the same heimdall-api binary serves every app, so the set does not vary by `:appId`. Lets the console surface an \"Enforced\" badge so customers can tell which catalog entries do real work.","example":["session.revoke","user.list"],"type":"array","items":{"type":"string"}}},"required":["data"]},"CreateM2mClientDto":{"type":"object","properties":{"name":{"type":"string","description":"Client display name","example":"CI Pipeline"}},"required":["name"]},"M2mClientCreateResponseDto":{"type":"object","properties":{"id":{"type":"string"},"client_id":{"type":"string","example":"m2m_a1b2c3d4..."},"client_secret":{"type":"string","description":"Plaintext client secret. Returned exactly once."},"name":{"type":"string"},"created_at":{"format":"date-time","type":"string"}},"required":["id","client_id","client_secret","name","created_at"]},"M2mClientSummaryDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"client_id":{"type":"string"},"name":{"type":"string"},"is_active":{"type":"boolean"},"last_used_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","app_id","client_id","name","is_active","last_used_at","created_at"]},"M2mClientListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/M2mClientSummaryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"M2mScopeEntryDto":{"type":"object","properties":{"resource":{"type":"string","example":"user"},"action":{"type":"string","example":"read"}},"required":["resource","action"]},"M2mClientDetailResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"client_id":{"type":"string"},"name":{"type":"string"},"is_active":{"type":"boolean"},"last_used_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/M2mScopeEntryDto"}}},"required":["id","app_id","client_id","name","is_active","last_used_at","created_at","scopes"]},"M2mRotateSecretResponseDto":{"type":"object","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string","description":"New plaintext client secret. Returned exactly once."}},"required":["client_id","client_secret"]},"SetScopesDto":{"type":"object","properties":{"permissions":{"description":"Permission strings in resource.action format to assign as scopes","example":["user.read","app.read"],"type":"array","items":{"type":"string"}}},"required":["permissions"]},"UpdateM2mClientDto":{"type":"object","properties":{"is_active":{"type":"boolean","description":"Set active or inactive"}}},"AuditLogEntryDto":{"type":"object","properties":{"id":{"type":"string","description":"Audit row UUID."},"app_id":{"type":"string","description":"App this audit row belongs to."},"action":{"type":"string","description":"Canonical action string (e.g. `app.member.removed`, `role.created`).","example":"app.member.removed"},"resource_type":{"type":"string","description":"Resource type touched (e.g. `app_membership`, `role`).","nullable":true},"resource_id":{"type":"string","description":"Resource id touched.","nullable":true},"actor_id":{"type":"string","description":"Account id that triggered the action. `null` for `system` actor type.","nullable":true},"details":{"type":"object","description":"Free-form structured detail object captured at log time.","nullable":true},"created_at":{"format":"date-time","type":"string","description":"ISO 8601 timestamp."}},"required":["id","app_id","action","resource_type","resource_id","actor_id","details","created_at"]},"AuditLogListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogEntryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"EndUserSummaryDto":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"display_name":{"type":"string","nullable":true},"status":{"type":"string","enum":["active","suspended","deactivated"]},"role":{"type":"string","nullable":true},"joined_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"},"email":{"type":"string","nullable":true},"email_verified_at":{"format":"date-time","type":"string","nullable":true},"active_session_count":{"type":"number"},"last_used_at":{"type":"string","format":"date-time","nullable":true}},"required":["id","username","display_name","status","role","joined_at","created_at","email","email_verified_at","active_session_count","last_used_at"]},"EndUserListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EndUserSummaryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"UpdateEndUserDto":{"type":"object","properties":{"display_name":{"type":"string","description":"New display name"},"email":{"type":"string","description":"New primary email"}}},"UpdateEndUserStatusDto":{"type":"object","properties":{"status":{"type":"string","description":"New end-user status","enum":["active","suspended","deactivated"],"example":"suspended"}},"required":["status"]},"UpdateEndUserRoleDto":{"type":"object","properties":{"role_name":{"type":"string","description":"Name of the role to assign","example":"admin"}},"required":["role_name"]},"IssuedVerificationCodeResponseDto":{"type":"object","properties":{"code":{"type":"string","description":"Plaintext verification code.","example":"123456"},"expires_at":{"type":"string","format":"date-time"}},"required":["code","expires_at"]},"BulkSelectionDto":{"type":"object","properties":{"mode":{"type":"string","description":"Selection strategy. `ids` operates on an explicit list of account ids (capped at 5,000). `filter` operates on every account matching the same status / search filter the list endpoint exposes — uncapped.","enum":["ids","filter"]},"ids":{"description":"Required when mode=ids. Account ids to operate on.","maxItems":5000,"type":"array","items":{"type":"string"}},"status":{"type":"string","description":"mode=filter only. Same status filter as GET /end-users.","enum":["active","suspended","deactivated"]},"search":{"type":"string","description":"mode=filter only. Same search filter as GET /end-users."}},"required":["mode"]},"BulkRevokeSessionsDto":{"type":"object","properties":{"selection":{"$ref":"#/components/schemas/BulkSelectionDto"}},"required":["selection"]},"BulkAffectedUsersResponseDto":{"type":"object","properties":{"affected_users":{"type":"number","description":"Number of accounts (distinct ids) affected by the bulk write."}},"required":["affected_users"]},"BulkUpdateStatusDto":{"type":"object","properties":{"selection":{"$ref":"#/components/schemas/BulkSelectionDto"},"status":{"type":"string","enum":["active","suspended","deactivated"],"example":"suspended"}},"required":["selection","status"]},"BulkUpdateRoleDto":{"type":"object","properties":{"selection":{"$ref":"#/components/schemas/BulkSelectionDto"},"role_name":{"type":"string","description":"Role name to assign to all selected users"}},"required":["selection","role_name"]},"BulkDeleteDto":{"type":"object","properties":{"selection":{"$ref":"#/components/schemas/BulkSelectionDto"},"confirm_all_in_app":{"type":"boolean","description":"Defence-in-depth confirmation for the destructive case where selection is `mode: 'filter'` with no `status` and no `search` (i.e. matches every account in the app). The server rejects that combination with 400 unless this flag is `true`. Set explicitly when you really mean \"delete every end-user in this app\"."}},"required":["selection"]},"AccountContactDto":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["email","phone"]},"value":{"type":"string"},"is_primary":{"type":"boolean"},"verified_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","type","value","is_primary","verified_at","created_at"]},"AccountContactListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AccountContactDto"}}},"required":["data"]},"CreateApiKeyDto":{"type":"object","properties":{"name":{"type":"string","description":"Human-readable label for this key (shown in the API keys list).","example":"CI deploy bot","minLength":1,"maxLength":120},"permissions":{"description":"Permission scope for the key. Each entry must match `resource.action` (lowercase + underscores).","example":["user.read","user.list"],"minItems":1,"type":"array","items":{"type":"string"}}},"required":["name","permissions"]},"CreateApiKeyResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"API key row UUID."},"name":{"type":"string","description":"Display name.","example":"CI deploy bot"},"key":{"type":"string","description":"Raw API key. Returned exactly once — store it now.","example":"hdk_live_aZxYbpKL..."},"key_prefix":{"type":"string","description":"First 16 chars of the key — safe to display in lists.","example":"hdk_live_aZxYbpKL"},"permissions":{"description":"Permission scope.","example":["user.read","user.list"],"type":"array","items":{"type":"string"}},"created_at":{"format":"date-time","type":"string"}},"required":["id","name","key","key_prefix","permissions","created_at"]},"ApiKeySummaryDto":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","example":"CI deploy bot"},"key_prefix":{"type":"string","description":"Prefix only — full key is never returned after creation."},"permissions":{"example":["user.read","user.list"],"type":"array","items":{"type":"string"}},"last_used_at":{"format":"date-time","type":"string","nullable":true},"created_at":{"format":"date-time","type":"string"}},"required":["id","name","key_prefix","permissions","last_used_at","created_at"]},"ApiKeyListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeySummaryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"PlatformUserStatsResponseDto":{"type":"object","properties":{"apps":{"type":"number","description":"Heimdall apps the caller is a member of (any role).","example":3},"end_users":{"type":"number","description":"EndUsers across those apps.","example":1247},"api_keys_active":{"type":"number","description":"API keys across those apps. Rows are hard-deleted on revoke so existence implies active.","example":8}},"required":["apps","end_users","api_keys_active"]},"AppNotificationSettingsResponseDto":{"type":"object","properties":{"app_id":{"type":"string","description":"App UUID."},"notifications_via_envoi":{"type":"boolean","description":"Master switch — when true, every populated template slot fires through the workspace Envoi domain."},"default_sender_address":{"type":"string","description":"Verified sender email used as the From envelope; `null` when unset.","nullable":true},"verification_template_name":{"type":"string","description":"Envoi template fired for end-user email verification; `null` when unset.","nullable":true},"welcome_template_name":{"type":"string","description":"Envoi template fired right after a successful verification.","nullable":true},"password_reset_template_name":{"type":"string","description":"Envoi template fired on password-reset request.","nullable":true},"web_origin":{"type":"string","description":"Public origin of the customer's portal used to render clickable buttons in verification + password-reset emails (`https://host[:port]` or `http://localhost[:port]`); `null` when unset.","nullable":true},"created_at":{"format":"date-time","type":"string","description":"Row created at, ISO 8601."},"updated_at":{"format":"date-time","type":"string","description":"Row last updated at, ISO 8601."}},"required":["app_id","notifications_via_envoi","default_sender_address","verification_template_name","welcome_template_name","password_reset_template_name","web_origin","created_at","updated_at"]},"UpdateAppNotificationSettingsDto":{"type":"object","properties":{"notifications_via_envoi":{"type":"boolean","description":"Master switch. When false (default) no notification mail goes out — customers handle email via webhooks. When true, every populated template slot fires through the workspace Envoi domain."},"default_sender_address":{"type":"string","description":"Verified sender email used as the From envelope. Must belong to a verified domain owned by this app's workspace. null clears the override.","nullable":true},"verification_template_name":{"type":"string","description":"Envoi template name fired when an end-user needs to verify their email. Must already exist in the workspace template store.","nullable":true},"welcome_template_name":{"type":"string","description":"Envoi template name fired right after an end-user verifies their email (welcome message).","nullable":true},"password_reset_template_name":{"type":"string","description":"Envoi template name fired when an end-user requests a password reset.","nullable":true},"web_origin":{"type":"string","description":"Public origin of the customer's portal (e.g. `https://portal.dispute.markets`). Used to build clickable buttons in the verification + password-reset emails. Must be `https://host[:port]` or `http://localhost[:port]`; no path, no userinfo. Null clears the override and falls back to code-only delivery.","nullable":true}}},"CreateTenantDto":{"type":"object","properties":{"display_name":{"type":"string","description":"Human-readable display name shown in the customer UI.","example":"Acme Co","minLength":1,"maxLength":200},"slug":{"type":"string","description":"Optional URL-safe slug, unique per app (case-insensitive). When omitted, derived from display_name; collisions retried with a random suffix.","example":"acme-co","pattern":"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$"},"metadata":{"type":"object","description":"Free-form metadata; opaque to Heimdall. Customer-controlled.","example":{"plan":"pro","region":"eu-west"}}},"required":["display_name"]},"ConsumerTenantResponseDto":{"type":"object","properties":{"id":{"type":"string"},"display_id":{"type":"string","example":"tnt_3a9f2b1c0d4e"},"app_id":{"type":"string"},"slug":{"type":"string","example":"acme-org"},"display_name":{"type":"string","example":"Acme Org"},"status":{"type":"string","enum":["active","suspended","archived"]},"metadata":{"type":"object"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","display_id","app_id","slug","display_name","status","metadata","created_at","updated_at"]},"ConsumerTenantListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerTenantResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"UpdateTenantDto":{"type":"object","properties":{"display_name":{"type":"string","minLength":1,"maxLength":200},"slug":{"type":"string","pattern":"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$"},"status":{"type":"string","enum":["active","suspended","archived"]},"metadata":{"type":"object"}}},"ConsumerTenantMembershipResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tenant_id":{"type":"string"},"account_id":{"type":"string"},"role_id":{"type":"string"},"role_name":{"type":"string"},"joined_at":{"type":"string","format":"date-time"},"invited_by":{"type":"string","nullable":true}},"required":["id","tenant_id","account_id","role_id","role_name","joined_at","invited_by"]},"ConsumerTenantMembershipListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerTenantMembershipResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"AddMemberDto":{"type":"object","properties":{"account_id":{"type":"string","description":"UUID of an existing EndUser account in this app. Use this OR `email`."},"email":{"type":"string","description":"Email address; auto-creates an account when not yet known to this app. Use this OR `accountId`."},"display_name":{"type":"string","description":"Display name for the auto-created account.","maxLength":200},"username":{"type":"string","description":"Username for the auto-created account; derived from email when omitted. Lowercased; non-alphanumerics stripped.","maxLength":64},"role_name":{"type":"string","description":"Role name from this app's `role` table; case-insensitive lookup.","example":"member"}},"required":["role_name"]},"ConsumerAddMemberResponseDto":{"type":"object","properties":{"membership":{"$ref":"#/components/schemas/ConsumerTenantMembershipResponseDto"},"account_id":{"type":"string"},"created_account":{"type":"boolean"}},"required":["membership","account_id","created_account"]},"UpdateMemberRoleDto":{"type":"object","properties":{"role_name":{"type":"string","description":"New role name from the app's `role` table.","example":"admin"}},"required":["role_name"]},"TenantResponseDto":{"type":"object","properties":{"id":{"type":"string"},"display_id":{"type":"string","description":"Customer-facing prefixed identifier.","example":"tnt_3a9f2b1c0d4e"},"app_id":{"type":"string"},"slug":{"type":"string","example":"acme-org"},"display_name":{"type":"string","example":"Acme Org"},"status":{"type":"string","enum":["active","suspended","archived"]},"metadata":{"type":"object"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","display_id","app_id","slug","display_name","status","metadata","created_at","updated_at"]},"TenantListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TenantResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"TenantMembershipResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tenant_id":{"type":"string"},"account_id":{"type":"string"},"role_id":{"type":"string"},"role_name":{"type":"string"},"joined_at":{"type":"string","format":"date-time"},"invited_by":{"type":"string","nullable":true}},"required":["id","tenant_id","account_id","role_id","role_name","joined_at","invited_by"]},"TenantMembershipListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TenantMembershipResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"AddMemberResponseDto":{"type":"object","properties":{"membership":{"$ref":"#/components/schemas/TenantMembershipResponseDto"},"account_id":{"type":"string","description":"Account UUID (existing or newly created)."},"created_account":{"type":"boolean","description":"True when a new account was created inline (only possible via the email branch)."}},"required":["membership","account_id","created_account"]},"AppWebhookConfigResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"url":{"type":"string","example":"https://customer.example.com/webhooks/heimdall"},"event_types":{"example":["user.email_verified","tenant.created"],"type":"array","items":{"type":"string"}},"signing_secret_hint":{"type":"string","description":"Last 4 chars of the signing secret."},"disabled_at":{"type":"string","format":"date-time","nullable":true},"disabled_reason":{"type":"string","nullable":true},"consecutive_failures":{"type":"number"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","app_id","url","event_types","signing_secret_hint","disabled_at","disabled_reason","consecutive_failures","created_at","updated_at"]},"UpsertAppWebhookDto":{"type":"object","properties":{"url":{"type":"string","description":"Destination URL. Must be absolute https:// with a public TLD.","example":"https://api.acme.com/webhooks/heimdall","maxLength":2048},"event_types":{"type":"array","description":"Event types this destination subscribes to.","example":["user.signup","user.email_verified"],"items":{"type":"string","enum":["user.signup","user.signin","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","tenant.created","tenant.updated","tenant.deleted","tenant.member.added","tenant.member.removed","tenant.member.role_changed","app.slug_changed","user.mfa.factor_enrolled","user.mfa.factor_disabled","user.mfa.recovery_code_used","user.mfa.step_up_succeeded","user.mfa.step_up_locked"]}}},"required":["url","event_types"]},"AppWebhookConfigWithSecretResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"url":{"type":"string","example":"https://customer.example.com/webhooks/heimdall"},"event_types":{"example":["user.email_verified","tenant.created"],"type":"array","items":{"type":"string"}},"signing_secret_hint":{"type":"string","description":"Last 4 chars of the signing secret."},"disabled_at":{"type":"string","format":"date-time","nullable":true},"disabled_reason":{"type":"string","nullable":true},"consecutive_failures":{"type":"number"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"signing_secret":{"type":"string","description":"Plaintext signing secret. Returned exactly once."}},"required":["id","app_id","url","event_types","signing_secret_hint","disabled_at","disabled_reason","consecutive_failures","created_at","updated_at","signing_secret"]},"AppWebhookDeliveryAttemptDto":{"type":"object","properties":{"id":{"type":"string"},"event_id":{"type":"string"},"event_type":{"type":"string","example":"user.email_verified"},"attempt_number":{"type":"number"},"status_code":{"type":"number","nullable":true},"error_message":{"type":"string","nullable":true},"latency_ms":{"type":"number","nullable":true},"attempted_at":{"format":"date-time","type":"string"},"url":{"type":"string"}},"required":["id","event_id","event_type","attempt_number","status_code","error_message","latency_ms","attempted_at","url"]}}}}