# Merged spec generated by docs-generator.
# This document is the effective YAML behind the rendered page —
# consolidated from every file in the source directory.
# project: account
# yaml-language-server: $schema=/docs/../schemas/spec.schema.json

info:
    title: Account Service API
    version: "1.0"
    description: |
        Multi-tenant identity, authentication, and organization service. Issues
        short-lived RS256 JWTs (access + refresh) for human users and exchanges
        long-lived API keys for the same JWTs for service accounts. Organizations,
        memberships, roles, sessions, and audit activity all live here.
    base_urls:
        - label: Production
          url: https://account.ikavia.com
          default: true
        - label: Staging
          url: https://account-dev.ikavia.com
        - label: Local
          url: http://localhost:8874
    overview_cards:
        - icon: "\U0001F510"
          title: Two principal types, four sign-in surfaces
          description: Human users sign in via password, Google, or Firebase. Services authenticate with API keys.
          content: |
            - **Human via password** — `POST /auth/login` with email + password.
              The historical / default path. Subject to brute-force protection.
            - **Human via Sign-In-With-Google** — `POST /auth/google` with a
              Google ID token from GIS / native SDK. Auto-links to an existing
              row by email match, or auto-creates a new account.
            - **Human via Sign-In-With-Firebase** — `POST /auth/firebase` with a
              Firebase ID token. Same resolution rules as Google but keyed on
              `firebase_uid`. Useful when the mobile app is already using
              Firebase Auth for multi-provider (Apple, Facebook, …).
            - **Service** principals authenticate with an API key
              (`X-API-Key`). Use `/auth/token-exchange` once an hour to swap a
              key for a JWT and avoid per-request key validation.

            Users can mix-and-match: e.g. register with password, then later
            click "Connect Google account" on `/dashboard/connected-accounts`
            to add Google as a second sign-in method against the same row.
        - icon: "\U0001F3E2"
          title: Organization-scoped
          description: Every authenticated request resolves to a current organization context.
          content: |
            - On login the user's `default_organization_id` becomes the current org.
            - Use `POST /organizations/{org_id}/switch` to issue a new token bound to a different org. The session id is preserved across the switch.
        - icon: "\U0001F91D"
          title: 'For sibling services: which auth do I need?'
          description: Peer microservices have three integration paths; pick the one that matches your use case before requesting credentials.
          content: |
            Important context for teams building wacca, chat, planner,
            survey, media, or any future ikavia microservice.
            account-service is the central hub — your service authenticates
            against it differently depending on what you're trying to do:

            - **Calling public HTTP endpoints as your service identity**
              (e.g. `GET /api/v1/accounts/{id}` to enrich a response):
              create a `service_account` inside an organization, generate
              an **API key**, and either send `X-API-Key: <key>` directly
              or swap it for a JWT via `/auth/token-exchange` and send
              `Authorization: Bearer <jwt>`. This is the **default path
              for HTTP**. No coordination needed — any org owner can
              create the service_account.
            - **Calling internal gRPC `InternalGrantService`** (to grant
              your product's service-key like `wacca-tenant:*` to a user):
              your service needs a **system account** — a basic-auth
              credential issued by a **platform admin** via
              `/dashboard/admin-system-accounts`. **Request this from
              the platform team before you start integration**; without
              it the gRPC port rejects you. The credential's
              `allowed_prefixes` constrains which service-keys you may
              touch — propose your prefix when you file the request
              (e.g. `wacca-` for any `wacca-*` key).
            - **Acting on behalf of a logged-in user** (forwarding a
              request your user made): just forward their JWT
              `Authorization` header to account-service. The principal
              stays human; no new credential needed. **This is the path
              you want if you're building an API Key Manager** in your
              service's UI — but only expose that panel to
              **organization owners** in our model, or to your own
              service's **superadmin role**. Regular org members should
              not see it; API keys are too powerful to be a general
              self-service feature. See the "Recipe: API Key Manager in
              your own service" section for the audience rules, CORS
              allowlist setup, and worked examples.

            **Don't mix these up.** `service_account` (org-owned,
            API-key) and `system_account` (platform-level, basic-auth)
            sound similar but are unrelated tables. See the
            "System accounts (admin)" section for the issuance UI and
            the "Internal gRPC" section for the wire protocol.
        - icon: "\U0001F6E1️"
          title: Defence in depth
          description: Brute-force lockout, refresh-token rotation with reuse detection, server-side revocation.
          content: |
            - Failed logins increment per-account and per-IP counters with progressive delay and lockout.
            - Refresh tokens are rotated on every `/auth/refresh`; presenting an old token after rotation revokes the session.
            - Logout / password-change writes the JTI to a Redis revocation list so stolen access tokens stop validating immediately.
        - icon: "\U0001F4E6"
          title: Standard response envelope
          description: Every JSON response follows the same wrapper shape — success, error, and 4xx/5xx alike.
          content: |
            Successful responses:
            ```json
            { "success": true, "code": 2010, "data": { ... } }
            ```
            - `code` is omitted (or absent) on most happy paths and is filled in for created (`2010`) and a few special cases.
            - `data` carries the endpoint's actual payload.

            Failure responses:
            ```json
            { "success": false, "code": 4003, "message": "...", "trace_id": "..." }
            ```
            - `code` follows the error-code table below — always check it before falling back to HTTP status.
            - `trace_id` is set on errors that originate from a use case so support can correlate logs.
        - icon: "\U0001F4E7"
          title: How to tell if an account is email-verified
          description: Three signals carry the verification state — pick the one closest to where your code already is.
          content: |
            A human account starts with `email_verified=false`; the user
            clicks the link in the welcome email (or the resend) to flip
            it. **Three places** expose that state — same source of
            truth, different surfaces.

            - **`data.email_verified` on `GET /api/v1/me`** — the
              canonical boolean. Read this from any authenticated
              client; the dashboard verify-email banner is wired this
              way.
            - **`data.requires_email_verification` on login responses**
              — appears on `/auth/login`, `/auth/register`,
              `/auth/google`, `/auth/firebase`, and `/auth/refresh`.
              `true` when verification is still pending. Useful for
              routing the user to a "check your email" page right
              after sign-in instead of straight to the dashboard.
            - **JWT `permissions` claim on the access token** — verified
              humans get `["*"]`; unverified humans get the partial
              read-only set `["read:profile", "read:organizations",
              "read:sessions"]`. Service-to-service code that inspects
              the JWT should treat the partial set as "ask the user to
              verify"; any `*`-gated endpoint will 403 the request
              otherwise.

            Practical rules of thumb:

            - **Frontend after sign-in** — read `data.email_verified`
              from `GET /api/v1/me`. The dashboard verify-email banner
              (`components/layout/verify-email-banner.tsx`) handles the
              resend button.
            - **Login screen UX** — route off
              `requires_email_verification` straight from the login
              response, no extra round-trip.
            - **Backend / RBAC** — inspect the JWT `permissions` claim.

            Unverified users do NOT block sign-in or session creation —
            they get a reduced permission set plus the banner. Email
            verification gates feature reach, not authentication.
        - icon: "\U0001F6A6"
          title: Error code table
          description: Numeric codes mirror HTTP status families (2xxx success, 4xxx client error, 5xxx server error).
          content: |
            | Code | HTTP | Meaning |
            |------|------|---------|
            | `2010` | 201 | Resource created (used on `/auth/register`). |
            | `4000` | 400 | Bad request (missing required header, malformed body). |
            | `4002` | 422 | Validation failed (e.g. invalid email format, weak password, phone-number charset). |
            | `4003` | 401 | Authentication / authorization failure (invalid token, missing perm, revoked session). |
            | `4004` | 404 | Resource not found (account, organization, role, session). |
            | `4005` | 409 | Duplicate entry (email already exists; Google sub / Firebase uid already linked to another account; org slug collision). |
            | `4008` | 429 | Rate limit exceeded — `retry_after` field is included. |
            | `4009` | 409 | Conflict — destructive op blocked. Admin hard-delete: target owns a multi-member org (body includes `blocking_organizations`). Transactional rollback delete: target owns a multi-member org, or has FK-blocking actor rows (api_keys.created_by, invitations.invited_by, service_accounts.created_by_account_id) — the account isn't pristine, rollback unsafe. |
            | `4030` | 403 | CSRF validation failed. |
            | `5000` | 500 | Internal error — usually transient (DB / Redis / RabbitMQ blip). Safe to retry with backoff. |

            Validation errors carry the offending field name in `message` (e.g. `"email: invalid format"`).
        - icon: "\U0001F36A"
          title: Cookies + SSO
          description: Browser clients receive HttpOnly refresh + readable CSRF cookies, both scoped to the configured SSO domain.
          content: |
            Two cookies are issued by `/auth/login`, `/auth/register`, and rotated on `/auth/refresh`. Both are cleared by `/auth/logout` and `/auth/logout-all`.

            | Cookie | Purpose | Attributes |
            |--------|---------|------------|
            | `refresh_token` | Carries the refresh JWT for the SSO subdomain flow. | `HttpOnly`, `Path=/`, `Max-Age` matches the refresh-token TTL, `SameSite={configured}`, `Secure` (configurable, default ON in prod), `Domain={configured}` (e.g. `.example.com`). |
            | `csrf_token`  | Random URL-safe base64 token used by the double-submit pattern. | **Not** HttpOnly (frontend JS reads it), `Path=/`, `Max-Age` mirrors `refresh_token`, `SameSite`/`Secure`/`Domain` inherited from the refresh cookie config. |

            - Domain is `{configurable per-deployment}` — set once at the parent SSO domain so a login on `account.example.com` is honoured by `app.example.com`.
            - The login response body **also** carries `access_token` and `refresh_token` for non-browser clients (mobile, CLI, server-to-server) — these are the same values written to the cookies.
        - icon: "\U0001F6C2"
          title: CSRF middleware
          description: Double-submit-cookie protection runs only on cookie-authenticated mutations.
          content: |
            The middleware enforces the double-submit-cookie pattern. It runs **only** when ALL of the following hold:
            1. Method is mutating (`POST`, `PUT`, `PATCH`, `DELETE`).
            2. No `Authorization` header is present (Bearer/JWT flows are exempt).
            3. No `X-API-Key` header is present (service-account flows are exempt).
            4. The request actually carries the `csrf_token` cookie.

            When the gate is active the request must echo the cookie value back in the `X-CSRF-Token` header (configurable per-deployment). The comparison is constant-time. A mismatch returns:
            ```json
            { "success": false, "code": 4030, "message": "Invalid CSRF token" }
            ```
            Mobile/CLI/SDK clients that authenticate with `Authorization: Bearer ...` or `X-API-Key: ...` never see this middleware. CSRF is a browser concern.
        - icon: ⏱️
          title: Rate limits
          description: Per-IP token-bucket on auth endpoints; 429 carries retry hints.
          content: |
            Limits are configured per-deployment. Defaults at the time of writing:

            | Endpoint family | Limit |
            |----------------|-------|
            | `/auth/register`, `/auth/resend-verification` | 3 / minute |
            | `/auth/login`, `/me/password` | 5 / minute |
            | `/auth/verify` | 100 / minute |
            | All other endpoints | bound by general per-IP cap |

            On exceed:
            ```json
            {
              "success": false,
              "code": 4008,
              "message": "Rate limit exceeded. Try again in 30 seconds.",
              "retry_after": 30,
              "limit": 5,
              "window": "60s"
            }
            ```
            Honour `retry_after` (seconds) before retrying. The login flow ALSO applies per-account brute-force counters with progressive delay independent of this rate limit — a 200 OK can still be slow if the account is under attack.
        - icon: "\U0001F4D1"
          title: Pagination
          description: '`?page` + `?per_page` query parameters; response wraps items in a paginated envelope.'
          content: |
            Every list endpoint accepts:
            - `page` — 1-indexed (default `1`).
            - `per_page` — page size (default `20`, hard cap `100`).

            Response shape (inside `data`):
            ```json
            {
              "items": [ ... ],
              "total": 42,
              "page": 1,
              "per_page": 20,
              "total_pages": 3
            }
            ```
            `total_pages` is `ceil(total / per_page)`. There is no `next_cursor` — use `page + 1` until `page > total_pages`.
        - icon: "\U0001F504"
          title: gRPC parity
          description: Most HTTP endpoints have a gRPC counterpart sharing the same use cases.
          content: |
            Every documented HTTP endpoint here delegates to an application use case. The gRPC delivery (`account.v1.AuthService`, `account.v1.AccountService`, `account.v1.OrgService`, `account.v1.ServiceAccountService`) wraps the same use cases — request and response shapes are derived from `proto/account/v1/*.proto`.

            Use HTTP for browser/mobile/web clients. Use gRPC for service-to-service when you control both ends and want the typed contract. Both paths share session storage and the same revocation list.
        - icon: "\U0001F4DA"
          title: OpenAPI / Swagger UI
          description: This page is for human consumers; for codegen, use the OpenAPI spec.
          content: |
            - **OpenAPI 3.x JSON:** `GET /openapi.json` — generated from `utoipa` annotations on the route handlers, always in sync with what the server actually exposes.
            - **Swagger UI:** `GET /docs` (path is configurable per-deployment) renders an interactive client against `openapi.json`.

            The OpenAPI spec is the source of truth for client SDK generation; this docs page is the narrative companion.
authentication:
    methods:
        - type: JWT Bearer
          header: Authorization
          format: Bearer <access_token>
          source: account-service
          description: |
            Short-lived access token (current production TTL: **8 hours**;
            tunable per-deployment via
            `APP__SECURITY__JWT__ACCESS_TOKEN_TTL_MINS`) issued by
            `/auth/login`, `/auth/register`, `/auth/refresh`,
            `/auth/google`, `/auth/firebase`, or `/auth/token-exchange`.
            RS256-signed; verifiers can fetch the public key from
            `/auth/public-key`.
          note: 'Refresh token (current production TTL: **7 days**; tunable via `APP__SECURITY__JWT__REFRESH_TOKEN_TTL_DAYS`) is sent separately as an HttpOnly cookie or in the `/auth/refresh` body. Refresh rotates on every call but a 30-second **grace window** accepts a recently-rotated token to avoid revoking sessions on benign cross-tab races.'
          token_contains:
            - sub (account id)
            - sid (session id; nil for service principals)
            - org_id (current organization)
            - permissions
            - organizations[] (id + role)
            - principal_type (human | service)
            - jti (revocable id)
            - exp / iat / iss / kid
        - type: API Key
          header: X-API-Key
          format: <api_key>
          source: account-service (`POST /service-accounts/{account_id}/api-keys`)
          description: |
            Long-lived credential bound to a service account. Send as `X-API-Key`
            header on any protected endpoint, or call `/auth/token-exchange` once
            an hour to swap it for a JWT (cheaper than per-request key lookup).
          note: Plaintext is returned only at creation time; only a sha-256 hash is stored server-side.
flow_overview:
    methods:
        - type: JWT Bearer
          steps:
            - title: Register or log in
              detail: POST /auth/register (new user) or POST /auth/login (existing). Both return access_token, refresh_token, and an HttpOnly refresh cookie.
            - title: Attach the access token
              detail: 'Send Authorization: Bearer <access_token> on every protected request. CSRF middleware does not apply when this header is present.'
            - title: Rotate before expiry
              detail: Call POST /auth/refresh (body or cookie carries the refresh token) to receive a fresh pair. The old refresh token is revoked atomically — replaying it later kills the session.
            - title: Switch organization (optional)
              detail: POST /organizations/{org_id}/switch reissues a token bound to the new org. The session id is preserved.
            - title: Log out
              detail: POST /auth/logout revokes the current session and adds the access JTI to the revocation list. POST /auth/logout-all sweeps every active session for the account.
        - type: API Key
          steps:
            - title: Create a key
              detail: Owner of the org calls POST /service-accounts/{account_id}/api-keys. The plaintext key is returned ONCE — store it client-side immediately.
            - title: Send X-API-Key on each call
              detail: 'Set X-API-Key: <key> as a header. The middleware accepts it on any protected endpoint.'
            - title: Or exchange for a JWT (recommended)
              detail: POST /auth/token-exchange with X-API-Key returns a 1-hour service JWT (principal_type=service). Verifiers can validate it locally with /auth/public-key, no per-request roundtrip to account-service.
            - title: Revoke when leaked
              detail: POST /api-keys/{api_key_id}/revoke marks the key dead immediately. Use DELETE /api-keys/{api_key_id} for permanent removal.
        - type: JWT Bearer (Sign in with Google)
          steps:
            - title: Render the GIS button
              detail: Frontend loads `https://accounts.google.com/gsi/client` and calls `google.accounts.id.initialize({ client_id, callback })` with the OAuth Client ID issued by Google Cloud Console for this app. The button is rendered by Google inside an embedded iframe.
            - title: User picks an account
              detail: Google returns a `CredentialResponse` containing a signed ID-token JWT in `response.credential`.
            - title: POST /auth/google
              detail: Frontend posts `{ credential }` to `/api/v1/auth/google`. The server verifies the token against Google's JWKS, then either logs in an already-linked row, auto-links a matching-email row, or auto-creates a fresh account + personal org. Response shape matches `/auth/login`.
            - title: Use the returned JWT
              detail: 'From this point the flow is identical to password login — same `access_token` + `refresh_token` + refresh / CSRF cookies. Subsequent calls happen via `Authorization: Bearer ...`.'
    note: |
        Most non-auth endpoints accept either method. Sessions and `/auth/logout` are JWT-only — service principals don't own sessions.
