{"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"404":{"description":"App slug does not match an app in this workspace.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"404":{"description":"App slug does not match an app in this workspace.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.\n\nWhen the app has bot protection on (`auth_config.bot_protection`, Cloudflare Turnstile or hCaptcha), send the widget response as `bot_protection_token`. Auth verifies it with the provider before it looks at the email, username or password, and refuses the sign-up when the token is missing (400 `BOT_PROTECTION_REQUIRED`), rejected (403 `BOT_PROTECTION_FAILED`) or cannot be checked (503 `BOT_PROTECTION_UNAVAILABLE`; no account is created). This applies to PAK-authenticated calls too: a backend forwards the token its browser received.","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"}]}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`BOT_PROTECTION_REQUIRED`: the app has bot protection on and the request carried no `bot_protection_token`. Also: 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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","BOT_PROTECTION_REQUIRED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"The app sets `signup_requires_pak` and no Platform API Key was presented on `Authorization: Bearer`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"`BOT_PROTECTION_FAILED`: the bot-protection provider rejected the `bot_protection_token` (bad, expired or already used), or the hostname it reported is not one of the app's `bot_protection.expected_hostnames`; render a fresh widget and retry. Otherwise: 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`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","BOT_PROTECTION_FAILED","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"`BOT_PROTECTION_UNAVAILABLE`: the app has bot protection on and the token could not be checked (the provider timed out or failed, or the app's bot-protection secret is missing or was refused by the provider). Sign-up fails closed: no account was created. Retry shortly. Otherwise: The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE","BOT_PROTECTION_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}]}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Email or username already in use.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"Invite revoked, expired, or fully used.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"An account already exists for the invited address (`ACCOUNT_EXISTS`), or the requested username is taken.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","ACCOUNT_EXISTS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"Invite revoked, expired, or fully used.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}]}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Factor is not an email factor on this challenge, or request validation failed (missing / malformed body field) in the global `ValidationPipe`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Invalid or expired challenge.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"The bound email contact no longer exists or is no longer verified.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"The app has not completed Mail notification setup for MFA codes.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Invalid or expired challenge, or wrong code.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"No passkey is enrolled on the account behind this challenge.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Invalid or expired challenge.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"Passkeys are not enabled, or no allowed redirect origin matches the configured relying-party id.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Signin is disabled for this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"Passkeys are not enabled, or no allowed redirect origin matches the configured relying-party id.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Invalid or expired challenge, or wrong recovery code.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unsupported factor type, or the account already holds a factor.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"Email factor requested but the bound contact is unverified, or the app has not completed Mail notification setup.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Enrolment token invalid, expired, already used, or minted for a different app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"The refresh token matches no session.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Provide exactly one of `email` or `phone`, or request validation failed in the global `ValidationPipe`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"No PAK provided.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"PAK lacks `auth.user.verify.create` on this app, or the app is suspended / archived.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"No PAK provided.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"PAK lacks auth.user.verify.send-email on this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"Mail precondition not met (see error code in the response body).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"Mail dispatch failed or client not wired.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"Code invalid, expired, locked, or already consumed.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Provide exactly one of `email` or `phone`, or request validation failed in the global `ValidationPipe`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"No PAK provided.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"PAK lacks `auth.user.password-reset.create` on this app, or the app is suspended / archived.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"No PAK provided.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"PAK lacks auth.user.password-reset.send-email on this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"Mail precondition not met (see error code in the response body).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"Mail dispatch failed or client not wired.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","PASSWORD_TOO_COMMON","PASSWORD_BREACHED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"Code invalid, expired, locked, or already consumed.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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. The new access token keeps the session's `amr` and `mfa_at` (as `/auth/refresh` does), so a sign-in that completed MFA still proves it after a switch. 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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing / invalid bearer, the token carries no `sid`, or the session named by `sid` is revoked, expired or no longer exists.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The caller holds no membership in the target tenant, or the app is suspended / archived.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"The `:appSlug` matches no app, or `tenant_id` names no tenant in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing / invalid bearer, or the session behind the token is revoked, expired or no longer exists.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app exists but is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`). Decided by `AppStatusGuard` before the handler runs.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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 VERIFIED email contacts in this app — holding the code is not sufficient, because whoever minted the invite also holds it, and an unverified contact is not proof of owning the address. A refused accept leaves the invite redeemable.","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Two cases, told apart by `error.code`: `FORBIDDEN` — the invite was issued to an address that is not a contact of this account; `invite_email_unverified` — the invited address is a contact of this account but is not verified yet. Verify the address, then retry with the same code.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No such invite in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Already a member of that tenant.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"Invite revoked, expired, or fully used.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Account not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Requested username is already held by another account in this app (`USERNAME_TAKEN`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","USERNAME_TAKEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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. The app audit log keeps one `auth.account.deleted` row (`via: self`; username and email domain only, never the address), and `user.deleted` fires with `via: \"self\"`.","operationId":"ConsumerMeController_deleteAccount","parameters":[{"name":"app_slug","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Account deleted.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"413":{"description":"File exceeds 2 MB (`AVATAR_TOO_LARGE`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PAYLOAD_TOO_LARGE","AVATAR_TOO_LARGE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"415":{"description":"Wrong mimetype, undecodable image, or content does not match the declared type (`AVATAR_INVALID_TYPE` / `AVATAR_MAGIC_MISMATCH`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE","AVATAR_INVALID_TYPE","AVATAR_MAGIC_MISMATCH"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"404":{"description":"Account not found in this app (`ACCOUNT_NOT_FOUND`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","ACCOUNT_NOT_FOUND"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","PASSWORD_TOO_COMMON","PASSWORD_BREACHED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Current password incorrect or no credential.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app enforces permissions and the caller's role (or M2M scopes) lacks `session.revoke`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): the general bucket, 60 requests per minute per IP. Adding, verifying or removing a contact is in the strict `auth` bucket (10 per minute).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed: unsupported `type`, invalid email, or empty/over-long `value`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"409":{"description":"Contact value already in use for this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): `/me/contacts` is in the strict `auth` bucket (10 requests per minute per IP).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"404":{"description":"Contact not found on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Cannot delete the primary verified email; promote another email first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): `/me/contacts` is in the strict `auth` bucket (10 requests per minute per IP).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"404":{"description":"Contact not found on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Contact is unverified and cannot be promoted to primary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): `/me/contacts` is in the strict `auth` bucket (10 requests per minute per IP).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"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`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"404":{"description":"Uniform miss: app unknown, account not in this app, or no avatar set.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed (empty `token`), or no JWT supplied: neither `token` in the body nor an `Authorization: Bearer` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed, neither `permission` nor `permissions` given, or no JWT supplied (body `token` or `Authorization: Bearer`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed, a check has neither `permission` nor `permissions`, or no JWT supplied (body `token` or `Authorization: Bearer`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`grant_type` is missing or is not `client_credentials`, or request validation failed in the global `ValidationPipe`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"The `:appSlug` in the path matches no app. About the **app**, not the client — decided before `client_id` is looked at.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed in the global `ValidationPipe`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"The app is suspended (`APP_SUSPENDED`) or archived (`APP_ARCHIVED`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNSUPPORTED_MEDIA_TYPE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid email, unknown role, or a password that fails the app policy — `PASSWORD_TOO_COMMON` / `PASSWORD_BREACHED`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","PASSWORD_TOO_COMMON","PASSWORD_BREACHED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Email or username already in use.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`, or a cursor issued for a different sort order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `user.list`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"End-user not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed: `display_name` over 120 chars or `email` not a valid address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `user.update`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app, or the end-user is not in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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, and the deletion itself is audited as `auth.account.deleted` with `via: consumer_admin` and the caller as actor. Fires `user.deleted` with `via: \"consumer_admin\"`.","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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `user.delete`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed: `status` must be `active`, `suspended` or `deactivated`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `user.update`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app, or the end-user is not in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed, or `role_name` names no role in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `role.assign`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions. Also returned when an EndUser admin tries to assign a role carrying permissions they do not hold.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Role name already exists in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `role.read`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Role not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Role not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Cannot delete a system role.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Role is in use by at least one member.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown permission(s) in the request.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller is missing one or more permissions they're trying to grant.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Role not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Conflicts with a system permission or already exists in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired Consumer-API bearer: an EndUser or M2M token, or an app API key (`hdk_live_*` / `hdk_test_*`); or the account, session, M2M client or key is no longer active, or the key belongs to another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The app is suspended or archived (`APP_SUSPENDED` / `APP_ARCHIVED`), or the caller lacks `permission.read`: an app API key always needs it in its permissions; a role or M2M scopes need it once the app enforces permissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","APP_SUSPENDED","APP_ARCHIVED"]},"404":{"description":"The `:appSlug` matches no app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"permissionKey must be in `resource.action` format.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Cannot delete system permissions.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Permission not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing bearer token, or the Platform API Key is malformed, unknown or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The PAK policy denies `auth.user.factors.read` on this app's URN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"The `:appSlug` matches no app (or the PAK belongs to another workspace), or the user is not in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so PAK access is refused until configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed (unsupported `type`, over-long `label`, non-UUID `contact_id`), or `type` is `webauthn` — passkeys cannot be provisioned out-of-band.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing bearer token, or the Platform API Key is malformed, unknown or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The PAK policy denies `auth.user.factors.provision` on this app's URN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"The `:appSlug` matches no app (or the PAK belongs to another workspace), or the user is not in this app. Email factors: `contact_id` names no email contact on the account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so PAK access is refused until configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]},"412":{"description":"Email factors: the contact (or primary email) is unverified, or the app has not completed Mail setup for MFA codes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing bearer token, or the Platform API Key is malformed, unknown or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"The PAK policy denies `auth.user.factors.delete` on this app's URN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"The `:appSlug` matches no app (or the PAK belongs to another workspace), or the user is not in this app. Also when the factor is not on that user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so PAK access is refused until configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Request validation failed: unsupported `type`, over-long `label`, or `contact_id` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"404":{"description":"Email factors: `contact_id` names no email contact on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"412":{"description":"Email factors: the contact (or primary email) is unverified, or the app has not completed Mail setup for MFA codes. Passkeys: not enabled or not fully configured for this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid or repeated codes.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"404":{"description":"Factor not found on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token, unknown or inactive app, or the account/session is no longer active.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Account has no enabled email factor (or the named factor is not an email factor).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"410":{"description":"The bound email contact no longer exists or is no longer verified.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["GONE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"The app has not completed Mail notification setup for MFA codes.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Send throttled, or step-up is locked on this session.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"No passkey is enrolled on this account.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"Passkeys are not enabled or not fully configured for this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Step-up is locked on this session.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Code did not match any enabled factor or recovery code.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Step-up locked on this session after too many failures.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"The service is momentarily busy (`SERVICE_UNAVAILABLE`): too many password hashes or verifications are already running or queued on the instance that took the request. Nothing was changed and no attempt was counted. Retry after the number of seconds in the `Retry-After` header (also in `details.retry_after_seconds`). Decided by instance load only, never by the identifier or the account, so it reveals nothing about whether an account exists.","headers":{"Retry-After":{"description":"Seconds to wait before retrying (at least 1).","schema":{"type":"integer","minimum":1}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Code is unknown, expired, or already redeemed.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Code does not belong to this `appSlug`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}]}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"ID token verification failed (bad signature / issuer / audience / nonce / expired).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","link_required","account_exists_with_different_provider"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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>`."},{"name":"sign_in_test","required":false,"in":"query","schema":{"type":"string","format":"uuid"},"description":"Try sign-in only: the id of a sign-in test created with `POST /v1/apps/{appId}/sign-in-tests`. The link from that response (`start_url`) carries it. With it, `return_to` must be exactly the console result page stored on the test, the link works once and for 10 minutes, and the round trip creates no account and issues no token: the result is recorded on the test."}],"responses":{"302":{"description":"Redirect to provider authorize URL (or, for a sign-in test that already failed its checks, to the console result page).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Missing/invalid `return_to`, origin not allowed, web flow disabled (no `allowed_redirect_origins` configured), or a `sign_in_test` that is unknown, used, expired or for another app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown / expired state, or state↔provider mismatch.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"ID token verification failed (signature / issuer / audience / nonce).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Email collides with an existing account. Returned via redirect with `?heimdall_error=link_required` or `?heimdall_error=account_exists_with_different_provider`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Missing or over-long `state` / `code` / `error` query, unknown or expired state, or state↔route (app/provider) mismatch. Returned as a status, not a redirect — no trusted `return_to` exists yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"404":{"description":"The `:appSlug` matches no app, or the provider is no longer configured for this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"429":{"description":"Per-IP rate limit (`RATE_LIMITED`): OAuth routes are in the strict `auth` bucket (10 requests per minute per IP).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"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":"workspace_id","required":false,"in":"query","description":"Limit the result to this one workspace (UUID). The caller must be a member; an id the caller does not belong to returns nothing. Omit to cover every workspace the caller belongs to.","schema":{"example":"24fab978-3c1e-4c55-9a0e-0f6b3f1c2d4e","type":"string"}},{"name":"limit","required":false,"in":"query","description":"Page size (default 20, max 100).","schema":{"example":"20","type":"string"}},{"name":"cursor","required":false,"in":"query","description":"Opaque `next_cursor` from the previous page.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppListResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid pagination cursor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"security":[{"bearer":[]}],"summary":"List apps in workspaces the caller is a member of. Pass `workspace_id` 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":{"description":"The new app starts with emails to its users turned on: they go out from the workspace default sender chosen in Mail, and the default verification, welcome, password-reset, invite and sign-in code templates are created in the workspace's Mail templates (an existing template with the same name is reused). Change or turn this off with `PATCH /v1/apps/{appId}/notification-settings`.","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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (slug, display name, or workspaceId).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller email not verified, or caller lacks `auth.create` on the target workspace.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Slug already taken. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure, or the slug matches the source.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.create` on the source app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Source app not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Slug already taken (including as a live rename alias). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure — including a retention value outside 30-3650 days (audit) or 7-365 days (webhook attempts), or `AUTH_SETTINGS_IN_METADATA`: `metadata.auth` differs from the stored sign-in settings (change those with `PATCH /v1/apps/{appId}/auth-config`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","AUTH_SETTINGS_IN_METADATA"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.delete`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found or caller is not a member.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid status transition (e.g. archived → active).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found or caller is not a member.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid pagination cursor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.list` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.delete` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or the account is not a member of this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure — a value out of range (password length, token/session lifetimes, lockout threshold, max sessions, MFA grace days, unverified-account window), an unknown enum value, a `signup_default_role` that does not exist in this app, or a `bot_protection` change that cannot stand: no provider or site key on first setup, a hostname that is not a hostname, or `enabled: true` with no secret stored or sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"`APP_HAS_NO_WORKSPACE`: `signup_requires_pak: true` on an app that belongs to no workspace, so no Platform API Key could ever be accepted for its sign-ups.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","APP_HAS_NO_WORKSPACE"]}},"security":[{"bearer":[]}],"summary":"Update the per-app auth config. Only fields you pass are changed.","tags":["apps"]}},"/v1/apps/{app_id}/auth-config/providers":{"get":{"description":"Each entry carries `status` (`ready` | `blocked` | `disabled` | `not_configured`) and `blocked_reasons`, computed on the server with the same rule the web sign-in flow enforces, so a provider shown as `ready` is one people can actually sign in with.","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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List federated sign-in providers Auth supports, with their setup 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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown provider id.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Provider not configured for this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown provider or malformed config.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown provider or merged config invalid.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Provider not configured for this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown provider id.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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}/auth-config/providers/{provider}/check":{"post":{"description":"Calls the provider's token endpoint with a dummy sign-in code. If the provider refuses the client (`invalid_client`, GitHub `incorrect_client_credentials` or `unknown_client`), the result is `credentials_rejected` and the provider shows as `blocked` with reason `credentials_rejected` until the credentials are fixed. If it only refuses the code (`invalid_grant`, `bad_verification_code`), the credentials are valid. Nobody is signed in. The result is saved on the provider (`last_check_*`). The same check runs automatically whenever the credentials are saved. Works for a provider that is saved but not switched on yet.","operationId":"AuthConfigProviderController_check","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/ProviderCheckDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown provider id, or the saved config can no longer be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or the provider is not configured for this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Check the saved provider credentials with the provider.","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`rp_id` is malformed, or is not a registrable suffix of any origin in `allowed_redirect_origins`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"412":{"description":"The app has no `allowed_redirect_origins` configured, so `rp_id` cannot be validated.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (`name` 1-64 chars, `description` up to 256).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"A role with this name already exists in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such role in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (`description` over 256 chars).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such role in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.delete` on this app, or the app is suspended or archived, or the role is a system role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such role in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Role is still assigned to members (`code: ROLE_IN_USE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (a permission not in `resource.action` format), or a permission not in the app catalogue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived, or the caller does not hold every permission it is trying to grant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such role in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (`accountId` not a UUID, or `role` empty / over 64 chars).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.assign` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, no such role in this app, or the account is not a member of this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (`resource` / `action` segment format, or `description` over 256 chars).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Permission collides with a system permission or already exists in this app, or the app has reached its custom-permission limit (`code` set).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`permissionKey` is not in `resource.action` format.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived, or the key names a system permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such custom permission in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (missing or invalid name).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller email not verified, or lacks `auth.create`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found or caller is not a member.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.list` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such M2M client in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`isActive` missing or not a boolean.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such M2M client in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.delete`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"M2M client not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.rotate-secret`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"M2M client not found in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`permissions` is not a string array, or names a permission unknown to this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such M2M client in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`, `since`, or `until`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.audit.read`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found or caller is not a member.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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.","content":{"application/x-ndjson":{"schema":{"type":"string","description":"Newline-delimited JSON: one audit-row object per line."}},"text/csv":{"schema":{"type":"string","description":"RFC 4180 CSV, header row first, one audit row per line."}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `format`, or malformed `since` / `until`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.audit.read`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found or caller is not a member.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"413":{"description":"More than 1,000,000 rows match. Narrow the window with `since` / `until` (or a tighter `action` / `actor_id` filter) and retry.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PAYLOAD_TOO_LARGE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.read`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No audit sink configured.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"URL is not HTTPS, or the SSRF guard refused it (private address, unresolvable host, non-public TLD).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Create or replace the audit sink. Returns a freshly generated signing secret exactly once.","tags":["app-audit-sink"]},"patch":{"description":"Send `url`, `enabled` or both. `enabled: false` pauses export; `enabled: true` resumes it and clears a stop after repeated failures and the failure count. The position in the audit log is never moved: nothing is skipped and nothing is replayed twice on purpose. A new `url` is checked by the same address guard as `PUT`.","operationId":"AppAuditSinkController_update","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAuditSinkDto"}}}},"responses":{"200":{"description":"The updated sink.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditSinkResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"URL is not HTTPS, or the address guard refused it (private address, unresolvable host).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No audit sink configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Change the address or switch export on or off, keeping the signing secret.","tags":["app-audit-sink"]},"delete":{"operationId":"AppAuditSinkController_remove","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Sink removed.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No audit sink configured.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Remove the audit sink. Shipping stops immediately; the audit log itself is untouched.","tags":["app-audit-sink"]}},"/v1/apps/{app_id}/audit-sink/setup-status":{"get":{"description":"Rows, in order: `destination` (not_set until a sink exists), `test_batch` (pass once the receiver accepted a test batch with the current address and secret; blocked when the last test was refused; carries `test.action: audit_sink.test`, run with `POST /v1/apps/{appId}/audit-sink/test`), and `delivery` (blocked when export stopped after repeated failures, attention while paused or while recent batches fail). Computed from the stored sink on every request; answers 200 whether or not a sink exists.","operationId":"AppAuditSinkController_setupStatus","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The checklist, computed now.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupStatusDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.read`, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Get the audit export checklist: destination, receiver test and delivery.","tags":["app-audit-sink"]}},"/v1/apps/{app_id}/audit-sink/rotate-secret":{"post":{"description":"The new secret signs every batch from now on; the old one stops at once. Update your receiver right away: batches it rejects in between are retried every minute (the position only moves after a 2xx), so nothing is lost, but export stops after 20 failures in a row. Clears the receiver-test proof.","operationId":"AppAuditSinkController_rotateSecret","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"New secret; `signing_secret` is shown only here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditSinkWithSecretResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No audit sink configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Issue a new signing secret, returned exactly once.","tags":["app-audit-sink"]}},"/v1/apps/{app_id}/audit-sink/test":{"post":{"description":"POSTs one signed batch, shaped exactly like a real one (`type: audit.batch`, same headers and signature), with `test: true` and a single synthetic record whose `id` starts with `test_`. Works while export is paused or stopped, so a fix can be proven before export is turned back on. The outcome is recorded on the sink (`last_attempt_*`, `last_test_succeeded_at`) but never moves the position in the audit log or counts toward the automatic stop. A refused test is still a 200 with `delivered: false`. At most 20 tests per app per hour.","operationId":"AppAuditSinkController_sendTest","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"A retried request with the same key returns the first result instead of sending a second test. 24h TTL.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"What the receiver answered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditSinkTestResultDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No audit sink configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]},"429":{"description":"More than 20 tests for this app in the last hour (`RATE_LIMITED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"security":[{"bearer":[]}],"summary":"Send a test batch to the receiver now and report what it answered.","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `signal` / `sort` / `order`, or a `cursor` minted under a different ordering than the one requested.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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. `Content-Disposition` names the file.","content":{"application/x-ndjson":{"schema":{"type":"string","description":"Newline-delimited JSON: one end-user object per line."}},"text/csv":{"schema":{"type":"string","description":"RFC 4180 CSV, header row first, one end user per line."}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `format`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.user.export`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"413":{"description":"More than 1,000,000 end-users match. Narrow the filters.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PAYLOAD_TOO_LARGE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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`.  `password_hash_algorithm` is `argon2id` or `bcrypt`. **argon2id** hashes are accepted at any parameters. **bcrypt** hashes (`$2a$`, `$2b$`, `$2y$` — Auth0, PHP `password_hash`, Spring, node `bcrypt`, Devise / Rails, Laravel, Django) are accepted at any valid cost (4-31), verified on the user's first signin and replaced by an argon2id hash of the same password in that request, so the user keeps their password and never notices. Signin pads a bcrypt verify up to the argon2id floor, so at cost 10 and below an imported account cannot be told apart by timing; at cost 11 and above the verify is slower than the floor and answers when it finishes — a small timing difference on migrated accounts only, gone after each one's first signin.  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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Body is empty or contains no NDJSON rows.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.user.import`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"413":{"description":"Body exceeds 20 MiB or 50,000 rows. Split and retry.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PAYLOAD_TOO_LARGE"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.user.import`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No such import job in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body: empty `displayName`, malformed `email`, `metadata` not an object, or metadata over 8 KB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Update end-user display name, primary email, or metadata keys (merged).","tags":["end-users"]},"delete":{"description":"Removes the account with its contacts, sessions and tenant memberships. Writes one `auth.account.deleted` audit row (`via: platform`) in the same transaction, mirrors the deletion into the workspace audit log as `auth.end_users.deleted`, and fires `user.deleted` with `via: \"platform\"`.","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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.delete` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`status` is not one of active, suspended, deactivated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`roleName` missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.assign` on this app, cannot grant every permission the role carries, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, end-user or role not found in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller's workspace policy does not allow `auth.user.factors.delete` on this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No such end-user in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"End-user has no primary email contact, or it is already verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid `selection` body (including more than 5,000 ids).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid `selection` body, or `status` is not one of active, suspended, deactivated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid `selection` body or empty `roleName`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.assign` on this app, cannot grant every permission the role carries, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or role not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Bulk-assign a role across a selection.","tags":["end-users"]}},"/v1/apps/{app_id}/end-users/bulk-delete":{"post":{"description":"Deletes every matched account in one transaction. Each deleted account gets an `auth.account.deleted` audit row (`via: platform_bulk`) whose metadata carries the `selection` (`{ mode: \"ids\" }`, or `{ mode: \"filter\", filter }`), so a filter sweep reads as one. The workspace audit log gets one `auth.end_users.deleted` row with the count. `user.deleted` (`via: \"platform_bulk\"`) fires per account only when 100 or fewer were deleted.","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid `selection` body, or an unfiltered selection without `confirm_all_in_app: true` (including more than 5,000 ids).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.delete` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body: unsupported `type`, malformed or empty `value`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such end-user in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Contact value already in use in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, end-user or contact not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Contact is the verified primary email; promote another email first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, end-user or contact not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Contact is not verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, end-user or contact not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Contact is already verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown role, or maxUses / expiresInHours out of range.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"An EndUser with this email already exists in the app. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `status` value.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.revoke` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or no such invite in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Revoke an EndUser invite (soft-delete).","tags":["end-user-invites"]}},"/v1/apps/{app_id}/unverified-account-expiry":{"get":{"operationId":"UnverifiedAccountExpiryController_status","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnverifiedAccountExpiryStatusDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Get the unverified-account expiry setting and how many accounts it deleted in the last 30 days.","tags":["end-users"]}},"/v1/apps/{app_id}/unverified-account-expiry/run":{"post":{"description":"Permanently deletes every account that qualifies under the app's `unverified_account_ttl_days`, exactly as the nightly job does: each deletion is audited as `auth.account.expired_unverified` and fires `user.deleted` with `reason: \"unverified_expired\"`. Use `dry_run: true` to preview.","operationId":"UnverifiedAccountExpiryController_run","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key; a retry with the same key returns the first run's result. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunUnverifiedAccountExpiryDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnverifiedAccountExpiryRunDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (`min_age_minutes` out of range).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.delete` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Expiry is off for this app and the request is not a dry run. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]},"422":{"description":"`min_age_minutes` was sent for an app that is not a development app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNPROCESSABLE_ENTITY"]}},"security":[{"bearer":[]}],"summary":"Run the unverified-account expiry for this app now (or as a dry run).","tags":["end-users"]}},"/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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserInsightsDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`appId` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.audit.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]}},"security":[{"bearer":[]}],"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EndUserDetailInsightsDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`appId` or `userId` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.audit.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such end-user in this app. Same response as an account in another app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure (name, empty `permissions`, a permission not matching `resource.action`, unsupported `expiresInDays`), or a malformed `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.create` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"`Idempotency-Key` reused with a different request body. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"security":[{"bearer":[]}],"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No such key in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"No such key in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"The key is revoked or already expired. There is nothing to roll over from — mint a new key instead. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"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 `workspace_id` 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","description":"Limit the result to this one workspace (UUID). The caller must be a member; an id the caller does not belong to returns nothing. Omit to cover every workspace the caller belongs to.","schema":{"example":"24fab978-3c1e-4c55-9a0e-0f6b3f1c2d4e","type":"string"}}],"responses":{"200":{"description":"Aggregate counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformUserStatsResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]}},"security":[{"bearer":[]}],"summary":"Aggregate counts for the signed-in PlatformUser","tags":["Platform · Stats"]}},"/v1/workspaces/{workspace_id}/stats":{"get":{"description":"Active end users over 1, 7 and 30 days, total and new end users, production app count, last end-user activity and a daily active-users series, for the workspace's **production** apps only.\n\n**Active** means the end user signed in successfully or refreshed a session. Customer backends verify access tokens locally against the JWKS, so use between two refreshes is invisible to ProductCraft; resolution is bounded by each app's access-token lifetime.\n\nDays are UTC. Counts only: no end-user identifiers. Cached for up to 60 seconds (`generated_at`).","operationId":"WorkspaceStatsController_get","parameters":[{"name":"workspace_id","required":true,"in":"path","description":"Workspace id.","schema":{"format":"uuid","type":"string"}},{"name":"window","required":false,"in":"query","description":"Window for `in_window.new_end_users` and the length of `series`. The three `active_users_*` counts are always returned.","schema":{"default":"30d","example":"30d","enum":["1d","7d","30d"],"type":"string"}}],"responses":{"200":{"description":"Auth stats.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthWorkspaceStatsDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`workspaceId` is not a UUID or `window` is not one of 1d, 7d, 30d.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.read` on the workspace (including non-members), or Auth is not enabled on it (`SERVICE_NOT_ENABLED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","SERVICE_NOT_ENABLED"]},"404":{"description":"Workspace not found. Today an unknown workspace answers 403 like every other workspace route, so callers cannot probe which ids exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Auth stats for a workspace","tags":["Platform · Stats"]}},"/v1/apps/{app_id}/notification-settings":{"get":{"description":"`effective_sender` is the sender emails actually go out from, checked live in Mail with your own access (the workspace default sender, or the app's custom address).","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Read the per-app notification settings. Absent row reads as defaults (notifications_via_envoi: false, sender_mode: workspace, every template slot null).","tags":["app-notification-settings"]},"patch":{"description":"Turning `notifications_via_envoi` on (from off) creates the default template of every empty slot in the workspace's Mail templates (`auth-verification`, `auth-welcome`, `auth-password-reset`, `auth-invite`, `auth-mfa-code`), reusing a template that already has that name, and fills the slot. Templates are created with your own Mail access; a slot that could not be created stays empty. A slot you clear later is not refilled until emails are switched off and on again.","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid payload.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"422":{"description":"`SENDER_ADDRESS_REQUIRED`: `sender_mode: custom` with no `default_sender_address` (in the body or already saved).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNPROCESSABLE_ENTITY","SENDER_ADDRESS_REQUIRED"]}},"security":[{"bearer":[]}],"summary":"Update the per-app notification settings. Each field is independently optional; null clears that slot.","tags":["app-notification-settings"]}},"/v1/apps/{app_id}/notifications/{slot}/test":{"post":{"description":"Sends the slot's real Mail template, with this app's real sender (the workspace default or the custom address) and example values for every variable, to your own verified account address. It cannot be sent anywhere else. It uses your own Mail access, goes through the same send path as real emails, and shows up in Mail > Emails. At most 10 tests per person per hour. Not available to API keys (a key has no inbox).","operationId":"NotificationTestController_sendTest","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. A retried request with the same key returns the first response instead of sending a second email. 24h TTL.","required":false,"schema":{"type":"string"}},{"name":"slot","required":true,"in":"path","description":"The email to test.","schema":{"enum":["verification","welcome","password_reset","invite","mfa_code"],"type":"string"}}],"responses":{"202":{"description":"Accepted by Mail and queued for delivery.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationTestResponseDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `slot`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, the app is suspended or archived, or Mail refused the send for your role (you need Mail send access in this workspace).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or the slot's template no longer exists in Mail.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]},"412":{"description":"`ENVOI_TEMPLATE_NOT_CONFIGURED`: the slot has no template. `ENVOI_SENDER_NOT_CONFIGURED`: `sender_mode: custom` with no address. `ENVOI_APP_NOT_BOUND_TO_WORKSPACE`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["PRECONDITION_FAILED","ENVOI_TEMPLATE_NOT_CONFIGURED","ENVOI_SENDER_NOT_CONFIGURED","ENVOI_APP_NOT_BOUND_TO_WORKSPACE"]},"422":{"description":"`operator_email_unavailable`: the caller has no verified account email (API keys included). From Mail: `no_default_sender` (workspace mode and no default sender set in Mail), `from_domain_not_verified`, `domain_not_verified_by_provider`, a suppressed address, or a render failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNPROCESSABLE_ENTITY"]},"429":{"description":"More than 10 tests by you in the last hour (`RATE_LIMITED`), or over a Mail send limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]},"503":{"description":"`MAIL_UNREACHABLE`: Mail did not answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["SERVICE_UNAVAILABLE","MAIL_UNREACHABLE"]}},"security":[{"bearer":[]}],"summary":"Send me a test of one email.","tags":["app-notification-settings"]}},"/v1/apps/{app_id}/webhooks/event-types":{"get":{"description":"Each type with a plain description, its group and an example `data` object (what \"Send test\" delivers).","operationId":"WebhooksController_auth_app_listEventTypes","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhookEventTypesResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List the events an endpoint can subscribe to.","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/endpoints":{"get":{"description":"At most 10, so this is always one page. Signing secrets are never returned here, only their last 4 characters.","operationId":"WebhooksController_auth_app_listEndpoints","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhookEndpointsResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"The scope id in the path is malformed.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List webhook endpoints, oldest first.","tags":["app-webhooks"]},"post":{"description":"Deliveries are signed with the Standard Webhooks headers `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,<base64 HMAC-SHA256 of \"<id>.<timestamp>.<body>\">`); the returned `whsec_…` secret works unchanged with any Standard Webhooks library. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h.","operationId":"WebhooksController_auth_app_createEndpoint","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecretDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure: URL not https with a public host, or an unknown event type (`WEBHOOK_EVENT_TYPE_UNKNOWN`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","WEBHOOK_EVENT_TYPE_UNKNOWN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Already 10 endpoints (`WEBHOOK_ENDPOINT_LIMIT`), or Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","WEBHOOK_ENDPOINT_LIMIT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"422":{"description":"The URL points at a private, loopback or internal address.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNPROCESSABLE_ENTITY"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Add a webhook endpoint. Returns its signing secret once.","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/endpoints/{endpoint_id}":{"get":{"operationId":"WebhooksController_auth_app_getEndpoint","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"endpoint_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`endpoint_id` is not a UUID.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such endpoint here (`WEBHOOK_ENDPOINT_NOT_FOUND`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_ENDPOINT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Get one webhook endpoint (without its secret).","tags":["app-webhooks"]},"patch":{"description":"Only the fields sent change. `enabled: true` (or a new URL) also clears an automatic disable. Switching off cancels queued retries. The secret is unchanged; rotate it with `POST …/rotate-secret`.","operationId":"WebhooksController_auth_app_updateEndpoint","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"endpoint_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure, or `endpoint_id` is not a UUID.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such endpoint here (`WEBHOOK_ENDPOINT_NOT_FOUND`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_ENDPOINT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"422":{"description":"The new URL points at a private, loopback or internal address.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNPROCESSABLE_ENTITY"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Change a webhook endpoint: URL, description, events, on/off.","tags":["app-webhooks"]},"delete":{"description":"Queued retries are dropped. Its past attempts stay in the event log until the events expire.","operationId":"WebhooksController_auth_app_deleteEndpoint","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"endpoint_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Deleted.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`endpoint_id` is not a UUID.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such endpoint here (`WEBHOOK_ENDPOINT_NOT_FOUND`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_ENDPOINT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Delete a webhook endpoint.","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/endpoints/{endpoint_id}/rotate-secret":{"post":{"description":"For `grace_hours` (default 24) the previous secret keeps signing too: `webhook-signature` then lists one `v1,` signature per secret, so receivers can switch over without dropping a delivery.","operationId":"WebhooksController_auth_app_rotateSecret","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"endpoint_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWebhookSecretDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecretDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`grace_hours` out of range, or `endpoint_id` is not a UUID.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such endpoint here (`WEBHOOK_ENDPOINT_NOT_FOUND`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_ENDPOINT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Rotate the signing secret. The new one is returned once.","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/endpoints/{endpoint_id}/test":{"post":{"description":"Delivers the catalogue example of `event_type`, signed like any delivery, with `\"test\": true` in the body. Works while the endpoint is switched off or not subscribed to that type, is logged in the event log, and is never retried or counted toward an automatic disable. Waits up to 15 s for your receiver.","operationId":"WebhooksController_auth_app_sendTest","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"endpoint_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendWebhookTestDto"}}}},"responses":{"200":{"description":"The attempt, successful or not.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryAttemptDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `event_type`, or `endpoint_id` is not a UUID.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such endpoint here (`WEBHOOK_ENDPOINT_NOT_FOUND`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_ENDPOINT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Send a test event to this endpoint now and return the result.","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/events":{"get":{"description":"Every event logged while at least one endpoint existed, with the latest state per endpoint and when the next retry is due.","operationId":"WebhooksController_auth_app_listEvents","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"type","required":false,"in":"query","description":"Only these types. Repeat the parameter or comma-separate.","schema":{"type":"array","items":{"type":"string","enum":["user.signup","user.signin","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","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","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"]}}},{"name":"endpoint_id","required":false,"in":"query","description":"Only events sent (or queued) to this endpoint.","schema":{"format":"uuid","type":"string"}},{"name":"status","required":false,"in":"query","description":"Only events in this delivery status.","schema":{"$ref":"#/components/schemas/WebhookEventStatus"}},{"name":"created_after","required":false,"in":"query","description":"Only events created at or after this instant.","schema":{"format":"date-time","type":"string"}},{"name":"created_before","required":false,"in":"query","description":"Only events created before this instant.","schema":{"format":"date-time","type":"string"}},{"name":"limit","required":false,"in":"query","description":"Page size.","schema":{"minimum":1,"maximum":100,"default":20,"type":"integer"}},{"name":"cursor","required":false,"in":"query","description":"The previous page's `pagination.next_cursor`.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhookEventsResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid filter value or cursor.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List webhook events, newest first (kept for 30 days).","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/events/{event_id}":{"get":{"operationId":"WebhooksController_auth_app_getEvent","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"event_id","required":true,"in":"path","description":"The event id, `evt_<uuid>`.","schema":{"type":"string","pattern":"^evt_[0-9a-f-]{36}$"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEventDetailDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`event_id` is not `evt_<uuid>`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such event here (`WEBHOOK_EVENT_NOT_FOUND`); events are kept for 30 days.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_EVENT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Get one webhook event with every delivery attempt.","tags":["app-webhooks"]}},"/v1/apps/{app_id}/webhooks/events/{event_id}/replay":{"post":{"description":"Same body and `webhook-id`, freshly signed, to every switched-on endpoint that receives this event type now, or only to `endpoint_id`. Waits for the receivers (up to 15 s each) and returns the attempts. A success cancels a retry still queued for that endpoint.","operationId":"WebhooksController_auth_app_replay","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"event_id","required":true,"in":"path","description":"The event id, `evt_<uuid>`.","schema":{"type":"string","pattern":"^evt_[0-9a-f-]{36}$"}},{"name":"endpoint_id","required":false,"in":"query","description":"Send to this endpoint only.","schema":{"format":"uuid","type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`) that makes a retry of this request safe for 24 hours. The same key with a different body returns 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayWebhookEventResponseDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`event_id` is not `evt_<uuid>`, or `endpoint_id` is not a UUID.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` (reads) or `auth.update` (changes, Send test, replay), or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such event here (`WEBHOOK_EVENT_NOT_FOUND`); events are kept for 30 days. Or No such endpoint here (`WEBHOOK_ENDPOINT_NOT_FOUND`).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","WEBHOOK_EVENT_NOT_FOUND","WEBHOOK_ENDPOINT_NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"`endpoint_id` is switched off or does not receive this event type (`WEBHOOK_ENDPOINT_NOT_SUBSCRIBED`), or Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","WEBHOOK_ENDPOINT_NOT_SUBSCRIBED","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Send a logged event again, to its endpoints or to one endpoint.","tags":["app-webhooks"]}},"/{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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid display_name or slug.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"PAK missing.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"PAK policy denies action.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"App not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Slug already taken in this app.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid `status` filter or malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"PAK missing, malformed, unknown or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"PAK policy denies `auth.tenant.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found (or the PAK belongs to another workspace).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"App has no workspace binding; PAK access refused.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid patch.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Slug collision.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Over-long `search` / `role` filter or malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"PAK missing, malformed, unknown or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"PAK policy denies `auth.tenant.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or tenant not found (or the PAK belongs to another workspace).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"App has no workspace binding; PAK access refused.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body (missing accountId/email or unknown role).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant or account not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"Account is already a member of this tenant.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown role.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant or membership not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant or membership not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid email, unknown role, or maxUses/expiresInHours out of range.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"PAK missing.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"PAK policy denies action.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"That email is already a member of the tenant, or already has a pending invite to it.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `status` value.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant or invite not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"]}},"/{app_slug}/v1/tenant-api-keys/introspect":{"post":{"description":"For your backend: is this `pctk_` key, presented to YOUR API, usable right now, for which tenant and with which scopes? A key of another app is `{\"active\": false}`. Cache answers by the key's SHA-256: positives up to 30 s (never past `expires_at`), negatives up to 60 s. Intersect `scopes` with the permissions of `created_by.tenant_role` in your app. Requires `auth.tenant.api_keys.introspect` on the app. Not audited per call; `last_used_at` records use (5-minute granularity). This route has its own per-app rate bucket (1200 calls a minute by default), separate from your PAK's other calls; over it the answer is 429 with `Retry-After`, never a false `active: false`, so keep serving keys you hold a fresh positive for.","operationId":"AppTenantApiKeysController_introspect","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntrospectTenantApiKeyDto"}}}},"responses":{"200":{"description":"The verdict. Inactive is exactly `{\"active\": false}` for every reason.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeyIntrospectionDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`key` missing or not a string.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, malformed or revoked Platform API Key (`Authorization: Bearer pcft_live_…`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`PAK_WORKSPACE_MISMATCH` (the PAK belongs to another workspace than the app), or the PAK's policy does not allow `auth.tenant.api_keys.introspect` on `pcft:heimdall:app/<appId>`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","PAK_WORKSPACE_MISMATCH"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so no PAK can act on it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]},"429":{"description":"`RATE_LIMITED`: this app's introspect bucket is exhausted for the current minute. `Retry-After` says when it refills.","headers":{"Retry-After":{"description":"Seconds until the bucket refills.","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"x-pc-error-codes":["RATE_LIMITED"]}},"security":[{"bearer":[]}],"summary":"Verify a tenant API key (RFC 7662 shape)","tags":["Consumer · Tenant API keys"]}},"/{app_slug}/v1/tenant-api-keys/config":{"get":{"description":"Requires `auth.tenant.api_keys.configure` on the app.","operationId":"AppTenantApiKeysController_getConfig","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}}],"responses":{"200":{"description":"The effective settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeysConfigDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, malformed or revoked Platform API Key (`Authorization: Bearer pcft_live_…`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`PAK_WORKSPACE_MISMATCH` (the PAK belongs to another workspace than the app), or the PAK's policy does not allow `auth.tenant.api_keys.configure` on `pcft:heimdall:app/<appId>`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","PAK_WORKSPACE_MISMATCH"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so no PAK can act on it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"summary":"Read this app's tenant API key settings","tags":["Consumer · Tenant API keys"]},"put":{"description":"Call it on boot: your code is the source of truth for the scopes a key can carry. Fields you leave out keep their stored value. `suspended` is the platform's kill switch: this route never changes it, and a body that carries it is refused. Requires `auth.tenant.api_keys.configure` on the app.","operationId":"AppTenantApiKeysController_putConfig","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Retry safety. Same key and body within 24 h replays the first response; same key with a different body is 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushTenantApiKeysConfigDto"}}}},"responses":{"200":{"description":"The settings now stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeysConfigDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`VALIDATION_ERROR`, `TENANT_API_KEYS_SCOPE_INVALID` (`details.scopes`), `TENANT_API_KEYS_UNKNOWN_ROLE` (`details.roles`: `manage_roles` names a role this app does not have), `TENANT_API_KEY_EXPIRY_INVALID`, `TENANT_API_KEYS_NO_SCOPES` (enabling with no scope), `TENANT_API_KEYS_SUSPENDED_NOT_WRITABLE` (the body carries `suspended`), or a malformed `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","TENANT_API_KEYS_SCOPE_INVALID","TENANT_API_KEYS_UNKNOWN_ROLE","TENANT_API_KEY_EXPIRY_INVALID","TENANT_API_KEYS_NO_SCOPES","TENANT_API_KEYS_SUSPENDED_NOT_WRITABLE"]},"401":{"description":"Missing, malformed or revoked Platform API Key (`Authorization: Bearer pcft_live_…`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`PAK_WORKSPACE_MISMATCH` (the PAK belongs to another workspace than the app), or the PAK's policy does not allow `auth.tenant.api_keys.configure` on `pcft:heimdall:app/<appId>`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","PAK_WORKSPACE_MISMATCH"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so no PAK can act on it. Or `IDEMPOTENCY_KEY_REUSE`. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"security":[{"bearer":[]}],"summary":"Push this app's tenant API key settings","tags":["Consumer · Tenant API keys"]}},"/{app_slug}/v1/tenant-api-keys/{key_id}/status":{"get":{"description":"For your own audit trail: record why a key you saw go inactive died (revoked in the console, creator left the tenant, expired, keys suspended). Only keys of this app; another app's key is 404. Requires `auth.tenant.api_keys.introspect` on the app.","operationId":"AppTenantApiKeysController_status","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}},{"name":"key_id","required":true,"in":"path","description":"Key id (UUID) or display id (`tak_…`), as `introspect` returned it.","schema":{"type":"string"}}],"responses":{"200":{"description":"The key status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeyStatusDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, malformed or revoked Platform API Key (`Authorization: Bearer pcft_live_…`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`PAK_WORKSPACE_MISMATCH` (the PAK belongs to another workspace than the app), or the PAK's policy does not allow `auth.tenant.api_keys.introspect` on `pcft:heimdall:app/<appId>`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","PAK_WORKSPACE_MISMATCH"]},"404":{"description":"`TENANT_API_KEY_NOT_FOUND`: no such key in this app (or app not found).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","TENANT_API_KEY_NOT_FOUND"]},"409":{"description":"The app has no workspace binding, so no PAK can act on it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"security":[{"bearer":[]}],"summary":"Why a tenant API key is (in)active","tags":["Consumer · Tenant API keys"]}},"/{app_slug}/v1/tenant-api-keys":{"get":{"description":"Newest first, cursor-paginated (20 per page, max 100). Secrets are never returned; `key_prefix` and `last4` identify a key.","operationId":"ConsumerTenantApiKeysController_list","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","description":"Only keys in this state.","schema":{"enum":["active","expired","revoked"],"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"`next_cursor` from the previous page."},{"name":"X-Heimdall-App-Key","in":"header","description":"The app's Platform API Key (`pcft_live_…`, holding `auth.tenant.api_keys.introspect` on this app). Required, next to the end user's bearer, when the app has set `management_lane: app_proxy_only`: the app's backend then proxies these routes and the end user cannot call them directly.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"A page of keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeyListResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `status` or malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid end-user bearer token (M2M tokens, PAKs and API keys are refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`TENANT_API_KEYS_NOT_ENABLED` (the app has not enabled tenant API keys), `TENANT_API_KEYS_SUSPENDED`, `TENANT_API_KEYS_APP_PROXY_ONLY` (the app manages keys through its own backend; call it instead), `TENANT_CONTEXT_REQUIRED` (no active tenant on the session: switch tenant first), `TENANT_MEMBERSHIP_REQUIRED`, `TENANT_NOT_ACTIVE`, `TENANT_API_KEYS_FORBIDDEN` (your tenant role is not in the app's `manage_roles`), or a PAK refusal on `X-Heimdall-App-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","TENANT_API_KEYS_NOT_ENABLED","TENANT_API_KEYS_SUSPENDED","TENANT_API_KEYS_APP_PROXY_ONLY","TENANT_CONTEXT_REQUIRED","TENANT_MEMBERSHIP_REQUIRED","TENANT_NOT_ACTIVE","TENANT_API_KEYS_FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List the active tenant's API keys","tags":["Consumer · Tenant API keys"]},"post":{"description":"Returns the secret ONCE (`key`). Scopes must be grantable by the app and held by your own tenant role. At most 25 active keys per tenant. If the app requires recent MFA, verify a factor first (403 `step_up_required`). Responses carry `Cache-Control: no-store`.","operationId":"ConsumerTenantApiKeysController_create","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Retry safety for key creation. The first request returns the secret; a repeat with the same key and body returns 409 `idempotency_secret_not_replayable` with `details.key_id` (the secret is never replayed). Same key with a different body: 409 `IDEMPOTENCY_KEY_REUSE`. 24 h window.","required":false,"schema":{"type":"string"}},{"name":"X-Heimdall-App-Key","in":"header","description":"The app's Platform API Key (`pcft_live_…`, holding `auth.tenant.api_keys.introspect` on this app). Required, next to the end user's bearer, when the app has set `management_lane: app_proxy_only`: the app's backend then proxies these routes and the end user cannot call them directly.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantApiKeyDto"}}}},"responses":{"201":{"description":"Key created; `key` is the secret, shown once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeyWithSecretDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`VALIDATION_ERROR`, `TENANT_API_KEY_SCOPE_NOT_GRANTABLE` (`details.scopes`), `TENANT_API_KEY_EXPIRY_INVALID` (`details.allowed`), or a malformed `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","TENANT_API_KEY_SCOPE_NOT_GRANTABLE","TENANT_API_KEY_EXPIRY_INVALID"]},"401":{"description":"Missing or invalid end-user bearer token (M2M tokens, PAKs and API keys are refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`TENANT_API_KEYS_NOT_ENABLED` (the app has not enabled tenant API keys), `TENANT_API_KEYS_SUSPENDED`, `TENANT_API_KEYS_APP_PROXY_ONLY` (the app manages keys through its own backend; call it instead), `TENANT_CONTEXT_REQUIRED` (no active tenant on the session: switch tenant first), `TENANT_MEMBERSHIP_REQUIRED`, `TENANT_NOT_ACTIVE`, `TENANT_API_KEYS_FORBIDDEN` (your tenant role is not in the app's `manage_roles`), or a PAK refusal on `X-Heimdall-App-Key`. Also `TENANT_API_KEY_SCOPE_ABOVE_CEILING` (`details.scopes`: scopes your role does not hold) and `step_up_required` (`details.max_age_minutes`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","TENANT_API_KEYS_NOT_ENABLED","TENANT_API_KEYS_SUSPENDED","TENANT_API_KEYS_APP_PROXY_ONLY","TENANT_CONTEXT_REQUIRED","TENANT_MEMBERSHIP_REQUIRED","TENANT_NOT_ACTIVE","TENANT_API_KEYS_FORBIDDEN","TENANT_API_KEY_SCOPE_ABOVE_CEILING","step_up_required"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"`TENANT_API_KEY_LIMIT` (25 active keys), `idempotency_secret_not_replayable` (`details.key_id`), or `IDEMPOTENCY_KEY_REUSE`. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","TENANT_API_KEY_LIMIT","idempotency_secret_not_replayable","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"security":[{"bearer":[]}],"summary":"Create an API key for the active tenant","tags":["Consumer · Tenant API keys"]}},"/{app_slug}/v1/tenant-api-keys/{key_id}/rotate":{"post":{"description":"Mints a replacement with the same name and scopes and returns its secret ONCE. You become the replacement's creator, so its scopes must be within your own tenant role. The outgoing key is revoked now (`grace_hours: 0`, the default) or keeps working for up to 24 hours. To kill a leaked key, revoke it. Responses carry `Cache-Control: no-store`.","operationId":"ConsumerTenantApiKeysController_rotate","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}},{"name":"key_id","required":true,"in":"path","description":"Key id (UUID) or display id (`tak_…`).","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Retry safety for key creation. The first request returns the secret; a repeat with the same key and body returns 409 `idempotency_secret_not_replayable` with `details.key_id` (the secret is never replayed). Same key with a different body: 409 `IDEMPOTENCY_KEY_REUSE`. 24 h window.","required":false,"schema":{"type":"string"}},{"name":"X-Heimdall-App-Key","in":"header","description":"The app's Platform API Key (`pcft_live_…`, holding `auth.tenant.api_keys.introspect` on this app). Required, next to the end user's bearer, when the app has set `management_lane: app_proxy_only`: the app's backend then proxies these routes and the end user cannot call them directly.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateTenantApiKeyDto"}}}},"responses":{"201":{"description":"Replacement created; `key` is the new secret, shown once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeyWithSecretDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`VALIDATION_ERROR` (`grace_hours` outside 0-24), `TENANT_API_KEY_EXPIRY_INVALID`, or a malformed `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","TENANT_API_KEY_EXPIRY_INVALID"]},"401":{"description":"Missing or invalid end-user bearer token (M2M tokens, PAKs and API keys are refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`TENANT_API_KEYS_NOT_ENABLED` (the app has not enabled tenant API keys), `TENANT_API_KEYS_SUSPENDED`, `TENANT_API_KEYS_APP_PROXY_ONLY` (the app manages keys through its own backend; call it instead), `TENANT_CONTEXT_REQUIRED` (no active tenant on the session: switch tenant first), `TENANT_MEMBERSHIP_REQUIRED`, `TENANT_NOT_ACTIVE`, `TENANT_API_KEYS_FORBIDDEN` (your tenant role is not in the app's `manage_roles`), or a PAK refusal on `X-Heimdall-App-Key`. Also `TENANT_API_KEY_SCOPE_ABOVE_CEILING` and `step_up_required`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","TENANT_API_KEYS_NOT_ENABLED","TENANT_API_KEYS_SUSPENDED","TENANT_API_KEYS_APP_PROXY_ONLY","TENANT_CONTEXT_REQUIRED","TENANT_MEMBERSHIP_REQUIRED","TENANT_NOT_ACTIVE","TENANT_API_KEYS_FORBIDDEN","TENANT_API_KEY_SCOPE_ABOVE_CEILING","step_up_required"]},"404":{"description":"`TENANT_API_KEY_NOT_FOUND`: no such key in your active tenant (or app not found).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","TENANT_API_KEY_NOT_FOUND"]},"409":{"description":"`TENANT_API_KEY_NOT_ACTIVE` (revoked or expired keys cannot be rotated), `TENANT_API_KEY_SCOPE_NOT_GRANTABLE` (the app no longer offers one of its scopes), `idempotency_secret_not_replayable`, or `IDEMPOTENCY_KEY_REUSE`. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","TENANT_API_KEY_NOT_ACTIVE","TENANT_API_KEY_SCOPE_NOT_GRANTABLE","idempotency_secret_not_replayable","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"security":[{"bearer":[]}],"summary":"Rotate an API key","tags":["Consumer · Tenant API keys"]}},"/{app_slug}/v1/tenant-api-keys/{key_id}":{"delete":{"description":"Immediate and final. Idempotent: revoking a revoked key is a 204 that changes nothing.","operationId":"ConsumerTenantApiKeysController_revoke","parameters":[{"name":"app_slug","required":true,"in":"path","description":"The app slug.","schema":{"type":"string"}},{"name":"key_id","required":true,"in":"path","description":"Key id (UUID) or display id (`tak_…`).","schema":{"type":"string"}},{"name":"X-Heimdall-App-Key","in":"header","description":"The app's Platform API Key (`pcft_live_…`, holding `auth.tenant.api_keys.introspect` on this app). Required, next to the end user's bearer, when the app has set `management_lane: app_proxy_only`: the app's backend then proxies these routes and the end user cannot call them directly.","required":false,"schema":{"type":"string"}}],"responses":{"204":{"description":"Revoked (or already revoked).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid end-user bearer token (M2M tokens, PAKs and API keys are refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"`TENANT_API_KEYS_NOT_ENABLED` (the app has not enabled tenant API keys), `TENANT_API_KEYS_SUSPENDED`, `TENANT_API_KEYS_APP_PROXY_ONLY` (the app manages keys through its own backend; call it instead), `TENANT_CONTEXT_REQUIRED` (no active tenant on the session: switch tenant first), `TENANT_MEMBERSHIP_REQUIRED`, `TENANT_NOT_ACTIVE`, `TENANT_API_KEYS_FORBIDDEN` (your tenant role is not in the app's `manage_roles`), or a PAK refusal on `X-Heimdall-App-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","TENANT_API_KEYS_NOT_ENABLED","TENANT_API_KEYS_SUSPENDED","TENANT_API_KEYS_APP_PROXY_ONLY","TENANT_CONTEXT_REQUIRED","TENANT_MEMBERSHIP_REQUIRED","TENANT_NOT_ACTIVE","TENANT_API_KEYS_FORBIDDEN"]},"404":{"description":"`TENANT_API_KEY_NOT_FOUND`: no such key in your active tenant (or app not found).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","TENANT_API_KEY_NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Revoke an API key","tags":["Consumer · Tenant API keys"]}},"/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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body, empty `displayName`, or `slug` not 1-64 chars of [a-z0-9-].","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.create` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Slug already taken in this app, or no unique slug could be derived. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `status` value.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or tenant not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body, unknown `status`, or malformed `slug`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or tenant not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Slug already taken in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.delete` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or tenant not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`search` or `role` exceeds its max length.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or tenant not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body, neither or both of `accountId` / `email`, or unknown role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.members.add` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, tenant or account not found in this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Account is already a member of this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT"]}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid body or unknown role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.members.update-role` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, tenant or membership not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.tenant.members.remove` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App, tenant or membership not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"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"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid email, unknown role, or maxUses/expiresInHours out of range.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"409":{"description":"That email is already a member of the tenant, or already has a pending invite to it. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `status` value.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Tenant or invite not found.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"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}/tenant-api-keys":{"get":{"description":"Every tenant of the app, newest first, cursor-paginated (20 per page, max 100). Secrets are never returned. Each key names its creator (`created_by`), and its `status` is `orphaned` when it is neither revoked nor expired but its creator left the tenant or the tenant is not active (introspection refuses it). Requires `auth.read` on the app.","operationId":"PlatformTenantApiKeysController_list","parameters":[{"name":"app_id","required":true,"in":"path","description":"App UUID (or slug).","schema":{"type":"string"}},{"name":"tenant_id","required":false,"in":"query","description":"Only this tenant: UUID or `tnt_…` display id.","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","description":"Only keys in this state. `active` excludes `orphaned` keys.","schema":{"enum":["active","expired","revoked","orphaned"],"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"cursor","required":false,"in":"query","schema":{"type":"string"},"description":"`next_cursor` from the previous page."}],"responses":{"200":{"description":"A page of keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformTenantApiKeyListResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `status` or malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or `tenant_id` names no tenant of this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List the app's tenant API keys","tags":["tenant-api-keys"]}},"/v1/apps/{app_id}/tenant-api-keys/config":{"get":{"description":"Includes the platform kill switch `suspended`. Requires `auth.read` on the app.","operationId":"PlatformTenantApiKeysController_getConfig","parameters":[{"name":"app_id","required":true,"in":"path","description":"App UUID (or slug).","schema":{"type":"string"}}],"responses":{"200":{"description":"The effective settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeysConfigDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Read the app's tenant API key settings","tags":["tenant-api-keys"]},"put":{"description":"Fields you leave out keep their stored value. `suspended: true` is the kill switch: introspection answers `{\"active\": false}` for every key of the app at once, and key management is refused, until you set it back to `false`. The app's own config push (`PUT /:appSlug/v1/tenant-api-keys/config`) can never change it. Requires `auth.update` on the app.","operationId":"PlatformTenantApiKeysController_putConfig","parameters":[{"name":"app_id","required":true,"in":"path","description":"App UUID (or slug).","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Retry safety. Same key and body within 24 h replays the first response; same key with a different body is 409 `IDEMPOTENCY_KEY_REUSE`.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformTenantApiKeysConfigDto"}}}},"responses":{"200":{"description":"The settings now stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantApiKeysConfigDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`VALIDATION_ERROR`, `TENANT_API_KEYS_SCOPE_INVALID`, `TENANT_API_KEYS_UNKNOWN_ROLE`, `TENANT_API_KEY_EXPIRY_INVALID`, `TENANT_API_KEYS_NO_SCOPES`, or a malformed `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","TENANT_API_KEYS_SCOPE_INVALID","TENANT_API_KEYS_UNKNOWN_ROLE","TENANT_API_KEY_EXPIRY_INVALID","TENANT_API_KEYS_NO_SCOPES"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"`IDEMPOTENCY_KEY_REUSE`. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"security":[{"bearer":[]}],"summary":"Change the app's tenant API key settings, or suspend its keys","tags":["tenant-api-keys"]}},"/v1/apps/{app_id}/tenant-api-keys/{key_id}":{"delete":{"description":"Immediate and final; recorded as revoked by you (`revoked_by_type: platform_user`) in the key and in the audit log. Idempotent: revoking a revoked key is a 204 that changes nothing. Requires `auth.revoke` on the app.","operationId":"PlatformTenantApiKeysController_revoke","parameters":[{"name":"app_id","required":true,"in":"path","description":"App UUID (or slug).","schema":{"type":"string"}},{"name":"key_id","required":true,"in":"path","description":"Key id (UUID) or display id (`tak_…`).","schema":{"type":"string"}}],"responses":{"204":{"description":"Revoked (or already revoked).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.revoke` on this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"`TENANT_API_KEY_NOT_FOUND`: no such key in this app (or app not found).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","TENANT_API_KEY_NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Revoke a tenant's API key","tags":["tenant-api-keys"]}},"/v1/apps/{app_id}/webhook":{"get":{"deprecated":true,"operationId":"AppOutboundWebhookController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The endpoint, or null if the app has none.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookConfigResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read`, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Read the app's first webhook endpoint (or null if it has none). Plaintext secret is NOT returned, only the last-4 hint. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]},"put":{"deprecated":true,"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":"The endpoint and its new plaintext secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookConfigWithSecretResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Invalid URL or event type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"422":{"description":"The URL points at a private, loopback or internal address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNPROCESSABLE_ENTITY"]}},"security":[{"bearer":[]}],"summary":"Create or replace the app's first webhook endpoint. Returns a new plaintext signing secret ONCE (the old one stops working). Also clears an automatic disable. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]},"delete":{"deprecated":true,"operationId":"AppOutboundWebhookController_delete","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted (or there was none).","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.delete`, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Delete the app's first webhook endpoint. Other endpoints keep receiving events. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/rotate-secret":{"post":{"deprecated":true,"operationId":"AppOutboundWebhookController_rotateSecret","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Rotated endpoint and the new plaintext.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppWebhookConfigWithSecretResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.rotate-secret`, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found, or it has no webhook endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Rotate the signing secret of the first endpoint. Returns the new plaintext ONCE. `X-Auth-Signature` uses it at once; the standard `webhook-signature` header carries both secrets for 24 hours. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/attempts":{"get":{"deprecated":true,"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.read`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]}},"security":[{"bearer":[]}],"summary":"Webhook delivery attempts to any of this app's endpoints, newest first, with status code, latency and error. Filter with `?outcome=failed` to see only the deliveries that did not land. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/dead-letters":{"get":{"deprecated":true,"description":"A failed delivery is retried automatically 1 min, 5 min, 30 min, 2 h, 6 h and 24 h after each failure (7 attempts in all). An event is listed here when the last of those attempts fails, or when it happened while the endpoint was switched off. Events are kept for 30 days (`GET /v1/apps/{appId}/webhooks/events?status=failed` is the replacement).","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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Malformed `cursor`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.read`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]}},"security":[{"bearer":[]}],"summary":"Events the first endpoint never accepted and that will not be retried automatically, newest first. Each carries the original signed payload so it can be replayed once the receiver is fixed. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/dead-letters/{dead_letter_id}/replay":{"post":{"deprecated":true,"description":"Returns `delivered` only on a 2xx from the receiver. A bounce comes back as `failed` with the status code, and so does an endpoint that is switched off. The URL is checked again, so an endpoint 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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"No such event in this app (events are kept for 30 days), or the app has no webhook endpoint to replay to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Re-deliver one dead-lettered event to the first endpoint. One attempt; the result tells you whether it landed. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/webhook/dead-letters/replay":{"post":{"deprecated":true,"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"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing, invalid or expired bearer token.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"],"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"403":{"description":"Caller lacks `auth.update`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]}},"security":[{"bearer":[]}],"summary":"Replay every unreplayed dead letter of the first endpoint, oldest first. Up to 100 per call; stops at the first bounce. Deprecated: use `/v1/apps/{appId}/webhooks/endpoints` (several endpoints, event descriptions, Send test, event log, replay). This route acts on the app's first endpoint. Responses carry `Deprecation: true` and `Sunset: Fri, 15 Jan 2027 00:00:00 GMT`.","tags":["app-outbound-webhooks"]}},"/v1/apps/{app_id}/setup-status":{"get":{"description":"Rows, in order: `signups` (attention while sign-ups are off), `redirect_origins` (blocked until at least one site may start sign-in), one `provider_<id>` row per switched-on sign-in provider (blocked while its status is blocked), `email` (sender chosen and a template for every email; attention while emails are off), `api_key` (attention until an active app key exists), `webhook` (attention until an endpoint has accepted a delivery), and `passkeys` (only while passkeys are on; blocked when no allowed redirect origin is on the passkey site). `complete` is true once no row is `not_set` or `blocked`. Each `fix.href` is a console path to the exact setting. Computed on every request from stored settings, plus the workspace default sender read from Mail with your own access when emails use it: nothing here calls DNS or a sign-in provider (a provider whose last credential check was rejected shows as blocked). A ready provider row carries `test.action: sign_in.try`: run it with `POST /v1/apps/{appId}/sign-in-tests` `{ method: \"oauth\", provider }`.","operationId":"SetupStatusController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"The checklist, computed now.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupStatusDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Get the setup checklist for an app: what works, what is missing, and where to fix it.","tags":["apps"]}},"/v1/apps/{app_id}/sign-in-tests":{"post":{"description":"For `oauth`, the server first checks what it can on its own (an allowed redirect origin, saved credentials, and the provider credential check). If one fails, the test comes back `failed` at once with the fix. Otherwise open `start_url` in a popup: the person signs in at the provider, the provider returns to Auth, Auth resolves who it is exactly as a real sign-in would, records `would_sign_in_existing` or `would_create` (or the provider error mapped to its fix), creates no account and issues no token, then sends the popup to the console result page. Poll `GET …/sign-in-tests/{id}` until `state` is not `pending`. Works with a provider that is saved but not switched on yet. For `password` / `otp`, the test runs now: it checks the sign-up or sign-in rules for your own email and sends the real email to your own verified address. At most 20 tests per app per hour; tests are deleted after 24 hours.","operationId":"SignInTestController_create","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. A retried create with the same key returns the same test instead of sending a second email or starting a second test. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSignInTestDto"}}}},"responses":{"201":{"description":"The test, already settled for `password` / `otp` and for an `oauth` test that failed its checks.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInTestDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Unknown `method` or provider, `provider` missing for `oauth`, or `custom_domain` on a `password` / `otp` test (`CUSTOM_DOMAIN_OAUTH_ONLY`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","CUSTOM_DOMAIN_OAUTH_ONLY"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"Idempotency-Key reused with a different body (`IDEMPOTENCY_KEY_REUSE`), or `custom_domain: true` while the app has no active custom domain (`CUSTOM_DOMAIN_NOT_ACTIVE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","IDEMPOTENCY_KEY_REUSE","CUSTOM_DOMAIN_NOT_ACTIVE","IDEMPOTENCY_IN_PROGRESS"]},"429":{"description":"More than 20 tests for this app in the last hour (`RATE_LIMITED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["RATE_LIMITED"]}},"security":[{"bearer":[]}],"summary":"Start a sign-in test: prove sign-in works without creating a user.","tags":["apps"]}},"/v1/apps/{app_id}/sign-in-tests/{test_id}":{"get":{"description":"Poll this after opening `start_url`. `state` turns `succeeded` (with `outcome` and `identity`) or `failed` (with `error.fix`). A test that is never finished turns `failed` with code `expired` 15 minutes after it was created.","operationId":"SignInTestController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"test_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInTestDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`testId` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or sign-in test not found (tests are deleted after 24 hours).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Get a sign-in test and its result.","tags":["apps"]}},"/v1/apps/{app_id}/custom-domains":{"post":{"description":"Returns the two records to publish: a CNAME from the host to `edge_target`, and a TXT token at `_productcraft-auth.<host>` that proves the domain is yours. The zone and its DNS provider are detected, and the first check runs straight away, so `records[].state` and `conflicts` already say what is missing or in the way. Publish the records by hand, import `…/export`, or let `…/apply` write them with a short-lived provider token. The domain turns `active` once both records are seen. Sign-in tokens keep the canonical issuer (`https://api.auth.productcraft.co/{slug}`), so nothing changes for code that verifies them. One custom domain per app. While the feature is in early access it is switched on per workspace.","operationId":"CustomDomainController_create","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Stripe-style idempotency key. A retried create with the same key and body returns the first response. 24h TTL.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomDomainDto"}}}},"responses":{"201":{"description":"The domain, with its records and the first check's result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Not a usable host name (`INVALID_HOSTNAME`), one of ours (`HOSTNAME_RESERVED`), or the root of a DNS zone, which cannot hold a CNAME (`APEX_NOT_SUPPORTED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","INVALID_HOSTNAME","HOSTNAME_RESERVED","APEX_NOT_SUPPORTED"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, the app is suspended or archived, or custom domains are not switched on for the workspace (`FEATURE_NOT_ENABLED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN","FEATURE_NOT_ENABLED"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]},"409":{"description":"The app already has a custom domain (`CUSTOM_DOMAIN_EXISTS`), the host is set up on another app (`HOSTNAME_TAKEN`), or the Idempotency-Key was reused with a different body (`IDEMPOTENCY_KEY_REUSE`). Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","CUSTOM_DOMAIN_EXISTS","HOSTNAME_TAKEN","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]}},"security":[{"bearer":[]}],"summary":"Add a custom domain (such as auth.acme.com) for this app's sign-in.","tags":["apps"]},"get":{"description":"Each with its status and the last check of its records, plus `available`: whether custom domains are switched on for the workspace. Always a single page.","operationId":"CustomDomainController_list","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainListDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"List the app's custom domains (at most one).","tags":["apps"]}},"/v1/apps/{app_id}/custom-domains/{domain_id}":{"get":{"description":"Reads stored state only; `POST …/check` runs a new DNS check.","operationId":"CustomDomainController_get","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"domain_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`domainId` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or custom domain not found (`CUSTOM_DOMAIN_NOT_FOUND`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","CUSTOM_DOMAIN_NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Get a custom domain and the last check of its records.","tags":["apps"]},"delete":{"description":"The host stops serving this app at once and its certificate is no longer renewed. Your DNS records are not touched: delete the CNAME and the `_productcraft-auth` TXT record at your DNS provider. Sign-ins on the canonical host are unaffected.","operationId":"CustomDomainController_remove","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"domain_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Removed.","headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`domainId` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or custom domain not found (`CUSTOM_DOMAIN_NOT_FOUND`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","CUSTOM_DOMAIN_NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Remove the custom domain.","tags":["apps"]}},"/v1/apps/{app_id}/custom-domains/{domain_id}/check":{"post":{"description":"Asks the zone's own nameservers and a public resolver for both records, stores each record's state and conflicts, and moves the status: `active` once both are verified; an active domain turns `failing` only when a record is definitely gone or blocked (a DNS outage never takes a working domain down). Safe to repeat.","operationId":"CustomDomainController_check","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"domain_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The domain after the check.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`domainId` is not a UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or custom domain not found (`CUSTOM_DOMAIN_NOT_FOUND`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","CUSTOM_DOMAIN_NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Check the custom domain's DNS records now.","tags":["apps"]}},"/v1/apps/{app_id}/custom-domains/{domain_id}/apply":{"post":{"description":"Reads the zone through the provider's API and plans both records: `create`, or `skip` when already there. Nothing is ever changed or deleted, and only names at or under the custom host are written (Cloudflare records are DNS-only, not proxied). Anything in the way (another CNAME at the host, A/MX/TXT records where the CNAME must go) refuses the whole apply with 409 and the plan, before anything is written. `dry_run: true` returns the plan and changes nothing; `dry_run: false` writes exactly that plan and runs a check straight away (the records are usually `propagating` for a few minutes). The token is used for this request only: never stored, logged, queued or returned; revoke it afterwards (`warnings` says so when it could see more than this zone).","operationId":"CustomDomainController_apply","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"domain_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Required. Opaque client-generated string (1-256 chars, `[A-Za-z0-9_-:]`). A retry with the same key and body (the token is not part of the comparison) replays the first response instead of writing again, for 24 hours.","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyCustomDomainDnsDto"}}}},"responses":{"200":{"description":"The plan (dry run), or the plan with each record's `result` and the domain after a fresh check.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainApplyResultDto"}}},"headers":{"Idempotent-Replay":{"description":"`true` when this response is a replay of an earlier request with the same `Idempotency-Key`.","schema":{"type":"string","enum":["true"]}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"Validation failure; `Idempotency-Key` missing (`IDEMPOTENCY_KEY_REQUIRED`); `zone` is not the host or a parent (`INVALID_ZONE`); the provider rejected the token (`PROVIDER_TOKEN_REJECTED`), it lacks DNS rights (`PROVIDER_PERMISSION_DENIED`), or it cannot see a zone for the host (`PROVIDER_ZONE_NOT_FOUND`); the host is a zone root (`APEX_NOT_SUPPORTED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","IDEMPOTENCY_KEY_REQUIRED","INVALID_ZONE","PROVIDER_TOKEN_REJECTED","PROVIDER_PERMISSION_DENIED","PROVIDER_ZONE_NOT_FOUND","APEX_NOT_SUPPORTED"]},"401":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.update` on this app, or the app is suspended or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or custom domain not found (`CUSTOM_DOMAIN_NOT_FOUND`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","CUSTOM_DOMAIN_NOT_FOUND"]},"409":{"description":"`DNS_CONFLICTS`: something at the names blocks a record. Nothing was written; the body carries the full `plan` and `conflicts` next to `code` and `message`. Or `IDEMPOTENCY_KEY_REUSE`. Idempotency: `IDEMPOTENCY_KEY_REUSE` when this `Idempotency-Key` was already used with a different body or route; `IDEMPOTENCY_IN_PROGRESS` while another request with the same key is still running (retry after `Retry-After` seconds, with the same key).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying with the same key (sent with `IDEMPOTENCY_IN_PROGRESS`).","schema":{"type":"integer"}},"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["CONFLICT","DNS_CONFLICTS","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_IN_PROGRESS"]},"502":{"description":"The provider failed or did not answer (`PROVIDER_UNAVAILABLE`, `PROVIDER_ERROR`). Nothing was written unless the plan items say so.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_GATEWAY","PROVIDER_UNAVAILABLE","PROVIDER_ERROR"]}},"security":[{"bearer":[]}],"summary":"Set up the custom domain's DNS records in one step with a short-lived Cloudflare or DigitalOcean API token.","tags":["apps"]}},"/v1/apps/{app_id}/custom-domains/{domain_id}/export":{"get":{"description":"`format=bind`: a zone-file fragment (`$ORIGIN <zone>.`, `$TTL 3600`, names relative to the zone, a comment per record with its purpose and any conflict) that Cloudflare and Route 53 import directly. `format=csv`: `type,name,value,priority,ttl,purpose,required`, fully qualified names. By default only records not verified yet; `all=true` for both. Served as an attachment.","operationId":"CustomDomainController_export","parameters":[{"name":"app_id","required":true,"in":"path","schema":{"type":"string"}},{"name":"domain_id","required":true,"in":"path","schema":{"format":"uuid","type":"string"}},{"name":"format","required":true,"in":"query","schema":{"type":"string","enum":["bind","csv"]}},{"name":"all","required":false,"in":"query","schema":{"type":"boolean","default":false},"description":"Include records that already verify."}],"responses":{"200":{"description":"The file.","content":{"text/dns":{"schema":{"type":"string","example":"$ORIGIN acme.com.\n$TTL 3600\n; routing (required)\nauth\t3600\tIN\tCNAME\tedge.auth.productcraft.co.\n"}},"text/csv":{"schema":{"type":"string","example":"type,name,value,priority,ttl,purpose,required\r\nTXT,_productcraft-auth.auth.acme.com,productcraft-auth-verification=Zk3q9Lr2Vx0bYt7NwPa1Qc5e,,3600,ownership,true\r\n"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}}},"400":{"description":"`format` missing or not `bind` / `csv` (`INVALID_FORMAT`), or `domainId` is not a UUID.","content":{"text/dns":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}},"text/csv":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["BAD_REQUEST","VALIDATION_ERROR","INVALID_FORMAT"]},"401":{"description":"Missing or invalid bearer token.","content":{"text/dns":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}},"text/csv":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["UNAUTHORIZED"]},"403":{"description":"Caller lacks `auth.read` on this app, or the app is suspended or archived.","content":{"text/dns":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}},"text/csv":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["FORBIDDEN"]},"404":{"description":"App or custom domain not found (`CUSTOM_DOMAIN_NOT_FOUND`).","content":{"text/dns":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}},"text/csv":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}},"headers":{"X-Request-Id":{"$ref":"#/components/headers/RequestId"}},"x-pc-error-codes":["NOT_FOUND","CUSTOM_DOMAIN_NOT_FOUND"]}},"security":[{"bearer":[]}],"summary":"Download the custom domain's DNS records as a BIND zone fragment or a CSV.","tags":["apps"]}}},"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":[{"name":"Auth · Tenants"},{"name":"Auth-admin · Apps"},{"name":"Consumer · Admin"},{"name":"Consumer · Admin · MFA"},{"name":"Consumer · Admin · Permissions"},{"name":"Consumer · Admin · Roles"},{"name":"Consumer · Auth"},{"name":"Consumer · Discovery"},{"name":"Consumer · MFA"},{"name":"Consumer · Me"},{"name":"Consumer · OAuth (M2M)"},{"name":"Consumer · OAuth Sign-In"},{"name":"Consumer · Tenant API keys"},{"name":"Consumer · Tenants"},{"name":"Consumer · Verify"},{"name":"Platform · Stats"},{"name":"api-keys"},{"name":"app-audit"},{"name":"app-audit-sink"},{"name":"app-notification-settings"},{"name":"app-outbound-webhooks"},{"name":"app-webhooks"},{"name":"apps"},{"name":"credentials"},{"name":"end-user-insights"},{"name":"end-user-invites"},{"name":"end-users"},{"name":"permissions"},{"name":"roles"},{"name":"tenant-api-keys"}],"servers":[{"url":"https://api.auth.productcraft.co","description":"Auth API"}],"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"},"bot_protection_token":{"type":"string","description":"The response token from the app's bot-protection widget (Cloudflare Turnstile or hCaptcha). Required when the app has bot protection switched on (`auth_config.bot_protection.enabled`); ignored otherwise. Auth verifies it with the provider before anything else about the sign-up is looked at. Tokens are single-use and expire after a few minutes, so render a fresh widget for every attempt. A backend that signs users up with a Platform API Key forwards the token its browser received.","maxLength":2048,"example":"0.Zx3xS9oWnZ1Y…"}},"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"]},"ErrorParamDto":{"type":"object","properties":{"param":{"type":"string","description":"Wire path of the invalid field: `email`, `items.0.display_name`.","example":"email"},"message":{"type":"string","description":"What is wrong with it; the same text as the field's entry in an array `message`.","example":"email must be an email"},"code":{"type":"string","description":"The failed rule (class-validator constraint name, e.g. `isEmail`, `isUuid`, `whitelistValidation` for a field the operation does not declare), or `invalid`.","example":"isEmail"}},"required":["param","message","code"]},"ErrorResponseDto":{"type":"object","properties":{"status_code":{"type":"number","description":"HTTP status code, same as the response status.","example":404},"statusCode":{"type":"number","description":"Deprecated alias of `status_code`, same value. Kept so existing clients keep working; read `status_code`. Error responses carry `Deprecation: true` while it is sent.","example":404,"deprecated":true},"message":{"description":"Human-readable error message. A string, or on a `VALIDATION_ERROR` an array with one message per invalid field (the same texts as `errors[].message`). Branch on `code`, not on this.","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"example":"Domain not found"},"code":{"description":"Stable, machine-readable error code; branch on this, not on `message`. Always present. A route-specific code when the route defines one, otherwise the generic code for the status (`NOT_FOUND`, `VALIDATION_ERROR`, `RATE_LIMITED`, ...). Open set: accept codes you do not know. Each code is documented at https://productcraft.co/docs/errors#<code>.","example":"NOT_FOUND","allOf":[{"$ref":"#/components/schemas/AuthErrorCode"}]},"request_id":{"type":"string","description":"The request's id, same as the `X-Request-Id` response header. Quote it when contacting support.","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","example":"req_6f1c2a9d4b7e4f0a8c3d2e1f0a9b8c7d"},"errors":{"description":"One entry per invalid request field on a `VALIDATION_ERROR`; empty on every other error.","type":"array","items":{"$ref":"#/components/schemas/ErrorParamDto"}},"details":{"type":"object","description":"Structured context for errors a client has to act on, keyed in snake_case and specific to the code (e.g. `{ fqdn, reason }` on `from_domain_not_verified`). Absent on most errors.","additionalProperties":true,"example":{"fqdn":"mail.example.com","reason":"not_verified"}}},"required":["status_code","message","code","request_id","errors"]},"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":"Your own key/value data for this app. It replaces the stored metadata, and keys are kept exactly as you send them. The `auth` key holds the app's sign-in settings, which you change with `PATCH /v1/apps/{appId}/auth-config` (it checks every value): leave `auth` out and it is kept as it is, or send it back unchanged. A different `auth` value is refused with 400 `AUTH_SETTINGS_IN_METADATA`.","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"]},"BotProtectionResponseDto":{"type":"object","properties":{"provider":{"type":"string","enum":["turnstile","hcaptcha"],"example":"turnstile"},"site_key":{"type":"string","description":"Public widget site key.","example":"0x4AAAAAAABkMYinukE8nzY"},"enabled":{"type":"boolean","description":"Whether sign-up requires a verified token."},"expected_hostnames":{"description":"Hostnames a solved challenge must come from. Empty = any.","type":"array","items":{"type":"string"}},"secret_set":{"type":"boolean","description":"Whether a provider secret is stored. The secret itself is never returned."},"secret_last4":{"type":"string","description":"Last four characters of the stored secret, so you can tell which one it is. null when none is stored.","nullable":true,"example":"cr3t"}},"required":["provider","site_key","enabled","expected_hostnames","secret_set","secret_last4"]},"AuthConfigResponseDto":{"type":"object","properties":{"signup_enabled":{"type":"boolean"},"signup_requires_pak":{"type":"boolean","description":"When true, sign-up requires a Platform API Key from this app's workspace (anonymous sign-ups get 401)."},"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},"unverified_account_ttl_days":{"type":"number","example":7,"nullable":true,"minimum":1,"maximum":90,"description":"Days an account may keep an unverified primary email before it is deleted. null = never."},"bot_protection":{"nullable":true,"description":"Sign-up bot protection; null when not configured.","allOf":[{"$ref":"#/components/schemas/BotProtectionResponseDto"}]}},"required":["signup_enabled","signup_requires_pak","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","unverified_account_ttl_days","bot_protection"]},"UpdateBotProtectionDto":{"type":"object","properties":{"provider":{"type":"string","description":"Which CAPTCHA the app's sign-up form renders: Cloudflare Turnstile or hCaptcha. Required the first time the block is configured. Changing it without sending a new `secret` clears the stored secret, which belonged to the other provider.","enum":["turnstile","hcaptcha"]},"site_key":{"type":"string","description":"The public site key your sign-up page renders the widget with. Not a secret. Required the first time the block is configured.","example":"0x4AAAAAAABkMYinukE8nzY","maxLength":256},"secret":{"type":"string","description":"The provider secret Auth verifies tokens with. WRITE-ONLY: no response ever returns it (GET shows `secret_set` and the last four characters). Send a string to store or replace it, null to clear it, or leave it out to keep the stored one. Stored encrypted.","example":"0x4AAAAAAABkMYinukE8nzYS_secret","maxLength":512,"nullable":true,"writeOnly":true},"enabled":{"type":"boolean","description":"When true, every sign-up on this app must carry a `bot_protection_token` the provider accepts. Needs a stored secret. Takes effect within seconds."},"expected_hostnames":{"description":"Hostnames the solved challenge must come from, as the provider reports them (exact match, no scheme, port or path). Empty (default) accepts any hostname the site key allows. Send null or [] to clear.","example":["app.example.com"],"maxItems":20,"nullable":true,"type":"array","items":{"type":"string"}}}},"UpdateAuthConfigDto":{"type":"object","properties":{"signup_enabled":{"type":"boolean","description":"Whether new EndUsers can sign up."},"signup_requires_pak":{"type":"boolean","description":"When true, sign-up only works from your backend: every `POST /{app_slug}/v1/auth/signup` must carry a Platform API Key (`Authorization: Bearer pcft_live_…`) from this app's workspace whose policy allows `auth.signup.create` on this app. Anonymous sign-ups get 401. Use it when your own backend gates sign-ups (CAPTCHA, invite or payment checks). Independent of `bot_protection`; an app can use both. Turning it on for an app that has no workspace is refused with 409."},"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},"unverified_account_ttl_days":{"type":"number","description":"Delete accounts whose primary email is still unverified this many days after sign-up. Only self-sign-ups that never signed in after sign-up, hold no two-step factor, no verified contact and no social login qualify. Deletion is permanent, frees the email address and username, is audited as `auth.account.expired_unverified` and fires `user.deleted` with `reason: \"unverified_expired\"`. Send null to never delete. New apps start at 7; apps created before this setting existed start at null.","example":7,"nullable":true,"minimum":1,"maximum":90},"bot_protection":{"description":"Sign-up bot protection (Cloudflare Turnstile or hCaptcha). Fields you send are merged onto the stored block; send null to remove the block and its secret. While `enabled`, sign-up requires a `bot_protection_token` the provider accepts, checked before anything else, and fails closed (503) when the provider cannot be reached.","nullable":true,"allOf":[{"$ref":"#/components/schemas/UpdateBotProtectionDto"}]}}},"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."},"status":{"type":"string","enum":["ready","blocked","disabled","not_configured"],"example":"blocked","description":"Whether people can sign in with this provider right now, computed on the server with the same rule the web sign-in flow enforces. `ready`: switched on and nothing blocks sign-in. `blocked`: switched on, but sign-in fails until every `blocked_reasons` entry is fixed. `disabled`: configured but switched off. `not_configured`: no credentials saved for this provider."},"blocked_reasons":{"type":"array","example":["no_redirect_origins"],"description":"Why sign-in with this provider fails. Empty when `status` is `ready` or `not_configured`. For a `disabled` provider it lists what would block sign-in once switched on.\n\n- `no_redirect_origins`: The app has no allowed redirect origins, so web sign-in is refused. Add the origin of the site that starts sign-in (for example `https://app.example.com`) under Redirect origins.\n- `missing_credentials`: The stored provider credentials are incomplete or can no longer be read. Save the client id and secret (or Apple key) again.\n- `credentials_rejected`: The provider rejected the stored credentials the last time they were checked. Copy the client id and secret again from the provider console.","items":{"type":"string","enum":["no_redirect_origins","missing_credentials","credentials_rejected"]}},"last_check_at":{"type":"string","nullable":true,"format":"date-time","description":"When the credential check last ran. Null until it has run for the saved credentials."},"last_check_result":{"type":"string","nullable":true,"enum":["credentials_valid","credentials_rejected","inconclusive"],"description":"Result of the last credential check (run on every save and on `POST …/{provider}/check`). `credentials_valid`: the provider recognised the client id and secret (it only refused the dummy sign-in code we sent). `credentials_rejected`: the provider does not accept the client id or secret; sign-in with this provider is blocked until they are fixed. `inconclusive`: the provider could not be reached or gave an answer we cannot read; nothing is blocked."},"last_check_error":{"type":"string","nullable":true,"example":"invalid_client","description":"The provider's OAuth error code from the last check, if any."},"updated_at":{"type":"string","nullable":true,"format":"date-time"}},"required":["provider","display_name","configured","enabled","status","blocked_reasons","last_check_at","last_check_result","last_check_error"]},"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."},"last_check_at":{"type":"string","nullable":true,"format":"date-time","description":"When the credential check last ran. Null until it has run for the saved credentials."},"last_check_result":{"type":"string","nullable":true,"enum":["credentials_valid","credentials_rejected","inconclusive"],"description":"Result of the last credential check (run on every save and on `POST …/{provider}/check`). `credentials_valid`: the provider recognised the client id and secret (it only refused the dummy sign-in code we sent). `credentials_rejected`: the provider does not accept the client id or secret; sign-in with this provider is blocked until they are fixed. `inconclusive`: the provider could not be reached or gave an answer we cannot read; nothing is blocked."},"last_check_error":{"type":"string","nullable":true,"example":"invalid_client","description":"The provider's OAuth error code from the last check, if any."},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["provider","enabled","config","last_check_at","last_check_result","last_check_error","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."}}},"ProviderCheckDto":{"type":"object","properties":{"provider":{"type":"string","example":"google"},"result":{"type":"string","enum":["credentials_valid","credentials_rejected","inconclusive"],"example":"credentials_rejected","description":"`credentials_valid`: the provider recognised the client id and secret (it only refused the dummy sign-in code we sent). `credentials_rejected`: the provider does not accept the client id or secret; sign-in with this provider is blocked until they are fixed. `inconclusive`: the provider could not be reached or gave an answer we cannot read; nothing is blocked."},"provider_error":{"type":"string","nullable":true,"example":"invalid_client","description":"The provider's own OAuth error code from its token endpoint (`invalid_client`, `incorrect_client_credentials`, `invalid_grant`, `redirect_uri_mismatch`, …), or null when it gave none. GitHub answers an unknown client id with a plain 404; that is reported as `unknown_client`. `redirect_uri_mismatch` with `credentials_valid` means the client is right but the redirect URI registered at the provider is not `callback_url`."},"checked_at":{"type":"string","format":"date-time"},"callback_url":{"type":"string","format":"uri","description":"The exact redirect (callback) URL to register at the provider for this app.","example":"https://api.auth.productcraft.co/acme/v1/auth/oauth/google/callback"}},"required":["provider","result","provider_error","checked_at","callback_url"]},"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`). Account deletions: `auth.account.deleted` (one row per account, written in the deleting transaction; `resource` is `account`, `resource_id` the deleted account id, `metadata` `{ via, username, email_domain, created_at, verified }` where `via` is `platform`, `platform_bulk`, `consumer_admin` or `self`, plus `selection` (`{ mode, filter? }`) on bulk rows) and `auth.account.expired_unverified` (the unverified-account expiry job).","example":"app.member.removed"},"resource":{"type":"string","description":"Resource type touched (e.g. `app_membership`, `role`, `account`).","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},"actor_type":{"type":"string","description":"Who acted: `end_user`, `platform_user`, `api_key`, `system`, or an agent type.","example":"end_user"},"tenant_id":{"type":"string","description":"Tenant the action concerns, when it concerns one (e.g. `tenant.api_key.*` rows). `null` on app-level rows and on rows written before the column existed.","nullable":true,"format":"uuid"},"ip":{"type":"string","description":"Client IP recorded at log time, when known.","nullable":true},"metadata":{"type":"object","description":"Free-form structured detail object captured at log time (e.g. `{ \"from\": \"bcrypt\", \"to\": \"argon2id\" }` on `auth.password.upgraded`). This is the `metadata` key on the wire.","nullable":true},"created_at":{"format":"date-time","type":"string","description":"ISO 8601 timestamp."}},"required":["id","app_id","action","resource","resource_id","actor_id","actor_type","tenant_id","ip","metadata","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."},"state":{"type":"string","enum":["active","paused","auto_disabled"],"description":"`active`: sending. `paused`: switched off with `PATCH { enabled: false }`. `auto_disabled`: stopped after repeated failures (`disabled_reason` says which); `PATCH { enabled: true }` or a `PUT` turns it back on.","example":"active"},"last_attempt_at":{"type":"string","format":"date-time","nullable":true,"description":"Most recent POST to the receiver, accepted or not (a real batch or a test)."},"last_attempt_kind":{"type":"string","enum":["batch","test"],"nullable":true,"description":"`batch`: a real delivery. `test`: \"Send test batch\"."},"last_attempt_ok":{"type":"boolean","nullable":true,"description":"Whether the receiver accepted the most recent attempt (2xx)."},"last_attempt_status_code":{"type":"number","nullable":true,"description":"HTTP status of the most recent attempt; null when no response arrived (DNS, TLS, timeout, refused address).","example":200},"last_error":{"type":"string","nullable":true,"description":"Why the most recent attempt failed, in a short sentence; null after a success.","example":"Your receiver answered HTTP 401: invalid signature"},"last_test_succeeded_at":{"type":"string","format":"date-time","nullable":true,"description":"When the receiver last accepted a test batch. Cleared when the address or the signing secret changes."},"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","state","last_attempt_at","last_attempt_kind","last_attempt_ok","last_attempt_status_code","last_error","last_test_succeeded_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."},"state":{"type":"string","enum":["active","paused","auto_disabled"],"description":"`active`: sending. `paused`: switched off with `PATCH { enabled: false }`. `auto_disabled`: stopped after repeated failures (`disabled_reason` says which); `PATCH { enabled: true }` or a `PUT` turns it back on.","example":"active"},"last_attempt_at":{"type":"string","format":"date-time","nullable":true,"description":"Most recent POST to the receiver, accepted or not (a real batch or a test)."},"last_attempt_kind":{"type":"string","enum":["batch","test"],"nullable":true,"description":"`batch`: a real delivery. `test`: \"Send test batch\"."},"last_attempt_ok":{"type":"boolean","nullable":true,"description":"Whether the receiver accepted the most recent attempt (2xx)."},"last_attempt_status_code":{"type":"number","nullable":true,"description":"HTTP status of the most recent attempt; null when no response arrived (DNS, TLS, timeout, refused address).","example":200},"last_error":{"type":"string","nullable":true,"description":"Why the most recent attempt failed, in a short sentence; null after a success.","example":"Your receiver answered HTTP 401: invalid signature"},"last_test_succeeded_at":{"type":"string","format":"date-time","nullable":true,"description":"When the receiver last accepted a test batch. Cleared when the address or the signing secret changes."},"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","state","last_attempt_at","last_attempt_kind","last_attempt_ok","last_attempt_status_code","last_error","last_test_succeeded_at","created_at","updated_at","signing_secret"]},"SetupRowFixDto":{"type":"object","properties":{"label":{"type":"string","description":"Button text for the fix, in plain words.","example":"Add a redirect origin"},"href":{"type":"string","description":"Console-relative path of the exact place to fix this row (may carry a `#section` anchor). Link to it directly; it is never an absolute URL.","example":"/auth/apps/0b6f3c7e-6a43-4c55-9d4b-2a9a7f5b1c11/settings#auth-config"}},"required":["label","href"]},"SetupRowTestDto":{"type":"object","properties":{"label":{"type":"string","description":"Button text for the test, in plain words.","example":"Send a test event"},"action":{"type":"string","description":"Identifier of the test the console runs for this row (for example `webhook.test` or `sign_in.try`). The product documents each action next to the route that performs it.","example":"webhook.test"}},"required":["label","action"]},"SetupRowDto":{"type":"object","properties":{"id":{"type":"string","description":"Stable row identifier. Safe to key UI state on; new rows may be added.","example":"redirect_origins"},"title":{"type":"string","description":"Short title of the step.","example":"Sites allowed to start sign-in"},"state":{"type":"string","enum":["pass","attention","not_set","blocked"],"example":"blocked","description":"`pass`: done. `attention`: works, but worth a look (not blocking). `not_set`: a required step has not been started. `blocked`: set up, but something stops it from working until it is fixed."},"detail":{"type":"string","description":"One or two plain sentences: what is true now and, when the row is not `pass`, what to do about it.","example":"No site is allowed to start sign-in, so web sign-in is refused. Add the origin of your site, for example https://app.example.com."},"fix":{"nullable":true,"description":"Where to fix this row. Null when there is nothing to do or no single place to do it.","allOf":[{"$ref":"#/components/schemas/SetupRowFixDto"}]},"test":{"nullable":true,"description":"A real test that proves this row works, when the product offers one. Null otherwise.","allOf":[{"$ref":"#/components/schemas/SetupRowTestDto"}]}},"required":["id","title","state","detail","fix","test"]},"SetupStatusDto":{"type":"object","properties":{"checked_at":{"type":"string","format":"date-time","description":"When the server computed these rows.","example":"2026-10-16T12:00:00.000Z"},"complete":{"type":"boolean","description":"True when no row is `not_set` or `blocked`. `attention` rows do not stop setup from being complete.","example":false},"rows":{"description":"Checklist rows in the order they should be shown.","type":"array","items":{"$ref":"#/components/schemas/SetupRowDto"}}},"required":["checked_at","complete","rows"]},"UpdateAuditSinkDto":{"type":"object","properties":{"url":{"type":"string","description":"New HTTPS address. The signing secret is kept (unlike `PUT`, which issues a new one). Changing it clears the receiver-test proof.","example":"https://siem.customer.example.com/ingest/productcraft-auth"},"enabled":{"type":"boolean","description":"`false` pauses export; `true` turns it back on and clears a stop after repeated failures. The position is kept either way: records written meanwhile are sent once export is on, within the audit log retention period.","example":true}}},"AuditSinkTestResultDto":{"type":"object","properties":{"delivered":{"type":"boolean","description":"True when the receiver answered 2xx.","example":true},"status_code":{"type":"number","nullable":true,"description":"The receiver's HTTP status; null when no response arrived.","example":200},"error":{"type":"string","nullable":true,"description":"Why it was not accepted, in a short sentence; null on success.","example":null},"duration_ms":{"type":"number","description":"Round trip in milliseconds.","example":182},"attempted_at":{"type":"string","format":"date-time"},"sink":{"description":"The sink after recording this test.","allOf":[{"$ref":"#/components/schemas/AuditSinkResponseDto"}]}},"required":["delivered","status_code","error","duration_ms","attempted_at","sink"]},"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"},"metadata":{"type":"object","description":"Keys to merge into the end-user's metadata (shallow; a key set to null is removed; at most 8 KB stored). Set `app_active: true` to mark someone who uses your app: the unverified-account expiry never deletes an account carrying it.","additionalProperties":true,"example":{"app_active":true}}}},"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"]},"UnverifiedAccountExpiryStatusDto":{"type":"object","properties":{"ttl_days":{"type":"number","nullable":true,"example":7,"description":"The app's `unverified_account_ttl_days` (null = expiry is off)."},"deleted_last_30_days":{"type":"number","example":3,"description":"Accounts this app's expiry deleted in the last 30 days (from the audit log)."}},"required":["ttl_days","deleted_last_30_days"]},"RunUnverifiedAccountExpiryDto":{"type":"object","properties":{"dry_run":{"type":"boolean","default":false,"description":"Count and list the accounts that would be deleted without deleting any. Allowed while the setting is off, in which case it previews the default 7-day window."},"min_age_minutes":{"type":"number","minimum":0,"maximum":1440,"description":"TEST OVERRIDE, development apps only (`environment: development`; any other app answers 422). Replaces the day-based window with this many minutes, so an integration test can create an unverified account and expire it at once. Every other eligibility rule still applies."}}},"UnverifiedAccountExpiryRunDto":{"type":"object","properties":{"dry_run":{"type":"boolean","example":false},"ttl_days":{"type":"number","example":7,"description":"The window the run used, in days."},"user_ids":{"description":"Accounts deleted (or, on a dry run, that would be deleted).","type":"array","items":{"type":"string","format":"uuid"}},"truncated":{"type":"boolean","example":false,"description":"True when the per-run ceiling stopped the run; the nightly job drains the rest."}},"required":["dry_run","ttl_days","user_ids","truncated"]},"InsightsUsersDto":{"type":"object","properties":{"total":{"type":"number","description":"End users in the app.","example":257},"new_in_window":{"type":"number","description":"End users created in the window.","example":31},"active_in_window":{"type":"number","description":"End users who were the actor of at least one audit event in the window.","example":88}},"required":["total","new_in_window","active_in_window"]},"InsightsVerificationDto":{"type":"object","properties":{"requested":{"type":"number","description":"Verification requests in the window.","example":40},"completed":{"type":"number","description":"Verifications completed in the window.","example":33},"completion_rate":{"type":"number","nullable":true,"description":"`completed / requested`, 0–1. Null when nothing was requested.","example":0.825}},"required":["requested","completed","completion_rate"]},"InsightsSigninDto":{"type":"object","properties":{"success":{"type":"number","description":"Successful sign-ins in the window.","example":410},"failed":{"type":"number","description":"Failed sign-ins in the window.","example":12},"failure_rate":{"type":"number","nullable":true,"description":"`failed / (success + failed)`, 0–1. Null when there were no attempts.","example":0.028}},"required":["success","failed","failure_rate"]},"InsightsMfaDto":{"type":"object","properties":{"users_with_factor":{"type":"number","description":"End users with at least one enabled factor.","example":19},"adoption_rate":{"type":"number","nullable":true,"description":"`users_with_factor / users.total`, 0–1. Null when the app has no users.","example":0.074},"by_type":{"type":"object","additionalProperties":{"type":"integer"},"description":"End users with an enabled factor, by factor type (a user with two types counts in both).","example":{"totp":12,"webauthn":9}}},"required":["users_with_factor","adoption_rate","by_type"]},"InsightsPasswordResetDto":{"type":"object","properties":{"requested":{"type":"number","description":"Password resets requested in the window.","example":7},"completed":{"type":"number","description":"Password resets completed in the window.","example":5}},"required":["requested","completed"]},"InsightsSessionsDto":{"type":"object","properties":{"active":{"type":"number","description":"Sessions that are neither revoked nor expired.","example":120},"with_mfa":{"type":"number","description":"Sessions with a completed MFA step (`mfa_at` set), whether or not still active.","example":14}},"required":["active","with_mfa"]},"EndUserInsightsDto":{"type":"object","properties":{"window_days":{"type":"number","description":"Window, in days, every in-window figure covers.","example":30},"users":{"$ref":"#/components/schemas/InsightsUsersDto"},"verification":{"$ref":"#/components/schemas/InsightsVerificationDto"},"signin":{"$ref":"#/components/schemas/InsightsSigninDto"},"mfa":{"$ref":"#/components/schemas/InsightsMfaDto"},"password_reset":{"$ref":"#/components/schemas/InsightsPasswordResetDto"},"sessions":{"$ref":"#/components/schemas/InsightsSessionsDto"}},"required":["window_days","users","verification","signin","mfa","password_reset","sessions"]},"EndUserActivityEntryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Audit row id."},"action":{"type":"string","description":"Audit action.","example":"auth.signin.succeeded"},"resource":{"type":"string","description":"Resource type the action touched.","example":"session"},"resource_id":{"type":"string","nullable":true,"description":"Id of the resource, when there is one."},"ip":{"type":"string","nullable":true,"description":"Client IP recorded on the row.","example":"203.0.113.7"},"created_at":{"type":"string","format":"date-time"}},"required":["id","action","resource","resource_id","ip","created_at"]},"EndUserActionCountDto":{"type":"object","properties":{"action":{"type":"string","description":"Audit action.","example":"auth.signin.succeeded"},"count":{"type":"number","description":"Rows with this action in the user's whole history.","example":42}},"required":["action","count"]},"EndUserSessionEntryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ip":{"type":"string","nullable":true,"example":"203.0.113.7"},"user_agent":{"type":"string","nullable":true},"auth_methods":{"description":"Authentication methods used to establish the session.","example":["password","totp"],"type":"array","items":{"type":"string"}},"mfa_at":{"type":"string","format":"date-time","nullable":true,"description":"When an MFA step completed on this session."},"created_at":{"type":"string","format":"date-time"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"expires_at":{"type":"string","format":"date-time"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"step_up_failed_attempts":{"type":"number","description":"Consecutive failed step-up attempts.","example":0},"step_up_locked_until":{"type":"string","format":"date-time","nullable":true,"description":"Step-up locked until, when locked."}},"required":["id","ip","user_agent","auth_methods","mfa_at","created_at","last_used_at","expires_at","revoked_at","step_up_failed_attempts","step_up_locked_until"]},"EndUserFactorEntryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","description":"Factor type.","example":"totp"},"label":{"type":"string","nullable":true,"description":"User-supplied label."},"created_at":{"type":"string","format":"date-time"},"enabled_at":{"type":"string","format":"date-time","nullable":true},"disabled_at":{"type":"string","format":"date-time","nullable":true}},"required":["id","type","label","created_at","enabled_at","disabled_at"]},"EndUserDetailInsightsDto":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"no_activity_recorded":{"type":"boolean","description":"True when the user has no audit row at all (\"nothing recorded\"), as opposed to an empty page."},"first_seen_at":{"type":"string","format":"date-time","nullable":true},"last_seen_at":{"type":"string","format":"date-time","nullable":true},"activity":{"description":"Most recent audit entries, newest first (capped).","type":"array","items":{"$ref":"#/components/schemas/EndUserActivityEntryDto"}},"activity_truncated":{"type":"boolean","description":"True when `activity` hit its cap and older rows exist."},"action_counts":{"description":"Whole history by action, most frequent first.","type":"array","items":{"$ref":"#/components/schemas/EndUserActionCountDto"}},"sessions":{"type":"array","items":{"$ref":"#/components/schemas/EndUserSessionEntryDto"}},"factors":{"type":"array","items":{"$ref":"#/components/schemas/EndUserFactorEntryDto"}}},"required":["user_id","no_activity_recorded","first_seen_at","last_seen_at","activity","activity_truncated","action_counts","sessions","factors"]},"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"]},"AuthStatsTotalsDto":{"type":"object","properties":{"apps":{"type":"number","description":"Production apps in the workspace.","example":3},"end_users":{"type":"number","description":"End users across those apps, all time.","example":1247}},"required":["apps","end_users"]},"AuthStatsInWindowDto":{"type":"object","properties":{"active_users_1d":{"type":"number","description":"Distinct end users active today (UTC).","example":12},"active_users_7d":{"type":"number","description":"Distinct end users active in the last 7 UTC days, including today.","example":40},"active_users_30d":{"type":"number","description":"Distinct end users active in the last 30 UTC days, including today (MAU).","example":118},"new_end_users":{"type":"number","description":"End users created in the requested window.","example":9}},"required":["active_users_1d","active_users_7d","active_users_30d","new_end_users"]},"StatsSeriesPointDto":{"type":"object","properties":{"date":{"type":"string","description":"UTC calendar day.","format":"date","example":"2026-10-07"},"value":{"type":"number","description":"Value for that day.","example":12}},"required":["date","value"]},"AuthStatsSeriesDto":{"type":"object","properties":{"metric":{"type":"string","enum":["active_users"],"description":"Distinct end users active on each UTC day (DAU) across the workspace's production apps.","example":"active_users"},"granularity":{"type":"string","enum":["day"],"description":"Bucket width.","example":"day"},"points":{"description":"Dense and zero-filled: one point per UTC day in the window, oldest first, ending today.","type":"array","items":{"$ref":"#/components/schemas/StatsSeriesPointDto"}}},"required":["metric","granularity","points"]},"AuthWorkspaceStatsDto":{"type":"object","properties":{"window":{"type":"string","enum":["1d","7d","30d"],"description":"Window the counts cover.","example":"30d"},"generated_at":{"type":"string","format":"date-time","description":"When these numbers were computed. Responses are cached for up to 60 seconds.","example":"2026-10-07T09:00:00.000Z"},"last_activity_at":{"type":"string","format":"date-time","nullable":true,"description":"Most recent end-user sign-in or session refresh across the workspace's production apps. Null when none is on record.","example":"2026-10-07T08:41:12.000Z"},"totals":{"description":"All-time counts.","allOf":[{"$ref":"#/components/schemas/AuthStatsTotalsDto"}]},"in_window":{"description":"Activity counts.","allOf":[{"$ref":"#/components/schemas/AuthStatsInWindowDto"}]},"series":{"description":"Daily active users.","allOf":[{"$ref":"#/components/schemas/AuthStatsSeriesDto"}]}},"required":["window","generated_at","last_activity_at","totals","in_window","series"]},"SenderDomainStatus":{"type":"string","enum":["not_started","pending","partially_verified","verified","temporary_failure","failed","disabled","deleted","not_found"]},"EffectiveSenderDto":{"type":"object","properties":{"source":{"type":"string","enum":["workspace","custom"],"description":"`workspace`: the workspace default sender set in Mail. `custom`: this app's own `default_sender_address`.","example":"workspace"},"address":{"type":"string","nullable":true,"description":"The From Auth emails go out with: a bare address or `Name <address>`. null when none is set (no workspace default in Mail yet, or `custom` with no address), and also when the workspace default could not be read (`verified: null`).","example":"Acme <hello@acme.com>"},"verified":{"type":"boolean","nullable":true,"description":"Whether mail from this sender goes out right now, checked live in Mail. false: the domain is not verified, no sender is set, or a custom address is on no domain of this workspace. null: not checked (your role cannot read Mail, or Mail did not answer).","example":true},"domain_status":{"nullable":true,"description":"The sender domain's live `verification_status` in Mail (`verified`, `pending`, `temporary_failure`, `failed`, …), `deleted` when the domain behind the workspace default was removed, `not_found` for a custom address on no sending domain of this workspace, or null when unknown or there is no address. The shared `SenderDomainStatus` enum, the same in Mail and Waitlist.","example":"verified","allOf":[{"$ref":"#/components/schemas/SenderDomainStatus"}]}},"required":["source","address","verified","domain_status"]},"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."},"sender_mode":{"type":"string","enum":["workspace","custom"],"description":"Where the From comes from: `workspace` (the workspace default sender set in Mail; the default for new apps) or `custom` (`default_sender_address`). Apps that had an address before this setting existed are `custom`.","example":"workspace"},"effective_sender":{"description":"The sender Auth emails actually go out from, with its live verification status in Mail.","allOf":[{"$ref":"#/components/schemas/EffectiveSenderDto"}]},"default_sender_address":{"type":"string","description":"The From used when `sender_mode` is `custom`; `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","sender_mode","effective_sender","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."},"sender_mode":{"type":"string","enum":["workspace","custom"],"description":"Where the From comes from. `workspace` (the default for new apps): the workspace default sender set in Mail, resolved at send time, so changing it in Mail changes it for every product. `custom`: `default_sender_address`. Sending `default_sender_address` without `sender_mode` sets `custom`.","example":"workspace"},"default_sender_address":{"type":"string","description":"The From used when `sender_mode` is `custom` (kept, but unused, in `workspace` mode). Must belong to a verified domain owned by this app's workspace. null clears it. Setting it without `sender_mode` switches the app to `custom`.","nullable":true,"format":"email"},"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}}},"NotificationTestResponseDto":{"type":"object","properties":{"to":{"type":"string","format":"email","description":"Your own verified account address: tests only ever go to the person who asked.","example":"you@acme.com"},"template_name":{"type":"string","description":"The Mail template that was sent.","example":"auth-verification"},"from":{"type":"string","nullable":true,"description":"The From it went out with (the workspace default sender or the app's custom address).","example":"Acme <hello@acme.com>"},"message_id":{"type":"string","nullable":true,"format":"uuid","description":"The Mail message id; look it up in Mail > Emails to follow delivery.","example":"0d6c1c3e-5f2a-4b8e-9d17-3a4b5c6d7e8f"}},"required":["to","template_name","from","message_id"]},"WebhookEventType":{"type":"string","enum":["user.signup","user.signin","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","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","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"]},"WebhookEventTypeDto":{"type":"object","properties":{"type":{"example":"user.signup","description":"The event type (one of this product's catalogue).","allOf":[{"$ref":"#/components/schemas/WebhookEventType"}]},"description":{"type":"string","example":"A user signed up or was created by an admin.","description":"When this event is sent."},"category":{"type":"string","example":"Users","description":"Group shown in the event picker."},"example":{"type":"object","additionalProperties":true,"description":"An example `data` object; \"Send test\" delivers exactly this."}},"x-pc-sdk-model":"WebhookEventTypeInfo","required":["type","description","category","example"]},"ListWebhookEventTypesResponseDto":{"type":"object","properties":{"data":{"description":"Every event type this product sends.","type":"array","items":{"$ref":"#/components/schemas/WebhookEventTypeDto"}}},"required":["data"]},"WebhookEndpointStatus":{"type":"string","enum":["enabled","disabled","auto_disabled"]},"WebhookEndpointDisabledReason":{"type":"string","enum":["manual","failing"]},"WebhookEndpointDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The endpoint's id."},"scope_type":{"type":"string","example":"workspace","description":"What owns the endpoint: `workspace`, `app` or `community`."},"scope_id":{"type":"string","description":"Id of the owning workspace, app or community."},"url":{"type":"string","format":"uri","example":"https://api.acme.com/webhooks","description":"Where events are sent."},"description":{"type":"string","nullable":true,"example":"Production receiver","description":"A label for people; null when unset."},"event_types":{"description":"Subscribed event types. Empty means every event, including types added later.","example":[],"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean","description":"The on/off switch (`PATCH … { enabled }`)."},"status":{"description":"`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.","allOf":[{"$ref":"#/components/schemas/WebhookEndpointStatus"}]},"disabled_at":{"type":"string","format":"date-time","nullable":true,"description":"When it was switched off; null while enabled."},"disabled_reason":{"nullable":true,"description":"`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.","allOf":[{"$ref":"#/components/schemas/WebhookEndpointDisabledReason"}]},"consecutive_failures":{"type":"number","description":"Failed automatic deliveries in a row; a success resets it."},"first_failure_at":{"type":"string","format":"date-time","nullable":true,"description":"Start of the current failure streak."},"signing_secret_hint":{"type":"string","description":"Last 4 characters of the signing secret.","example":"x9Q="},"prev_signing_secret_active":{"type":"boolean","description":"True while the previous secret still signs too (after a rotation, for its grace period)."},"prev_signing_secret_expires_at":{"type":"string","format":"date-time","nullable":true,"description":"Until then the previous secret signs too (after a rotation)."},"legacy_signature":{"type":"boolean","description":"True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date."},"created_at":{"type":"string","format":"date-time","description":"When the endpoint was created."},"updated_at":{"type":"string","format":"date-time","description":"When the endpoint last changed."}},"required":["id","scope_type","scope_id","url","description","event_types","enabled","status","disabled_at","disabled_reason","consecutive_failures","first_failure_at","signing_secret_hint","prev_signing_secret_active","prev_signing_secret_expires_at","legacy_signature","created_at","updated_at"]},"WebhookPaginationDto":{"type":"object","properties":{"next_cursor":{"type":"string","nullable":true,"description":"Pass as `cursor` to get the next page; null on the last page."},"has_more":{"type":"boolean","description":"Whether another page follows."}},"required":["next_cursor","has_more"]},"ListWebhookEndpointsResponseDto":{"type":"object","properties":{"data":{"description":"The endpoints, newest first.","type":"array","items":{"$ref":"#/components/schemas/WebhookEndpointDto"}},"pagination":{"description":"Always one page (an endpoint limit applies).","allOf":[{"$ref":"#/components/schemas/WebhookPaginationDto"}]}},"required":["data","pagination"]},"CreateWebhookEndpointDto":{"type":"object","properties":{"url":{"type":"string","description":"Where to send events: an absolute https:// URL on a public host.","example":"https://api.acme.com/webhooks","maxLength":2048,"format":"uri"},"description":{"type":"string","nullable":true,"maxLength":200,"example":"Production receiver","description":"A label for people."},"event_types":{"type":"array","description":"Event types from `GET …/webhooks/event-types`. Empty or omitted means every event, including types added later.","example":["user.signup","user.signin"],"items":{"type":"string","enum":["user.signup","user.signin","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","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","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"]}},"enabled":{"type":"boolean","default":true,"description":"Create it switched off."}},"required":["url"]},"WebhookEndpointWithSecretDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The endpoint's id."},"scope_type":{"type":"string","example":"workspace","description":"What owns the endpoint: `workspace`, `app` or `community`."},"scope_id":{"type":"string","description":"Id of the owning workspace, app or community."},"url":{"type":"string","format":"uri","example":"https://api.acme.com/webhooks","description":"Where events are sent."},"description":{"type":"string","nullable":true,"example":"Production receiver","description":"A label for people; null when unset."},"event_types":{"description":"Subscribed event types. Empty means every event, including types added later.","example":[],"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean","description":"The on/off switch (`PATCH … { enabled }`)."},"status":{"description":"`enabled` receives events. `disabled` was switched off. `auto_disabled` was switched off after 20 failed deliveries in a row or 24 hours of failures; `PATCH { enabled: true }` turns it back on.","allOf":[{"$ref":"#/components/schemas/WebhookEndpointStatus"}]},"disabled_at":{"type":"string","format":"date-time","nullable":true,"description":"When it was switched off; null while enabled."},"disabled_reason":{"nullable":true,"description":"`manual`: switched off by a caller. `failing`: switched off automatically after failed deliveries. Null while enabled.","allOf":[{"$ref":"#/components/schemas/WebhookEndpointDisabledReason"}]},"consecutive_failures":{"type":"number","description":"Failed automatic deliveries in a row; a success resets it."},"first_failure_at":{"type":"string","format":"date-time","nullable":true,"description":"Start of the current failure streak."},"signing_secret_hint":{"type":"string","description":"Last 4 characters of the signing secret.","example":"x9Q="},"prev_signing_secret_active":{"type":"boolean","description":"True while the previous secret still signs too (after a rotation, for its grace period)."},"prev_signing_secret_expires_at":{"type":"string","format":"date-time","nullable":true,"description":"Until then the previous secret signs too (after a rotation)."},"legacy_signature":{"type":"boolean","description":"True for endpoints carried over from the product's previous webhook system: they also receive the old signature header until its sunset date."},"created_at":{"type":"string","format":"date-time","description":"When the endpoint was created."},"updated_at":{"type":"string","format":"date-time","description":"When the endpoint last changed."},"signing_secret":{"type":"string","description":"The signing secret, shown once: pass it unchanged to any Standard Webhooks verifier. Store it now; it cannot be shown again.","example":"whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"}},"required":["id","scope_type","scope_id","url","description","event_types","enabled","status","disabled_at","disabled_reason","consecutive_failures","first_failure_at","signing_secret_hint","prev_signing_secret_active","prev_signing_secret_expires_at","legacy_signature","created_at","updated_at","signing_secret"]},"UpdateWebhookEndpointDto":{"type":"object","properties":{"url":{"type":"string","maxLength":2048,"format":"uri","example":"https://api.acme.com/webhooks","description":"A new absolute https:// URL on a public host."},"description":{"type":"string","nullable":true,"maxLength":200,"description":"A new label; null clears it."},"event_types":{"type":"array","description":"Replaces the subscription. Event types from `GET …/webhooks/event-types`. Empty or omitted means every event, including types added later.","items":{"type":"string","enum":["user.signup","user.signin","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","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","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"]}},"enabled":{"type":"boolean","description":"Switch it on or off. Switching on also clears an automatic disable."}}},"RotateWebhookSecretDto":{"type":"object","properties":{"grace_hours":{"type":"number","default":24,"minimum":0,"maximum":168,"description":"Hours the previous secret keeps signing too (0 ends it now)."}}},"SendWebhookTestDto":{"type":"object","properties":{"event_type":{"type":"string","enum":["user.signup","user.signin","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","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","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"],"description":"Which event to send; its catalogue example is the `data`.","example":"user.signup"}},"required":["event_type"]},"WebhookAttemptTrigger":{"type":"string","enum":["automatic","replay","test"]},"WebhookDeliveryAttemptDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The attempt's id."},"event_id":{"type":"string","pattern":"^evt_[0-9a-f-]{36}$","example":"evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f","description":"The event that was delivered."},"endpoint_id":{"type":"string","format":"uuid","description":"The endpoint it was delivered to."},"event_type":{"type":"string","example":"user.created","description":"The event's type."},"attempt_number":{"type":"number","description":"1 for the first delivery; retries and replays count up."},"trigger":{"description":"`automatic` (first delivery or a retry), `replay` or `test`.","allOf":[{"$ref":"#/components/schemas/WebhookAttemptTrigger"}]},"url":{"type":"string","format":"uri","description":"Where it was sent."},"status_code":{"type":"number","nullable":true,"description":"HTTP status; null when no response came back."},"succeeded":{"type":"boolean","description":"True for a 2xx response."},"error_message":{"type":"string","nullable":true,"example":"request timed out","description":"Why the attempt failed when no 2xx came back; null on success."},"response_body":{"type":"string","nullable":true,"description":"The first 1 KiB of the response body."},"latency_ms":{"type":"number","nullable":true,"description":"Milliseconds until the response; null when none came back."},"attempted_at":{"type":"string","format":"date-time","description":"When the attempt was made."}},"required":["id","event_id","endpoint_id","event_type","attempt_number","trigger","url","status_code","succeeded","error_message","response_body","latency_ms","attempted_at"]},"WebhookEventStatus":{"type":"string","enum":["pending","delivered","failed","not_sent"]},"WebhookEventDeliveryDto":{"type":"object","properties":{"endpoint_id":{"type":"string","format":"uuid","description":"The endpoint."},"attempt_number":{"type":"number","description":"Number of the latest attempt; retries and replays count up."},"status_code":{"type":"number","nullable":true,"description":"HTTP status of the latest attempt; null when no response came back."},"succeeded":{"type":"boolean","description":"True when the latest attempt got a 2xx."},"attempted_at":{"type":"string","format":"date-time","description":"When the latest attempt was made."},"next_attempt_at":{"type":"string","format":"date-time","nullable":true,"description":"When the next automatic attempt is due; null when none is queued."}},"required":["endpoint_id","attempt_number","status_code","succeeded","attempted_at","next_attempt_at"]},"WebhookEventDto":{"type":"object","properties":{"id":{"type":"string","pattern":"^evt_[0-9a-f-]{36}$","example":"evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f","description":"Sent as `webhook-id`; the same on every retry and replay."},"type":{"example":"user.signup","description":"The event type (one of this product's catalogue).","allOf":[{"$ref":"#/components/schemas/WebhookEventType"}]},"created_at":{"type":"string","format":"date-time","description":"When the event happened."},"test":{"type":"boolean","description":"Made by \"Send test\"."},"status":{"description":"`pending`: a delivery is queued. `failed`: the latest attempt to some endpoint failed and none is queued. `delivered`: every endpoint answered 2xx. `not_sent`: no endpoint subscribed.","allOf":[{"$ref":"#/components/schemas/WebhookEventStatus"}]},"payload":{"type":"object","additionalProperties":true,"description":"The JSON body exactly as sent."},"deliveries":{"description":"Latest attempt per endpoint (an endpoint whose first attempt is still queued is not listed yet).","type":"array","items":{"$ref":"#/components/schemas/WebhookEventDeliveryDto"}}},"required":["id","type","created_at","test","status","payload","deliveries"]},"ListWebhookEventsResponseDto":{"type":"object","properties":{"data":{"description":"The events, newest first.","type":"array","items":{"$ref":"#/components/schemas/WebhookEventDto"}},"pagination":{"description":"The cursor for the next page.","allOf":[{"$ref":"#/components/schemas/WebhookPaginationDto"}]}},"required":["data","pagination"]},"WebhookEventDetailDto":{"type":"object","properties":{"id":{"type":"string","pattern":"^evt_[0-9a-f-]{36}$","example":"evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f","description":"Sent as `webhook-id`; the same on every retry and replay."},"type":{"example":"user.signup","description":"The event type (one of this product's catalogue).","allOf":[{"$ref":"#/components/schemas/WebhookEventType"}]},"created_at":{"type":"string","format":"date-time","description":"When the event happened."},"test":{"type":"boolean","description":"Made by \"Send test\"."},"status":{"description":"`pending`: a delivery is queued. `failed`: the latest attempt to some endpoint failed and none is queued. `delivered`: every endpoint answered 2xx. `not_sent`: no endpoint subscribed.","allOf":[{"$ref":"#/components/schemas/WebhookEventStatus"}]},"payload":{"type":"object","additionalProperties":true,"description":"The JSON body exactly as sent."},"deliveries":{"description":"Latest attempt per endpoint (an endpoint whose first attempt is still queued is not listed yet).","type":"array","items":{"$ref":"#/components/schemas/WebhookEventDeliveryDto"}},"attempts":{"description":"Every attempt, newest first.","type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttemptDto"}}},"required":["id","type","created_at","test","status","payload","deliveries","attempts"]},"ReplayWebhookEventResponseDto":{"type":"object","properties":{"event_id":{"type":"string","pattern":"^evt_[0-9a-f-]{36}$","description":"The replayed event (the same id as the original)."},"attempts":{"description":"One attempt per endpoint it was sent to.","type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttemptDto"}}},"required":["event_id","attempts"]},"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"]},"IntrospectTenantApiKeyDto":{"type":"object","properties":{"key":{"type":"string","description":"The tenant API key your API received (`pctk_live_…` / `pctk_test_…`). Sent in the body, never a header. Anything that is not a well-formed key of this app is simply `{\"active\": false}`.","example":"pctk_live_4fQ2aZ0000000000000000000000000000000000000_1a2B3c","maxLength":256}},"required":["key"]},"IntrospectionCreatorDto":{"type":"object","properties":{"account_id":{"type":"string","format":"uuid","description":"End user who created (or last rotated) the key."},"tenant_role":{"type":"string","description":"The creator's CURRENT role in the tenant, read live. Intersect `scopes` with this role's permissions in your app: drift can only narrow access.","example":"agent_full"}},"required":["account_id","tenant_role"]},"TenantApiKeyIntrospectionDto":{"type":"object","properties":{"active":{"type":"boolean","description":"`true` only for a usable key of THIS app. `false` for every other case (unknown, malformed, another app's key, revoked, expired, creator no longer a member, tenant not active, keys suspended), and then no other field is present."},"key_id":{"type":"string","format":"uuid"},"display_id":{"type":"string","example":"tak_3a9f2b1c0d4e"},"tenant_id":{"type":"string","format":"uuid","description":"The tenant the key acts for."},"tenant_display_id":{"type":"string","example":"tnt_3a9f2b1c0d4e"},"name":{"type":"string","example":"CI e2e"},"scopes":{"example":["disputes.read","audit.read"],"type":"array","items":{"type":"string"}},"created_by":{"$ref":"#/components/schemas/IntrospectionCreatorDto"},"expires_at":{"type":"string","format":"date-time","description":"Never cache a positive answer past this instant."},"env":{"type":"string","enum":["live","test"],"description":"`live` for keys of a production app."}},"required":["active"]},"GrantableScopeDto":{"type":"object","properties":{"name":{"type":"string","description":"Scope name, `<resource>.<verb>`; matches `^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_.]*$`.","example":"disputes.read"},"description":{"type":"string","description":"Shown to the person creating a key.","maxLength":200,"example":"Read disputes"}},"required":["name"]},"TenantApiKeysConfigDto":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Effective: `false` whenever there is no grantable scope."},"grantable_scopes":{"type":"array","items":{"$ref":"#/components/schemas/GrantableScopeDto"}},"manage_roles":{"example":["owner"],"type":"array","items":{"type":"string"}},"management_lane":{"type":"string","enum":["direct","app_proxy_only"]},"default_expiry_days":{"type":"number","enum":[7,30,90,365]},"max_expiry_days":{"type":"number","enum":[7,30,90,365]},"require_recent_mfa_minutes":{"type":"integer","nullable":true},"suspended":{"type":"boolean","description":"Platform kill switch (console / Auth-admin only)."},"pushed_by_app_at":{"type":"string","format":"date-time","nullable":true,"description":"When the app's own backend last pushed this config (`PUT /:appSlug/v1/tenant-api-keys/config`); `null` if it never has. Set by the server, never by a request. While set, the console treats the app's fields as owned by the app's code and shows them read-only; `suspended` stays the platform's."}},"required":["enabled","grantable_scopes","manage_roles","management_lane","default_expiry_days","max_expiry_days","require_recent_mfa_minutes","suspended","pushed_by_app_at"]},"PushTenantApiKeysConfigDto":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Turn tenant API keys on for this app. Needs at least one grantable scope."},"grantable_scopes":{"maxItems":64,"description":"The scopes a key of this app can carry. Replaces the stored list. At most 64, unique.","type":"array","items":{"$ref":"#/components/schemas/GrantableScopeDto"}},"manage_roles":{"description":"Tenant roles (this app's role names, case-insensitive) whose members may create, rotate and revoke keys. Default `['owner']`.","example":["owner"],"type":"array","items":{"type":"string"}},"management_lane":{"type":"string","enum":["direct","app_proxy_only"],"description":"`direct`: an end user's bearer alone can manage keys. `app_proxy_only`: the bearer is accepted only together with this app's PAK in `X-Heimdall-App-Key`, so every change passes through your backend."},"default_expiry_days":{"type":"number","enum":[7,30,90,365],"description":"Lifetime when the creator names none. At most `max_expiry_days`."},"max_expiry_days":{"type":"number","enum":[7,30,90,365],"description":"Longest lifetime a key may have."},"require_recent_mfa_minutes":{"type":"integer","nullable":true,"minimum":1,"maximum":1440,"description":"When set, creating or rotating a key needs a second factor verified within this many minutes. `null` switches it off."}}},"TenantApiKeyStatusDto":{"type":"object","properties":{"status":{"type":"string","enum":["active","expired","revoked","orphaned","suspended"],"description":"`revoked` (wins over everything), `expired`, `orphaned` (the creator left the tenant or the tenant is not active), `suspended` (the app's tenant keys are suspended or switched off, or the app is not active), `active`."},"revoke_reason":{"type":"string","nullable":true,"enum":["manual","rotated","member_removed","tenant_deleted"],"description":"Why a `revoked` key was revoked; `null` otherwise."}},"required":["status","revoke_reason"]},"TenantApiKeyDto":{"type":"object","properties":{"id":{"type":"string","description":"Key id.","format":"uuid"},"display_id":{"type":"string","description":"Short public id.","example":"tak_3a9f2b1c0d4e"},"tenant_id":{"type":"string","description":"Tenant the key belongs to.","format":"uuid"},"tenant_display_id":{"type":"string","example":"tnt_3a9f2b1c0d4e"},"name":{"type":"string","example":"CI e2e"},"key_prefix":{"type":"string","description":"First 16 characters of the key (environment prefix plus 6). The rest is never retrievable.","example":"pctk_live_4fQ2aZ"},"last4":{"type":"string","description":"Last 4 characters of the key.","example":"x9Qb"},"scopes":{"example":["disputes.read"],"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","expired","revoked"],"description":"`revoked` wins over `expired`."},"created_by_account_id":{"type":"string","description":"End user who created (or last rotated) the key. The key dies when they leave the tenant.","format":"uuid"},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"last_used_at":{"type":"string","format":"date-time","nullable":true,"description":"Updated at most every 5 minutes."},"revoked_at":{"type":"string","format":"date-time","nullable":true},"revoke_reason":{"type":"string","nullable":true,"enum":["manual","rotated","member_removed","tenant_deleted"]},"rotated_from":{"type":"string","nullable":true,"format":"uuid","description":"On a replacement key, the id of the key it replaced."}},"required":["id","display_id","tenant_id","tenant_display_id","name","key_prefix","last4","scopes","status","created_by_account_id","created_at","expires_at","last_used_at","revoked_at","revoke_reason","rotated_from"]},"TenantApiKeyListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TenantApiKeyDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"CreateTenantApiKeyDto":{"type":"object","properties":{"name":{"type":"string","description":"Label shown in lists, 1-64 characters (e.g. `CI e2e`).","minLength":1,"maxLength":64,"example":"CI e2e"},"scopes":{"description":"Scopes the key carries. Each must be one of the app's grantable scopes AND held by your own tenant role (an `owner` may grant every grantable scope).","minItems":1,"maxItems":64,"example":["disputes.read","audit.read"],"type":"array","items":{"type":"string"}},"expires_in_days":{"type":"number","description":"Lifetime in days. One of 7, 30, 90, 365, capped by the app's `max_expiry_days`. Defaults to the app's `default_expiry_days` (90 unless configured). Keys always expire.","enum":[7,30,90,365],"example":90}},"required":["name","scopes"]},"TenantApiKeyWithSecretDto":{"type":"object","properties":{"id":{"type":"string","description":"Key id.","format":"uuid"},"display_id":{"type":"string","description":"Short public id.","example":"tak_3a9f2b1c0d4e"},"tenant_id":{"type":"string","description":"Tenant the key belongs to.","format":"uuid"},"tenant_display_id":{"type":"string","example":"tnt_3a9f2b1c0d4e"},"name":{"type":"string","example":"CI e2e"},"key_prefix":{"type":"string","description":"First 16 characters of the key (environment prefix plus 6). The rest is never retrievable.","example":"pctk_live_4fQ2aZ"},"last4":{"type":"string","description":"Last 4 characters of the key.","example":"x9Qb"},"scopes":{"example":["disputes.read"],"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","expired","revoked"],"description":"`revoked` wins over `expired`."},"created_by_account_id":{"type":"string","description":"End user who created (or last rotated) the key. The key dies when they leave the tenant.","format":"uuid"},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"last_used_at":{"type":"string","format":"date-time","nullable":true,"description":"Updated at most every 5 minutes."},"revoked_at":{"type":"string","format":"date-time","nullable":true},"revoke_reason":{"type":"string","nullable":true,"enum":["manual","rotated","member_removed","tenant_deleted"]},"rotated_from":{"type":"string","nullable":true,"format":"uuid","description":"On a replacement key, the id of the key it replaced."},"key":{"type":"string","description":"The secret, `pctk_live_…` / `pctk_test_…`. Returned exactly once: it is not stored and an `Idempotency-Key` replay does not return it again.","example":"pctk_live_4fQ2aZ0000000000000000000000000000000000000_1a2B3c"}},"required":["id","display_id","tenant_id","tenant_display_id","name","key_prefix","last4","scopes","status","created_by_account_id","created_at","expires_at","last_used_at","revoked_at","revoke_reason","rotated_from","key"]},"RotateTenantApiKeyDto":{"type":"object","properties":{"grace_hours":{"type":"number","description":"How long the OUTGOING key keeps working, in hours (0-24). `0` (the default) revokes it immediately. Rotation never lengthens the outgoing key. To get rid of a leaked key, revoke it instead.","minimum":0,"maximum":24,"default":0,"example":0},"expires_in_days":{"type":"number","description":"Lifetime of the replacement in days (7, 30, 90, 365, capped by the app's maximum). Defaults to the original key's window.","enum":[7,30,90,365]}}},"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"]},"TenantApiKeyCreatorDto":{"type":"object","properties":{"account_id":{"type":"string","format":"uuid","description":"End user who created (or last rotated) the key."},"username":{"type":"string","nullable":true,"description":"The end user's username; `null` if none (or the account is gone)."},"display_name":{"type":"string","nullable":true,"description":"The end user's display name; `null` if none (or the account is gone)."},"email":{"type":"string","format":"email","nullable":true,"description":"The end user's primary email; `null` if none (or the account is gone)."}},"required":["account_id","username","display_name","email"]},"PlatformTenantApiKeyDto":{"type":"object","properties":{"id":{"type":"string","description":"Key id.","format":"uuid"},"display_id":{"type":"string","description":"Short public id.","example":"tak_3a9f2b1c0d4e"},"tenant_id":{"type":"string","description":"Tenant the key belongs to.","format":"uuid"},"tenant_display_id":{"type":"string","example":"tnt_3a9f2b1c0d4e"},"name":{"type":"string","example":"CI e2e"},"key_prefix":{"type":"string","description":"First 16 characters of the key (environment prefix plus 6). The rest is never retrievable.","example":"pctk_live_4fQ2aZ"},"last4":{"type":"string","description":"Last 4 characters of the key.","example":"x9Qb"},"scopes":{"example":["disputes.read"],"type":"array","items":{"type":"string"}},"created_by_account_id":{"type":"string","description":"End user who created (or last rotated) the key. The key dies when they leave the tenant.","format":"uuid"},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"last_used_at":{"type":"string","format":"date-time","nullable":true,"description":"Updated at most every 5 minutes."},"revoked_at":{"type":"string","format":"date-time","nullable":true},"revoke_reason":{"type":"string","nullable":true,"enum":["manual","rotated","member_removed","tenant_deleted"]},"rotated_from":{"type":"string","nullable":true,"format":"uuid","description":"On a replacement key, the id of the key it replaced."},"status":{"type":"string","enum":["active","expired","revoked","orphaned"],"description":"`revoked` (wins over everything), `expired`, `orphaned` (neither, but the creator is no longer a member of the tenant or the tenant is not `active`, so introspection answers `{\"active\": false}`), or `active`. The app-wide kill switch (`suspended` in the config) is not reflected per key."},"created_by":{"description":"Who created (or last rotated) the key.","allOf":[{"$ref":"#/components/schemas/TenantApiKeyCreatorDto"}]}},"required":["id","display_id","tenant_id","tenant_display_id","name","key_prefix","last4","scopes","created_by_account_id","created_at","expires_at","last_used_at","revoked_at","revoke_reason","rotated_from","status","created_by"]},"PlatformTenantApiKeyListResponseDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PlatformTenantApiKeyDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"}},"required":["data","pagination"]},"PlatformTenantApiKeysConfigDto":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Turn tenant API keys on for this app. Needs at least one grantable scope."},"grantable_scopes":{"maxItems":64,"description":"The scopes a key of this app can carry. Replaces the stored list. At most 64, unique.","type":"array","items":{"$ref":"#/components/schemas/GrantableScopeDto"}},"manage_roles":{"description":"Tenant roles (this app's role names, case-insensitive) whose members may create, rotate and revoke keys. Default `['owner']`.","example":["owner"],"type":"array","items":{"type":"string"}},"management_lane":{"type":"string","enum":["direct","app_proxy_only"],"description":"`direct`: an end user's bearer alone can manage keys. `app_proxy_only`: the bearer is accepted only together with this app's PAK in `X-Heimdall-App-Key`, so every change passes through your backend."},"default_expiry_days":{"type":"number","enum":[7,30,90,365],"description":"Lifetime when the creator names none. At most `max_expiry_days`."},"max_expiry_days":{"type":"number","enum":[7,30,90,365],"description":"Longest lifetime a key may have."},"require_recent_mfa_minutes":{"type":"integer","nullable":true,"minimum":1,"maximum":1440,"description":"When set, creating or rotating a key needs a second factor verified within this many minutes. `null` switches it off."},"suspended":{"type":"boolean","description":"Platform kill switch: while `true`, introspection answers `{\"active\": false}` for every key of this app and key management is refused with 403 `TENANT_API_KEYS_SUSPENDED`. Only this route sets it; the app's own config push cannot change it."}}},"AppWebhookConfigResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string","format":"uuid"},"url":{"type":"string","example":"https://customer.example.com/webhooks/auth"},"event_types":{"example":["user.email_verified","tenant.created"],"description":"Subscribed events. Empty means the endpoint is switched off.","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,"description":"Set when the endpoint was switched off automatically after sustained failures."},"disabled_reason":{"type":"string","nullable":true},"consecutive_failures":{"type":"number"},"dead_letter_dropped_count":{"type":"number","description":"Always 0: every event is kept in the event log for 30 days, so none is dropped. Kept for older clients.","example":0},"dead_letter_dropped_at":{"type":"string","format":"date-time","nullable":true,"description":"Always null. Kept for older clients."},"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. An empty list receives nothing (the endpoint is switched off).","example":["user.signup","user.email_verified"],"items":{"type":"string","enum":["user.signup","user.signin","user.suspended","user.deactivated","user.reinstated","user.role_changed","user.deleted","user.contact_verified","user.email_verified","user.password_reset.requested","user.password_reset.completed","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","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"]}}},"required":["url","event_types"]},"AppWebhookConfigWithSecretResponseDto":{"type":"object","properties":{"id":{"type":"string"},"app_id":{"type":"string","format":"uuid"},"url":{"type":"string","example":"https://customer.example.com/webhooks/auth"},"event_types":{"example":["user.email_verified","tenant.created"],"description":"Subscribed events. Empty means the endpoint is switched off.","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,"description":"Set when the endpoint was switched off automatically after sustained failures."},"disabled_reason":{"type":"string","nullable":true},"consecutive_failures":{"type":"number"},"dead_letter_dropped_count":{"type":"number","description":"Always 0: every event is kept in the event log for 30 days, so none is dropped. Kept for older clients.","example":0},"dead_letter_dropped_at":{"type":"string","format":"date-time","nullable":true,"description":"Always null. Kept for older clients."},"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","description":"Which attempt this was for the event: 1 is the first delivery, 2 to 7 are the automatic retries (1 min, 5 min, 30 min, 2 h, 6 h and 24 h after the previous failure), 0 is a manual send (a replay or a test). Every attempt for one event carries the same `event_id`.","example":1},"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","description":"The event id (`evt_<uuid>`); pass it to the replay route.","example":"evt_3f0c3c1e-8a2b-4c55-9d3f-0a1b2c3d4e5f"},"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":"Automatic delivery attempts made. 0 when the event happened while the endpoint was switched off."},"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"]},"CreateSignInTestDto":{"type":"object","properties":{"method":{"type":"string","enum":["oauth","password","otp"],"example":"oauth","description":"`oauth`: sign in at a provider in a popup (needs `provider`). `password`: checks the sign-up / sign-in rules for your own email and sends the real verification email to you when emails are set up (the test passes without it, like real sign-up). `otp`: the same with the sign-in code email, which it needs. Emails only ever go to your own verified address."},"provider":{"type":"string","enum":["google","github","apple"],"example":"google"},"custom_domain":{"type":"boolean","default":false,"example":false,"description":"`oauth` only: run the test on the app's active custom domain instead of `api.auth.productcraft.co`, so the provider sees the custom host and its callback URL. The provider's app must list that callback URL too."}},"required":["method"]},"SignInTestIdentityDto":{"type":"object","properties":{"provider":{"type":"string","description":"How this person signed in: the provider id (`google`, `github`, `apple`), `password`, or `email_code`.","example":"google"},"subject":{"type":"string","nullable":true,"description":"For a provider: the provider's stable user id (`sub`). For a password or email-code test: the id of the existing user with your email in this app, or null when one would be created.","example":"109876543210987654321"},"email":{"type":"string","nullable":true,"format":"email","description":"The email the provider reported, or yours for a password / email-code test.","example":"jane@example.com"}},"required":["provider","subject","email"]},"SignInTestErrorDto":{"type":"object","properties":{"code":{"type":"string","description":"Codes include the provider's own OAuth error (`redirect_uri_mismatch`, `invalid_client`, `unauthorized_client`, `access_denied`, `invalid_scope`, `invalid_grant`, GitHub `incorrect_client_credentials` / `bad_verification_code`) and ours: `invalid_private_key`, `unknown_client`, `token_invalid`, `provider_not_configured`, `missing_credentials`, `no_redirect_origins`, `link_required`, `account_exists_with_different_provider`, `signup_disabled`, `signin_disabled`, `email_domain_not_allowed`, `operator_email_unavailable`, `emails_off`, `email_sender_missing`, `email_template_missing`, `email_workspace_missing`, `email_unavailable`, `email_send_failed`, `expired`. An unknown provider code is passed through as is.","example":"redirect_uri_mismatch"},"message":{"type":"string","description":"What went wrong, in plain words.","example":"Google does not accept our callback URL for this app."},"fix":{"type":"string","description":"Exactly what to do. Where the fix is a URL to paste into the provider console, the exact URL is included.","example":"In Google Cloud console (APIs & Services → Credentials), add this exact URL as an authorized redirect URI, then try again: https://api.auth.productcraft.co/acme/v1/auth/oauth/google/callback"}},"required":["code","message","fix"]},"SignInTestEmailNoticeDto":{"type":"object","properties":{"code":{"type":"string","enum":["emails_off","email_sender_missing","email_template_missing","email_workspace_missing","email_unavailable","email_send_failed"],"example":"emails_off"},"message":{"type":"string","description":"Why the email did not go out, in plain words.","example":"Sign-up and sign-in work, but no verification email is sent: emails are switched off for this app."},"fix":{"type":"string","description":"What to do to send it.","example":"Optional. To send new users a verification email, turn on emails under the app's Email settings and choose a sender."}},"required":["code","message","fix"]},"SignInTestEmailDeliveryDto":{"type":"object","properties":{"sent":{"type":"boolean","description":"True when the verification (or sign-in code) email went to your address.","example":true},"notice":{"nullable":true,"description":"Why the email did not go out; null when it was sent.","allOf":[{"$ref":"#/components/schemas/SignInTestEmailNoticeDto"}]}},"required":["sent","notice"]},"SignInTestDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"6f1c2b8e-3a4d-4e5f-8a9b-0c1d2e3f4a5b"},"app_id":{"type":"string","format":"uuid","example":"0b6f3c7e-6a43-4c55-9d4b-2a9a7f5b1c11"},"method":{"type":"string","enum":["oauth","password","otp"],"example":"oauth"},"provider":{"type":"string","nullable":true,"enum":["google","github","apple"],"example":"google"},"state":{"type":"string","enum":["pending","succeeded","failed"],"example":"pending","description":"`pending`: waiting for the sign-in at the provider (open `start_url`). `succeeded`: the sign-in would work; see `outcome` and `identity`. `failed`: see `error` for the fix. A test that is never finished becomes `failed` with code `expired`."},"outcome":{"type":"string","nullable":true,"enum":["would_sign_in_existing","would_create"],"example":"would_create","description":"What a real sign-in would have done: sign in an existing user, or create a new one. The test itself never creates or links an account."},"identity":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/SignInTestIdentityDto"}]},"error":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/SignInTestErrorDto"}]},"email_delivery":{"nullable":true,"description":"Succeeded `password` / `otp` tests: whether the email went out. A `password` test passes even when it did not, because real sign-up and sign-in do not need it; `notice` says why and how to turn it on. Null for `oauth` tests and failed tests.","allOf":[{"$ref":"#/components/schemas/SignInTestEmailDeliveryDto"}]},"start_url":{"type":"string","nullable":true,"format":"uri","description":"OAuth tests only: open this in a popup to sign in at the provider. Works once, until `expires_at`. Null once used, for password and email-code tests, and when the test already failed its checks.","example":"https://api.auth.productcraft.co/acme/v1/auth/oauth/google/authorize?return_to=https%3A%2F%2Fconsole.productcraft.co%2Fauth%2Fsign-in-test%2Fcallback&sign_in_test=6f1c2b8e-3a4d-4e5f-8a9b-0c1d2e3f4a5b"},"callback_host":{"type":"string","nullable":true,"format":"hostname","description":"The custom domain the test runs on (`custom_domain: true`), or null for the canonical host. The start link is on this host and the provider is sent its callback URL, `https://<callback_host>/v1/auth/oauth/<provider>/callback`.","example":"auth.acme.com"},"expires_at":{"type":"string","format":"date-time","description":"The start link works until then (10 minutes)."},"created_at":{"type":"string","format":"date-time"},"completed_at":{"type":"string","format":"date-time","nullable":true}},"required":["id","app_id","method","provider","state","outcome","identity","error","email_delivery","start_url","callback_host","expires_at","created_at","completed_at"]},"ApplyConflictDto":{"type":"object","properties":{"code":{"type":"string","description":"Why the record cannot work as things stand. `cname_foreign`: the name already points somewhere else. `records_at_cname_host`: the name holds A, MX or TXT records, and a CNAME cannot share its name. `cname_at_host`: the TXT name is itself a CNAME.","example":"cname_foreign"},"severity":{"type":"string","enum":["blocking","warning"],"example":"blocking","description":"`blocking`: the record cannot be published until this is fixed. `warning`: it works, but read the message."},"message":{"type":"string","example":"auth.acme.com already points at acme.netlify.app. Change that CNAME to edge.auth.productcraft.co, or use another name."},"suggested_value":{"type":"string","description":"The value to publish instead, when there is one.","example":"edge.auth.productcraft.co"},"purpose":{"type":"string","enum":["routing","ownership"],"example":"routing"},"host":{"type":"string","format":"hostname","example":"auth.acme.com"}},"required":["code","severity","message","purpose","host"]},"CreateCustomDomainDto":{"type":"object","properties":{"hostname":{"type":"string","format":"hostname","example":"auth.acme.com","description":"A subdomain you control, such as auth.acme.com. Not the root of a domain (it needs a CNAME record) and not a productcraft.co name."}},"required":["hostname"]},"DnsProviderRefDto":{"type":"object","properties":{"id":{"type":"string","example":"cloudflare","description":"Provider id from the zone's nameservers (`cloudflare`, `digitalocean`, `route53`, `godaddy`, … or `other`)."},"name":{"type":"string","example":"Cloudflare"}},"required":["id","name"]},"DnsConflictDto":{"type":"object","properties":{"code":{"type":"string","description":"Why the record cannot work as things stand. `cname_foreign`: the name already points somewhere else. `records_at_cname_host`: the name holds A, MX or TXT records, and a CNAME cannot share its name. `cname_at_host`: the TXT name is itself a CNAME.","example":"cname_foreign"},"severity":{"type":"string","enum":["blocking","warning"],"example":"blocking","description":"`blocking`: the record cannot be published until this is fixed. `warning`: it works, but read the message."},"message":{"type":"string","example":"auth.acme.com already points at acme.netlify.app. Change that CNAME to edge.auth.productcraft.co, or use another name."},"suggested_value":{"type":"string","description":"The value to publish instead, when there is one.","example":"edge.auth.productcraft.co"}},"required":["code","severity","message"]},"CustomDomainRecordDto":{"type":"object","properties":{"purpose":{"type":"string","enum":["routing","ownership"],"example":"routing","description":"`routing`: the CNAME that sends the host to our edge. `ownership`: the TXT token that proves the domain is yours."},"type":{"type":"string","enum":["CNAME","TXT"],"example":"CNAME"},"host":{"type":"string","format":"hostname","description":"Fully qualified record name.","example":"auth.acme.com"},"name":{"type":"string","description":"The name relative to the DNS zone, as most DNS provider forms want it (`@` for the zone itself).","example":"auth"},"value":{"type":"string","description":"The value to publish.","example":"edge.auth.productcraft.co"},"ttl":{"type":"number","example":3600,"description":"Suggested TTL in seconds."},"state":{"type":"string","enum":["not_found","propagating","verified","conflict","lookup_failed"],"example":"verified","description":"`verified`: public DNS serves it. `propagating`: your DNS provider serves it, public resolvers not yet (usually minutes). `not_found`: not published yet. `conflict`: something at the name blocks it (see `conflicts`). `lookup_failed`: DNS did not answer; the next check tries again."},"observed":{"description":"What the name serves for this record type at the last check.","example":["edge.auth.productcraft.co"],"type":"array","items":{"type":"string"}},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/DnsConflictDto"}},"last_checked_at":{"type":"string","format":"date-time","nullable":true},"verified_at":{"type":"string","format":"date-time","nullable":true,"description":"First check that found the record, since it was last missing."}},"required":["purpose","type","host","name","value","ttl","state","observed","conflicts","last_checked_at","verified_at"]},"CustomDomainCallbackUrlsDto":{"type":"object","properties":{"google":{"type":"string","format":"uri","example":"https://auth.acme.com/v1/auth/oauth/google/callback"},"github":{"type":"string","format":"uri","example":"https://auth.acme.com/v1/auth/oauth/github/callback"},"apple":{"type":"string","format":"uri","example":"https://auth.acme.com/v1/auth/oauth/apple/callback"}},"required":["google","github","apple"]},"CustomDomainDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"1d2c3b4a-5e6f-4a7b-8c9d-0e1f2a3b4c5d"},"app_id":{"type":"string","format":"uuid","example":"0b6f3c7e-6a43-4c55-9d4b-2a9a7f5b1c11"},"hostname":{"type":"string","format":"hostname","example":"auth.acme.com"},"status":{"type":"string","enum":["pending","active","failing"],"example":"pending","description":"`pending`: waiting for both records. `active`: both records are published; the host serves this app's sign-in routes. `failing`: was active, and a later check no longer finds the records. Checked every 5 minutes while pending (for 7 days) and hourly after that; `POST …/check` checks now."},"serving":{"type":"boolean","example":false,"description":"True when the host serves this app right now: the domain is `active` and custom domains are switched on for the workspace."},"edge_target":{"type":"string","format":"hostname","example":"edge.auth.productcraft.co","description":"Where the CNAME must point."},"dns_zone":{"type":"string","format":"hostname","nullable":true,"example":"acme.com","description":"The DNS zone holding the host, when it could be found."},"dns_provider":{"nullable":true,"description":"Who hosts the zone, from its nameservers.","allOf":[{"$ref":"#/components/schemas/DnsProviderRefDto"}]},"apply_providers":{"type":"array","example":["cloudflare"],"description":"Providers `POST …/apply` can set the records up with for this zone. Empty when the zone is at a provider we cannot write to: publish the records by hand or download them with `…/export`.","items":{"type":"string","enum":["cloudflare","digitalocean"]}},"records":{"description":"The two records to publish, routing first.","type":"array","items":{"$ref":"#/components/schemas/CustomDomainRecordDto"}},"callback_urls":{"nullable":true,"description":"Once active: the OAuth redirect URLs on this host. A sign-in started on the custom host returns to it, so add these to each provider's app next to the existing ones. Null while not active.","allOf":[{"$ref":"#/components/schemas/CustomDomainCallbackUrlsDto"}]},"verified_at":{"type":"string","format":"date-time","nullable":true,"description":"When the domain last became active."},"failing_since":{"type":"string","format":"date-time","nullable":true},"last_checked_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}},"required":["id","app_id","hostname","status","serving","edge_target","dns_zone","dns_provider","apply_providers","records","callback_urls","verified_at","failing_since","last_checked_at","created_at"]},"CustomDomainListDto":{"type":"object","properties":{"data":{"description":"An app has at most one custom domain.","type":"array","items":{"$ref":"#/components/schemas/CustomDomainDto"}},"pagination":{"$ref":"#/components/schemas/PaginationDto"},"available":{"type":"boolean","example":true,"description":"True when custom domains are switched on for this app's workspace, so `POST` can add one. While the feature is in early access it is switched on per workspace; when false, adding a domain answers 403 `FEATURE_NOT_ENABLED`."}},"required":["data","pagination","available"]},"ApplyCustomDomainDnsDto":{"type":"object","properties":{"provider":{"type":"string","enum":["cloudflare","digitalocean"],"example":"cloudflare"},"token":{"type":"string","description":"A short-lived API token with DNS edit rights for the zone (Cloudflare: Zone → DNS → Edit for that zone; DigitalOcean: a token with the domain scopes). Used for this request only: never stored, logged or returned. Revoke it afterwards.","example":"cf-token-from-your-dashboard","writeOnly":true},"zone":{"type":"string","format":"hostname","example":"acme.com","description":"The zone to write in (the host or one of its parents). Defaults to the detected zone."},"dry_run":{"type":"boolean","example":true,"description":"`true`: return the plan and change nothing. `false`: write exactly that plan, then check the domain."}},"required":["provider","token","dry_run"]},"ApplyPlanItemDto":{"type":"object","properties":{"action":{"type":"string","enum":["create","skip"],"example":"create","description":"`create`: the record will be added. `skip`: it is already there. Nothing is ever changed or deleted."},"purpose":{"type":"string","enum":["routing","ownership"],"example":"routing"},"type":{"type":"string","enum":["CNAME","TXT"],"example":"CNAME"},"host":{"type":"string","format":"hostname","example":"auth.acme.com"},"name":{"type":"string","example":"auth","description":"Relative to the provider zone."},"value":{"type":"string","example":"edge.auth.productcraft.co"},"ttl":{"type":"number","example":3600},"existing":{"description":"What the zone holds at the name for this type today.","example":[],"type":"array","items":{"type":"string"}},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/DnsConflictDto"}},"note":{"type":"string","nullable":true,"example":null},"result":{"type":"string","nullable":true,"enum":["created","unchanged","failed"],"example":"created","description":"Set when applied (not on a dry run)."},"error":{"type":"string","nullable":true,"example":null,"description":"Why this record failed to write."}},"required":["action","purpose","type","host","name","value","ttl","existing","conflicts","note","result","error"]},"ApplyCountsDto":{"type":"object","properties":{"create":{"type":"number","example":2},"skip":{"type":"number","example":0},"failed":{"type":"number","example":0}},"required":["create","skip","failed"]},"CustomDomainApplyResultDto":{"type":"object","properties":{"domain_id":{"type":"string","format":"uuid","example":"1d2c3b4a-5e6f-4a7b-8c9d-0e1f2a3b4c5d"},"hostname":{"type":"string","format":"hostname","example":"auth.acme.com"},"provider":{"type":"string","enum":["cloudflare","digitalocean"],"example":"cloudflare"},"zone":{"type":"string","format":"hostname","example":"acme.com","description":"The provider zone the records go in."},"dry_run":{"type":"boolean","example":true},"applied":{"type":"boolean","example":false,"description":"True when the records were written."},"plan":{"type":"array","items":{"$ref":"#/components/schemas/ApplyPlanItemDto"}},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/ApplyConflictDto"}},"counts":{"$ref":"#/components/schemas/ApplyCountsDto"},"warnings":{"type":"array","example":[],"description":"`token_scope_broader_than_zone`: the token could see other zones; revoke it now. `partially_applied`: some records were written and some failed (see each item's `error`).","items":{"type":"string","enum":["token_scope_broader_than_zone","partially_applied"]}},"domain":{"nullable":true,"description":"The domain right after the apply, with a fresh check. Null on a dry run.","allOf":[{"$ref":"#/components/schemas/CustomDomainDto"}]}},"required":["domain_id","hostname","provider","zone","dry_run","applied","plan","conflicts","counts","warnings","domain"]},"AuthErrorCode":{"description":"Error codes this API returns (open set: a client must accept codes it does not know; new codes are not a breaking change). Each is documented at https://productcraft.co/docs/errors#<code>.","anyOf":[{"type":"string","enum":["BAD_REQUEST","VALIDATION_ERROR","UNAUTHORIZED","PAYMENT_REQUIRED","FORBIDDEN","NOT_FOUND","METHOD_NOT_ALLOWED","NOT_ACCEPTABLE","REQUEST_TIMEOUT","CONFLICT","GONE","PRECONDITION_FAILED","PAYLOAD_TOO_LARGE","UNSUPPORTED_MEDIA_TYPE","UNPROCESSABLE_ENTITY","PRECONDITION_REQUIRED","RATE_LIMITED","INTERNAL_SERVER_ERROR","NOT_IMPLEMENTED","BAD_GATEWAY","SERVICE_UNAVAILABLE","GATEWAY_TIMEOUT","SERVICE_NOT_ENABLED","INVALID_CREDENTIALS","SESSION_REVOKED","MCP_NOT_PERMITTED","MCP_WORKSPACE_MISMATCH","PAK_NOT_PERMITTED","ACTIONS_NOT_GRANTABLE","IDEMPOTENCY_KEY_REUSE","IDEMPOTENCY_KEY_REQUIRED","IDEMPOTENCY_IN_PROGRESS","WEBHOOK_ENDPOINT_LIMIT","WEBHOOK_ENDPOINT_NOT_FOUND","WEBHOOK_ENDPOINT_NOT_SUBSCRIBED","WEBHOOK_EVENT_NOT_FOUND","WEBHOOK_EVENT_TYPE_UNKNOWN","ACCOUNT_EXISTS","ACCOUNT_NOT_FOUND","ACCOUNT_SUSPENDED","APEX_NOT_SUPPORTED","APP_ARCHIVED","APP_HAS_NO_WORKSPACE","APP_NOT_FOUND","APP_SUSPENDED","AUTH_SETTINGS_IN_METADATA","AVATAR_INVALID_TYPE","AVATAR_MAGIC_MISMATCH","AVATAR_NOT_FOUND","AVATAR_TOO_LARGE","BOT_PROTECTION_FAILED","BOT_PROTECTION_REQUIRED","BOT_PROTECTION_UNAVAILABLE","CUSTOM_DOMAIN_EXISTS","CUSTOM_DOMAIN_NOT_ACTIVE","CUSTOM_DOMAIN_NOT_FOUND","CUSTOM_DOMAIN_OAUTH_ONLY","DNS_CONFLICTS","ENVOI_APP_NOT_BOUND_TO_WORKSPACE","ENVOI_NOT_ENABLED","ENVOI_NOT_WIRED","ENVOI_SENDER_NOT_CONFIGURED","ENVOI_TEMPLATE_NOT_CONFIGURED","FEATURE_NOT_ENABLED","HOSTNAME_RESERVED","HOSTNAME_TAKEN","INSUFFICIENT_PERMISSIONS","INVALID_FORMAT","INVALID_HOSTNAME","INVALID_IDENTIFIER","INVALID_PROVIDER","INVALID_ZONE","MAIL_UNREACHABLE","PAK_WORKSPACE_MISMATCH","PASSWORD_BREACHED","PASSWORD_TOO_COMMON","PROVIDER_ERROR","PROVIDER_PERMISSION_DENIED","PROVIDER_TOKEN_REJECTED","PROVIDER_UNAVAILABLE","PROVIDER_ZONE_NOT_FOUND","ROLE_IN_USE","SENDER_ADDRESS_REQUIRED","SESSION_EXPIRED","SIGN_IN_TEST_INVALID","SLUG_TAKEN","SLUG_UUID_SHAPED","TENANT_API_KEYS_APP_PROXY_ONLY","TENANT_API_KEYS_FORBIDDEN","TENANT_API_KEYS_NOT_ENABLED","TENANT_API_KEYS_NO_SCOPES","TENANT_API_KEYS_SCOPE_INVALID","TENANT_API_KEYS_SUSPENDED","TENANT_API_KEYS_SUSPENDED_NOT_WRITABLE","TENANT_API_KEYS_UNKNOWN_ROLE","TENANT_API_KEY_EXPIRY_INVALID","TENANT_API_KEY_LIMIT","TENANT_API_KEY_NOT_ACTIVE","TENANT_API_KEY_NOT_FOUND","TENANT_API_KEY_SCOPE_ABOVE_CEILING","TENANT_API_KEY_SCOPE_NOT_GRANTABLE","TENANT_CONTEXT_REQUIRED","TENANT_MEMBERSHIP_REQUIRED","TENANT_NOT_ACTIVE","TOKEN_EXPIRED","TOKEN_INVALID","TOKEN_REVOKED","USERNAME_TAKEN","account_exists_with_different_provider","idempotency_secret_not_replayable","link_required","step_up_required"]},{"type":"string","description":"A code added after this document was published."}],"x-pc-open-enum":true,"x-pc-error-docs":"https://productcraft.co/docs/errors#{code}","x-enum-descriptions":{"BAD_REQUEST":"The request is malformed (not valid JSON, a bad header, an unusable parameter).","VALIDATION_ERROR":"One or more request fields are invalid; `errors` lists each field (`param`, `message`, `code`).","UNAUTHORIZED":"No credentials, or the token or API key is invalid, expired or revoked.","PAYMENT_REQUIRED":"The workspace has no credit or plan for this operation.","FORBIDDEN":"The caller is authenticated but not allowed to do this.","NOT_FOUND":"The resource does not exist, or belongs to another workspace or app.","METHOD_NOT_ALLOWED":"The route does not accept this HTTP method.","NOT_ACCEPTABLE":"The route cannot produce the requested content type.","REQUEST_TIMEOUT":"The request took too long to arrive.","CONFLICT":"The request conflicts with the current state of the resource.","GONE":"The resource existed but has been removed for good.","PRECONDITION_FAILED":"A precondition header did not match.","PAYLOAD_TOO_LARGE":"The request body is over the size limit.","UNSUPPORTED_MEDIA_TYPE":"The request body is not in a content type the route accepts.","UNPROCESSABLE_ENTITY":"The request is well-formed but cannot be carried out.","PRECONDITION_REQUIRED":"The route requires a precondition header.","RATE_LIMITED":"Too many requests; retry after the `Retry-After` header.","INTERNAL_SERVER_ERROR":"An unexpected server error. Retrying is safe for idempotent requests; quote `request_id` to support.","NOT_IMPLEMENTED":"The server does not support this operation.","BAD_GATEWAY":"An upstream service returned an invalid response.","SERVICE_UNAVAILABLE":"The service or one of its dependencies is temporarily unavailable; retry.","GATEWAY_TIMEOUT":"An upstream service did not answer in time; retry.","SERVICE_NOT_ENABLED":"The workspace has not enabled this product.","INVALID_CREDENTIALS":"The credentials were not accepted.","SESSION_REVOKED":"The session behind this token was signed out or revoked.","MCP_NOT_PERMITTED":"An MCP (agent) token cannot call this route.","MCP_WORKSPACE_MISMATCH":"The MCP token was issued for a different workspace.","PAK_NOT_PERMITTED":"A workspace API key cannot call this route.","ACTIONS_NOT_GRANTABLE":"The policy grants actions the caller does not hold.","IDEMPOTENCY_KEY_REUSE":"The `Idempotency-Key` was already used with a different body or route.","IDEMPOTENCY_KEY_REQUIRED":"This operation requires an `Idempotency-Key` header.","IDEMPOTENCY_IN_PROGRESS":"A request with the same `Idempotency-Key` is still running. Retry after `Retry-After` seconds with the same key to get its response.","WEBHOOK_ENDPOINT_LIMIT":"The workspace has the maximum number of webhook endpoints.","WEBHOOK_ENDPOINT_NOT_FOUND":"No webhook endpoint with this id in this workspace.","WEBHOOK_ENDPOINT_NOT_SUBSCRIBED":"The endpoint is not subscribed to this event type.","WEBHOOK_EVENT_NOT_FOUND":"No webhook event with this id in this workspace.","WEBHOOK_EVENT_TYPE_UNKNOWN":"The event type is not one this product sends.","ACCOUNT_EXISTS":"An account already exists for this address. Sign in and accept the invite from your account instead.","ACCOUNT_NOT_FOUND":"Account not found.","ACCOUNT_SUSPENDED":"Account suspended.","APEX_NOT_SUPPORTED":"A custom domain must be a subdomain, not the apex of a zone.","APP_ARCHIVED":"The app is archived.","APP_HAS_NO_WORKSPACE":"App has no workspace.","APP_NOT_FOUND":"App not found.","APP_SUSPENDED":"The app is suspended; its consumer routes are closed.","AUTH_SETTINGS_IN_METADATA":"Sign-in settings cannot be changed through app metadata. Use PATCH /v1/apps/{appId}/auth-config, or leave the auth key out of metadata.","AVATAR_INVALID_TYPE":"Avatar file could not be decoded as an image.","AVATAR_MAGIC_MISMATCH":"Avatar file content does not match declared type.","AVATAR_NOT_FOUND":"Avatar not found.","AVATAR_TOO_LARGE":"Avatar exceeds 2 MB.","BOT_PROTECTION_FAILED":"The bot-protection token was rejected.","BOT_PROTECTION_REQUIRED":"The app requires a bot-protection token on sign-up.","BOT_PROTECTION_UNAVAILABLE":"Bot protection could not be checked; retry.","CUSTOM_DOMAIN_EXISTS":"This app already has a custom domain; remove it before adding another.","CUSTOM_DOMAIN_NOT_ACTIVE":"This app has no active custom domain to test on. Finish its setup first.","CUSTOM_DOMAIN_NOT_FOUND":"Custom domain not found.","CUSTOM_DOMAIN_OAUTH_ONLY":"The custom domain only serves OAuth routes; test sign-in on the app domain instead.","DNS_CONFLICTS":"Some records cannot be written without breaking existing ones; nothing was changed.","ENVOI_APP_NOT_BOUND_TO_WORKSPACE":"The app is not linked to a workspace, so it cannot send through Mail.","ENVOI_NOT_ENABLED":"Mail is not enabled for the app's workspace.","ENVOI_NOT_WIRED":"Mail client not wired in this deployment.","ENVOI_SENDER_NOT_CONFIGURED":"No sender address is set for this notification.","ENVOI_TEMPLATE_NOT_CONFIGURED":"No Mail template is set for this notification.","FEATURE_NOT_ENABLED":"Custom domains are not available for this workspace yet.","HOSTNAME_RESERVED":"That host name is reserved and cannot be used.","HOSTNAME_TAKEN":"That host name is already used by another app.","INSUFFICIENT_PERMISSIONS":"The token does not grant the permission this route needs.","INVALID_FORMAT":"A path or query value is not in the expected format.","INVALID_HOSTNAME":"The host name is not valid; use a name such as `auth.example.com`.","INVALID_IDENTIFIER":"An id in the path or query is not a valid UUID.","INVALID_PROVIDER":"Unsupported DNS provider.","INVALID_ZONE":"The DNS zone is not one this domain belongs to.","MAIL_UNREACHABLE":"The Mail service did not answer; try again in a minute.","PAK_WORKSPACE_MISMATCH":"The workspace API key belongs to a different workspace.","PASSWORD_BREACHED":"This password has appeared in a known data breach — pick a different one.","PASSWORD_TOO_COMMON":"Password is too common — pick something less guessable.","PROVIDER_ERROR":"The DNS provider returned an error.","PROVIDER_PERMISSION_DENIED":"The DNS provider token lacks permission to edit this zone.","PROVIDER_TOKEN_REJECTED":"The DNS provider rejected the token.","PROVIDER_UNAVAILABLE":"The DNS provider is unavailable; try again later.","PROVIDER_ZONE_NOT_FOUND":"The DNS provider has no zone for this domain.","ROLE_IN_USE":"Role is in use.","SENDER_ADDRESS_REQUIRED":"A sender address is required for these notification settings.","SESSION_EXPIRED":"Session expired.","SIGN_IN_TEST_INVALID":"This sign-in test link is unknown, already used or expired. Start a new test from the console.","SLUG_TAKEN":"The clone needs a slug of its own.","SLUG_UUID_SHAPED":"A slug cannot look like a UUID.","TENANT_API_KEYS_APP_PROXY_ONLY":"Tenant API keys for this app are managed through the app, not directly.","TENANT_API_KEYS_FORBIDDEN":"Your tenant role cannot manage API keys.","TENANT_API_KEYS_NOT_ENABLED":"Tenant API keys are not enabled for this app.","TENANT_API_KEYS_NO_SCOPES":"Enabling tenant API keys needs at least one grantable scope.","TENANT_API_KEYS_SCOPE_INVALID":"A scope name is malformed; scopes look like `orders.read` (`details.scopes`).","TENANT_API_KEYS_SUSPENDED":"Tenant API keys are suspended for this app.","TENANT_API_KEYS_SUSPENDED_NOT_WRITABLE":"Tenant API keys are suspended for this app; only `suspended` can be changed.","TENANT_API_KEYS_UNKNOWN_ROLE":"`manage_roles` names a role this app does not have (`details.roles`).","TENANT_API_KEY_EXPIRY_INVALID":"The requested expiry is not one of the allowed values (`details.allowed`).","TENANT_API_KEY_LIMIT":"The tenant has the maximum number of active API keys (25); revoke one first.","TENANT_API_KEY_NOT_ACTIVE":"Only an active key can be rotated.","TENANT_API_KEY_NOT_FOUND":"API key not found.","TENANT_API_KEY_SCOPE_ABOVE_CEILING":"You cannot rotate a key whose scopes your own tenant role does not hold.","TENANT_API_KEY_SCOPE_NOT_GRANTABLE":"Some scopes are not offered by this app.","TENANT_CONTEXT_REQUIRED":"No active tenant on this session. Switch to a tenant first (POST /auth/switch-tenant).","TENANT_MEMBERSHIP_REQUIRED":"You are not a member of the active tenant.","TENANT_NOT_ACTIVE":"The active tenant is not active.","TOKEN_EXPIRED":"Token expired.","TOKEN_INVALID":"Token invalid.","TOKEN_REVOKED":"Token revoked.","USERNAME_TAKEN":"Username already in use.","account_exists_with_different_provider":"An account with this email exists and signs in with a different method.","idempotency_secret_not_replayable":"A repeat of a key-creating request: the secret is never replayed (`details.key_id`).","link_required":"An account with this email exists; confirm the link to sign in with this provider.","step_up_required":"Recent MFA is required; verify a factor and retry (`details.max_age_minutes`)."}}},"headers":{"RequestId":{"description":"The request's id: the inbound `X-Request-Id` when it is 1 to 128 characters of `A-Z a-z 0-9 . _ : -`, otherwise a fresh `req_...` id. Error bodies repeat it as `request_id`. Quote it when contacting support.","schema":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","example":"req_6f1c2a9d4b7e4f0a8c3d2e1f0a9b8c7d"}}}}}