{"openapi":"3.0.0","paths":{"/{app_slug}/.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}/.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. When the app has completed Mail notification setup, a verification code is auto-emailed to the address; customers delivering their own verification messages call /auth/request-verification (or /auth/send-verification-email) instead — doing so invalidates the auto-sent code, so the user only ever has one valid code. Subject to the app-configured signup policy (signup enabled, password policy, default role, optional `signup_requires_pak`). The password must clear the app's length + character-class rules, a common-password deny-list, and — unless the app sets `password_breach_check` to `warn` or `off` — a public breach corpus; a breached password is refused with 400 `PASSWORD_BREACHED` and a deny-listed one with 400 `PASSWORD_TOO_COMMON`. The breach lookup is k-anonymous (only five characters of the password's SHA-1 leave the service) and fails open, so an unreachable corpus never blocks a signup.\n\nWhen the app runs `auth_config.mfa: \"required\"`, the account is still created but NO session is minted: the response is the `ConsumerMfaEnrollmentRequiredResponseDto` shape (`mfa_enrollment_required: true`), completed via `POST /auth/mfa/enroll` + `/auth/mfa/enroll/verify`. Inside `mfa_grace_days` of account creation — which a fresh signup always is when the window is non-zero — the ordinary token pair is returned with `mfa_enrollment_due_at` set instead.","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":{"201":{"description":"Account created. A session was minted unless `mfa_enrollment_required` is present — branch on that flag.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ConsumerTokenResponseDto"},{"$ref":"#/components/schemas/ConsumerMfaEnrollmentRequiredResponseDto"}]}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"The app sets `signup_requires_pak` and no Platform API Key was presented on `Authorization: Bearer`."},"403":{"description":"Signup is disabled for this app, the email domain is not on the app's allow-list, the presented PAK belongs to a different workspace, or its policy denies `auth.signup.create` on this app. Also returned when the app itself is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`)."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"409":{"description":"Email or username already in use in this app, or the app has no workspace binding and therefore cannot accept a PAK signup."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Sign up an EndUser","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/accept-invite":{"post":{"description":"Materialises a pre-allocated invite into a real EndUser: creates account + primary email contact (verified, since mailing the code proved control) + password credential + role membership, then mints app-scoped tokens. Public endpoint — the invite code is the authority. 410 on revoked/expired/exhausted invites; 409 on duplicate email or username. The same password policy as `/auth/signup` applies, including the common-password deny-list and the breach check, and so does the app-level MFA mandate: on an `auth_config.mfa: \"required\"` app the account is created but the response is `mfa_enrollment_required: true` with no session behind it.","operationId":"ConsumerAuthController_acceptInvite","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAcceptInviteDto"}}}},"responses":{"201":{"description":"Account created from the invite. A session was minted unless `mfa_enrollment_required` is present — branch on that flag.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ConsumerTokenResponseDto"},{"$ref":"#/components/schemas/ConsumerMfaEnrollmentRequiredResponseDto"}]}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"Invite code not found in this app, or the `:appSlug` matches no app at all. Deliberately the same status — neither says anything about accounts."},"409":{"description":"Email or username already in use."},"410":{"description":"Invite revoked, expired, or fully used."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Accept an EndUser invite by code","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/accept-tenant-invite":{"post":{"description":"Materialises a pending `tenant_invite` into a real EndUser: creates account + verified primary email contact (mailing the code proved control of the address) + password credential + an app-level membership on the app's signup default role + a tenant membership on the role the invite names, then mints a session already scoped to that tenant, so the access token carries `org_id` / `org_role` with no follow-up switch-tenant call. Public endpoint — the invite code is the authority. 410 on revoked/expired/exhausted invites. **409 when an account already exists for the invited address**: this endpoint will not set a password on, or mint a session for, an account it did not create, because the plaintext code is also held by whoever minted the invite. Existing users sign in and call `POST /:appSlug/v1/me/tenant-invites/accept` instead, which adds the membership without touching credentials.","operationId":"ConsumerAuthController_acceptTenantInvite","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAcceptTenantInviteDto"}}}},"responses":{"201":{"description":"Account created from the invite and a tenant-scoped session minted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"Invite code not found in this app, or the `:appSlug` matches no app at all. Deliberately the same status — neither says anything about accounts."},"409":{"description":"An account already exists for the invited address (`ACCOUNT_EXISTS`), or the requested username is taken."},"410":{"description":"Invite revoked, expired, or fully used."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Accept a tenant invite by code","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. When the app runs `auth_config.mfa: \"required\"` and the account has NO enabled factor, returns a third shape (`ConsumerMfaEnrollmentRequiredResponseDto`, `mfa_enrollment_required: true`) — again no session — which is completed via `POST /auth/mfa/enroll` + `/auth/mfa/enroll/verify`. Inside `mfa_grace_days` of account creation that case instead returns the ordinary token pair with `mfa_enrollment_due_at` set.","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":"A normal token response, an MFA challenge, or an MFA enrolment demand — distinguish by `mfa_required` / `mfa_enrollment_required`. Neither flag present means a session was minted.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ConsumerTokenResponseDto"},{"$ref":"#/components/schemas/ConsumerMfaChallengeResponseDto"},{"$ref":"#/components/schemas/ConsumerMfaEnrollmentRequiredResponseDto"}]}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"Invalid credentials. Returned for every failed attempt, whatever went wrong: a wrong password, an identifier that matches no account, an account with no password credential, an account locked out through a different identifier, an account an operator has blocked, and a wrong password against a suspended or deactivated account. The responses are deliberately identical in body, timing, DB round trips and Redis ops, so signin is not an account-existence oracle."},"403":{"description":"Signin is disabled for this app, or the account is suspended / deactivated. The account-status refusal is only reachable once the caller has proven the password — a wrong password against such an account is a 401."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Too many failed attempts. Two counters produce it, both keyed purely by the submitted identifier: a burst limit of 5 attempts per minute, and a 15-minute lock after the app's `max_failed_login_attempts` failures (default 5). Both fire on the same attempt for a registered and an unregistered identifier. The lock threshold was a fixed 4 until the knob was wired up in tasks/users-api/014. Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Sign in an EndUser","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/send-code":{"post":{"description":"Delivers a single-use 6-digit code to the masked address of an `email` factor listed on the challenge. Submit the code via `/auth/mfa/verify`. Requires the `mfa_token` from the signin response — possession of it (i.e. a correct password) is the authority. Throttled per challenge: max 3 sends, 30s between sends; each send supersedes the prior code.","operationId":"ConsumerAuthController_mfaSendCode","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaSendCodeDto"}}}},"responses":{"200":{"description":"Code sent. Returns the masked destination and the send-attempt counters for this challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaSendCodeResponseDto"}}}},"400":{"description":"Factor is not an email factor on this challenge, or request validation failed (missing / malformed body field) in the global `ValidationPipe`."},"401":{"description":"Invalid or expired challenge."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"410":{"description":"The bound email contact no longer exists or is no longer verified."},"412":{"description":"The app has not completed Mail notification setup for MFA codes."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Send throttled (max 3 sends per challenge, 30s apart), or too many failed attempts on the challenge. Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Email a sign-in code for an email MFA factor","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/verify":{"post":{"description":"Submit the `mfa_token` from a `mfa_required: true` signin response, plus EITHER a `code` (a current TOTP code, or the emailed code requested via `/auth/mfa/send-code`) OR a `credential` (a passkey assertion for the options from `/auth/mfa/webauthn/options`). Mints session + tokens with `amr: [\"pwd\", \"<factor type>\"]` and `mfa_at` set to now. The challenge is single-use; 5 wrong attempts 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":"MFA challenge completed; session + tokens minted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"Invalid or expired challenge, or wrong code."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Too many failed attempts against this challenge (5 locks it). Per-IP rate limit (`RATE_LIMITED`): this path is in the `verify_code` bucket — 5 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Complete an MFA challenge with a TOTP or emailed code","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/webauthn/options":{"post":{"description":"Mints a single-use WebAuthn challenge scoped to the account behind `mfa_token` and returns the `publicKey` member for `navigator.credentials.get()`. Submit the result to `/auth/mfa/verify` as `credential` instead of `code`. Possession of the `mfa_token` (i.e. a correct password) is the authority — nothing here reveals whether an account exists or holds passkeys until that token has been presented.","operationId":"ConsumerAuthController_mfaWebauthnOptions","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaWebauthnOptionsDto"}}}},"responses":{"200":{"description":"Assertion options. Single-use, 5-minute TTL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerWebauthnOptionsResponseDto"}}}},"400":{"description":"No passkey is enrolled on the account behind this challenge."},"401":{"description":"Invalid or expired challenge."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"412":{"description":"Passkeys are not enabled, or no allowed redirect origin matches the configured relying-party id."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Too many failed attempts on the challenge. Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Get passkey assertion options for an open MFA challenge","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/passkey/options":{"post":{"description":"Returns a single-use WebAuthn challenge for `navigator.credentials.get()`. `allowCredentials` is ALWAYS empty and the response is shaped identically for every caller: this endpoint does not take an identifier and does not look one up, because returning a credential list for a registered address and an empty list for an unregistered one would be a single-request account-existence oracle. Sign-in therefore requires a discoverable (resident) credential — the authenticator finds its own passkey for the relying-party id. Submit the result to `/auth/passkey/verify`.","operationId":"ConsumerAuthController_passkeyOptions","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerPasskeyOptionsDto"}}}},"responses":{"200":{"description":"Assertion options. Single-use, 5-minute TTL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerWebauthnOptionsResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"403":{"description":"Signin is disabled for this app."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"412":{"description":"Passkeys are not enabled, or no allowed redirect origin matches the configured relying-party id."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP burst limit. The endpoint does no credential work — one random challenge and one insert — so the bucket is the whole bound. Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Begin a passwordless passkey sign-in","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/passkey/verify":{"post":{"description":"Verifies the assertion against the challenge from `/auth/passkey/options` and mints a session. The authenticator MUST have performed user verification (biometric / PIN) — a presence-only tap is refused on this path whatever the app policy says, because the passkey is the only authentication happening. Tokens carry `amr: [\"webauthn\"]` and a fresh `mfa_at`, so a passkey sign-in satisfies MFA-recency policies without a second step.","operationId":"ConsumerAuthController_passkeyVerify","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerPasskeyVerifyDto"}}}},"responses":{"200":{"description":"Session + tokens minted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"Invalid credentials. Returned identically for a forged assertion, an unknown credential, a challenge that was never issued, one already used, and one that has expired — the endpoint is not an account-existence oracle."},"403":{"description":"Signin is disabled for this app, or the account is suspended / deactivated. The status refusal is only reachable once the assertion has verified."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP burst limit on credential consumption. Per-IP rate limit (`RATE_LIMITED`): this path is in the `verify_code` bucket — 5 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Complete a passwordless passkey sign-in","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":"Recovery code accepted; session + tokens minted with `amr: [\"pwd\",\"recovery_code\"]`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"Invalid or expired challenge, or wrong recovery code."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Too many failed attempts against this challenge (5 locks it). Per-IP rate limit (`RATE_LIMITED`): this path is in the `verify_code` bucket — 5 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Complete an MFA challenge with a recovery code","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/enroll":{"post":{"description":"For apps running `auth_config.mfa: \"required\"`. When a user proves their password but holds no enabled factor, `/auth/signin` returns `{ mfa_enrollment_required: true, mfa_token, expires_at }` and mints NO session; this endpoint starts the enrolment that `mfa_token` authorises. Same body and same response as `POST /{app_slug}/v1/me/mfa/factors`, but authenticated by the token rather than by a session — there is no session yet. TOTP returns the otpauth URI, QR data URL and shared secret; email sends a 6-digit code to the bound verified contact. The factor stays disabled until `/auth/mfa/enroll/verify` succeeds. The token is single-purpose: `/auth/mfa/verify`, `/auth/mfa/send-code` and `/auth/mfa/recover` all refuse it with 401, it is not an access token so `/me/*` refuses it, and `/auth/refresh` does not accept it.","operationId":"ConsumerAuthController_mfaEnroll","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaEnrollDto"}}}},"responses":{"201":{"description":"Pending factor created; enrolment payload returned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartEnrollmentResponseDto"}}}},"400":{"description":"Unsupported factor type, or the account already holds a factor."},"401":{"description":"Enrolment token invalid, expired, already used, or minted for a different app. A sign-in `mfa_token` (an MFA challenge, not an enrolment token) gets the same 401 — the response never says which kind of token was presented."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"412":{"description":"Email factor requested but the bound contact is unverified, or the app has not completed Mail notification setup."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Begin MFA enrolment against an enrolment token","tags":["Consumer · Auth"]}},"/{app_slug}/v1/auth/mfa/enroll/verify":{"post":{"description":"Completes the flow started by `/auth/mfa/enroll`. TOTP: submit two `codes` from consecutive 30-second windows. Email: submit the emailed 6-digit `code`. On success the factor is enabled, a fresh set of recovery codes is minted and returned ONCE (invalidating any prior set), and the session sign-in withheld is finally minted with `amr: [\"pwd\",\"mfa\"]` and `mfa_at` set to now. A wrong code counts against the token exactly as it does on the sign-in challenge; 5 failures lock it and the user must sign in again.","operationId":"ConsumerAuthController_mfaEnrollVerify","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaEnrollVerifyDto"}}}},"responses":{"200":{"description":"Factor enabled, recovery codes issued, session minted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerMfaEnrollVerifyResponseDto"}}}},"400":{"description":"Invalid or repeated codes, the factor is not pending, or the factor does not belong to the account the token was minted for."},"401":{"description":"Enrolment token invalid, expired, already used, or minted for a different app."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Too many failed attempts against this enrolment token (5 locks it). Per-IP rate limit (`RATE_LIMITED`): this path is in the `verify_code` bucket — 5 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"summary":"Confirm the enrolled factor and receive the withheld session","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":"A rotated access + refresh token pair.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"The refresh token does not exist, belongs to another app, has been revoked (including by reuse detection), or its session has passed `expires_at`. One message for all of them."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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":"Session revoked."},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"The refresh token matches no session."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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 `auth.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.\n\nIssuing a code invalidates any previously issued, unconsumed code for the same contact — including the one auto-emailed at signup. Only the most recently minted code is accepted.","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`, or request validation failed in the global `ValidationPipe`."},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks `auth.user.verify.create` on this app, or the app is suspended / archived."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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 Mail 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 Mail 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 — no Mail client wired in this deployment\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`).\n\nIssuing a code invalidates any previously issued, unconsumed code for the same contact — including the one auto-emailed at signup. Only the most recently minted code is accepted.","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"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks auth.user.verify.send-email on this app."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"412":{"description":"Mail precondition not met (see error code in the response body)."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."},"503":{"description":"Mail dispatch failed or client not wired."}},"security":[{"bearer":[]}],"summary":"Mint + dispatch a verification email via Mail (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"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"410":{"description":"Code invalid, expired, locked, or already consumed."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `verify_code` bucket — 5 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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: `auth.user.password-reset.create`. Returns `{ code, expires_at }` on success, `{}` on no-match (uniform shape prevents enumeration). Issuing a reset code invalidates any previously issued, unconsumed reset code for the account — only the most recent one is accepted.\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`, or request validation failed in the global `ValidationPipe`."},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks `auth.user.password-reset.create` on this app, or the app is suspended / archived."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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: `auth.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"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"No PAK provided."},"403":{"description":"PAK lacks auth.user.password-reset.send-email on this app."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"412":{"description":"Mail precondition not met (see error code in the response body)."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `auth` bucket — 10 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."},"503":{"description":"Mail dispatch failed or client not wired."}},"security":[{"bearer":[]}],"summary":"Mint + dispatch a password-reset email via Mail (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."},"400":{"description":"The new password fails the app-configured password policy (length, uppercase, digit, special character), is on the common-password deny-list (`PASSWORD_TOO_COMMON`), or — unless the app sets `password_breach_check` to `warn`/`off` — appears in a public breach corpus (`PASSWORD_BREACHED`). Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"410":{"description":"Code invalid, expired, locked, or already consumed."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `verify_code` bucket — 5 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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":"Tokens re-issued against the new tenant, carrying its `org_id` + `org_role` claims.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerTokenResponseDto"}}}},"400":{"description":"Request validation failed: a required body field is missing, has the wrong type, or fails its format constraint. Produced by the global `ValidationPipe` before the handler runs; the response body lists the offending fields."},"401":{"description":"Missing / invalid bearer, the token carries no `sid`, or the session named by `sid` is revoked, expired or no longer exists."},"403":{"description":"The caller holds no membership in the target tenant, or the app is suspended / archived."},"404":{"description":"The `:appSlug` matches no app, or `tenant_id` names no tenant in this app."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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":"Every tenant membership the caller holds in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyTenantsResponseDto"}}}},"401":{"description":"Missing / invalid bearer, or the session behind the token is revoked, expired or no longer exists."},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs."},"404":{"description":"The `:appSlug` in the path matches no app (or no live rename alias for one). This is about the **app**, not the account — it is decided before any identifier, code or credential in the body is looked at, so it is never a signal about whether an account or a contact exists."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"security":[{"bearer":[]}],"summary":"List the EndUser's tenant memberships in this app","tags":["Consumer · Auth"]}},"/{app_slug}/v1/me/tenant-invites/accept":{"post":{"description":"Adds the caller to the invited tenant with the role the invite names, and consumes the invite. Requires the invited address to be one of the caller's own email contacts in this app — holding the code is not sufficient, because whoever minted the invite also holds it.","operationId":"ConsumerMeController_acceptTenantInvite","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptTenantInviteDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptedTenantInviteResponseDto"}}}},"403":{"description":"The invite was issued to an address this account does not own."},"404":{"description":"No such invite in this app."},"409":{"description":"Already a member of that tenant."},"410":{"description":"Invite revoked, expired, or fully used."}},"security":[{"bearer":[]}],"summary":"Accept a tenant invite as the signed-in EndUser","tags":["Consumer · Me"]}},"/{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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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":"Editable fields: `display_name` and `username`. The username is unique per app (case-insensitive, same rule as signup); sending the current username is a no-op. Usernames cannot be cleared.","operationId":"ConsumerMeController_updateProfile","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeDto"}}}},"responses":{"200":{"description":"Updated account row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeUpdatedAccountResponseDto"}}}},"409":{"description":"Requested username is already held by another account in this app (`USERNAME_TAKEN`)."}},"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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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/avatar":{"post":{"description":"PNG/JPEG/WEBP only — SVG is rejected (stored-XSS vector). ≤2 MB. Magic bytes are sniffed; a mislabeled file is rejected. The image is re-encoded server-side (orientation baked in, all metadata incl. EXIF/GPS stripped, bounded to 512×512, webp). An uploaded avatar takes precedence over a provider-captured picture.","operationId":"ConsumerMeController_setAvatar","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"PNG, JPEG, or WEBP image, ≤2 MB."}},"required":["file"]}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarUrlResponseDto"}}}},"413":{"description":"File exceeds 2 MB (`AVATAR_TOO_LARGE`)."},"415":{"description":"Wrong mimetype, undecodable image, or content does not match the declared type (`AVATAR_INVALID_TYPE` / `AVATAR_MAGIC_MISMATCH`)."}},"security":[{"bearer":[]}],"summary":"Upload (or replace) the EndUser avatar","tags":["Consumer · Me"]},"delete":{"description":"Clears both the uploaded avatar and any provider-captured picture, and deletes the stored object. A later Google/GitHub sign-in re-populates the provider picture — that is \"sync from provider\" behavior, by design.","operationId":"ConsumerMeController_removeAvatar","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Avatar removed (or already absent)."}},"security":[{"bearer":[]}],"summary":"Remove the EndUser avatar","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. The new password is held to exactly the same policy as signup: the app's length + character-class rules, the common-password deny-list, and the breach corpus.","operationId":"ConsumerMeController_changePassword","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordDto"}}}},"responses":{"204":{"description":"Password updated."},"400":{"description":"The new password fails the app-configured policy, is on the common-password deny-list (`PASSWORD_TOO_COMMON`), appears in a public breach corpus (`PASSWORD_BREACHED`), or is identical to the current one."},"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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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"}},{"name":"app_slug","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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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":[{"name":"app_slug","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/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"}},{"name":"app_slug","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"}},{"name":"app_slug","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/avatars/{account_id}":{"get":{"description":"Stable, non-expiring URL for an EndUser avatar. 302s to a short-lived presigned GET for an uploaded avatar, or to the identity provider picture URL. Public — no auth; the account UUID is the capability. Responses are cacheable for 60 seconds.","operationId":"ConsumerAvatarsController_redirect","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"account_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the avatar image. `Cache-Control: private, max-age=60`."},"404":{"description":"Uniform miss: app unknown, account not in this app, or no avatar set."}},"summary":"Redirect to the current avatar image","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.\n\nToken source: `body.token` if provided, else the `Authorization: Bearer <jwt>` header. At least one must be present.","operationId":"ConsumerVerifyController_verify","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/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.\n\nToken source: `body.token` if provided, else the `Authorization: Bearer <jwt>` header. At least one must be present.","operationId":"ConsumerVerifyController_authorize","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/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 check accepts `permission` (singular) or `permissions` (array — ALL must be held); pass an `id` to correlate results back to inputs. Each result reports `authorized` plus the missing permissions (if any).\n\nToken source: `body.token` if provided, else the `Authorization: Bearer <jwt>` header. At least one must be present.","operationId":"ConsumerVerifyController_authorizeBatch","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/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":"An app-scoped M2M access token and its granted scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenResponseDto"}}}},"400":{"description":"`grant_type` is missing or is not `client_credentials`, or request validation failed in the global `ValidationPipe`."},"401":{"description":"`Invalid client credentials`. The SINGLE answer for an unknown `client_id`, a deactivated client, a wrong secret and a credential whose stored hash predates the SHA-256 migration — identical in status, message, DB round trips, wall time and CPU cost, so the endpoint is neither a client-existence oracle nor a way for an anonymous caller to spend our CPU. If you need to know whether a credential was deactivated, read it on the authenticated admin surface; if a credential minted before the migration stopped working, rotate its secret."},"403":{"description":"The app is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`)."},"404":{"description":"The `:appSlug` in the path matches no app. About the **app**, not the client — decided before `client_id` is looked at."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `token_exchange` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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, or a session that is revoked, past its `expires_at`, or gone because the end user was deleted) 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":"Either `{ active: true }` with the claim set, or `{ active: false }` and nothing else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectResponseDto"}}}},"400":{"description":"Request validation failed in the global `ValidationPipe`."},"401":{"description":"No `Authorization: Bearer` header, or a `token` in the body that does not match the bearer. Every failure ABOUT THE TOKEN — bad signature, unknown / cross-app `kid`, wrong `aid`, expired, or a session that is revoked, past its `expires_at` or gone because the end user was deleted — is a 200 with `{ active: false }`, on purpose. A bad `:appSlug` is NOT one of those: it is a 404 (see below), decided by `AppStatusGuard` before this handler runs."},"403":{"description":"The app is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`)."},"404":{"description":"The `:appSlug` in the path matches no app. Raised by `AppStatusGuard`, which runs before the handler — so an introspection against a misspelled app slug is a 404, not an `{ active: false }`. About the **app**, decided before the token is parsed, so it says nothing about the token."},"415":{"description":"The request body was sent as `application/x-www-form-urlencoded` (`UNSUPPORTED_MEDIA_TYPE`). This API is JSON-only: a form body is a CORS-simple request that skips preflight, so accepting one would widen the CSRF surface. Send `application/json`. Rejected in `main.ts` before routing, on every non-GET request."},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): this path is in the `default` bucket — 60 requests per minute per IP over a sliding 60-second window, shared across pods through Redis. Applied by `RateLimitMiddleware` before the handler runs, so it is reachable whatever the body says, and the response carries `Retry-After` in seconds."}},"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`.\n\nWhat is NOT distinct: the password policy. A supplied `password` goes through the same app-configured rules, the same common-password deny-list and the same breach check as `/auth/signup`.","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, unknown role, or a password that fails the app policy — `PASSWORD_TOO_COMMON` / `PASSWORD_BREACHED`."},"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 auth-admin equivalent. Use the cursor in subsequent calls — Auth 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 auth-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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerAdminRoleAssignmentResponseDto"}}}}},"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. Returns the set the role now holds — the write is caller-narrowed, so the resulting set is what the caller needs to see.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerRolePermissionEntryDto"}}}}},"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. TOTP: 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. Email: binds the user's verified email contact (primary by default, `contact_id` to override; 412 when unverified) and returns the masked address + recovery codes — sign-in codes are then delivered on demand via `/auth/mfa/send-code`.","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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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. TOTP: the otpauth URI, QR data URL, and shared secret; confirm with two consecutive codes. Email: a 6-digit code is sent to the bound verified email contact (primary by default, `contact_id` to override); confirm with that single code. The factor stays disabled until `/factors/:id/verify` succeeds.","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":"TOTP: submit two `codes` from consecutive 30-second windows. Email: submit the emailed 6-digit `code`. 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"}},{"name":"app_slug","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"}},{"name":"app_slug","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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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/send-code":{"post":{"description":"Mints a single-use 6-digit code against one of the account's enabled email factors and delivers it to the bound address. Follow with `/step-up` submitting the code. Codes expire after 5 minutes; sends are throttled per contact (30s between sends, shared with the sign-in path). Refused while step-up is locked on this session.","operationId":"ConsumerMfaController_stepUpSendCode","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpSendCodeDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpSendCodeResponseDto"}}}},"400":{"description":"Account has no enabled email factor (or the named factor is not an email factor)."},"410":{"description":"The bound email contact no longer exists or is no longer verified."},"412":{"description":"The app has not completed Mail notification setup for MFA codes."},"429":{"description":"Send throttled, or step-up is locked on this session."}},"security":[{"bearer":[]}],"summary":"Email a step-up code","tags":["Consumer · MFA"]}},"/{app_slug}/v1/me/mfa/step-up/webauthn/options":{"post":{"description":"Mints a single-use WebAuthn challenge for the enabled passkeys on this account and returns the `publicKey` member for `navigator.credentials.get()`. Submit the result to `/step-up` as `credential`. Refused while step-up is locked on this session — a locked session must not be able to keep minting challenges.","operationId":"ConsumerMfaController_stepUpWebauthnOptions","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebauthnOptionsResponseDto"}}}},"400":{"description":"No passkey is enrolled on this account."},"412":{"description":"Passkeys are not enabled or not fully configured for this app."},"429":{"description":"Step-up is locked on this session."}},"security":[{"bearer":[]}],"summary":"Get passkey assertion options for step-up","tags":["Consumer · MFA"]}},"/{app_slug}/v1/me/mfa/step-up":{"post":{"description":"Verify a fresh code (TOTP, emailed via `/step-up/send-code`, 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":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"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":"Either `{access_token, refresh_token, token_type, expires_in}` (ordinary sign-in) or `{mfa_enrollment_required: true, mfa_token, expires_at}` (the app mandates MFA and the account holds no factor). Branch on `mfa_enrollment_required`.","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 Auth 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 Auth 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. Native flow supports: apple, google. GitHub is web-only — POSTing to this endpoint with provider=github 400s.","schema":{"enum":["apple","google","github"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdpNativeSigninDto"}}}},"responses":{"200":{"description":"Either a token pair or, when the app runs `auth_config.mfa: \"required\"` and the account holds no enabled factor, an enrolment demand (`mfa_enrollment_required: true`) with no session behind it. Branch on `mfa_enrollment_required`.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/IdpTokenResponseDto"},{"$ref":"#/components/schemas/IdpMfaEnrollmentRequiredResponseDto"}]}}}},"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","google","github"],"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","google","github"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallbackBodyDto"}}}},"responses":{"303":{"description":"Redirect to `return_to`. Carries `?heimdall_code=<code>` on success and `?heimdall_error=<code>` on a handled failure. When the app runs `auth_config.mfa: \"required\"` and the account holds no enabled factor, NO session is minted and the redirect carries `?heimdall_code=<code>&heimdall_mfa_enrollment_required=1`; redeeming that code at `POST /{app_slug}/v1/auth/oauth/exchange` returns `{mfa_enrollment_required, mfa_token, expires_at}` instead of tokens, and the page must then complete `POST /{app_slug}/v1/auth/mfa/enroll` and `/auth/mfa/enroll/verify` with `mfa_token` to obtain tokens. No credential is ever put on the URL."},"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 Auth tokens, stashes them behind a single-use code, 303s to `<return_to>?heimdall_code=…`.","tags":["Consumer · OAuth Sign-In"]},"get":{"operationId":"ConsumerIdpWebController_callbackGet","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple","google","github"],"type":"string"}},{"name":"error","required":false,"in":"query","schema":{"type":"string"}},{"name":"code","required":false,"in":"query","schema":{"type":"string"}},{"name":"state","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"303":{"description":"Redirect to `return_to`. Carries `?heimdall_code=<code>` on success and `?heimdall_error=<code>` on a handled failure. When the app runs `auth_config.mfa: \"required\"` and the account holds no enabled factor, NO session is minted and the redirect carries `?heimdall_code=<code>&heimdall_mfa_enrollment_required=1`; redeeming that code at `POST /{app_slug}/v1/auth/oauth/exchange` returns `{mfa_enrollment_required, mfa_token, expires_at}` instead of tokens, and the page must then complete `POST /{app_slug}/v1/auth/mfa/enroll` and `/auth/mfa/enroll/verify` with `mfa_token` to obtain tokens. No credential is ever put on the URL."}},"summary":"Provider callback (Google / GitHub redirect with `code` on the query string). Exchanges code, mints Auth tokens, stashes them behind a single-use code, 303s to `<return_to>?heimdall_code=…`.","tags":["Consumer · OAuth Sign-In"]}},"/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 in workspaces the caller 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 `auth.create` on the target workspace."},"409":{"description":"Slug already taken."}},"summary":"Create a new app owned by the caller","tags":["apps"]}},"/v1/apps/{app_id}/clone":{"post":{"operationId":"AppController_cloneApp","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. A clone mints JWKS keys, copies the RBAC model and returns a webhook signing secret exactly once; a retry without this header creates a second app and a second secret the customer never sees. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CloneAppDto"}}}},"responses":{"201":{"description":"Clone created. `webhook_signing_secret` is returned exactly once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CloneAppResponseDto"}}}},"400":{"description":"Validation failure, or the slug matches the source."},"403":{"description":"Caller lacks `auth.create` on the source app."},"404":{"description":"Source app not found."},"409":{"description":"Slug already taken (including as a live rename alias)."}},"summary":"Create a second app in the same workspace whose configuration matches this one — the \"staging app\" flow. Copies the auth config, roles, permissions, notification settings and the webhook target (with a NEW signing secret), and optionally tenants. Never copies credentials, end-users, sessions, signing keys, federated provider configs or audit history.","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"}}}},"400":{"description":"Validation failure — including a retention value outside 30-3650 days (audit) or 7-365 days (webhook attempts)."}},"summary":"Update app display name, slug, metadata, and retention settings","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 `auth.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 `auth.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}/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 Auth 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","google","github"],"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","google","github"],"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"]},"patch":{"operationId":"AuthConfigProviderController_patch","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"provider","required":true,"in":"path","schema":{"enum":["apple","google","github"],"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchProviderConfigDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderDetailDto"}}}},"400":{"description":"Unknown provider or merged config invalid."},"404":{"description":"Provider not configured for this app."}},"summary":"Partially update the config for an (app, provider). Only top-level keys present in `config` are changed; omitted keys — secrets included — keep their stored values. The merged config is validated by the provider impl's `parseConfig`; 404 if the provider is not configured yet (create it with PUT).","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","google","github"],"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}/webauthn-config":{"get":{"description":"Returns defaults with `enabled: false` and a null `rp_id` when passkeys have never been configured for the app.","operationId":"WebauthnConfigController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebauthnConfigResponseDto"}}}}},"summary":"Get the per-app passkey (WebAuthn) relying-party settings.","tags":["Auth-admin · Apps"]},"put":{"operationId":"WebauthnConfigController_put","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebauthnConfigDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebauthnConfigResponseDto"}}}},"400":{"description":"`rp_id` is malformed, or is not a registrable suffix of any origin in `allowed_redirect_origins`."},"409":{"description":"`rp_id` differs from the stored one while passkeys are registered. Every existing credential is bound to the old value and would stop verifying; remove them first."},"412":{"description":"The app has no `allowed_redirect_origins` configured, so `rp_id` cannot be validated."}},"summary":"Set the per-app passkey (WebAuthn) relying-party settings.","tags":["Auth-admin · 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"}},{"name":"search","required":false,"in":"query","schema":{"type":"string"},"description":"Case-insensitive substring match on the role name. Composes with the cursor."}],"responses":{"200":{"description":"Paginated role list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleListResponseDto"}}}}},"summary":"List all roles in an app. Filter by `?search=` on the role name.","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":"app_id","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/RoleWithPermissionsResponseDto"}}}}},"summary":"Get role details with permissions","tags":["roles"]},"patch":{"operationId":"RoleController_updateRole","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/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":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"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. Returns the set the role now holds — the write is caller-narrowed, so the resulting set is what the caller needs to see.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RolePermissionEntryDto"}}}}}},"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 users-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":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"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 `auth.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":"Status updated. Returns the credential in the state it landed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/M2mClientDetailResponseDto"}}}}},"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 `auth.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 `auth.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. Returns the scopes the credential now holds.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/M2mScopeEntryDto"}}}}}},"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"}},{"name":"since","required":false,"in":"query","schema":{"type":"string","format":"date-time"},"description":"Inclusive lower bound on `created_at`. ISO-8601 date (`2026-05-01`, midnight UTC) or date-time (`2026-05-01T09:00:00Z`). Composes with `action` / `actor_id` / `until` and with the cursor."},{"name":"until","required":false,"in":"query","schema":{"type":"string","format":"date-time"},"description":"Inclusive upper bound on `created_at`. Same formats as `since`. Note a bare date resolves to midnight UTC, so `until=2026-05-01` excludes that day's later entries."}],"responses":{"200":{"description":"Page of audit entries with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditLogListResponseDto"}}}},"400":{"description":"Malformed `cursor`, `since`, or `until`."},"403":{"description":"Caller lacks `auth.audit.read`."},"404":{"description":"App not found or caller is not a member."}},"summary":"Append-only audit log for this app, newest first. Filter by `?action=`, `?actor_id=`, and/or the `?since=` / `?until=` date range. 20/page (max 100), cursor-paginated.","tags":["app-audit"]}},"/v1/apps/{app_id}/audit-logs/export":{"get":{"description":"The response is streamed: rows are walked in keyset batches and written as they are read, so neither the service nor the client has to hold the whole result set. Works on both lanes — a platform bearer token and a `pcft_live_*` Platform API Key with `auth.audit.read` hit the same route. Hard cap of 1,000,000 rows per call; a wider request is refused with 413 before any body is written, so a truncated file can never masquerade as a complete one.","operationId":"AppAuditController_exportAuditLogs","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"type":"string","enum":["ndjson","csv"]},"description":"Defaults to `ndjson`."},{"name":"action","required":false,"in":"query","schema":{"type":"string"}},{"name":"actor_id","required":false,"in":"query","schema":{"type":"string"}},{"name":"since","required":false,"in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","required":false,"in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Streamed export. `application/x-ndjson` (one JSON object per line) or `text/csv` (RFC 4180, header row first). `Content-Disposition` names the file."},"400":{"description":"Unknown `format`, or malformed `since` / `until`."},"403":{"description":"Caller lacks `auth.audit.read`."},"404":{"description":"App not found or caller is not a member."},"413":{"description":"More than 1,000,000 rows match. Narrow the window with `since` / `until` (or a tighter `action` / `actor_id` filter) and retry."}},"summary":"Stream this app's audit log as NDJSON or CSV, applying the same `?action=` / `?actor_id=` / `?since=` / `?until=` filters as the list. Newest first. Reading the export is itself audited as `audit.exported`.","tags":["app-audit"]}},"/v1/apps/{app_id}/audit-sink":{"get":{"operationId":"AppAuditSinkController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditSinkResponseDto"}}}},"403":{"description":"Caller lacks `auth.read`."},"404":{"description":"No audit sink configured."}},"security":[{"bearer":[]}],"summary":"Current audit sink for this app, or 404 when none is configured. Never returns the signing secret — only its last-4 hint.","tags":["app-audit-sink"]},"put":{"description":"Audit rows are POSTed in batches of up to 100, signed with the same `X-Auth-Signature: t=<unix>,v1=<hex>` HMAC header as the outbound webhooks. Delivery is at-least-once: the cursor advances only after a 2xx, so a batch that times out after the receiver processed it is re-sent. Every record carries its immutable `audit_log` id, so dedupe on that. A new sink starts from the moment it is created — configuring one does not replay the existing history into your SIEM. Replacing an existing sink clears the auto-disabled state (the same \"I fixed my receiver\" signal a webhook PUT carries) but does NOT rewind the cursor.","operationId":"AppAuditSinkController_upsert","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertAuditSinkDto"}}}},"responses":{"200":{"description":"Sink configured. `signing_secret` is shown only here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditSinkWithSecretResponseDto"}}}},"400":{"description":"URL is not HTTPS, or the SSRF guard refused it (private address, unresolvable host, non-public TLD)."},"403":{"description":"Caller lacks `auth.update`."},"404":{"description":"App not found."}},"security":[{"bearer":[]}],"summary":"Create or replace the audit sink. Returns a freshly generated signing secret exactly once.","tags":["app-audit-sink"]},"delete":{"operationId":"AppAuditSinkController_remove","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Sink removed."},"403":{"description":"Caller lacks `auth.update`."},"404":{"description":"No audit sink configured."}},"security":[{"bearer":[]}],"summary":"Remove the audit sink. Shipping stops immediately; the audit log itself is untouched.","tags":["app-audit-sink"]}},"/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"}},{"name":"signal","required":false,"in":"query","description":"Narrow to accounts carrying one risk signal. Each is a fact already recorded: `unverified` (no verified email contact), `failing_signin` (>= 3 failed sign-ins in 30d), `no_mfa` (no enabled auth factor), `locked` (a live session locked out of step-up), `never_active` (no audit-log entry at all).","schema":{"enum":["unverified","failing_signin","no_mfa","locked","never_active"],"type":"string"}},{"name":"provider","required":false,"in":"query","schema":{"type":"string"},"description":"Narrow to accounts that can sign in with this provider. `password` matches accounts with a password credential; any other value matches a linked OAuth identity."},{"name":"sort","required":false,"in":"query","description":"Order the list. Nulls sort last in BOTH directions.","schema":{"enum":["created","joined","user","status","role","sessions","last_activity"],"type":"string"}},{"name":"order","required":false,"in":"query","schema":{"enum":["asc","desc"],"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserListResponseDto"}}}},"400":{"description":"Unknown `signal` / `sort` / `order`, or a `cursor` minted under a different ordering than the one requested."}},"summary":"List end-users in this app (cursor-paginated).","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/export":{"get":{"description":"Streamed in keyset batches — neither the service nor the client holds the whole directory. Ordered oldest-first by `(created_at, id)` so a re-run is stable. Gated on `auth.user.export`, which is escalation class: this hands over every address in the directory in one call, where `auth.list` pages it. Hard cap of 1,000,000 rows; a wider request is refused with 413 before any body is written.","operationId":"EndUserController_exportEndUsers","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"type":"string","enum":["ndjson","csv"]}},{"name":"status","required":false,"in":"query","schema":{"type":"string"}},{"name":"search","required":false,"in":"query","schema":{"type":"string"}},{"name":"provider","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Streamed export."},"400":{"description":"Unknown `format`."},"403":{"description":"Caller lacks `auth.user.export`."},"404":{"description":"App not found."},"413":{"description":"More than 1,000,000 end-users match. Narrow the filters."}},"summary":"Stream this app's end-user directory as NDJSON or CSV, applying the list's `?status=` / `?search=` / `?provider=` filters. Audited as `users.exported`.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/import":{"post":{"description":"Row fields: `email` (required), `username`, `display_name`, `role`, `tenant_slugs` (array), `external_id`, `metadata`, `email_verified`, and `password_hash` + `password_hash_algorithm`.  **Only `argon2id` password hashes are accepted.** bcrypt is refused per row with a message saying so: verifying it needs a second KDF on the signin compare, and a second cost there is an account-existence oracle for every imported user. Import those users without a hash and send them through password reset.  A re-run is idempotent: rows match on `external_id` first, then the primary email, and update rather than duplicate. An existing password, an already-verified address and an existing member's role are never overwritten — see the module docs for why each one.","operationId":"EndUserController_importEndUsers","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. A retried import without one queues a SECOND job over the same file; the rows themselves are idempotent (matched on `external_id` then email) but the duplicate job muddies the report. 24h TTL.","required":false,"schema":{"type":"string"}}],"responses":{"202":{"description":"Import queued. Poll the job for progress.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportJobResponseDto"}}}},"400":{"description":"Body is empty or contains no NDJSON rows."},"403":{"description":"Caller lacks `auth.user.import`."},"413":{"description":"Body exceeds 20 MiB or 50,000 rows. Split and retry."}},"summary":"Queue a bulk end-user import. Body is NDJSON — one JSON object per line. Returns 202 with a job id; poll `GET .../import/:jobId` for progress and the per-row error report.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/import/{job_id}":{"get":{"description":"`status: succeeded` means the job RAN TO COMPLETION, not that every row landed — read `failed_count` and `errors` for that. `errors` is capped at the first 500 entries; the counts stay exact.","operationId":"EndUserController_importStatus","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"job_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportJobResponseDto"}}}},"403":{"description":"Caller lacks `auth.user.import`."},"404":{"description":"No such import job in this app."}},"summary":"Import job status and per-row error report. Never returns the uploaded file.","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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserRoleAssignmentResponseDto"}}}}},"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":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"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}/mfa/require-reenrollment":{"post":{"description":"Disables every enabled factor on the account, destroys its recovery codes, and revokes every live session. The next sign-in on an app running `auth_config.mfa: \"required\"` returns `mfa_enrollment_required` and the user must enrol a new factor before a session is minted. On an app running `mfa: \"optional\"` the user simply ends up with no MFA, which is the pre-existing meaning of deleting their factors. All three steps matter: leaving the recovery codes alive would let whoever holds the old printout complete `/auth/mfa/recover` and skip enrolment entirely. Audited, and emits `user.mfa.factor_disabled` per factor plus one `user.mfa.reenrollment_required`. Idempotent.","operationId":"EndUserController_requireMfaReenrollment","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Factors disabled, recovery codes destroyed, sessions revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequireMfaReenrollmentResponseDto"}}}},"403":{"description":"Caller's workspace policy does not allow `auth.user.factors.delete` on this app."},"404":{"description":"No such end-user in this app."}},"summary":"Force an end-user back through MFA enrolment","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":"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/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":"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":{"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":"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":{"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}/end-users/invites":{"post":{"operationId":"EndUserInviteController_create","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. Mint dispatches an email + writes an `invite` row; a retry without an idempotency key would double-send.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEndUserInviteDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserInviteResponseDto"}}}},"400":{"description":"Unknown role, or maxUses / expiresInHours out of range."},"409":{"description":"An EndUser with this email already exists in the app."}},"summary":"Pre-allocate an EndUser invite. Dispatches an invitation email via the workspace's Mail sender.","tags":["end-user-invites"]},"get":{"operationId":"EndUserInviteController_list","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","enum":["pending","revoked","expired","used"]},"description":"Derived lifecycle state. `revoked` = explicitly revoked; `expired` = past `expires_at`; `used` = `use_count` has reached `max_uses`; `pending` = still redeemable. Evaluated with the same precedence the accept-invite path rejects in, so the filter and redemption always agree."}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserInviteListResponseDto"}}}},"400":{"description":"Unknown `status` value."}},"summary":"List EndUser invites for this app, newest first. Filter by `?status=`.","tags":["end-user-invites"]}},"/v1/apps/{app_id}/end-users/invites/{invite_id}":{"delete":{"operationId":"EndUserInviteController_revoke","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 EndUser invite (soft-delete).","tags":["end-user-invites"]}},"/v1/apps/{app_id}/end-user-insights":{"get":{"operationId":"EndUserInsightsController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Aggregated auth health."},"403":{"description":"Caller lacks `auth.audit.read` on this app."}},"summary":"Auth health for this app over the last 30 days: verification completion, sign-in failure rate, MFA adoption, password-reset follow-through, and session counts. Rates are null when there is nothing to divide by.","tags":["end-user-insights"]}},"/v1/apps/{app_id}/end-user-insights/users/{user_id}":{"get":{"operationId":"EndUserInsightsController_getUser","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"user_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Per-user activity, sessions and factors."},"403":{"description":"Caller lacks `auth.audit.read` on this app."},"404":{"description":"No such end-user in this app. Same response as an account in another app."}},"summary":"One end-user's recorded activity: their audit trail, their sessions with device/IP, and their enrolled factors. `noActivityRecorded` distinguishes \"nothing happened\" from \"no data\".","tags":["end-user-insights"]}},"/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"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 20, clamped to 100. Values that do not parse as a positive integer fall back to the default."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page. Keyed to the `created_at DESC, id DESC` ordering — do not construct one by hand."}],"responses":{"200":{"description":"Page of API keys (raw secret omitted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyListResponseDto"}}}},"400":{"description":"Malformed `cursor`."}},"summary":"List API keys for an app (no raw secret), newest first. Cursor-paginated: 20/page by default, 100 max.","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 revoked. Idempotent — revoking twice is still a 204 and does not overwrite the first revoker."},"404":{"description":"No such key in this app."}},"summary":"Revoke an API key. The key stops authenticating immediately; the row is kept (soft delete) so the list and the audit trail can still answer who revoked it and when.","tags":["api-keys"]}},"/v1/apps/{app_id}/api-keys/{key_id}/rotate":{"post":{"operationId":"ApiKeyController_rotateApiKey","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"key_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"The replacement key is returned exactly once. A retry without this header mints a SECOND replacement whose plaintext the caller may never see, and shortens the outgoing key twice. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateApiKeyDto"}}}},"responses":{"201":{"description":"Replacement key with raw secret, plus when the old one dies.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateApiKeyResponseDto"}}}},"404":{"description":"No such key in this app."},"409":{"description":"The key is revoked or already expired. There is nothing to roll over from — mint a new key instead."}},"summary":"Mint a replacement for an existing key, carrying its name and permissions over. The outgoing key keeps working for `grace_hours` (24 by default) so a fleet can roll over without downtime. The raw key is returned ONCE.","tags":["api-keys"]}},"/v1/stats/me":{"get":{"description":"Returns total Auth apps owned by workspaces the caller is a member of, 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 data does 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 auth.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 auth.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 auth.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 auth.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 auth.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","description":"Page size. Defaults to 20, clamped to 100. Values that do not parse as a positive integer (negatives, fractions, text) fall back to the default.","schema":{"type":"integer","minimum":1,"default":20,"maximum":100}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page. Keyed to the `joined_at DESC, account_id DESC` ordering."},{"name":"search","required":false,"in":"query","description":"Case-insensitive substring match on the member's username, account display name, or any contact value (email / phone). Composes with `role` and with the cursor.","schema":{"type":"string","maxLength":200}},{"name":"role","required":false,"in":"query","description":"Exact role name (case-insensitive) from the app's `role` table.","schema":{"type":"string","maxLength":64}}],"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. Filter by `?search=` and/or `?role=`. PAK must hold auth.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 auth.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 auth.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 auth.tenant.members.remove.","tags":["Consumer · Tenants"]}},"/{app_slug}/v1/tenants/{tenant_id}/invites":{"post":{"description":"Unlike `POST /members`, nothing is granted until the invitee accepts at `POST /:appSlug/v1/auth/accept-tenant-invite`. Only a SHA-256 digest of the code is stored, so the `code` in this response is unrecoverable afterwards.","operationId":"ConsumerTenantsController_createInvite","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/CreateTenantInviteDto"}}}},"responses":{"201":{"description":"Pending invite. `code` appears here and nowhere else.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantInviteResponseDto"}}}},"400":{"description":"Invalid email, unknown role, or maxUses/expiresInHours out of range."},"401":{"description":"PAK missing."},"403":{"description":"PAK policy denies action."},"404":{"description":"Tenant not found."},"409":{"description":"That email is already a member of the tenant, or already has a pending invite to it."}},"security":[{"bearer":[]}],"summary":"Invite an email address into a tenant. Mints a pending invite, mails the code through the app's `invite_template_name` Envoi slot, and returns the plaintext code ONCE. PAK must hold auth.tenant.members.add.","tags":["Consumer · Tenants"]},"get":{"description":"Codes are never returned here — only the digest is stored. Use `status=pending` to show outstanding invitations.","operationId":"ConsumerTenantsController_listInvites","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","description":"Page size. Defaults to 20, clamped to 100. Values that do not parse as a positive integer fall back to the default.","schema":{"type":"integer","minimum":1,"default":20,"maximum":100}},{"name":"cursor","required":false,"in":"query","description":"Opaque `next_cursor` from the previous page. Keyed to the `created_at DESC, id DESC` ordering.","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","description":"Derived lifecycle state. Precedence revoked > expired > used > pending, matching what the accept endpoint enforces.","schema":{"type":"string","enum":["pending","revoked","expired","used"]}}],"responses":{"200":{"description":"List page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantInviteListResponseDto"}}}},"400":{"description":"Unknown `status` value."},"404":{"description":"Tenant not found."}},"security":[{"bearer":[]}],"summary":"List a tenant's invites, newest first. Optional `?status=pending|revoked|expired|used`. PAK must hold auth.tenant.read.","tags":["Consumer · Tenants"]}},"/{app_slug}/v1/tenants/{tenant_id}/invites/{invite_id}":{"delete":{"operationId":"ConsumerTenantsController_revokeInvite","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"invite_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Revoked."},"404":{"description":"Tenant or invite not found."}},"security":[{"bearer":[]}],"summary":"Revoke a pending invite. Idempotent — revoking an already-revoked invite is a 204 no-op and fires no second webhook. PAK must hold auth.tenant.members.remove.","tags":["Consumer · Tenants"]}},"/v1/apps/{app_id}/tenants":{"post":{"operationId":"PlatformTenantsController_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. Auth-admin lane; requires auth.tenant.create on the app URN.","tags":["Auth · Tenants"]},"get":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}":{"get":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]},"patch":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]},"delete":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}/members":{"get":{"operationId":"PlatformTenantsController_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","description":"Page size. Defaults to 20, clamped to 100. Values that do not parse as a positive integer (negatives, fractions, text) fall back to the default.","schema":{"type":"integer","minimum":1,"default":20,"maximum":100}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page. Keyed to the `joined_at DESC, account_id DESC` ordering."},{"name":"search","required":false,"in":"query","description":"Case-insensitive substring match on the member's username, account display name, or any contact value (email / phone). Composes with `role` and with the cursor.","schema":{"type":"string","maxLength":200}},{"name":"role","required":false,"in":"query","description":"Exact role name (case-insensitive) from the app's `role` table.","schema":{"type":"string","maxLength":64}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantMembershipListResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List members of a tenant, newest membership first. Filter by `?search=` and/or `?role=`.","tags":["Auth · Tenants"]},"post":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}/members/{account_id}":{"patch":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]},"delete":{"operationId":"PlatformTenantsController_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":["Auth · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}/invites":{"post":{"operationId":"PlatformTenantsController_createInvite","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. A retried invite mint would otherwise send the invitee a second email carrying a second live code, and the first code stays valid — replay protection matters more here than on most creates. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantInviteDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantInviteResponseDto"}}}},"400":{"description":"Invalid email, unknown role, or maxUses/expiresInHours out of range."},"404":{"description":"Tenant not found."},"409":{"description":"That email is already a member of the tenant, or already has a pending invite to it."}},"security":[{"bearer":[]}],"summary":"Invite an email address into a tenant. Returns the plaintext code once; only its SHA-256 digest is stored.","tags":["Auth · Tenants"]},"get":{"operationId":"PlatformTenantsController_listInvites","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","description":"Page size. Defaults to 20, clamped to 100. Values that do not parse as a positive integer fall back to the default.","schema":{"type":"integer","minimum":1,"default":20,"maximum":100}},{"name":"cursor","required":false,"in":"query","description":"Opaque `next_cursor` from the previous page. Keyed to the `created_at DESC, id DESC` ordering.","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","description":"Derived lifecycle state. Precedence revoked > expired > used > pending, matching what the accept endpoint enforces.","schema":{"type":"string","enum":["pending","revoked","expired","used"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantInviteListResponseDto"}}}},"400":{"description":"Unknown `status` value."},"404":{"description":"Tenant not found."}},"security":[{"bearer":[]}],"summary":"List a tenant's invites, newest first. Optional `?status=pending|revoked|expired|used`. Codes are never returned.","tags":["Auth · Tenants"]}},"/v1/apps/{app_id}/tenants/{tenant_id}/invites/{invite_id}":{"delete":{"operationId":"PlatformTenantsController_revokeInvite","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"tenant_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"invite_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Invite revoked."},"404":{"description":"Tenant or invite not found."}},"security":[{"bearer":[]}],"summary":"Revoke a pending invite. Idempotent — a second revoke is a 204 no-op and fires no second webhook.","tags":["Auth · 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"},"description":"Page size. Defaults to 50, clamped to 200."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"Opaque `next_cursor` from the previous page."},{"name":"outcome","required":false,"in":"query","schema":{"type":"string","enum":["succeeded","failed"]},"description":"`succeeded` is a 2xx response. `failed` is every other status AND every attempt that got no response at all (timeout, DNS failure, TLS error). Omit for both. An unrecognised value is ignored rather than rejected."}],"responses":{"200":{"description":"Page of attempts with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookAttemptPageDto"}}}},"400":{"description":"Malformed `cursor`."}},"security":[{"bearer":[]}],"summary":"Recent webhook delivery attempts for this app, newest first. Includes status code, latency, response body (truncated to 1KB), and error if any. Filter with `?outcome=failed` to see only the deliveries that did not land.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/dead-letters":{"get":{"description":"A delivery lands here when it failed and no further attempt is scheduled, and ALSO when the webhook config was auto-disabled at the time the event fired — those used to be dropped with no record at all, so an hour of receiver downtime meant an hour of events nobody could enumerate.","operationId":"AppOutboundWebhookController_listDeadLetters","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"string"},"description":"Page size. Defaults to 50, clamped to 200."},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"}},{"name":"unreplayed","required":false,"in":"query","schema":{"type":"string","enum":["true","false"]},"description":"`true` shows only events that have never been accepted by the receiver — the backlog you would replay."}],"responses":{"200":{"description":"Page of dead letters with `next_cursor` and `has_more`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookDeadLetterPageDto"}}}},"400":{"description":"Malformed `cursor`."},"403":{"description":"Caller lacks `auth.read`."}},"security":[{"bearer":[]}],"summary":"Webhook events that were not delivered and will not be retried automatically, newest first. Each row carries the original signed payload so it can be replayed once the receiver is fixed.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/dead-letters/{dead_letter_id}/replay":{"post":{"description":"Returns `delivered` only on a 2xx from the receiver. A bounce comes back as `failed` with the status code — an operator is never told \"replayed\" about an event that bounced again. The SSRF guard re-resolves the URL, so a config that has since been re-pointed at a private address is refused here too.","operationId":"AppOutboundWebhookController_replayDeadLetter","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"dead_letter_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Replay attempted. Read `status` for the outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayResultDto"}}}},"403":{"description":"Caller lacks `auth.update`."},"404":{"description":"No such dead letter in this app, or the app has no webhook configured to replay to."}},"security":[{"bearer":[]}],"summary":"Re-deliver one dead-lettered event to the current webhook URL. One attempt; the result tells you whether it landed.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/dead-letters/replay":{"post":{"description":"Sequential rather than concurrent: a bulk replay is N outbound requests at a receiver that has just come back up, and firing them in parallel is how you knock it over again. Stopping at the first failure means a still-broken receiver is not hammered with the whole backlog; call again after fixing it.","operationId":"AppOutboundWebhookController_replayAllDeadLetters","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Bulk replay finished or stopped at a bounce.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReplayResultDto"}}}},"403":{"description":"Caller lacks `auth.update`."}},"security":[{"bearer":[]}],"summary":"Replay every unreplayed dead letter for this app, oldest first. Up to 100 per call; stops at the first bounce.","tags":["app-outbound-webhooks"]}}},"info":{"title":"Auth API","description":"Multi-tenant authentication for ProductCraft customers. The Consumer API (`/{appSlug}/v1/*`) is what external apps integrate to sign in their EndUsers; the Auth-admin Platform API (`/v1/*`) is for managing the Auth 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":"https://api.auth.productcraft.co/<slug>","description":"Token `iss` claim — the Auth API base URL with the app slug appended. Pin this literally in your verifier so a token minted for another app on the platform cannot pass."},"jwks_uri":{"type":"string","example":"https://api.auth.productcraft.co/<slug>/.well-known/jwks.json","description":"Issuer-aligned JWKS URL — exactly `issuer + '/.well-known/jwks.json'`, which is what OIDC Discovery §4 relying parties expect. The older `/<slug>/v1/.well-known/jwks.json` alias serves the identical document and is not going away."},"token_endpoint":{"type":"string","example":"https://api.auth.productcraft.co/<slug>/v1/oauth/token"},"introspection_endpoint":{"type":"string","example":"https://api.auth.productcraft.co/<slug>/v1/oauth/introspect"},"userinfo_endpoint":{"type":"string","example":"https://api.auth.productcraft.co/<slug>/v1/me","description":"OIDC userinfo equivalent — returns the signed-in EndUser profile."},"heimdall_verify_endpoint":{"type":"string","description":"Auth-specific verify endpoint. Not part of standard OIDC.","example":"https://api.auth.productcraft.co/<slug>/v1/verify"},"heimdall_authorize_endpoint":{"type":"string","description":"Auth-specific authorize endpoint. Not part of standard OIDC.","example":"https://api.auth.productcraft.co/<slug>/v1/authorize"},"heimdall_admin_users_endpoint":{"type":"string","description":"Customer-product admin lane root. M2M-callable user CRUD.","example":"https://api.auth.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 — `totp`, `email` or `webauthn`; widens as new factor types ship. For `email`, request delivery via `POST /auth/mfa/send-code` before verifying. For `webauthn`, request assertion options via `POST /auth/mfa/webauthn/options`.","example":"totp"},"label":{"type":"string","nullable":true},"email_masked":{"type":"string","description":"Email factors only: masked address codes are delivered to (e.g. `a***a@e***e.com`). Null / absent for other factor types.","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"]},"ConsumerMfaEnrollmentRequiredResponseDto":{"type":"object","properties":{"mfa_enrollment_required":{"type":"boolean","description":"Always `true` on this response. Distinguishes it from both the normal token response and the `mfa_required` challenge response.","example":true},"mfa_token":{"type":"string","description":"Single-use enrolment token. Pass back as `mfa_token` on `/auth/mfa/enroll` and `/auth/mfa/enroll/verify`. It is NOT an access token: it cannot call `/me/*`, cannot be refreshed, and is refused by `/auth/mfa/verify`, `/auth/mfa/send-code` and `/auth/mfa/recover`. No session exists yet. 10-minute TTL."},"expires_at":{"type":"string","format":"date-time","description":"When the enrolment token expires (ISO 8601, UTC)."}},"required":["mfa_enrollment_required","mfa_token","expires_at"]},"ConsumerSignupDto":{"type":"object","properties":{"username":{"type":"string","description":"Unique username inside the app. 3-32 characters: letters, numbers, dot, underscore, hyphen. No \"@\" — a username shaped like an email address would sit in front of that address at sign-in.","example":"john_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: 12+ chars, at least one uppercase letter and one digit), must not be on the common-password deny-list, and — unless the app sets `password_breach_check` to `off` or `warn` — must not appear in the public breach corpus.","example":"CorrectHorseBatteryStaple"},"display_name":{"type":"string","description":"Optional display name shown back to the user.","example":"Jane Doe"}},"required":["username","email","password"]},"ConsumerAcceptInviteDto":{"type":"object","properties":{"code":{"type":"string","description":"Invite code from the invitation email (`inv_…`).","example":"inv_x7H2k3jLqXr"},"password":{"type":"string","description":"Password the accepter sets for their new account.","example":"CorrectHorseBatteryStaple"},"username":{"type":"string","description":"Optional username override. Defaults to a derivation of the invite email local-part.","example":"john_doe"},"display_name":{"type":"string","description":"Optional display name shown back to the user.","example":"Jane Doe"}},"required":["code","password"]},"ConsumerAcceptTenantInviteDto":{"type":"object","properties":{"code":{"type":"string","description":"Tenant invite code from the invitation email (`tinv_…`). Distinct from the app-level `inv_…` codes `/auth/accept-invite` takes.","example":"tinv_9dK2mQ7pR4vXbN1sT6yZ0hLcJeW3fAgU"},"password":{"type":"string","description":"Password the accepter sets for their new account. Validated against the app's password policy.","example":"CorrectHorseBatteryStaple"},"username":{"type":"string","description":"Optional username override. Defaults to a derivation of the invited email local-part.","example":"john_doe"},"display_name":{"type":"string","description":"Optional display name shown back to the user.","example":"Jane Doe"}},"required":["code","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},"password_warnings":{"description":"Non-fatal notes about the password that was just set. Present only on `signup` and `accept-invite`, and only when the app runs `password_breach_check: \"warn\"` and the password was found in a public breach corpus — the value is then `[\"breached\"]` and the session is minted anyway, so the client can prompt the user to change it. The key is ABSENT (not an empty array) when there is nothing to report; do not branch on its length without checking that it exists. Apps on the default `block` never see it — a breached password is refused with 400 `PASSWORD_BREACHED`.","example":["breached"],"type":"array","items":{"type":"string"}},"mfa_enrollment_due_at":{"type":"string","description":"Present only when the app runs `mfa: \"required\"`, the user holds no enabled factor, and the account is still inside `mfa_grace_days` of its creation. The session IS minted; this is the deadline after which sign-in will start returning `mfa_enrollment_required` instead, so a client can prompt rather than block. Absent in every other case.","format":"date-time"}},"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"]},"ConsumerMfaSendCodeDto":{"type":"object","properties":{"mfa_token":{"type":"string","description":"The `mfa_token` returned by `/auth/signin`. UUID."},"factor_id":{"type":"string","description":"Id of the email factor (from the challenge `factors` list) to deliver the code through.","format":"uuid"}},"required":["mfa_token","factor_id"]},"ConsumerMfaSendCodeResponseDto":{"type":"object","properties":{"email_masked":{"type":"string","description":"Masked address the code was sent to.","example":"a***a@e***e.com"},"code_expires_at":{"type":"string","format":"date-time","description":"Expiry of the emailed code. Also bounded by the challenge expiry."}},"required":["email_masked","code_expires_at"]},"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, or the emailed code requested via `/auth/mfa/send-code` (6 digits either way). Omit when submitting `credential`.","example":"123456"},"credential":{"type":"object","description":"Passkey MFA: the `PublicKeyCredential` from `navigator.credentials.get()`, driven by the options from `/auth/mfa/webauthn/options`. ArrayBuffers base64url-encoded. Mutually exclusive with `code`.","additionalProperties":true}},"required":["mfa_token"]},"ConsumerMfaWebauthnOptionsDto":{"type":"object","properties":{"mfa_token":{"type":"string","description":"The `mfa_token` returned by `/auth/signin`. UUID."}},"required":["mfa_token"]},"ConsumerWebauthnOptionsResponseDto":{"type":"object","properties":{"public_key":{"type":"object","description":"The `publicKey` member to hand to `navigator.credentials.get()` verbatim. Single-use; 5-minute TTL.","additionalProperties":true}},"required":["public_key"]},"ConsumerPasskeyOptionsDto":{"type":"object","properties":{"tenant_id":{"type":"string","description":"Optional tenant to activate on the session the ceremony will mint, same semantics as `tenant_id` on `/auth/signin`.","format":"uuid"}}},"ConsumerPasskeyVerifyDto":{"type":"object","properties":{"credential":{"type":"object","description":"The `PublicKeyCredential` returned by `navigator.credentials.get()` for the challenge from `/auth/passkey/options`. ArrayBuffers base64url-encoded.","additionalProperties":true}},"required":["credential"]},"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"]},"ConsumerMfaEnrollDto":{"type":"object","properties":{"mfa_token":{"type":"string","description":"The `mfa_token` from an `mfa_enrollment_required: true` signin response. UUID."},"type":{"type":"string","description":"Factor type to enrol — `totp` (authenticator app) or `email` (single-use code to a verified email contact).","enum":["totp","email"],"example":"totp"},"label":{"type":"string","description":"Optional human-readable label for the factor (e.g. \"iPhone 15\").","maxLength":64},"contact_id":{"type":"string","description":"Email factors only: id of the verified email contact codes should be delivered to. Defaults to the primary verified email.","format":"uuid"}},"required":["mfa_token","type"]},"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"]},"EmailEnrollmentDataDto":{"type":"object","properties":{"email_masked":{"type":"string","description":"Masked address the enrollment code was sent to.","example":"a***a@e***e.com"},"code_expires_at":{"type":"string","format":"date-time","description":"Expiry of the emailed enrollment code (self-service enrollment only)."}},"required":["email_masked"]},"WebauthnEnrollmentDataDto":{"type":"object","properties":{"public_key":{"type":"object","description":"The `publicKey` member to hand to `navigator.credentials.create()` verbatim — `challenge`, `rp`, `user`, `pubKeyCredParams`, `excludeCredentials`, `authenticatorSelection`, `attestation: \"none\"`. Submit the result to `/factors/:id/verify` as `credential`.","additionalProperties":true}},"required":["public_key"]},"PublicFactorDto":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","description":"Factor type — `totp` (authenticator app), `email` (single-use code delivered to a verified email contact), or `webauthn` (a passkey). Further factor types 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"]},"StartEnrollmentResponseDto":{"type":"object","properties":{"factor":{"$ref":"#/components/schemas/PublicFactorDto"},"enrollment":{"description":"Type-specific enrollment payload. Shape depends on `factor.type` — TotpEnrollmentData for `totp`, EmailEnrollmentData for `email` (a code is emailed to the bound contact; submit it via `/factors/:id/verify` as `code`), WebauthnEnrollmentData for `webauthn` (pass `publicKey` to `navigator.credentials.create()` and submit the result as `credential`).","oneOf":[{"$ref":"#/components/schemas/TotpEnrollmentDataDto"},{"$ref":"#/components/schemas/EmailEnrollmentDataDto"},{"$ref":"#/components/schemas/WebauthnEnrollmentDataDto"}]}},"required":["factor","enrollment"]},"ConsumerMfaEnrollVerifyDto":{"type":"object","properties":{"mfa_token":{"type":"string","description":"The same `mfa_token` used to start the enrolment. UUID."},"factor_id":{"type":"string","description":"Id of the pending factor returned by `/auth/mfa/enroll`.","format":"uuid"},"codes":{"description":"TOTP factors: two codes from consecutive 30-second windows.","example":["123456","654321"],"minItems":2,"maxItems":2,"type":"array","items":{"type":"string"}},"code":{"type":"string","description":"Email factors: the single 6-digit code emailed when enrolment started.","example":"123456"}},"required":["mfa_token","factor_id"]},"ConsumerMfaEnrollVerifyResponseDto":{"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},"password_warnings":{"description":"Non-fatal notes about the password that was just set. Present only on `signup` and `accept-invite`, and only when the app runs `password_breach_check: \"warn\"` and the password was found in a public breach corpus — the value is then `[\"breached\"]` and the session is minted anyway, so the client can prompt the user to change it. The key is ABSENT (not an empty array) when there is nothing to report; do not branch on its length without checking that it exists. Apps on the default `block` never see it — a breached password is refused with 400 `PASSWORD_BREACHED`.","example":["breached"],"type":"array","items":{"type":"string"}},"mfa_enrollment_due_at":{"type":"string","description":"Present only when the app runs `mfa: \"required\"`, the user holds no enabled factor, and the account is still inside `mfa_grace_days` of its creation. The session IS minted; this is the deadline after which sign-in will start returning `mfa_enrollment_required` instead, so a client can prompt rather than block. Absent in every other case.","format":"date-time"},"factor":{"type":"object","description":"The factor that was just enabled.","additionalProperties":true,"example":{"id":"8a7f…","type":"totp","label":"iPhone 15","enabled":true}},"recovery_codes":{"description":"Single-use recovery codes for this account. Returned ONCE — any previously-issued set is invalidated. Store them now.","example":["a1b2-c3d4-e5f6-7890","1234-5678-9abc-def0"],"type":"array","items":{"type":"string"}}},"required":["access_token","refresh_token","token_type","expires_in","factor","recovery_codes"]},"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 — Mail 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"]},"AcceptTenantInviteDto":{"type":"object","properties":{"code":{"type":"string","description":"Tenant invite code (`tinv_…`) from the invitation email. Must have been issued to an email address this account already owns in this app.","example":"tinv_9dK2mQ7pR4vXbN1sT6yZ0hLcJeW3fAgU"}},"required":["code"]},"AcceptedTenantInviteResponseDto":{"type":"object","properties":{"tenant_id":{"type":"string","description":"Tenant the caller just joined."},"role_id":{"type":"string"},"role_name":{"type":"string","nullable":true,"example":"member"},"membership_id":{"type":"string","description":"The new `tenant_membership` row id."}},"required":["tenant_id","role_id","role_name","membership_id"]},"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."},"avatar_url":{"type":"string","nullable":true,"description":"Stable avatar URL (`/:appSlug/v1/avatars/:accountId`) when the user has an avatar — uploaded, or captured from a Google/GitHub sign-in. `null` when no avatar exists. Does not expire; each hit 302s to a short-lived presigned URL (or the provider URL).","example":"https://api.auth.productcraft.co/my-app/v1/avatars/8f14…"}},"required":["id","username","display_name","role","joined_at","created_at","email","email_verified_at","avatar_url"]},"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},"username":{"type":"string","description":"New username. Unique per app, case-insensitive. 3-32 chars: letters, numbers, dot, underscore, hyphen. Omit to leave unchanged — cannot be cleared. 409 `USERNAME_TAKEN` when another account in the app holds it.","example":"jane_doe","minLength":3,"maxLength":32,"pattern":"^[a-zA-Z0-9._-]+$"}}},"MeUpdatedAccountResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"EndUser account id (UUID)."},"app_id":{"type":"string","description":"Owning app UUID."},"seq_id":{"type":"string","description":"Monotonic per-table sequence id (Postgres int8, serialized as a string).","example":"42"},"username":{"type":"string","description":"Username inside the app.","example":"ada"},"display_name":{"type":"string","nullable":true,"example":"Ada Lovelace"},"status":{"type":"string","example":"active"},"locale":{"type":"string","nullable":true,"example":"en-GB"},"time_zone":{"type":"string","nullable":true,"example":"Europe/Lisbon"},"metadata":{"type":"object","description":"Free-form metadata blob stored on the account row."},"created_at":{"format":"date-time","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","app_id","seq_id","username","display_name","status","locale","time_zone","metadata","created_at","updated_at"]},"AvatarUrlResponseDto":{"type":"object","properties":{"avatar_url":{"type":"string","description":"Stable avatar URL (`/:appSlug/v1/avatars/:accountId`). Safe to cache and hotlink — it 302s to a fresh short-lived presigned GET on every hit.","example":"https://api.auth.productcraft.co/my-app/v1/avatars/8f14…"}},"required":["avatar_url"]},"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. Optional — if omitted, the token is read from the `Authorization: Bearer <jwt>` header instead."}}},"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 the backing session are all valid. For `end_user` tokens the session must exist, be unrevoked and be unexpired, and the account row must still exist and be active — a deleted end user is never `valid: true`, even inside the access token's own `exp` window."},"error":{"type":"string","description":"Failure code on `valid: false`. `TOKEN_INVALID` — signature / issuer / audience / app mismatch, an `end_user` token with no `sid` claim, or an `end_user` token whose account row no longer exists. `TOKEN_EXPIRED` — the token's own `exp` has passed. `TOKEN_REVOKED` — the backing session was revoked (sign-out, password change, admin revoke) or its row no longer exists because the end user was deleted. `SESSION_EXPIRED` — the session row is past `expires_at`; the access token outlived the session that minted it. `ACCOUNT_SUSPENDED` — the account exists but its status is not `active`."},"principal":{"$ref":"#/components/schemas/VerifyPrincipalDto"}},"required":["valid"]},"AuthorizeBody":{"type":"object","properties":{"token":{"type":"string","description":"JWT to verify and authorize. Optional — if omitted, the token is read from the `Authorization: Bearer <jwt>` header."},"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"}}}},"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":{"id":{"type":"string","description":"Caller-chosen correlation id for this check. Echoed back on the matching result so the caller can map results to inputs without relying on array order."},"permission":{"type":"string","description":"Single permission to check, e.g. 'invoice.read'. Use this OR `permissions`."},"permissions":{"description":"Multiple permissions; ALL must be held for this check to pass.","type":"array","items":{"type":"string"}}}},"AuthorizeBatchBody":{"type":"object","properties":{"token":{"type":"string","description":"JWT to verify and authorize against each check. Optional — if omitted, the token is read from the `Authorization: Bearer <jwt>` header."},"checks":{"description":"List of checks. Each returns { id?, authorized, missing_permissions }.","type":"array","items":{"$ref":"#/components/schemas/AuthorizeBatchEntry"}}},"required":["checks"]},"AuthorizeBatchResultDto":{"type":"object","properties":{"id":{"type":"string","description":"Caller-supplied id from the corresponding check entry. Echoed back so callers can correlate results to inputs without relying on array order."},"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 / expired / deleted 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":"https://api.auth.productcraft.co/<appSlug>","description":"Issuer URL — the public Auth API base joined with this app slug. Pin this in your local JWKS verifier so a token minted for another app on the platform cannot pass."},"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.","example":"john_doe"},"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. When supplied it must satisfy the SAME rules as `/auth/signup`: the app-configured length and character classes, the common-password deny-list (400 `PASSWORD_TOO_COMMON`) and, unless the app sets `password_breach_check` to `off` or `warn`, the breach corpus (400 `PASSWORD_BREACHED`)."},"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"},"password_warnings":{"description":"Present only when the app runs `password_breach_check: 'warn'` and the supplied password was found in the breach corpus. The account IS created; in `'block'` mode the request would have been rejected with 400 `PASSWORD_BREACHED` instead. Same shape as the signup response.","example":["breached"],"type":"array","items":{"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"]},"ConsumerAdminRoleAssignmentResponseDto":{"type":"object","properties":{"user_id":{"type":"string","description":"Account UUID of the end-user whose role changed."},"role_name":{"type":"string","description":"Role name now bound to the app membership.","example":"admin"}},"required":["user_id","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"]},"FactorListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicFactorDto"}}},"required":["data"]},"StartEnrollmentDto":{"type":"object","properties":{"type":{"type":"string","enum":["totp","email","webauthn"],"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},"contact_id":{"type":"string","description":"Email factors only: id of the verified email contact the codes should be delivered to. Defaults to the primary verified email. 412 when the contact (or the primary email) is unverified.","format":"uuid"}},"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"]},"WebauthnCredentialDto":{"type":"object","properties":{"id":{"type":"string","description":"base64url credential id (`PublicKeyCredential.id`)."},"response":{"type":"object","description":"The `response` member, with `clientDataJSON` + `attestationObject` (registration) or `clientDataJSON` + `authenticatorData` + `signature` + `userHandle` (assertion), each base64url-encoded.","additionalProperties":true}},"required":["id","response"]},"ConfirmEnrollmentDto":{"type":"object","properties":{"codes":{"description":"TOTP factors: 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"}},"code":{"type":"string","description":"Email factors: the single 6-digit code that was emailed when enrollment started.","example":"123456"},"credential":{"description":"Passkey factors: the `PublicKeyCredential` returned by `navigator.credentials.create()`, ArrayBuffers base64url-encoded. The challenge inside `clientDataJSON` must be the one issued when enrollment started.","allOf":[{"$ref":"#/components/schemas/WebauthnCredentialDto"}]}}},"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"]},"StepUpSendCodeDto":{"type":"object","properties":{"factor_id":{"type":"string","description":"Email factor to deliver the code through. Defaults to the first enabled email factor on the account.","format":"uuid"}}},"StepUpSendCodeResponseDto":{"type":"object","properties":{"email_masked":{"type":"string","description":"Masked address the code was sent to.","example":"a***a@e***e.com"},"code_expires_at":{"type":"string","format":"date-time"}},"required":["email_masked","code_expires_at"]},"WebauthnOptionsResponseDto":{"type":"object","properties":{"public_key":{"type":"object","description":"The `publicKey` member to hand to `navigator.credentials.get()` verbatim: `challenge`, `rpId`, `timeout`, `userVerification` and `allowCredentials`. Single-use; expires after 5 minutes.","additionalProperties":true}},"required":["public_key"]},"StepUpDto":{"type":"object","properties":{"code":{"type":"string","description":"TOTP or emailed code (6 digits) OR a recovery code (16 hex chars, hyphenated or not). Emailed codes are requested via `/me/mfa/step-up/send-code` first. Omit when submitting `credential` instead.","example":"123456"},"credential":{"description":"Passkey step-up: the `PublicKeyCredential` returned by `navigator.credentials.get()` against the options from `/me/mfa/step-up/webauthn/options`. Mutually exclusive with `code`.","allOf":[{"$ref":"#/components/schemas/WebauthnCredentialDto"}]}}},"StepUpResponseDto":{"type":"object","properties":{"amr":{"example":["pwd","webauthn"],"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","description":"Present on the ordinary sign-in outcome. Absent when `mfa_enrollment_required` is true — no session was minted."},"refresh_token":{"type":"string"},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"number","description":"Lifetime of the access token in seconds."},"mfa_enrollment_required":{"type":"number","enum":[true],"description":"Set when the app runs `auth_config.mfa: \"required\"` and the account holds no enabled factor. NO session was minted; the token fields are absent and `mfa_token` carries the enrolment challenge instead."},"mfa_token":{"type":"string","description":"Enrolment challenge token. Drive `POST /{app_slug}/v1/auth/mfa/enroll` then `/auth/mfa/enroll/verify` with it to enrol a factor and receive tokens. Session-equivalent — treat it like a refresh token and never place it in a URL."},"expires_at":{"type":"string","description":"ISO-8601 expiry of `mfa_token` (10 minutes out)."}}},"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."},"mfa_enrollment_due_at":{"type":"string","description":"Present only when the app runs `mfa: \"required\"`, the user holds no enabled factor, and the account is still inside `mfa_grace_days` of its creation. Tokens ARE issued; this is the deadline after which federated sign-in starts returning `mfa_enrollment_required` instead.","format":"date-time"}},"required":["access_token","refresh_token","token_type","expires_in"]},"IdpMfaEnrollmentRequiredResponseDto":{"type":"object","properties":{"mfa_enrollment_required":{"type":"boolean","example":true},"mfa_token":{"type":"string","description":"Single-use enrolment token. Not an access token: no session has been created. Complete via `POST /{app_slug}/v1/auth/mfa/enroll` then `/auth/mfa/enroll/verify`."},"expires_at":{"type":"string","format":"date-time"}},"required":["mfa_enrollment_required","mfa_token","expires_at"]},"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"]},"CallbackBodyDto":{"type":"object","properties":{}},"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"]},"environment":{"type":"string","enum":["production","staging","development"],"description":"Which environment this app IS. One app is one environment — there is no switch; a staging app is a separate app created via `POST /v1/apps/:appId/clone`. Set at create time and never changed afterwards. Every app that predates this field is `production`.","example":"production"},"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"},"audit_retention_days":{"type":"number","description":"Days of `audit_log` history kept for this app. A nightly job deletes rows older than this. 30-3650.","example":365},"webhook_attempt_retention_days":{"type":"number","description":"Days of outbound-webhook delivery-attempt history kept for this app. 7-365.","example":30}},"required":["id","slug","display_name","workspace_id","status","environment","created_by","metadata","created_at","updated_at","audit_retention_days","webhook_attempt_retention_days"]},"AppListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AppResponseDto"}},"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 `auth.create` on this workspace.","example":"835015dc-7bde-4a8a-b306-c066d4733b90"},"environment":{"type":"string","description":"Which environment this app is. Omit for `production` — the historical behaviour and the value every existing app has. Cannot be changed later; clone the app instead.","enum":["production","staging","development"],"default":"production"}},"required":["slug","display_name","workspace_id"]},"CloneAppDto":{"type":"object","properties":{"slug":{"type":"string","description":"Slug for the new app. Must be free — a slug still live as a rename alias counts as taken.","example":"acme-app-staging","minLength":3,"maxLength":64},"environment":{"type":"string","description":"Environment the clone is. Usually `staging`.","enum":["production","staging","development"],"example":"staging"},"display_name":{"type":"string","description":"Display name for the clone. Defaults to the source's, which is what makes the console group the two together.","example":"Acme","minLength":1,"maxLength":120},"copy_tenants":{"type":"boolean","description":"Also copy the source app's tenants (slug, display name, metadata, status). Memberships are NEVER copied — they point at end-user accounts, which are not copied either, so a cloned tenant is an empty tenant. Default false.","default":false}},"required":["slug","environment"]},"CloneAppResponseDto":{"type":"object","properties":{"app":{"$ref":"#/components/schemas/AppResponseDto"},"copied":{"type":"object","description":"What came across: counts for roles, permissions, role_permissions and tenants, plus booleans for notification settings and the webhook."},"providers_to_configure":{"description":"Federated providers configured on the source that the clone does NOT have. A provider config IS its secret (Apple private key, OAuth client secret), so there is nothing safe to copy — configure these on the clone before using federated sign-in.","example":["apple","google"],"type":"array","items":{"type":"string"}},"webhook_signing_secret":{"type":"string","nullable":true,"description":"Freshly minted webhook signing secret for the clone, returned exactly ONCE. Null when the source had no webhook, or when its target no longer resolves safely. The source app's secret is never reused."},"notes":{"description":"Human-readable notes about anything deliberately skipped.","type":"array","items":{"type":"string"}}},"required":["app","copied","providers_to_configure","webhook_signing_secret","notes"]},"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, not shaped like a UUID.","example":"acme-corp"},"metadata":{"type":"object","description":"Arbitrary metadata attached to the app","example":{"plan":"pro","region":"us-east-1"}},"audit_retention_days":{"type":"number","description":"Days of `audit_log` history kept for this app. A nightly job deletes rows older than this. Minimum 30 days so an incident review always has a month to look at; maximum 3650 (10 years).","minimum":30,"maximum":3650,"default":365,"example":365},"webhook_attempt_retention_days":{"type":"number","description":"Days of outbound-webhook delivery-attempt history kept for this app. Attempts are a debugging aid rather than a compliance record, hence the shorter floor.","minimum":7,"maximum":365,"default":30,"example":30}}},"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"]},"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},"access_token_minutes":{"type":"number","example":60},"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":null,"nullable":true},"enforce_app_permissions":{"type":"boolean"},"oauth_link_policy":{"type":"string","enum":["auto","confirm","reject"]},"allowed_redirect_origins":{"type":"array","items":{"type":"string"}},"password_breach_check":{"type":"string","enum":["off","warn","block"],"example":"block"},"mfa":{"type":"string","enum":["optional","required"],"example":"optional"},"mfa_grace_days":{"type":"number","example":0}},"required":["signup_enabled","signin_enabled","signup_allowed_email_domains","signup_default_role","session_duration_minutes","access_token_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","password_breach_check","mfa","mfa_grace_days"]},"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},"access_token_minutes":{"type":"number","description":"EndUser access-token lifetime in minutes (5-60, default 60). Independent of `session_duration_minutes`: the session and its refresh token can live for days while the access token expires in minutes. An access token is never issued for longer than the session it belongs to has left. M2M (client-credentials) tokens are unaffected and keep their fixed one-hour lifetime.","example":60,"minimum":5,"maximum":60},"max_failed_login_attempts":{"type":"number","description":"Consecutive failed signin attempts before an EndUser is locked out for 15 minutes. Applies to the signin lockout counters only; the separate short-burst limiter (5 attempts per identifier per minute) is fixed and not configurable.","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. When a signin would take the account past this number, the OLDEST session — the one created longest ago, not the one used longest ago — is revoked (audited as `auth.session.evicted`). Omit or send null for unlimited.","example":5,"nullable":true,"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"}},"password_breach_check":{"type":"string","description":"Whether passwords are checked against a public breach corpus (HIBP Pwned Passwords, k-anonymity range API) before being accepted. `block` (default) rejects a breached password with 400 `PASSWORD_BREACHED`. `warn` accepts it and returns `password_warnings: [\"breached\"]` alongside the tokens. `off` disables the lookup entirely, so no outbound call is made. The lookup always fails open — a timeout or an unreachable corpus never blocks a signup. Only the first five characters of the password's SHA-1 ever leave the service.","enum":["off","warn","block"]},"mfa":{"type":"string","description":"Whether every EndUser must hold an MFA factor. `optional` (default) leaves MFA per-user opt-in. `required` refuses to mint a session for a user with no enabled factor: sign-in answers `{ mfa_enrollment_required: true, mfa_token, expires_at }` and the user enrols via POST /{app_slug}/v1/auth/mfa/enroll. IMPORTANT: turning this on does NOT terminate sessions that are already live — existing users keep their session until it expires or is revoked and only meet the requirement on their next sign-in. Revoke sessions explicitly if the policy has to bite immediately.","enum":["optional","required"]},"mfa_grace_days":{"type":"number","description":"Grace window in days, measured from ACCOUNT CREATION, during which `mfa: required` still mints ordinary tokens. The sign-in response then also carries `mfa_enrollment_due_at` so a client can prompt rather than block. 0 (default) means no grace. Note the anchor: an app that turns the policy on with existing users will find every account older than the window is already past it.","example":0,"minimum":0,"maximum":365}}},"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"]},"PatchProviderConfigDto":{"type":"object","properties":{"config":{"type":"object","description":"PARTIAL provider config. Only top-level keys present here are changed; omitted keys — secrets included — keep their stored values, so you can update e.g. an Apple service_id without re-uploading the private key. The merged result is validated by the provider impl's `parseConfig` before storage."},"enabled":{"type":"boolean","description":"Flip the provider live/inactive without touching config."}}},"WebauthnConfigResponseDto":{"type":"object","properties":{"enabled":{"type":"boolean"},"rp_id":{"type":"string","nullable":true},"rp_name":{"type":"string","nullable":true},"require_user_verification":{"type":"boolean"},"ceremony_origins":{"description":"Origins a passkey ceremony will be accepted from: the entries of `allowed_redirect_origins` that `rp_id` actually covers. An EMPTY array while `enabled` is true means the app is misconfigured and every ceremony will be refused with 412 — the usual cause is an `allowed_redirect_origins` edit that dropped the passkey site.","type":"array","items":{"type":"string"}},"credential_count":{"type":"number","description":"Number of passkeys currently registered against this app. Non-zero means `rp_id` is frozen: it is hashed into every one of those credentials, so a PUT that changes it is refused with 409. Exposed so an admin UI can say that before the change is attempted rather than after."}},"required":["enabled","rp_id","rp_name","require_user_verification","ceremony_origins","credential_count"]},"UpdateWebauthnConfigDto":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether passkey enrollment and passkey sign-in are available for this app. Turning it off leaves existing credentials in place; they simply cannot be used until it is turned back on."},"rp_id":{"type":"string","description":"WebAuthn relying-party id — a bare domain, no scheme or port (`example.com`, not `https://app.example.com`). Must be the host of, or a registrable suffix of the host of, at least one origin in the app auth config's `allowed_redirect_origins`; the browser enforces the same rule client-side, so a mismatch makes the ceremony fail silently in the user's browser. Cannot be changed once any passkey is registered.","example":"example.com"},"rp_name":{"type":"string","description":"Human-readable name the platform passkey UI shows (\"Save a passkey for <rp_name>\"). Defaults to `rp_id` when blank.","example":"Acme"},"require_user_verification":{"type":"boolean","description":"Require the authenticator to verify the user (biometric / PIN) for SECOND-FACTOR ceremonies. Passwordless sign-in always requires it regardless of this flag — a presence-only tap is not an authentication when the passkey is the only credential. Default true."}},"required":["enabled","rp_id"]},"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 users-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"]},"AuditSinkResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"url":{"type":"string"},"signing_secret_hint":{"type":"string","description":"Last 4 chars of the signing secret."},"enabled":{"type":"boolean"},"disabled_at":{"type":"string","format":"date-time","nullable":true},"disabled_reason":{"type":"string","nullable":true},"consecutive_failures":{"type":"number"},"last_delivered_at":{"type":"string","format":"date-time","nullable":true,"description":"When a batch was last accepted (2xx) by the receiver."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","app_id","url","signing_secret_hint","enabled","disabled_at","disabled_reason","consecutive_failures","last_delivered_at","created_at","updated_at"]},"UpsertAuditSinkDto":{"type":"object","properties":{"url":{"type":"string","description":"HTTPS endpoint that receives batched audit rows. Validated at config time AND re-resolved on every delivery, so a URL that later points at a private address is refused rather than dialled.","example":"https://siem.customer.example.com/ingest/productcraft-auth"}},"required":["url"]},"AuditSinkWithSecretResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"url":{"type":"string"},"signing_secret_hint":{"type":"string","description":"Last 4 chars of the signing secret."},"enabled":{"type":"boolean"},"disabled_at":{"type":"string","format":"date-time","nullable":true},"disabled_reason":{"type":"string","nullable":true},"consecutive_failures":{"type":"number"},"last_delivered_at":{"type":"string","format":"date-time","nullable":true,"description":"When a batch was last accepted (2xx) by the receiver."},"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, here. Only the encrypted envelope is stored."}},"required":["id","app_id","url","signing_secret_hint","enabled","disabled_at","disabled_reason","consecutive_failures","last_delivered_at","created_at","updated_at","signing_secret"]},"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},"last_activity_at":{"type":"string","format":"date-time","nullable":true,"description":"Most recent audit-log entry with this user as the actor. Null means we have never recorded them doing anything — which is a different fact from \"no session\", and the one that tells you whether they ever got in."},"failed_signins":{"type":"number","description":"Failed sign-ins in the last 30 days. Flagged at 3."},"mfa_factor_count":{"type":"number","description":"Enabled auth factors (TOTP, email OTP)."},"verified_email_count":{"type":"number","description":"Verified email contacts, primary or not. Distinct from `emailVerifiedAt`, which is the primary contact only — the `unverified` filter accepts any verified email, so a renderer branching on `emailVerifiedAt` would disagree with it."},"auth_providers":{"description":"How this person can sign in: `password` when a credential exists, plus each linked OAuth provider (`google`, `apple`, …). Empty means neither — an admin-created account that has not been sent a password reset cannot get in at all, and that is a real state rather than missing data.","example":["password","google"],"type":"array","items":{"type":"string"}},"step_up_locked_until":{"type":"string","format":"date-time","nullable":true,"description":"Set while a live session is locked out of step-up verification."}},"required":["id","username","display_name","status","role","joined_at","created_at","email","email_verified_at","active_session_count","last_used_at","last_activity_at","failed_signins","mfa_factor_count","verified_email_count","auth_providers","step_up_locked_until"]},"EndUserListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EndUserSummaryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"ImportJobErrorDto":{"type":"object","properties":{"line":{"type":"number","description":"1-based line number in the uploaded NDJSON. -1 marks the truncation notice."},"error":{"type":"string"}},"required":["line","error"]},"ImportJobResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"status":{"type":"string","enum":["queued","running","succeeded","failed"],"description":"`succeeded` means the job ran to completion, not that every row landed — read `failed_count`."},"total_rows":{"type":"number"},"processed_rows":{"type":"number"},"created_count":{"type":"number"},"updated_count":{"type":"number"},"failed_count":{"type":"number"},"errors":{"description":"First 500 per-row failures. `failed_count` stays exact.","type":"array","items":{"$ref":"#/components/schemas/ImportJobErrorDto"}},"error_message":{"type":"string","nullable":true,"description":"Set when the JOB itself aborted, as opposed to a row failing."},"created_at":{"type":"string","format":"date-time"},"started_at":{"type":"string","format":"date-time","nullable":true},"finished_at":{"type":"string","format":"date-time","nullable":true}},"required":["id","app_id","status","total_rows","processed_rows","created_count","updated_count","failed_count","errors","error_message","created_at","started_at","finished_at"]},"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"]},"EndUserRoleAssignmentResponseDto":{"type":"object","properties":{"user_id":{"type":"string","description":"Account UUID of the end-user whose role changed."},"role_name":{"type":"string","description":"Role name now bound to the app membership.","example":"admin"}},"required":["user_id","role_name"]},"RequireMfaReenrollmentResponseDto":{"type":"object","properties":{"factors_disabled":{"type":"number","description":"How many enabled factors were disabled. Zero when the account already held none — the call still destroys recovery codes and revokes sessions.","example":1}},"required":["factors_disabled"]},"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."},"signal":{"type":"string","description":"mode=filter only. Same risk-signal filter as GET /end-users. Present so a bulk operation acts on exactly the rows the operator was looking at — omitting it would silently widen the selection to every account in the app.","enum":["unverified","failing_signin","no_mfa","locked","never_active"]},"provider":{"type":"string","description":"mode=filter only. Same provider filter as GET /end-users. Carried for the same reason as `signal`: the server re-resolves the set at send time, so omitting it widens the operation past what the operator was looking at."}},"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"]},"CreateEndUserInviteDto":{"type":"object","properties":{"email":{"type":"string","description":"Invitee email. Locks the invite to this address.","example":"alice@example.com"},"name":{"type":"string","description":"Optional human-readable name shown in the email.","example":"Alice Wong"},"role_name":{"type":"string","description":"Role slug to grant on accept. Defaults to `member`.","example":"member"},"max_uses":{"type":"number","description":"Maximum number of accepts. Defaults to 1.","minimum":1,"maximum":10000},"expires_in_hours":{"type":"number","description":"Hours until the invite expires. Defaults to 72 (3 days).","minimum":1,"maximum":720}},"required":["email"]},"EndUserInviteResponseDto":{"type":"object","properties":{"id":{"type":"string"},"code":{"type":"string","description":"Invite code the recipient submits to accept."},"email":{"type":"string","nullable":true},"role_name":{"type":"string","nullable":true},"role_id":{"type":"string"},"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","email","role_name","role_id","max_uses","use_count","expires_at","revoked_at","created_at"]},"EndUserInviteListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EndUserInviteResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"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"}},"expires_in_days":{"type":"number","description":"Lifetime of the key in days. Omit or send `null` for a key that never expires — the historical behaviour and still the default.","enum":[30,90,365],"nullable":true,"example":90}},"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"}},"expires_at":{"format":"date-time","type":"string","nullable":true,"description":"When this key stops authenticating. `null` means it never expires."},"created_at":{"format":"date-time","type":"string"}},"required":["id","name","key","key_prefix","permissions","expires_at","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},"status":{"type":"string","description":"Lifecycle state at the time of the request. `active` keys authenticate; `expired` and `revoked` ones do not. Revoked wins over expired.","enum":["active","expired","revoked"]},"expires_at":{"format":"date-time","type":"string","nullable":true,"description":"`null` = never expires."},"revoked_at":{"format":"date-time","type":"string","nullable":true},"revoked_by":{"type":"string","nullable":true,"description":"PlatformUser id of the revoker."},"rotated_from":{"type":"string","nullable":true,"description":"Id of the key this one replaced, if it was minted by a rotation."},"created_at":{"format":"date-time","type":"string"}},"required":["id","name","key_prefix","permissions","last_used_at","status","expires_at","revoked_at","revoked_by","rotated_from","created_at"]},"ApiKeyListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeySummaryDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"RotateApiKeyDto":{"type":"object","properties":{"grace_hours":{"type":"number","description":"How long the OUTGOING key keeps working, in hours. `0` kills it immediately. Capped at 168 (7 days) — a rotation answers \"this key may be compromised\", so an unbounded overlap would defeat the point. Rotation can only ever shorten a key: if the old key was already due to expire sooner, that earlier instant stands.","minimum":0,"maximum":168,"default":24,"example":24}}},"RotateApiKeyResponseDto":{"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"}},"expires_at":{"format":"date-time","type":"string","nullable":true,"description":"When this key stops authenticating. `null` means it never expires."},"created_at":{"format":"date-time","type":"string"},"rotated_from":{"type":"string","description":"Id of the key this one replaces."},"previous_key_expires_at":{"format":"date-time","type":"string","description":"When the replaced key stops authenticating. Until then BOTH keys work, so a fleet can roll over without downtime."}},"required":["id","name","key","key_prefix","permissions","expires_at","created_at","rotated_from","previous_key_expires_at"]},"PlatformUserStatsResponseDto":{"type":"object","properties":{"apps":{"type":"number","description":"Auth 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 Mail 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":"Mail template fired for end-user email verification; `null` when unset.","nullable":true},"welcome_template_name":{"type":"string","description":"Mail template fired right after a successful verification.","nullable":true},"password_reset_template_name":{"type":"string","description":"Mail template fired on password-reset request.","nullable":true},"invite_template_name":{"type":"string","description":"Mail template fired when an end-user is invited into the app; `null` when unset (the invite row and code are still created, but no email goes out).","nullable":true},"mfa_code_template_name":{"type":"string","description":"Mail template fired to deliver email-MFA sign-in / step-up codes; `null` when unset (email MFA challenges fail loudly until configured).","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","invite_template_name","mfa_code_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 Mail 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":"Mail 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":"Mail template name fired right after an end-user verifies their email (welcome message).","nullable":true},"password_reset_template_name":{"type":"string","description":"Mail template name fired when an end-user requests a password reset.","nullable":true},"invite_template_name":{"type":"string","description":"Mail template name fired when an end-user is invited into the app (`POST /v1/apps/:appId/end-users/invites` with an `email`). Until this slot is populated the invite row is still created and the code returned, but no email is dispatched.","nullable":true},"mfa_code_template_name":{"type":"string","description":"Mail template name fired when an email-type MFA factor needs a sign-in / step-up code delivered. Required (along with the master switch + sender) before email MFA factors can complete a challenge.","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 Auth. 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 the email when omitted.","example":"john_doe"},"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"]},"CreateTenantInviteDto":{"type":"object","properties":{"email":{"type":"string","description":"Address the invite is bound to. The account created on accept gets this as its verified primary email, so it cannot be changed after mint — revoke and re-invite instead.","example":"alice@example.com","maxLength":254},"name":{"type":"string","description":"Human-readable name rendered in the invite email.","example":"Alice Wong","maxLength":200},"role_name":{"type":"string","description":"Tenant-scoped role granted on accept. Case-insensitive lookup against this app's `role` table.","example":"member"},"max_uses":{"type":"number","description":"Number of times the code may be redeemed. Defaults to 1. Values above 1 only matter for a code you intend to hand out manually — the first acceptance consumes the email binding, so a second accept of the same address is rejected regardless.","minimum":1,"maximum":10,"default":1},"expires_in_hours":{"type":"number","description":"Hours until the invite expires. Defaults to 168 (7 days).","minimum":1,"maximum":720,"default":168}},"required":["email","role_name"]},"CreateTenantInviteResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tenant_id":{"type":"string"},"email":{"type":"string","example":"alice@example.com"},"role_id":{"type":"string"},"role_name":{"type":"string","nullable":true,"example":"member"},"status":{"type":"string","enum":["pending","revoked","expired","used"],"description":"Derived, never stored: revoked > expired > used > pending."},"max_uses":{"type":"number","example":1},"use_count":{"type":"number","example":0},"expires_at":{"type":"string","format":"date-time"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"invited_by":{"type":"string","nullable":true,"description":"Account id of the inviter. Null when the invite was minted by a Platform API Key, which has no account row — the audit log carries that attribution instead."},"created_at":{"type":"string","format":"date-time"},"code":{"type":"string","description":"The plaintext invite code, returned EXACTLY ONCE. Only its SHA-256 digest is stored, so this value cannot be recovered from the list endpoint or the database — if you lose it, revoke the invite and mint a new one. It is also mailed to the invitee through the app's `invite_template_name` Envoi slot.","example":"tinv_9dK2mQ7pR4vXbN1sT6yZ0hLcJeW3fAgU"}},"required":["id","tenant_id","email","role_id","role_name","status","max_uses","use_count","expires_at","revoked_at","invited_by","created_at","code"]},"TenantInviteResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tenant_id":{"type":"string"},"email":{"type":"string","example":"alice@example.com"},"role_id":{"type":"string"},"role_name":{"type":"string","nullable":true,"example":"member"},"status":{"type":"string","enum":["pending","revoked","expired","used"],"description":"Derived, never stored: revoked > expired > used > pending."},"max_uses":{"type":"number","example":1},"use_count":{"type":"number","example":0},"expires_at":{"type":"string","format":"date-time"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"invited_by":{"type":"string","nullable":true,"description":"Account id of the inviter. Null when the invite was minted by a Platform API Key, which has no account row — the audit log carries that attribution instead."},"created_at":{"type":"string","format":"date-time"}},"required":["id","tenant_id","email","role_id","role_name","status","max_uses","use_count","expires_at","revoked_at","invited_by","created_at"]},"TenantInviteListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TenantInviteResponseDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"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/auth"},"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"},"dead_letter_dropped_count":{"type":"number","description":"Events that were NOT dead-lettered because this config's unreplayed backlog was at its 1,000-row cap. Non-zero means events for this app were lost with no row to replay — drain the dead-letter queue.","example":0},"dead_letter_dropped_at":{"type":"string","format":"date-time","nullable":true,"description":"When the most recent event was dropped for a full backlog."},"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","dead_letter_dropped_count","dead_letter_dropped_at","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/auth","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","tenant.invite.created","tenant.invite.accepted","tenant.invite.revoked","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","user.mfa.enrollment_required","user.mfa.reenrollment_required"]}}},"required":["url","event_types"]},"AppWebhookConfigWithSecretResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"url":{"type":"string","example":"https://customer.example.com/webhooks/auth"},"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"},"dead_letter_dropped_count":{"type":"number","description":"Events that were NOT dead-lettered because this config's unreplayed backlog was at its 1,000-row cap. Non-zero means events for this app were lost with no row to replay — drain the dead-letter queue.","example":0},"dead_letter_dropped_at":{"type":"string","format":"date-time","nullable":true,"description":"When the most recent event was dropped for a full backlog."},"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","dead_letter_dropped_count","dead_letter_dropped_at","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"]},"AppWebhookAttemptPageDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AppWebhookDeliveryAttemptDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"AppWebhookDeadLetterDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string"},"event_id":{"type":"string"},"event_type":{"type":"string","example":"user.email_verified"},"payload":{"type":"object","description":"The original signed event body. A replay re-delivers exactly this."},"attempts":{"type":"number","description":"Delivery attempts made before giving up."},"last_status_code":{"type":"number","nullable":true},"last_error":{"type":"string","nullable":true},"failed_at":{"format":"date-time","type":"string"},"replayed_at":{"format":"date-time","type":"string","nullable":true,"description":"Null until a replay of this event has been accepted (2xx)."},"replay_count":{"type":"number"}},"required":["id","app_id","event_id","event_type","payload","attempts","last_status_code","last_error","failed_at","replayed_at","replay_count"]},"AppWebhookDeadLetterPageDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AppWebhookDeadLetterDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"ReplayResultDto":{"type":"object","properties":{"status":{"type":"string","enum":["delivered","failed"]},"status_code":{"type":"number","nullable":true},"error":{"type":"string","nullable":true}},"required":["status","status_code","error"]},"BulkReplayResultDto":{"type":"object","properties":{"replayed":{"type":"number","description":"Dead letters accepted by the receiver (2xx)."},"remaining":{"type":"number","description":"Dead letters in this batch that were not replayed, because the walk stopped at the first failure."},"stopped_on":{"type":"object","nullable":true,"description":"The dead letter the walk stopped on, with the receiver response. Null when the whole batch landed."}},"required":["replayed","remaining","stopped_on"]}}}}