sections:
    - id: account
      title: Account & Sessions
      description: |
        Per-user endpoints: profile, password change, sessions, activity feed.
        All require an authenticated principal — most are JWT-only because
        they read or mutate session-bound state.
      endpoints:
        - name: Get my profile
          method: GET
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Return the authenticated principal's profile (account details +
            email verification status). Works for both human and service
            principals.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "account_type": "human",
                "email": "user@example.com",
                "username": "jane.doe",
                "display_name": "Jane Doe",
                "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
                "timezone": "Asia/Jakarta",
                "language": "id",
                "email_verified": true,
                "bio": "",
                "phone_number": "+62 812-3456-7890",
                "metadata": {},
                "must_change_password": false,
                "has_password": true,
                "created_at": 1735689600
              }
            }

            The `has_password` flag is **false** for users created via
            Google or Firebase auto-signup who haven't yet set a
            password. The Profile → Password tab uses this to swap
            between a "Set password" and "Change password" form.

            `phone_number` is optional free-form text (E.164-friendly).
            Empty string when not set. The PUT endpoint accepts
            updates with charset `[+0-9 ()-.]` and length 6-32; an
            empty string in the PUT body clears it.

            `avatar_media_id` is the profile photo, stored as a
            **media-service media id** (UUID), not a URL. Render it via
            `https://media.ikavia.com/api/media/{id}`. `null` when unset.
            On the PUT endpoint: omit to leave unchanged, `""` to clear,
            or a UUID to set — the BE validates the id exists in
            media-service (422 if not found / expired).
        - name: Update my profile
          method: PUT
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Patch the authenticated user's profile. Omitted fields are left
            untouched. `model_config` and `capabilities` apply to service
            accounts only.

            **`username`** is the unique, public handle (3-64 chars, only
            letters/digits and `._@+-`). Case-insensitive uniqueness is
            enforced at the database level — a collision returns
            `4002 validation_failed` rather than overwriting another row.
            The same field can also be edited by other services via the
            `account.profile.update_requested` RabbitMQ event (see Events).
          body:
            - name: username
              type: string
              description: New username handle. 3-64 chars; letters, digits, `._@+-`. Must be globally unique (case-insensitive).
            - name: display_name
              type: string
              description: Updated display name (fullname).
            - name: bio
              type: string
              description: Free-form bio (markdown allowed downstream).
            - name: phone_number
              type: string
              description: 'Optional phone number. Three intents: field omitted from body → leave alone; empty string → explicit clear; non-empty → set/replace. Charset `[+0-9 ()-.]`, length 6-32.'
            - name: avatar_media_id
              type: string
              description: Profile photo as a media-service media id (UUID). Same three intents as phone_number (omit → leave, `""` → clear, UUID → set). On set, the BE validates the id exists in media-service (422 if not found/expired).
            - name: timezone
              type: string
              description: IANA timezone.
            - name: language
              type: string
              description: ISO 639-1 code.
            - name: model_config
              type: string
              description: Service accounts only — model preferences blob.
            - name: capabilities
              type: string[]
              description: Service accounts only — capabilities list (e.g. `museum:read`).
          example_body: |
            {
              "username": "jane.d",
              "display_name": "Jane D.",
              "bio": "Building things.",
              "phone_number": "+62 812-3456-7890",
              "timezone": "Asia/Singapore",
              "language": "en"
            }
          example_response: |
            { "success": true, "data": { "account_id": "...", "account_type": "human", "email": "user@example.com", "username": "jane.d", "display_name": "Jane D.", "timezone": "Asia/Singapore", "language": "en", "email_verified": true, "bio": "Building things.", "phone_number": "+62 812-3456-7890", "metadata": {}, "must_change_password": false, "has_password": true, "created_at": 1735689600 } }
        - name: Change or set password
          method: POST
          path: /api/v1/me/password
          auth: JWT Bearer
          description: |
            Two modes selected automatically by the BE based on whether
            the account currently has a `password_hash`:

            - **Change mode** (`has_password = true`): the request must
              supply the correct `current_password`. We verify it
              against the stored hash, accept the swap, then revoke
              every active session **except** the one making this
              request (so the tab the user is sitting in doesn't get
              logged out).
            - **First-time set mode** (`has_password = false`): typical
              for users who signed up via Google or Firebase and never
              set a password. The `current_password` field is
              **ignored** in this mode — there is nothing to verify
              — but the caller must still be authenticated via JWT.
              After success the account has `has_password = true` and
              future calls hit the change-mode path.

            The mode is decided server-side from the database; the FE
            uses the `has_password` field from `/me` to pick between
            a "Change password" and a "Set password" form.

            Service accounts cannot call this — they have no password.
            **Rate limit:** 5 / minute / IP.
          body:
            - name: current_password
              type: string
              description: Existing password. Required in change mode; ignored in first-time-set mode (send empty string or omit). The BE picks the mode automatically.
            - name: new_password
              type: string
              required: true
              description: New password — same complexity rules as registration.
          example_body: |
            {
              "current_password": "OldPass123!",
              "new_password": "NewSecurePass456!"
            }
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Request email change
          method: POST
          path: /api/v1/me/email
          auth: JWT Bearer
          description: |
            Step 1 of the **verify-new-email** flow. Does NOT change the
            email immediately — instead sends a confirmation link to the
            **new** address and a security notice to the **old** one. The
            old email stays active until the new one is confirmed
            (step 2). Guards against typos and session-hijack.

            **Re-auth:** accounts that have a password MUST send
            `current_password`. SSO-only accounts (NULL password) skip it
            — the valid session is the only credential they have.

            **Errors:**
            - `422` — `new_email` invalid, or `current_password` missing
              on a password account.
            - `401` — `current_password` incorrect.
            - `409` — `new_email` already belongs to another account.

            Sessions are **not** revoked by an email change (it's not a
            credential change). The confirmation link expires in 1 hour.
          body:
            - name: new_email
              type: string
              required: true
              description: The address to switch to. Must be unused by another account.
            - name: current_password
              type: string
              description: Required for password accounts (re-auth). Omit for SSO-only accounts.
          example_body: |
            {
              "new_email": "new.address@example.com",
              "current_password": "CurrentPass123!"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "message": "Confirmation link sent to your new email address. The change takes effect once you click it."
              }
            }
        - name: Confirm email change
          method: POST
          path: /api/v1/me/email/confirm
          auth: none
          description: |
            Step 2 — **public** (the token from the confirmation email is
            the auth). Consumes the token, swaps the account's email,
            marks it verified, and publishes `account.account.updated` /
            `account.account.email_changed` to exchange
            `account.events`. Single-use: the token is dropped after a
            successful swap.

            Two entry points hit the same logic:
            - **This JSON endpoint** (`POST`) — for a FE-driven confirm page.
            - **`GET /confirm-email-change?token=…`** — the link in the
              confirmation email lands here directly; the backend runs the
              same swap and renders a branded HTML result page (mirrors
              `GET /verify-email`). No FE page required.

            For `platform=wacca-mobile` requests the email's primary button
            is a `wacca://` deeplink with the https link as fallback — but
            that's set at registration/login time, not here.

            **Errors:**
            - `422` — token missing / invalid / expired.
            - `409` — the new email was taken by someone else during the
              confirmation window.
          body:
            - name: token
              type: string
              required: true
              description: Plaintext confirmation token from the email link.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "new.address@example.com"
              }
            }
        - name: List my sessions
          method: GET
          path: /api/v1/me/sessions
          auth: JWT Bearer
          permission: read:sessions
          description: |
            List every active session for the authenticated user. The session
            backing the current request is flagged with `is_current: true`.
            Also available at `/api/v1/sessions` (legacy path).
          example_response: |
            {
              "success": true,
              "data": {
                "sessions": [
                  {
                    "session_id": "550e8400-...",
                    "ip_address": "203.0.113.42",
                    "user_agent": "Mozilla/5.0 ...",
                    "device_fingerprint": "",
                    "created_at": 1735689600,
                    "last_used_at": 1735693200,
                    "expires_at": 1738281600,
                    "is_current": true
                  }
                ]
              }
            }
        - name: Revoke a session
          method: DELETE
          path: /api/v1/me/sessions/{session_id}
          auth: JWT Bearer
          description: |
            Revoke a specific session by id. The user can revoke any of their
            own sessions, including the current one (which terminates the
            calling session). Also available at `/api/v1/sessions/{session_id}`.
          example_response: |
            { "success": true, "data": null }
        - name: List connected accounts
          method: GET
          path: /api/v1/me/connected-accounts
          auth: JWT Bearer
          description: |
            Snapshot of which external identity providers are currently
            linked to the authenticated account. Today the only one is
            Google; the response shape is forward-compatible — new
            providers added later become additional entries in the
            `providers` array without changing the wrapper.

            For Google, `linked_email` carries the email of the linked
            Google identity (e.g. `arian.personal@gmail.com`) which
            **may differ** from the account's own email
            (`arian@ikavia.com`). The FE renders "Connected as
            {linked_email}" so the user knows which Google account is
            attached. Absent from the response when the provider is
            not linked.

            `can_disconnect_google` is `true` only when the user has a
            password set; this lets the UI disable the disconnect button
            for Google-only accounts so they don't lock themselves out.
          example_response: |
            {
              "success": true,
              "data": {
                "providers": [
                  {
                    "provider": "google",
                    "linked": true,
                    "linked_email": "arian.personal@gmail.com"
                  }
                ],
                "can_disconnect_google": true
              }
            }
        - name: Link Google
          method: POST
          path: /api/v1/me/connected-accounts/google/link
          auth: JWT Bearer
          description: |
            Link a verified Google identity to the currently-signed-in
            account. The body shape is identical to `/auth/google`:
            the `credential` is the raw JWT returned by Google Identity
            Services (web) or the native Google SDK (Android / iOS).

            **Validation:**
            - Google ID token signature, `iss`, `aud`, `exp` must all
              check out (same JWKS-verified path as `/auth/google`).
            - Google `email_verified` must NOT be `false`.
            - The token's email is **NOT** required to match the
              account's email. A typical use case is a work account
              (`arian@ikavia.com`) linked to a personal Google
              identity (`arian.personal@gmail.com`); both are
              persisted so the FE can display the Google address
              alongside the account address.

            **Identity model:** `google_sub` (Google's stable, never-
            reused subject id) is the lookup key — DB UNIQUE
            constraint sits there. `google_email` is persisted only
            for display; on every successful login the BE refreshes
            it from the token so a user who later changes their
            Google primary email still sees the right value.

            **Idempotent**: if the same Google `sub` is already linked
            to this account, the call refreshes the stored
            `google_email` and returns 200 with the current status.
            Linking a DIFFERENT `sub` to a row that already has one
            returns 4005 duplicate_entry — the user must disconnect
            the old Google identity first. Linking a `sub` already
            owned by ANOTHER account also returns 4005.
          body:
            - name: credential
              type: string
              required: true
              description: Raw Google ID token (the same JWT shape /auth/google accepts).
          example_body: |
            { "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y..." }
          example_response: |
            {
              "success": true,
              "data": {
                "providers": [
                  {
                    "provider": "google",
                    "linked": true,
                    "linked_email": "arian.personal@gmail.com"
                  }
                ],
                "can_disconnect_google": true
              }
            }
        - name: Disconnect Google
          method: DELETE
          path: /api/v1/me/connected-accounts/google
          auth: JWT Bearer
          description: |
            Clear the account's `google_sub` AND `google_email`.
            After this call the user can no longer sign in via Google
            for this account — they must use password (or re-link
            Google later).

            **Lock-out guard:** if the account has no password set
            (proxied today by `password_hash IS NULL`), the call
            returns 4002 with a message instructing the user to set a
            password first via `/auth/password-reset`. Idempotent:
            calling DELETE when Google is not linked returns 200 with
            the current status.
          example_response: |
            {
              "success": true,
              "data": {
                "providers": [
                  { "provider": "google", "linked": false }
                ],
                "can_disconnect_google": false
              }
            }
        - name: My activity feed
          method: GET
          path: /api/v1/me/activities
          auth: JWT Bearer
          description: |
            Audit-log entries where the authenticated user is the actor. Paged.
            The `actor_id` filter is forced server-side to the calling user, so
            callers cannot pivot to another user's feed.

            **Action vocabulary** (dot-notation, used in `action` filter and
            `action` field of returned items):
            - `account.profile_updated`, `account.accessed`
            - `auth.login`, `auth.login_failed`, `auth.logout`,
              `auth.token_refreshed`, `auth.password_changed`, `auth.password_reset`
            - `member.invited`, `member.removed`
            - `invitation.cancelled`
            - `service_account.archived`, `service_account.paused`,
              `service_account.resumed`
            - `api_key.created`, `api_key.revoked`

            New actions may be added without a docs revision; consumers should
            tolerate unknown values (display as-is, don't reject).
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page number.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
            - name: since
              type: string
              description: ISO-8601 — filter `created_at >= since`.
            - name: until
              type: string
              description: ISO-8601 — filter `created_at < until`.
            - name: action
              type: string
              description: Dot-notation filter, e.g. `member.invited`.
            - name: org_id
              type: string
              description: Optional org scope. Org-side activities are also available at `/organizations/{org_id}/activities`.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "550e8400-...",
                    "actor_id": "550e8400-...",
                    "actor_email": "user@example.com",
                    "actor_display_name": null,
                    "organization_id": "550e8400-...",
                    "organization_name": null,
                    "action": "organization.created",
                    "target_type": "organization",
                    "target_id": "550e8400-...",
                    "target_label": null,
                    "metadata": {},
                    "ip_address": "203.0.113.42",
                    "user_agent": "Mozilla/5.0 ...",
                    "created_at": 1735689600
                  }
                ],
                "total": 42,
                "page": 1,
                "per_page": 20,
                "total_pages": 3
              }
            }
        - name: Search accounts
          method: GET
          path: /api/v1/accounts/search
          auth: JWT Bearer
          description: |
            Authenticated user directory search. Match is `ILIKE %q%`
            against either `display_name` OR `email`. Returns the
            minimum fields a "find someone" picker needs.

            **Mitigations against enumeration** (this endpoint is global,
            which is intentional but exposed):
            - Server enforces `APP__SECURITY__SEARCH__MIN_QUERY_LENGTH`
              (default **3** characters). Shorter `q` → 422.
            - `per_page` is clamped server-side to
              `APP__SECURITY__SEARCH__MAX_PER_PAGE` (default **50**).
            - Dedicated rate-limit bucket (`search`, default **30/min/IP
              fail-closed**) — tighter than `general`, fail-closed so a
              limiter outage doesn't open a scraping window.

            **Excluded from results:** soft-deleted accounts
            (`deleted_at IS NOT NULL`) and `deactivated` accounts — the
            user closed their account; surfacing them in pickers would
            re-attach them to other people's workflows.

            Service principals should use the existing s2s endpoints
            `GET /accounts/{id}` / `POST /accounts/bulk-lookup` instead —
            fuzzy search isn't part of the s2s contract.

            **Errors:**
            - `422 / 4002` — `q` shorter than the configured minimum.
            - `429 / 4008` — rate-limit hit, includes `retry_after`.
          query_params:
            - name: q
              type: string
              required: true
              description: Search pattern. Substring match against display_name or email. Min length governed by `APP__SECURITY__SEARCH__MIN_QUERY_LENGTH`.
            - name: page
              type: int
              default: "1"
              description: 1-based page index.
            - name: per_page
              type: int
              default: "20"
              description: Page size. Clamped to `APP__SECURITY__SEARCH__MAX_PER_PAGE`.
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "account_id": "c8555be3-17b0-44d9-87a6-57560281d4bf",
                  "display_name": "dddd",
                  "email": "cobaaja123@yopmail.com",
                  "status": "active",
                  "email_verified": true,
                  "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11"
                }
              ],
              "pagination": {
                "page": 1,
                "per_page": 20,
                "total": 1,
                "total_pages": 1
              }
            }

            `avatar_media_id` is `null` when the user has no avatar.
            Render via `https://media.ikavia.com/api/media/{id}` — same
            contract as `/me` and the s2s lookup endpoints.
    - id: accounts-s2s
      title: Service-to-service profile lookup
      description: |
        Endpoints peer microservices (wacca, chat, …) call to enrich
        their own response payloads with display data for users they
        don't own. Lives on the public HTTP surface so existing
        service-principal credentials (API key → JWT) just work; the
        route gate makes sure no human-issued JWT can reach it.

        **Who calls this.** Service principals only — `principal_type`
        in the access-token claims MUST be `service`. Human-issued
        JWTs are rejected with `4031` so a logged-in user can't
        enumerate other users' profiles via this path. Use `/me` for
        reading your own profile.

        **What credential do I need?** A `service_account` row (org-
        owned) with an API key — NOT a `system_account` (those are
        only for the internal gRPC `InternalGrantService`). Send the
        key via `X-API-Key`, or swap it for a JWT once an hour via
        `/auth/token-exchange` and send `Authorization: Bearer …`.
        See the "For sibling services" card on the landing page for
        the full integration matrix.

        **What's returned.** Only the fields peer services have a
        legitimate UI need for: `id`, `display_name`, `email`,
        `avatar_media_id`, `phone_number`. Fields that belong to the
        account owner (`has_password`, `must_change_password`,
        `bio`, `metadata`, …) are intentionally omitted. Adding a
        field here is non-breaking; removing one is breaking — see
        `account-service-is-the-hub` for the contract discipline.

        **N+1 avoidance.** Use the bulk variant when enriching a
        list response — the single-id endpoint will work, but
        `bulk-lookup` is one DB roundtrip regardless of input size
        (capped at 100 ids/request).
      endpoints:
        - name: Lookup single account
          method: GET
          path: /api/v1/accounts/{id}
          auth: JWT Bearer
          description: |
            Returns the public profile of the account with the given
            UUID. `404` when the account doesn't exist or has been
            soft-deleted; `403` when the caller is a human principal
            (use `/api/v1/me` instead); `422` when `{id}` isn't a
            UUID.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "550e8400-e29b-41d4-a716-446655440000",
                "display_name": "Rian Saputra",
                "email": "rian@example.com",
                "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
                "phone_number": "+62812..."
              }
            }
        - name: Bulk lookup accounts
          method: POST
          path: /api/v1/accounts/bulk-lookup
          auth: JWT Bearer
          description: |
            Batch variant. Send up to **100 UUIDs** in one request
            and get back the matching profiles in a single DB
            round-trip. Use this when enriching a list response —
            looping the single-id endpoint would N+1 the DB.

            **Behaviour:**
            - Empty `ids` array → 200 with `items: []` (no DB hit).
            - Duplicates in `ids` are de-duplicated; each account
              appears at most once in `items`.
            - IDs with no matching account are silently omitted —
              the consumer can diff against their request to detect
              gaps.
            - Output order is **not** guaranteed to match input
              order. Build a `Map<id, profile>` on the consumer side
              and look up by id.

            **Errors:**
            - `403` — caller is a human principal.
            - `422` — `ids` has more than 100 entries, or any entry
              is not a UUID. The whole request is rejected; we do
              not return partial results for a bad payload, so the
              caller fixes their data instead of silently dropping
              an id.
          body:
            - name: ids
              type: array<uuid>
              required: true
              description: Up to 100 account UUIDs to enrich. Duplicates are de-duplicated.
          example_body: |
            {
              "ids": [
                "550e8400-e29b-41d4-a716-446655440000",
                "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
              ]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "550e8400-e29b-41d4-a716-446655440000",
                    "display_name": "Rian Saputra",
                    "email": "rian@example.com",
                    "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
                    "phone_number": "+62812..."
                  }
                ]
              }
            }
        - name: Transactional rollback delete (service-only)
          method: POST
          path: /api/v1/internal/accounts/{account_id}/hard-delete-recent
          auth: JWT Bearer (service principal) or X-API-Key
          description: |
            Lets a **service principal** undo a freshly-created account
            when its own outer transaction fails. Example flow: WACCA
            calls `POST /api/v1/auth/register` to provision a user, then
            its next onboarding step (e.g., creating a tenant row) fails
            — WACCA hits this endpoint to discard the account before the
            user discovers it exists.

            **Strict guardrails:**
            - **Service-principal only.** Human callers → **403**. (Platform
              admins use the existing 2-step `POST /api/v1/admin/accounts/
              {id}/hard-delete/prepare` flow.)
            - **Time-boxed:** only accounts younger than
              `APP__SECURITY__RECENT_DELETE_WINDOW_MINS` (default **3**)
              qualify. Older → **403** with the suggestion to use the
              admin flow. The window is enforced server-side from
              `accounts.created_at`.
            - **No reassignment.** Within 3 minutes an account is normally
              pristine; if it has already produced downstream state we
              won't silently undo (api_keys.created_by, invitations,
              service_accounts) the call returns **409** and the caller
              should NOT rollback — there's real downstream state.
            - **Personal-org sweep:** the target's personal org (where it
              is the lone owner) is deleted in the same transaction. A
              multi-member org owned by the target also returns **409**.

            **Idempotency.** Retrying on a target that no longer exists
            returns **404**; treat that as "already rolled back" on the
            caller side.

            **Cascade.** Account-side rows (sessions, audit_logs,
            memberships, password_reset_tokens, account_service_grants,
            api_keys[as resource]) cascade via the DB.

            **Audit log.** An entry with `action=DELETE`,
            `resource_type=ACCOUNT`, `actor_id=<calling service>`,
            `metadata.flow="transactional_rollback"` is written
            best-effort (the delete commits regardless).

            **Rate limit:** 10 / minute / IP (`sensitive` preset,
            fail-closed). 429 includes `retry_after`.

            **Errors:**
            - `403 / 4004` — caller is a human principal, OR account
              exceeds the window.
            - `404` — target does not exist (treat as already-deleted).
            - `409` — target owns a multi-member org, or has FK-blocking
              dependents (api_keys.created_by, invitations.invited_by,
              service_accounts.created_by_account_id) — rollback unsafe.
            - `429 / 4008` — rate-limit exceeded.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "rolledback@example.com",
                "deleted": true
              }
            }
    - id: auth
      title: Authentication
      description: |
        Endpoints under `/api/v1/auth`. Issue, rotate, revoke, and verify
        credentials for both human users and service accounts. All endpoints
        are rate-limited per IP (limits noted on each endpoint).
      endpoints:
        - name: Register account
          method: POST
          path: /api/v1/auth/register
          auth: none
          description: |
            Create a new human account. A personal organization is created in
            the same transaction with the registrant as owner. A verification
            email is queued (event `email.requested`). Auto-logs in: returns
            access + refresh tokens and sets the SSO refresh cookie.
            **Rate limit:** 3 / minute / IP.

            **Optional `platform` field (non-breaking).** Shapes the
            verification email's links:
            - omitted / any other value → web link only
              `https://account.ikavia.com/verify-email?token=…`
            - `"wacca-mobile"` → primary button is a `wacca://` deeplink
              (opens the mobile app) **and** the `https://` link is still
              shown below as a clickable fallback for desktop.

            Also accepts the spelling `flatform` (serde alias). Callers
            that omit it are unaffected.
          body:
            - name: email
              type: string
              required: true
              description: Valid email address. Must be unique across all accounts.
            - name: password
              type: string
              required: true
              description: Minimum 8 chars, must include upper, lower, digit, and special character.
            - name: display_name
              type: string
              required: true
              description: Free-form. Used for the personal organization name and greeting.
            - name: timezone
              type: string
              description: IANA timezone. Defaults to 'UTC'.
              example: Asia/Jakarta
            - name: language
              type: string
              description: ISO 639-1 code. Defaults to 'en'.
              example: id
            - name: platform
              type: string
              description: 'Caller platform. `wacca-mobile` ⇒ verification email gets a wacca:// deeplink button + https fallback. Alias: `flatform`.'
              example: wacca-mobile
          example_body: |
            {
              "email": "user@example.com",
              "password": "SecurePass123!",
              "display_name": "Jane Doe",
              "timezone": "Asia/Jakarta",
              "language": "id",
              "platform": "wacca-mobile"
            }
          example_response: |
            {
              "success": true,
              "code": 2010,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@example.com",
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
                "expires_in": 900,
                "personal_org": {
                  "org_id": "550e8400-e29b-41d4-a716-446655440001",
                  "name": "Jane Doe's Personal",
                  "slug": "jane-doe-personal-550e8400",
                  "status": "active",
                  "owner_account_id": "550e8400-e29b-41d4-a716-446655440000",
                  "plan": "free",
                  "created_at": 1735689600,
                  "updated_at": 1735689600,
                  "my_role": "owner",
                  "my_permissions": ["*"]
                }
              }
            }
        - name: Login
          method: POST
          path: /api/v1/auth/login
          auth: none
          description: |
            Authenticate with email + password. Returns access + refresh tokens
            and the user's full organization list. Sets refresh + CSRF cookies.
            Brute-force protection: per-IP and per-account counters with
            progressive delay and lockout.
            **Rate limit:** 5 / minute / IP.
          body:
            - name: email
              type: string
              required: true
              description: Account email.
            - name: password
              type: string
              required: true
              description: Account password.
          example_body: |
            {
              "email": "user@example.com",
              "password": "SecurePass123!"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
                "expires_in": 900,
                "organizations": [
                  { "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-...", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] }
                ],
                "current_org_id": "550e8400-e29b-41d4-a716-446655440001",
                "requires_email_verification": false,
                "csrf_token": "f3a1c0..."
              }
            }
        - name: Sign in with Google
          method: POST
          path: /api/v1/auth/google
          auth: none
          description: |
            Exchange a Google **ID token** (the `credential` returned by the
            Google Identity Services button or One Tap) for our own access +
            refresh JWT pair. Response shape and cookies are identical to
            `/auth/login`, so a client can treat the two flows interchangeably.

            **Verification.** The server validates the inbound JWT against
            Google's JWKS at `https://www.googleapis.com/oauth2/v3/certs`,
            checks `iss ∈ {accounts.google.com, https://accounts.google.com}`,
            `aud == <APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID>`, and `exp`. The
            JWKS document is cached in Redis with TTL derived from Google's
            `Cache-Control: max-age` (fallback 1 h); a `kid` miss triggers a
            one-shot refresh.

            **Account resolution.** Three branches, each surfaced to
            the client via the `action` field of the response so the FE
            can render the right copy without an extra round-trip:

            1. **By `sub`** — token's `sub` already linked to an account
               (subsequent logins of a previously-linked Google identity).
               Response carries `action: "logged_in"`.
            2. **By email** — `google_sub` is empty but a row exists with
               this email. The row is **auto-linked** (Google has verified
               the address, so we trust the email match). If the row was
               still Pending it is promoted to Active in the same write.
               Response carries `action: "linked"` — the FE typically
               toasts "We connected your Google account to your existing
               profile" so the user understands what changed.
            3. **Auto-signup** — no row matches `sub` or email. A new
               human account, personal organization, and the standard
               five system roles are created in one transaction. Email is
               marked verified at creation since Google already proved
               ownership. No password is set — the schema
               `chk_human_must_have_auth` is satisfied because
               `google_sub` is non-null. The user can add a password
               later via the change-password endpoint (which detects
               the NULL hash and skips the current-password check).
               Response carries `action: "registered"`.

            `created_now` is the legacy boolean form — equivalent to
            `action == "registered"` — kept for clients that integrated
            before `action` existed.

            **Errors:**
            - `503` — feature disabled (operator left
              `APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID` empty).
            - `401` — token signature invalid, wrong audience, expired,
              or `email_verified: false` from Google.
            - `409` — account already linked to a DIFFERENT Google `sub`.

            **Rate limit:** 5 / minute / IP (same bucket as `/auth/login`).
          body:
            - name: credential
              type: string
              required: true
              description: JWT issued by Google Identity Services (the `credential` field of the GIS `CredentialResponse` callback).
            - name: persist_session
              type: bool
              description: Controls refresh-cookie persistence. `true` (default) = 30 d Max-Age, `false` = session-only. Cosmetic for mobile clients (they read tokens from the response body, not the cookie).
            - name: device_timezone
              type: string
              description: IANA timezone applied **only on auto-signup** (when this request triggers brand-new account creation). Length cap 64 chars; invalid values silently fall back to UTC. Mobile clients should send the device's current timezone; web normally omits.
            - name: device_language
              type: string
              description: ISO 639-1 code applied **only on auto-signup**. Length cap 8 chars; invalid values fall back to `en`.
          example_body: |
            {
              "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y2...",
              "device_timezone": "Asia/Jakarta",
              "device_language": "id"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@gmail.com",
                "display_name": "Jane Doe",
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
                "expires_in": 7200,
                "organizations": [
                  { "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] }
                ],
                "current_org_id": "550e8400-e29b-41d4-a716-446655440001",
                "requires_email_verification": false,
                "created_now": true,
                "action": "registered",
                "csrf_token": "f3a1c0..."
              }
            }
        - name: Sign in with Firebase
          method: POST
          path: /api/v1/auth/firebase
          auth: none
          description: |
            Exchange a **Firebase Authentication ID token** for our own
            access + refresh JWT pair. Mobile clients that already use
            Firebase Auth on the device (for Firestore rules, Functions,
            Crashlytics user binding, Remote Config personalisation,
            etc.) can send the Firebase-issued token here directly
            rather than juggling a separate raw Google token.

            **Verification.** The server validates the inbound JWT
            against Firebase's JWKS at
            `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com`,
            checks
            `iss == https://securetoken.google.com/<APP__SECURITY__FIREBASE_AUTH__PROJECT_ID>`,
            `aud == <APP__SECURITY__FIREBASE_AUTH__PROJECT_ID>`, and
            `exp`. The JWKS is cached in Redis under
            `auth:firebase:jwks` with TTL derived from Google's
            `Cache-Control` header.

            **Account resolution.** Three branches, mirroring
            `/auth/google`. Each branch surfaces an `action` field in
            the response so the client can tell what actually
            happened:

            1. **By `firebase_uid`** — token's `sub` already linked to
               an account. Fast path for returning users. Response
               carries `action: "logged_in"`.
            2. **By email** — auto-link an existing row (created via
               `/auth/register` or `/auth/google` or password) when
               the Firebase token's verified email matches. Promotes
               a Pending row to Active in the same write. Response
               carries `action: "linked"`.
            3. **Auto-signup** — fresh account + personal organization
               + 5 system roles. Response carries `action: "registered"`.

            `created_now` is the legacy boolean form — equivalent to
            `action == "registered"` — kept for clients that integrated
            before `action` existed.

            **Email required.** Firebase Anonymous / custom-token
            sign-ins (no email) are rejected with 4003. If the mobile
            app needs anonymous flows, those will get a dedicated
            endpoint later.

            **Errors:**
            - `503` — feature disabled (operator left
              `APP__SECURITY__FIREBASE_AUTH__PROJECT_ID` empty).
            - `401` — token signature invalid, wrong audience, wrong
              issuer, expired, no email, or `email_verified: false`.
            - `409` — account already linked to a DIFFERENT Firebase
              UID.

            **Rate limit:** 5 / minute / IP (same bucket as
            `/auth/login` and `/auth/google`).
          body:
            - name: credential
              type: string
              required: true
              description: Firebase ID token (raw JWT). Typically obtained on the client via `firebase.auth().currentUser.getIdToken()` (Android/Web) or `Auth.auth().currentUser.getIDToken(...)` (iOS).
            - name: persist_session
              type: bool
              description: Cosmetic on mobile (no cookies read). Send `true` (default) for consistency.
            - name: device_timezone
              type: string
              description: IANA timezone, applied only on auto-signup. Length cap 64; invalid → UTC.
            - name: device_language
              type: string
              description: ISO 639-1 code, applied only on auto-signup. Length cap 8; invalid → `en`.
          example_body: |
            {
              "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...",
              "device_timezone": "Asia/Jakarta",
              "device_language": "id"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@gmail.com",
                "display_name": "Jane Doe",
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
                "expires_in": 7200,
                "organizations": [
                  { "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] }
                ],
                "current_org_id": "550e8400-e29b-41d4-a716-446655440001",
                "requires_email_verification": false,
                "created_now": true,
                "action": "registered",
                "csrf_token": "f3a1c0..."
              }
            }
        - name: Force email verification
          method: POST
          path: /api/v1/admin/accounts/{account_id}/force-verify-email
          auth: JWT Bearer
          permission: '*'
          description: |
            Stamps `email_verified_at = NOW()` on the target account
            without requiring the user to click the verification link
            sent at registration. Use only when identity has been
            confirmed out-of-band — for example, a user mistyped their
            email at signup and the team has since corrected it; an
            internal employee changed laptops and lost access to the
            original mailbox; a partner can't receive mail from the
            verification domain.

            **Behavior:**
            - Platform-admin only (`*` permission).
            - Idempotent. Calling on an already-verified account
              returns `200` with `was_already_verified: true` and does
              not bump `updated_at`.
            - Rejects service accounts (no email) with `422`.
            - On change, emits an `AccountUpdated` event to the
              `account.events` RabbitMQ exchange so downstream
              consumers see the new verified state.

            **Errors:**
            - `403` — caller lacks `*`.
            - `404` — target account not found.
            - `422` — target has no email (service account).

            **Audit:** logged with `admin_id`, `target_id`, and the
            previous `email_verified_at` value (NULL on first
            verification).
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "alice@example.com",
                "email_verified": true,
                "was_already_verified": false
              }
            }
        - name: Admin reset user password (V2)
          method: POST
          path: /api/v1/admin/users/{user_id}/reset-password
          auth: JWT Bearer
          description: |
            Force-reset a user's password. Generates a temporary password
            per the deployment's policy, hashes it, persists with a TTL
            (login rejects the credential after expiry), and revokes every
            active session of the target. Audit-logged.

            **Authorization (strict per-org):**
            - **Platform-admin** (`*` permission): may reset any user.
              `X-Organization-Id` is optional and used only as the audit
              log's organization context.
            - **Org owner / admin**: MUST send `X-Organization-Id`; must
              be `owner` or `admin` in that org; the target must be a
              member of the SAME org. Cross-org → **403**.
            - Self-reset is refused (use the normal change-password flow
              or have another admin do it) so the audit log can't be
              silently rotated.

            **Delivery modes:**
            - `delivery: "return"` (default) — plaintext temp password is
              returned in the response **once**. The admin is expected to
              hand it to the user out-of-band. A subsequent reset issues a
              fresh password; the prior one is discarded.
            - `delivery: "email"` — account-service emails the user the
              temp password with instructions. Response omits the plaintext.

            **Policy** is configurable per deployment:
            `APP__SECURITY__TEMP_PASSWORD__LENGTH` (default 12) and
            `APP__SECURITY__TEMP_PASSWORD__TTL_HOURS` (default 72). The
            charset is fixed (upper+lower+digits+symbols, confusable
            characters removed).

            **Rate limit:** 10 / minute / IP (fail-closed). 429 includes
            `retry_after`.

            **Errors:**
            - `400 / 4002` — `delivery` not `return|email`,
              `X-Organization-Id` not a UUID.
            - `403 / 4004` — caller not owner/admin in target org, or
              target is not a member of target org, or admin attempted
              self-reset.
            - `404` — target user_id does not exist.
            - `422` — service account (no email/password) target,
              `X-Organization-Id` required for non-platform admin and
              absent.
            - `429 / 4008` — rate limit exceeded.

            The legacy platform-admin-only endpoint
            `POST /api/v1/admin/accounts/{account_id}/reset-password`
            remains in place unchanged (default behaviour preserved).

            **Headers:** `Authorization: Bearer <admin-jwt>`. Optional
            `X-Organization-Id: <uuid>` — REQUIRED for non-platform
            admins; optional (audit context only) for platform-admin.
            Sending it as anything but a UUID returns 422.
          body:
            - name: delivery
              type: string
              description: '`return` (plaintext in response) or `email` (account-service emails the user).'
              default: return
            - name: force_change_on_next_login
              type: boolean
              description: When `true` (default), user is forced into the change-password screen on first login. When `false`, they can keep using the temp until **`expires_at`** — the deployment-level TTL still applies in both modes. To issue a non-expiring password, change it yourself via `/me/password` after logging in with the temp.
              default: "true"
          example_body: |
            { "delivery": "return", "force_change_on_next_login": true }
          example_response: |
            {
              "success": true,
              "data": {
                "user_id": "550e8400-e29b-41d4-a716-446655440000",
                "temp_password": "Gk7#mPq2RwZ9",
                "expires_at": "2026-06-02T08:00:00Z",
                "must_change_on_next_login": true
              }
            }

            # For delivery=email, `temp_password` is null (it lives in the
            # user's inbox, not in this response).
        - name: Hard delete (prepare)
          method: POST
          path: /api/v1/admin/accounts/{account_id}/hard-delete/prepare
          auth: JWT Bearer
          permission: '*'
          description: |
            Step 1 of the two-step **hard delete** flow. Issues a short
            alphanumeric confirmation code scoped to `(admin_id,
            target_account_id)` and returns it to the admin UI. Code
            lives 5 minutes in Redis and is single-use.

            > **For service-driven rollback** (the calling service just
            > created an account and its outer transaction failed): use
            > [`POST /api/v1/internal/accounts/{account_id}/hard-delete-recent`](#accounts-s2s)
            > instead — it's service-principal-only, time-boxed by
            > `APP__SECURITY__RECENT_DELETE_WINDOW_MINS` (default 3 min),
            > and skips this two-step confirmation.

            The UI's destructive-action modal displays this code and
            forces the admin to retype it before the actual delete is
            submitted to `/hard-delete`. Pure friction-against-fat-
            finger protection; the real authorization is the `*`
            permission check.

            Pre-validates the target exists (404 if not) and that the
            admin is not targeting themselves (422). Each call
            replaces any previous code for the same `(admin, target)`
            pair — re-opening the modal generates a fresh code.

            **Rate limit:** general per-IP cap. No specific bucket.
          example_response: |
            {
              "success": true,
              "data": {
                "confirmation_code": "7RP6JB3K",
                "expires_in_secs": 300
              }
            }
        - name: Hard delete (execute)
          method: POST
          path: /api/v1/admin/accounts/{account_id}/hard-delete
          auth: JWT Bearer
          permission: '*'
          description: |
            Step 2 of the hard delete flow. Verifies the
            `confirmation_code` against the value stashed by
            `/prepare`, then **irreversibly** removes the account row
            plus everything tied to it.

            **Cascade rules:**
            - `audit_logs`, `sessions`, `api_keys` (where the row's
              `account_id` is the target), `account_service_grants`,
              `password_reset_tokens`, `organization_memberships`
              (target-side), `service_accounts` (target-side):
              removed automatically via existing `ON DELETE CASCADE`.
            - **Personal organization** (target is owner AND only
              active member): auto-deleted in the same transaction.
              Its memberships, roles, sessions and grants cascade
              via the FKs on `organizations.id`.
            - **Multi-member organization the target owns**: blocks
              the delete. Response is 409 with
              `blocking_organizations` listing each org's name, slug,
              and active member count. Admin must transfer ownership
              (or delete the org) before retrying.
            - **NOT-NULL FK columns without `ON DELETE`**
              (`api_keys.created_by`, `invitations.invited_by`,
              `service_accounts.created_by_account_id`): reassigned
              to the admin doing the delete so the rows stay valid.
              The audit-log entries covering the original creation
              events are gone, so use this knowing the
              "originally created by" attribution is lost.

            **Errors:**
            - `404` — target account not found (e.g. already deleted
              in a parallel admin session).
            - `422` — `validation_failed`: code mismatch, code
              expired, or admin tried to delete themselves.
            - `409` — `blocking_organizations` payload, see above.
            - `5xx` — DB transaction failure; nothing was committed.
          body:
            - name: confirmation_code
              type: string
              required: true
              description: The exact code returned by /prepare. Single-use; deleted from Redis on first match.
          example_body: |
            {
              "confirmation_code": "7RP6JB3K"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@example.com",
                "deleted_personal_org_ids": [
                  "49f18abc-64c1-40db-830a-b7d191c4232f"
                ]
              }
            }
        - name: Refresh tokens
          method: POST
          path: /api/v1/auth/refresh
          auth: none
          description: |
            Exchange a refresh token for a new access + refresh pair. The old
            refresh token's hash is rotated server-side; the old JTI is added
            to the revocation list once rotation is persisted. Replaying the
            old refresh token after this call kills the session (reuse
            detection). Refresh token may be in the body or in the
            `refresh_token` cookie (SSO flow).
          body:
            - name: refresh_token
              type: string
              description: Optional in body. Falls back to the `refresh_token` cookie when omitted.
          example_body: |
            { "refresh_token": "eyJhbGciOiJSUzI1NiIs..." }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "...",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "eyJhbGciOiJSUzI1NiIs... (new)",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs... (new)",
                "expires_in": 900,
                "organizations": [ ... ],
                "current_org_id": "...",
                "requires_email_verification": false,
                "csrf_token": "..."
              }
            }
        - name: Logout (current session)
          method: POST
          path: /api/v1/auth/logout
          auth: JWT Bearer
          description: |
            Revoke the current session. The access token's JTI is also added
            to the revocation list so a token stolen before logout stops
            validating immediately. Cookies are always cleared on the response,
            even if the server-side revoke fails. **Service principals cannot
            call this endpoint** — they have no session.
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Logout from all devices
          method: POST
          path: /api/v1/auth/logout-all
          auth: JWT Bearer
          description: |
            Revoke every active session for the calling account. Useful for
            "I lost my phone" or post-password-change cleanups. Cookies are
            rotated/cleared on the response.
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Resend verification email
          method: POST
          path: /api/v1/auth/resend-verification
          auth: none
          description: |
            Re-emit the verification email for an unverified account.
            The response is **deliberately indistinguishable** from the
            "email not found" case so a caller can't enumerate which
            addresses have an account — both return 200 with the same
            generic message. A verified account also returns 200 (and
            quietly does nothing).

            The FE surface for this endpoint is the **Verify-email
            banner** mounted in the dashboard layout
            (`components/layout/verify-email-banner.tsx`): a sticky
            yellow strip that shows for any user with
            `email_verified=false` and carries a "Resend email"
            button. Calling the endpoint manually is the path for
            unauthenticated callers (e.g. a "Didn't get the email?"
            link before sign-in).

            **Rate limit:** 3 / minute / IP.
          body:
            - name: email
              type: string
              required: true
              description: Email to resend verification to.
            - name: platform
              type: string
              description: Optional. `wacca-mobile` makes the resent email use a `wacca://` deeplink button (https link kept as fallback), matching register. Alias `flatform` accepted.
          example_body: |
            { "email": "user@example.com", "platform": "wacca-mobile" }
          example_response: |
            { "success": true, "code": 2000, "data": { "message": "If the email exists, a verification email has been sent" } }
        - name: Request password reset
          method: POST
          path: /api/v1/auth/password-reset
          auth: none
          description: |
            Initiate a password reset. Always returns success, even when the
            email is unknown, to prevent email enumeration. Generates a
            1-hour JWT and emits an `email.requested` event with the
            reset link.

            **Optional `platform` field (non-breaking).** Controls the
            scheme of the reset link in the emailed message:
            - omitted / any other value → web link
              `https://account.ikavia.com/reset-password?token=…`
            - `"wacca-mobile"` → custom-scheme deeplink
              `wacca://account.ikavia.com/reset-password?token=…` so the
              mobile OS opens the app instead of a browser.

            The field also accepts the spelling `flatform` (serde alias)
            so the mobile client can send either key. Existing callers
            that send only `email` are unaffected.
          body:
            - name: email
              type: string
              required: true
              description: Email of the account to reset.
            - name: platform
              type: string
              description: 'Caller platform. `wacca-mobile` ⇒ wacca:// deeplink reset URL. Alias: `flatform`. Omit for the normal web link.'
          example_body: |
            {
              "email": "user@example.com",
              "platform": "wacca-mobile"
            }
          example_response: |
            { "success": true, "data": null }
        - name: Confirm password reset
          method: POST
          path: /api/v1/auth/password-reset/confirm
          auth: none
          description: |
            Complete a password reset using the JWT from the email link.
            Validates the token (signature + jti not previously used), updates
            the password hash, and revokes all sessions for the account.
          body:
            - name: token
              type: string
              required: true
              description: Reset token from the email link (JWT).
            - name: new_password
              type: string
              required: true
              description: Same complexity rules as registration.
          example_body: |
            {
              "token": "eyJhbGciOiJSUzI1NiIs...",
              "new_password": "NewSecurePass456!"
            }
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Verify access token
          method: POST
          path: /api/v1/auth/verify
          auth: none
          description: |
            Introspect an access token (signature + exp + revocation). Used by
            downstream services that prefer a server-side check over local
            public-key verification. Returns the claims **as-is**: permissions
            come from the token, not re-derived from current state. Use
            `/auth/refresh` if you need fresh permissions.
            **Rate limit:** 100 / minute / IP.
          body:
            - name: token
              type: string
              required: true
              description: Access token to introspect.
          example_body: |
            { "token": "eyJhbGciOiJSUzI1NiIs..." }
          example_response: |
            {
              "success": true,
              "data": {
                "valid": true,
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "permissions": ["*"],
                "expires_at": 1735693200,
                "session_id": "550e8400-..."
              }
            }
        - name: Whoami
          method: GET
          path: /api/v1/auth/whoami
          auth: JWT Bearer
          description: |
            Return the principal identity carried by the request — exactly what
            the middleware extracted, with no extra DB lookups. Useful for SDKs
            and gateways debugging auth wiring.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "principal_type": "human",
                "session_id": "550e8400-...",
                "current_org_id": "550e8400-...",
                "organizations": [ { "id": "550e8400-...", "role": "owner" } ],
                "permissions": ["*"]
              }
            }
        - name: Get public key (RS256)
          method: GET
          path: /api/v1/auth/public-key
          auth: none
          description: |
            Return the RS256 public key (PEM) for local JWT verification by
            downstream services. Cache this for the lifetime of `expires_at`
            (default 90 days).

            **Key rotation strategy:**
            - Each issued JWT carries a `kid` claim in its header naming the
              key that signed it (e.g. `key-2026-03-01-v1`).
            - Verifiers should cache by `kid`. On a cache miss (unfamiliar
              `kid`), re-fetch this endpoint to learn the new key.
            - During rotation, account-service serves the **new** key here
              but continues to **accept** tokens signed by the previous key
              until those tokens naturally exp. Verifiers should retain the
              previous key entry until its tokens have aged out.
            - `expires_at` is the rough cache horizon — refresh before that
              even without a `kid` mismatch to pick up scheduled rotations.
          example_response: |
            {
              "success": true,
              "data": {
                "key_id": "key-2026-03-01-v1",
                "algorithm": "RS256",
                "public_key": "-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----\n",
                "expires_at": 1743465600
              }
            }
        - name: Exchange API key for JWT
          method: POST
          path: /api/v1/auth/token-exchange
          auth: API Key
          description: |
            Validate the supplied `X-API-Key` header and return a 1-hour
            service JWT (`principal_type: service`). Lets a service principal
            authenticate downstream calls with a verifiable JWT instead of
            re-presenting the API key on every hop. The JWT carries the
            service account's capabilities as `permissions` and `nil` as `sid`.
          example_response: |
            {
              "success": true,
              "data": {
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "token_type": "Bearer",
                "expires_in": 3600,
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "permissions": ["museum:read", "artifact:write"],
                "principal_type": "service"
              }
            }
        - name: Exchange System-account credentials for JWT
          method: POST
          path: /api/v1/auth/system-token
          auth: Basic
          description: |
            Network-reachable counterpart to the gRPC grant-management flow.
            A **System account** (basic-auth credential issued by a platform
            admin for a headless peer backend — distinct from an org-owned
            service account) swaps its credentials for a short-lived RS256
            service JWT (`principal_type: service`).

            **Pick which token-mint endpoint based on the caller principal:**

            | Caller is …                                    | Use                          | Auth                                  |
            | ---------------------------------------------- | ---------------------------- | ------------------------------------- |
            | An **org-owned service account** with an API key | [`/auth/token-exchange`](#auth) | `X-API-Key: <api-key>` header         |
            | A **platform System account** (no org)         | `/auth/system-token` (this)  | `Authorization: Basic name:password`  |

            Both endpoints return the same JWT shape — same issuer, `kid`,
            RS256 key, `principal_type: service`. Peer services verify
            either token through the same JWKS / `/auth/public-key` path
            without any code change. The difference is **who's calling**
            and **how their credentials are managed**: service accounts
            are managed inside an organization, System accounts are
            managed by the platform admin under
            [`/admin/system-accounts`](#system-accounts-admin).

            The token is byte-for-byte compatible with every other token this
            service mints (same issuer, `kid`, RS256 key), so peer services
            (e.g. Media Service) verify it through their existing public-key /
            JWKS path with **no change** to their validation.

            **Permissions are admin-granted, never caller-chosen.** The token's
            `permissions` come solely from the System account's
            `token_permissions` grant (set via the admin
            `POST /api/v1/admin/system-accounts/{id}/permissions` endpoint),
            e.g. `["media:upload"]`. The request body carries no permission
            input. `sub` is the System account id; `org_id` is absent (System
            accounts have no organization); `sid` is `nil`.

            TTL follows the access-token policy
            (`APP__SECURITY__JWT__ACCESS_TOKEN_TTL_MINS`, default 15 min).

            **Auth:** `Authorization: Basic base64(name:password)`.
            `401` for missing/invalid credentials (neutral body — never reveals
            whether a name exists); `403` once the password is correct but the
            System account is revoked.
          example_body: |
            # Basic auth — the System account name + password issued by the
            # platform admin. The permission set baked into the token is the
            # admin-granted `token_permissions`, NOT anything sent here.
            curl -X POST 'http://localhost:8874/api/v1/auth/system-token' \
              -u 'recruitment-backend:<system-account-password>' \
              -H 'Accept: application/json'
          example_response: |
            {
              "success": true,
              "data": {
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "token_type": "Bearer",
                "expires_in": 900
              }
            }
    - id: internal-grpc
      title: Internal gRPC (service-to-service)
      description: |
        Loopback-only gRPC surface for sibling services (wacca, chat, …) to
        manage their own service-grant entitlements on accounts.

        **Reachability.** The gRPC server binds to the address configured in
        `APP__GRPC__ADDRESS` (default `127.0.0.1:50051`). It is *not* exposed
        through the public ingress or any reverse proxy. Other services on
        the same host reach it over loopback; services on other hosts must
        tunnel (mTLS / Wireguard / Tailscale) — there is no plan to expose
        it publicly.

        **Authentication.** Each caller authenticates with HTTP Basic in the
        gRPC `authorization` metadata field. The username MUST match a
        row in the `system_accounts` table (status='active'); the
        password is verified against the argon2 hash stored in that row.
        System accounts are created and managed by a platform-admin
        via `/api/v1/admin/system-accounts` (UI:
        `/dashboard/admin-system-accounts`) — they are NOT the same as
        `service_accounts`, which are org-owned machine-users with API
        keys.

        **Caching.** The interceptor caches each row in Redis under
        `system_account:{name}` with a 5-minute TTL. Mutations
        (create / rotate / revoke) invalidate the same key, so the
        next gRPC call observes the change within seconds. A stale
        cache window of up to 5 minutes is possible only when the
        cache invalidation step fails (logged as a warning).

        **Authorization.** Each configured caller carries a *prefix
        allowlist*. A call referencing service key `S` is rejected with
        `PERMISSION_DENIED` unless one of the caller's prefixes is a prefix
        of `S` (or an exact match). `wacca` configured with
        `["wacca-"]` may operate on `wacca-tenant` and `wacca-admin` but
        not on `chat-room` or `platform_admin`.

        **Audit.** Every grant / revoke writes a structured log line
        tagged with the caller name, account, service, and org_id. There
        is no separate audit-log row today — follow the access log if
        you need historical attribution.
      endpoints:
        - name: GrantService
          method: GRPC
          path: account.v1.internal.InternalGrantService/GrantService
          auth: Basic (internal-callers registry)
          description: |
            Issue a service-grant for `account_id`. Idempotent — calling
            with the same `(account_id, service, organization_id)` tuple
            returns `granted_now=false` and the existing `granted_at`.

            **Scope.**
            - Empty `organization_id` ⇒ **global** grant. Visible in the
              holder's JWT in every org context.
            - Non-empty `organization_id` ⇒ **org-scoped** grant. Only
              contributes to the JWT when the holder's current org
              matches.

            **JWT emission.** Granting a service whose key is not in the
            built-in list (`email`, `planner`, `survey`, `media`,
            `platform_admin`) results in `{service}:*` being added to
            the holder's JWT permissions array on next token issuance.
            Granting `wacca-tenant` produces `wacca-tenant:*`.

            **Errors:**
            - `UNAUTHENTICATED` — no `authorization` metadata, wrong
              scheme, unknown user, or wrong password.
            - `PERMISSION_DENIED` — `service` not covered by the
              caller's prefix allowlist.
            - `INVALID_ARGUMENT` — `account_id` / `organization_id` not
              a UUID, or `service` empty.
          example_body: |
            # Pseudocode — adapt to your gRPC client library
            metadata.authorization = "Basic " + base64("wacca:<password>")
            GrantServiceRequest {
              account_id: "550e8400-e29b-41d4-a716-446655440000",
              service: "wacca-tenant",
              organization_id: ""  // global
            }
          example_response: |
            GrantServiceResponse {
              granted_now: true,
              granted_at: "2026-05-21T10:50:00Z"
            }
        - name: RevokeService
          method: GRPC
          path: account.v1.internal.InternalGrantService/RevokeService
          auth: Basic (internal-callers registry)
          description: |
            Revoke a previously-issued grant. Idempotent — calling on a
            tuple that doesn't exist returns `revoked=false`. The scope
            (`organization_id` empty vs. non-empty) must match the row
            to be revoked; revoking a global grant with an
            `organization_id` set is a no-op.

            **Errors:** Same set as `GrantService`. Notably, a caller
            can only revoke what they could grant — revoking
            `platform_admin` from wacca's credential is
            `PERMISSION_DENIED`, not silent success.
          example_body: |
            RevokeServiceRequest {
              account_id: "550e8400-...",
              service: "wacca-tenant",
              organization_id: ""
            }
          example_response: |
            RevokeServiceResponse { revoked: true }
        - name: ListGrantsForAccount
          method: GRPC
          path: account.v1.internal.InternalGrantService/ListGrantsForAccount
          auth: Basic (internal-callers registry)
          description: |
            Enumerate the grants `account_id` holds **within the
            caller's allowlist**. Grants outside the allowlist (other
            services' keys, `platform_admin`) are filtered out — wacca
            cannot use this to map who else has what.

            `service_prefix` further narrows the result. If non-empty,
            the prefix must itself be allowed for the caller, otherwise
            `PERMISSION_DENIED`.
          example_body: |
            ListGrantsForAccountRequest {
              account_id: "550e8400-...",
              service_prefix: "wacca-"
            }
          example_response: |
            ListGrantsForAccountResponse {
              grants: [
                { service: "wacca-tenant", organization_id: "", granted_at: "2026-05-21T10:50:00Z" }
              ]
            }
    - id: internal-account-grpc
      title: Internal gRPC — account directory (read-only)
      description: |
        Read-only companion to [InternalGrantService](#internal-grpc).
        Same trust model — every RPC begins with the same Basic auth
        against the `system_accounts` table — but **no** prefix-allowlist
        gate: these are directory lookups, not entitlement mutations, so
        resolving the caller's `system_account` row is the trust boundary.

        Same loopback reachability rule as the other internal service
        (binds to `APP__GRPC__ADDRESS`, default `127.0.0.1:50051`, not
        exposed via ingress).

        **Implemented today:** `GetAccount`, `AccountExists`,
        `EmailIsVerified`, `LookupAccount`, `BulkLookupAccounts`,
        `LookupAccountsByEmail`.
        **Returns `UNIMPLEMENTED` for now:** `GetOrganization`,
        `ListOrgServiceAccounts` — the handler is mounted (auth still
        runs, so unauthenticated callers see `UNAUTHENTICATED` first),
        but the use cases backing them haven't landed yet. Don't depend
        on them; the next iteration will fill them in.

        ## When to use which read

        | Need                                     | Use                                  |
        | ---------------------------------------- | ------------------------------------ |
        | Full account row (status, created_at, …) | `GetAccount`                         |
        | Just "does this id resolve?"             | `AccountExists`                      |
        | Just "is this account's email verified?" | `EmailIsVerified`                    |
        | Display fields for ONE id (slim)         | `LookupAccount`                      |
        | Batch hydrate by id (e.g. timeline feed) | `BulkLookupAccounts` (≤ batch cap)   |
        | Resolve by email (login-with-email flow) | `LookupAccountsByEmail` (≤ batch cap)|

        The slim resolvers (`Lookup*`) return the same
        `{id, display_name, email, avatar_media_id, phone_number}`
        projection — same shape as REST `PublicProfileDto` on
        `GET /accounts/{id}`. Soft-deleted / deactivated accounts are
        silently omitted from results (or `NOT_FOUND` on the single
        lookup) — they cannot be resolved through this surface, by
        design.

        Server-side caps + per-caller rate-limit buckets:

        | Setting                                                       | Default | Notes                                |
        | ------------------------------------------------------------- | ------- | ------------------------------------ |
        | `APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_IDS`               | 100     | `BulkLookupAccounts.account_ids` cap |
        | `APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_EMAILS`            | 50      | `LookupAccountsByEmail.emails` cap   |
        | `APP__SECURITY__INTERNAL_LOOKUP__RATE_LIMIT_PER_MIN_IDS`      | 600     | Per system-account, id resolvers     |
        | `APP__SECURITY__INTERNAL_LOOKUP__RATE_LIMIT_PER_MIN_EMAILS`   | 120     | Per system-account, email resolver   |
        | `APP__SECURITY__INTERNAL_LOOKUP__RATE_LIMIT_WINDOW_SECS`      | 60      | Bucket window for both               |

        Going over a batch cap → `INVALID_ARGUMENT`. Going over a
        rate-limit bucket → `RESOURCE_EXHAUSTED` with a `retry in Ns`
        hint in the message. Buckets are keyed by the caller's
        system-account NAME, not IP — multiple callers share the
        bucket only if they share credentials (don't).

        **Effective account-resolutions per minute = `BATCH_CAP × RATE_LIMIT_PER_MIN`.**
        At defaults that's **60,000/min** for ids
        (`100 × 600`) and **6,000/min** for emails
        (`50 × 120`) per credential. Size your deployment with the
        effective number in mind, not the per-RPC number — a single
        caller running parallel `BulkLookupAccounts` calls at the
        rate-limit ceiling is doing 1,000 account-resolutions per
        second against the repo.

        ## Auth posture

        This is a **service-principal-only** surface. The trust gate
        is Basic auth against the `system_accounts` registry — there
        is no JWT acceptance path on these RPCs. The practical
        consequence is that **human callers receive `UNAUTHENTICATED`
        (gRPC status 16), not `PERMISSION_DENIED` (status 7)**: humans
        cannot obtain a `system_accounts` credential, so they fail at
        the auth gate before any principal-type check could run.
        The REST companion (`GET /accounts/{id}` etc.) returns 403 in
        that case because a human CAN authenticate with a JWT and
        then get principal-typed out; the gRPC trust model is
        simpler — Basic or nothing.

        Outcome for the caller is the same — rejection — only the
        status code differs. If you build a generic adapter that
        bridges REST `403` to gRPC, map `UNAUTHENTICATED` on these
        RPCs to whatever your downstream interprets as "not a
        service principal", not to "credentials invalid".
      endpoints:
        - name: GetAccount
          method: GRPC
          path: account.v1.internal.InternalAccountService/GetAccount
          auth: Basic (system_accounts registry)
          description: |
            Look up an account by id. Returns the public-mirror fields
            a sibling service usually wants for display / authorization:
            `account_id`, `account_type` (`"human" | "service"`),
            `display_name`, `email` (empty string for service accounts
            with no email), `status`, `created_at` (unix seconds).

            A soft-deleted row (`deleted_at IS NOT NULL`) answers
            `NOT_FOUND` — the row is logically gone for caller purposes.

            **Errors:**
            - `UNAUTHENTICATED` — no `authorization` metadata, wrong
              scheme, unknown user, or wrong password.
            - `INVALID_ARGUMENT` — `account_id` is not a UUID.
            - `NOT_FOUND` — no live row matches the id.
          example_body: |
            # Pseudocode — adapt to your gRPC client library
            metadata.authorization = "Basic " + base64("wacca:<password>")
            GetAccountRequest {
              account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
            }
          example_response: |
            GetAccountResponse {
              account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
              account_type: "human",
              display_name: "Jane Doe",
              email: "jane@example.com",
              status: "active",
              created_at: 1735689600
            }
        - name: AccountExists
          method: GRPC
          path: account.v1.internal.InternalAccountService/AccountExists
          auth: Basic (system_accounts registry)
          description: |
            Cheap existence probe. Use this when the caller only needs to
            confirm an `account_id` resolves (e.g., precondition before
            storing a foreign key) and doesn't need the rest of the
            account row. Same lookup semantics as `GetAccount`: the
            underlying repo filters `WHERE deleted_at IS NULL`, so
            **both soft-deleted AND deactivated** rows answer
            `exists=false` (deactivate sets `deleted_at` alongside
            `status='deactivated'`, so the two states are colinear
            in practice — there is no reachable row with
            `deleted_at IS NULL AND status='deactivated'`).

            `status` carries the account's status string only when
            `exists=true` (empty string otherwise). In practice the
            values you'll see are `"active"` or `"pending"` — every
            row that's reached `deactivated` is also soft-deleted,
            and the probe returns `false` before it can surface that
            status.

            **Errors:**
            - `UNAUTHENTICATED` — see `GetAccount`.
            - `INVALID_ARGUMENT` — `account_id` is not a UUID.
          example_body: |
            AccountExistsRequest {
              account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
            }
          example_response: |
            AccountExistsResponse {
              exists: true,
              status: "active"
            }
        - name: EmailIsVerified
          method: GRPC
          path: account.v1.internal.InternalAccountService/EmailIsVerified
          auth: Basic (system_accounts registry)
          description: |
            Cheap probe: is the account's email verified?

            Use when a sibling service needs to gate on "email-
            verified user" before issuing some capability (sending a
            notification to the address, marking a profile public,
            allowing high-trust action) and wants a single boolean
            answer without fetching the full profile.

            Response shape — two booleans:

            - `verified` — `true` iff the account exists, is live
              (not soft-deleted), AND its email is verified. False
              covers four cases at once: unknown id, soft-deleted
              row, account has no email (some service accounts),
              email not yet verified. For most callers a single
              check on `verified` is sufficient: all four cases mean
              "do not trust this email".
            - `account_exists` — `true` iff a live row matches
              `account_id`, regardless of verification state. Lets a
              caller distinguish "no such user" from "user exists
              but unverified" when that matters (e.g. distinct
              error message in a UI).

            Same `WHERE deleted_at IS NULL` filter as the other
            reads on this service — soft-deleted / deactivated rows
            resolve to `verified=false, account_exists=false`.

            **Errors:**
            - `UNAUTHENTICATED` — see `GetAccount`.
            - `INVALID_ARGUMENT` — `account_id` is not a UUID.
          example_body: |
            EmailIsVerifiedRequest {
              account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
            }
          example_response: |
            EmailIsVerifiedResponse {
              verified: true,
              account_exists: true
            }
        - name: LookupAccount
          method: GRPC
          path: account.v1.internal.InternalAccountService/LookupAccount
          auth: Basic (system_accounts registry)
          description: |
            Slim single-account resolver. Same lookup semantics as
            `GetAccount` (soft-deleted / deactivated → `NOT_FOUND`),
            but returns only the public-display projection:
            `{id, display_name, email, avatar_media_id, phone_number}`.

            Use this for non-hot resolve paths — replica backfill on
            cache miss, daily reconciliation. Don't reach for
            `GetAccount` if you only need display fields; the slim
            projection is the stable replica contract.

            Empty string conventions on proto3:
            - `email` = `""` when the row has no email (some service
              accounts) — treat as absent, not literal.
            - `avatar_media_id` = `""` when unset.
            - `phone_number` = `""` when unset.

            **Errors:**
            - `UNAUTHENTICATED` — see `GetAccount`.
            - `INVALID_ARGUMENT` — `account_id` is not a UUID.
            - `NOT_FOUND` — no live row matches the id.
            - `RESOURCE_EXHAUSTED` — id-resolver rate-limit tripped.
          example_body: |
            LookupAccountRequest {
              account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
            }
          example_response: |
            LookupAccountResponse {
              account: {
                id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
                display_name: "Jane Doe",
                email: "jane@example.com",
                avatar_media_id: "a8f3c2d1-...",
                phone_number: "+628111234567"
              }
            }
        - name: BulkLookupAccounts
          method: GRPC
          path: account.v1.internal.InternalAccountService/BulkLookupAccounts
          auth: Basic (system_accounts registry)
          description: |
            Batch resolve by account id. Server caps the slice length
            at `APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_IDS`
            (default 100); going over → `INVALID_ARGUMENT`. The server
            also dedups the id slice before hitting the repo, so
            repeated ids in the request don't double-charge the
            backend or appear twice in the response.

            Behaviour matches REST `POST /accounts/bulk-lookup`:

            - Missing ids (no live row) are silently OMITTED — diff
              against the request slice to detect gaps.
            - Order of `accounts` is NOT meaningful.
            - Soft-deleted / deactivated rows are NOT returned.

            **Errors:**
            - `UNAUTHENTICATED` — see `GetAccount`.
            - `INVALID_ARGUMENT` — any element fails UUID parse, OR
              slice length > the configured cap.
            - `RESOURCE_EXHAUSTED` — id-resolver rate-limit tripped.
          example_body: |
            BulkLookupAccountsRequest {
              account_ids: [
                "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
                "ff000000-0000-0000-0000-000000000000",
                "1c37ff2c-2f42-4d86-94d3-a20163909ccf"
              ]
            }
          example_response: |
            # Two of three resolved; the unknown id (ff…) is omitted.
            BulkLookupAccountsResponse {
              accounts: [
                { id: "0a2f...", display_name: "Jane Doe",   email: "jane@example.com",  avatar_media_id: "...", phone_number: "..." },
                { id: "1c37...", display_name: "John Smith", email: "john@example.com",  avatar_media_id: "",    phone_number: ""     }
              ]
            }
        - name: LookupAccountsByEmail
          method: GRPC
          path: account.v1.internal.InternalAccountService/LookupAccountsByEmail
          auth: Basic (system_accounts registry)
          description: |
            Batch resolve by email. The email VO stores canonicalized
            lowercase, so matching is **exact case-insensitive** — no
            fuzzy, no substring, no domain wildcards.

            Tighter batch cap and tighter rate-limit bucket than the
            id resolver because email lookup is a higher enumeration
            risk surface. Defaults:
            - Batch cap 50 (`APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_EMAILS`).
            - Rate limit 120/min/system-account
              (`__RATE_LIMIT_PER_MIN_EMAILS`).

            The server normalises every input (trim + lowercase) and
            dedups before hitting the repo. Empty-after-trim entries
            are dropped silently. A malformed address in the slice
            rejects the WHOLE batch (caller bug — chunk + retry won't
            recover; fix the input).

            Missing emails (no live row) are silently omitted — same
            pattern as the id resolver. Diff against the input slice.

            Soft-deleted / deactivated rows are NOT returned. The
            surface is service-principal only — this is NOT a
            public email-existence oracle.

            **Errors:**
            - `UNAUTHENTICATED` — see `GetAccount`.
            - `INVALID_ARGUMENT` — any element fails email parse, OR
              slice length > the configured cap.
            - `RESOURCE_EXHAUSTED` — email-resolver rate-limit tripped.
          example_body: |
            LookupAccountsByEmailRequest {
              emails: [
                "Jane@Example.COM",
                "  john@example.com  ",
                "noone@example.com"
              ]
            }
          example_response: |
            # Two of three resolved; case + whitespace handled
            # server-side. "noone@example.com" silently omitted.
            LookupAccountsByEmailResponse {
              accounts: [
                { id: "0a2f...", display_name: "Jane Doe",   email: "jane@example.com",  avatar_media_id: "...", phone_number: "..." },
                { id: "1c37...", display_name: "John Smith", email: "john@example.com",  avatar_media_id: "",    phone_number: ""     }
              ]
            }
    - id: internal-auth-grpc
      title: Internal gRPC — auth (verify password)
      description: |
        Sibling-service step-up auth. A service that already has the
        user's `account_id` (from a JWT subject, for example) and a
        freshly-typed password can verify the credentials without
        running a full login. No new session is minted, no tokens
        rotate — this RPC is a yes/no answer.

        Trust model: identical to the other internal services. Basic
        auth against `system_accounts`. No prefix-allowlist gate; the
        caller already holds a valid system-account credential.

        **Safety:** failed `VerifyPassword`s share the same Redis
        brute-force bucket as failed logins on the same `account_id`,
        so this endpoint cannot be used as a brute-force bypass — once
        the threshold trips, both login AND verify reject with
        `RESOURCE_EXHAUSTED` until cooldown. Every attempt (success or
        fail) writes an audit row tagged
        `metadata.flow="internal_verify_password"` with the calling
        system-account name.

        **Implemented today:** `VerifyPassword`.
        **Returns `UNIMPLEMENTED`:** `ValidateToken`, `ValidateAPIKey`
        — backing use cases haven't landed yet; auth gate still runs.
      endpoints:
        - name: VerifyPassword
          method: GRPC
          path: account.v1.internal.InternalAuthService/VerifyPassword
          auth: Basic (system_accounts registry)
          description: |
            Verify a user's plaintext password against the stored
            argon2 hash. Account is identified by **UUID only** (no
            email lookup — keeps the RPC from doubling as an email
            enumeration probe).

            Response shape:
            - `valid=true` — argon2 matched. `account_status` carries
              the row's status string ("active" / "pending" /
              "suspended"), so the caller can decide whether to
              proceed (e.g., refuse step-up auth on a "suspended"
              account even though the password was right).
            - `valid=false` — password didn't match, OR the account
              is soft-deleted, OR the account has no password set
              (SSO-only). `account_status` is empty so a caller can't
              probe state by toggling the password input.

            Soft-deleted / unknown accounts short-circuit **before**
            argon2 runs, so the response time can't be used to probe
            state-of-existence via timing.

            **Errors:**
            - `UNAUTHENTICATED` — missing / invalid system-account
              Basic credential.
            - `INVALID_ARGUMENT` — `account_id` not a UUID, or
              `password` empty.
            - `RESOURCE_EXHAUSTED` — the shared brute-force counter
              for this `account_id` has tripped; retry after the
              cooldown encoded in the status message.
          example_body: |
            metadata.authorization = "Basic " + base64("wacca:<password>")
            VerifyPasswordRequest {
              account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
              password: "the-user-typed-this"
            }
          example_response: |
            VerifyPasswordResponse {
              valid: true,
              account_status: "active"
            }
    - id: system-accounts-admin
      title: System accounts (admin)
      description: |
        Superadmin HTTP endpoints for managing the basic-auth
        credentials consumed by [InternalGrantService](#internal-grpc)
        AND by the network-reachable bearer-token endpoint
        [`/auth/system-token`](#auth). All endpoints require the
        `platform_admin` service grant (`*` permission) and live under
        `/api/v1/admin/system-accounts`.

        ## Two scopes on every system account

        A system account carries **two distinct authorization scopes**
        — they are independent and admin-controlled separately:

        | Field               | Used by                            | Semantics                                                       |
        | ------------------- | ---------------------------------- | --------------------------------------------------------------- |
        | `allowed_prefixes`  | gRPC grant-management RPCs         | Which service-key prefixes the caller may grant / revoke / list |
        | `token_permissions` | `POST /auth/system-token` token mint | Permission set embedded into the issued RS256 bearer token      |

        `token_permissions` is empty by default — an account with no
        grant mints a token carrying zero permissions (which every
        downstream service will reject for protected actions). Grant
        explicitly via Create body or the dedicated permissions
        endpoint below.

        ## UI

        A platform-admin sees these surface in the sidebar at
        `/dashboard/admin-system-accounts`. The flow there is:

        - **Create**: superadmin inputs name + allowed prefixes +
          (optional) token permissions, the BE generates a 32-character
          password and returns it once in the response. Modal shows
          the plaintext with a copy button; once dismissed it cannot
          be recovered.
        - **Rotate**: same modal flow with a fresh password. The
          old password stops working as soon as the Redis cache
          invalidates (effectively instant; up to 5 min in the
          unlikely event the DEL command fails).
        - **Update permissions**: replace `token_permissions` on an
          active row without touching the password. Affected tokens
          already in flight keep their old permissions until they
          expire (≤ 15 min); the next token mint picks up the new
          grant.
        - **Revoke**: flips status to `revoked`. The row stays in
          the DB for audit; create a fresh row to re-enable. Revoked
          rows cannot be re-permissioned — rotate or recreate.
      endpoints:
        - name: List system accounts
          method: GET
          path: /api/v1/admin/system-accounts
          auth: JWT Bearer
          permission: '*'
          description: |
            All rows ordered by `created_at` desc, including
            revoked rows (greyed out in the UI). Hash and prefix
            allowlist are returned because the admin who sees this
            page already has full platform access; not exposing the
            hash wouldn't add security but would prevent the UI
            from showing whether a recent rotate succeeded.
        - name: Create system account
          method: POST
          path: /api/v1/admin/system-accounts
          auth: JWT Bearer
          permission: '*'
          description: |
            Creates a new credential and returns the plaintext
            password ONCE in the response. The hash is the only
            persisted form.

            **Validation:**
            - `name`: ASCII letters, digits, `-`, or `_`. Unique
              across active AND revoked rows (you cannot re-use a
              name once revoked).
            - `allowed_prefixes`: at least one non-empty entry.
            - `description`: optional free-text.

            **Errors:**
            - `409` — `name` already exists (active or revoked).
            - `422` — validation failure on name or prefixes.
          body:
            - name: name
              type: string
              required: true
              description: Basic-auth username.
            - name: allowed_prefixes
              type: array<string>
              required: true
              description: Service-key prefixes this caller may grant via gRPC grant-management. Required, at least one entry.
            - name: description
              type: string
              description: Free-text note.
            - name: token_permissions
              type: array<string>
              description: Permission set embedded into bearer tokens minted via [`POST /auth/system-token`](#auth) (e.g. `["media:upload"]`). Empty by default — defer with the dedicated permissions endpoint below if you don't want to set on create. Trimmed + deduped server-side; order preserved.
          example_response: |
            {
              "success": true,
              "data": {
                "account": {
                  "id": "550e8400-...",
                  "name": "careers-backend",
                  "allowed_prefixes": ["*"],
                  "token_permissions": ["media:upload"],
                  "description": "Owned by careers-team",
                  "status": "active",
                  ...
                },
                "password": "K4j8m..."
              }
            }
        - name: Update token permissions
          method: POST
          path: /api/v1/admin/system-accounts/{id}/permissions
          auth: JWT Bearer
          permission: '*'
          description: |
            Replace the `token_permissions` array on an **active**
            system account, without touching the password. Used to
            grant (or rescind) capabilities such as `media:upload`
            consumed by the [`/auth/system-token`](#auth) bearer-token
            flow.

            Server-side normalisation: each entry is trimmed; empty
            entries are dropped; duplicates are folded while preserving
            first-seen order. Send `[]` to clear all permissions.

            **Cache discipline.** Bearer-token minting reads
            `token_permissions` directly from Postgres on every call
            (the gRPC Redis cache deliberately omits this column), so
            a new grant takes effect on the **next token mint**.
            Tokens already in flight keep the permissions they were
            issued with until they expire (≤ access-token TTL, default
            15 min).

            **Errors:**
            - `403` — caller is not a platform admin.
            - `404` — id unknown or row is revoked (revoked rows
              cannot be re-permissioned; rotate or create fresh).
            - `422` — `token_permissions` field missing from body.
          body:
            - name: token_permissions
              type: array<string>
              required: true
              description: Replacement permission set. Send `[]` to clear all permissions.
          example_body: |
            {
              "token_permissions": ["media:upload", "media:read"]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "550e8400-...",
                "token_permissions": ["media:upload", "media:read"]
              }
            }
        - name: Rotate password
          method: POST
          path: /api/v1/admin/system-accounts/{id}/rotate-password
          auth: JWT Bearer
          permission: '*'
          description: |
            Generates a fresh 32-character password and replaces
            the stored hash atomically. Old password stops working
            on the next gRPC call (Redis cache is invalidated as
            part of the same handler).

            Cannot be called on a revoked row — use Create with a
            fresh name instead. Returns `422` in that case.
        - name: Revoke system account
          method: DELETE
          path: /api/v1/admin/system-accounts/{id}
          auth: JWT Bearer
          permission: '*'
          description: |
            Flips status to `revoked`, stamps `revoked_at` and
            `revoked_by_account_id`. Idempotent — the response
            includes `already_revoked: true` if the row was
            already revoked. The row is never hard-deleted so the
            audit trail (when issued, by whom, when revoked)
            stays intact.
    - id: operations
      title: Operations
      description: |
        Operational endpoints — health probes for orchestrators (Kubernetes,
        Nomad, load balancers) and Prometheus scraping. Not part of the
        product API surface; safe to firewall off from public traffic.
      endpoints:
        - name: Liveness probe
          method: GET
          path: /health
          auth: none
          description: |
            Lightweight liveness check. Returns 200 with a small JSON when
            the process is up. Does not touch DB / Redis / RabbitMQ — use
            `/health/detailed` for that. Suitable for k8s `livenessProbe`.
          example_response: |
            { "status": "ok" }
        - name: Detailed health
          method: GET
          path: /health/detailed
          auth: none
          description: |
            Per-dependency health snapshot. Probes Postgres, Redis, and
            RabbitMQ and returns each dependency's state. A degraded
            dependency yields HTTP 503 so a load balancer can drop the pod
            from rotation. Heavier than `/health` — call from
            `readinessProbe`, not `livenessProbe`.
          example_response: |
            {
              "status": "ok",
              "dependencies": {
                "postgres": { "status": "ok", "latency_ms": 3 },
                "redis":    { "status": "ok", "latency_ms": 1 },
                "rabbitmq": { "status": "ok", "latency_ms": 5 }
              },
              "version": "1.0.0",
              "uptime_seconds": 12345
            }
        - name: Prometheus metrics
          method: GET
          path: /metrics
          auth: none
          description: |
            Prometheus exposition format. Includes HTTP request counters /
            latency histograms (sanitized path labels — id segments collapsed
            to `:id` so cardinality stays bounded), gRPC counters, brute-force
            counters, rate-limiter counters, and Redis connection pool stats.
            Should be firewalled to the metrics-collector subnet, not
            exposed publicly.
          example_response: |
            # HELP http_requests_total Total HTTP requests
            # TYPE http_requests_total counter
            http_requests_total{method="GET",path="/api/v1/me",status="200"} 142
            ...
    - id: organizations
      title: Organizations
      description: |
        Multi-tenant organization management — CRUD, switching context,
        invitations, role assignment, and per-org activity feed. Most
        endpoints require a permission within the targeted organization.
      endpoints:
        - name: Check organization (public)
          method: GET
          path: /api/v1/organizations/{org_id}/check
          auth: none
          description: |
            Lightweight existence + display info for an organization. Used by
            frontends to validate org-scoped invitation links before login.
            Returns minimal data — no member or role info is leaked.
          example_response: |
            {
              "success": true,
              "data": {
                "org_id": "550e8400-...",
                "name": "Acme Corp",
                "created_at": 1735689600
              }
            }
        - name: List my organizations
          method: GET
          path: /api/v1/organizations
          auth: JWT Bearer
          permission: read:organizations
          description: |
            List organizations the authenticated principal is a member of.
            Each entry includes the principal's role in that org.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page number.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 }
                ],
                "total": 1,
                "page": 1,
                "per_page": 20,
                "total_pages": 1
              }
            }
        - name: Create organization
          method: POST
          path: /api/v1/organizations
          auth: JWT Bearer
          description: |
            Create a new organization with the caller as owner. Slug is
            auto-derived from the name when omitted; if the slug collides
            a 4xx is returned and the caller should retry with an explicit
            slug.
          body:
            - name: name
              type: string
              required: true
              description: Display name of the organization.
            - name: slug
              type: string
              description: URL-safe slug. Auto-generated from name when omitted.
          example_body: |
            { "name": "Acme Corp", "slug": "acme-corp" }
          example_response: |
            {
              "success": true,
              "data": {
                "org_id": "550e8400-...",
                "name": "Acme Corp",
                "slug": "acme-corp",
                "description": null,
                "status": "active",
                "owner_id": "550e8400-...",
                "created_at": 1735689600,
                "updated_at": 1735689600,
                "member_count": 1
              }
            }
        - name: Get organization
          method: GET
          path: /api/v1/organizations/{org_id}
          auth: JWT Bearer
          description: Fetch a single organization by id. Caller must be a member.
          example_response: |
            { "success": true, "data": { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 } }
        - name: Update organization
          method: PUT
          path: /api/v1/organizations/{org_id}
          auth: JWT Bearer
          permission: organizations:update
          description: Update the organization's name or slug.
          body:
            - name: name
              type: string
              description: New display name.
            - name: slug
              type: string
              description: New URL-safe slug.
          example_body: |
            { "name": "Acme Inc.", "slug": "acme-inc" }
          example_response: |
            { "success": true, "data": { "org_id": "...", "name": "Acme Inc.", "slug": "acme-inc", "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735693200, "member_count": 12 } }
        - name: Delete organization
          method: DELETE
          path: /api/v1/organizations/{org_id}
          auth: JWT Bearer
          permission: '*'
          description: |
            Soft-delete (archive) an organization. Owner-only — protected by the
            owner role's `*` wildcard rather than a per-action permission. Members
            lose access on next token rotation; all sessions remain intact for
            other orgs the user belongs to.
          example_response: |
            { "success": true, "data": null }
        - name: Switch active organization
          method: POST
          path: /api/v1/organizations/{org_id}/switch
          auth: JWT Bearer
          description: |
            Issue a new token pair bound to a different organization. The
            existing session id is reused, so refresh continuity is preserved.
            The caller must already be a member of the target org.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "...",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "eyJ...new...",
                "refresh_token": "eyJ...new...",
                "expires_in": 900,
                "organizations": [ { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] } ],
                "current_org_id": "550e8400-...new..."
              }
            }
        - name: Leave organization
          method: POST
          path: /api/v1/organizations/{org_id}/leave
          auth: JWT Bearer
          description: |
            Remove the caller's own membership. Owners cannot leave — transfer
            ownership first or delete the organization.
          example_response: |
            { "success": true, "data": null }
        - name: List organization members
          method: GET
          path: /api/v1/organizations/{org_id}/members
          auth: JWT Bearer
          description: Paged member list with role + permissions.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "account_id": "...", "email": "user@example.com", "display_name": "Jane Doe", "role": "owner", "permissions": ["*"], "joined_at": 1735689600 }
                ],
                "total": 1,
                "page": 1,
                "per_page": 20,
                "total_pages": 1
              }
            }
        - name: Invite member
          method: POST
          path: /api/v1/organizations/{org_id}/members
          auth: JWT Bearer
          permission: members:invite
          description: |
            Send an invitation to an email address. If a role is omitted the
            org's default-member role is assigned on accept. The recipient
            gets an email via the `email.requested` event.
          body:
            - name: email
              type: string
              required: true
              description: Invitee email.
            - name: role_id
              type: string
              description: Role id to assign on accept. Defaults to org default member role.
          example_body: |
            { "email": "newmember@example.com", "role_id": "550e8400-..." }
          example_response: |
            {
              "success": true,
              "data": {
                "invitation_id": "550e8400-...",
                "message": "Invitation sent to newmember@example.com"
              }
            }
        - name: Remove member
          method: DELETE
          path: /api/v1/organizations/{org_id}/members/{account_id}
          auth: JWT Bearer
          permission: members:remove
          description: Remove a member from the org. Cannot remove the owner.
          example_response: |
            { "success": true, "data": null }
        - name: Update member role
          method: PUT
          path: /api/v1/organizations/{org_id}/members/{account_id}/role
          auth: JWT Bearer
          permission: members:update_role
          description: |
            Reassign a member to a different role. New role takes effect on
            their next token rotation.
          body:
            - name: role_id
              type: string
              required: true
              description: Target role id.
          example_body: |
            { "role_id": "550e8400-..." }
          example_response: |
            { "success": true, "data": null }
        - name: List roles
          method: GET
          path: /api/v1/organizations/{org_id}/roles
          auth: JWT Bearer
          description: List roles defined in the org (system + custom).
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "role_id": "...", "name": "owner", "description": "Organization owner with full access", "permissions": ["*"], "is_system": true, "created_at": 1735689600, "updated_at": 1735689600 }
                ],
                "total": 1,
                "page": 1,
                "per_page": 20,
                "total_pages": 1
              }
            }
        - name: Create role
          method: POST
          path: /api/v1/organizations/{org_id}/roles
          auth: JWT Bearer
          permission: roles:create
          description: |
            Create a custom role. Permissions are free-form strings (the
            permission service interprets them); see the Permissions section
            for the canonical names used by account-service itself.
          body:
            - name: name
              type: string
              required: true
              description: Display name (unique within the org).
            - name: description
              type: string
              description: Optional description.
            - name: permissions
              type: string[]
              required: true
              description: Permission strings granted by this role.
          example_body: |
            {
              "name": "auditor",
              "description": "Read-only access to audit logs",
              "permissions": ["read:organizations", "audit_logs:read"]
            }
          example_response: |
            { "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
        - name: Get role
          method: GET
          path: /api/v1/organizations/{org_id}/roles/{role_id}
          auth: JWT Bearer
          description: Fetch a single role.
          example_response: |
            { "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
        - name: Update role
          method: PUT
          path: /api/v1/organizations/{org_id}/roles/{role_id}
          auth: JWT Bearer
          permission: roles:update
          description: Patch a role. System roles (e.g. `owner`) are read-only.
          body:
            - name: name
              type: string
              description: New name.
            - name: description
              type: string
              description: New description (pass null to clear).
            - name: permissions
              type: string[]
              description: New permissions list (replaces, not merges).
          example_body: |
            { "permissions": ["read:organizations", "audit_logs:read", "members:invite"] }
          example_response: |
            { "success": true, "data": { "role_id": "...", "name": "auditor", "permissions": ["read:organizations","audit_logs:read","members:invite"], "is_system": false, "created_at": 1735689600, "updated_at": 1735693200 } }
        - name: Delete role
          method: DELETE
          path: /api/v1/organizations/{org_id}/roles/{role_id}
          auth: JWT Bearer
          permission: roles:delete
          description: |
            Delete a custom role. Fails if any member is currently assigned to
            it — reassign them first. System roles cannot be deleted.
          example_response: |
            { "success": true, "data": null }
        - name: Organization activity feed
          method: GET
          path: /api/v1/organizations/{org_id}/activities
          auth: JWT Bearer
          permission: audit_logs:read
          description: |
            Org-wide audit feed. Same shape as `/me/activities` but scoped to
            all actors within the organization. Requires `audit_logs:read`
            permission in the org.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
            - name: since
              type: string
              description: ISO-8601.
            - name: until
              type: string
              description: ISO-8601.
            - name: action
              type: string
              description: Dot-notation action filter.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "id": "...", "actor_id": "...", "actor_email": "user@example.com", "actor_display_name": null, "organization_id": "...", "organization_name": null, "action": "member.invited", "target_type": "member", "target_id": "...", "target_label": null, "metadata": {}, "ip_address": "203.0.113.42", "user_agent": "...", "created_at": 1735689600 }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
    - id: registration-lock
      title: Registration kill-switch
      description: |
        Platform-wide emergency switch for new account creation.
        Intended for rare situations where the platform team wants
        to gate every new account by hand (abuse spike, infra
        capacity, controlled rollout).

        **Behaviour when locked.**
        - `POST /api/v1/auth/register` — request is queued into
          `registration_requests` with status `pending` and the
          response is `202 Accepted` with `data.pending = true`. No
          account is created yet.
        - `POST /api/v1/auth/google` — the auto-create branch still
          returns `423 Locked` for new users (queueing for SSO is
          Phase B). Existing Google-linked users log in normally.
        - `POST /api/v1/auth/firebase` — same as Google.

        **Behaviour when open.** All paths behave normally. New
        submissions create accounts directly.

        **What is NOT affected.**
        - Existing-user login on any path.
        - Existing-user SSO link.
        - Invitation accept — invitee must already have an account
          (they go through `/auth/register` first, which is gated).

        **Wire response when locked.**
        - For `/auth/register`: `202` with body
          `{success:true, code:2020, data:{pending:true, request_id, message}}`.
          FE renders a "request received" view.
        - For `/auth/google` and `/auth/firebase`: `423` with code
          `4010` and the admin-supplied message (or the default
          copy).

        **State storage.** Single-row Postgres table
        `registration_lock` is the source of truth; a 5-minute
        Redis cache on `registration_lock` key keeps the hot path
        fast. Toggling via the admin endpoints invalidates the
        cache explicitly so the new state is visible to the next
        request.

        **Audit.** Every toggle writes a structured log line tagged
        with the admin's account id, the new `enabled` state, and
        whether a message was set. No separate audit-log row yet —
        follow the access log if you need historical attribution.
      endpoints:
        - name: Get registration lock state (admin)
          method: GET
          path: /api/v1/admin/registration-lock
          auth: JWT Bearer
          permission: '*'
          description: |
            Read the full lock row including audit columns. Used by
            the superadmin UI at `/dashboard/admin-registration`.
          example_response: |
            {
              "success": true,
              "data": {
                "enabled": false,
                "message": null,
                "updated_at": "2026-05-22T10:00:00Z",
                "updated_by_account_id": "550e8400-..."
              }
            }
        - name: Set registration lock state (admin)
          method: PUT
          path: /api/v1/admin/registration-lock
          auth: JWT Bearer
          permission: '*'
          description: |
            Toggle the lock and optionally set a custom message.
            Idempotent — calling with the same `(enabled, message)`
            twice is a no-op.

            **Body fields:**
            - `enabled` (bool, required) — `true` blocks, `false`
              opens.
            - `message` (string, optional) — shown to users on the
              register page when locked. Omit or send empty string
              to clear and let the FE use its default copy.

            Returns the new state; Redis cache is invalidated as
            part of the handler so the change is observable on the
            next request.
          body:
            - name: enabled
              type: boolean
              required: true
              description: Lock state to set.
            - name: message
              type: string
              description: Optional user-facing reason.
          example_body: |
            {
              "enabled": true,
              "message": "Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB"
            }
        - name: Get public registration status
          method: GET
          path: /api/v1/auth/registration-status
          auth: none
          description: |
            Public read of the current lock state. No auth required
            — the register page calls this on first render so it
            can show the banner instead of letting the user fill
            out a form that's going to 423.

            Only two fields are returned (`locked`, `message`); the
            audit columns stay private. Backed by the same 5-minute
            Redis cache as the gate check.
          example_response: |
            {
              "success": true,
              "data": {
                "locked": true,
                "message": "Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB"
              }
            }
    - id: registration-requests
      title: Registration requests (review queue)
      description: |
        Queue of registration attempts that arrived while the
        kill-switch was on. Superadmin reviews each row from
        `/dashboard/admin-registration-requests` and either
        approves it (which issues an approval token the user
        consumes to set their password and activate the account)
        or rejects it.

        **States.**
        - `pending` — submission captured, no review yet.
        - `approved` — superadmin approved, approval token valid
          for 24 hours, account NOT yet created.
        - `rejected` — superadmin rejected. Terminal.
        - `completed` — user followed the approval link and
          activated the account. Terminal.

        **Token model.** Approval issues a 32-character base62
        plaintext token. The DB stores only its sha256 hash; the
        plaintext is returned ONCE in the approve response (Phase
        A: shown to the admin to share manually; Phase B will
        email it). The user POSTs the plaintext back to
        `/auth/complete-registration`, which hashes, looks up,
        and consumes it.

        **Phase A scope.** This release covers email-password
        submissions only. SSO requests during the lock window
        still return `423` and do not enter the queue. Email
        notifications are not automated yet — the admin shares
        the approval link out-of-band.
      endpoints:
        - name: List registration requests
          method: GET
          path: /api/v1/admin/registration-requests
          auth: JWT Bearer
          permission: '*'
          description: |
            List requests filtered by status. Pagination is
            intentionally omitted — queue depth is expected to
            stay small under realistic emergency conditions.
          query_params:
            - name: status
              type: string
              default: pending
              description: One of pending, approved, rejected, completed.
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "550e8400-...",
                  "email": "newuser@example.com",
                  "display_name": "Alice",
                  "timezone": "Asia/Jakarta",
                  "language": "id",
                  "source": "email_password",
                  "ip_address": "203.0.113.42",
                  "user_agent": null,
                  "status": "pending",
                  "requested_at": "2026-05-23T05:30:00Z",
                  "reviewed_at": null,
                  "reviewed_by_account_id": null,
                  "review_note": null,
                  "completed_account_id": null
                }
              ]
            }
        - name: Approve a registration request
          method: POST
          path: /api/v1/admin/registration-requests/{id}/approve
          auth: JWT Bearer
          permission: '*'
          description: |
            Mark a pending request approved + mint a 24-hour
            approval token. The plaintext token is returned ONCE
            in `data.approval_url`; share it with the user
            out-of-band (chat, manual email) until the
            email-event automation lands.

            **Errors:**
            - `404` — request not found.
            - `422` — request is not in `pending` (already
              approved/rejected/completed).
          body:
            - name: note
              type: string
              description: Internal review note (not shown to the user).
          example_response: |
            {
              "success": true,
              "data": {
                "id": "550e8400-...",
                "approval_url": "https://account.ikavia.com/complete-registration?token=K4j8m...",
                "expires_at": "2026-05-24T05:30:00Z"
              }
            }
        - name: Reject a registration request
          method: POST
          path: /api/v1/admin/registration-requests/{id}/reject
          auth: JWT Bearer
          permission: '*'
          description: |
            Mark a pending request rejected. No notification is
            sent to the user automatically (intentional — guards
            against probe-and-confirm by attackers).
          body:
            - name: note
              type: string
              description: Internal review note.
        - name: Complete approved registration
          method: POST
          path: /api/v1/auth/complete-registration
          auth: none
          description: |
            Public endpoint — the approval token IS the auth.
            Consume the token, set the password, create the
            account, auto-login. Same response shape as
            `/auth/register` so the FE can treat completion as a
            normal "you're logged in now".

            **Errors:**
            - `422` — token missing/invalid/expired, or password
              too short (< 8 chars).
            - `429` — rate-limit (3/min/IP, same as register).

            **Phase A limitation.** SSO-source requests return
            `422` here — only `email_password` source is finalised
            by this endpoint for now.
          body:
            - name: token
              type: string
              required: true
              description: Plaintext approval token.
            - name: password
              type: string
              required: true
              description: User's new password (min 8 chars).
    - id: service-account-quotas
      title: Service-account creation quotas (admin)
      description: |
        Two-dimensional governance over how many service accounts
        exist on the platform. Every `POST /api/v1/service-accounts`
        is gated against:

        1. **Per-user limit** — how many service accounts a single
           human can own across ALL orgs (default 10).
        2. **Per-org limit** — how many service accounts a single
           organization can have (default 50).

        Both checks run. The stricter one wins — if the user has 9
        slots left but the org has 0, the create is rejected.
        Archived / deleted service accounts are NOT counted; archive
        one to free up a slot.

        **Wire response when limit is reached:** HTTP `429 Too Many
        Requests` with code `4011` and a message naming which
        dimension overflowed.

        **Lookup order** when computing the effective limit at
        check time:
        1. Per-target override row (user or org) — if present, use
           its `max_count`.
        2. Global override row (`scope='global'`, single row) — if
           present, use its `max_count`.
        3. Hardcoded defaults (`DEFAULT_MAX_PER_USER = 10`,
           `DEFAULT_MAX_PER_ORG = 50`).

        **Storage.** Each row in `service_account_quotas` is an
        override. Empty table ⇒ everyone uses the in-code defaults.
        Three partial unique indexes enforce one row per
        `(scope, target_id)` combination.

        **Audit.** Each upsert / delete writes a structured log line
        with `admin_id`, `scope`, `target_id`, and the new value.
        A dedicated audit_log row is not written yet.
      endpoints:
        - name: List quotas (admin)
          method: GET
          path: /api/v1/admin/service-account-quotas
          auth: JWT Bearer
          permission: '*'
          description: |
            Returns both the currently effective defaults and the
            full list of override rows. `global_override_active`
            is `true` when a `global`-scope row exists; otherwise
            the defaults are the hardcoded values.
          example_response: |
            {
              "success": true,
              "data": {
                "defaults": {
                  "per_user": 10,
                  "per_org": 50,
                  "global_override_active": false
                },
                "overrides": [
                  {
                    "id": "...",
                    "scope": "org",
                    "target_id": "550e8400-...",
                    "max_count": 100,
                    "note": "INC-1234 raised limit",
                    "updated_at": "2026-05-23T07:00:00Z",
                    "updated_by_account_id": "..."
                  }
                ]
              }
            }
        - name: Upsert quota override (admin)
          method: PUT
          path: /api/v1/admin/service-account-quotas
          auth: JWT Bearer
          permission: '*'
          description: |
            Create or update an override row. `target_id` is
            required for `scope` of `org` or `user`; ignored when
            `scope=global`. `max_count` must be `>= 0` (zero
            effectively blocks all creation for that target).
          body:
            - name: scope
              type: string
              required: true
              description: 'One of: global, org, user.'
            - name: target_id
              type: string
              description: UUID of the org or user. Required for non-global scopes.
            - name: max_count
              type: integer
              required: true
              description: Maximum active service accounts allowed at the target.
            - name: note
              type: string
              description: Free-text note shown only in the admin list view.
          example_body: |
            {
              "scope": "org",
              "target_id": "550e8400-e29b-41d4-a716-446655440000",
              "max_count": 100,
              "note": "INC-1234 raised limit for prod rollout"
            }
        - name: Remove org / user override (admin)
          method: DELETE
          path: /api/v1/admin/service-account-quotas/{scope}/{target_id}
          auth: JWT Bearer
          permission: '*'
          description: |
            Remove a non-global override; the target falls back to
            the global override or hardcoded defaults. Use the
            separate global endpoint below for the global row.
        - name: Remove global override (admin)
          method: DELETE
          path: /api/v1/admin/service-account-quotas/global
          auth: JWT Bearer
          permission: '*'
          description: |
            Remove the global override row; defaults fall back to
            the hardcoded values (`DEFAULT_MAX_PER_USER = 10`,
            `DEFAULT_MAX_PER_ORG = 50`).
    - id: api-keys-integration-guide
      title: 'Recipe: API Key Manager in your own service'
      description: |
        End-to-end integration recipe for sibling services (wacca, chat,
        planner, …) that want to expose an **API Key Manager** inside
        their own UI. The goal: a user logged into wacca.ikavia.com can
        create, list, and revoke service accounts + API keys for their
        org **without leaving wacca.ikavia.com** — no redirect to
        account.ikavia.com required.

        This is the default and recommended pattern. The wire shape is
        identical to what `account.ikavia.com/dashboard/api-keys` itself
        uses; you are just calling the same endpoints from your domain.

        **Who should see this panel — important.** API keys are
        long-lived credentials that grant programmatic access to org
        data. **Do NOT expose this panel to every user of your
        service.** Restrict the menu entry / route to:
        - **Organization owners** in account-service's model (the
          user who created the org, or anyone with `service_accounts:*`
          + `api_keys:*` grants — check `permissions` array on their
          JWT or call `/me` to inspect).
        - **Your own service's superadmin / platform-admin role** if
          you maintain a separate admin tier (e.g. a wacca-staff role
          that operates across orgs).

        Regular org members — even "admin"-ish roles like project
        lead or team manager — should NOT see this panel by default.
        A leaked API key bypasses every UI guardrail; the audience
        list for this feature should match who you'd trust with a
        raw database credential.

        **Permission check on the BE side.** account-service enforces
        `service_accounts:create` / `api_keys:create` on the user's
        JWT regardless of what your FE renders — a manually crafted
        request from a non-owner gets 403. That makes the FE
        restriction a UX choice (don't show what won't work), not
        the security boundary. The security boundary lives on
        account-service.

        **Setup with the platform team.** No registration needed —
        account-service does not know your panel exists. Each call
        runs as the user via their forwarded JWT.

        **One-time CORS setup.** Required only if you call
        account.ikavia.com directly from the browser:
        - File a request to add your subdomain (e.g.
          `https://wacca.ikavia.com`) to
          `APP__HTTP__CORS__ALLOWED_ORIGINS`. Until added, the browser
          blocks the request with a CORS error.
        - Skip this step if you proxy the call through your own
          backend instead (BE→BE has no CORS).
      endpoints:
        - name: Step 1 — Plan your panel UI
          method: STEP
          path: (client-side design)
          auth: N/A
          description: |
            A typical API Key Manager has three views:
            - **List** — table of existing service accounts (bots) in
              the user's current org, each with a chevron to expand its
              keys.
            - **Create service account** — form with name + description.
              Service accounts hold the keys; you cannot have a key
              without first having a service account to attach it to.
            - **Create / revoke / delete API key** — under a service
              account, list its keys (showing only `key_prefix` —
              plaintext is never re-fetched) with create, revoke, and
              delete actions.

            Mirror the layout of
            `https://account.ikavia.com/dashboard/api-keys` if you want
            a known-good reference; everything you see there is
            buildable from the public endpoints in this section.
        - name: Step 2 — Verify the user is allowed AND get their org
          method: GET
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Two things at once: check the user is in the allowed
            audience for this panel, and pick up the org context.

            **Audience check (do this before rendering the menu
            item):**
            - Look at `permissions` in the user's access-token claims
              (or call `/me` and inspect the org's role). The user
              must hold `service_accounts:create` and `api_keys:create`
              on the target org — or your own service's superadmin
              role, if you have one.
            - If they don't, hide the menu entry. Don't render the
              page just to show a 403 — surface the capability only
              to people who can actually use it.

            **Org context:** read `current_organization_id` from
            `/me`. If your service has its own concept of "the org
            this user is currently looking at", use that instead, as
            long as it matches one of `organizations[]` in the
            response. Every CRUD call below filters by this org.
        - name: Step 3 — List service accounts in the org
          method: GET
          path: /api/v1/service-accounts?organization_id={org_id}
          auth: JWT Bearer
          description: |
            Render this as the parent table. Each row's `account_id`
            is the parent for that bot's API keys (Step 5).
        - name: Step 4 — Create a new service account (the 'bot')
          method: POST
          path: /api/v1/service-accounts
          auth: JWT Bearer
          permission: service_accounts:create
          description: |
            Required body: `organization_id` + `display_name`.
            Returns the new row; **no API key is minted yet** — that's
            a separate call so the admin can decide permissions for
            each key independently.
          example_body: |
            {
              "organization_id": "550e8400-...",
              "display_name": "Wacca tenant sync bot",
              "description": "Reads tenant rosters for nightly sync"
            }
        - name: Step 5 — Create an API key under that service account
          method: POST
          path: /api/v1/service-accounts/{account_id}/api-keys
          auth: JWT Bearer
          permission: api_keys:create
          description: |
            **Plaintext-key handling — read carefully.** The response
            field `data.key` is the ONLY time the raw key is ever
            exposed by account-service. Subsequent reads see only
            `key_prefix` (first 8 chars) for visual identification.

            Your FE MUST render this in a "show-once modal":
            - Display `data.key` in a copyable field with a `Copy`
              button.
            - Strongly word the warning that dismissing the modal
              discards the plaintext.
            - Drop the value from in-memory state once the modal
              closes. Do not store it in localStorage, sessionStorage,
              or send it to your own analytics — treat it like a
              credit-card CVV.

            Reference implementation:
            `account.ikavia.com/dashboard/api-keys` (open dev tools,
            watch the network tab as you create a key to see the
            exact response shape).
        - name: Step 6 — Revoke or delete a key
          method: POST
          path: /api/v1/api-keys/{api_key_id}/revoke
          auth: JWT Bearer
          permission: api_keys:revoke
          description: |
            Revoke marks the key as inactive; the row stays around for
            audit. Use `DELETE /api/v1/api-keys/{id}` only if the user
            explicitly wants the audit history gone.
        - name: Worked example — fetch from your BE (CORS-free path)
          method: EXAMPLE
          path: (server-side pseudocode)
          auth: N/A
          description: |
            If you'd rather skip the CORS allowlist request, proxy the
            call through your own backend. The Bearer token you forward
            IS the user's token — your BE doesn't need its own
            credential.

            ```
            // wacca BE handler — runs after wacca's own auth middleware
            // has validated and exposed the user's JWT.
            fn list_service_accounts(user_jwt: String, org_id: String) {
                let response = http_client
                    .get(format!(
                        "https://account.ikavia.com/api/v1/service-accounts?organization_id={}",
                        org_id
                    ))
                    .header("Authorization", format!("Bearer {}", user_jwt))
                    .send()?;
                return response.json();
            }
            ```

            Same pattern for every other call. The user's JWT is the
            only credential you carry; your service has no separate
            identity in this flow.
        - name: Worked example — fetch from the browser (CORS path)
          method: EXAMPLE
          path: (client-side pseudocode)
          auth: N/A
          description: |
            After your subdomain has been added to the CORS allowlist:

            ```
            // wacca FE — assumes you store the JWT in memory or a
            // cookie scoped to .ikavia.com (the SSO shared cookie).
            const res = await fetch(
                `https://account.ikavia.com/api/v1/service-accounts?organization_id=${orgId}`,
                {
                    method: 'GET',
                    headers: { Authorization: `Bearer ${jwt}` },
                    credentials: 'omit',  // bearer header, no cookies
                },
            );
            const { data } = await res.json();
            ```

            `credentials: 'omit'` is correct here — the cookie path
            (`.ikavia.com`-scoped session) is for human flows on
            account.ikavia.com itself. From your origin, the bearer
            token is the auth.
    - id: service-accounts
      title: Service Accounts
      description: |
        Non-human principals scoped to one organization. They authenticate
        via API keys (`X-API-Key`) or service JWTs obtained from
        `/auth/token-exchange`. Service accounts cannot log in interactively.

        **For sibling services (wacca, chat, planner, …) building their own
        "API keys" panel.** These endpoints are designed to be called by
        your own UI on behalf of a logged-in user — you do not need to
        redirect them to account.ikavia.com. Forward the user's existing
        access token in `Authorization: Bearer …`, and the call runs with
        that user's permissions. The user (not your service) must hold
        `service_accounts:*` / `api_keys:*` in the org they're managing —
        typically that's the org owner or an admin role you've assigned
        those permissions to.

        **Wire details for embedding:**
        - **Auth.** Bearer JWT only — do NOT use the cookie-based
          session here (your origin won't have the cookie). Read the
          token from your own session store, forward it, that's it.
        - **CORS.** `account.ikavia.com` only allows requests from an
          explicit allowlist (`APP__HTTP__CORS__ALLOWED_ORIGINS`).
          Today: account / media / email / mail / survey / planner /
          docs. **Your subdomain must be added by the platform team
          before browser calls work.** Filing the request with the
          URL of the panel you're building is enough; until added,
          the browser blocks the request and you'll see a CORS error
          in the console.
        - **CORS-free alternative.** Proxy through your service's
          backend instead of calling from the browser. Your BE
          already has the JWT (forwarded from the browser); make the
          outbound call from there. Skips CORS entirely and gives
          you a chance to add per-service rate limits.
        - **Plaintext key handling.** `POST /service-accounts/{id}/api-keys`
          returns the raw key in `data.key` ONLY at creation. Show it
          in a "copy once" modal exactly like account.ikavia.com's
          own API-keys page does (see [[account_service_role]]); on
          every subsequent read the key is hashed and only
          `key_prefix` is visible.
        - **Permission model.** The user's JWT permissions are checked
          against `service_accounts:create`, `api_keys:create`, etc.
          If your service exposes a "team management" page where a
          non-owner can create keys, that user needs the relevant
          grant on the org first — either via the org's roles UI or
          by being the org owner.
      endpoints:
        - name: List service accounts
          method: GET
          path: /api/v1/service-accounts
          auth: JWT Bearer
          permission: service_accounts:read
          description: |
            List service accounts in an organization. The org is supplied as a
            query parameter (not a path segment) because callers commonly
            enumerate across orgs they manage.
          query_params:
            - name: organization_id
              type: string
              required: true
              description: Organization id to filter by.
            - name: status
              type: string
              description: One of `active`, `paused`, `archived`.
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "account_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read"], "last_used_at": 1735693200, "created_at": 1735689600 }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
        - name: Create service account
          method: POST
          path: /api/v1/service-accounts
          auth: JWT Bearer
          permission: service_accounts:create
          description: |
            Create a new service account scoped to an organization. The
            returned account has no API key yet — call
            `POST /service-accounts/{account_id}/api-keys` afterwards.
          body:
            - name: organization_id
              type: string
              required: true
              description: Organization to attach the service account to.
            - name: display_name
              type: string
              required: true
              description: Human-readable name.
            - name: description
              type: string
              description: Optional description.
            - name: capabilities
              type: string[]
              required: true
              description: Capabilities granted (resource:action format).
            - name: allowed_tools
              type: string[]
              required: true
              description: Subset of tool ids the service may invoke. Empty list = none.
            - name: rate_limit_per_minute
              type: integer
              description: Per-key rate cap; clamped to 1-10000. Default 60.
          example_body: |
            {
              "organization_id": "550e8400-...",
              "display_name": "Render bot",
              "description": "Generates thumbnail previews",
              "capabilities": ["museum:read", "artifact:write"],
              "allowed_tools": ["thumbnailer"],
              "rate_limit_per_minute": 120
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "display_name": "Render bot",
                "description": "Generates thumbnail previews",
                "status": "active",
                "capabilities": ["museum:read", "artifact:write"],
                "allowed_tools": ["thumbnailer"],
                "rate_limit_per_minute": 120,
                "last_used_at": null,
                "created_at": 1735689600,
                "updated_at": 1735689600
              }
            }
        - name: Get service account
          method: GET
          path: /api/v1/service-accounts/{account_id}
          auth: JWT Bearer
          description: Fetch one service account by id.
          example_response: |
            {
              "success": true,
              "data": { "account_id": "...", "organization_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "rate_limit_per_minute": 120, "last_used_at": 1735693200, "created_at": 1735689600, "updated_at": 1735689600 }
            }
        - name: Update service account
          method: PUT
          path: /api/v1/service-accounts/{account_id}
          auth: JWT Bearer
          permission: service_accounts:update
          description: |
            Patch metadata, capabilities, or rate limit. Permission changes
            take effect on the next `/auth/token-exchange` call (or
            immediately for raw `X-API-Key` flows).
          body:
            - name: organization_id
              type: string
              required: true
              description: Org id (used as a safety check that the caller scoped the request correctly).
            - name: display_name
              type: string
              description: New display name.
            - name: description
              type: string
              description: New description.
            - name: capabilities
              type: string[]
              description: Replacement capabilities list.
            - name: allowed_tools
              type: string[]
              description: Replacement allowed-tools list.
            - name: rate_limit_per_minute
              type: integer
              description: New rate limit.
          example_body: |
            {
              "organization_id": "550e8400-...",
              "display_name": "Render bot v2",
              "rate_limit_per_minute": 240
            }
          example_response: |
            { "success": true, "data": { "account_id": "...", "display_name": "Render bot v2", "rate_limit_per_minute": 240, "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "organization_id": "...", "created_at": 1735689600, "updated_at": 1735693200 } }
        - name: Archive service account
          method: DELETE
          path: /api/v1/service-accounts/{account_id}
          auth: JWT Bearer
          permission: service_accounts:archive
          description: |
            Soft-delete (archive) a service account. All of its API keys are
            revoked. Existing service JWTs continue to validate until their
            natural exp (max 1 hour); add the JTI to the revocation list via
            re-issued tokens to shorten that further.
          example_response: |
            { "success": true, "data": null }
        - name: Pause service account
          method: POST
          path: /api/v1/service-accounts/{account_id}/pause
          auth: JWT Bearer
          permission: service_accounts:pause
          description: |
            Temporarily disable a service account. API keys stop
            authenticating until resumed; metadata and capabilities are
            preserved.
          example_response: |
            { "success": true, "data": null }
        - name: Resume service account
          method: POST
          path: /api/v1/service-accounts/{account_id}/resume
          auth: JWT Bearer
          permission: service_accounts:resume
          description: Re-enable a paused service account.
          example_response: |
            { "success": true, "data": null }
    - id: api-keys
      title: API Keys
      description: |
        API keys are issued under a service account and grant programmatic
        access. The plaintext key is returned **only at creation time** —
        account-service stores a sha-256 hash. Revoke leaked keys immediately
        via `/api-keys/{api_key_id}/revoke`.

        **Embedding in your own panel.** Same model as the parent section:
        forward the logged-in user's JWT, the user needs `api_keys:*` on
        the org, your subdomain needs to be in the CORS allowlist (or
        proxy via your BE). The Create endpoint returns the plaintext
        once — your FE owns the show-once modal.
      endpoints:
        - name: List API keys for service account
          method: GET
          path: /api/v1/service-accounts/{account_id}/api-keys
          auth: JWT Bearer
          permission: api_keys:read
          description: List metadata for every key on a service account. The plaintext is never returned here — only `key_prefix` for identification.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "api_key_id": "...", "key_prefix": "ak_live_a1b2", "name": "prod render bot", "permissions": ["museum:read"], "expires_at": 1751155200, "last_used_at": 1735693200, "created_at": 1735689600, "is_revoked": false, "is_expired": false }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
        - name: Create API key
          method: POST
          path: /api/v1/service-accounts/{account_id}/api-keys
          auth: JWT Bearer
          permission: api_keys:create
          description: |
            Mint a new API key. **The `key` field in the response is the only
            time the plaintext is ever exposed** — store it client-side
            immediately. Subsequent calls see only the hash and `key_prefix`.
          body:
            - name: name
              type: string
              description: Free-form label for the key.
            - name: permissions
              type: string[]
              required: true
              description: Permission strings granted to this key (subset of the service account's capabilities).
            - name: expires_in_days
              type: integer
              description: TTL in days. Omit for no expiry.
          example_body: |
            {
              "name": "prod render bot",
              "permissions": ["museum:read"],
              "expires_in_days": 180
            }
          example_response: |
            {
              "success": true,
              "data": {
                "api_key_id": "...",
                "key": "ak_live_a1b2c3d4e5f6...REDACTED_RAW_KEY...",
                "key_prefix": "ak_live_a1b2",
                "name": "prod render bot",
                "permissions": ["museum:read"],
                "expires_at": 1751155200,
                "created_at": 1735689600
              }
            }
        - name: Revoke API key
          method: POST
          path: /api/v1/api-keys/{api_key_id}/revoke
          auth: JWT Bearer
          permission: api_keys:revoke
          description: |
            Mark a key as revoked. The key continues to exist for audit
            purposes but stops authenticating immediately.
          body:
            - name: reason
              type: string
              description: Optional human-readable reason recorded on the audit log.
          example_body: |
            { "reason": "leaked in public repo" }
          example_response: |
            { "success": true, "data": null }
        - name: Delete API key
          method: DELETE
          path: /api/v1/api-keys/{api_key_id}
          auth: JWT Bearer
          permission: api_keys:delete
          description: |
            Permanently remove a key (hard delete). Prefer revoke for
            auditable history.
          example_response: |
            { "success": true, "data": null }
guides:
    - id: lost-device
      icon: "\U0001F4F1"
      title: Lost device / session recovery
      description: |
        What to do when a user reports a lost or stolen device, or just
        wants to audit where they're signed in. Three patterns: review,
        revoke-one, revoke-all.
      flow:
        - step: 1
          title: Sign in from a trusted device
          description: |
            The user logs in from a device they still control. This issues a
            fresh access + refresh pair on a new session id. **Existing
            sessions are not affected** — the lost device's session is still
            alive at this point.
          endpoint:
            method: POST
            path: /api/v1/auth/login
            service: account-service
            auth: none
            fields:
                - name: email
                  type: string
                  required: true
                  description: Account email.
                - name: password
                  type: string
                  required: true
                  description: Account password.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/login \
              -H 'Content-Type: application/json' \
              -d '{ "email": "user@example.com", "password": "SecurePass123!" }'
        - step: 2
          title: Review active sessions
          description: |
            Lists every active session on the account, with last-used IP and
            user-agent so the user can spot the rogue one. The session
            backing the current request is flagged with `is_current: true`.
          endpoint:
            method: GET
            path: /api/v1/me/sessions
            service: account-service
            auth: JWT Bearer
            permission: read:sessions
          curl_example_jwt: |
            curl https://account.example.com/api/v1/me/sessions \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            {
              "success": true,
              "data": {
                "sessions": [
                  { "session_id": "550e8400-...", "ip_address": "203.0.113.42", "user_agent": "Mozilla/5.0 (iPhone)", "created_at": 1735689600, "last_used_at": 1735693200, "expires_at": 1738281600, "is_current": false },
                  { "session_id": "660e8400-...", "ip_address": "198.51.100.7",  "user_agent": "Mozilla/5.0 (Macintosh)", "created_at": 1735693300, "last_used_at": 1735693300, "expires_at": 1738285200, "is_current": true }
                ]
              }
            }
        - step: 3
          title: Revoke just the lost device's session (option A)
          description: |
            Targeted revoke. Pass the suspected `session_id` from the list.
            The next time that device tries to refresh its access token, it
            will get `4003 Session has been revoked or expired` and be
            forced back to the login screen.
          endpoint:
            method: DELETE
            path: /api/v1/me/sessions/{session_id}
            service: account-service
            auth: JWT Bearer
          curl_example_jwt: |
            curl -X DELETE https://account.example.com/api/v1/me/sessions/550e8400-... \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            { "success": true, "data": null }
        - step: 4
          title: Or revoke EVERYTHING (option B — paranoid)
          description: |
            Sweep every active session for the account, including the one
            making the request. Use when the user can't tell which session
            is the rogue one, or after a credential leak. The browser cookies
            are also cleared on the response.

            After this, the user is logged out everywhere — they'll need to
            re-login on each device they actually want to keep.
          endpoint:
            method: POST
            path: /api/v1/auth/logout-all
            service: account-service
            auth: JWT Bearer
          actions:
            - type: link
              description: Consider also rotating your password →
              endpoint: '#change-or-set-password'
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/auth/logout-all \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            { "success": true, "data": { "success": true } }
    - id: mobile-google-signin
      icon: "\U0001F4F1"
      title: Sign in with Google (Mobile)
      description: |
        End-to-end integration of Google Sign-In for native Android and
        iOS apps targeting `account.ikavia.com`. The backend endpoint
        and account-resolution rules are identical to the web flow (see
        the **Sign in with Google** endpoint in the Authentication
        section); the platform-specific work is on the mobile side
        where each SDK mints a Google ID token, plus a small amount of
        setup in Google Cloud Console.

        The BE verifier accepts ID tokens whose `aud` claim matches
        **any** of the configured client ids — Web, Android, and iOS
        may each use their own. Mobile teams do NOT need to coordinate
        a single shared "server client id"; the BE handles a list of
        audiences from one Google Cloud project.

        **Status on `account.ikavia.com`:** both `/auth/google` and
        `/auth/firebase` are currently **live**. Web + Android
        OAuth Client IDs are wired into `/auth/google`; Firebase
        project `wacca-77277` is wired into `/auth/firebase`. iOS
        OAuth Client ID is the only piece still pending (waiting on
        bundle identifier from the mobile team). See Step 1 for the
        full provisioning table.

        ### Firebase Auth — two supported patterns

        Mobile teams that use Firebase Auth on the client have two
        equally-valid paths:

        - **Pattern A — dual-consume the raw Google token.** Sign in
          natively (Credential Manager / GoogleSignIn-iOS), POST the
          raw Google `idToken` to our `/auth/google`, and ALSO hand
          the same token to Firebase locally via
          `GoogleAuthProvider.credential(...)`. See the "Using
          Firebase Auth in parallel" subsections under steps 3 and 4.
        - **Pattern B — send the Firebase-issued token directly.**
          Sign in via Firebase (any provider: Google, Apple,
          Facebook, email/password), grab the Firebase ID token via
          `firebase.auth().currentUser.getIdToken()`, and POST it to
          the dedicated `/auth/firebase` endpoint. The BE verifies
          Firebase's signature, resolves the account via
          `firebase_uid → email → auto-create`, and returns the same
          `LoginResponse` shape as `/auth/google`. See step 14.

        Pattern A is recommended when the only provider is Google —
        it's simpler and avoids an extra Firebase initialization.
        Pattern B is recommended when the app supports multiple
        providers through Firebase (Apple Sign-In, Facebook, etc.) —
        one endpoint covers all of them.

        ## Sequence at a glance

        ```
        ┌──────────┐   1. open app           ┌─────────────┐
        │  Mobile  │ ────────────────────────│  Local      │
        │  App     │                         │  secure     │
        │          │   2. try cached tokens  │  storage    │
        │          │ ◄───────────────────────│ (Keystore   │
        │          │                         │  / Keychain)│
        └────┬─────┘                         └─────────────┘
             │
             │ 3. no token / expired → tap "Continue with Google"
             ▼
        ┌──────────┐   4. show account picker   ┌──────────┐
        │ Google   │ ─────────────────────────► │ User     │
        │ SDK      │                            └────┬─────┘
        │ (GIS)    │   5. pick account              │
        │          │ ◄──────────────────────────────┘
        │          │   6. mint ID token (signed JWT)
        └────┬─────┘
             │
             │ 7. POST /api/v1/auth/google { credential, ... }
             ▼
        ┌──────────────────────────┐
        │  account.ikavia.com      │
        │  - verify JWT vs JWKS    │
        │  - check iss + aud + exp │
        │  - find or auto-link or  │
        │    auto-create account   │
        │  - issue our access +    │
        │    refresh JWT pair      │
        └────┬─────────────────────┘
             │
             │ 8. response: { access_token, refresh_token, created_now, ... }
             ▼
        ┌──────────┐
        │  Mobile  │  9. persist tokens in Keystore/Keychain
        │  App     │ 10. attach `Authorization: Bearer <access>` to all
        │          │     subsequent API calls
        │          │ 11. on 401 → POST /auth/refresh → retry once
        │          │ 12. on logout → /auth/logout + clear local storage
        └──────────┘
        ```
      flow:
        - step: 1
          title: Provision platform-specific OAuth client ids in Google Cloud
          description: |
            The **Web** Client ID for `account.ikavia.com` already
            exists. Mobile needs **one additional Client ID per
            platform** in the same Google Cloud project. Both are
            created from
            [console.cloud.google.com/auth/clients](https://console.cloud.google.com/auth/clients)
            → **+ Create client**.

            **Android**

            - **Application type:** Android
            - **Package name:** the Android app's package
              (e.g. `com.ikavia.app`). Must match `applicationId` in
              `build.gradle`.
            - **SHA-1 certificate fingerprint:**
              - Debug keystore: `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android` → take the SHA1 line.
              - Release: same command pointing at your release keystore.
              - If using Play App Signing, take the SHA-1 from Play
                Console → **Setup → App signing → App signing key
                certificate**.
              - Add **both** debug and release fingerprints — or create
                two separate Client IDs if you prefer environment
                isolation.

            **iOS**

            - **Application type:** iOS
            - **Bundle ID:** the iOS app's bundle identifier
              (e.g. `com.ikavia.app`). Must match Xcode → target →
              **General → Identity → Bundle Identifier**.
            - **App Store ID** (optional): the numeric id from App
              Store Connect once published. Skip if pre-launch.

            Send the resulting Client IDs to ops; they'll be added to
            `APP__SECURITY__GOOGLE_OAUTH__CLIENT_IDS` (comma-separated)
            alongside the existing Web id, and the BE restarted.

            **Currently provisioned audiences on production**
            (as of this doc revision; ask ops for the live list):

            | Surface | Client ID / project | Endpoint enabled |
            |---|---|---|
            | Web | `836801358076-lfhjvt3l…apps.googleusercontent.com` | `/auth/google` ✅ |
            | Android | `836801358076-74v49lgq…apps.googleusercontent.com` | `/auth/google` ✅ |
            | iOS | *pending* — mobile team to supply bundle id | `/auth/google` (will accept once provisioned) |
            | Firebase | project `wacca-77277` | `/auth/firebase` ✅ |

            Both endpoints (`POST /api/v1/auth/google` and
            `POST /api/v1/auth/firebase`) are **live**. Smoke-tested
            on `account.ikavia.com` — bogus tokens get a clean 401
            rather than a 503, and Firebase JWKS is being cached in
            our Redis. Mobile team can integrate against either or
            both at any time without further ops coordination.
        - step: 2
          title: Backend — multi-audience configuration
          description: |
            Operator updates the deployment env so the verifier
            accepts the mobile tokens. Both forms below are accepted;
            the plural form is preferred.

            ```env
            # singular (legacy, still honoured)
            APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID=<web-client-id>.apps.googleusercontent.com

            # plural (preferred). Comma-separated.
            APP__SECURITY__GOOGLE_OAUTH__CLIENT_IDS=<web-id>.apps.googleusercontent.com,<android-id>.apps.googleusercontent.com,<ios-id>.apps.googleusercontent.com
            ```

            Both env vars are merged at startup — supplying either is
            fine. The startup log line `Sign-In-With-Google enabled`
            carries `count=<n>` so ops can confirm the right number of
            audiences was loaded. Restart `account-service` after
            editing.
        - step: 3
          title: Android — request an ID token via Credential Manager
          description: |
            The current Google-recommended path on Android is the
            [Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager-siwg)
            (`androidx.credentials:credentials`). The deprecated
            `GoogleSignInClient` still works but should not be used in
            new code.

            **Gradle (`app/build.gradle`):**

            ```gradle
            // Pin to a known-good range. As of May 2026 the latest
            // stable line is 1.3.x; bump only after a smoke test.
            implementation "androidx.credentials:credentials:1.3.0"
            implementation "androidx.credentials:credentials-play-services-auth:1.3.0"
            implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1"
            ```

            **Kotlin** — initiate the picker, get the ID token:

            ```kotlin
            val credentialManager = CredentialManager.create(context)
            val googleIdOption = GetGoogleIdOption.Builder()
                // false = show every Google account on the device
                // (best UX for first-time signin). true = only show
                // accounts that have previously signed in to this app.
                .setFilterByAuthorizedAccounts(false)
                // ANDROID Client ID from step 1.
                .setServerClientId("836801358076-74v49lgqvi48lf47ff4h90uon8pt5e8c.apps.googleusercontent.com")
                // Nonce binds this credential to one signin attempt;
                // currently not validated server-side but cheap to
                // include and useful when we tighten validation later.
                .setNonce(java.util.UUID.randomUUID().toString())
                .build()

            val request = GetCredentialRequest.Builder()
                .addCredentialOption(googleIdOption)
                .build()

            try {
                val result = credentialManager.getCredential(activityContext, request)
                val credential = result.credential
                if (credential is CustomCredential
                    && credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                    val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
                    val idToken: String = googleIdTokenCredential.idToken
                    // POST idToken to account.ikavia.com (see step 5).
                }
            } catch (e: NoCredentialException) {
                // Device has zero Google accounts, or user dismissed
                // the bottom sheet without picking. UX: surface a
                // friendly "Try a different way to sign in" message
                // and keep the email-password form available.
            } catch (e: GetCredentialCancellationException) {
                // User explicitly tapped "Cancel". Treat as a no-op,
                // do not show an error toast.
            } catch (e: GetCredentialException) {
                // Other transient SDK errors. Log + retry-friendly
                // toast: "Google sign-in temporarily unavailable.
                // Please try again."
            }
            ```

            **Handling `NoCredentialException`.** Common when the
            emulator has no signed-in Google account or the user wipes
            accounts in Settings. Don't crash; fall back gracefully —
            the email/password form is still wired and is the only
            path for users who deliberately don't keep a Google
            account on the device.

            ### Using Firebase Auth in parallel (optional)

            If the Android app *also* needs Firebase Auth on the
            client (for Firestore security rules, Cloud Functions
            callable auth, Crashlytics user binding, Remote Config
            personalization, etc.), use the same `idToken` for
            BOTH paths — our BE and Firebase. Account-service only
            accepts **raw Google ID tokens**; it does NOT verify
            Firebase-issued tokens (different `iss`, different
            JWKS, different `aud` format).

            ```kotlin
            // 1. idToken from Credential Manager above. Send it to
            //    our BE first so the network call is in-flight.
            val ourBackendDeferred = scope.async {
                loginToIkaviaWithGoogle(idToken)  // POST /api/v1/auth/google
            }

            // 2. In parallel, hand the SAME idToken to Firebase
            //    so client-side Firebase services know who the
            //    user is. Firebase mints its own internal token
            //    locally — we don't send it anywhere.
            val firebaseCredential = GoogleAuthProvider.getCredential(idToken, null)
            FirebaseAuth.getInstance().signInWithCredential(firebaseCredential).await()

            // 3. Both paths now reflect the same user.
            val ourBackendResponse = ourBackendDeferred.await()
            ```

            Key points:

            - The `idToken` is single-use against Google's verifier
              but **both** consumers (our BE and Firebase) verify
              it independently — passing it to one does not consume
              it for the other. They can run in any order or in
              parallel.
            - Firebase will mint a Firebase UID for the user that
              is **different** from the `account_id` returned by
              our BE. Don't try to reconcile them; treat the two
              as orthogonal identities — Firebase UID for
              client-side Firebase rules, our `account_id` for
              anything that talks to `account.ikavia.com`.
            - On logout, call `FirebaseAuth.getInstance().signOut()`
              alongside the steps in section 11.
        - step: 4
          title: iOS — request an ID token via GoogleSignIn-iOS
          description: |
            Use the official [GoogleSignIn-iOS](https://github.com/google/GoogleSignIn-iOS) Swift package. Add it to the project via **File → Add Packages**:

            ```
            https://github.com/google/GoogleSignIn-iOS
            ```

            **`Info.plist`** — register the URL scheme Google uses to
            return to your app. The scheme is the iOS Client ID
            **reversed** (e.g. Client ID
            `123-abc.apps.googleusercontent.com` becomes scheme
            `com.googleusercontent.apps.123-abc`):

            ```xml
            <key>CFBundleURLTypes</key>
            <array>
                <dict>
                    <key>CFBundleURLSchemes</key>
                    <array>
                        <string>com.googleusercontent.apps.<reversed-ios-client-id></string>
                    </array>
                </dict>
            </array>
            ```

            Recent GoogleSignIn-iOS releases (7.x+) use
            `ASWebAuthenticationSession` under the hood and may not
            strictly require the URL scheme for the basic ID-token
            flow, but registering it is still recommended — it's the
            documented fallback path and costs nothing.

            **Swift** — initiate the sign-in. The caller must be a
            live `UIViewController` (or `NSViewController` on
            macOS Catalyst); pass it as `presenting`:

            ```swift
            import GoogleSignIn

            // AppDelegate / SceneDelegate — handle the redirect back.
            func application(_ app: UIApplication, open url: URL,
                             options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
                GIDSignIn.sharedInstance.handle(url)
            }

            // From any UIViewController (the one mounting the
            // "Continue with Google" button is the natural choice):
            let config = GIDConfiguration(clientID: "<ios-client-id>.apps.googleusercontent.com")
            GIDSignIn.sharedInstance.configuration = config
            GIDSignIn.sharedInstance.signIn(withPresenting: self) { result, error in
                if let error = error {
                    // GIDSignInError.canceled is the "user dismissed"
                    // case — treat as a silent no-op. Other codes
                    // (network, presenter-not-available) deserve a
                    // toast.
                    if (error as NSError).code == GIDSignInError.canceled.rawValue { return }
                    // surface other errors to the user
                    return
                }
                guard let user = result?.user,
                      let idToken = user.idToken?.tokenString else { return }
                // POST idToken to account.ikavia.com (see step 5).
            }
            ```

            ### Using Firebase Auth in parallel (optional)

            Same idea as Android: send `idToken` to our BE, and
            *also* hand it to Firebase if the iOS app uses any
            Firebase client SDK (Firestore, Functions, Crashlytics
            user binding, Remote Config). Our BE does NOT accept
            Firebase-issued tokens — only raw Google ones.

            ```swift
            import FirebaseAuth
            import GoogleSignIn

            GIDSignIn.sharedInstance.signIn(withPresenting: self) { result, error in
                guard error == nil,
                      let user = result?.user,
                      let idToken = user.idToken?.tokenString else { return }
                let accessToken = user.accessToken.tokenString

                // 1. POST to our BE (kick off network early).
                Task { await loginToIkaviaWithGoogle(idToken: idToken) }

                // 2. In parallel, sign the user into Firebase locally.
                //    Firebase verifies the SAME idToken against
                //    Google's JWKS and mints its own internal
                //    session — we don't send the Firebase token
                //    anywhere.
                let credential = GoogleAuthProvider.credential(
                    withIDToken: idToken,
                    accessToken: accessToken,
                )
                Auth.auth().signIn(with: credential) { _, error in
                    // handle error; the BE login can still succeed
                    // independently if this fails.
                }
            }
            ```

            Same caveats as Android:

            - `idToken` can be verified by both Google (Firebase
              does this) and our BE in any order; the two paths
              are independent.
            - The Firebase UID (`Auth.auth().currentUser?.uid`)
              is **not** the same identifier as our
              `account_id`. Keep them in separate variables and
              send the right one to the right service.
            - On logout, call `try? Auth.auth().signOut()`
              alongside the steps in section 11.
        - step: 5
          title: POST the ID token to account-service
          description: |
            Identical contract for both platforms — `idToken` is the
            raw JWT the SDK just returned. The response shape mirrors
            password login, with two mobile-relevant additions:

            - `created_now` — `true` if THIS request created the
              account. Use it to gate first-time onboarding (welcome
              screen, profile completion prompt). `false` for every
              subsequent login.
            - `csrf_token` — **mobile clients should ignore this
              field.** It exists for browser-side CSRF defence and is
              irrelevant when the caller authenticates with
              `Authorization: Bearer ...`.

            `device_timezone` / `device_language` are applied **only
            on auto-signup** (first-ever sign-in for that email). On
            subsequent logins they are ignored — the user owns those
            settings via the profile endpoint.
          endpoint:
            method: POST
            path: /api/v1/auth/google
            service: account-service
            content_type: application/json
            auth: none
            fields:
                - name: credential
                  type: string
                  required: true
                  description: The Google ID token (raw JWT) returned by the platform SDK.
                - name: persist_session
                  type: bool
                  description: Cosmetic on mobile — mobile clients ignore the SSO refresh cookie this controls and just read access_token + refresh_token from the response body. Send `true` (default) to be consistent with web.
                - name: device_timezone
                  type: string
                  description: IANA timezone (e.g. `Asia/Jakarta`). Used as the default only when this request triggers auto-signup. Length cap 64 chars; invalid / overlong values silently fall back to UTC.
                - name: device_language
                  type: string
                  description: ISO 639-1 language code (e.g. `id`). Applied only on auto-signup. Length cap 8 chars; invalid / overlong fall back to `en`.
          curl_example: |
            curl -X POST https://account.ikavia.com/api/v1/auth/google \
              -H 'Content-Type: application/json' \
              -d '{
                "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y...",
                "device_timezone": "Asia/Jakarta",
                "device_language": "id"
              }'
          response_example: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@gmail.com",
                "display_name": "Jane Doe",
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 7200,
                "organizations": [ { "...": "..." } ],
                "current_org_id": "550e8400-...",
                "requires_email_verification": false,
                "created_now": true,
                "csrf_token": "f3a1c0..."
              }
            }
        - step: 6
          title: Error responses and how to handle them
          description: |
            The endpoint returns one of these on failure. **Always
            read `code` (numeric) rather than HTTP status** — the
            mapping is stable while HTTP status may collapse multiple
            codes onto the same number.

            - **`code: 4003` HTTP 401 — `invalid google credential`.**
              Token signature failed, expired, has the wrong audience,
              wrong issuer, or `email_verified=false` from Google.
              UX: surface "We couldn't sign you in with Google. Please
              try again or use email/password." Do NOT retry
              automatically — the token won't get better.
            - **`code: 4005` HTTP 409 — `google_sub` duplicate.**
              The same Google identity is already linked to a
              different account. Almost certainly a developer-side
              mistake (testing with a stale build that hardcoded a
              sub). Surface "This Google account is already linked to
              another user." and log full response for triage.
            - **`code: 4008` HTTP 429 — rate limit exceeded.**
              Five-per-minute-per-IP. The response body carries
              `retry_after` (seconds). UX: disable the button for
              `retry_after` seconds, show a countdown.
            - **`code: 5000` HTTP 503 — feature disabled.**
              Operator hasn't configured any Client ID. Should never
              happen in prod once integrated; if it does, log and tell
              the user "Google sign-in is temporarily unavailable" —
              this is an ops problem, not a user-recoverable error.
            - **Network / 5xx.** Treat as transient. Allow the user
              to retry; consider a short auto-retry with exponential
              backoff (1s → 3s → 9s) before showing a permanent error.

            **Common Google-side issues that produce `4003`:**

            - Wrong `serverClientId` in the SDK call — the BE doesn't
              recognise that audience. Ops must add it to
              `CLIENT_IDS`.
            - User signed into a Google Workspace account whose admin
              blocks third-party app access. Nothing the app can do;
              ask the user to try a personal Google account.
            - Clock skew on the device. The verifier tolerates 60s of
              skew but anything beyond that breaks `exp` validation.
              Encourage user to enable automatic time on the device.
        - step: 7
          title: Secure token storage
          description: |
            The `access_token` has a short lifetime (2 hours) but the
            `refresh_token` is long-lived (7 days; see the deployment's
            `APP__SECURITY__JWT__REFRESH_TOKEN_TTL_DAYS`) and grants
            the same authority as the user's password while it
            remains valid. It MUST NOT be stored in any plaintext key/value
            store.

            **Android** — use the Keystore-backed
            [EncryptedSharedPreferences](https://developer.android.com/topic/security/data):

            ```kotlin
            val masterKey = MasterKey.Builder(context)
                .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
                .build()
            val prefs = EncryptedSharedPreferences.create(
                context,
                "ikavia_auth",
                masterKey,
                EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
                EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
            )
            prefs.edit()
                .putString("access_token", accessToken)
                .putString("refresh_token", refreshToken)
                .putLong("access_expires_at_epoch_ms",
                         System.currentTimeMillis() + expiresInSeconds * 1000L)
                .apply()
            ```

            For higher assurance (financial / health data), use the
            AndroidKeyStore directly with a hardware-backed key —
            `setUserAuthenticationRequired(true)` forces a biometric
            unlock per use.

            **iOS** — use Keychain. The simplest path is the
            [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess)
            wrapper. Pin a recent version (4.x).

            ```swift
            let keychain = Keychain(service: "com.ikavia.app.auth")
                .accessibility(.whenUnlockedThisDeviceOnly)
            try keychain.set(accessToken, key: "access_token")
            try keychain.set(refreshToken, key: "refresh_token")
            try keychain.set(String(accessExpiresAtUnix), key: "access_expires_at_unix")
            ```

            `whenUnlockedThisDeviceOnly` prevents the token from
            syncing to iCloud Keychain or restoring to a different
            device — the right default for an auth credential.

            **Never log `refresh_token`** — scrub it from analytics
            and crash reporting payloads. Configure Crashlytics /
            Sentry / Datadog to redact keys named `refresh_token`,
            `access_token`, `credential`, `Authorization`.
        - step: 8
          title: Bootstrap on app launch
          description: |
            On every cold start, the app needs to decide whether the
            user is still signed in. The recommended algorithm:

            1. Read `access_token`, `refresh_token`,
               `access_expires_at_*` from secure storage.
            2. If no `refresh_token` → show the sign-in screen. Done.
            3. If `access_expires_at_*` is in the future with at
               least 60s of headroom → user is "signed in"; mount the
               home screen and attach the cached `access_token` to
               the first protected call.
            4. Otherwise → call `POST /auth/refresh` (step 9) *before*
               the first protected call. On success update storage
               and proceed. On failure (any error) → wipe local
               tokens and route to sign-in.

            The 60-second headroom in step 3 prevents the rare race
            where an `access_token` is valid at the moment of the
            first request but expires mid-flight. Adjust if your
            backend latency is unusually high.
        - step: 9
          title: Refresh-on-401 policy
          description: |
            On HTTP 401 from any protected endpoint, the app should:

            1. **Acquire a lock** (mutex / coroutine actor). If a
               refresh is already in flight, *wait* for that result
               rather than firing a parallel refresh. Two parallel
               refreshes against the same `refresh_token` rotate it
               at the same time, then both retries 401-fail because
               the old `refresh_token` was just revoked by the
               first call.
            2. Call `/auth/refresh` once.
            3. **On success** — update storage, retry the original
               request exactly once with the new `access_token`. If
               *that* also 401s, give up (the server thinks the user
               is gone; don't infinite-loop) — wipe local tokens,
               route to sign-in.
            4. **On 401 from refresh itself** — the session was
               revoked server-side (logout-all, admin reset, refresh
               token theft detection). Wipe local tokens, route to
               sign-in. Do NOT call `/auth/refresh` again.
            5. **On network / 5xx from refresh** — surface the error;
               don't wipe tokens. The user retries the action and the
               refresh is attempted again.
          endpoint:
            method: POST
            path: /api/v1/auth/refresh
            service: account-service
            content_type: application/json
            auth: none
            fields:
                - name: refresh_token
                  type: string
                  required: true
                  description: The refresh token stored in step 7. NOT the access token.
          curl_example: |
            curl -X POST https://account.ikavia.com/api/v1/auth/refresh \
              -H 'Content-Type: application/json' \
              -d '{ "refresh_token": "eyJ..." }'
          response_example: |
            {
              "success": true,
              "data": {
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 7200
              }
            }
        - step: 10
          title: 'Attach `Authorization: Bearer …` to every protected call'
          description: |
            After login or refresh succeeds, every subsequent call to
            a protected endpoint (anything under `/api/v1` that isn't
            `/auth/...`) must carry the access token in the
            `Authorization` header. There is no cookie path for
            mobile — mobile clients do NOT participate in the SSO
            refresh-cookie flow that web uses.

            **Android (OkHttp interceptor):**

            ```kotlin
            class BearerAuthInterceptor(
                private val tokens: () -> String?, // reads from EncryptedSharedPreferences
            ) : Interceptor {
                override fun intercept(chain: Interceptor.Chain): Response {
                    val req = chain.request().newBuilder().apply {
                        tokens()?.let { addHeader("Authorization", "Bearer $it") }
                    }.build()
                    return chain.proceed(req)
                }
            }
            ```

            **iOS (URLSession):**

            ```swift
            var request = URLRequest(url: url)
            if let token = keychain["access_token"] {
                request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
            }
            ```
        - step: 11
          title: Logout (and the difference vs full Google revoke)
          description: |
            There are two distinct user actions, with two different
            server-side effects:

            - **"Log out"** — sign out of *this app*, but keep the
              Google account linked. Next time the user opens the app
              and taps "Continue with Google", they get the same
              account back with one tap (Google remembers them).
            - **"Disconnect Google account"** — fully revoke the
              authorization grant on Google's side. The next sign-in
              attempt re-prompts for consent. Rare; offer it as a
              buried option in account settings.

            The flow for plain logout:

            1. Call `/auth/logout` to revoke the session on the BE.
            2. Wipe local secure storage.
            3. Call the platform SDK's local-sign-out so the next
               Credential Manager / GIDSignIn invocation re-prompts:

               - **Android (Credential Manager):**
                 `credentialManager.clearCredentialState(ClearCredentialStateRequest())`
               - **iOS (GoogleSignIn):**
                 `GIDSignIn.sharedInstance.signOut()`

            For "disconnect", *also* call
            `GIDSignIn.sharedInstance.disconnect { … }` on iOS, or
            revoke via the Google account-settings web link on
            Android (the Credential Manager API has no equivalent
            revoke call as of May 2026).
          endpoint:
            method: POST
            path: /api/v1/auth/logout
            service: account-service
            content_type: application/json
            auth: JWT Bearer
            fields:
                - name: refresh_token
                  type: string
                  required: true
                  description: The refresh token to revoke. After this call the JTI is added to the revocation list and the session row is marked inactive.
          curl_example: |
            curl -X POST https://account.ikavia.com/api/v1/auth/logout \
              -H 'Authorization: Bearer <access_token>' \
              -H 'Content-Type: application/json' \
              -d '{ "refresh_token": "eyJ..." }'
        - step: 12
          title: Testing checklist
          description: |
            Run through this checklist on each platform before
            shipping the integration. None of these scenarios require
            coordinating with ops — just one debug build and a couple
            of Google accounts.

            **Happy path**

            - Sign in with a brand-new Google account whose email is
              NOT in our DB. Expect: `created_now: true`,
              `display_name` matches the Google account name,
              timezone / language reflect what the device sent.
            - Sign out, sign in again with the same Google account.
              Expect: `created_now: false`, same `account_id` as
              before, same `display_name`.
            - Sign in with a Google account whose email IS already in
              our DB (register that email first via the web flow,
              then sign in with Google on mobile). Expect:
              `created_now: false`, same `account_id` the web account
              has, and from that point the user can sign in with
              EITHER password or Google.

            **Edge cases**

            - Device with zero Google accounts → `NoCredentialException`
              (Android) or `signIn` error (iOS). UI should not crash
              and should keep email/password available.
            - Tap "Cancel" on the account picker → silent no-op, no
              error toast, no log.
            - Airplane mode during sign-in → SDK error before the BE
              call. Toast should be retry-friendly.
            - Airplane mode during the `/auth/google` POST → network
              error after the SDK succeeded. The Google credential is
              already consumed; the UI should let the user retry the
              same login (the SDK will mint a fresh credential).
            - Sign-in with the same Google account on two devices →
              both should succeed and own independent sessions. Logging
              out on device A must NOT log out device B.
            - `/auth/refresh` after the refresh-token TTL (currently
              7 days) of inactivity → expected
              to fail with 401. Confirm the app routes to sign-in
              without crashing.

            **Security checks**

            - Inspect the app's `EncryptedSharedPreferences` /
              Keychain entries with the debugger. Confirm
              `refresh_token` is unreadable from a non-app process.
            - Inspect Crashlytics / Sentry payloads on a forced
              crash. Confirm no auth field is present.
            - Run `frida` or equivalent against a release build with
              ProGuard / R8 on. Confirm Client IDs are not the only
              secret protecting the flow (they aren't — Google's
              signature is).
        - step: 13
          title: Operational notes
          description: |
            A small grab-bag of facts that catch teams off-guard
            once they've shipped:

            - **Same-project requirement.** Android / iOS / Web
              Client IDs must live in the *same* Google Cloud
              project. A token minted in project A cannot be
              verified by a key from project B.
            - **`email_verified=false` is rejected.** If Google can't
              confirm the user owns the email, the BE returns 401.
              This happens for federated Workspace accounts that
              haven't completed verification on Google's side; the
              user should resolve it with their admin, not retry.
            - **Account linking is automatic by email.** If the
              user's Google email already has a password row in our
              DB (created via `/auth/register`), the first Google
              sign-in **auto-links** — sets `google_sub` on the
              existing row. No conflict prompt; Google's email
              verification is considered sufficient proof of
              ownership.
            - **Account creation is automatic.** If the email is new,
              account-service creates a row + personal organization
              + five system roles in one transaction. The response's
              `created_now` is `true` and
              `requires_email_verification` is `false`.
            - **`sub` is the stable identifier.** Email can change
              owner inside a Google Workspace; `sub` never does. The
              BE persists `accounts.google_sub` on first link and
              uses it as the fast path on subsequent logins, falling
              back to email match only when `google_sub` is null.
            - **JWKS is cached.** The BE caches Google's signing
              keys in Redis (TTL ≈ 6 hours by default, derived from
              Google's `Cache-Control` header). A key rotation
              forces a one-shot refresh on the next `kid` miss.
              Operators normally don't need to do anything around
              rotation.
        - step: 14
          title: 'Alternative endpoint: POST a Firebase token to /auth/firebase'
          description: |
            When the app uses Firebase Auth as its primary client-side
            auth layer — typically to unlock multi-provider sign-in
            (Apple, Facebook, Microsoft, …) without writing a verifier
            per provider — send the Firebase-issued ID token to the
            dedicated `/auth/firebase` endpoint instead of unwrapping
            to a raw Google token.

            **Acquiring the Firebase ID token (Android):**

            ```kotlin
            // After FirebaseAuth.getInstance().signInWithCredential(...)
            // completes successfully:
            val firebaseUser = FirebaseAuth.getInstance().currentUser
                ?: return  // shouldn't happen if signIn succeeded
            firebaseUser.getIdToken(/* forceRefresh = */ false)
                .addOnSuccessListener { result ->
                    val firebaseIdToken = result.token  // <-- send THIS
                    // POST to /api/v1/auth/firebase
                }
            ```

            **Acquiring the Firebase ID token (iOS):**

            ```swift
            // After Auth.auth().signIn(with: credential) completes:
            Auth.auth().currentUser?.getIDToken { idToken, error in
                guard let firebaseIdToken = idToken else { return }
                // POST firebaseIdToken to /api/v1/auth/firebase
            }
            ```

            **Account resolution differs from `/auth/google`** in
            one key way: the link column on `accounts` is
            `firebase_uid` (Firebase's UID), NOT `google_sub`. A user
            who signs in via BOTH endpoints ends up with both columns
            populated on the same row, linked by their verified
            email — Step 13 (Operational notes) covers the linking
            semantics in detail.

            The wire format is identical to `/auth/google`:
          endpoint:
            method: POST
            path: /api/v1/auth/firebase
            service: account-service
            content_type: application/json
            auth: none
            fields:
                - name: credential
                  type: string
                  required: true
                  description: Firebase ID token (raw JWT). `iss=https://securetoken.google.com/<project_id>`, `aud=<project_id>`. Anonymous / custom-token sign-ins (no email) are rejected with 4003.
                - name: persist_session
                  type: bool
                  description: Cosmetic on mobile; send `true` (default) for consistency with web.
                - name: device_timezone
                  type: string
                  description: Applied only on auto-signup. Length cap 64, invalid → UTC.
                - name: device_language
                  type: string
                  description: Applied only on auto-signup. Length cap 8, invalid → `en`.
          curl_example: |
            curl -X POST https://account.ikavia.com/api/v1/auth/firebase \
              -H 'Content-Type: application/json' \
              -d '{
                "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...",
                "device_timezone": "Asia/Jakarta",
                "device_language": "id"
              }'
          response_example: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "email": "user@gmail.com",
                "display_name": "Jane Doe",
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 7200,
                "organizations": [ { "...": "..." } ],
                "current_org_id": "550e8400-...",
                "requires_email_verification": false,
                "created_now": false,
                "csrf_token": "f3a1c0..."
              }
            }
    - id: onboarding
      icon: "\U0001F680"
      title: Onboard a new tenant
      description: |
        End-to-end flow from a brand-new email address to a working
        multi-member organization. Covers registration, email verification,
        tenant org creation, and inviting the first teammate.
      flow:
        - step: 1
          title: Register
          description: |
            Creates a human account + a personal organization (the user is
            the owner) in a single transaction. Returns access + refresh
            tokens immediately — the user is auto-logged-in. A verification
            email is queued via the `email.requested` event.
          endpoint:
            method: POST
            path: /api/v1/auth/register
            service: account-service
            auth: none
            fields:
                - name: email
                  type: string
                  required: true
                  description: Unique across all accounts.
                - name: password
                  type: string
                  required: true
                  description: Min 8, must include upper/lower/digit/special.
                - name: display_name
                  type: string
                  required: true
                  description: Used for the personal org name.
                - name: timezone
                  type: string
                  description: IANA, default UTC.
                - name: language
                  type: string
                  description: ISO 639-1, default en.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/register \
              -H 'Content-Type: application/json' \
              -d '{
                "email": "user@example.com",
                "password": "SecurePass123!",
                "display_name": "Jane Doe",
                "timezone": "Asia/Jakarta",
                "language": "id"
              }'
          response_example: |
            {
              "success": true, "code": 2010,
              "data": {
                "account_id": "550e8400-...",
                "email": "user@example.com",
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 900,
                "personal_org": { "org_id": "550e8400-...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "my_role": "owner", "my_permissions": ["*"], "...": "..." }
              }
            }
        - step: 2
          title: Verify email (out-of-band)
          description: |
            The user clicks the link in the verification email. Account
            service hosts the verification page itself at `GET /verify-email`
            (HTML, outside `/api/v1`). Once verified, the user's NEXT issued
            token (login, register, or refresh) carries `permissions: ["*"]`
            instead of the read-only fallback. **Existing tokens issued before
            verification keep their reduced perms** until refreshed.

            If the email never arrived:
          endpoint:
            method: POST
            path: /api/v1/auth/resend-verification
            service: account-service
            auth: none
            fields:
                - name: email
                  type: string
                  required: true
                  description: Email to resend to. Always returns success regardless of existence.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/resend-verification \
              -H 'Content-Type: application/json' \
              -d '{ "email": "user@example.com" }'
          response_example: |
            { "success": true, "data": { "message": "If the account exists and is unverified, a new verification email has been sent." } }
        - step: 3
          title: Refresh after verification
          description: |
            To upgrade an in-flight session from read-only to full perms
            without forcing the user to re-login, hit `/auth/refresh`. The
            new access token will reflect the now-verified status.
          endpoint:
            method: POST
            path: /api/v1/auth/refresh
            service: account-service
            auth: none
            fields:
                - name: refresh_token
                  type: string
                  description: Falls back to refresh_token cookie when omitted.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/refresh \
              -H 'Content-Type: application/json' \
              -d '{ "refresh_token": "eyJ..." }'
          response_example: |
            {
              "success": true,
              "data": {
                "access_token": "eyJ...new_with_full_perms...",
                "refresh_token": "eyJ...",
                "expires_in": 900,
                "...": "..."
              }
            }
        - step: 4
          title: Create a team org (optional)
          description: |
            The personal org is private to the user. To collaborate, create a
            shared org. The caller becomes its owner.
          endpoint:
            method: POST
            path: /api/v1/organizations
            service: account-service
            auth: JWT Bearer
            fields:
                - name: name
                  type: string
                  required: true
                  description: Display name.
                - name: slug
                  type: string
                  description: URL-safe; auto-derived if omitted.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/organizations \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{ "name": "Acme Corp", "slug": "acme-corp" }'
          response_example: |
            {
              "success": true,
              "data": { "org_id": "550e8400-...", "name": "Acme Corp", "slug": "acme-corp", "owner_id": "550e8400-...", "status": "active", "...": "..." }
            }
        - step: 5
          title: Invite first teammate
          description: |
            Sends an invitation email. Recipient accepts via the email link
            (which lives on the frontend; account-service emits the
            `email.requested` event with the token). Requires
            `members:invite` permission in the org.
          endpoint:
            method: POST
            path: /api/v1/organizations/{org_id}/members
            service: account-service
            auth: JWT Bearer
            permission: members:invite
            fields:
                - name: email
                  type: string
                  required: true
                  description: Invitee email.
                - name: role_id
                  type: string
                  description: Assign on accept; defaults to org default member role.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../members \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{ "email": "teammate@example.com", "role_id": "550e8400-..." }'
          response_example: |
            { "success": true, "data": { "invitation_id": "550e8400-...", "message": "Invitation sent to teammate@example.com" } }
        - step: 6
          title: Switch to the new org (when ready)
          description: |
            The invitation creator's tokens still point at their personal
            org. Switching reissues the token bound to the team org without
            re-login. Session id is preserved across the switch.
          endpoint:
            method: POST
            path: /api/v1/organizations/{org_id}/switch
            service: account-service
            auth: JWT Bearer
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../switch \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            {
              "success": true,
              "data": { "access_token": "eyJ...new...", "refresh_token": "eyJ...new...", "current_org_id": "550e8400-...", "...": "..." }
            }
    - id: password-reset
      icon: "\U0001F511"
      title: Password reset (forgot password)
      description: |
        End-to-end password reset for users who can't log in. Two endpoints
        and one out-of-band email step. Both endpoints always return success
        to prevent account enumeration — meaningful failures (invalid token,
        weak password) are surfaced only on the confirm call when the user
        actually has a valid token in hand.
      flow:
        - step: 1
          title: Request the reset
          description: |
            Generates a 1-hour JWT carrying `permissions: ["password:reset"]`
            and emits the `email.requested` event. The token's hash is stored
            in Redis so it can only be used once.

            **Always** returns success regardless of whether the email
            exists — checking the existence here would leak account
            membership to anyone who can reach the endpoint.
          endpoint:
            method: POST
            path: /api/v1/auth/password-reset
            service: account-service
            auth: none
            fields:
                - name: email
                  type: string
                  required: true
                  description: Account email.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/password-reset \
              -H 'Content-Type: application/json' \
              -d '{ "email": "user@example.com" }'
          response_example: |
            { "success": true, "data": null }
        - step: 2
          title: User clicks link in email (out-of-band)
          description: |
            The email contains a frontend URL with the token as a query
            parameter (or fragment, depending on frontend convention). The
            frontend renders a "set new password" form. Account-service
            itself also hosts a fallback HTML form at `GET /reset-password`
            (outside `/api/v1`) for out-of-the-box use.

            Token TTL is 1 hour. After expiry the user must restart from
            step 1.
        - step: 3
          title: Confirm with new password
          description: |
            Validates the token (signature + jti not previously used + not
            expired), updates the password hash, and **revokes every active
            session for the account** so any in-flight access tokens stop
            validating immediately. The user must log in again with the new
            password.
          endpoint:
            method: POST
            path: /api/v1/auth/password-reset/confirm
            service: account-service
            auth: none
            fields:
                - name: token
                  type: string
                  required: true
                  description: JWT from the email link.
                - name: new_password
                  type: string
                  required: true
                  description: Same complexity rules as registration.
          actions:
            - type: link
              description: Now log in with the new password →
              endpoint: '#login'
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/password-reset/confirm \
              -H 'Content-Type: application/json' \
              -d '{
                "token": "eyJ...reset_token...",
                "new_password": "NewSecurePass456!"
              }'
          response_example: |
            { "success": true, "data": { "success": true } }
    - id: service-to-service
      icon: "\U0001F916"
      title: Service-to-service authentication
      description: |
        How a non-human consumer (job runner, ML pipeline, internal
        automation) authenticates against your APIs. Covers creating a
        service account, minting an API key, exchanging it for a JWT, and
        revoking on leak.
      flow:
        - step: 1
          title: Create the service account
          description: |
            A service account lives inside one organization and has
            capabilities (e.g. `museum:read`, `artifact:write`) that bound
            what its keys may grant. Requires `service_accounts:create`
            in the target org.
          endpoint:
            method: POST
            path: /api/v1/service-accounts
            service: account-service
            auth: JWT Bearer
            permission: service_accounts:create
            fields:
                - name: organization_id
                  type: string
                  required: true
                  description: Org to attach to.
                - name: display_name
                  type: string
                  required: true
                  description: Human-readable name.
                - name: description
                  type: string
                  description: Optional.
                - name: capabilities
                  type: string[]
                  required: true
                  description: resource:action format.
                - name: allowed_tools
                  type: string[]
                  required: true
                  description: Subset of tool ids the service may invoke.
                - name: rate_limit_per_minute
                  type: integer
                  description: 1-10000, default 60.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/service-accounts \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{
                "organization_id": "550e8400-...",
                "display_name": "Render bot",
                "capabilities": ["museum:read", "artifact:write"],
                "allowed_tools": ["thumbnailer"],
                "rate_limit_per_minute": 120
              }'
          response_example: |
            { "success": true, "data": { "account_id": "550e8400-...", "status": "active", "capabilities": ["museum:read","artifact:write"], "...": "..." } }
        - step: 2
          title: Mint an API key
          description: |
            The plaintext `key` is returned **only here, only once** — store
            it client-side immediately (e.g. in a secret manager). Subsequent
            list/get calls expose only `key_prefix` for identification.
            Requires `api_keys:create`.
          endpoint:
            method: POST
            path: /api/v1/service-accounts/{account_id}/api-keys
            service: account-service
            auth: JWT Bearer
            permission: api_keys:create
            fields:
                - name: name
                  type: string
                  description: Free-form label.
                - name: permissions
                  type: string[]
                  required: true
                  description: Subset of the service account's capabilities.
                - name: expires_in_days
                  type: integer
                  description: Omit for no expiry.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/service-accounts/550e8400-.../api-keys \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{
                "name": "prod render bot",
                "permissions": ["museum:read"],
                "expires_in_days": 180
              }'
          response_example: |
            {
              "success": true,
              "data": {
                "api_key_id": "550e8400-...",
                "key": "ak_live_a1b2c3d4...REDACTED...",
                "key_prefix": "ak_live_a1b2",
                "name": "prod render bot",
                "permissions": ["museum:read"],
                "expires_at": 1751155200,
                "created_at": 1735689600
              }
            }
        - step: 3
          title: Use the key directly (option A)
          description: |
            Cheapest integration: send `X-API-Key` on every protected request.
            The middleware accepts it identically to a Bearer JWT. Suitable
            for low-throughput automations.
          endpoint:
            method: GET
            path: /api/v1/me
            service: account-service
            auth: API Key
          curl_example_api_key: |
            curl https://account.example.com/api/v1/me \
              -H 'X-API-Key: ak_live_a1b2c3d4...'
          response_example: |
            { "success": true, "data": { "account_id": "550e8400-...", "account_type": "service", "display_name": "Render bot", "...": "..." } }
        - step: 4
          title: Or exchange for a JWT (option B — recommended)
          description: |
            For high-throughput callers, swap the key for a 1-hour service JWT
            and validate locally with `/auth/public-key`. Reduces account-service
            load from per-request to ~once an hour per pod. The JWT carries
            `principal_type: service` and `sid: <nil-uuid>`.
          endpoint:
            method: POST
            path: /api/v1/auth/token-exchange
            service: account-service
            auth: API Key
          curl_example_api_key: |
            curl -X POST https://account.example.com/api/v1/auth/token-exchange \
              -H 'X-API-Key: ak_live_a1b2c3d4...'
          response_example: |
            {
              "success": true,
              "data": {
                "access_token": "eyJ...service_jwt...",
                "token_type": "Bearer",
                "expires_in": 3600,
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "permissions": ["museum:read"],
                "principal_type": "service"
              }
            }
        - step: 5
          title: Call downstream services with the JWT
          description: |
            Downstream services fetch `/auth/public-key` once, cache by `kid`,
            then verify the JWT locally on every request — no roundtrip to
            account-service per call. The `permissions` claim is derived from
            the API key's permissions at exchange time.
          endpoint:
            method: GET
            path: /v1/<resource>
            service: downstream-service
            auth: JWT Bearer
          curl_example_jwt: |
            curl https://media.example.com/v1/artifacts/123 \
              -H 'Authorization: Bearer eyJ...service_jwt...'
        - step: 6
          title: Revoke on leak
          description: |
            When a key leaks (committed to a public repo, posted in a Slack
            channel, etc.), revoke immediately. The key continues to exist
            for audit/forensics but stops authenticating. Use DELETE to hard-
            remove (loses audit trail).
          endpoint:
            method: POST
            path: /api/v1/api-keys/{api_key_id}/revoke
            service: account-service
            auth: JWT Bearer
            permission: api_keys:revoke
            fields:
                - name: reason
                  type: string
                  description: Recorded on the audit log.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/api-keys/550e8400-.../revoke \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{ "reason": "leaked in public repo" }'
          response_example: |
            { "success": true, "data": null }
permissions:
    - name: '*'
      description: Wildcard — all permissions. Carried by verified human access tokens. Membership/role grants in the org also use `*` for the system 'owner' role.
    - name: read:profile
      description: Read your own account profile. Issued on tokens for unverified-email users.
    - name: read:organizations
      description: List the organizations you belong to. Issued on tokens for unverified-email users.
    - name: read:sessions
      description: List your own active sessions. Issued on tokens for unverified-email users.
    - name: members:invite
      description: POST /organizations/{org_id}/members.
    - name: members:remove
      description: DELETE /organizations/{org_id}/members/{account_id}.
    - name: members:update_role
      description: PUT /organizations/{org_id}/members/{account_id}/role.
    - name: invitations:cancel
      description: Cancel a pending invitation (gRPC only today).
    - name: organizations:update
      description: PUT /organizations/{org_id}.
    - name: roles:create
      description: POST /organizations/{org_id}/roles.
    - name: roles:update
      description: PUT /organizations/{org_id}/roles/{role_id}.
    - name: roles:delete
      description: DELETE /organizations/{org_id}/roles/{role_id}.
    - name: service_accounts:create
      description: POST /service-accounts.
    - name: service_accounts:read
      description: GET /service-accounts and GET /service-accounts/{id}.
    - name: service_accounts:update
      description: PUT /service-accounts/{id}.
    - name: service_accounts:archive
      description: DELETE /service-accounts/{id}.
    - name: service_accounts:pause
      description: POST /service-accounts/{id}/pause.
    - name: service_accounts:resume
      description: POST /service-accounts/{id}/resume.
    - name: api_keys:create
      description: POST /service-accounts/{id}/api-keys.
    - name: api_keys:read
      description: GET /service-accounts/{id}/api-keys.
    - name: api_keys:revoke
      description: POST /api-keys/{id}/revoke.
    - name: api_keys:delete
      description: DELETE /api-keys/{id}.
    - name: audit_logs:read
      description: GET /organizations/{org_id}/activities. The /me/activities feed is filtered to the calling user and needs no extra permission.
    - name: password:reset
      description: Embedded only in the JWT issued by /auth/password-reset (1 h TTL). Not user-grantable.
constraints:
    - Every authenticated request resolves to one organization (`org_id` in the JWT). Cross-org actions require switching first.
    - Refresh tokens rotate on every `/auth/refresh`. Presenting an old refresh token after rotation revokes the entire session.
    - Access tokens are bound to a session id (`sid`) for human principals and to `nil` for service principals. Logout / sessions revocation only applies to human sessions.
    - 'Personal organization is auto-created on registration with the user as owner. The owner role is non-deletable (`is_system: true`).'
    - Service accounts cannot log in interactively. They authenticate via API key only.
flow_diagram_nodes:
    - id: client
      label: "\U0001F4BB Client (web/mobile)"
      type: client
      color: '#0ea5e9'
    - id: account
      label: "\U0001F464 Account Service"
      type: service
      color: '#4f46e5'
    - id: postgres
      label: "\U0001F418 PostgreSQL"
      type: external
      color: '#64748b'
    - id: redis
      label: "\U0001F7E5 Redis"
      type: external
      color: '#64748b'
    - id: rabbitmq
      label: "\U0001F407 RabbitMQ"
      type: queue
      color: '#8b5cf6'
    - id: email_service
      label: ✉️ Email Service
      type: service
      color: '#10b981'
    - id: jwt
      label: "\U0001F511 RS256 JWT"
      type: data
      color: '#f59e0b'
    - id: downstream
      label: "\U0001F9E9 Downstream services"
      type: service
      color: '#10b981'
flow_diagram_edges:
    - source: client
      target: account
      label: auth + CRUD
      animated: true
      color: '#0ea5e9'
    - source: account
      target: postgres
      label: accounts / orgs / sessions
      color: '#4f46e5'
    - source: account
      target: redis
      label: sessions cache, rate limit, JTI revocation
      color: '#4f46e5'
    - source: account
      target: jwt
      label: issues
      color: '#f59e0b'
    - source: client
      target: downstream
      label: Bearer JWT
      animated: true
      color: '#0ea5e9'
    - source: downstream
      target: account
      label: GET /auth/public-key
      color: '#10b981'
      style: dashed
    - source: account
      target: rabbitmq
      label: publish email.requested + account.updated
      animated: true
      color: '#4f46e5'
    - source: rabbitmq
      target: email_service
      label: consume
      color: '#8b5cf6'
    - source: rabbitmq
      target: account
      label: consume profile.update_requested
      animated: true
      color: '#8b5cf6'
    - source: downstream
      target: rabbitmq
      label: publish profile.update_requested
      color: '#10b981'
      style: dashed
    - source: rabbitmq
      target: downstream
      label: consume account.updated
      color: '#8b5cf6'
      style: dashed
api_tester_defaults:
    methods:
        - GET
        - POST
        - PUT
        - PATCH
        - DELETE
    auth_modes:
        - name: JWT Bearer
          header: Authorization
          prefix: 'Bearer '
          placeholder: YOUR_JWT_ACCESS_TOKEN
        - name: API Key
          header: X-API-Key
          placeholder: YOUR_API_KEY
        - name: none
          header: Authorization
events:
    - id: replica-consumer-contract
      title: Event contract — quick map for replica consumers
      description: |
        One-page checklist for sibling services that maintain a local
        read-only projection of account state. Each row maps to the
        detail section below. If you skim only one block in this page,
        read this one.

        **Got a specific "is this delta or snapshot / does X event
        fire / how do I order / how do I dedup" question?** Jump to
        [`replica-consumer-faq`](#replica-consumer-faq) — that section
        answers the exact questions we receive from consumer teams,
        in their phrasing, with the authoritative answer inline.

        ## Status against the standard replica checklist

        | Need                                                     | Status | Where it lives                                                                  |
        | -------------------------------------------------------- | ------ | ------------------------------------------------------------------------------- |
        | Payload schema for every event touching account data     | ✅     | Per-event sections below. Field list with type + required-ness for all 25 events. |
        | `account.account.created`                                | ✅     | Section [`Account Created (outbound)`](#account-created). Body has `account_id`, `account_type`, `email`, `organization_id`, `timestamp`. |
        | `account.account.deleted`                                | ✅     | Section [`Account Deleted (outbound)`](#account-deleted). Body has `account_id`, `deleted_by`, `was_email`, `timestamp`. Fires from both admin two-step hard-delete AND service-initiated transactional rollback. |
        | `account.account.deactivated` (soft-close tombstone hint) | ✅     | Section [`Account Deactivated (outbound)`](#account-deactivated). Row stays queryable (status='deactivated'). Use this OR `account.account.deleted` per your tombstone semantics. |
        | `event_id` — idempotency key                             | ✅     | Every event body has `event_id` (UUID). Mirrored to AMQP header `x-event-id` so middleware can dedup without parsing JSON. |
        | `occurred_at` — ordering marker                          | ✅     | Every event body has `timestamp` (RFC3339, or `joined_at`/`changed_at`/`left_at` on a few). Mirrored to AMQP header `x-occurred-at`. Discard inbound when `x-occurred-at < replica.last_applied_at`. |
        | `schema_version` on the envelope                         | ✅     | AMQP header `x-schema-version: "1"`. Bumped on a breaking body change for a given routing key. Stringly typed so cross-language clients parse uniformly. |
        | Delivery semantics documented                            | ✅     | Section [`Delivery semantics + AMQP headers`](#delivery-semantics). TL;DR: best-effort publish, at-least-once consume, ordering not globally guaranteed, persistent (`delivery_mode=2`), DB is source of truth. |
        | Final exchange + routing keys                            | ✅     | Exchange `account.events` (topic, durable). All routing keys in section [`Events catalogue`](#events-catalogue). |

        ## Minimum-viable consumer (pseudocode)

        ```
        on_message(headers, body):
            eid = headers["x-event-id"]                          # dedup key
            if already_applied(eid): return ack()
            occurred_at = parse_rfc3339(headers["x-occurred-at"])
            rk = headers["x-event-type"]                         # e.g. "account.updated"
            if rk == "account.deleted":
                tombstone(body["AccountDeleted"]["account_id"])
                mark_applied(eid); return ack()
            if rk == "account.deactivated":
                mark_inactive(body["AccountDeactivated"]["account_id"])
                mark_applied(eid); return ack()
            if rk == "account.updated" or rk == "account.created":
                account_id = first_value_of(body)["account_id"]
                if occurred_at < replica.last_applied_at(account_id):
                    return ack()                                 # stale, skip
                refetch_via_rest_and_upsert(account_id, occurred_at)
                mark_applied(eid); return ack()
            # ...other events handled per business need
        ```

        ## What replicas should NOT depend on

        - Per-routing-key ordering — multiple publisher workers can
          reorder; always trust `x-occurred-at`.
        - Field values for fields NOT in this contract. Existing field
          types/names are stable; net-new optional fields may be added
          without a version bump.
        - The wire format of these variants — they are declared in the
          `DomainEvent` enum but never emitted: `auth.login_failed`,
          `auth.logout`, `auth.session_revoked`. Don't build handlers
          for them; we'll announce + version-bump if that changes.

        ## REST companion for backfill / reconciliation

        Pair this with the S2S lookup endpoints when bootstrapping a
        replica or running periodic reconciliation:

        - `GET  /api/v1/accounts/{id}`     → single fetch.
        - `POST /api/v1/accounts/bulk-lookup` (≤ 100 ids/request) → batch refresh.

        Daily reconciliation: enumerate every `account_id` your replica
        holds → bulk-lookup → drop entries that 404 → upsert the rest.
    - id: replica-consumer-faq
      title: Event contract — frequently-asked semantics
      description: |
        The exact questions consumer teams send us when wiring up an
        account-events replica. Each answer is authoritative — bookmark
        this section if your team is at the "do I trust this assumption?"
        stage of integration. Cross-references jump to the detail
        section that backs the answer.

        ## Q: Is `account.account.updated` delta or snapshot?

        **A: Delta-by-default with explicit clear-to-null signal.** The
        payload is not a snapshot of the account row; it carries the
        changed fields, no more. Read `updated_fields[]` FIRST — that
        array is authoritative on what changed in this commit.

        Three-state decision per mirror field (`display_name`, `bio`,
        `phone_number`, `timezone`, `language`, `avatar_media_id`,
        `username`):

        | Field name in `updated_fields[]`? | Field key present in body? | Meaning                        |
        | --------------------------------- | -------------------------- | ------------------------------ |
        | No                                | (irrelevant)               | **Unchanged** — don't touch    |
        | Yes                               | Yes                        | **New value** — overwrite      |
        | Yes                               | No                         | **Cleared to null** — null out |

        The third row is the one most consumers miss. Absent fields are
        omitted by the wire-format and that absence is meaningful when
        the name appears in `updated_fields[]`. Treating "absent =
        unchanged" without the array check will silently keep stale
        values when a user clears their avatar / bio / phone.

        `email` never appears here — email changes ride a dedicated
        [`account.account.email_changed`](#account-email-changed)
        event with both `old_email` and `new_email`. Detail:
        [`account-updated`](#account-updated).

        ## Q: Does a deactivated account ever come back to Active?

        **A: No. Deactivated is terminal for the `account_id`.** There
        is no reactivation transition, and no event is published for
        one. A replica that tombstones the `account_id` on
        `account.account.deactivated` is correct and complete for that
        identity.

        If the same human comes back, they re-register with the same
        email. That produces a fresh row with a **new `account_id`**
        (different UUID) and a fresh `account.account.created` event.
        Email matches the old account; `account_id` does not.

        Pattern for replicas that want to detect re-registration:

        1. On `account.account.deactivated` → tombstone the old
           `account_id` permanently.
        2. Subscribe `account.account.created` and key the handler
           on the body's `email` in addition to `account_id`.
        3. If the email matches a tombstoned row, record a bridge
           (`old_account_id → new_account_id`) in your own table.
           Do not reuse the old `account_id`.

        Detail: [`account-deactivated`](#account-deactivated).

        ## Q: My replica missed events during a deploy / outage. How do I catch up?

        **A: Pair the event stream with the S2S REST resolvers.**

        Events are best-effort publish, at-least-once consume — a
        broker outage on our side or a deploy on yours can drop or
        delay messages. The DB is the source of truth; the events are
        the fast path. Reconcile periodically:

        - `GET /api/v1/accounts/{id}` — single fetch when a specific
          replica entry looks stale.
        - `POST /api/v1/accounts/bulk-lookup` (≤ 100 ids/request) —
          batch refresh.
        - For replicas with > 100 accounts: chunk the bulk-lookup at
          100 per request.

        Daily reconciliation pattern: enumerate every `account_id` your
        replica holds → bulk-lookup → drop entries that 404 → upsert
        the rest. Catches anything the publisher dropped during a
        broker outage. Detail: [`delivery-semantics`](#delivery-semantics).

        ## Q: Can I bind to wildcards (e.g., `account.account.*`)?

        **A: Yes.** The exchange `account.events` is a topic exchange.
        Bind your queue with any RabbitMQ topic wildcard:

        - `account.account.*` — every account-lifecycle event.
        - `account.org.*` — every org-lifecycle event.
        - `account.#` — every event we publish (broad, mostly for
          debugging — production consumers should bind narrowly).

        Wildcard binding doesn't change delivery semantics; you still
        get at-least-once with the same headers per event.

        ## Q: How do I order events when two arrive out of sequence?

        **A: Use the `x-occurred-at` AMQP header.** Per-routing-key
        ordering is NOT guaranteed (multiple publisher workers can
        reorder). Keep a `last_applied_occurred_at` per record in your
        replica; discard inbound events whose `x-occurred-at` is older.

        Don't trust delivery order, even within the same `account_id`.

        ## Q: How do I dedup at-least-once redeliveries?

        **A: Use the `x-event-id` AMQP header.** Every event carries a
        unique UUID stamped both in the body (`event_id`) and at the
        AMQP envelope level (`x-event-id`). Persist `(routing_key,
        event_id)` per applied event and skip duplicates.

        Header form is the easier integration point — middleware can
        dedup without parsing the JSON body. Detail:
        [`delivery-semantics`](#delivery-semantics).

        ## Q: How will I know when the event contract changes?

        **A: The AMQP header `x-schema-version` is the version
        marker.** Today every event ships `x-schema-version: "1"`.
        Additive changes (new optional fields) do NOT bump the
        version — consumers tolerating unknown fields keep working.
        Breaking changes (field renamed, type changed, semantic
        changed) DO bump it. Compare on string equality and fail-
        closed on an unrecognised value.

        ## Q: Which variants in the `DomainEvent` enum are NOT emitted today?

        **A: Three variants are reserved but never published —
        `auth.login_failed`, `auth.logout`, `auth.session_revoked`.**
        Don't build handlers for them; we'll announce + version-bump
        if any of those start firing. `auth.login_success` IS emitted
        but only on the SSO login paths (Google / Firebase) — password
        login writes only to `audit_logs`.
    - id: events-catalogue
      title: Events catalogue
      description: |
        Every domain event the service publishes (and the one it
        consumes) at a glance. **Exchange:** `account.events` (topic).
        Every emitted event's routing key is prefixed with `account.`
        by the publisher, so the table below shows the **wire** key
        (what consumers bind to), not the raw `event_type` string in
        the envelope.

        The envelope is always `{"<VariantName>": { ... }}` — that's
        the `DomainEvent` enum tag plus the per-event payload. Bind
        to `account.account.*` for every account-lifecycle event,
        `account.org.*` for every org-lifecycle event, etc., or to a
        specific key like `account.account.updated` for one event.

        Detailed payload schemas are in the sections below the catalogue.

        ## Account lifecycle (outbound)

        | Routing key                       | Emitted when                                                                 |
        | --------------------------------- | ---------------------------------------------------------------------------- |
        | `account.account.created`         | A new account is registered (human via `/register` or service via admin UI). |
        | `account.account.activated`       | Account transitions Pending → Active (verify-email click, SSO activation).   |
        | `account.account.deactivated`     | User calls `POST /me/deactivate`. Soft close (row still queryable).          |
        | `account.account.deleted`         | Account row is **physically** removed (admin two-step hard-delete or service-initiated transactional rollback). Replicas should tombstone immediately. |
        | `account.account.email_changed`   | Email change confirm flow completes. Old + new email in payload.             |
        | `account.account.password_changed`| Password is changed (self via `/me/password`, or admin reset).               |
        | `account.account.updated`         | Mirror-field change (display_name / bio / phone / timezone / language / avatar). |

        ## Auth (outbound)

        | Routing key                  | Emitted when                                                |
        | ---------------------------- | ----------------------------------------------------------- |
        | `account.auth.login_success` | SSO login completes (Google / Firebase). NOT emitted by the password login path today — that path writes only to `audit_logs`. |

        The following auth variants exist in the `DomainEvent` enum
        but are **not currently emitted by any code path**:
        `auth.login_failed`, `auth.logout`, `auth.session_revoked`.
        Reserved for future use — don't build consumers expecting them.

        ## Organization lifecycle (outbound)

        | Routing key                        | Emitted when                                                |
        | ---------------------------------- | ----------------------------------------------------------- |
        | `account.org.created`              | New organization is created.                                |
        | `account.org.updated`              | Organization profile fields change.                         |
        | `account.org.deleted`              | Organization is deleted (admin hard-delete cascade).        |
        | `account.org.member_joined`        | Account joins an org (via invitation accept or signup).     |
        | `account.org.member_left`          | Account leaves an org (self or admin removal).              |
        | `account.org.role_changed`         | A member's role changes inside an org.                      |
        | `account.org.member_invited`       | An invitation is issued.                                    |
        | `account.org.invitation_accepted`  | Invitation is accepted (companion to `org.member_joined`).  |
        | `account.org.invitation_cancelled` | Invitation is cancelled by the inviter / admin.             |

        ## Service-account + API-key lifecycle (outbound)

        | Routing key                            | Emitted when                                       |
        | -------------------------------------- | -------------------------------------------------- |
        | `account.service_account.created`      | An org creates a service account.                  |
        | `account.service_account.updated`      | Service-account profile/config changes.            |
        | `account.service_account.paused`       | Service account is paused.                         |
        | `account.service_account.resumed`      | Service account is resumed.                        |
        | `account.service_account.archived`     | Service account is archived (irreversible).        |
        | `account.api_key.created`              | API key is issued for a service account.           |
        | `account.api_key.revoked`              | API key is revoked.                                |

        ## Email delivery (outbound)

        | Routing key             | Emitted when                                                              |
        | ----------------------- | ------------------------------------------------------------------------- |
        | `account.email.requested` | Service needs an outbound email sent (verification, reset, invitation, etc.). Consumed by `email-service`. See the detailed section below. |

        ## Inbound

        | Routing key                              | Consumed when                                                          |
        | ---------------------------------------- | ---------------------------------------------------------------------- |
        | `account.profile.update_requested`       | A sibling service wants to update a user's profile (username, display_name, bio, timezone, language) without holding the user's JWT. |
    - id: delivery-semantics
      title: Delivery semantics + AMQP headers
      description: |
        The contract sibling services should code against. Stable;
        changes to this block constitute a wire-level event.

        ## Exchange + persistence

        - **Exchange:** `account.events` — topic, durable. Bind your
          queue with the routing keys from the catalogue above (or
          wildcards like `account.account.*`).
        - **Persistence:** every message is published with
          `delivery_mode=2` (persistent). Events survive a broker
          restart in the queue they're delivered to.
        - **Best-effort publish:** the publisher is in-process and
          bounded. If the broker is down, the user-facing request that
          triggered the event still succeeds — the publish is queued
          for retry (default 3 attempts with backoff) and dropped if
          retries are exhausted. **The DB row is the source of truth.**
          Consumer reconciliation jobs should periodically diff
          replicas against the REST S2S lookup for state that matters.
        - **At-least-once delivery:** the broker stores until the
          consumer acks. A consumer crash or `nack` triggers
          redelivery, so handlers MUST be idempotent (the `x-event-id`
          / `event_id` makes this easy — see "Headers" below).
        - **Ordering** is **NOT** globally guaranteed. Per-routing-key
          ordering is also not guaranteed: events that fan-out across
          publisher worker threads can arrive out-of-order. Use
          `x-occurred-at` (or `body.timestamp`) at the consumer to
          decide whether to apply an event — discard an event whose
          `occurred_at` is older than the replica's last-applied
          timestamp for that record.

        ## Envelope shape

        The JSON body is the externally-tagged `DomainEvent` enum:

        ```json
        { "AccountUpdated": { "event_id": "...", "account_id": "...", ... } }
        ```

        The only exception is `email.requested` — it serializes the
        inner `EmailRequestedEvent` flat (no enum wrapper) because
        `email-service` was built against that shape long before this
        catalogue existed. Don't depend on flat-vs-wrapped consistency
        across all events.

        ## AMQP message headers

        Every published message carries the following AMQP headers
        (in `BasicProperties.headers`). Consumers can read these
        without parsing the JSON body — useful for routing, dedup, and
        ordering decisions in middleware.

        | Header             | Type                  | Purpose                                                                                          |
        | ------------------ | --------------------- | ------------------------------------------------------------------------------------------------ |
        | `x-event-id`       | string (UUID)         | Unique per event. Mirror of `body.event_id`. Use as your dedup key for at-least-once redelivery. |
        | `x-event-type`     | string                | Routing key without the `account.` prefix (e.g. `account.updated`, `org.member_joined`).         |
        | `x-occurred-at`    | string (RFC3339)      | Wall-clock time the event happened. Use for out-of-order discard.                                |
        | `x-schema-version` | string (currently `"1"`) | Bumped on a breaking change to the event body for that routing key. If you see a higher value than you understand, stop applying and alert. |
        | `x-trace-id`       | string (UUID)         | Trace id from the request that produced the event. Carry into your handler's tracing context for end-to-end correlation. |

        Headers are stringly typed across the board so cross-language
        AMQP clients (Go / Python / Node) deserialize them uniformly
        without integer-type juggling.

        ## Bumping `x-schema-version`

        `"1"` is the current value for every event in this catalogue.
        Additive payload changes (new optional fields) do NOT bump the
        version — consumers tolerate unknown fields. Removing or
        renaming a field, changing a field type, or changing the
        envelope shape DOES bump it. Consumers should compare on
        string equality and fail-closed on an unrecognised version.

        ## Reconciliation guidance for replicas

        A consumer that maintains a local read-only projection of
        account state should:

        1. **Idempotency**: persist `(routing_key, x-event-id)` per
           applied event. Skip duplicates.
        2. **Ordering**: store `last_applied_occurred_at` per record;
           discard inbound events older than that.
        3. **Tombstone fast**: on `account.account.deleted` mark the
           local row tombstoned in the same transaction that records
           the event id. On `account.account.deactivated`, tombstone
           (or mark inactive) — the row is **terminal**: the domain
           rejects any `Deactivated → Active` transition with
           `AlreadyDeactivated`, so the same `account_id` will not
           come back. If the same user later re-registers with the
           same email, that's a brand-new row with a brand-new
           `account_id` (`account.account.created` fires) — see
           "Reactivation" in [`account-deactivated`](#account-deactivated).
        4. **Reconcile periodically**: a daily job that fetches every
           live `account_id` via the S2S lookup catches anything the
           publisher dropped on a broker outage. Without this, gaps
           can persist indefinitely.
    - id: email-requested
      title: Email Requested
      description: |
        Emitted when the service needs an outbound email — verification link on
        registration, resent verification, or password reset. The email service
        consumes this event and renders/sends the actual mail. Account service
        never talks to SMTP directly.
      protocol: amqp
      address: 'exchange: account.events / routing key: email.requested'
      operations:
        - type: publish
          summary: Outbound email request
          description: One event per outbound mail. The email service is the only documented subscriber today.
          payload:
            - name: event_type
              type: string
              required: true
              description: Always 'email.requested' for this channel.
              example: email.requested
            - name: to
              type: string
              required: true
              description: Recipient email address.
            - name: template
              type: string
              required: true
              description: 'One of: email_verification, password_reset.'
              example: email_verification
            - name: display_name
              type: string
              description: Used for greeting line.
            - name: token
              type: string
              required: true
              description: Verification or reset token (raw — hash is what's stored server-side).
            - name: base_url
              type: string
              required: true
              description: Frontend base URL used to construct the click-through link.
            - name: is_resend
              type: bool
              description: Only set for verification template; false on first send.
          example: |
            {
              "event_type": "email.requested",
              "to": "user@example.com",
              "template": "email_verification",
              "display_name": "Jane Doe",
              "token": "8x...redacted...",
              "base_url": "https://app.example.com",
              "is_resend": false
            }
    - id: profile-update-requested
      title: Profile Update Requested (inbound)
      description: |
        Inbound channel: other services can ask account-service to edit a
        user's `username` and / or `display_name` on their behalf. The
        account service owns the canonical row; this event is how a
        caller (planner, email, …) proposes a change without holding the
        user's JWT.

        Trust model is **intra-cluster** — the broker runs on the host
        network and only local services hold credentials. Every published
        message is accepted as authoritative; include `actor` and
        `correlation_id` so audits and cross-system traces stay joinable.

        Validation happens at the consumer. Username rules: 3-64 chars,
        charset `[A-Za-z0-9._@+\-]`, case-insensitive uniqueness. Failures
        classify as either **poison** (validation, not-found, conflict —
        message is dropped, not requeued) or **transient** (DB hiccup —
        requeued with exponential backoff). A successful apply triggers
        an `account.account.updated` publish (see below) so subscribers
        learn the new value without an HTTP round-trip.

        Omitted / explicit-null fields are no-ops — the consumer never
        wipes a column from a missing key. Sending the message with all
        editable fields null is silently ack'd (treated as a no-op).
      protocol: amqp
      address: 'exchange: account.events / routing key: account.profile.update_requested / queue: account.profile.update_requested'
      operations:
        - type: subscribe
          summary: Inbound profile-update request
          description: |
            The account-service runs a single durable consumer on the
            `account.profile.update_requested` queue (bound to the
            `account.events` topic exchange with the matching routing
            key). Prefetch = 8.
          payload:
            - name: account_id
              type: string (uuid)
              required: true
              description: Target account whose profile is being edited.
            - name: username
              type: string
              description: New handle. 3-64 chars, charset `[A-Za-z0-9._@+\-]`. Omit / null to leave unchanged.
            - name: display_name
              type: string
              description: New display name. Omit / null to leave unchanged.
            - name: bio
              type: string
              description: Optional bio update.
            - name: timezone
              type: string
              description: Optional IANA timezone.
            - name: language
              type: string
              description: Optional ISO 639-1 code.
            - name: actor
              type: string
              description: Free-form audit hint, e.g. `planner.assign-task`. Logged, not persisted.
            - name: correlation_id
              type: string
              description: Caller-chosen id echoed in logs so the caller's trace + ours are joinable.
          example: |
            {
              "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
              "username": "jane.d",
              "display_name": "Jane D.",
              "actor": "planner.assign-task",
              "correlation_id": "3f8a-1c2b-9912"
            }
    - id: account-updated
      title: Account Updated (outbound)
      description: |
        Emitted after any successful change to a profile row — whether
        the trigger was the HTTP `PUT /api/v1/me` endpoint, the avatar
        change, or the inbound `account.profile.update_requested` event.
        Downstream services use this to refresh their cached mirror of
        the user's profile without an HTTP round-trip.

        The payload carries the **new value of every changed mirror
        field**: `username`, `display_name`, `bio`, `phone_number`,
        `timezone`, `language`, `avatar_media_id`.

        **Consumer contract.** `updated_fields` is authoritative — it
        lists exactly what changed. For each field named there, read the
        same-named key for the new value. A field that IS in
        `updated_fields` but whose key is **absent** was *cleared to null*
        (e.g. avatar removed, phone cleared) — null values are omitted via
        `skip_serializing_if` to keep the payload small. A field NOT in
        `updated_fields` did not change: ignore it, do not wipe your
        cached value.

        `email` is **not** carried here — email changes ship via the
        dedicated `account.account.email_changed` event. `metadata` is
        also omitted (can be large / service-account-only); fetch via
        HTTP if you mirror it.

        Routing: published with key `account.account.updated` (the
        publisher prefixes every event_type with `account.`). Bind your
        queue to `account.account.updated` for this single event or
        `account.account.*` to receive every account-lifecycle change.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.updated'
      operations:
        - type: publish
          summary: Profile mirror update
          description: |
            Best-effort: a broker outage logs and continues — the DB row
            is the source of truth. Consumers should be idempotent
            (events may be redelivered after a retry).

            The outer JSON envelope is the `DomainEvent` enum tag
            (`{"AccountUpdated": { ... }}`), matching every other
            non-email account event published by this service.
          payload:
            - name: AccountUpdated.event_id
              type: string (uuid)
              required: true
              description: Unique id for this emission. Use it for at-least-once dedup.
            - name: AccountUpdated.account_id
              type: string (uuid)
              required: true
              description: Subject account.
            - name: AccountUpdated.updated_fields
              type: string[]
              required: true
              description: Authoritative list of fields that changed (e.g. `["display_name", "avatar_media_id"]`).
            - name: AccountUpdated.username
              type: string
              description: New username. Present when `updated_fields` includes `username`.
            - name: AccountUpdated.display_name
              type: string
              description: New display name. Present when `updated_fields` includes `display_name`.
            - name: AccountUpdated.bio
              type: string
              description: New bio. Present when changed; absent-but-listed = cleared.
            - name: AccountUpdated.phone_number
              type: string
              description: New phone number. Present when changed; absent-but-listed = cleared.
            - name: AccountUpdated.timezone
              type: string
              description: New IANA timezone. Present when `updated_fields` includes `timezone`.
            - name: AccountUpdated.language
              type: string
              description: New ISO 639-1 language. Present when `updated_fields` includes `language`.
            - name: AccountUpdated.avatar_media_id
              type: string (uuid)
              description: New avatar media id. Present when changed; absent-but-listed = avatar cleared.
            - name: AccountUpdated.timestamp
              type: string (rfc3339)
              required: true
              description: When the change committed.
          example: |
            {
              "AccountUpdated": {
                "event_id": "c96d13d6-6e31-4eea-9038-881899623f74",
                "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
                "updated_fields": ["display_name", "avatar_media_id", "phone_number"],
                "display_name": "Muhamiyan",
                "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
                "timestamp": "2026-05-19T16:46:32.001Z"
              }
            }

            # Note: phone_number is in updated_fields but absent above —
            # that means it was cleared to null in this commit.
    - id: account-email-changed
      title: Account Email Changed (outbound)
      description: |
        Emitted after a user confirms an email change (the
        verify-new-email flow: `POST /api/v1/me/email`, then the
        `/confirm-email-change` link). Carries **both** the old and new
        address so downstream services can update their mirror and
        re-key any email-indexed records.

        Email is deliberately **not** part of `account.account.updated` —
        it gets this dedicated event because the change is a verified
        two-step flow and the *old* value matters to consumers. The
        `new_email`
        is already verified at emission time (clicking the confirm link
        proved ownership).

        Routing: published with key `account.account.email_changed`.
        Bind to it directly, or to `account.account.*` for every
        account-lifecycle change.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.email_changed'
      operations:
        - type: publish
          summary: User email changed (verified)
          description: |
            Best-effort publish (the DB row is the source of truth;
            consumers must be idempotent — redelivery is possible). The
            outer JSON envelope is the `DomainEvent` enum tag
            (`{"AccountEmailChanged": { ... }}`), like every other
            non-email account event.
          payload:
            - name: AccountEmailChanged.event_id
              type: string (uuid)
              required: true
              description: Unique id for this emission. Use it for at-least-once dedup.
            - name: AccountEmailChanged.account_id
              type: string (uuid)
              required: true
              description: Subject account.
            - name: AccountEmailChanged.old_email
              type: string
              description: Previous email. Absent/null if the account had none (rare).
            - name: AccountEmailChanged.new_email
              type: string
              required: true
              description: New, already-verified email. Mirror this.
            - name: AccountEmailChanged.timestamp
              type: string (rfc3339)
              required: true
              description: When the change committed.
          example: |
            {
              "AccountEmailChanged": {
                "event_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
                "old_email": "old@example.com",
                "new_email": "new@example.com",
                "timestamp": "2026-05-29T03:50:00Z"
              }
            }
    - id: account-created
      title: Account Created (outbound)
      description: |
        Emitted right after a new account row is persisted — via human
        registration (`POST /auth/register`), SSO bootstrap (Google /
        Firebase first-login), or admin-driven creation. Carries the
        personal org id so a consumer can stand up tenant-side state
        in the same hop.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.created'
      operations:
        - type: publish
          summary: New account provisioned
          description: |
            Envelope is `{"AccountCreated": { ... }}`. Best-effort
            publish — consumers should be idempotent on `event_id`.
          payload:
            - name: AccountCreated.event_id
              type: string (uuid)
              required: true
              description: Unique id for this emission.
            - name: AccountCreated.account_id
              type: string (uuid)
              required: true
              description: Newly created account.
            - name: AccountCreated.account_type
              type: string
              required: true
              description: '`human` or `service`.'
            - name: AccountCreated.email
              type: string
              description: Email (absent for service accounts that have none).
            - name: AccountCreated.organization_id
              type: string (uuid)
              required: true
              description: Default org. For human accounts this is the personal org; for service accounts it's the owning org.
            - name: AccountCreated.timestamp
              type: string (rfc3339)
              required: true
              description: When created.
          example: |
            {
              "AccountCreated": {
                "event_id": "c96d13d6-6e31-4eea-9038-881899623f74",
                "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
                "account_type": "human",
                "email": "jane@example.com",
                "organization_id": "f2c355f1-82aa-4d72-b729-1a17be6216ef",
                "timestamp": "2026-06-01T08:14:22Z"
              }
            }
    - id: account-activated
      title: Account Activated (outbound)
      description: |
        Status transitions `Pending` → `Active`. Triggered by the
        verify-email link click, by an SSO login that implicitly verifies
        the account, or by the platform-admin force-verify endpoint.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.activated'
      operations:
        - type: publish
          summary: Account is now usable
          description: |
            Envelope `{"AccountActivated": { ... }}`. Consumers gating
            access on `status=active` should refresh their mirror.
          payload:
            - name: AccountActivated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: AccountActivated.account_id
              type: string (uuid)
              required: true
              description: Activated account.
            - name: AccountActivated.timestamp
              type: string (rfc3339)
              required: true
              description: When activated.
          example: |
            { "AccountActivated": { "event_id": "...", "account_id": "...", "timestamp": "..." } }
    - id: account-deactivated
      title: Account Deactivated (outbound)
      description: |
        User self-deactivated via `POST /me/deactivate`. The row's
        `status` is set to `deactivated` AND `deleted_at` is stamped, so
        the email frees up for re-registration and every active session
        is revoked. Consumers that mirror user state should mark the
        account closed.

        ## Reactivation — important for replica consumers

        `Deactivated` is a **terminal state for this `account_id`**.
        The domain `entity.activate()` method rejects any
        `Deactivated → Active` transition with `AlreadyDeactivated`,
        so:

        - There is **no** `account.account.activated` event for a
          deactivated row. Don't wait for one.
        - There is **no** "undeactivate" RPC. The same `account_id`
          will never come back to `Active`.
        - If the same human user comes back, they re-register with
          the same email. Because the uniqueness check filters
          `deleted_at IS NULL`, the soft-deleted row does not block
          the new registration. The new registration produces:
          - A **new `account_id`** (different UUID).
          - A fresh `account.account.created` event whose
            `email` matches the old account but whose `account_id`
            does not.

        Replica strategy: on `account.account.deactivated` mark the
        old `account_id` tombstoned. To detect "user came back",
        subscribe to `account.account.created` and key on `email` —
        a fresh `AccountCreated` whose email matches a previously
        tombstoned row is a re-registration. Bridge identity in
        your own table; do NOT try to revive the old `account_id`.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.deactivated'
      operations:
        - type: publish
          summary: Account closed by user
          description: |
            Envelope `{"AccountDeactivated": { ... }}`. `reason` is free
            text from the user (where the UI captures it) or absent.
          payload:
            - name: AccountDeactivated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: AccountDeactivated.account_id
              type: string (uuid)
              required: true
              description: Deactivated account.
            - name: AccountDeactivated.reason
              type: string
              description: Optional user-supplied reason.
            - name: AccountDeactivated.timestamp
              type: string (rfc3339)
              required: true
              description: When closed.
    - id: account-deleted
      title: Account Deleted (outbound)
      description: |
        Account row is **physically** gone — admin two-step hard-delete
        cascade, or service-initiated transactional rollback within the
        `APP__SECURITY__RECENT_DELETE_WINDOW_MINS` window. Distinct from
        [Account Deactivated](#account-deactivated): deactivation soft-
        closes (`status='deactivated'` AND `deleted_at` stamped — both
        set in the same transaction; the row is queryable by admins
        but the domain treats deactivated as **terminal** and rejects
        any reactivation attempt). This event signals the row no
        longer exists, period.

        Replicas should tombstone on receipt — there is no row to
        reconcile against; an HTTP `GET /api/v1/accounts/{id}` will
        answer `404`.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.deleted'
      operations:
        - type: publish
          summary: Account row physically removed
          description: |
            Envelope `{"AccountDeleted": { ... }}`. Best-effort publish
            **after** the DB commit, so a broker outage doesn't leave
            an orphan row — but it can leave a stale replica until your
            reconciliation cron runs. Plan for that.

            `deleted_by` provenance: the admin's account id for the
            two-step hard-delete, or the calling service-principal's
            id for the transactional rollback. Cross-reference the
            `audit_logs.metadata.flow` field for the exact path
            (`admin_hard_delete` vs `transactional_rollback`).

            `was_email` carries the deleted row's email — handy for
            consumers indexing on email, since the account is now
            gone and a follow-up HTTP fetch would 404. `null` for
            service accounts that had no email.
          payload:
            - name: AccountDeleted.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: AccountDeleted.account_id
              type: string (uuid)
              required: true
              description: Account that was deleted.
            - name: AccountDeleted.deleted_by
              type: string (uuid)
              required: true
              description: Admin (two-step) or service-principal (transactional rollback) that ran the delete.
            - name: AccountDeleted.was_email
              type: string
              description: Email the row had before deletion. Null for service accounts without an email.
            - name: AccountDeleted.timestamp
              type: string (rfc3339)
              required: true
              description: When deleted.
          example: |
            {
              "AccountDeleted": {
                "event_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
                "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
                "deleted_by": "a2f32c84-639c-4778-8d30-2cc1ba3ca427",
                "was_email": "deleted@example.com",
                "timestamp": "2026-06-04T08:14:22Z"
              }
            }
    - id: account-password-changed
      title: Account Password Changed (outbound)
      description: |
        Password rotated — whether the user changed it via
        `POST /me/password`, or an admin force-reset via either reset
        endpoint. Carries the org context so consumers that mirror
        per-org security posture can audit it.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.account.password_changed'
      operations:
        - type: publish
          summary: Password rotation
          description: |
            Envelope `{"AccountPasswordChanged": { ... }}`. The new
            password hash is **not** in the payload — only the fact of
            rotation. The `LOGIN_FAIL` / `LOGIN` audit-log rows have
            per-attempt details if you need them.
          payload:
            - name: AccountPasswordChanged.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: AccountPasswordChanged.account_id
              type: string (uuid)
              required: true
              description: Subject account.
            - name: AccountPasswordChanged.account_type
              type: string
              required: true
              description: '`human` or `service`.'
            - name: AccountPasswordChanged.organization_id
              type: string (uuid)
              required: true
              description: Account's default org (`00000000-...` if none).
            - name: AccountPasswordChanged.changed_at
              type: string (rfc3339)
              required: true
              description: When rotated.
    - id: auth-login-success
      title: Auth Login Success (outbound)
      description: |
        Emitted **only** by the SSO login paths (`POST /auth/google` and
        `POST /auth/firebase`) for now. The password-login path
        (`POST /auth/login`) writes a `LOGIN` row to `audit_logs` but
        does **not** publish to RabbitMQ — historical asymmetry, watch
        out if you're building a "login activity" mirror.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.auth.login_success'
      operations:
        - type: publish
          summary: SSO login succeeded
          description: |
            Envelope `{"LoginSuccess": { ... }}`. `ip_address` is the
            peer IP as seen by the backend; for production traffic
            through nginx that's typically `127.0.0.1` (the nginx
            loopback proxy), not the public client IP — fix that at the
            publish site if you depend on it.
          payload:
            - name: LoginSuccess.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: LoginSuccess.account_id
              type: string (uuid)
              required: true
              description: Account that logged in.
            - name: LoginSuccess.session_id
              type: string (uuid)
              required: true
              description: Session minted by this login.
            - name: LoginSuccess.ip_address
              type: string
              required: true
              description: Peer IP at the backend (see note above).
            - name: LoginSuccess.user_agent
              type: string
              required: true
              description: User-Agent from the request.
            - name: LoginSuccess.timestamp
              type: string (rfc3339)
              required: true
              description: When the login completed.
    - id: org-created
      title: Organization Created (outbound)
      description: |
        Emitted when an organization row is persisted. Includes the
        personal-org bootstrap created on every human registration, as
        well as explicit org creations via the admin API.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.created'
      operations:
        - type: publish
          summary: New organization provisioned
          description: |
            Envelope `{"OrganizationCreated": { ... }}`.
          payload:
            - name: OrganizationCreated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: OrganizationCreated.organization_id
              type: string (uuid)
              required: true
              description: Newly created org.
            - name: OrganizationCreated.owner_id
              type: string (uuid)
              required: true
              description: Account that owns / created the org.
            - name: OrganizationCreated.timestamp
              type: string (rfc3339)
              required: true
              description: When created.
    - id: org-updated
      title: Organization Updated (outbound)
      description: |
        Org profile fields change. `updated_fields` lists the keys that
        moved (e.g. `name`, `slug`, `plan`). Field values aren't carried
        in the payload — fetch the org via HTTP if your mirror needs the
        new values.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.updated'
      operations:
        - type: publish
          summary: Org profile mutated
          description: |
            Envelope `{"OrganizationUpdated": { ... }}`.
          payload:
            - name: OrganizationUpdated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: OrganizationUpdated.organization_id
              type: string (uuid)
              required: true
              description: Subject org.
            - name: OrganizationUpdated.updated_fields
              type: string[]
              required: true
              description: Field names that changed in this commit.
            - name: OrganizationUpdated.timestamp
              type: string (rfc3339)
              required: true
              description: When committed.
    - id: org-deleted
      title: Organization Deleted (outbound)
      description: |
        Organization is hard-deleted. Today this fires only as part of
        the admin-hard-delete cascade (a deleted account that owns a
        personal org takes that org down with it). Consumers that mirror
        org-keyed state should drop the row.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.deleted'
      operations:
        - type: publish
          summary: Org row gone
          description: |
            Envelope `{"OrganizationDeleted": { ... }}`.
          payload:
            - name: OrganizationDeleted.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: OrganizationDeleted.organization_id
              type: string (uuid)
              required: true
              description: Org being removed.
            - name: OrganizationDeleted.deleted_by
              type: string (uuid)
              required: true
              description: Admin / actor that performed the delete.
            - name: OrganizationDeleted.timestamp
              type: string (rfc3339)
              required: true
              description: When deleted.
    - id: org-member-joined
      title: Org Member Joined (outbound)
      description: |
        Account becomes an active member of an organization — through
        invitation acceptance, personal-org bootstrap at register time,
        or an admin add.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.member_joined'
      operations:
        - type: publish
          summary: Membership created
          description: |
            Envelope `{"MemberJoined": { ... }}`.
          payload:
            - name: MemberJoined.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: MemberJoined.organization_id
              type: string (uuid)
              required: true
              description: Org joined.
            - name: MemberJoined.account_id
              type: string (uuid)
              required: true
              description: Account joining.
            - name: MemberJoined.role_id
              type: string (uuid)
              required: true
              description: Role assigned at join time.
            - name: MemberJoined.invited_by
              type: string (uuid)
              description: Account that issued the invitation (absent for self-bootstrap).
            - name: MemberJoined.joined_at
              type: string (rfc3339)
              required: true
              description: When joined.
    - id: org-member-left
      title: Org Member Left (outbound)
      description: |
        Member leaves an org — self via the leave endpoint, or removed
        by an admin.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.member_left'
      operations:
        - type: publish
          summary: Membership ended
          description: |
            Envelope `{"MemberLeft": { ... }}`.
          payload:
            - name: MemberLeft.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: MemberLeft.organization_id
              type: string (uuid)
              required: true
              description: Org left.
            - name: MemberLeft.account_id
              type: string (uuid)
              required: true
              description: Account that left.
            - name: MemberLeft.left_at
              type: string (rfc3339)
              required: true
              description: When left.
    - id: org-role-changed
      title: Org Role Changed (outbound)
      description: |
        A member's role inside an org changes — promotion, demotion, or
        lateral move. Both old and new role ids are included so audit
        consumers can capture the transition without a follow-up fetch.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.role_changed'
      operations:
        - type: publish
          summary: Membership role changed
          description: |
            Envelope `{"RoleChanged": { ... }}`.
          payload:
            - name: RoleChanged.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: RoleChanged.organization_id
              type: string (uuid)
              required: true
              description: Org context.
            - name: RoleChanged.account_id
              type: string (uuid)
              required: true
              description: Member whose role changed.
            - name: RoleChanged.old_role_id
              type: string (uuid)
              required: true
              description: Role id before the change.
            - name: RoleChanged.new_role_id
              type: string (uuid)
              required: true
              description: Role id after the change.
            - name: RoleChanged.changed_by
              type: string (uuid)
              required: true
              description: Actor that made the change.
            - name: RoleChanged.timestamp
              type: string (rfc3339)
              required: true
              description: When changed.
    - id: org-member-invited
      title: Org Member Invited (outbound)
      description: |
        An invitation is created. Companion to `org.invitation_accepted`
        / `org.invitation_cancelled` (state transitions on the same
        invitation row).
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.member_invited'
      operations:
        - type: publish
          summary: Invitation issued
          description: |
            Envelope `{"MemberInvited": { ... }}`. `email` is the
            recipient address; `invitation_id` is the row that
            subsequent accept/cancel events reference.
          payload:
            - name: MemberInvited.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: MemberInvited.invitation_id
              type: string (uuid)
              required: true
              description: Invitation row id.
            - name: MemberInvited.organization_id
              type: string (uuid)
              required: true
              description: Org being invited into.
            - name: MemberInvited.email
              type: string
              required: true
              description: Email of the invitee.
            - name: MemberInvited.role_id
              type: string (uuid)
              required: true
              description: Role to grant on accept.
            - name: MemberInvited.invited_by
              type: string (uuid)
              required: true
              description: Account that issued the invitation.
            - name: MemberInvited.timestamp
              type: string (rfc3339)
              required: true
              description: When issued.
    - id: org-invitation-accepted
      title: Org Invitation Accepted (outbound)
      description: |
        Invitation transitions to `accepted`. Always emitted alongside
        `org.member_joined`; the two events carry overlapping but not
        identical info — bind to whichever fits your projection.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.invitation_accepted'
      operations:
        - type: publish
          summary: Invitation accepted
          description: |
            Envelope `{"InvitationAccepted": { ... }}`.
          payload:
            - name: InvitationAccepted.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: InvitationAccepted.invitation_id
              type: string (uuid)
              required: true
              description: Invitation row id.
            - name: InvitationAccepted.organization_id
              type: string (uuid)
              required: true
              description: Org joined.
            - name: InvitationAccepted.account_id
              type: string (uuid)
              required: true
              description: Account that accepted.
            - name: InvitationAccepted.email
              type: string
              required: true
              description: Email used to accept (matches the invitation).
            - name: InvitationAccepted.timestamp
              type: string (rfc3339)
              required: true
              description: When accepted.
    - id: org-invitation-cancelled
      title: Org Invitation Cancelled (outbound)
      description: |
        Invitation row is cancelled — by the inviter, an admin, or
        automatic expiry cleanup.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.org.invitation_cancelled'
      operations:
        - type: publish
          summary: Invitation cancelled
          description: |
            Envelope `{"InvitationCancelled": { ... }}`.
          payload:
            - name: InvitationCancelled.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: InvitationCancelled.invitation_id
              type: string (uuid)
              required: true
              description: Invitation row id.
            - name: InvitationCancelled.organization_id
              type: string (uuid)
              required: true
              description: Org context.
            - name: InvitationCancelled.cancelled_by
              type: string (uuid)
              required: true
              description: Actor that cancelled.
            - name: InvitationCancelled.timestamp
              type: string (rfc3339)
              required: true
              description: When cancelled.
    - id: service-account-created
      title: Service Account Created (outbound)
      description: |
        A new service-account row is provisioned inside an org.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.service_account.created'
      operations:
        - type: publish
          summary: Service account provisioned
          description: |
            Envelope `{"ServiceAccountCreated": { ... }}`.
          payload:
            - name: ServiceAccountCreated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ServiceAccountCreated.account_id
              type: string (uuid)
              required: true
              description: New service-account id.
            - name: ServiceAccountCreated.organization_id
              type: string (uuid)
              required: true
              description: Owning org.
            - name: ServiceAccountCreated.created_by
              type: string (uuid)
              required: true
              description: Actor that created it.
            - name: ServiceAccountCreated.timestamp
              type: string (rfc3339)
              required: true
              description: When created.
    - id: service-account-updated
      title: Service Account Updated (outbound)
      description: |
        Service-account profile / config mutation (display_name,
        metadata, capabilities).
      protocol: amqp
      address: 'exchange: account.events / routing key: account.service_account.updated'
      operations:
        - type: publish
          summary: Service account updated
          description: |
            Envelope `{"ServiceAccountUpdated": { ... }}`. Lean payload —
            this event carries no per-field snapshot. Fetch via HTTP if
            your mirror needs the new values.
          payload:
            - name: ServiceAccountUpdated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ServiceAccountUpdated.account_id
              type: string (uuid)
              required: true
              description: Subject service-account.
            - name: ServiceAccountUpdated.organization_id
              type: string (uuid)
              required: true
              description: Owning org.
            - name: ServiceAccountUpdated.timestamp
              type: string (rfc3339)
              required: true
              description: When updated.
    - id: service-account-paused
      title: Service Account Paused (outbound)
      description: |
        Service account status flipped to `paused` — temporarily disabled
        without revoking its API keys. Consumers that gate on
        `status=active` should stop honoring the principal.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.service_account.paused'
      operations:
        - type: publish
          summary: Service account paused
          description: |
            Envelope `{"ServiceAccountPaused": { ... }}`.
          payload:
            - name: ServiceAccountPaused.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ServiceAccountPaused.account_id
              type: string (uuid)
              required: true
              description: Paused account.
            - name: ServiceAccountPaused.organization_id
              type: string (uuid)
              required: true
              description: Owning org.
            - name: ServiceAccountPaused.timestamp
              type: string (rfc3339)
              required: true
              description: When paused.
    - id: service-account-resumed
      title: Service Account Resumed (outbound)
      description: |
        Service account status flipped back to `active` from `paused`.
        Companion to `service_account.paused`.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.service_account.resumed'
      operations:
        - type: publish
          summary: Service account resumed
          description: |
            Envelope `{"ServiceAccountResumed": { ... }}`.
          payload:
            - name: ServiceAccountResumed.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ServiceAccountResumed.account_id
              type: string (uuid)
              required: true
              description: Resumed account.
            - name: ServiceAccountResumed.organization_id
              type: string (uuid)
              required: true
              description: Owning org.
            - name: ServiceAccountResumed.timestamp
              type: string (rfc3339)
              required: true
              description: When resumed.
    - id: service-account-archived
      title: Service Account Archived (outbound)
      description: |
        Service account is archived — irreversible terminal state. All
        API keys are implicitly revoked. Consumers should drop the
        principal from any access path.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.service_account.archived'
      operations:
        - type: publish
          summary: Service account archived
          description: |
            Envelope `{"ServiceAccountArchived": { ... }}`.
          payload:
            - name: ServiceAccountArchived.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ServiceAccountArchived.account_id
              type: string (uuid)
              required: true
              description: Archived account.
            - name: ServiceAccountArchived.organization_id
              type: string (uuid)
              required: true
              description: Owning org.
            - name: ServiceAccountArchived.timestamp
              type: string (rfc3339)
              required: true
              description: When archived.
    - id: api-key-created
      title: API Key Created (outbound)
      description: |
        A new API key is minted for a service account. The plaintext key
        is **not** in the payload — only the `api_key_id` (the row id).
        Plaintext is returned once at create time on the HTTP response.
      protocol: amqp
      address: 'exchange: account.events / routing key: account.api_key.created'
      operations:
        - type: publish
          summary: API key issued
          description: |
            Envelope `{"ApiKeyCreated": { ... }}`.
          payload:
            - name: ApiKeyCreated.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ApiKeyCreated.api_key_id
              type: string (uuid)
              required: true
              description: API key row id (NOT the plaintext key).
            - name: ApiKeyCreated.account_id
              type: string (uuid)
              required: true
              description: Service-account the key belongs to.
            - name: ApiKeyCreated.created_by
              type: string (uuid)
              required: true
              description: Actor that issued the key.
            - name: ApiKeyCreated.timestamp
              type: string (rfc3339)
              required: true
              description: When issued.
    - id: api-key-revoked
      title: API Key Revoked (outbound)
      description: |
        API key row is revoked — manually or as part of a parent
        service-account archive. Authentication using the key starts
        failing immediately (cache invalidated as part of the same
        handler).
      protocol: amqp
      address: 'exchange: account.events / routing key: account.api_key.revoked'
      operations:
        - type: publish
          summary: API key revoked
          description: |
            Envelope `{"ApiKeyRevoked": { ... }}`.
          payload:
            - name: ApiKeyRevoked.event_id
              type: string (uuid)
              required: true
              description: Unique id.
            - name: ApiKeyRevoked.api_key_id
              type: string (uuid)
              required: true
              description: API key row id.
            - name: ApiKeyRevoked.account_id
              type: string (uuid)
              required: true
              description: Owning service-account.
            - name: ApiKeyRevoked.revoked_by
              type: string (uuid)
              required: true
              description: Actor that revoked the key.
            - name: ApiKeyRevoked.timestamp
              type: string (rfc3339)
              required: true
              description: When revoked.
theme:
    title: Account · Ikavia
    logo_icon: "\U0001F510"
    primary_color: '#360185'
