{"info":{"title":"Account Service API","version":"1.0","description":"Multi-tenant identity, authentication, and organization service. Issues\nshort-lived RS256 JWTs (access + refresh) for human users and exchanges\nlong-lived API keys for the same JWTs for service accounts. Organizations,\nmemberships, roles, sessions, and audit activity all live here.\n","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":"🔐","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.\n  The historical / default path. Subject to brute-force protection.\n- **Human via Sign-In-With-Google** — `POST /auth/google` with a\n  Google ID token from GIS / native SDK. Auto-links to an existing\n  row by email match, or auto-creates a new account.\n- **Human via Sign-In-With-Firebase** — `POST /auth/firebase` with a\n  Firebase ID token. Same resolution rules as Google but keyed on\n  `firebase_uid`. Useful when the mobile app is already using\n  Firebase Auth for multi-provider (Apple, Facebook, …).\n- **Service** principals authenticate with an API key\n  (`X-API-Key`). Use `/auth/token-exchange` once an hour to swap a\n  key for a JWT and avoid per-request key validation.\n\nUsers can mix-and-match: e.g. register with password, then later\nclick \"Connect Google account\" on `/dashboard/connected-accounts`\nto add Google as a second sign-in method against the same row.\n"},{"icon":"🏢","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.\n- Use `POST /organizations/{org_id}/switch` to issue a new token bound to a different org. The session id is preserved across the switch.\n"},{"icon":"🤝","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,\nsurvey, media, or any future ikavia microservice.\naccount-service is the central hub — your service authenticates\nagainst it differently depending on what you're trying to do:\n\n- **Calling public HTTP endpoints as your service identity**\n  (e.g. `GET /api/v1/accounts/{id}` to enrich a response):\n  create a `service_account` inside an organization, generate\n  an **API key**, and either send `X-API-Key: \u003ckey\u003e` directly\n  or swap it for a JWT via `/auth/token-exchange` and send\n  `Authorization: Bearer \u003cjwt\u003e`. This is the **default path\n  for HTTP**. No coordination needed — any org owner can\n  create the service_account.\n- **Calling internal gRPC `InternalGrantService`** (to grant\n  your product's service-key like `wacca-tenant:*` to a user):\n  your service needs a **system account** — a basic-auth\n  credential issued by a **platform admin** via\n  `/dashboard/admin-system-accounts`. **Request this from\n  the platform team before you start integration**; without\n  it the gRPC port rejects you. The credential's\n  `allowed_prefixes` constrains which service-keys you may\n  touch — propose your prefix when you file the request\n  (e.g. `wacca-` for any `wacca-*` key).\n- **Acting on behalf of a logged-in user** (forwarding a\n  request your user made): just forward their JWT\n  `Authorization` header to account-service. The principal\n  stays human; no new credential needed. **This is the path\n  you want if you're building an API Key Manager** in your\n  service's UI — but only expose that panel to\n  **organization owners** in our model, or to your own\n  service's **superadmin role**. Regular org members should\n  not see it; API keys are too powerful to be a general\n  self-service feature. See the \"Recipe: API Key Manager in\n  your own service\" section for the audience rules, CORS\n  allowlist setup, and worked examples.\n\n**Don't mix these up.** `service_account` (org-owned,\nAPI-key) and `system_account` (platform-level, basic-auth)\nsound similar but are unrelated tables. See the\n\"System accounts (admin)\" section for the issuance UI and\nthe \"Internal gRPC\" section for the wire protocol.\n"},{"icon":"🛡️","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.\n- Refresh tokens are rotated on every `/auth/refresh`; presenting an old token after rotation revokes the session.\n- Logout / password-change writes the JTI to a Redis revocation list so stolen access tokens stop validating immediately.\n"},{"icon":"📦","title":"Standard response envelope","description":"Every JSON response follows the same wrapper shape — success, error, and 4xx/5xx alike.","content":"Successful responses:\n```json\n{ \"success\": true, \"code\": 2010, \"data\": { ... } }\n```\n- `code` is omitted (or absent) on most happy paths and is filled in for created (`2010`) and a few special cases.\n- `data` carries the endpoint's actual payload.\n\nFailure responses:\n```json\n{ \"success\": false, \"code\": 4003, \"message\": \"...\", \"trace_id\": \"...\" }\n```\n- `code` follows the error-code table below — always check it before falling back to HTTP status.\n- `trace_id` is set on errors that originate from a use case so support can correlate logs.\n"},{"icon":"📧","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\nclicks the link in the welcome email (or the resend) to flip\nit. **Three places** expose that state — same source of\ntruth, different surfaces.\n\n- **`data.email_verified` on `GET /api/v1/me`** — the\n  canonical boolean. Read this from any authenticated\n  client; the dashboard verify-email banner is wired this\n  way.\n- **`data.requires_email_verification` on login responses**\n  — appears on `/auth/login`, `/auth/register`,\n  `/auth/google`, `/auth/firebase`, and `/auth/refresh`.\n  `true` when verification is still pending. Useful for\n  routing the user to a \"check your email\" page right\n  after sign-in instead of straight to the dashboard.\n- **JWT `permissions` claim on the access token** — verified\n  humans get `[\"*\"]`; unverified humans get the partial\n  read-only set `[\"read:profile\", \"read:organizations\",\n  \"read:sessions\"]`. Service-to-service code that inspects\n  the JWT should treat the partial set as \"ask the user to\n  verify\"; any `*`-gated endpoint will 403 the request\n  otherwise.\n\nPractical rules of thumb:\n\n- **Frontend after sign-in** — read `data.email_verified`\n  from `GET /api/v1/me`. The dashboard verify-email banner\n  (`components/layout/verify-email-banner.tsx`) handles the\n  resend button.\n- **Login screen UX** — route off\n  `requires_email_verification` straight from the login\n  response, no extra round-trip.\n- **Backend / RBAC** — inspect the JWT `permissions` claim.\n\nUnverified users do NOT block sign-in or session creation —\nthey get a reduced permission set plus the banner. Email\nverification gates feature reach, not authentication.\n"},{"icon":"🚦","title":"Error code table","description":"Numeric codes mirror HTTP status families (2xxx success, 4xxx client error, 5xxx server error).","content":"| Code | HTTP | Meaning |\n|------|------|---------|\n| `2010` | 201 | Resource created (used on `/auth/register`). |\n| `4000` | 400 | Bad request (missing required header, malformed body). |\n| `4002` | 422 | Validation failed (e.g. invalid email format, weak password, phone-number charset). |\n| `4003` | 401 | Authentication / authorization failure (invalid token, missing perm, revoked session). |\n| `4004` | 404 | Resource not found (account, organization, role, session). |\n| `4005` | 409 | Duplicate entry (email already exists; Google sub / Firebase uid already linked to another account; org slug collision). |\n| `4008` | 429 | Rate limit exceeded — `retry_after` field is included. |\n| `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. |\n| `4030` | 403 | CSRF validation failed. |\n| `5000` | 500 | Internal error — usually transient (DB / Redis / RabbitMQ blip). Safe to retry with backoff. |\n\nValidation errors carry the offending field name in `message` (e.g. `\"email: invalid format\"`).\n"},{"icon":"🍪","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`.\n\n| Cookie | Purpose | Attributes |\n|--------|---------|------------|\n| `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`). |\n| `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. |\n\n- 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`.\n- 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.\n"},{"icon":"🛂","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:\n1. Method is mutating (`POST`, `PUT`, `PATCH`, `DELETE`).\n2. No `Authorization` header is present (Bearer/JWT flows are exempt).\n3. No `X-API-Key` header is present (service-account flows are exempt).\n4. The request actually carries the `csrf_token` cookie.\n\nWhen 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:\n```json\n{ \"success\": false, \"code\": 4030, \"message\": \"Invalid CSRF token\" }\n```\nMobile/CLI/SDK clients that authenticate with `Authorization: Bearer ...` or `X-API-Key: ...` never see this middleware. CSRF is a browser concern.\n"},{"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:\n\n| Endpoint family | Limit |\n|----------------|-------|\n| `/auth/register`, `/auth/resend-verification` | 3 / minute |\n| `/auth/login`, `/me/password` | 5 / minute |\n| `/auth/verify` | 100 / minute |\n| All other endpoints | bound by general per-IP cap |\n\nOn exceed:\n```json\n{\n  \"success\": false,\n  \"code\": 4008,\n  \"message\": \"Rate limit exceeded. Try again in 30 seconds.\",\n  \"retry_after\": 30,\n  \"limit\": 5,\n  \"window\": \"60s\"\n}\n```\nHonour `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.\n"},{"icon":"📑","title":"Pagination","description":"`?page` + `?per_page` query parameters; response wraps items in a paginated envelope.","content":"Every list endpoint accepts:\n- `page` — 1-indexed (default `1`).\n- `per_page` — page size (default `20`, hard cap `100`).\n\nResponse shape (inside `data`):\n```json\n{\n  \"items\": [ ... ],\n  \"total\": 42,\n  \"page\": 1,\n  \"per_page\": 20,\n  \"total_pages\": 3\n}\n```\n`total_pages` is `ceil(total / per_page)`. There is no `next_cursor` — use `page + 1` until `page \u003e total_pages`.\n"},{"icon":"🔄","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`.\n\nUse 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.\n"},{"icon":"📚","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.\n- **Swagger UI:** `GET /docs` (path is configurable per-deployment) renders an interactive client against `openapi.json`.\n\nThe OpenAPI spec is the source of truth for client SDK generation; this docs page is the narrative companion.\n"}]},"authentication":{"methods":[{"type":"JWT Bearer","header":"Authorization","format":"Bearer \u003caccess_token\u003e","source":"account-service","description":"Short-lived access token (current production TTL: **8 hours**;\ntunable per-deployment via\n`APP__SECURITY__JWT__ACCESS_TOKEN_TTL_MINS`) issued by\n`/auth/login`, `/auth/register`, `/auth/refresh`,\n`/auth/google`, `/auth/firebase`, or `/auth/token-exchange`.\nRS256-signed; verifiers can fetch the public key from\n`/auth/public-key`.\n","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":"\u003capi_key\u003e","source":"account-service (`POST /service-accounts/{account_id}/api-keys`)","description":"Long-lived credential bound to a service account. Send as `X-API-Key`\nheader on any protected endpoint, or call `/auth/token-exchange` once\nan hour to swap it for a JWT (cheaper than per-request key lookup).\n","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 \u003caccess_token\u003e 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: \u003ckey\u003e 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.\n"},"sections":[{"id":"account","title":"Account \u0026 Sessions","description":"Per-user endpoints: profile, password change, sessions, activity feed.\nAll require an authenticated principal — most are JWT-only because\nthey read or mutate session-bound state.\n","endpoints":[{"name":"Get my profile","method":"GET","path":"/api/v1/me","auth":"JWT Bearer","description":"Return the authenticated principal's profile (account details +\nemail verification status). Works for both human and service\nprincipals.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-...\",\n    \"account_type\": \"human\",\n    \"email\": \"user@example.com\",\n    \"username\": \"jane.doe\",\n    \"display_name\": \"Jane Doe\",\n    \"avatar_media_id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n    \"timezone\": \"Asia/Jakarta\",\n    \"language\": \"id\",\n    \"email_verified\": true,\n    \"bio\": \"\",\n    \"phone_number\": \"+62 812-3456-7890\",\n    \"metadata\": {},\n    \"must_change_password\": false,\n    \"has_password\": true,\n    \"created_at\": 1735689600\n  }\n}\n\nThe `has_password` flag is **false** for users created via\nGoogle or Firebase auto-signup who haven't yet set a\npassword. The Profile → Password tab uses this to swap\nbetween a \"Set password\" and \"Change password\" form.\n\n`phone_number` is optional free-form text (E.164-friendly).\nEmpty string when not set. The PUT endpoint accepts\nupdates with charset `[+0-9 ()-.]` and length 6-32; an\nempty string in the PUT body clears it.\n\n`avatar_media_id` is the profile photo, stored as a\n**media-service media id** (UUID), not a URL. Render it via\n`https://media.ikavia.com/api/media/{id}`. `null` when unset.\nOn the PUT endpoint: omit to leave unchanged, `\"\"` to clear,\nor a UUID to set — the BE validates the id exists in\nmedia-service (422 if not found / expired).\n"},{"name":"Update my profile","method":"PUT","path":"/api/v1/me","auth":"JWT Bearer","description":"Patch the authenticated user's profile. Omitted fields are left\nuntouched. `model_config` and `capabilities` apply to service\naccounts only.\n\n**`username`** is the unique, public handle (3-64 chars, only\nletters/digits and `._@+-`). Case-insensitive uniqueness is\nenforced at the database level — a collision returns\n`4002 validation_failed` rather than overwriting another row.\nThe same field can also be edited by other services via the\n`account.profile.update_requested` RabbitMQ event (see Events).\n","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":"{\n  \"username\": \"jane.d\",\n  \"display_name\": \"Jane D.\",\n  \"bio\": \"Building things.\",\n  \"phone_number\": \"+62 812-3456-7890\",\n  \"timezone\": \"Asia/Singapore\",\n  \"language\": \"en\"\n}\n","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 } }\n"},{"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\nthe account currently has a `password_hash`:\n\n- **Change mode** (`has_password = true`): the request must\n  supply the correct `current_password`. We verify it\n  against the stored hash, accept the swap, then revoke\n  every active session **except** the one making this\n  request (so the tab the user is sitting in doesn't get\n  logged out).\n- **First-time set mode** (`has_password = false`): typical\n  for users who signed up via Google or Firebase and never\n  set a password. The `current_password` field is\n  **ignored** in this mode — there is nothing to verify\n  — but the caller must still be authenticated via JWT.\n  After success the account has `has_password = true` and\n  future calls hit the change-mode path.\n\nThe mode is decided server-side from the database; the FE\nuses the `has_password` field from `/me` to pick between\na \"Change password\" and a \"Set password\" form.\n\nService accounts cannot call this — they have no password.\n**Rate limit:** 5 / minute / IP.\n","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":"{\n  \"current_password\": \"OldPass123!\",\n  \"new_password\": \"NewSecurePass456!\"\n}\n","example_response":"{ \"success\": true, \"data\": { \"success\": true } }\n"},{"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\nemail immediately — instead sends a confirmation link to the\n**new** address and a security notice to the **old** one. The\nold email stays active until the new one is confirmed\n(step 2). Guards against typos and session-hijack.\n\n**Re-auth:** accounts that have a password MUST send\n`current_password`. SSO-only accounts (NULL password) skip it\n— the valid session is the only credential they have.\n\n**Errors:**\n- `422` — `new_email` invalid, or `current_password` missing\n  on a password account.\n- `401` — `current_password` incorrect.\n- `409` — `new_email` already belongs to another account.\n\nSessions are **not** revoked by an email change (it's not a\ncredential change). The confirmation link expires in 1 hour.\n","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":"{\n  \"new_email\": \"new.address@example.com\",\n  \"current_password\": \"CurrentPass123!\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"message\": \"Confirmation link sent to your new email address. The change takes effect once you click it.\"\n  }\n}\n"},{"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\nthe auth). Consumes the token, swaps the account's email,\nmarks it verified, and publishes `account.account.updated` /\n`account.account.email_changed` to exchange\n`account.events`. Single-use: the token is dropped after a\nsuccessful swap.\n\nTwo entry points hit the same logic:\n- **This JSON endpoint** (`POST`) — for a FE-driven confirm page.\n- **`GET /confirm-email-change?token=…`** — the link in the\n  confirmation email lands here directly; the backend runs the\n  same swap and renders a branded HTML result page (mirrors\n  `GET /verify-email`). No FE page required.\n\nFor `platform=wacca-mobile` requests the email's primary button\nis a `wacca://` deeplink with the https link as fallback — but\nthat's set at registration/login time, not here.\n\n**Errors:**\n- `422` — token missing / invalid / expired.\n- `409` — the new email was taken by someone else during the\n  confirmation window.\n","body":[{"name":"token","type":"string","required":true,"description":"Plaintext confirmation token from the email link."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"new.address@example.com\"\n  }\n}\n"},{"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\nbacking the current request is flagged with `is_current: true`.\nAlso available at `/api/v1/sessions` (legacy path).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"sessions\": [\n      {\n        \"session_id\": \"550e8400-...\",\n        \"ip_address\": \"203.0.113.42\",\n        \"user_agent\": \"Mozilla/5.0 ...\",\n        \"device_fingerprint\": \"\",\n        \"created_at\": 1735689600,\n        \"last_used_at\": 1735693200,\n        \"expires_at\": 1738281600,\n        \"is_current\": true\n      }\n    ]\n  }\n}\n"},{"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\nown sessions, including the current one (which terminates the\ncalling session). Also available at `/api/v1/sessions/{session_id}`.\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"name":"List connected accounts","method":"GET","path":"/api/v1/me/connected-accounts","auth":"JWT Bearer","description":"Snapshot of which external identity providers are currently\nlinked to the authenticated account. Today the only one is\nGoogle; the response shape is forward-compatible — new\nproviders added later become additional entries in the\n`providers` array without changing the wrapper.\n\nFor Google, `linked_email` carries the email of the linked\nGoogle identity (e.g. `arian.personal@gmail.com`) which\n**may differ** from the account's own email\n(`arian@ikavia.com`). The FE renders \"Connected as\n{linked_email}\" so the user knows which Google account is\nattached. Absent from the response when the provider is\nnot linked.\n\n`can_disconnect_google` is `true` only when the user has a\npassword set; this lets the UI disable the disconnect button\nfor Google-only accounts so they don't lock themselves out.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"providers\": [\n      {\n        \"provider\": \"google\",\n        \"linked\": true,\n        \"linked_email\": \"arian.personal@gmail.com\"\n      }\n    ],\n    \"can_disconnect_google\": true\n  }\n}\n"},{"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\naccount. The body shape is identical to `/auth/google`:\nthe `credential` is the raw JWT returned by Google Identity\nServices (web) or the native Google SDK (Android / iOS).\n\n**Validation:**\n- Google ID token signature, `iss`, `aud`, `exp` must all\n  check out (same JWKS-verified path as `/auth/google`).\n- Google `email_verified` must NOT be `false`.\n- The token's email is **NOT** required to match the\n  account's email. A typical use case is a work account\n  (`arian@ikavia.com`) linked to a personal Google\n  identity (`arian.personal@gmail.com`); both are\n  persisted so the FE can display the Google address\n  alongside the account address.\n\n**Identity model:** `google_sub` (Google's stable, never-\nreused subject id) is the lookup key — DB UNIQUE\nconstraint sits there. `google_email` is persisted only\nfor display; on every successful login the BE refreshes\nit from the token so a user who later changes their\nGoogle primary email still sees the right value.\n\n**Idempotent**: if the same Google `sub` is already linked\nto this account, the call refreshes the stored\n`google_email` and returns 200 with the current status.\nLinking a DIFFERENT `sub` to a row that already has one\nreturns 4005 duplicate_entry — the user must disconnect\nthe old Google identity first. Linking a `sub` already\nowned by ANOTHER account also returns 4005.\n","body":[{"name":"credential","type":"string","required":true,"description":"Raw Google ID token (the same JWT shape /auth/google accepts)."}],"example_body":"{ \"credential\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y...\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"providers\": [\n      {\n        \"provider\": \"google\",\n        \"linked\": true,\n        \"linked_email\": \"arian.personal@gmail.com\"\n      }\n    ],\n    \"can_disconnect_google\": true\n  }\n}\n"},{"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`.\nAfter this call the user can no longer sign in via Google\nfor this account — they must use password (or re-link\nGoogle later).\n\n**Lock-out guard:** if the account has no password set\n(proxied today by `password_hash IS NULL`), the call\nreturns 4002 with a message instructing the user to set a\npassword first via `/auth/password-reset`. Idempotent:\ncalling DELETE when Google is not linked returns 200 with\nthe current status.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"providers\": [\n      { \"provider\": \"google\", \"linked\": false }\n    ],\n    \"can_disconnect_google\": false\n  }\n}\n"},{"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.\nThe `actor_id` filter is forced server-side to the calling user, so\ncallers cannot pivot to another user's feed.\n\n**Action vocabulary** (dot-notation, used in `action` filter and\n`action` field of returned items):\n- `account.profile_updated`, `account.accessed`\n- `auth.login`, `auth.login_failed`, `auth.logout`,\n  `auth.token_refreshed`, `auth.password_changed`, `auth.password_reset`\n- `member.invited`, `member.removed`\n- `invitation.cancelled`\n- `service_account.archived`, `service_account.paused`,\n  `service_account.resumed`\n- `api_key.created`, `api_key.revoked`\n\nNew actions may be added without a docs revision; consumers should\ntolerate unknown values (display as-is, don't reject).\n","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 \u003e= since`."},{"name":"until","type":"string","description":"ISO-8601 — filter `created_at \u003c 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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\": \"550e8400-...\",\n        \"actor_id\": \"550e8400-...\",\n        \"actor_email\": \"user@example.com\",\n        \"actor_display_name\": null,\n        \"organization_id\": \"550e8400-...\",\n        \"organization_name\": null,\n        \"action\": \"organization.created\",\n        \"target_type\": \"organization\",\n        \"target_id\": \"550e8400-...\",\n        \"target_label\": null,\n        \"metadata\": {},\n        \"ip_address\": \"203.0.113.42\",\n        \"user_agent\": \"Mozilla/5.0 ...\",\n        \"created_at\": 1735689600\n      }\n    ],\n    \"total\": 42,\n    \"page\": 1,\n    \"per_page\": 20,\n    \"total_pages\": 3\n  }\n}\n"},{"name":"Search accounts","method":"GET","path":"/api/v1/accounts/search","auth":"JWT Bearer","description":"Authenticated user directory search. Match is `ILIKE %q%`\nagainst either `display_name` OR `email`. Returns the\nminimum fields a \"find someone\" picker needs.\n\n**Mitigations against enumeration** (this endpoint is global,\nwhich is intentional but exposed):\n- Server enforces `APP__SECURITY__SEARCH__MIN_QUERY_LENGTH`\n  (default **3** characters). Shorter `q` → 422.\n- `per_page` is clamped server-side to\n  `APP__SECURITY__SEARCH__MAX_PER_PAGE` (default **50**).\n- Dedicated rate-limit bucket (`search`, default **30/min/IP\n  fail-closed**) — tighter than `general`, fail-closed so a\n  limiter outage doesn't open a scraping window.\n\n**Excluded from results:** soft-deleted accounts\n(`deleted_at IS NOT NULL`) and `deactivated` accounts — the\nuser closed their account; surfacing them in pickers would\nre-attach them to other people's workflows.\n\nService principals should use the existing s2s endpoints\n`GET /accounts/{id}` / `POST /accounts/bulk-lookup` instead —\nfuzzy search isn't part of the s2s contract.\n\n**Errors:**\n- `422 / 4002` — `q` shorter than the configured minimum.\n- `429 / 4008` — rate-limit hit, includes `retry_after`.\n","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":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"account_id\": \"c8555be3-17b0-44d9-87a6-57560281d4bf\",\n      \"display_name\": \"dddd\",\n      \"email\": \"cobaaja123@yopmail.com\",\n      \"status\": \"active\",\n      \"email_verified\": true,\n      \"avatar_media_id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"per_page\": 20,\n    \"total\": 1,\n    \"total_pages\": 1\n  }\n}\n\n`avatar_media_id` is `null` when the user has no avatar.\nRender via `https://media.ikavia.com/api/media/{id}` — same\ncontract as `/me` and the s2s lookup endpoints.\n"}]},{"id":"accounts-s2s","title":"Service-to-service profile lookup","description":"Endpoints peer microservices (wacca, chat, …) call to enrich\ntheir own response payloads with display data for users they\ndon't own. Lives on the public HTTP surface so existing\nservice-principal credentials (API key → JWT) just work; the\nroute gate makes sure no human-issued JWT can reach it.\n\n**Who calls this.** Service principals only — `principal_type`\nin the access-token claims MUST be `service`. Human-issued\nJWTs are rejected with `4031` so a logged-in user can't\nenumerate other users' profiles via this path. Use `/me` for\nreading your own profile.\n\n**What credential do I need?** A `service_account` row (org-\nowned) with an API key — NOT a `system_account` (those are\nonly for the internal gRPC `InternalGrantService`). Send the\nkey via `X-API-Key`, or swap it for a JWT once an hour via\n`/auth/token-exchange` and send `Authorization: Bearer …`.\nSee the \"For sibling services\" card on the landing page for\nthe full integration matrix.\n\n**What's returned.** Only the fields peer services have a\nlegitimate UI need for: `id`, `display_name`, `email`,\n`avatar_media_id`, `phone_number`. Fields that belong to the\naccount owner (`has_password`, `must_change_password`,\n`bio`, `metadata`, …) are intentionally omitted. Adding a\nfield here is non-breaking; removing one is breaking — see\n`account-service-is-the-hub` for the contract discipline.\n\n**N+1 avoidance.** Use the bulk variant when enriching a\nlist response — the single-id endpoint will work, but\n`bulk-lookup` is one DB roundtrip regardless of input size\n(capped at 100 ids/request).\n","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\nUUID. `404` when the account doesn't exist or has been\nsoft-deleted; `403` when the caller is a human principal\n(use `/api/v1/me` instead); `422` when `{id}` isn't a\nUUID.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"display_name\": \"Rian Saputra\",\n    \"email\": \"rian@example.com\",\n    \"avatar_media_id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n    \"phone_number\": \"+62812...\"\n  }\n}\n"},{"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\nand get back the matching profiles in a single DB\nround-trip. Use this when enriching a list response —\nlooping the single-id endpoint would N+1 the DB.\n\n**Behaviour:**\n- Empty `ids` array → 200 with `items: []` (no DB hit).\n- Duplicates in `ids` are de-duplicated; each account\n  appears at most once in `items`.\n- IDs with no matching account are silently omitted —\n  the consumer can diff against their request to detect\n  gaps.\n- Output order is **not** guaranteed to match input\n  order. Build a `Map\u003cid, profile\u003e` on the consumer side\n  and look up by id.\n\n**Errors:**\n- `403` — caller is a human principal.\n- `422` — `ids` has more than 100 entries, or any entry\n  is not a UUID. The whole request is rejected; we do\n  not return partial results for a bad payload, so the\n  caller fixes their data instead of silently dropping\n  an id.\n","body":[{"name":"ids","type":"array\u003cuuid\u003e","required":true,"description":"Up to 100 account UUIDs to enrich. Duplicates are de-duplicated."}],"example_body":"{\n  \"ids\": [\n    \"550e8400-e29b-41d4-a716-446655440000\",\n    \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\"\n  ]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n        \"display_name\": \"Rian Saputra\",\n        \"email\": \"rian@example.com\",\n        \"avatar_media_id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n        \"phone_number\": \"+62812...\"\n      }\n    ]\n  }\n}\n"},{"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\nwhen its own outer transaction fails. Example flow: WACCA\ncalls `POST /api/v1/auth/register` to provision a user, then\nits next onboarding step (e.g., creating a tenant row) fails\n— WACCA hits this endpoint to discard the account before the\nuser discovers it exists.\n\n**Strict guardrails:**\n- **Service-principal only.** Human callers → **403**. (Platform\n  admins use the existing 2-step `POST /api/v1/admin/accounts/\n  {id}/hard-delete/prepare` flow.)\n- **Time-boxed:** only accounts younger than\n  `APP__SECURITY__RECENT_DELETE_WINDOW_MINS` (default **3**)\n  qualify. Older → **403** with the suggestion to use the\n  admin flow. The window is enforced server-side from\n  `accounts.created_at`.\n- **No reassignment.** Within 3 minutes an account is normally\n  pristine; if it has already produced downstream state we\n  won't silently undo (api_keys.created_by, invitations,\n  service_accounts) the call returns **409** and the caller\n  should NOT rollback — there's real downstream state.\n- **Personal-org sweep:** the target's personal org (where it\n  is the lone owner) is deleted in the same transaction. A\n  multi-member org owned by the target also returns **409**.\n\n**Idempotency.** Retrying on a target that no longer exists\nreturns **404**; treat that as \"already rolled back\" on the\ncaller side.\n\n**Cascade.** Account-side rows (sessions, audit_logs,\nmemberships, password_reset_tokens, account_service_grants,\napi_keys[as resource]) cascade via the DB.\n\n**Audit log.** An entry with `action=DELETE`,\n`resource_type=ACCOUNT`, `actor_id=\u003ccalling service\u003e`,\n`metadata.flow=\"transactional_rollback\"` is written\nbest-effort (the delete commits regardless).\n\n**Rate limit:** 10 / minute / IP (`sensitive` preset,\nfail-closed). 429 includes `retry_after`.\n\n**Errors:**\n- `403 / 4004` — caller is a human principal, OR account\n  exceeds the window.\n- `404` — target does not exist (treat as already-deleted).\n- `409` — target owns a multi-member org, or has FK-blocking\n  dependents (api_keys.created_by, invitations.invited_by,\n  service_accounts.created_by_account_id) — rollback unsafe.\n- `429 / 4008` — rate-limit exceeded.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"rolledback@example.com\",\n    \"deleted\": true\n  }\n}\n"}]},{"id":"auth","title":"Authentication","description":"Endpoints under `/api/v1/auth`. Issue, rotate, revoke, and verify\ncredentials for both human users and service accounts. All endpoints\nare rate-limited per IP (limits noted on each endpoint).\n","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\nthe same transaction with the registrant as owner. A verification\nemail is queued (event `email.requested`). Auto-logs in: returns\naccess + refresh tokens and sets the SSO refresh cookie.\n**Rate limit:** 3 / minute / IP.\n\n**Optional `platform` field (non-breaking).** Shapes the\nverification email's links:\n- omitted / any other value → web link only\n  `https://account.ikavia.com/verify-email?token=…`\n- `\"wacca-mobile\"` → primary button is a `wacca://` deeplink\n  (opens the mobile app) **and** the `https://` link is still\n  shown below as a clickable fallback for desktop.\n\nAlso accepts the spelling `flatform` (serde alias). Callers\nthat omit it are unaffected.\n","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":"{\n  \"email\": \"user@example.com\",\n  \"password\": \"SecurePass123!\",\n  \"display_name\": \"Jane Doe\",\n  \"timezone\": \"Asia/Jakarta\",\n  \"language\": \"id\",\n  \"platform\": \"wacca-mobile\"\n}\n","example_response":"{\n  \"success\": true,\n  \"code\": 2010,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"user@example.com\",\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"refresh_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"expires_in\": 900,\n    \"personal_org\": {\n      \"org_id\": \"550e8400-e29b-41d4-a716-446655440001\",\n      \"name\": \"Jane Doe's Personal\",\n      \"slug\": \"jane-doe-personal-550e8400\",\n      \"status\": \"active\",\n      \"owner_account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n      \"plan\": \"free\",\n      \"created_at\": 1735689600,\n      \"updated_at\": 1735689600,\n      \"my_role\": \"owner\",\n      \"my_permissions\": [\"*\"]\n    }\n  }\n}\n"},{"name":"Login","method":"POST","path":"/api/v1/auth/login","auth":"none","description":"Authenticate with email + password. Returns access + refresh tokens\nand the user's full organization list. Sets refresh + CSRF cookies.\nBrute-force protection: per-IP and per-account counters with\nprogressive delay and lockout.\n**Rate limit:** 5 / minute / IP.\n","body":[{"name":"email","type":"string","required":true,"description":"Account email."},{"name":"password","type":"string","required":true,"description":"Account password."}],"example_body":"{\n  \"email\": \"user@example.com\",\n  \"password\": \"SecurePass123!\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"user@example.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"refresh_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"expires_in\": 900,\n    \"organizations\": [\n      { \"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\": [\"*\"] }\n    ],\n    \"current_org_id\": \"550e8400-e29b-41d4-a716-446655440001\",\n    \"requires_email_verification\": false,\n    \"csrf_token\": \"f3a1c0...\"\n  }\n}\n"},{"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\nGoogle Identity Services button or One Tap) for our own access +\nrefresh JWT pair. Response shape and cookies are identical to\n`/auth/login`, so a client can treat the two flows interchangeably.\n\n**Verification.** The server validates the inbound JWT against\nGoogle's JWKS at `https://www.googleapis.com/oauth2/v3/certs`,\nchecks `iss ∈ {accounts.google.com, https://accounts.google.com}`,\n`aud == \u003cAPP__SECURITY__GOOGLE_OAUTH__CLIENT_ID\u003e`, and `exp`. The\nJWKS document is cached in Redis with TTL derived from Google's\n`Cache-Control: max-age` (fallback 1 h); a `kid` miss triggers a\none-shot refresh.\n\n**Account resolution.** Three branches, each surfaced to\nthe client via the `action` field of the response so the FE\ncan render the right copy without an extra round-trip:\n\n1. **By `sub`** — token's `sub` already linked to an account\n   (subsequent logins of a previously-linked Google identity).\n   Response carries `action: \"logged_in\"`.\n2. **By email** — `google_sub` is empty but a row exists with\n   this email. The row is **auto-linked** (Google has verified\n   the address, so we trust the email match). If the row was\n   still Pending it is promoted to Active in the same write.\n   Response carries `action: \"linked\"` — the FE typically\n   toasts \"We connected your Google account to your existing\n   profile\" so the user understands what changed.\n3. **Auto-signup** — no row matches `sub` or email. A new\n   human account, personal organization, and the standard\n   five system roles are created in one transaction. Email is\n   marked verified at creation since Google already proved\n   ownership. No password is set — the schema\n   `chk_human_must_have_auth` is satisfied because\n   `google_sub` is non-null. The user can add a password\n   later via the change-password endpoint (which detects\n   the NULL hash and skips the current-password check).\n   Response carries `action: \"registered\"`.\n\n`created_now` is the legacy boolean form — equivalent to\n`action == \"registered\"` — kept for clients that integrated\nbefore `action` existed.\n\n**Errors:**\n- `503` — feature disabled (operator left\n  `APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID` empty).\n- `401` — token signature invalid, wrong audience, expired,\n  or `email_verified: false` from Google.\n- `409` — account already linked to a DIFFERENT Google `sub`.\n\n**Rate limit:** 5 / minute / IP (same bucket as `/auth/login`).\n","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":"{\n  \"credential\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y2...\",\n  \"device_timezone\": \"Asia/Jakarta\",\n  \"device_language\": \"id\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"user@gmail.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"refresh_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"expires_in\": 7200,\n    \"organizations\": [\n      { \"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\": [\"*\"] }\n    ],\n    \"current_org_id\": \"550e8400-e29b-41d4-a716-446655440001\",\n    \"requires_email_verification\": false,\n    \"created_now\": true,\n    \"action\": \"registered\",\n    \"csrf_token\": \"f3a1c0...\"\n  }\n}\n"},{"name":"Sign in with Firebase","method":"POST","path":"/api/v1/auth/firebase","auth":"none","description":"Exchange a **Firebase Authentication ID token** for our own\naccess + refresh JWT pair. Mobile clients that already use\nFirebase Auth on the device (for Firestore rules, Functions,\nCrashlytics user binding, Remote Config personalisation,\netc.) can send the Firebase-issued token here directly\nrather than juggling a separate raw Google token.\n\n**Verification.** The server validates the inbound JWT\nagainst Firebase's JWKS at\n`https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com`,\nchecks\n`iss == https://securetoken.google.com/\u003cAPP__SECURITY__FIREBASE_AUTH__PROJECT_ID\u003e`,\n`aud == \u003cAPP__SECURITY__FIREBASE_AUTH__PROJECT_ID\u003e`, and\n`exp`. The JWKS is cached in Redis under\n`auth:firebase:jwks` with TTL derived from Google's\n`Cache-Control` header.\n\n**Account resolution.** Three branches, mirroring\n`/auth/google`. Each branch surfaces an `action` field in\nthe response so the client can tell what actually\nhappened:\n\n1. **By `firebase_uid`** — token's `sub` already linked to\n   an account. Fast path for returning users. Response\n   carries `action: \"logged_in\"`.\n2. **By email** — auto-link an existing row (created via\n   `/auth/register` or `/auth/google` or password) when\n   the Firebase token's verified email matches. Promotes\n   a Pending row to Active in the same write. Response\n   carries `action: \"linked\"`.\n3. **Auto-signup** — fresh account + personal organization\n   + 5 system roles. Response carries `action: \"registered\"`.\n\n`created_now` is the legacy boolean form — equivalent to\n`action == \"registered\"` — kept for clients that integrated\nbefore `action` existed.\n\n**Email required.** Firebase Anonymous / custom-token\nsign-ins (no email) are rejected with 4003. If the mobile\napp needs anonymous flows, those will get a dedicated\nendpoint later.\n\n**Errors:**\n- `503` — feature disabled (operator left\n  `APP__SECURITY__FIREBASE_AUTH__PROJECT_ID` empty).\n- `401` — token signature invalid, wrong audience, wrong\n  issuer, expired, no email, or `email_verified: false`.\n- `409` — account already linked to a DIFFERENT Firebase\n  UID.\n\n**Rate limit:** 5 / minute / IP (same bucket as\n`/auth/login` and `/auth/google`).\n","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":"{\n  \"credential\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...\",\n  \"device_timezone\": \"Asia/Jakarta\",\n  \"device_language\": \"id\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"user@gmail.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"refresh_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"expires_in\": 7200,\n    \"organizations\": [\n      { \"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\": [\"*\"] }\n    ],\n    \"current_org_id\": \"550e8400-e29b-41d4-a716-446655440001\",\n    \"requires_email_verification\": false,\n    \"created_now\": true,\n    \"action\": \"registered\",\n    \"csrf_token\": \"f3a1c0...\"\n  }\n}\n"},{"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\nwithout requiring the user to click the verification link\nsent at registration. Use only when identity has been\nconfirmed out-of-band — for example, a user mistyped their\nemail at signup and the team has since corrected it; an\ninternal employee changed laptops and lost access to the\noriginal mailbox; a partner can't receive mail from the\nverification domain.\n\n**Behavior:**\n- Platform-admin only (`*` permission).\n- Idempotent. Calling on an already-verified account\n  returns `200` with `was_already_verified: true` and does\n  not bump `updated_at`.\n- Rejects service accounts (no email) with `422`.\n- On change, emits an `AccountUpdated` event to the\n  `account.events` RabbitMQ exchange so downstream\n  consumers see the new verified state.\n\n**Errors:**\n- `403` — caller lacks `*`.\n- `404` — target account not found.\n- `422` — target has no email (service account).\n\n**Audit:** logged with `admin_id`, `target_id`, and the\nprevious `email_verified_at` value (NULL on first\nverification).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"alice@example.com\",\n    \"email_verified\": true,\n    \"was_already_verified\": false\n  }\n}\n"},{"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\nper the deployment's policy, hashes it, persists with a TTL\n(login rejects the credential after expiry), and revokes every\nactive session of the target. Audit-logged.\n\n**Authorization (strict per-org):**\n- **Platform-admin** (`*` permission): may reset any user.\n  `X-Organization-Id` is optional and used only as the audit\n  log's organization context.\n- **Org owner / admin**: MUST send `X-Organization-Id`; must\n  be `owner` or `admin` in that org; the target must be a\n  member of the SAME org. Cross-org → **403**.\n- Self-reset is refused (use the normal change-password flow\n  or have another admin do it) so the audit log can't be\n  silently rotated.\n\n**Delivery modes:**\n- `delivery: \"return\"` (default) — plaintext temp password is\n  returned in the response **once**. The admin is expected to\n  hand it to the user out-of-band. A subsequent reset issues a\n  fresh password; the prior one is discarded.\n- `delivery: \"email\"` — account-service emails the user the\n  temp password with instructions. Response omits the plaintext.\n\n**Policy** is configurable per deployment:\n`APP__SECURITY__TEMP_PASSWORD__LENGTH` (default 12) and\n`APP__SECURITY__TEMP_PASSWORD__TTL_HOURS` (default 72). The\ncharset is fixed (upper+lower+digits+symbols, confusable\ncharacters removed).\n\n**Rate limit:** 10 / minute / IP (fail-closed). 429 includes\n`retry_after`.\n\n**Errors:**\n- `400 / 4002` — `delivery` not `return|email`,\n  `X-Organization-Id` not a UUID.\n- `403 / 4004` — caller not owner/admin in target org, or\n  target is not a member of target org, or admin attempted\n  self-reset.\n- `404` — target user_id does not exist.\n- `422` — service account (no email/password) target,\n  `X-Organization-Id` required for non-platform admin and\n  absent.\n- `429 / 4008` — rate limit exceeded.\n\nThe legacy platform-admin-only endpoint\n`POST /api/v1/admin/accounts/{account_id}/reset-password`\nremains in place unchanged (default behaviour preserved).\n\n**Headers:** `Authorization: Bearer \u003cadmin-jwt\u003e`. Optional\n`X-Organization-Id: \u003cuuid\u003e` — REQUIRED for non-platform\nadmins; optional (audit context only) for platform-admin.\nSending it as anything but a UUID returns 422.\n","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 }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"user_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"temp_password\": \"Gk7#mPq2RwZ9\",\n    \"expires_at\": \"2026-06-02T08:00:00Z\",\n    \"must_change_on_next_login\": true\n  }\n}\n\n# For delivery=email, `temp_password` is null (it lives in the\n# user's inbox, not in this response).\n"},{"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\nalphanumeric confirmation code scoped to `(admin_id,\ntarget_account_id)` and returns it to the admin UI. Code\nlives 5 minutes in Redis and is single-use.\n\n\u003e **For service-driven rollback** (the calling service just\n\u003e created an account and its outer transaction failed): use\n\u003e [`POST /api/v1/internal/accounts/{account_id}/hard-delete-recent`](#accounts-s2s)\n\u003e instead — it's service-principal-only, time-boxed by\n\u003e `APP__SECURITY__RECENT_DELETE_WINDOW_MINS` (default 3 min),\n\u003e and skips this two-step confirmation.\n\nThe UI's destructive-action modal displays this code and\nforces the admin to retype it before the actual delete is\nsubmitted to `/hard-delete`. Pure friction-against-fat-\nfinger protection; the real authorization is the `*`\npermission check.\n\nPre-validates the target exists (404 if not) and that the\nadmin is not targeting themselves (422). Each call\nreplaces any previous code for the same `(admin, target)`\npair — re-opening the modal generates a fresh code.\n\n**Rate limit:** general per-IP cap. No specific bucket.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"confirmation_code\": \"7RP6JB3K\",\n    \"expires_in_secs\": 300\n  }\n}\n"},{"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\n`confirmation_code` against the value stashed by\n`/prepare`, then **irreversibly** removes the account row\nplus everything tied to it.\n\n**Cascade rules:**\n- `audit_logs`, `sessions`, `api_keys` (where the row's\n  `account_id` is the target), `account_service_grants`,\n  `password_reset_tokens`, `organization_memberships`\n  (target-side), `service_accounts` (target-side):\n  removed automatically via existing `ON DELETE CASCADE`.\n- **Personal organization** (target is owner AND only\n  active member): auto-deleted in the same transaction.\n  Its memberships, roles, sessions and grants cascade\n  via the FKs on `organizations.id`.\n- **Multi-member organization the target owns**: blocks\n  the delete. Response is 409 with\n  `blocking_organizations` listing each org's name, slug,\n  and active member count. Admin must transfer ownership\n  (or delete the org) before retrying.\n- **NOT-NULL FK columns without `ON DELETE`**\n  (`api_keys.created_by`, `invitations.invited_by`,\n  `service_accounts.created_by_account_id`): reassigned\n  to the admin doing the delete so the rows stay valid.\n  The audit-log entries covering the original creation\n  events are gone, so use this knowing the\n  \"originally created by\" attribution is lost.\n\n**Errors:**\n- `404` — target account not found (e.g. already deleted\n  in a parallel admin session).\n- `422` — `validation_failed`: code mismatch, code\n  expired, or admin tried to delete themselves.\n- `409` — `blocking_organizations` payload, see above.\n- `5xx` — DB transaction failure; nothing was committed.\n","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":"{\n  \"confirmation_code\": \"7RP6JB3K\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"user@example.com\",\n    \"deleted_personal_org_ids\": [\n      \"49f18abc-64c1-40db-830a-b7d191c4232f\"\n    ]\n  }\n}\n"},{"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\nrefresh token's hash is rotated server-side; the old JTI is added\nto the revocation list once rotation is persisted. Replaying the\nold refresh token after this call kills the session (reuse\ndetection). Refresh token may be in the body or in the\n`refresh_token` cookie (SSO flow).\n","body":[{"name":"refresh_token","type":"string","description":"Optional in body. Falls back to the `refresh_token` cookie when omitted."}],"example_body":"{ \"refresh_token\": \"eyJhbGciOiJSUzI1NiIs...\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"...\",\n    \"email\": \"user@example.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs... (new)\",\n    \"refresh_token\": \"eyJhbGciOiJSUzI1NiIs... (new)\",\n    \"expires_in\": 900,\n    \"organizations\": [ ... ],\n    \"current_org_id\": \"...\",\n    \"requires_email_verification\": false,\n    \"csrf_token\": \"...\"\n  }\n}\n"},{"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\nto the revocation list so a token stolen before logout stops\nvalidating immediately. Cookies are always cleared on the response,\neven if the server-side revoke fails. **Service principals cannot\ncall this endpoint** — they have no session.\n","example_response":"{ \"success\": true, \"data\": { \"success\": true } }\n"},{"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\n\"I lost my phone\" or post-password-change cleanups. Cookies are\nrotated/cleared on the response.\n","example_response":"{ \"success\": true, \"data\": { \"success\": true } }\n"},{"name":"Resend verification email","method":"POST","path":"/api/v1/auth/resend-verification","auth":"none","description":"Re-emit the verification email for an unverified account.\nThe response is **deliberately indistinguishable** from the\n\"email not found\" case so a caller can't enumerate which\naddresses have an account — both return 200 with the same\ngeneric message. A verified account also returns 200 (and\nquietly does nothing).\n\nThe FE surface for this endpoint is the **Verify-email\nbanner** mounted in the dashboard layout\n(`components/layout/verify-email-banner.tsx`): a sticky\nyellow strip that shows for any user with\n`email_verified=false` and carries a \"Resend email\"\nbutton. Calling the endpoint manually is the path for\nunauthenticated callers (e.g. a \"Didn't get the email?\"\nlink before sign-in).\n\n**Rate limit:** 3 / minute / IP.\n","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\" }\n","example_response":"{ \"success\": true, \"code\": 2000, \"data\": { \"message\": \"If the email exists, a verification email has been sent\" } }\n"},{"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\nemail is unknown, to prevent email enumeration. Generates a\n1-hour JWT and emits an `email.requested` event with the\nreset link.\n\n**Optional `platform` field (non-breaking).** Controls the\nscheme of the reset link in the emailed message:\n- omitted / any other value → web link\n  `https://account.ikavia.com/reset-password?token=…`\n- `\"wacca-mobile\"` → custom-scheme deeplink\n  `wacca://account.ikavia.com/reset-password?token=…` so the\n  mobile OS opens the app instead of a browser.\n\nThe field also accepts the spelling `flatform` (serde alias)\nso the mobile client can send either key. Existing callers\nthat send only `email` are unaffected.\n","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":"{\n  \"email\": \"user@example.com\",\n  \"platform\": \"wacca-mobile\"\n}\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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.\nValidates the token (signature + jti not previously used), updates\nthe password hash, and revokes all sessions for the account.\n","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":"{\n  \"token\": \"eyJhbGciOiJSUzI1NiIs...\",\n  \"new_password\": \"NewSecurePass456!\"\n}\n","example_response":"{ \"success\": true, \"data\": { \"success\": true } }\n"},{"name":"Verify access token","method":"POST","path":"/api/v1/auth/verify","auth":"none","description":"Introspect an access token (signature + exp + revocation). Used by\ndownstream services that prefer a server-side check over local\npublic-key verification. Returns the claims **as-is**: permissions\ncome from the token, not re-derived from current state. Use\n`/auth/refresh` if you need fresh permissions.\n**Rate limit:** 100 / minute / IP.\n","body":[{"name":"token","type":"string","required":true,"description":"Access token to introspect."}],"example_body":"{ \"token\": \"eyJhbGciOiJSUzI1NiIs...\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"valid\": true,\n    \"account_id\": \"550e8400-...\",\n    \"organization_id\": \"550e8400-...\",\n    \"permissions\": [\"*\"],\n    \"expires_at\": 1735693200,\n    \"session_id\": \"550e8400-...\"\n  }\n}\n"},{"name":"Whoami","method":"GET","path":"/api/v1/auth/whoami","auth":"JWT Bearer","description":"Return the principal identity carried by the request — exactly what\nthe middleware extracted, with no extra DB lookups. Useful for SDKs\nand gateways debugging auth wiring.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-...\",\n    \"principal_type\": \"human\",\n    \"session_id\": \"550e8400-...\",\n    \"current_org_id\": \"550e8400-...\",\n    \"organizations\": [ { \"id\": \"550e8400-...\", \"role\": \"owner\" } ],\n    \"permissions\": [\"*\"]\n  }\n}\n"},{"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\ndownstream services. Cache this for the lifetime of `expires_at`\n(default 90 days).\n\n**Key rotation strategy:**\n- Each issued JWT carries a `kid` claim in its header naming the\n  key that signed it (e.g. `key-2026-03-01-v1`).\n- Verifiers should cache by `kid`. On a cache miss (unfamiliar\n  `kid`), re-fetch this endpoint to learn the new key.\n- During rotation, account-service serves the **new** key here\n  but continues to **accept** tokens signed by the previous key\n  until those tokens naturally exp. Verifiers should retain the\n  previous key entry until its tokens have aged out.\n- `expires_at` is the rough cache horizon — refresh before that\n  even without a `kid` mismatch to pick up scheduled rotations.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"key_id\": \"key-2026-03-01-v1\",\n    \"algorithm\": \"RS256\",\n    \"public_key\": \"-----BEGIN PUBLIC KEY-----\\nMIIB...\\n-----END PUBLIC KEY-----\\n\",\n    \"expires_at\": 1743465600\n  }\n}\n"},{"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\nservice JWT (`principal_type: service`). Lets a service principal\nauthenticate downstream calls with a verifiable JWT instead of\nre-presenting the API key on every hop. The JWT carries the\nservice account's capabilities as `permissions` and `nil` as `sid`.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 3600,\n    \"account_id\": \"550e8400-...\",\n    \"organization_id\": \"550e8400-...\",\n    \"permissions\": [\"museum:read\", \"artifact:write\"],\n    \"principal_type\": \"service\"\n  }\n}\n"},{"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.\nA **System account** (basic-auth credential issued by a platform\nadmin for a headless peer backend — distinct from an org-owned\nservice account) swaps its credentials for a short-lived RS256\nservice JWT (`principal_type: service`).\n\n**Pick which token-mint endpoint based on the caller principal:**\n\n| Caller is …                                    | Use                          | Auth                                  |\n| ---------------------------------------------- | ---------------------------- | ------------------------------------- |\n| An **org-owned service account** with an API key | [`/auth/token-exchange`](#auth) | `X-API-Key: \u003capi-key\u003e` header         |\n| A **platform System account** (no org)         | `/auth/system-token` (this)  | `Authorization: Basic name:password`  |\n\nBoth endpoints return the same JWT shape — same issuer, `kid`,\nRS256 key, `principal_type: service`. Peer services verify\neither token through the same JWKS / `/auth/public-key` path\nwithout any code change. The difference is **who's calling**\nand **how their credentials are managed**: service accounts\nare managed inside an organization, System accounts are\nmanaged by the platform admin under\n[`/admin/system-accounts`](#system-accounts-admin).\n\nThe token is byte-for-byte compatible with every other token this\nservice mints (same issuer, `kid`, RS256 key), so peer services\n(e.g. Media Service) verify it through their existing public-key /\nJWKS path with **no change** to their validation.\n\n**Permissions are admin-granted, never caller-chosen.** The token's\n`permissions` come solely from the System account's\n`token_permissions` grant (set via the admin\n`POST /api/v1/admin/system-accounts/{id}/permissions` endpoint),\ne.g. `[\"media:upload\"]`. The request body carries no permission\ninput. `sub` is the System account id; `org_id` is absent (System\naccounts have no organization); `sid` is `nil`.\n\nTTL follows the access-token policy\n(`APP__SECURITY__JWT__ACCESS_TOKEN_TTL_MINS`, default 15 min).\n\n**Auth:** `Authorization: Basic base64(name:password)`.\n`401` for missing/invalid credentials (neutral body — never reveals\nwhether a name exists); `403` once the password is correct but the\nSystem account is revoked.\n","example_body":"# Basic auth — the System account name + password issued by the\n# platform admin. The permission set baked into the token is the\n# admin-granted `token_permissions`, NOT anything sent here.\ncurl -X POST 'http://localhost:8874/api/v1/auth/system-token' \\\n  -u 'recruitment-backend:\u003csystem-account-password\u003e' \\\n  -H 'Accept: application/json'\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJhbGciOiJSUzI1NiIs...\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 900\n  }\n}\n"}]},{"id":"internal-grpc","title":"Internal gRPC (service-to-service)","description":"Loopback-only gRPC surface for sibling services (wacca, chat, …) to\nmanage their own service-grant entitlements on accounts.\n\n**Reachability.** The gRPC server binds to the address configured in\n`APP__GRPC__ADDRESS` (default `127.0.0.1:50051`). It is *not* exposed\nthrough the public ingress or any reverse proxy. Other services on\nthe same host reach it over loopback; services on other hosts must\ntunnel (mTLS / Wireguard / Tailscale) — there is no plan to expose\nit publicly.\n\n**Authentication.** Each caller authenticates with HTTP Basic in the\ngRPC `authorization` metadata field. The username MUST match a\nrow in the `system_accounts` table (status='active'); the\npassword is verified against the argon2 hash stored in that row.\nSystem accounts are created and managed by a platform-admin\nvia `/api/v1/admin/system-accounts` (UI:\n`/dashboard/admin-system-accounts`) — they are NOT the same as\n`service_accounts`, which are org-owned machine-users with API\nkeys.\n\n**Caching.** The interceptor caches each row in Redis under\n`system_account:{name}` with a 5-minute TTL. Mutations\n(create / rotate / revoke) invalidate the same key, so the\nnext gRPC call observes the change within seconds. A stale\ncache window of up to 5 minutes is possible only when the\ncache invalidation step fails (logged as a warning).\n\n**Authorization.** Each configured caller carries a *prefix\nallowlist*. A call referencing service key `S` is rejected with\n`PERMISSION_DENIED` unless one of the caller's prefixes is a prefix\nof `S` (or an exact match). `wacca` configured with\n`[\"wacca-\"]` may operate on `wacca-tenant` and `wacca-admin` but\nnot on `chat-room` or `platform_admin`.\n\n**Audit.** Every grant / revoke writes a structured log line\ntagged with the caller name, account, service, and org_id. There\nis no separate audit-log row today — follow the access log if\nyou need historical attribution.\n","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\nwith the same `(account_id, service, organization_id)` tuple\nreturns `granted_now=false` and the existing `granted_at`.\n\n**Scope.**\n- Empty `organization_id` ⇒ **global** grant. Visible in the\n  holder's JWT in every org context.\n- Non-empty `organization_id` ⇒ **org-scoped** grant. Only\n  contributes to the JWT when the holder's current org\n  matches.\n\n**JWT emission.** Granting a service whose key is not in the\nbuilt-in list (`email`, `planner`, `survey`, `media`,\n`platform_admin`) results in `{service}:*` being added to\nthe holder's JWT permissions array on next token issuance.\nGranting `wacca-tenant` produces `wacca-tenant:*`.\n\n**Errors:**\n- `UNAUTHENTICATED` — no `authorization` metadata, wrong\n  scheme, unknown user, or wrong password.\n- `PERMISSION_DENIED` — `service` not covered by the\n  caller's prefix allowlist.\n- `INVALID_ARGUMENT` — `account_id` / `organization_id` not\n  a UUID, or `service` empty.\n","example_body":"# Pseudocode — adapt to your gRPC client library\nmetadata.authorization = \"Basic \" + base64(\"wacca:\u003cpassword\u003e\")\nGrantServiceRequest {\n  account_id: \"550e8400-e29b-41d4-a716-446655440000\",\n  service: \"wacca-tenant\",\n  organization_id: \"\"  // global\n}\n","example_response":"GrantServiceResponse {\n  granted_now: true,\n  granted_at: \"2026-05-21T10:50:00Z\"\n}\n"},{"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\ntuple that doesn't exist returns `revoked=false`. The scope\n(`organization_id` empty vs. non-empty) must match the row\nto be revoked; revoking a global grant with an\n`organization_id` set is a no-op.\n\n**Errors:** Same set as `GrantService`. Notably, a caller\ncan only revoke what they could grant — revoking\n`platform_admin` from wacca's credential is\n`PERMISSION_DENIED`, not silent success.\n","example_body":"RevokeServiceRequest {\n  account_id: \"550e8400-...\",\n  service: \"wacca-tenant\",\n  organization_id: \"\"\n}\n","example_response":"RevokeServiceResponse { revoked: true }\n"},{"name":"ListGrantsForAccount","method":"GRPC","path":"account.v1.internal.InternalGrantService/ListGrantsForAccount","auth":"Basic (internal-callers registry)","description":"Enumerate the grants `account_id` holds **within the\ncaller's allowlist**. Grants outside the allowlist (other\nservices' keys, `platform_admin`) are filtered out — wacca\ncannot use this to map who else has what.\n\n`service_prefix` further narrows the result. If non-empty,\nthe prefix must itself be allowed for the caller, otherwise\n`PERMISSION_DENIED`.\n","example_body":"ListGrantsForAccountRequest {\n  account_id: \"550e8400-...\",\n  service_prefix: \"wacca-\"\n}\n","example_response":"ListGrantsForAccountResponse {\n  grants: [\n    { service: \"wacca-tenant\", organization_id: \"\", granted_at: \"2026-05-21T10:50:00Z\" }\n  ]\n}\n"}]},{"id":"internal-account-grpc","title":"Internal gRPC — account directory (read-only)","description":"Read-only companion to [InternalGrantService](#internal-grpc).\nSame trust model — every RPC begins with the same Basic auth\nagainst the `system_accounts` table — but **no** prefix-allowlist\ngate: these are directory lookups, not entitlement mutations, so\nresolving the caller's `system_account` row is the trust boundary.\n\nSame loopback reachability rule as the other internal service\n(binds to `APP__GRPC__ADDRESS`, default `127.0.0.1:50051`, not\nexposed via ingress).\n\n**Implemented today:** `GetAccount`, `AccountExists`,\n`EmailIsVerified`, `LookupAccount`, `BulkLookupAccounts`,\n`LookupAccountsByEmail`.\n**Returns `UNIMPLEMENTED` for now:** `GetOrganization`,\n`ListOrgServiceAccounts` — the handler is mounted (auth still\nruns, so unauthenticated callers see `UNAUTHENTICATED` first),\nbut the use cases backing them haven't landed yet. Don't depend\non them; the next iteration will fill them in.\n\n## When to use which read\n\n| Need                                     | Use                                  |\n| ---------------------------------------- | ------------------------------------ |\n| Full account row (status, created_at, …) | `GetAccount`                         |\n| Just \"does this id resolve?\"             | `AccountExists`                      |\n| Just \"is this account's email verified?\" | `EmailIsVerified`                    |\n| Display fields for ONE id (slim)         | `LookupAccount`                      |\n| Batch hydrate by id (e.g. timeline feed) | `BulkLookupAccounts` (≤ batch cap)   |\n| Resolve by email (login-with-email flow) | `LookupAccountsByEmail` (≤ batch cap)|\n\nThe slim resolvers (`Lookup*`) return the same\n`{id, display_name, email, avatar_media_id, phone_number}`\nprojection — same shape as REST `PublicProfileDto` on\n`GET /accounts/{id}`. Soft-deleted / deactivated accounts are\nsilently omitted from results (or `NOT_FOUND` on the single\nlookup) — they cannot be resolved through this surface, by\ndesign.\n\nServer-side caps + per-caller rate-limit buckets:\n\n| Setting                                                       | Default | Notes                                |\n| ------------------------------------------------------------- | ------- | ------------------------------------ |\n| `APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_IDS`               | 100     | `BulkLookupAccounts.account_ids` cap |\n| `APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_EMAILS`            | 50      | `LookupAccountsByEmail.emails` cap   |\n| `APP__SECURITY__INTERNAL_LOOKUP__RATE_LIMIT_PER_MIN_IDS`      | 600     | Per system-account, id resolvers     |\n| `APP__SECURITY__INTERNAL_LOOKUP__RATE_LIMIT_PER_MIN_EMAILS`   | 120     | Per system-account, email resolver   |\n| `APP__SECURITY__INTERNAL_LOOKUP__RATE_LIMIT_WINDOW_SECS`      | 60      | Bucket window for both               |\n\nGoing over a batch cap → `INVALID_ARGUMENT`. Going over a\nrate-limit bucket → `RESOURCE_EXHAUSTED` with a `retry in Ns`\nhint in the message. Buckets are keyed by the caller's\nsystem-account NAME, not IP — multiple callers share the\nbucket only if they share credentials (don't).\n\n**Effective account-resolutions per minute = `BATCH_CAP × RATE_LIMIT_PER_MIN`.**\nAt defaults that's **60,000/min** for ids\n(`100 × 600`) and **6,000/min** for emails\n(`50 × 120`) per credential. Size your deployment with the\neffective number in mind, not the per-RPC number — a single\ncaller running parallel `BulkLookupAccounts` calls at the\nrate-limit ceiling is doing 1,000 account-resolutions per\nsecond against the repo.\n\n## Auth posture\n\nThis is a **service-principal-only** surface. The trust gate\nis Basic auth against the `system_accounts` registry — there\nis no JWT acceptance path on these RPCs. The practical\nconsequence is that **human callers receive `UNAUTHENTICATED`\n(gRPC status 16), not `PERMISSION_DENIED` (status 7)**: humans\ncannot obtain a `system_accounts` credential, so they fail at\nthe auth gate before any principal-type check could run.\nThe REST companion (`GET /accounts/{id}` etc.) returns 403 in\nthat case because a human CAN authenticate with a JWT and\nthen get principal-typed out; the gRPC trust model is\nsimpler — Basic or nothing.\n\nOutcome for the caller is the same — rejection — only the\nstatus code differs. If you build a generic adapter that\nbridges REST `403` to gRPC, map `UNAUTHENTICATED` on these\nRPCs to whatever your downstream interprets as \"not a\nservice principal\", not to \"credentials invalid\".\n","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\na sibling service usually wants for display / authorization:\n`account_id`, `account_type` (`\"human\" | \"service\"`),\n`display_name`, `email` (empty string for service accounts\nwith no email), `status`, `created_at` (unix seconds).\n\nA soft-deleted row (`deleted_at IS NOT NULL`) answers\n`NOT_FOUND` — the row is logically gone for caller purposes.\n\n**Errors:**\n- `UNAUTHENTICATED` — no `authorization` metadata, wrong\n  scheme, unknown user, or wrong password.\n- `INVALID_ARGUMENT` — `account_id` is not a UUID.\n- `NOT_FOUND` — no live row matches the id.\n","example_body":"# Pseudocode — adapt to your gRPC client library\nmetadata.authorization = \"Basic \" + base64(\"wacca:\u003cpassword\u003e\")\nGetAccountRequest {\n  account_id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\"\n}\n","example_response":"GetAccountResponse {\n  account_id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\",\n  account_type: \"human\",\n  display_name: \"Jane Doe\",\n  email: \"jane@example.com\",\n  status: \"active\",\n  created_at: 1735689600\n}\n"},{"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\nconfirm an `account_id` resolves (e.g., precondition before\nstoring a foreign key) and doesn't need the rest of the\naccount row. Same lookup semantics as `GetAccount`: the\nunderlying repo filters `WHERE deleted_at IS NULL`, so\n**both soft-deleted AND deactivated** rows answer\n`exists=false` (deactivate sets `deleted_at` alongside\n`status='deactivated'`, so the two states are colinear\nin practice — there is no reachable row with\n`deleted_at IS NULL AND status='deactivated'`).\n\n`status` carries the account's status string only when\n`exists=true` (empty string otherwise). In practice the\nvalues you'll see are `\"active\"` or `\"pending\"` — every\nrow that's reached `deactivated` is also soft-deleted,\nand the probe returns `false` before it can surface that\nstatus.\n\n**Errors:**\n- `UNAUTHENTICATED` — see `GetAccount`.\n- `INVALID_ARGUMENT` — `account_id` is not a UUID.\n","example_body":"AccountExistsRequest {\n  account_id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\"\n}\n","example_response":"AccountExistsResponse {\n  exists: true,\n  status: \"active\"\n}\n"},{"name":"EmailIsVerified","method":"GRPC","path":"account.v1.internal.InternalAccountService/EmailIsVerified","auth":"Basic (system_accounts registry)","description":"Cheap probe: is the account's email verified?\n\nUse when a sibling service needs to gate on \"email-\nverified user\" before issuing some capability (sending a\nnotification to the address, marking a profile public,\nallowing high-trust action) and wants a single boolean\nanswer without fetching the full profile.\n\nResponse shape — two booleans:\n\n- `verified` — `true` iff the account exists, is live\n  (not soft-deleted), AND its email is verified. False\n  covers four cases at once: unknown id, soft-deleted\n  row, account has no email (some service accounts),\n  email not yet verified. For most callers a single\n  check on `verified` is sufficient: all four cases mean\n  \"do not trust this email\".\n- `account_exists` — `true` iff a live row matches\n  `account_id`, regardless of verification state. Lets a\n  caller distinguish \"no such user\" from \"user exists\n  but unverified\" when that matters (e.g. distinct\n  error message in a UI).\n\nSame `WHERE deleted_at IS NULL` filter as the other\nreads on this service — soft-deleted / deactivated rows\nresolve to `verified=false, account_exists=false`.\n\n**Errors:**\n- `UNAUTHENTICATED` — see `GetAccount`.\n- `INVALID_ARGUMENT` — `account_id` is not a UUID.\n","example_body":"EmailIsVerifiedRequest {\n  account_id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\"\n}\n","example_response":"EmailIsVerifiedResponse {\n  verified: true,\n  account_exists: true\n}\n"},{"name":"LookupAccount","method":"GRPC","path":"account.v1.internal.InternalAccountService/LookupAccount","auth":"Basic (system_accounts registry)","description":"Slim single-account resolver. Same lookup semantics as\n`GetAccount` (soft-deleted / deactivated → `NOT_FOUND`),\nbut returns only the public-display projection:\n`{id, display_name, email, avatar_media_id, phone_number}`.\n\nUse this for non-hot resolve paths — replica backfill on\ncache miss, daily reconciliation. Don't reach for\n`GetAccount` if you only need display fields; the slim\nprojection is the stable replica contract.\n\nEmpty string conventions on proto3:\n- `email` = `\"\"` when the row has no email (some service\n  accounts) — treat as absent, not literal.\n- `avatar_media_id` = `\"\"` when unset.\n- `phone_number` = `\"\"` when unset.\n\n**Errors:**\n- `UNAUTHENTICATED` — see `GetAccount`.\n- `INVALID_ARGUMENT` — `account_id` is not a UUID.\n- `NOT_FOUND` — no live row matches the id.\n- `RESOURCE_EXHAUSTED` — id-resolver rate-limit tripped.\n","example_body":"LookupAccountRequest {\n  account_id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\"\n}\n","example_response":"LookupAccountResponse {\n  account: {\n    id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\",\n    display_name: \"Jane Doe\",\n    email: \"jane@example.com\",\n    avatar_media_id: \"a8f3c2d1-...\",\n    phone_number: \"+628111234567\"\n  }\n}\n"},{"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\nat `APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_IDS`\n(default 100); going over → `INVALID_ARGUMENT`. The server\nalso dedups the id slice before hitting the repo, so\nrepeated ids in the request don't double-charge the\nbackend or appear twice in the response.\n\nBehaviour matches REST `POST /accounts/bulk-lookup`:\n\n- Missing ids (no live row) are silently OMITTED — diff\n  against the request slice to detect gaps.\n- Order of `accounts` is NOT meaningful.\n- Soft-deleted / deactivated rows are NOT returned.\n\n**Errors:**\n- `UNAUTHENTICATED` — see `GetAccount`.\n- `INVALID_ARGUMENT` — any element fails UUID parse, OR\n  slice length \u003e the configured cap.\n- `RESOURCE_EXHAUSTED` — id-resolver rate-limit tripped.\n","example_body":"BulkLookupAccountsRequest {\n  account_ids: [\n    \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\",\n    \"ff000000-0000-0000-0000-000000000000\",\n    \"1c37ff2c-2f42-4d86-94d3-a20163909ccf\"\n  ]\n}\n","example_response":"# Two of three resolved; the unknown id (ff…) is omitted.\nBulkLookupAccountsResponse {\n  accounts: [\n    { id: \"0a2f...\", display_name: \"Jane Doe\",   email: \"jane@example.com\",  avatar_media_id: \"...\", phone_number: \"...\" },\n    { id: \"1c37...\", display_name: \"John Smith\", email: \"john@example.com\",  avatar_media_id: \"\",    phone_number: \"\"     }\n  ]\n}\n"},{"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\nlowercase, so matching is **exact case-insensitive** — no\nfuzzy, no substring, no domain wildcards.\n\nTighter batch cap and tighter rate-limit bucket than the\nid resolver because email lookup is a higher enumeration\nrisk surface. Defaults:\n- Batch cap 50 (`APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_EMAILS`).\n- Rate limit 120/min/system-account\n  (`__RATE_LIMIT_PER_MIN_EMAILS`).\n\nThe server normalises every input (trim + lowercase) and\ndedups before hitting the repo. Empty-after-trim entries\nare dropped silently. A malformed address in the slice\nrejects the WHOLE batch (caller bug — chunk + retry won't\nrecover; fix the input).\n\nMissing emails (no live row) are silently omitted — same\npattern as the id resolver. Diff against the input slice.\n\nSoft-deleted / deactivated rows are NOT returned. The\nsurface is service-principal only — this is NOT a\npublic email-existence oracle.\n\n**Errors:**\n- `UNAUTHENTICATED` — see `GetAccount`.\n- `INVALID_ARGUMENT` — any element fails email parse, OR\n  slice length \u003e the configured cap.\n- `RESOURCE_EXHAUSTED` — email-resolver rate-limit tripped.\n","example_body":"LookupAccountsByEmailRequest {\n  emails: [\n    \"Jane@Example.COM\",\n    \"  john@example.com  \",\n    \"noone@example.com\"\n  ]\n}\n","example_response":"# Two of three resolved; case + whitespace handled\n# server-side. \"noone@example.com\" silently omitted.\nLookupAccountsByEmailResponse {\n  accounts: [\n    { id: \"0a2f...\", display_name: \"Jane Doe\",   email: \"jane@example.com\",  avatar_media_id: \"...\", phone_number: \"...\" },\n    { id: \"1c37...\", display_name: \"John Smith\", email: \"john@example.com\",  avatar_media_id: \"\",    phone_number: \"\"     }\n  ]\n}\n"}]},{"id":"internal-auth-grpc","title":"Internal gRPC — auth (verify password)","description":"Sibling-service step-up auth. A service that already has the\nuser's `account_id` (from a JWT subject, for example) and a\nfreshly-typed password can verify the credentials without\nrunning a full login. No new session is minted, no tokens\nrotate — this RPC is a yes/no answer.\n\nTrust model: identical to the other internal services. Basic\nauth against `system_accounts`. No prefix-allowlist gate; the\ncaller already holds a valid system-account credential.\n\n**Safety:** failed `VerifyPassword`s share the same Redis\nbrute-force bucket as failed logins on the same `account_id`,\nso this endpoint cannot be used as a brute-force bypass — once\nthe threshold trips, both login AND verify reject with\n`RESOURCE_EXHAUSTED` until cooldown. Every attempt (success or\nfail) writes an audit row tagged\n`metadata.flow=\"internal_verify_password\"` with the calling\nsystem-account name.\n\n**Implemented today:** `VerifyPassword`.\n**Returns `UNIMPLEMENTED`:** `ValidateToken`, `ValidateAPIKey`\n— backing use cases haven't landed yet; auth gate still runs.\n","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\nargon2 hash. Account is identified by **UUID only** (no\nemail lookup — keeps the RPC from doubling as an email\nenumeration probe).\n\nResponse shape:\n- `valid=true` — argon2 matched. `account_status` carries\n  the row's status string (\"active\" / \"pending\" /\n  \"suspended\"), so the caller can decide whether to\n  proceed (e.g., refuse step-up auth on a \"suspended\"\n  account even though the password was right).\n- `valid=false` — password didn't match, OR the account\n  is soft-deleted, OR the account has no password set\n  (SSO-only). `account_status` is empty so a caller can't\n  probe state by toggling the password input.\n\nSoft-deleted / unknown accounts short-circuit **before**\nargon2 runs, so the response time can't be used to probe\nstate-of-existence via timing.\n\n**Errors:**\n- `UNAUTHENTICATED` — missing / invalid system-account\n  Basic credential.\n- `INVALID_ARGUMENT` — `account_id` not a UUID, or\n  `password` empty.\n- `RESOURCE_EXHAUSTED` — the shared brute-force counter\n  for this `account_id` has tripped; retry after the\n  cooldown encoded in the status message.\n","example_body":"metadata.authorization = \"Basic \" + base64(\"wacca:\u003cpassword\u003e\")\nVerifyPasswordRequest {\n  account_id: \"0a2f6766-893b-49e6-9acb-9e503cd93e1e\",\n  password: \"the-user-typed-this\"\n}\n","example_response":"VerifyPasswordResponse {\n  valid: true,\n  account_status: \"active\"\n}\n"}]},{"id":"system-accounts-admin","title":"System accounts (admin)","description":"Superadmin HTTP endpoints for managing the basic-auth\ncredentials consumed by [InternalGrantService](#internal-grpc)\nAND by the network-reachable bearer-token endpoint\n[`/auth/system-token`](#auth). All endpoints require the\n`platform_admin` service grant (`*` permission) and live under\n`/api/v1/admin/system-accounts`.\n\n## Two scopes on every system account\n\nA system account carries **two distinct authorization scopes**\n— they are independent and admin-controlled separately:\n\n| Field               | Used by                            | Semantics                                                       |\n| ------------------- | ---------------------------------- | --------------------------------------------------------------- |\n| `allowed_prefixes`  | gRPC grant-management RPCs         | Which service-key prefixes the caller may grant / revoke / list |\n| `token_permissions` | `POST /auth/system-token` token mint | Permission set embedded into the issued RS256 bearer token      |\n\n`token_permissions` is empty by default — an account with no\ngrant mints a token carrying zero permissions (which every\ndownstream service will reject for protected actions). Grant\nexplicitly via Create body or the dedicated permissions\nendpoint below.\n\n## UI\n\nA platform-admin sees these surface in the sidebar at\n`/dashboard/admin-system-accounts`. The flow there is:\n\n- **Create**: superadmin inputs name + allowed prefixes +\n  (optional) token permissions, the BE generates a 32-character\n  password and returns it once in the response. Modal shows\n  the plaintext with a copy button; once dismissed it cannot\n  be recovered.\n- **Rotate**: same modal flow with a fresh password. The\n  old password stops working as soon as the Redis cache\n  invalidates (effectively instant; up to 5 min in the\n  unlikely event the DEL command fails).\n- **Update permissions**: replace `token_permissions` on an\n  active row without touching the password. Affected tokens\n  already in flight keep their old permissions until they\n  expire (≤ 15 min); the next token mint picks up the new\n  grant.\n- **Revoke**: flips status to `revoked`. The row stays in\n  the DB for audit; create a fresh row to re-enable. Revoked\n  rows cannot be re-permissioned — rotate or recreate.\n","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\nrevoked rows (greyed out in the UI). Hash and prefix\nallowlist are returned because the admin who sees this\npage already has full platform access; not exposing the\nhash wouldn't add security but would prevent the UI\nfrom showing whether a recent rotate succeeded.\n"},{"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\npassword ONCE in the response. The hash is the only\npersisted form.\n\n**Validation:**\n- `name`: ASCII letters, digits, `-`, or `_`. Unique\n  across active AND revoked rows (you cannot re-use a\n  name once revoked).\n- `allowed_prefixes`: at least one non-empty entry.\n- `description`: optional free-text.\n\n**Errors:**\n- `409` — `name` already exists (active or revoked).\n- `422` — validation failure on name or prefixes.\n","body":[{"name":"name","type":"string","required":true,"description":"Basic-auth username."},{"name":"allowed_prefixes","type":"array\u003cstring\u003e","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\u003cstring\u003e","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":"{\n  \"success\": true,\n  \"data\": {\n    \"account\": {\n      \"id\": \"550e8400-...\",\n      \"name\": \"careers-backend\",\n      \"allowed_prefixes\": [\"*\"],\n      \"token_permissions\": [\"media:upload\"],\n      \"description\": \"Owned by careers-team\",\n      \"status\": \"active\",\n      ...\n    },\n    \"password\": \"K4j8m...\"\n  }\n}\n"},{"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**\nsystem account, without touching the password. Used to\ngrant (or rescind) capabilities such as `media:upload`\nconsumed by the [`/auth/system-token`](#auth) bearer-token\nflow.\n\nServer-side normalisation: each entry is trimmed; empty\nentries are dropped; duplicates are folded while preserving\nfirst-seen order. Send `[]` to clear all permissions.\n\n**Cache discipline.** Bearer-token minting reads\n`token_permissions` directly from Postgres on every call\n(the gRPC Redis cache deliberately omits this column), so\na new grant takes effect on the **next token mint**.\nTokens already in flight keep the permissions they were\nissued with until they expire (≤ access-token TTL, default\n15 min).\n\n**Errors:**\n- `403` — caller is not a platform admin.\n- `404` — id unknown or row is revoked (revoked rows\n  cannot be re-permissioned; rotate or create fresh).\n- `422` — `token_permissions` field missing from body.\n","body":[{"name":"token_permissions","type":"array\u003cstring\u003e","required":true,"description":"Replacement permission set. Send `[]` to clear all permissions."}],"example_body":"{\n  \"token_permissions\": [\"media:upload\", \"media:read\"]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"550e8400-...\",\n    \"token_permissions\": [\"media:upload\", \"media:read\"]\n  }\n}\n"},{"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\nthe stored hash atomically. Old password stops working\non the next gRPC call (Redis cache is invalidated as\npart of the same handler).\n\nCannot be called on a revoked row — use Create with a\nfresh name instead. Returns `422` in that case.\n"},{"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\n`revoked_by_account_id`. Idempotent — the response\nincludes `already_revoked: true` if the row was\nalready revoked. The row is never hard-deleted so the\naudit trail (when issued, by whom, when revoked)\nstays intact.\n"}]},{"id":"operations","title":"Operations","description":"Operational endpoints — health probes for orchestrators (Kubernetes,\nNomad, load balancers) and Prometheus scraping. Not part of the\nproduct API surface; safe to firewall off from public traffic.\n","endpoints":[{"name":"Liveness probe","method":"GET","path":"/health","auth":"none","description":"Lightweight liveness check. Returns 200 with a small JSON when\nthe process is up. Does not touch DB / Redis / RabbitMQ — use\n`/health/detailed` for that. Suitable for k8s `livenessProbe`.\n","example_response":"{ \"status\": \"ok\" }\n"},{"name":"Detailed health","method":"GET","path":"/health/detailed","auth":"none","description":"Per-dependency health snapshot. Probes Postgres, Redis, and\nRabbitMQ and returns each dependency's state. A degraded\ndependency yields HTTP 503 so a load balancer can drop the pod\nfrom rotation. Heavier than `/health` — call from\n`readinessProbe`, not `livenessProbe`.\n","example_response":"{\n  \"status\": \"ok\",\n  \"dependencies\": {\n    \"postgres\": { \"status\": \"ok\", \"latency_ms\": 3 },\n    \"redis\":    { \"status\": \"ok\", \"latency_ms\": 1 },\n    \"rabbitmq\": { \"status\": \"ok\", \"latency_ms\": 5 }\n  },\n  \"version\": \"1.0.0\",\n  \"uptime_seconds\": 12345\n}\n"},{"name":"Prometheus metrics","method":"GET","path":"/metrics","auth":"none","description":"Prometheus exposition format. Includes HTTP request counters /\nlatency histograms (sanitized path labels — id segments collapsed\nto `:id` so cardinality stays bounded), gRPC counters, brute-force\ncounters, rate-limiter counters, and Redis connection pool stats.\nShould be firewalled to the metrics-collector subnet, not\nexposed publicly.\n","example_response":"# HELP http_requests_total Total HTTP requests\n# TYPE http_requests_total counter\nhttp_requests_total{method=\"GET\",path=\"/api/v1/me\",status=\"200\"} 142\n...\n"}]},{"id":"organizations","title":"Organizations","description":"Multi-tenant organization management — CRUD, switching context,\ninvitations, role assignment, and per-org activity feed. Most\nendpoints require a permission within the targeted organization.\n","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\nfrontends to validate org-scoped invitation links before login.\nReturns minimal data — no member or role info is leaked.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"org_id\": \"550e8400-...\",\n    \"name\": \"Acme Corp\",\n    \"created_at\": 1735689600\n  }\n}\n"},{"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.\nEach entry includes the principal's role in that org.\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"org_id\": \"...\", \"name\": \"Acme Corp\", \"slug\": \"acme-corp\", \"description\": null, \"status\": \"active\", \"owner_id\": \"...\", \"created_at\": 1735689600, \"updated_at\": 1735689600, \"member_count\": 12 }\n    ],\n    \"total\": 1,\n    \"page\": 1,\n    \"per_page\": 20,\n    \"total_pages\": 1\n  }\n}\n"},{"name":"Create organization","method":"POST","path":"/api/v1/organizations","auth":"JWT Bearer","description":"Create a new organization with the caller as owner. Slug is\nauto-derived from the name when omitted; if the slug collides\na 4xx is returned and the caller should retry with an explicit\nslug.\n","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\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"org_id\": \"550e8400-...\",\n    \"name\": \"Acme Corp\",\n    \"slug\": \"acme-corp\",\n    \"description\": null,\n    \"status\": \"active\",\n    \"owner_id\": \"550e8400-...\",\n    \"created_at\": 1735689600,\n    \"updated_at\": 1735689600,\n    \"member_count\": 1\n  }\n}\n"},{"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 } }\n"},{"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\" }\n","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 } }\n"},{"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\nowner role's `*` wildcard rather than a per-action permission. Members\nlose access on next token rotation; all sessions remain intact for\nother orgs the user belongs to.\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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\nexisting session id is reused, so refresh continuity is preserved.\nThe caller must already be a member of the target org.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"...\",\n    \"email\": \"user@example.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJ...new...\",\n    \"refresh_token\": \"eyJ...new...\",\n    \"expires_in\": 900,\n    \"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\": [\"*\"] } ],\n    \"current_org_id\": \"550e8400-...new...\"\n  }\n}\n"},{"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\nownership first or delete the organization.\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"account_id\": \"...\", \"email\": \"user@example.com\", \"display_name\": \"Jane Doe\", \"role\": \"owner\", \"permissions\": [\"*\"], \"joined_at\": 1735689600 }\n    ],\n    \"total\": 1,\n    \"page\": 1,\n    \"per_page\": 20,\n    \"total_pages\": 1\n  }\n}\n"},{"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\norg's default-member role is assigned on accept. The recipient\ngets an email via the `email.requested` event.\n","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-...\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"invitation_id\": \"550e8400-...\",\n    \"message\": \"Invitation sent to newmember@example.com\"\n  }\n}\n"},{"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 }\n"},{"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\ntheir next token rotation.\n","body":[{"name":"role_id","type":"string","required":true,"description":"Target role id."}],"example_body":"{ \"role_id\": \"550e8400-...\" }\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"role_id\": \"...\", \"name\": \"owner\", \"description\": \"Organization owner with full access\", \"permissions\": [\"*\"], \"is_system\": true, \"created_at\": 1735689600, \"updated_at\": 1735689600 }\n    ],\n    \"total\": 1,\n    \"page\": 1,\n    \"per_page\": 20,\n    \"total_pages\": 1\n  }\n}\n"},{"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\npermission service interprets them); see the Permissions section\nfor the canonical names used by account-service itself.\n","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":"{\n  \"name\": \"auditor\",\n  \"description\": \"Read-only access to audit logs\",\n  \"permissions\": [\"read:organizations\", \"audit_logs:read\"]\n}\n","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 } }\n"},{"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 } }\n"},{"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\"] }\n","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 } }\n"},{"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\nit — reassign them first. System roles cannot be deleted.\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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\nall actors within the organization. Requires `audit_logs:read`\npermission in the org.\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"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 }\n    ],\n    \"total\": 1, \"page\": 1, \"per_page\": 20, \"total_pages\": 1\n  }\n}\n"}]},{"id":"registration-lock","title":"Registration kill-switch","description":"Platform-wide emergency switch for new account creation.\nIntended for rare situations where the platform team wants\nto gate every new account by hand (abuse spike, infra\ncapacity, controlled rollout).\n\n**Behaviour when locked.**\n- `POST /api/v1/auth/register` — request is queued into\n  `registration_requests` with status `pending` and the\n  response is `202 Accepted` with `data.pending = true`. No\n  account is created yet.\n- `POST /api/v1/auth/google` — the auto-create branch still\n  returns `423 Locked` for new users (queueing for SSO is\n  Phase B). Existing Google-linked users log in normally.\n- `POST /api/v1/auth/firebase` — same as Google.\n\n**Behaviour when open.** All paths behave normally. New\nsubmissions create accounts directly.\n\n**What is NOT affected.**\n- Existing-user login on any path.\n- Existing-user SSO link.\n- Invitation accept — invitee must already have an account\n  (they go through `/auth/register` first, which is gated).\n\n**Wire response when locked.**\n- For `/auth/register`: `202` with body\n  `{success:true, code:2020, data:{pending:true, request_id, message}}`.\n  FE renders a \"request received\" view.\n- For `/auth/google` and `/auth/firebase`: `423` with code\n  `4010` and the admin-supplied message (or the default\n  copy).\n\n**State storage.** Single-row Postgres table\n`registration_lock` is the source of truth; a 5-minute\nRedis cache on `registration_lock` key keeps the hot path\nfast. Toggling via the admin endpoints invalidates the\ncache explicitly so the new state is visible to the next\nrequest.\n\n**Audit.** Every toggle writes a structured log line tagged\nwith the admin's account id, the new `enabled` state, and\nwhether a message was set. No separate audit-log row yet —\nfollow the access log if you need historical attribution.\n","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\nthe superadmin UI at `/dashboard/admin-registration`.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"enabled\": false,\n    \"message\": null,\n    \"updated_at\": \"2026-05-22T10:00:00Z\",\n    \"updated_by_account_id\": \"550e8400-...\"\n  }\n}\n"},{"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.\nIdempotent — calling with the same `(enabled, message)`\ntwice is a no-op.\n\n**Body fields:**\n- `enabled` (bool, required) — `true` blocks, `false`\n  opens.\n- `message` (string, optional) — shown to users on the\n  register page when locked. Omit or send empty string\n  to clear and let the FE use its default copy.\n\nReturns the new state; Redis cache is invalidated as\npart of the handler so the change is observable on the\nnext request.\n","body":[{"name":"enabled","type":"boolean","required":true,"description":"Lock state to set."},{"name":"message","type":"string","description":"Optional user-facing reason."}],"example_body":"{\n  \"enabled\": true,\n  \"message\": \"Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB\"\n}\n"},{"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\n— the register page calls this on first render so it\ncan show the banner instead of letting the user fill\nout a form that's going to 423.\n\nOnly two fields are returned (`locked`, `message`); the\naudit columns stay private. Backed by the same 5-minute\nRedis cache as the gate check.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"locked\": true,\n    \"message\": \"Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB\"\n  }\n}\n"}]},{"id":"registration-requests","title":"Registration requests (review queue)","description":"Queue of registration attempts that arrived while the\nkill-switch was on. Superadmin reviews each row from\n`/dashboard/admin-registration-requests` and either\napproves it (which issues an approval token the user\nconsumes to set their password and activate the account)\nor rejects it.\n\n**States.**\n- `pending` — submission captured, no review yet.\n- `approved` — superadmin approved, approval token valid\n  for 24 hours, account NOT yet created.\n- `rejected` — superadmin rejected. Terminal.\n- `completed` — user followed the approval link and\n  activated the account. Terminal.\n\n**Token model.** Approval issues a 32-character base62\nplaintext token. The DB stores only its sha256 hash; the\nplaintext is returned ONCE in the approve response (Phase\nA: shown to the admin to share manually; Phase B will\nemail it). The user POSTs the plaintext back to\n`/auth/complete-registration`, which hashes, looks up,\nand consumes it.\n\n**Phase A scope.** This release covers email-password\nsubmissions only. SSO requests during the lock window\nstill return `423` and do not enter the queue. Email\nnotifications are not automated yet — the admin shares\nthe approval link out-of-band.\n","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\nintentionally omitted — queue depth is expected to\nstay small under realistic emergency conditions.\n","query_params":[{"name":"status","type":"string","default":"pending","description":"One of pending, approved, rejected, completed."}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"550e8400-...\",\n      \"email\": \"newuser@example.com\",\n      \"display_name\": \"Alice\",\n      \"timezone\": \"Asia/Jakarta\",\n      \"language\": \"id\",\n      \"source\": \"email_password\",\n      \"ip_address\": \"203.0.113.42\",\n      \"user_agent\": null,\n      \"status\": \"pending\",\n      \"requested_at\": \"2026-05-23T05:30:00Z\",\n      \"reviewed_at\": null,\n      \"reviewed_by_account_id\": null,\n      \"review_note\": null,\n      \"completed_account_id\": null\n    }\n  ]\n}\n"},{"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\napproval token. The plaintext token is returned ONCE\nin `data.approval_url`; share it with the user\nout-of-band (chat, manual email) until the\nemail-event automation lands.\n\n**Errors:**\n- `404` — request not found.\n- `422` — request is not in `pending` (already\n  approved/rejected/completed).\n","body":[{"name":"note","type":"string","description":"Internal review note (not shown to the user)."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"550e8400-...\",\n    \"approval_url\": \"https://account.ikavia.com/complete-registration?token=K4j8m...\",\n    \"expires_at\": \"2026-05-24T05:30:00Z\"\n  }\n}\n"},{"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\nsent to the user automatically (intentional — guards\nagainst probe-and-confirm by attackers).\n","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.\nConsume the token, set the password, create the\naccount, auto-login. Same response shape as\n`/auth/register` so the FE can treat completion as a\nnormal \"you're logged in now\".\n\n**Errors:**\n- `422` — token missing/invalid/expired, or password\n  too short (\u003c 8 chars).\n- `429` — rate-limit (3/min/IP, same as register).\n\n**Phase A limitation.** SSO-source requests return\n`422` here — only `email_password` source is finalised\nby this endpoint for now.\n","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\nexist on the platform. Every `POST /api/v1/service-accounts`\nis gated against:\n\n1. **Per-user limit** — how many service accounts a single\n   human can own across ALL orgs (default 10).\n2. **Per-org limit** — how many service accounts a single\n   organization can have (default 50).\n\nBoth checks run. The stricter one wins — if the user has 9\nslots left but the org has 0, the create is rejected.\nArchived / deleted service accounts are NOT counted; archive\none to free up a slot.\n\n**Wire response when limit is reached:** HTTP `429 Too Many\nRequests` with code `4011` and a message naming which\ndimension overflowed.\n\n**Lookup order** when computing the effective limit at\ncheck time:\n1. Per-target override row (user or org) — if present, use\n   its `max_count`.\n2. Global override row (`scope='global'`, single row) — if\n   present, use its `max_count`.\n3. Hardcoded defaults (`DEFAULT_MAX_PER_USER = 10`,\n   `DEFAULT_MAX_PER_ORG = 50`).\n\n**Storage.** Each row in `service_account_quotas` is an\noverride. Empty table ⇒ everyone uses the in-code defaults.\nThree partial unique indexes enforce one row per\n`(scope, target_id)` combination.\n\n**Audit.** Each upsert / delete writes a structured log line\nwith `admin_id`, `scope`, `target_id`, and the new value.\nA dedicated audit_log row is not written yet.\n","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\nfull list of override rows. `global_override_active`\nis `true` when a `global`-scope row exists; otherwise\nthe defaults are the hardcoded values.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"defaults\": {\n      \"per_user\": 10,\n      \"per_org\": 50,\n      \"global_override_active\": false\n    },\n    \"overrides\": [\n      {\n        \"id\": \"...\",\n        \"scope\": \"org\",\n        \"target_id\": \"550e8400-...\",\n        \"max_count\": 100,\n        \"note\": \"INC-1234 raised limit\",\n        \"updated_at\": \"2026-05-23T07:00:00Z\",\n        \"updated_by_account_id\": \"...\"\n      }\n    ]\n  }\n}\n"},{"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\nrequired for `scope` of `org` or `user`; ignored when\n`scope=global`. `max_count` must be `\u003e= 0` (zero\neffectively blocks all creation for that target).\n","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":"{\n  \"scope\": \"org\",\n  \"target_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"max_count\": 100,\n  \"note\": \"INC-1234 raised limit for prod rollout\"\n}\n"},{"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\nthe global override or hardcoded defaults. Use the\nseparate global endpoint below for the global row.\n"},{"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\nthe hardcoded values (`DEFAULT_MAX_PER_USER = 10`,\n`DEFAULT_MAX_PER_ORG = 50`).\n"}]},{"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,\nplanner, …) that want to expose an **API Key Manager** inside\ntheir own UI. The goal: a user logged into wacca.ikavia.com can\ncreate, list, and revoke service accounts + API keys for their\norg **without leaving wacca.ikavia.com** — no redirect to\naccount.ikavia.com required.\n\nThis is the default and recommended pattern. The wire shape is\nidentical to what `account.ikavia.com/dashboard/api-keys` itself\nuses; you are just calling the same endpoints from your domain.\n\n**Who should see this panel — important.** API keys are\nlong-lived credentials that grant programmatic access to org\ndata. **Do NOT expose this panel to every user of your\nservice.** Restrict the menu entry / route to:\n- **Organization owners** in account-service's model (the\n  user who created the org, or anyone with `service_accounts:*`\n  + `api_keys:*` grants — check `permissions` array on their\n  JWT or call `/me` to inspect).\n- **Your own service's superadmin / platform-admin role** if\n  you maintain a separate admin tier (e.g. a wacca-staff role\n  that operates across orgs).\n\nRegular org members — even \"admin\"-ish roles like project\nlead or team manager — should NOT see this panel by default.\nA leaked API key bypasses every UI guardrail; the audience\nlist for this feature should match who you'd trust with a\nraw database credential.\n\n**Permission check on the BE side.** account-service enforces\n`service_accounts:create` / `api_keys:create` on the user's\nJWT regardless of what your FE renders — a manually crafted\nrequest from a non-owner gets 403. That makes the FE\nrestriction a UX choice (don't show what won't work), not\nthe security boundary. The security boundary lives on\naccount-service.\n\n**Setup with the platform team.** No registration needed —\naccount-service does not know your panel exists. Each call\nruns as the user via their forwarded JWT.\n\n**One-time CORS setup.** Required only if you call\naccount.ikavia.com directly from the browser:\n- File a request to add your subdomain (e.g.\n  `https://wacca.ikavia.com`) to\n  `APP__HTTP__CORS__ALLOWED_ORIGINS`. Until added, the browser\n  blocks the request with a CORS error.\n- Skip this step if you proxy the call through your own\n  backend instead (BE→BE has no CORS).\n","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:\n- **List** — table of existing service accounts (bots) in\n  the user's current org, each with a chevron to expand its\n  keys.\n- **Create service account** — form with name + description.\n  Service accounts hold the keys; you cannot have a key\n  without first having a service account to attach it to.\n- **Create / revoke / delete API key** — under a service\n  account, list its keys (showing only `key_prefix` —\n  plaintext is never re-fetched) with create, revoke, and\n  delete actions.\n\nMirror the layout of\n`https://account.ikavia.com/dashboard/api-keys` if you want\na known-good reference; everything you see there is\nbuildable from the public endpoints in this section.\n"},{"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\naudience for this panel, and pick up the org context.\n\n**Audience check (do this before rendering the menu\nitem):**\n- Look at `permissions` in the user's access-token claims\n  (or call `/me` and inspect the org's role). The user\n  must hold `service_accounts:create` and `api_keys:create`\n  on the target org — or your own service's superadmin\n  role, if you have one.\n- If they don't, hide the menu entry. Don't render the\n  page just to show a 403 — surface the capability only\n  to people who can actually use it.\n\n**Org context:** read `current_organization_id` from\n`/me`. If your service has its own concept of \"the org\nthis user is currently looking at\", use that instead, as\nlong as it matches one of `organizations[]` in the\nresponse. Every CRUD call below filters by this org.\n"},{"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`\nis the parent for that bot's API keys (Step 5).\n"},{"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`.\nReturns the new row; **no API key is minted yet** — that's\na separate call so the admin can decide permissions for\neach key independently.\n","example_body":"{\n  \"organization_id\": \"550e8400-...\",\n  \"display_name\": \"Wacca tenant sync bot\",\n  \"description\": \"Reads tenant rosters for nightly sync\"\n}\n"},{"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\nfield `data.key` is the ONLY time the raw key is ever\nexposed by account-service. Subsequent reads see only\n`key_prefix` (first 8 chars) for visual identification.\n\nYour FE MUST render this in a \"show-once modal\":\n- Display `data.key` in a copyable field with a `Copy`\n  button.\n- Strongly word the warning that dismissing the modal\n  discards the plaintext.\n- Drop the value from in-memory state once the modal\n  closes. Do not store it in localStorage, sessionStorage,\n  or send it to your own analytics — treat it like a\n  credit-card CVV.\n\nReference implementation:\n`account.ikavia.com/dashboard/api-keys` (open dev tools,\nwatch the network tab as you create a key to see the\nexact response shape).\n"},{"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\naudit. Use `DELETE /api/v1/api-keys/{id}` only if the user\nexplicitly wants the audit history gone.\n"},{"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\ncall through your own backend. The Bearer token you forward\nIS the user's token — your BE doesn't need its own\ncredential.\n\n```\n// wacca BE handler — runs after wacca's own auth middleware\n// has validated and exposed the user's JWT.\nfn list_service_accounts(user_jwt: String, org_id: String) {\n    let response = http_client\n        .get(format!(\n            \"https://account.ikavia.com/api/v1/service-accounts?organization_id={}\",\n            org_id\n        ))\n        .header(\"Authorization\", format!(\"Bearer {}\", user_jwt))\n        .send()?;\n    return response.json();\n}\n```\n\nSame pattern for every other call. The user's JWT is the\nonly credential you carry; your service has no separate\nidentity in this flow.\n"},{"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:\n\n```\n// wacca FE — assumes you store the JWT in memory or a\n// cookie scoped to .ikavia.com (the SSO shared cookie).\nconst res = await fetch(\n    `https://account.ikavia.com/api/v1/service-accounts?organization_id=${orgId}`,\n    {\n        method: 'GET',\n        headers: { Authorization: `Bearer ${jwt}` },\n        credentials: 'omit',  // bearer header, no cookies\n    },\n);\nconst { data } = await res.json();\n```\n\n`credentials: 'omit'` is correct here — the cookie path\n(`.ikavia.com`-scoped session) is for human flows on\naccount.ikavia.com itself. From your origin, the bearer\ntoken is the auth.\n"}]},{"id":"service-accounts","title":"Service Accounts","description":"Non-human principals scoped to one organization. They authenticate\nvia API keys (`X-API-Key`) or service JWTs obtained from\n`/auth/token-exchange`. Service accounts cannot log in interactively.\n\n**For sibling services (wacca, chat, planner, …) building their own\n\"API keys\" panel.** These endpoints are designed to be called by\nyour own UI on behalf of a logged-in user — you do not need to\nredirect them to account.ikavia.com. Forward the user's existing\naccess token in `Authorization: Bearer …`, and the call runs with\nthat user's permissions. The user (not your service) must hold\n`service_accounts:*` / `api_keys:*` in the org they're managing —\ntypically that's the org owner or an admin role you've assigned\nthose permissions to.\n\n**Wire details for embedding:**\n- **Auth.** Bearer JWT only — do NOT use the cookie-based\n  session here (your origin won't have the cookie). Read the\n  token from your own session store, forward it, that's it.\n- **CORS.** `account.ikavia.com` only allows requests from an\n  explicit allowlist (`APP__HTTP__CORS__ALLOWED_ORIGINS`).\n  Today: account / media / email / mail / survey / planner /\n  docs. **Your subdomain must be added by the platform team\n  before browser calls work.** Filing the request with the\n  URL of the panel you're building is enough; until added,\n  the browser blocks the request and you'll see a CORS error\n  in the console.\n- **CORS-free alternative.** Proxy through your service's\n  backend instead of calling from the browser. Your BE\n  already has the JWT (forwarded from the browser); make the\n  outbound call from there. Skips CORS entirely and gives\n  you a chance to add per-service rate limits.\n- **Plaintext key handling.** `POST /service-accounts/{id}/api-keys`\n  returns the raw key in `data.key` ONLY at creation. Show it\n  in a \"copy once\" modal exactly like account.ikavia.com's\n  own API-keys page does (see [[account_service_role]]); on\n  every subsequent read the key is hashed and only\n  `key_prefix` is visible.\n- **Permission model.** The user's JWT permissions are checked\n  against `service_accounts:create`, `api_keys:create`, etc.\n  If your service exposes a \"team management\" page where a\n  non-owner can create keys, that user needs the relevant\n  grant on the org first — either via the org's roles UI or\n  by being the org owner.\n","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\nquery parameter (not a path segment) because callers commonly\nenumerate across orgs they manage.\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"account_id\": \"...\", \"display_name\": \"Render bot\", \"status\": \"active\", \"capabilities\": [\"museum:read\"], \"last_used_at\": 1735693200, \"created_at\": 1735689600 }\n    ],\n    \"total\": 1, \"page\": 1, \"per_page\": 20, \"total_pages\": 1\n  }\n}\n"},{"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\nreturned account has no API key yet — call\n`POST /service-accounts/{account_id}/api-keys` afterwards.\n","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":"{\n  \"organization_id\": \"550e8400-...\",\n  \"display_name\": \"Render bot\",\n  \"description\": \"Generates thumbnail previews\",\n  \"capabilities\": [\"museum:read\", \"artifact:write\"],\n  \"allowed_tools\": [\"thumbnailer\"],\n  \"rate_limit_per_minute\": 120\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-...\",\n    \"organization_id\": \"550e8400-...\",\n    \"display_name\": \"Render bot\",\n    \"description\": \"Generates thumbnail previews\",\n    \"status\": \"active\",\n    \"capabilities\": [\"museum:read\", \"artifact:write\"],\n    \"allowed_tools\": [\"thumbnailer\"],\n    \"rate_limit_per_minute\": 120,\n    \"last_used_at\": null,\n    \"created_at\": 1735689600,\n    \"updated_at\": 1735689600\n  }\n}\n"},{"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":"{\n  \"success\": true,\n  \"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 }\n}\n"},{"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\ntake effect on the next `/auth/token-exchange` call (or\nimmediately for raw `X-API-Key` flows).\n","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":"{\n  \"organization_id\": \"550e8400-...\",\n  \"display_name\": \"Render bot v2\",\n  \"rate_limit_per_minute\": 240\n}\n","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 } }\n"},{"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\nrevoked. Existing service JWTs continue to validate until their\nnatural exp (max 1 hour); add the JTI to the revocation list via\nre-issued tokens to shorten that further.\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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\nauthenticating until resumed; metadata and capabilities are\npreserved.\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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 }\n"}]},{"id":"api-keys","title":"API Keys","description":"API keys are issued under a service account and grant programmatic\naccess. The plaintext key is returned **only at creation time** —\naccount-service stores a sha-256 hash. Revoke leaked keys immediately\nvia `/api-keys/{api_key_id}/revoke`.\n\n**Embedding in your own panel.** Same model as the parent section:\nforward the logged-in user's JWT, the user needs `api_keys:*` on\nthe org, your subdomain needs to be in the CORS allowlist (or\nproxy via your BE). The Create endpoint returns the plaintext\nonce — your FE owns the show-once modal.\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"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 }\n    ],\n    \"total\": 1, \"page\": 1, \"per_page\": 20, \"total_pages\": 1\n  }\n}\n"},{"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\ntime the plaintext is ever exposed** — store it client-side\nimmediately. Subsequent calls see only the hash and `key_prefix`.\n","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":"{\n  \"name\": \"prod render bot\",\n  \"permissions\": [\"museum:read\"],\n  \"expires_in_days\": 180\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"api_key_id\": \"...\",\n    \"key\": \"ak_live_a1b2c3d4e5f6...REDACTED_RAW_KEY...\",\n    \"key_prefix\": \"ak_live_a1b2\",\n    \"name\": \"prod render bot\",\n    \"permissions\": [\"museum:read\"],\n    \"expires_at\": 1751155200,\n    \"created_at\": 1735689600\n  }\n}\n"},{"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\npurposes but stops authenticating immediately.\n","body":[{"name":"reason","type":"string","description":"Optional human-readable reason recorded on the audit log."}],"example_body":"{ \"reason\": \"leaked in public repo\" }\n","example_response":"{ \"success\": true, \"data\": null }\n"},{"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\nauditable history.\n","example_response":"{ \"success\": true, \"data\": null }\n"}]}],"guides":[{"id":"lost-device","icon":"📱","title":"Lost device / session recovery","description":"What to do when a user reports a lost or stolen device, or just\nwants to audit where they're signed in. Three patterns: review,\nrevoke-one, revoke-all.\n","flow":[{"step":1,"title":"Sign in from a trusted device","description":"The user logs in from a device they still control. This issues a\nfresh access + refresh pair on a new session id. **Existing\nsessions are not affected** — the lost device's session is still\nalive at this point.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"email\": \"user@example.com\", \"password\": \"SecurePass123!\" }'\n"},{"step":2,"title":"Review active sessions","description":"Lists every active session on the account, with last-used IP and\nuser-agent so the user can spot the rogue one. The session\nbacking the current request is flagged with `is_current: true`.\n","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 \\\n  -H 'Authorization: Bearer eyJ...'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"sessions\": [\n      { \"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 },\n      { \"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 }\n    ]\n  }\n}\n"},{"step":3,"title":"Revoke just the lost device's session (option A)","description":"Targeted revoke. Pass the suspected `session_id` from the list.\nThe next time that device tries to refresh its access token, it\nwill get `4003 Session has been revoked or expired` and be\nforced back to the login screen.\n","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-... \\\n  -H 'Authorization: Bearer eyJ...'\n","response_example":"{ \"success\": true, \"data\": null }\n"},{"step":4,"title":"Or revoke EVERYTHING (option B — paranoid)","description":"Sweep every active session for the account, including the one\nmaking the request. Use when the user can't tell which session\nis the rogue one, or after a credential leak. The browser cookies\nare also cleared on the response.\n\nAfter this, the user is logged out everywhere — they'll need to\nre-login on each device they actually want to keep.\n","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 \\\n  -H 'Authorization: Bearer eyJ...'\n","response_example":"{ \"success\": true, \"data\": { \"success\": true } }\n"}]},{"id":"mobile-google-signin","icon":"📱","title":"Sign in with Google (Mobile)","description":"End-to-end integration of Google Sign-In for native Android and\niOS apps targeting `account.ikavia.com`. The backend endpoint\nand account-resolution rules are identical to the web flow (see\nthe **Sign in with Google** endpoint in the Authentication\nsection); the platform-specific work is on the mobile side\nwhere each SDK mints a Google ID token, plus a small amount of\nsetup in Google Cloud Console.\n\nThe BE verifier accepts ID tokens whose `aud` claim matches\n**any** of the configured client ids — Web, Android, and iOS\nmay each use their own. Mobile teams do NOT need to coordinate\na single shared \"server client id\"; the BE handles a list of\naudiences from one Google Cloud project.\n\n**Status on `account.ikavia.com`:** both `/auth/google` and\n`/auth/firebase` are currently **live**. Web + Android\nOAuth Client IDs are wired into `/auth/google`; Firebase\nproject `wacca-77277` is wired into `/auth/firebase`. iOS\nOAuth Client ID is the only piece still pending (waiting on\nbundle identifier from the mobile team). See Step 1 for the\nfull provisioning table.\n\n### Firebase Auth — two supported patterns\n\nMobile teams that use Firebase Auth on the client have two\nequally-valid paths:\n\n- **Pattern A — dual-consume the raw Google token.** Sign in\n  natively (Credential Manager / GoogleSignIn-iOS), POST the\n  raw Google `idToken` to our `/auth/google`, and ALSO hand\n  the same token to Firebase locally via\n  `GoogleAuthProvider.credential(...)`. See the \"Using\n  Firebase Auth in parallel\" subsections under steps 3 and 4.\n- **Pattern B — send the Firebase-issued token directly.**\n  Sign in via Firebase (any provider: Google, Apple,\n  Facebook, email/password), grab the Firebase ID token via\n  `firebase.auth().currentUser.getIdToken()`, and POST it to\n  the dedicated `/auth/firebase` endpoint. The BE verifies\n  Firebase's signature, resolves the account via\n  `firebase_uid → email → auto-create`, and returns the same\n  `LoginResponse` shape as `/auth/google`. See step 14.\n\nPattern A is recommended when the only provider is Google —\nit's simpler and avoids an extra Firebase initialization.\nPattern B is recommended when the app supports multiple\nproviders through Firebase (Apple Sign-In, Facebook, etc.) —\none endpoint covers all of them.\n\n## Sequence at a glance\n\n```\n┌──────────┐   1. open app           ┌─────────────┐\n│  Mobile  │ ────────────────────────│  Local      │\n│  App     │                         │  secure     │\n│          │   2. try cached tokens  │  storage    │\n│          │ ◄───────────────────────│ (Keystore   │\n│          │                         │  / Keychain)│\n└────┬─────┘                         └─────────────┘\n     │\n     │ 3. no token / expired → tap \"Continue with Google\"\n     ▼\n┌──────────┐   4. show account picker   ┌──────────┐\n│ Google   │ ─────────────────────────► │ User     │\n│ SDK      │                            └────┬─────┘\n│ (GIS)    │   5. pick account              │\n│          │ ◄──────────────────────────────┘\n│          │   6. mint ID token (signed JWT)\n└────┬─────┘\n     │\n     │ 7. POST /api/v1/auth/google { credential, ... }\n     ▼\n┌──────────────────────────┐\n│  account.ikavia.com      │\n│  - verify JWT vs JWKS    │\n│  - check iss + aud + exp │\n│  - find or auto-link or  │\n│    auto-create account   │\n│  - issue our access +    │\n│    refresh JWT pair      │\n└────┬─────────────────────┘\n     │\n     │ 8. response: { access_token, refresh_token, created_now, ... }\n     ▼\n┌──────────┐\n│  Mobile  │  9. persist tokens in Keystore/Keychain\n│  App     │ 10. attach `Authorization: Bearer \u003caccess\u003e` to all\n│          │     subsequent API calls\n│          │ 11. on 401 → POST /auth/refresh → retry once\n│          │ 12. on logout → /auth/logout + clear local storage\n└──────────┘\n```\n","flow":[{"step":1,"title":"Provision platform-specific OAuth client ids in Google Cloud","description":"The **Web** Client ID for `account.ikavia.com` already\nexists. Mobile needs **one additional Client ID per\nplatform** in the same Google Cloud project. Both are\ncreated from\n[console.cloud.google.com/auth/clients](https://console.cloud.google.com/auth/clients)\n→ **+ Create client**.\n\n**Android**\n\n- **Application type:** Android\n- **Package name:** the Android app's package\n  (e.g. `com.ikavia.app`). Must match `applicationId` in\n  `build.gradle`.\n- **SHA-1 certificate fingerprint:**\n  - Debug keystore: `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android` → take the SHA1 line.\n  - Release: same command pointing at your release keystore.\n  - If using Play App Signing, take the SHA-1 from Play\n    Console → **Setup → App signing → App signing key\n    certificate**.\n  - Add **both** debug and release fingerprints — or create\n    two separate Client IDs if you prefer environment\n    isolation.\n\n**iOS**\n\n- **Application type:** iOS\n- **Bundle ID:** the iOS app's bundle identifier\n  (e.g. `com.ikavia.app`). Must match Xcode → target →\n  **General → Identity → Bundle Identifier**.\n- **App Store ID** (optional): the numeric id from App\n  Store Connect once published. Skip if pre-launch.\n\nSend the resulting Client IDs to ops; they'll be added to\n`APP__SECURITY__GOOGLE_OAUTH__CLIENT_IDS` (comma-separated)\nalongside the existing Web id, and the BE restarted.\n\n**Currently provisioned audiences on production**\n(as of this doc revision; ask ops for the live list):\n\n| Surface | Client ID / project | Endpoint enabled |\n|---|---|---|\n| Web | `836801358076-lfhjvt3l…apps.googleusercontent.com` | `/auth/google` ✅ |\n| Android | `836801358076-74v49lgq…apps.googleusercontent.com` | `/auth/google` ✅ |\n| iOS | *pending* — mobile team to supply bundle id | `/auth/google` (will accept once provisioned) |\n| Firebase | project `wacca-77277` | `/auth/firebase` ✅ |\n\nBoth endpoints (`POST /api/v1/auth/google` and\n`POST /api/v1/auth/firebase`) are **live**. Smoke-tested\non `account.ikavia.com` — bogus tokens get a clean 401\nrather than a 503, and Firebase JWKS is being cached in\nour Redis. Mobile team can integrate against either or\nboth at any time without further ops coordination.\n"},{"step":2,"title":"Backend — multi-audience configuration","description":"Operator updates the deployment env so the verifier\naccepts the mobile tokens. Both forms below are accepted;\nthe plural form is preferred.\n\n```env\n# singular (legacy, still honoured)\nAPP__SECURITY__GOOGLE_OAUTH__CLIENT_ID=\u003cweb-client-id\u003e.apps.googleusercontent.com\n\n# plural (preferred). Comma-separated.\nAPP__SECURITY__GOOGLE_OAUTH__CLIENT_IDS=\u003cweb-id\u003e.apps.googleusercontent.com,\u003candroid-id\u003e.apps.googleusercontent.com,\u003cios-id\u003e.apps.googleusercontent.com\n```\n\nBoth env vars are merged at startup — supplying either is\nfine. The startup log line `Sign-In-With-Google enabled`\ncarries `count=\u003cn\u003e` so ops can confirm the right number of\naudiences was loaded. Restart `account-service` after\nediting.\n"},{"step":3,"title":"Android — request an ID token via Credential Manager","description":"The current Google-recommended path on Android is the\n[Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager-siwg)\n(`androidx.credentials:credentials`). The deprecated\n`GoogleSignInClient` still works but should not be used in\nnew code.\n\n**Gradle (`app/build.gradle`):**\n\n```gradle\n// Pin to a known-good range. As of May 2026 the latest\n// stable line is 1.3.x; bump only after a smoke test.\nimplementation \"androidx.credentials:credentials:1.3.0\"\nimplementation \"androidx.credentials:credentials-play-services-auth:1.3.0\"\nimplementation \"com.google.android.libraries.identity.googleid:googleid:1.1.1\"\n```\n\n**Kotlin** — initiate the picker, get the ID token:\n\n```kotlin\nval credentialManager = CredentialManager.create(context)\nval googleIdOption = GetGoogleIdOption.Builder()\n    // false = show every Google account on the device\n    // (best UX for first-time signin). true = only show\n    // accounts that have previously signed in to this app.\n    .setFilterByAuthorizedAccounts(false)\n    // ANDROID Client ID from step 1.\n    .setServerClientId(\"836801358076-74v49lgqvi48lf47ff4h90uon8pt5e8c.apps.googleusercontent.com\")\n    // Nonce binds this credential to one signin attempt;\n    // currently not validated server-side but cheap to\n    // include and useful when we tighten validation later.\n    .setNonce(java.util.UUID.randomUUID().toString())\n    .build()\n\nval request = GetCredentialRequest.Builder()\n    .addCredentialOption(googleIdOption)\n    .build()\n\ntry {\n    val result = credentialManager.getCredential(activityContext, request)\n    val credential = result.credential\n    if (credential is CustomCredential\n        \u0026\u0026 credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {\n        val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)\n        val idToken: String = googleIdTokenCredential.idToken\n        // POST idToken to account.ikavia.com (see step 5).\n    }\n} catch (e: NoCredentialException) {\n    // Device has zero Google accounts, or user dismissed\n    // the bottom sheet without picking. UX: surface a\n    // friendly \"Try a different way to sign in\" message\n    // and keep the email-password form available.\n} catch (e: GetCredentialCancellationException) {\n    // User explicitly tapped \"Cancel\". Treat as a no-op,\n    // do not show an error toast.\n} catch (e: GetCredentialException) {\n    // Other transient SDK errors. Log + retry-friendly\n    // toast: \"Google sign-in temporarily unavailable.\n    // Please try again.\"\n}\n```\n\n**Handling `NoCredentialException`.** Common when the\nemulator has no signed-in Google account or the user wipes\naccounts in Settings. Don't crash; fall back gracefully —\nthe email/password form is still wired and is the only\npath for users who deliberately don't keep a Google\naccount on the device.\n\n### Using Firebase Auth in parallel (optional)\n\nIf the Android app *also* needs Firebase Auth on the\nclient (for Firestore security rules, Cloud Functions\ncallable auth, Crashlytics user binding, Remote Config\npersonalization, etc.), use the same `idToken` for\nBOTH paths — our BE and Firebase. Account-service only\naccepts **raw Google ID tokens**; it does NOT verify\nFirebase-issued tokens (different `iss`, different\nJWKS, different `aud` format).\n\n```kotlin\n// 1. idToken from Credential Manager above. Send it to\n//    our BE first so the network call is in-flight.\nval ourBackendDeferred = scope.async {\n    loginToIkaviaWithGoogle(idToken)  // POST /api/v1/auth/google\n}\n\n// 2. In parallel, hand the SAME idToken to Firebase\n//    so client-side Firebase services know who the\n//    user is. Firebase mints its own internal token\n//    locally — we don't send it anywhere.\nval firebaseCredential = GoogleAuthProvider.getCredential(idToken, null)\nFirebaseAuth.getInstance().signInWithCredential(firebaseCredential).await()\n\n// 3. Both paths now reflect the same user.\nval ourBackendResponse = ourBackendDeferred.await()\n```\n\nKey points:\n\n- The `idToken` is single-use against Google's verifier\n  but **both** consumers (our BE and Firebase) verify\n  it independently — passing it to one does not consume\n  it for the other. They can run in any order or in\n  parallel.\n- Firebase will mint a Firebase UID for the user that\n  is **different** from the `account_id` returned by\n  our BE. Don't try to reconcile them; treat the two\n  as orthogonal identities — Firebase UID for\n  client-side Firebase rules, our `account_id` for\n  anything that talks to `account.ikavia.com`.\n- On logout, call `FirebaseAuth.getInstance().signOut()`\n  alongside the steps in section 11.\n"},{"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**:\n\n```\nhttps://github.com/google/GoogleSignIn-iOS\n```\n\n**`Info.plist`** — register the URL scheme Google uses to\nreturn to your app. The scheme is the iOS Client ID\n**reversed** (e.g. Client ID\n`123-abc.apps.googleusercontent.com` becomes scheme\n`com.googleusercontent.apps.123-abc`):\n\n```xml\n\u003ckey\u003eCFBundleURLTypes\u003c/key\u003e\n\u003carray\u003e\n    \u003cdict\u003e\n        \u003ckey\u003eCFBundleURLSchemes\u003c/key\u003e\n        \u003carray\u003e\n            \u003cstring\u003ecom.googleusercontent.apps.\u003creversed-ios-client-id\u003e\u003c/string\u003e\n        \u003c/array\u003e\n    \u003c/dict\u003e\n\u003c/array\u003e\n```\n\nRecent GoogleSignIn-iOS releases (7.x+) use\n`ASWebAuthenticationSession` under the hood and may not\nstrictly require the URL scheme for the basic ID-token\nflow, but registering it is still recommended — it's the\ndocumented fallback path and costs nothing.\n\n**Swift** — initiate the sign-in. The caller must be a\nlive `UIViewController` (or `NSViewController` on\nmacOS Catalyst); pass it as `presenting`:\n\n```swift\nimport GoogleSignIn\n\n// AppDelegate / SceneDelegate — handle the redirect back.\nfunc application(_ app: UIApplication, open url: URL,\n                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -\u003e Bool {\n    GIDSignIn.sharedInstance.handle(url)\n}\n\n// From any UIViewController (the one mounting the\n// \"Continue with Google\" button is the natural choice):\nlet config = GIDConfiguration(clientID: \"\u003cios-client-id\u003e.apps.googleusercontent.com\")\nGIDSignIn.sharedInstance.configuration = config\nGIDSignIn.sharedInstance.signIn(withPresenting: self) { result, error in\n    if let error = error {\n        // GIDSignInError.canceled is the \"user dismissed\"\n        // case — treat as a silent no-op. Other codes\n        // (network, presenter-not-available) deserve a\n        // toast.\n        if (error as NSError).code == GIDSignInError.canceled.rawValue { return }\n        // surface other errors to the user\n        return\n    }\n    guard let user = result?.user,\n          let idToken = user.idToken?.tokenString else { return }\n    // POST idToken to account.ikavia.com (see step 5).\n}\n```\n\n### Using Firebase Auth in parallel (optional)\n\nSame idea as Android: send `idToken` to our BE, and\n*also* hand it to Firebase if the iOS app uses any\nFirebase client SDK (Firestore, Functions, Crashlytics\nuser binding, Remote Config). Our BE does NOT accept\nFirebase-issued tokens — only raw Google ones.\n\n```swift\nimport FirebaseAuth\nimport GoogleSignIn\n\nGIDSignIn.sharedInstance.signIn(withPresenting: self) { result, error in\n    guard error == nil,\n          let user = result?.user,\n          let idToken = user.idToken?.tokenString else { return }\n    let accessToken = user.accessToken.tokenString\n\n    // 1. POST to our BE (kick off network early).\n    Task { await loginToIkaviaWithGoogle(idToken: idToken) }\n\n    // 2. In parallel, sign the user into Firebase locally.\n    //    Firebase verifies the SAME idToken against\n    //    Google's JWKS and mints its own internal\n    //    session — we don't send the Firebase token\n    //    anywhere.\n    let credential = GoogleAuthProvider.credential(\n        withIDToken: idToken,\n        accessToken: accessToken,\n    )\n    Auth.auth().signIn(with: credential) { _, error in\n        // handle error; the BE login can still succeed\n        // independently if this fails.\n    }\n}\n```\n\nSame caveats as Android:\n\n- `idToken` can be verified by both Google (Firebase\n  does this) and our BE in any order; the two paths\n  are independent.\n- The Firebase UID (`Auth.auth().currentUser?.uid`)\n  is **not** the same identifier as our\n  `account_id`. Keep them in separate variables and\n  send the right one to the right service.\n- On logout, call `try? Auth.auth().signOut()`\n  alongside the steps in section 11.\n"},{"step":5,"title":"POST the ID token to account-service","description":"Identical contract for both platforms — `idToken` is the\nraw JWT the SDK just returned. The response shape mirrors\npassword login, with two mobile-relevant additions:\n\n- `created_now` — `true` if THIS request created the\n  account. Use it to gate first-time onboarding (welcome\n  screen, profile completion prompt). `false` for every\n  subsequent login.\n- `csrf_token` — **mobile clients should ignore this\n  field.** It exists for browser-side CSRF defence and is\n  irrelevant when the caller authenticates with\n  `Authorization: Bearer ...`.\n\n`device_timezone` / `device_language` are applied **only\non auto-signup** (first-ever sign-in for that email). On\nsubsequent logins they are ignored — the user owns those\nsettings via the profile endpoint.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"credential\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y...\",\n    \"device_timezone\": \"Asia/Jakarta\",\n    \"device_language\": \"id\"\n  }'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n    \"email\": \"user@gmail.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJ...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 7200,\n    \"organizations\": [ { \"...\": \"...\" } ],\n    \"current_org_id\": \"550e8400-...\",\n    \"requires_email_verification\": false,\n    \"created_now\": true,\n    \"csrf_token\": \"f3a1c0...\"\n  }\n}\n"},{"step":6,"title":"Error responses and how to handle them","description":"The endpoint returns one of these on failure. **Always\nread `code` (numeric) rather than HTTP status** — the\nmapping is stable while HTTP status may collapse multiple\ncodes onto the same number.\n\n- **`code: 4003` HTTP 401 — `invalid google credential`.**\n  Token signature failed, expired, has the wrong audience,\n  wrong issuer, or `email_verified=false` from Google.\n  UX: surface \"We couldn't sign you in with Google. Please\n  try again or use email/password.\" Do NOT retry\n  automatically — the token won't get better.\n- **`code: 4005` HTTP 409 — `google_sub` duplicate.**\n  The same Google identity is already linked to a\n  different account. Almost certainly a developer-side\n  mistake (testing with a stale build that hardcoded a\n  sub). Surface \"This Google account is already linked to\n  another user.\" and log full response for triage.\n- **`code: 4008` HTTP 429 — rate limit exceeded.**\n  Five-per-minute-per-IP. The response body carries\n  `retry_after` (seconds). UX: disable the button for\n  `retry_after` seconds, show a countdown.\n- **`code: 5000` HTTP 503 — feature disabled.**\n  Operator hasn't configured any Client ID. Should never\n  happen in prod once integrated; if it does, log and tell\n  the user \"Google sign-in is temporarily unavailable\" —\n  this is an ops problem, not a user-recoverable error.\n- **Network / 5xx.** Treat as transient. Allow the user\n  to retry; consider a short auto-retry with exponential\n  backoff (1s → 3s → 9s) before showing a permanent error.\n\n**Common Google-side issues that produce `4003`:**\n\n- Wrong `serverClientId` in the SDK call — the BE doesn't\n  recognise that audience. Ops must add it to\n  `CLIENT_IDS`.\n- User signed into a Google Workspace account whose admin\n  blocks third-party app access. Nothing the app can do;\n  ask the user to try a personal Google account.\n- Clock skew on the device. The verifier tolerates 60s of\n  skew but anything beyond that breaks `exp` validation.\n  Encourage user to enable automatic time on the device.\n"},{"step":7,"title":"Secure token storage","description":"The `access_token` has a short lifetime (2 hours) but the\n`refresh_token` is long-lived (7 days; see the deployment's\n`APP__SECURITY__JWT__REFRESH_TOKEN_TTL_DAYS`) and grants\nthe same authority as the user's password while it\nremains valid. It MUST NOT be stored in any plaintext key/value\nstore.\n\n**Android** — use the Keystore-backed\n[EncryptedSharedPreferences](https://developer.android.com/topic/security/data):\n\n```kotlin\nval masterKey = MasterKey.Builder(context)\n    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)\n    .build()\nval prefs = EncryptedSharedPreferences.create(\n    context,\n    \"ikavia_auth\",\n    masterKey,\n    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,\n    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,\n)\nprefs.edit()\n    .putString(\"access_token\", accessToken)\n    .putString(\"refresh_token\", refreshToken)\n    .putLong(\"access_expires_at_epoch_ms\",\n             System.currentTimeMillis() + expiresInSeconds * 1000L)\n    .apply()\n```\n\nFor higher assurance (financial / health data), use the\nAndroidKeyStore directly with a hardware-backed key —\n`setUserAuthenticationRequired(true)` forces a biometric\nunlock per use.\n\n**iOS** — use Keychain. The simplest path is the\n[KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess)\nwrapper. Pin a recent version (4.x).\n\n```swift\nlet keychain = Keychain(service: \"com.ikavia.app.auth\")\n    .accessibility(.whenUnlockedThisDeviceOnly)\ntry keychain.set(accessToken, key: \"access_token\")\ntry keychain.set(refreshToken, key: \"refresh_token\")\ntry keychain.set(String(accessExpiresAtUnix), key: \"access_expires_at_unix\")\n```\n\n`whenUnlockedThisDeviceOnly` prevents the token from\nsyncing to iCloud Keychain or restoring to a different\ndevice — the right default for an auth credential.\n\n**Never log `refresh_token`** — scrub it from analytics\nand crash reporting payloads. Configure Crashlytics /\nSentry / Datadog to redact keys named `refresh_token`,\n`access_token`, `credential`, `Authorization`.\n"},{"step":8,"title":"Bootstrap on app launch","description":"On every cold start, the app needs to decide whether the\nuser is still signed in. The recommended algorithm:\n\n1. Read `access_token`, `refresh_token`,\n   `access_expires_at_*` from secure storage.\n2. If no `refresh_token` → show the sign-in screen. Done.\n3. If `access_expires_at_*` is in the future with at\n   least 60s of headroom → user is \"signed in\"; mount the\n   home screen and attach the cached `access_token` to\n   the first protected call.\n4. Otherwise → call `POST /auth/refresh` (step 9) *before*\n   the first protected call. On success update storage\n   and proceed. On failure (any error) → wipe local\n   tokens and route to sign-in.\n\nThe 60-second headroom in step 3 prevents the rare race\nwhere an `access_token` is valid at the moment of the\nfirst request but expires mid-flight. Adjust if your\nbackend latency is unusually high.\n"},{"step":9,"title":"Refresh-on-401 policy","description":"On HTTP 401 from any protected endpoint, the app should:\n\n1. **Acquire a lock** (mutex / coroutine actor). If a\n   refresh is already in flight, *wait* for that result\n   rather than firing a parallel refresh. Two parallel\n   refreshes against the same `refresh_token` rotate it\n   at the same time, then both retries 401-fail because\n   the old `refresh_token` was just revoked by the\n   first call.\n2. Call `/auth/refresh` once.\n3. **On success** — update storage, retry the original\n   request exactly once with the new `access_token`. If\n   *that* also 401s, give up (the server thinks the user\n   is gone; don't infinite-loop) — wipe local tokens,\n   route to sign-in.\n4. **On 401 from refresh itself** — the session was\n   revoked server-side (logout-all, admin reset, refresh\n   token theft detection). Wipe local tokens, route to\n   sign-in. Do NOT call `/auth/refresh` again.\n5. **On network / 5xx from refresh** — surface the error;\n   don't wipe tokens. The user retries the action and the\n   refresh is attempted again.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"refresh_token\": \"eyJ...\" }'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJ...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 7200\n  }\n}\n"},{"step":10,"title":"Attach `Authorization: Bearer …` to every protected call","description":"After login or refresh succeeds, every subsequent call to\na protected endpoint (anything under `/api/v1` that isn't\n`/auth/...`) must carry the access token in the\n`Authorization` header. There is no cookie path for\nmobile — mobile clients do NOT participate in the SSO\nrefresh-cookie flow that web uses.\n\n**Android (OkHttp interceptor):**\n\n```kotlin\nclass BearerAuthInterceptor(\n    private val tokens: () -\u003e String?, // reads from EncryptedSharedPreferences\n) : Interceptor {\n    override fun intercept(chain: Interceptor.Chain): Response {\n        val req = chain.request().newBuilder().apply {\n            tokens()?.let { addHeader(\"Authorization\", \"Bearer $it\") }\n        }.build()\n        return chain.proceed(req)\n    }\n}\n```\n\n**iOS (URLSession):**\n\n```swift\nvar request = URLRequest(url: url)\nif let token = keychain[\"access_token\"] {\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n}\n```\n"},{"step":11,"title":"Logout (and the difference vs full Google revoke)","description":"There are two distinct user actions, with two different\nserver-side effects:\n\n- **\"Log out\"** — sign out of *this app*, but keep the\n  Google account linked. Next time the user opens the app\n  and taps \"Continue with Google\", they get the same\n  account back with one tap (Google remembers them).\n- **\"Disconnect Google account\"** — fully revoke the\n  authorization grant on Google's side. The next sign-in\n  attempt re-prompts for consent. Rare; offer it as a\n  buried option in account settings.\n\nThe flow for plain logout:\n\n1. Call `/auth/logout` to revoke the session on the BE.\n2. Wipe local secure storage.\n3. Call the platform SDK's local-sign-out so the next\n   Credential Manager / GIDSignIn invocation re-prompts:\n\n   - **Android (Credential Manager):**\n     `credentialManager.clearCredentialState(ClearCredentialStateRequest())`\n   - **iOS (GoogleSignIn):**\n     `GIDSignIn.sharedInstance.signOut()`\n\nFor \"disconnect\", *also* call\n`GIDSignIn.sharedInstance.disconnect { … }` on iOS, or\nrevoke via the Google account-settings web link on\nAndroid (the Credential Manager API has no equivalent\nrevoke call as of May 2026).\n","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 \\\n  -H 'Authorization: Bearer \u003caccess_token\u003e' \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"refresh_token\": \"eyJ...\" }'\n"},{"step":12,"title":"Testing checklist","description":"Run through this checklist on each platform before\nshipping the integration. None of these scenarios require\ncoordinating with ops — just one debug build and a couple\nof Google accounts.\n\n**Happy path**\n\n- Sign in with a brand-new Google account whose email is\n  NOT in our DB. Expect: `created_now: true`,\n  `display_name` matches the Google account name,\n  timezone / language reflect what the device sent.\n- Sign out, sign in again with the same Google account.\n  Expect: `created_now: false`, same `account_id` as\n  before, same `display_name`.\n- Sign in with a Google account whose email IS already in\n  our DB (register that email first via the web flow,\n  then sign in with Google on mobile). Expect:\n  `created_now: false`, same `account_id` the web account\n  has, and from that point the user can sign in with\n  EITHER password or Google.\n\n**Edge cases**\n\n- Device with zero Google accounts → `NoCredentialException`\n  (Android) or `signIn` error (iOS). UI should not crash\n  and should keep email/password available.\n- Tap \"Cancel\" on the account picker → silent no-op, no\n  error toast, no log.\n- Airplane mode during sign-in → SDK error before the BE\n  call. Toast should be retry-friendly.\n- Airplane mode during the `/auth/google` POST → network\n  error after the SDK succeeded. The Google credential is\n  already consumed; the UI should let the user retry the\n  same login (the SDK will mint a fresh credential).\n- Sign-in with the same Google account on two devices →\n  both should succeed and own independent sessions. Logging\n  out on device A must NOT log out device B.\n- `/auth/refresh` after the refresh-token TTL (currently\n  7 days) of inactivity → expected\n  to fail with 401. Confirm the app routes to sign-in\n  without crashing.\n\n**Security checks**\n\n- Inspect the app's `EncryptedSharedPreferences` /\n  Keychain entries with the debugger. Confirm\n  `refresh_token` is unreadable from a non-app process.\n- Inspect Crashlytics / Sentry payloads on a forced\n  crash. Confirm no auth field is present.\n- Run `frida` or equivalent against a release build with\n  ProGuard / R8 on. Confirm Client IDs are not the only\n  secret protecting the flow (they aren't — Google's\n  signature is).\n"},{"step":13,"title":"Operational notes","description":"A small grab-bag of facts that catch teams off-guard\nonce they've shipped:\n\n- **Same-project requirement.** Android / iOS / Web\n  Client IDs must live in the *same* Google Cloud\n  project. A token minted in project A cannot be\n  verified by a key from project B.\n- **`email_verified=false` is rejected.** If Google can't\n  confirm the user owns the email, the BE returns 401.\n  This happens for federated Workspace accounts that\n  haven't completed verification on Google's side; the\n  user should resolve it with their admin, not retry.\n- **Account linking is automatic by email.** If the\n  user's Google email already has a password row in our\n  DB (created via `/auth/register`), the first Google\n  sign-in **auto-links** — sets `google_sub` on the\n  existing row. No conflict prompt; Google's email\n  verification is considered sufficient proof of\n  ownership.\n- **Account creation is automatic.** If the email is new,\n  account-service creates a row + personal organization\n  + five system roles in one transaction. The response's\n  `created_now` is `true` and\n  `requires_email_verification` is `false`.\n- **`sub` is the stable identifier.** Email can change\n  owner inside a Google Workspace; `sub` never does. The\n  BE persists `accounts.google_sub` on first link and\n  uses it as the fast path on subsequent logins, falling\n  back to email match only when `google_sub` is null.\n- **JWKS is cached.** The BE caches Google's signing\n  keys in Redis (TTL ≈ 6 hours by default, derived from\n  Google's `Cache-Control` header). A key rotation\n  forces a one-shot refresh on the next `kid` miss.\n  Operators normally don't need to do anything around\n  rotation.\n"},{"step":14,"title":"Alternative endpoint: POST a Firebase token to /auth/firebase","description":"When the app uses Firebase Auth as its primary client-side\nauth layer — typically to unlock multi-provider sign-in\n(Apple, Facebook, Microsoft, …) without writing a verifier\nper provider — send the Firebase-issued ID token to the\ndedicated `/auth/firebase` endpoint instead of unwrapping\nto a raw Google token.\n\n**Acquiring the Firebase ID token (Android):**\n\n```kotlin\n// After FirebaseAuth.getInstance().signInWithCredential(...)\n// completes successfully:\nval firebaseUser = FirebaseAuth.getInstance().currentUser\n    ?: return  // shouldn't happen if signIn succeeded\nfirebaseUser.getIdToken(/* forceRefresh = */ false)\n    .addOnSuccessListener { result -\u003e\n        val firebaseIdToken = result.token  // \u003c-- send THIS\n        // POST to /api/v1/auth/firebase\n    }\n```\n\n**Acquiring the Firebase ID token (iOS):**\n\n```swift\n// After Auth.auth().signIn(with: credential) completes:\nAuth.auth().currentUser?.getIDToken { idToken, error in\n    guard let firebaseIdToken = idToken else { return }\n    // POST firebaseIdToken to /api/v1/auth/firebase\n}\n```\n\n**Account resolution differs from `/auth/google`** in\none key way: the link column on `accounts` is\n`firebase_uid` (Firebase's UID), NOT `google_sub`. A user\nwho signs in via BOTH endpoints ends up with both columns\npopulated on the same row, linked by their verified\nemail — Step 13 (Operational notes) covers the linking\nsemantics in detail.\n\nThe wire format is identical to `/auth/google`:\n","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/\u003cproject_id\u003e`, `aud=\u003cproject_id\u003e`. 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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"credential\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...\",\n    \"device_timezone\": \"Asia/Jakarta\",\n    \"device_language\": \"id\"\n  }'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"550e8400-...\",\n    \"email\": \"user@gmail.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"eyJ...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 7200,\n    \"organizations\": [ { \"...\": \"...\" } ],\n    \"current_org_id\": \"550e8400-...\",\n    \"requires_email_verification\": false,\n    \"created_now\": false,\n    \"csrf_token\": \"f3a1c0...\"\n  }\n}\n"}]},{"id":"onboarding","icon":"🚀","title":"Onboard a new tenant","description":"End-to-end flow from a brand-new email address to a working\nmulti-member organization. Covers registration, email verification,\ntenant org creation, and inviting the first teammate.\n","flow":[{"step":1,"title":"Register","description":"Creates a human account + a personal organization (the user is\nthe owner) in a single transaction. Returns access + refresh\ntokens immediately — the user is auto-logged-in. A verification\nemail is queued via the `email.requested` event.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"email\": \"user@example.com\",\n    \"password\": \"SecurePass123!\",\n    \"display_name\": \"Jane Doe\",\n    \"timezone\": \"Asia/Jakarta\",\n    \"language\": \"id\"\n  }'\n","response_example":"{\n  \"success\": true, \"code\": 2010,\n  \"data\": {\n    \"account_id\": \"550e8400-...\",\n    \"email\": \"user@example.com\",\n    \"access_token\": \"eyJ...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 900,\n    \"personal_org\": { \"org_id\": \"550e8400-...\", \"name\": \"Jane Doe's Personal\", \"slug\": \"jane-doe-personal-550e8400\", \"my_role\": \"owner\", \"my_permissions\": [\"*\"], \"...\": \"...\" }\n  }\n}\n"},{"step":2,"title":"Verify email (out-of-band)","description":"The user clicks the link in the verification email. Account\nservice hosts the verification page itself at `GET /verify-email`\n(HTML, outside `/api/v1`). Once verified, the user's NEXT issued\ntoken (login, register, or refresh) carries `permissions: [\"*\"]`\ninstead of the read-only fallback. **Existing tokens issued before\nverification keep their reduced perms** until refreshed.\n\nIf the email never arrived:\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"email\": \"user@example.com\" }'\n","response_example":"{ \"success\": true, \"data\": { \"message\": \"If the account exists and is unverified, a new verification email has been sent.\" } }\n"},{"step":3,"title":"Refresh after verification","description":"To upgrade an in-flight session from read-only to full perms\nwithout forcing the user to re-login, hit `/auth/refresh`. The\nnew access token will reflect the now-verified status.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"refresh_token\": \"eyJ...\" }'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJ...new_with_full_perms...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 900,\n    \"...\": \"...\"\n  }\n}\n"},{"step":4,"title":"Create a team org (optional)","description":"The personal org is private to the user. To collaborate, create a\nshared org. The caller becomes its owner.\n","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 \\\n  -H 'Authorization: Bearer eyJ...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"name\": \"Acme Corp\", \"slug\": \"acme-corp\" }'\n","response_example":"{\n  \"success\": true,\n  \"data\": { \"org_id\": \"550e8400-...\", \"name\": \"Acme Corp\", \"slug\": \"acme-corp\", \"owner_id\": \"550e8400-...\", \"status\": \"active\", \"...\": \"...\" }\n}\n"},{"step":5,"title":"Invite first teammate","description":"Sends an invitation email. Recipient accepts via the email link\n(which lives on the frontend; account-service emits the\n`email.requested` event with the token). Requires\n`members:invite` permission in the org.\n","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 \\\n  -H 'Authorization: Bearer eyJ...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"email\": \"teammate@example.com\", \"role_id\": \"550e8400-...\" }'\n","response_example":"{ \"success\": true, \"data\": { \"invitation_id\": \"550e8400-...\", \"message\": \"Invitation sent to teammate@example.com\" } }\n"},{"step":6,"title":"Switch to the new org (when ready)","description":"The invitation creator's tokens still point at their personal\norg. Switching reissues the token bound to the team org without\nre-login. Session id is preserved across the switch.\n","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 \\\n  -H 'Authorization: Bearer eyJ...'\n","response_example":"{\n  \"success\": true,\n  \"data\": { \"access_token\": \"eyJ...new...\", \"refresh_token\": \"eyJ...new...\", \"current_org_id\": \"550e8400-...\", \"...\": \"...\" }\n}\n"}]},{"id":"password-reset","icon":"🔑","title":"Password reset (forgot password)","description":"End-to-end password reset for users who can't log in. Two endpoints\nand one out-of-band email step. Both endpoints always return success\nto prevent account enumeration — meaningful failures (invalid token,\nweak password) are surfaced only on the confirm call when the user\nactually has a valid token in hand.\n","flow":[{"step":1,"title":"Request the reset","description":"Generates a 1-hour JWT carrying `permissions: [\"password:reset\"]`\nand emits the `email.requested` event. The token's hash is stored\nin Redis so it can only be used once.\n\n**Always** returns success regardless of whether the email\nexists — checking the existence here would leak account\nmembership to anyone who can reach the endpoint.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"email\": \"user@example.com\" }'\n","response_example":"{ \"success\": true, \"data\": null }\n"},{"step":2,"title":"User clicks link in email (out-of-band)","description":"The email contains a frontend URL with the token as a query\nparameter (or fragment, depending on frontend convention). The\nfrontend renders a \"set new password\" form. Account-service\nitself also hosts a fallback HTML form at `GET /reset-password`\n(outside `/api/v1`) for out-of-the-box use.\n\nToken TTL is 1 hour. After expiry the user must restart from\nstep 1.\n"},{"step":3,"title":"Confirm with new password","description":"Validates the token (signature + jti not previously used + not\nexpired), updates the password hash, and **revokes every active\nsession for the account** so any in-flight access tokens stop\nvalidating immediately. The user must log in again with the new\npassword.\n","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 \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"token\": \"eyJ...reset_token...\",\n    \"new_password\": \"NewSecurePass456!\"\n  }'\n","response_example":"{ \"success\": true, \"data\": { \"success\": true } }\n"}]},{"id":"service-to-service","icon":"🤖","title":"Service-to-service authentication","description":"How a non-human consumer (job runner, ML pipeline, internal\nautomation) authenticates against your APIs. Covers creating a\nservice account, minting an API key, exchanging it for a JWT, and\nrevoking on leak.\n","flow":[{"step":1,"title":"Create the service account","description":"A service account lives inside one organization and has\ncapabilities (e.g. `museum:read`, `artifact:write`) that bound\nwhat its keys may grant. Requires `service_accounts:create`\nin the target org.\n","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 \\\n  -H 'Authorization: Bearer eyJ...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"organization_id\": \"550e8400-...\",\n    \"display_name\": \"Render bot\",\n    \"capabilities\": [\"museum:read\", \"artifact:write\"],\n    \"allowed_tools\": [\"thumbnailer\"],\n    \"rate_limit_per_minute\": 120\n  }'\n","response_example":"{ \"success\": true, \"data\": { \"account_id\": \"550e8400-...\", \"status\": \"active\", \"capabilities\": [\"museum:read\",\"artifact:write\"], \"...\": \"...\" } }\n"},{"step":2,"title":"Mint an API key","description":"The plaintext `key` is returned **only here, only once** — store\nit client-side immediately (e.g. in a secret manager). Subsequent\nlist/get calls expose only `key_prefix` for identification.\nRequires `api_keys:create`.\n","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 \\\n  -H 'Authorization: Bearer eyJ...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"name\": \"prod render bot\",\n    \"permissions\": [\"museum:read\"],\n    \"expires_in_days\": 180\n  }'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"api_key_id\": \"550e8400-...\",\n    \"key\": \"ak_live_a1b2c3d4...REDACTED...\",\n    \"key_prefix\": \"ak_live_a1b2\",\n    \"name\": \"prod render bot\",\n    \"permissions\": [\"museum:read\"],\n    \"expires_at\": 1751155200,\n    \"created_at\": 1735689600\n  }\n}\n"},{"step":3,"title":"Use the key directly (option A)","description":"Cheapest integration: send `X-API-Key` on every protected request.\nThe middleware accepts it identically to a Bearer JWT. Suitable\nfor low-throughput automations.\n","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 \\\n  -H 'X-API-Key: ak_live_a1b2c3d4...'\n","response_example":"{ \"success\": true, \"data\": { \"account_id\": \"550e8400-...\", \"account_type\": \"service\", \"display_name\": \"Render bot\", \"...\": \"...\" } }\n"},{"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\nand validate locally with `/auth/public-key`. Reduces account-service\nload from per-request to ~once an hour per pod. The JWT carries\n`principal_type: service` and `sid: \u003cnil-uuid\u003e`.\n","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 \\\n  -H 'X-API-Key: ak_live_a1b2c3d4...'\n","response_example":"{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJ...service_jwt...\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 3600,\n    \"account_id\": \"550e8400-...\",\n    \"organization_id\": \"550e8400-...\",\n    \"permissions\": [\"museum:read\"],\n    \"principal_type\": \"service\"\n  }\n}\n"},{"step":5,"title":"Call downstream services with the JWT","description":"Downstream services fetch `/auth/public-key` once, cache by `kid`,\nthen verify the JWT locally on every request — no roundtrip to\naccount-service per call. The `permissions` claim is derived from\nthe API key's permissions at exchange time.\n","endpoint":{"method":"GET","path":"/v1/\u003cresource\u003e","service":"downstream-service","auth":"JWT Bearer"},"curl_example_jwt":"curl https://media.example.com/v1/artifacts/123 \\\n  -H 'Authorization: Bearer eyJ...service_jwt...'\n"},{"step":6,"title":"Revoke on leak","description":"When a key leaks (committed to a public repo, posted in a Slack\nchannel, etc.), revoke immediately. The key continues to exist\nfor audit/forensics but stops authenticating. Use DELETE to hard-\nremove (loses audit trail).\n","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 \\\n  -H 'Authorization: Bearer eyJ...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{ \"reason\": \"leaked in public repo\" }'\n","response_example":"{ \"success\": true, \"data\": null }\n"}]}],"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":"💻 Client (web/mobile)","type":"client","color":"#0ea5e9","position":{}},{"id":"account","label":"👤 Account Service","type":"service","color":"#4f46e5","position":{}},{"id":"postgres","label":"🐘 PostgreSQL","type":"external","color":"#64748b","position":{}},{"id":"redis","label":"🟥 Redis","type":"external","color":"#64748b","position":{}},{"id":"rabbitmq","label":"🐇 RabbitMQ","type":"queue","color":"#8b5cf6","position":{}},{"id":"email_service","label":"✉️ Email Service","type":"service","color":"#10b981","position":{}},{"id":"jwt","label":"🔑 RS256 JWT","type":"data","color":"#f59e0b","position":{}},{"id":"downstream","label":"🧩 Downstream services","type":"service","color":"#10b981","position":{}}],"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\nread-only projection of account state. Each row maps to the\ndetail section below. If you skim only one block in this page,\nread this one.\n\n**Got a specific \"is this delta or snapshot / does X event\nfire / how do I order / how do I dedup\" question?** Jump to\n[`replica-consumer-faq`](#replica-consumer-faq) — that section\nanswers the exact questions we receive from consumer teams,\nin their phrasing, with the authoritative answer inline.\n\n## Status against the standard replica checklist\n\n| Need                                                     | Status | Where it lives                                                                  |\n| -------------------------------------------------------- | ------ | ------------------------------------------------------------------------------- |\n| Payload schema for every event touching account data     | ✅     | Per-event sections below. Field list with type + required-ness for all 25 events. |\n| `account.account.created`                                | ✅     | Section [`Account Created (outbound)`](#account-created). Body has `account_id`, `account_type`, `email`, `organization_id`, `timestamp`. |\n| `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. |\n| `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. |\n| `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. |\n| `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 \u003c replica.last_applied_at`. |\n| `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. |\n| 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. |\n| Final exchange + routing keys                            | ✅     | Exchange `account.events` (topic, durable). All routing keys in section [`Events catalogue`](#events-catalogue). |\n\n## Minimum-viable consumer (pseudocode)\n\n```\non_message(headers, body):\n    eid = headers[\"x-event-id\"]                          # dedup key\n    if already_applied(eid): return ack()\n    occurred_at = parse_rfc3339(headers[\"x-occurred-at\"])\n    rk = headers[\"x-event-type\"]                         # e.g. \"account.updated\"\n    if rk == \"account.deleted\":\n        tombstone(body[\"AccountDeleted\"][\"account_id\"])\n        mark_applied(eid); return ack()\n    if rk == \"account.deactivated\":\n        mark_inactive(body[\"AccountDeactivated\"][\"account_id\"])\n        mark_applied(eid); return ack()\n    if rk == \"account.updated\" or rk == \"account.created\":\n        account_id = first_value_of(body)[\"account_id\"]\n        if occurred_at \u003c replica.last_applied_at(account_id):\n            return ack()                                 # stale, skip\n        refetch_via_rest_and_upsert(account_id, occurred_at)\n        mark_applied(eid); return ack()\n    # ...other events handled per business need\n```\n\n## What replicas should NOT depend on\n\n- Per-routing-key ordering — multiple publisher workers can\n  reorder; always trust `x-occurred-at`.\n- Field values for fields NOT in this contract. Existing field\n  types/names are stable; net-new optional fields may be added\n  without a version bump.\n- The wire format of these variants — they are declared in the\n  `DomainEvent` enum but never emitted: `auth.login_failed`,\n  `auth.logout`, `auth.session_revoked`. Don't build handlers\n  for them; we'll announce + version-bump if that changes.\n\n## REST companion for backfill / reconciliation\n\nPair this with the S2S lookup endpoints when bootstrapping a\nreplica or running periodic reconciliation:\n\n- `GET  /api/v1/accounts/{id}`     → single fetch.\n- `POST /api/v1/accounts/bulk-lookup` (≤ 100 ids/request) → batch refresh.\n\nDaily reconciliation: enumerate every `account_id` your replica\nholds → bulk-lookup → drop entries that 404 → upsert the rest.\n"},{"id":"replica-consumer-faq","title":"Event contract — frequently-asked semantics","description":"The exact questions consumer teams send us when wiring up an\naccount-events replica. Each answer is authoritative — bookmark\nthis section if your team is at the \"do I trust this assumption?\"\nstage of integration. Cross-references jump to the detail\nsection that backs the answer.\n\n## Q: Is `account.account.updated` delta or snapshot?\n\n**A: Delta-by-default with explicit clear-to-null signal.** The\npayload is not a snapshot of the account row; it carries the\nchanged fields, no more. Read `updated_fields[]` FIRST — that\narray is authoritative on what changed in this commit.\n\nThree-state decision per mirror field (`display_name`, `bio`,\n`phone_number`, `timezone`, `language`, `avatar_media_id`,\n`username`):\n\n| Field name in `updated_fields[]`? | Field key present in body? | Meaning                        |\n| --------------------------------- | -------------------------- | ------------------------------ |\n| No                                | (irrelevant)               | **Unchanged** — don't touch    |\n| Yes                               | Yes                        | **New value** — overwrite      |\n| Yes                               | No                         | **Cleared to null** — null out |\n\nThe third row is the one most consumers miss. Absent fields are\nomitted by the wire-format and that absence is meaningful when\nthe name appears in `updated_fields[]`. Treating \"absent =\nunchanged\" without the array check will silently keep stale\nvalues when a user clears their avatar / bio / phone.\n\n`email` never appears here — email changes ride a dedicated\n[`account.account.email_changed`](#account-email-changed)\nevent with both `old_email` and `new_email`. Detail:\n[`account-updated`](#account-updated).\n\n## Q: Does a deactivated account ever come back to Active?\n\n**A: No. Deactivated is terminal for the `account_id`.** There\nis no reactivation transition, and no event is published for\none. A replica that tombstones the `account_id` on\n`account.account.deactivated` is correct and complete for that\nidentity.\n\nIf the same human comes back, they re-register with the same\nemail. That produces a fresh row with a **new `account_id`**\n(different UUID) and a fresh `account.account.created` event.\nEmail matches the old account; `account_id` does not.\n\nPattern for replicas that want to detect re-registration:\n\n1. On `account.account.deactivated` → tombstone the old\n   `account_id` permanently.\n2. Subscribe `account.account.created` and key the handler\n   on the body's `email` in addition to `account_id`.\n3. If the email matches a tombstoned row, record a bridge\n   (`old_account_id → new_account_id`) in your own table.\n   Do not reuse the old `account_id`.\n\nDetail: [`account-deactivated`](#account-deactivated).\n\n## Q: My replica missed events during a deploy / outage. How do I catch up?\n\n**A: Pair the event stream with the S2S REST resolvers.**\n\nEvents are best-effort publish, at-least-once consume — a\nbroker outage on our side or a deploy on yours can drop or\ndelay messages. The DB is the source of truth; the events are\nthe fast path. Reconcile periodically:\n\n- `GET /api/v1/accounts/{id}` — single fetch when a specific\n  replica entry looks stale.\n- `POST /api/v1/accounts/bulk-lookup` (≤ 100 ids/request) —\n  batch refresh.\n- For replicas with \u003e 100 accounts: chunk the bulk-lookup at\n  100 per request.\n\nDaily reconciliation pattern: enumerate every `account_id` your\nreplica holds → bulk-lookup → drop entries that 404 → upsert\nthe rest. Catches anything the publisher dropped during a\nbroker outage. Detail: [`delivery-semantics`](#delivery-semantics).\n\n## Q: Can I bind to wildcards (e.g., `account.account.*`)?\n\n**A: Yes.** The exchange `account.events` is a topic exchange.\nBind your queue with any RabbitMQ topic wildcard:\n\n- `account.account.*` — every account-lifecycle event.\n- `account.org.*` — every org-lifecycle event.\n- `account.#` — every event we publish (broad, mostly for\n  debugging — production consumers should bind narrowly).\n\nWildcard binding doesn't change delivery semantics; you still\nget at-least-once with the same headers per event.\n\n## Q: How do I order events when two arrive out of sequence?\n\n**A: Use the `x-occurred-at` AMQP header.** Per-routing-key\nordering is NOT guaranteed (multiple publisher workers can\nreorder). Keep a `last_applied_occurred_at` per record in your\nreplica; discard inbound events whose `x-occurred-at` is older.\n\nDon't trust delivery order, even within the same `account_id`.\n\n## Q: How do I dedup at-least-once redeliveries?\n\n**A: Use the `x-event-id` AMQP header.** Every event carries a\nunique UUID stamped both in the body (`event_id`) and at the\nAMQP envelope level (`x-event-id`). Persist `(routing_key,\nevent_id)` per applied event and skip duplicates.\n\nHeader form is the easier integration point — middleware can\ndedup without parsing the JSON body. Detail:\n[`delivery-semantics`](#delivery-semantics).\n\n## Q: How will I know when the event contract changes?\n\n**A: The AMQP header `x-schema-version` is the version\nmarker.** Today every event ships `x-schema-version: \"1\"`.\nAdditive changes (new optional fields) do NOT bump the\nversion — consumers tolerating unknown fields keep working.\nBreaking changes (field renamed, type changed, semantic\nchanged) DO bump it. Compare on string equality and fail-\nclosed on an unrecognised value.\n\n## Q: Which variants in the `DomainEvent` enum are NOT emitted today?\n\n**A: Three variants are reserved but never published —\n`auth.login_failed`, `auth.logout`, `auth.session_revoked`.**\nDon't build handlers for them; we'll announce + version-bump\nif any of those start firing. `auth.login_success` IS emitted\nbut only on the SSO login paths (Google / Firebase) — password\nlogin writes only to `audit_logs`.\n"},{"id":"events-catalogue","title":"Events catalogue","description":"Every domain event the service publishes (and the one it\nconsumes) at a glance. **Exchange:** `account.events` (topic).\nEvery emitted event's routing key is prefixed with `account.`\nby the publisher, so the table below shows the **wire** key\n(what consumers bind to), not the raw `event_type` string in\nthe envelope.\n\nThe envelope is always `{\"\u003cVariantName\u003e\": { ... }}` — that's\nthe `DomainEvent` enum tag plus the per-event payload. Bind\nto `account.account.*` for every account-lifecycle event,\n`account.org.*` for every org-lifecycle event, etc., or to a\nspecific key like `account.account.updated` for one event.\n\nDetailed payload schemas are in the sections below the catalogue.\n\n## Account lifecycle (outbound)\n\n| Routing key                       | Emitted when                                                                 |\n| --------------------------------- | ---------------------------------------------------------------------------- |\n| `account.account.created`         | A new account is registered (human via `/register` or service via admin UI). |\n| `account.account.activated`       | Account transitions Pending → Active (verify-email click, SSO activation).   |\n| `account.account.deactivated`     | User calls `POST /me/deactivate`. Soft close (row still queryable).          |\n| `account.account.deleted`         | Account row is **physically** removed (admin two-step hard-delete or service-initiated transactional rollback). Replicas should tombstone immediately. |\n| `account.account.email_changed`   | Email change confirm flow completes. Old + new email in payload.             |\n| `account.account.password_changed`| Password is changed (self via `/me/password`, or admin reset).               |\n| `account.account.updated`         | Mirror-field change (display_name / bio / phone / timezone / language / avatar). |\n\n## Auth (outbound)\n\n| Routing key                  | Emitted when                                                |\n| ---------------------------- | ----------------------------------------------------------- |\n| `account.auth.login_success` | SSO login completes (Google / Firebase). NOT emitted by the password login path today — that path writes only to `audit_logs`. |\n\nThe following auth variants exist in the `DomainEvent` enum\nbut are **not currently emitted by any code path**:\n`auth.login_failed`, `auth.logout`, `auth.session_revoked`.\nReserved for future use — don't build consumers expecting them.\n\n## Organization lifecycle (outbound)\n\n| Routing key                        | Emitted when                                                |\n| ---------------------------------- | ----------------------------------------------------------- |\n| `account.org.created`              | New organization is created.                                |\n| `account.org.updated`              | Organization profile fields change.                         |\n| `account.org.deleted`              | Organization is deleted (admin hard-delete cascade).        |\n| `account.org.member_joined`        | Account joins an org (via invitation accept or signup).     |\n| `account.org.member_left`          | Account leaves an org (self or admin removal).              |\n| `account.org.role_changed`         | A member's role changes inside an org.                      |\n| `account.org.member_invited`       | An invitation is issued.                                    |\n| `account.org.invitation_accepted`  | Invitation is accepted (companion to `org.member_joined`).  |\n| `account.org.invitation_cancelled` | Invitation is cancelled by the inviter / admin.             |\n\n## Service-account + API-key lifecycle (outbound)\n\n| Routing key                            | Emitted when                                       |\n| -------------------------------------- | -------------------------------------------------- |\n| `account.service_account.created`      | An org creates a service account.                  |\n| `account.service_account.updated`      | Service-account profile/config changes.            |\n| `account.service_account.paused`       | Service account is paused.                         |\n| `account.service_account.resumed`      | Service account is resumed.                        |\n| `account.service_account.archived`     | Service account is archived (irreversible).        |\n| `account.api_key.created`              | API key is issued for a service account.           |\n| `account.api_key.revoked`              | API key is revoked.                                |\n\n## Email delivery (outbound)\n\n| Routing key             | Emitted when                                                              |\n| ----------------------- | ------------------------------------------------------------------------- |\n| `account.email.requested` | Service needs an outbound email sent (verification, reset, invitation, etc.). Consumed by `email-service`. See the detailed section below. |\n\n## Inbound\n\n| Routing key                              | Consumed when                                                          |\n| ---------------------------------------- | ---------------------------------------------------------------------- |\n| `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. |\n"},{"id":"delivery-semantics","title":"Delivery semantics + AMQP headers","description":"The contract sibling services should code against. Stable;\nchanges to this block constitute a wire-level event.\n\n## Exchange + persistence\n\n- **Exchange:** `account.events` — topic, durable. Bind your\n  queue with the routing keys from the catalogue above (or\n  wildcards like `account.account.*`).\n- **Persistence:** every message is published with\n  `delivery_mode=2` (persistent). Events survive a broker\n  restart in the queue they're delivered to.\n- **Best-effort publish:** the publisher is in-process and\n  bounded. If the broker is down, the user-facing request that\n  triggered the event still succeeds — the publish is queued\n  for retry (default 3 attempts with backoff) and dropped if\n  retries are exhausted. **The DB row is the source of truth.**\n  Consumer reconciliation jobs should periodically diff\n  replicas against the REST S2S lookup for state that matters.\n- **At-least-once delivery:** the broker stores until the\n  consumer acks. A consumer crash or `nack` triggers\n  redelivery, so handlers MUST be idempotent (the `x-event-id`\n  / `event_id` makes this easy — see \"Headers\" below).\n- **Ordering** is **NOT** globally guaranteed. Per-routing-key\n  ordering is also not guaranteed: events that fan-out across\n  publisher worker threads can arrive out-of-order. Use\n  `x-occurred-at` (or `body.timestamp`) at the consumer to\n  decide whether to apply an event — discard an event whose\n  `occurred_at` is older than the replica's last-applied\n  timestamp for that record.\n\n## Envelope shape\n\nThe JSON body is the externally-tagged `DomainEvent` enum:\n\n```json\n{ \"AccountUpdated\": { \"event_id\": \"...\", \"account_id\": \"...\", ... } }\n```\n\nThe only exception is `email.requested` — it serializes the\ninner `EmailRequestedEvent` flat (no enum wrapper) because\n`email-service` was built against that shape long before this\ncatalogue existed. Don't depend on flat-vs-wrapped consistency\nacross all events.\n\n## AMQP message headers\n\nEvery published message carries the following AMQP headers\n(in `BasicProperties.headers`). Consumers can read these\nwithout parsing the JSON body — useful for routing, dedup, and\nordering decisions in middleware.\n\n| Header             | Type                  | Purpose                                                                                          |\n| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------ |\n| `x-event-id`       | string (UUID)         | Unique per event. Mirror of `body.event_id`. Use as your dedup key for at-least-once redelivery. |\n| `x-event-type`     | string                | Routing key without the `account.` prefix (e.g. `account.updated`, `org.member_joined`).         |\n| `x-occurred-at`    | string (RFC3339)      | Wall-clock time the event happened. Use for out-of-order discard.                                |\n| `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. |\n| `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. |\n\nHeaders are stringly typed across the board so cross-language\nAMQP clients (Go / Python / Node) deserialize them uniformly\nwithout integer-type juggling.\n\n## Bumping `x-schema-version`\n\n`\"1\"` is the current value for every event in this catalogue.\nAdditive payload changes (new optional fields) do NOT bump the\nversion — consumers tolerate unknown fields. Removing or\nrenaming a field, changing a field type, or changing the\nenvelope shape DOES bump it. Consumers should compare on\nstring equality and fail-closed on an unrecognised version.\n\n## Reconciliation guidance for replicas\n\nA consumer that maintains a local read-only projection of\naccount state should:\n\n1. **Idempotency**: persist `(routing_key, x-event-id)` per\n   applied event. Skip duplicates.\n2. **Ordering**: store `last_applied_occurred_at` per record;\n   discard inbound events older than that.\n3. **Tombstone fast**: on `account.account.deleted` mark the\n   local row tombstoned in the same transaction that records\n   the event id. On `account.account.deactivated`, tombstone\n   (or mark inactive) — the row is **terminal**: the domain\n   rejects any `Deactivated → Active` transition with\n   `AlreadyDeactivated`, so the same `account_id` will not\n   come back. If the same user later re-registers with the\n   same email, that's a brand-new row with a brand-new\n   `account_id` (`account.account.created` fires) — see\n   \"Reactivation\" in [`account-deactivated`](#account-deactivated).\n4. **Reconcile periodically**: a daily job that fetches every\n   live `account_id` via the S2S lookup catches anything the\n   publisher dropped on a broker outage. Without this, gaps\n   can persist indefinitely.\n"},{"id":"email-requested","title":"Email Requested","description":"Emitted when the service needs an outbound email — verification link on\nregistration, resent verification, or password reset. The email service\nconsumes this event and renders/sends the actual mail. Account service\nnever talks to SMTP directly.\n","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":"{\n  \"event_type\": \"email.requested\",\n  \"to\": \"user@example.com\",\n  \"template\": \"email_verification\",\n  \"display_name\": \"Jane Doe\",\n  \"token\": \"8x...redacted...\",\n  \"base_url\": \"https://app.example.com\",\n  \"is_resend\": false\n}\n"}]},{"id":"profile-update-requested","title":"Profile Update Requested (inbound)","description":"Inbound channel: other services can ask account-service to edit a\nuser's `username` and / or `display_name` on their behalf. The\naccount service owns the canonical row; this event is how a\ncaller (planner, email, …) proposes a change without holding the\nuser's JWT.\n\nTrust model is **intra-cluster** — the broker runs on the host\nnetwork and only local services hold credentials. Every published\nmessage is accepted as authoritative; include `actor` and\n`correlation_id` so audits and cross-system traces stay joinable.\n\nValidation happens at the consumer. Username rules: 3-64 chars,\ncharset `[A-Za-z0-9._@+\\-]`, case-insensitive uniqueness. Failures\nclassify as either **poison** (validation, not-found, conflict —\nmessage is dropped, not requeued) or **transient** (DB hiccup —\nrequeued with exponential backoff). A successful apply triggers\nan `account.account.updated` publish (see below) so subscribers\nlearn the new value without an HTTP round-trip.\n\nOmitted / explicit-null fields are no-ops — the consumer never\nwipes a column from a missing key. Sending the message with all\neditable fields null is silently ack'd (treated as a no-op).\n","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\n`account.profile.update_requested` queue (bound to the\n`account.events` topic exchange with the matching routing\nkey). Prefetch = 8.\n","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":"{\n  \"account_id\": \"1c37ff2c-2f42-4d86-94d3-a20163909ccf\",\n  \"username\": \"jane.d\",\n  \"display_name\": \"Jane D.\",\n  \"actor\": \"planner.assign-task\",\n  \"correlation_id\": \"3f8a-1c2b-9912\"\n}\n"}]},{"id":"account-updated","title":"Account Updated (outbound)","description":"Emitted after any successful change to a profile row — whether\nthe trigger was the HTTP `PUT /api/v1/me` endpoint, the avatar\nchange, or the inbound `account.profile.update_requested` event.\nDownstream services use this to refresh their cached mirror of\nthe user's profile without an HTTP round-trip.\n\nThe payload carries the **new value of every changed mirror\nfield**: `username`, `display_name`, `bio`, `phone_number`,\n`timezone`, `language`, `avatar_media_id`.\n\n**Consumer contract.** `updated_fields` is authoritative — it\nlists exactly what changed. For each field named there, read the\nsame-named key for the new value. A field that IS in\n`updated_fields` but whose key is **absent** was *cleared to null*\n(e.g. avatar removed, phone cleared) — null values are omitted via\n`skip_serializing_if` to keep the payload small. A field NOT in\n`updated_fields` did not change: ignore it, do not wipe your\ncached value.\n\n`email` is **not** carried here — email changes ship via the\ndedicated `account.account.email_changed` event. `metadata` is\nalso omitted (can be large / service-account-only); fetch via\nHTTP if you mirror it.\n\nRouting: published with key `account.account.updated` (the\npublisher prefixes every event_type with `account.`). Bind your\nqueue to `account.account.updated` for this single event or\n`account.account.*` to receive every account-lifecycle change.\n","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\nis the source of truth. Consumers should be idempotent\n(events may be redelivered after a retry).\n\nThe outer JSON envelope is the `DomainEvent` enum tag\n(`{\"AccountUpdated\": { ... }}`), matching every other\nnon-email account event published by this service.\n","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":"{\n  \"AccountUpdated\": {\n    \"event_id\": \"c96d13d6-6e31-4eea-9038-881899623f74\",\n    \"account_id\": \"1c37ff2c-2f42-4d86-94d3-a20163909ccf\",\n    \"updated_fields\": [\"display_name\", \"avatar_media_id\", \"phone_number\"],\n    \"display_name\": \"Muhamiyan\",\n    \"avatar_media_id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n    \"timestamp\": \"2026-05-19T16:46:32.001Z\"\n  }\n}\n\n# Note: phone_number is in updated_fields but absent above —\n# that means it was cleared to null in this commit.\n"}]},{"id":"account-email-changed","title":"Account Email Changed (outbound)","description":"Emitted after a user confirms an email change (the\nverify-new-email flow: `POST /api/v1/me/email`, then the\n`/confirm-email-change` link). Carries **both** the old and new\naddress so downstream services can update their mirror and\nre-key any email-indexed records.\n\nEmail is deliberately **not** part of `account.account.updated` —\nit gets this dedicated event because the change is a verified\ntwo-step flow and the *old* value matters to consumers. The\n`new_email`\nis already verified at emission time (clicking the confirm link\nproved ownership).\n\nRouting: published with key `account.account.email_changed`.\nBind to it directly, or to `account.account.*` for every\naccount-lifecycle change.\n","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;\nconsumers must be idempotent — redelivery is possible). The\nouter JSON envelope is the `DomainEvent` enum tag\n(`{\"AccountEmailChanged\": { ... }}`), like every other\nnon-email account event.\n","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":"{\n  \"AccountEmailChanged\": {\n    \"event_id\": \"a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d\",\n    \"account_id\": \"1c37ff2c-2f42-4d86-94d3-a20163909ccf\",\n    \"old_email\": \"old@example.com\",\n    \"new_email\": \"new@example.com\",\n    \"timestamp\": \"2026-05-29T03:50:00Z\"\n  }\n}\n"}]},{"id":"account-created","title":"Account Created (outbound)","description":"Emitted right after a new account row is persisted — via human\nregistration (`POST /auth/register`), SSO bootstrap (Google /\nFirebase first-login), or admin-driven creation. Carries the\npersonal org id so a consumer can stand up tenant-side state\nin the same hop.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.account.created","operations":[{"type":"publish","summary":"New account provisioned","description":"Envelope is `{\"AccountCreated\": { ... }}`. Best-effort\npublish — consumers should be idempotent on `event_id`.\n","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":"{\n  \"AccountCreated\": {\n    \"event_id\": \"c96d13d6-6e31-4eea-9038-881899623f74\",\n    \"account_id\": \"1c37ff2c-2f42-4d86-94d3-a20163909ccf\",\n    \"account_type\": \"human\",\n    \"email\": \"jane@example.com\",\n    \"organization_id\": \"f2c355f1-82aa-4d72-b729-1a17be6216ef\",\n    \"timestamp\": \"2026-06-01T08:14:22Z\"\n  }\n}\n"}]},{"id":"account-activated","title":"Account Activated (outbound)","description":"Status transitions `Pending` → `Active`. Triggered by the\nverify-email link click, by an SSO login that implicitly verifies\nthe account, or by the platform-admin force-verify endpoint.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.account.activated","operations":[{"type":"publish","summary":"Account is now usable","description":"Envelope `{\"AccountActivated\": { ... }}`. Consumers gating\naccess on `status=active` should refresh their mirror.\n","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\": \"...\" } }\n"}]},{"id":"account-deactivated","title":"Account Deactivated (outbound)","description":"User self-deactivated via `POST /me/deactivate`. The row's\n`status` is set to `deactivated` AND `deleted_at` is stamped, so\nthe email frees up for re-registration and every active session\nis revoked. Consumers that mirror user state should mark the\naccount closed.\n\n## Reactivation — important for replica consumers\n\n`Deactivated` is a **terminal state for this `account_id`**.\nThe domain `entity.activate()` method rejects any\n`Deactivated → Active` transition with `AlreadyDeactivated`,\nso:\n\n- There is **no** `account.account.activated` event for a\n  deactivated row. Don't wait for one.\n- There is **no** \"undeactivate\" RPC. The same `account_id`\n  will never come back to `Active`.\n- If the same human user comes back, they re-register with\n  the same email. Because the uniqueness check filters\n  `deleted_at IS NULL`, the soft-deleted row does not block\n  the new registration. The new registration produces:\n  - A **new `account_id`** (different UUID).\n  - A fresh `account.account.created` event whose\n    `email` matches the old account but whose `account_id`\n    does not.\n\nReplica strategy: on `account.account.deactivated` mark the\nold `account_id` tombstoned. To detect \"user came back\",\nsubscribe to `account.account.created` and key on `email` —\na fresh `AccountCreated` whose email matches a previously\ntombstoned row is a re-registration. Bridge identity in\nyour own table; do NOT try to revive the old `account_id`.\n","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\ntext from the user (where the UI captures it) or absent.\n","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\ncascade, or service-initiated transactional rollback within the\n`APP__SECURITY__RECENT_DELETE_WINDOW_MINS` window. Distinct from\n[Account Deactivated](#account-deactivated): deactivation soft-\ncloses (`status='deactivated'` AND `deleted_at` stamped — both\nset in the same transaction; the row is queryable by admins\nbut the domain treats deactivated as **terminal** and rejects\nany reactivation attempt). This event signals the row no\nlonger exists, period.\n\nReplicas should tombstone on receipt — there is no row to\nreconcile against; an HTTP `GET /api/v1/accounts/{id}` will\nanswer `404`.\n","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\n**after** the DB commit, so a broker outage doesn't leave\nan orphan row — but it can leave a stale replica until your\nreconciliation cron runs. Plan for that.\n\n`deleted_by` provenance: the admin's account id for the\ntwo-step hard-delete, or the calling service-principal's\nid for the transactional rollback. Cross-reference the\n`audit_logs.metadata.flow` field for the exact path\n(`admin_hard_delete` vs `transactional_rollback`).\n\n`was_email` carries the deleted row's email — handy for\nconsumers indexing on email, since the account is now\ngone and a follow-up HTTP fetch would 404. `null` for\nservice accounts that had no email.\n","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":"{\n  \"AccountDeleted\": {\n    \"event_id\": \"a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d\",\n    \"account_id\": \"1c37ff2c-2f42-4d86-94d3-a20163909ccf\",\n    \"deleted_by\": \"a2f32c84-639c-4778-8d30-2cc1ba3ca427\",\n    \"was_email\": \"deleted@example.com\",\n    \"timestamp\": \"2026-06-04T08:14:22Z\"\n  }\n}\n"}]},{"id":"account-password-changed","title":"Account Password Changed (outbound)","description":"Password rotated — whether the user changed it via\n`POST /me/password`, or an admin force-reset via either reset\nendpoint. Carries the org context so consumers that mirror\nper-org security posture can audit it.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.account.password_changed","operations":[{"type":"publish","summary":"Password rotation","description":"Envelope `{\"AccountPasswordChanged\": { ... }}`. The new\npassword hash is **not** in the payload — only the fact of\nrotation. The `LOGIN_FAIL` / `LOGIN` audit-log rows have\nper-attempt details if you need them.\n","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\n`POST /auth/firebase`) for now. The password-login path\n(`POST /auth/login`) writes a `LOGIN` row to `audit_logs` but\ndoes **not** publish to RabbitMQ — historical asymmetry, watch\nout if you're building a \"login activity\" mirror.\n","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\npeer IP as seen by the backend; for production traffic\nthrough nginx that's typically `127.0.0.1` (the nginx\nloopback proxy), not the public client IP — fix that at the\npublish site if you depend on it.\n","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\npersonal-org bootstrap created on every human registration, as\nwell as explicit org creations via the admin API.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.created","operations":[{"type":"publish","summary":"New organization provisioned","description":"Envelope `{\"OrganizationCreated\": { ... }}`.\n","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\nmoved (e.g. `name`, `slug`, `plan`). Field values aren't carried\nin the payload — fetch the org via HTTP if your mirror needs the\nnew values.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.updated","operations":[{"type":"publish","summary":"Org profile mutated","description":"Envelope `{\"OrganizationUpdated\": { ... }}`.\n","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\nthe admin-hard-delete cascade (a deleted account that owns a\npersonal org takes that org down with it). Consumers that mirror\norg-keyed state should drop the row.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.deleted","operations":[{"type":"publish","summary":"Org row gone","description":"Envelope `{\"OrganizationDeleted\": { ... }}`.\n","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\ninvitation acceptance, personal-org bootstrap at register time,\nor an admin add.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.member_joined","operations":[{"type":"publish","summary":"Membership created","description":"Envelope `{\"MemberJoined\": { ... }}`.\n","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\nby an admin.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.member_left","operations":[{"type":"publish","summary":"Membership ended","description":"Envelope `{\"MemberLeft\": { ... }}`.\n","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\nlateral move. Both old and new role ids are included so audit\nconsumers can capture the transition without a follow-up fetch.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.role_changed","operations":[{"type":"publish","summary":"Membership role changed","description":"Envelope `{\"RoleChanged\": { ... }}`.\n","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`\n/ `org.invitation_cancelled` (state transitions on the same\ninvitation row).\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.member_invited","operations":[{"type":"publish","summary":"Invitation issued","description":"Envelope `{\"MemberInvited\": { ... }}`. `email` is the\nrecipient address; `invitation_id` is the row that\nsubsequent accept/cancel events reference.\n","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\n`org.member_joined`; the two events carry overlapping but not\nidentical info — bind to whichever fits your projection.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.invitation_accepted","operations":[{"type":"publish","summary":"Invitation accepted","description":"Envelope `{\"InvitationAccepted\": { ... }}`.\n","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\nautomatic expiry cleanup.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.org.invitation_cancelled","operations":[{"type":"publish","summary":"Invitation cancelled","description":"Envelope `{\"InvitationCancelled\": { ... }}`.\n","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.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.service_account.created","operations":[{"type":"publish","summary":"Service account provisioned","description":"Envelope `{\"ServiceAccountCreated\": { ... }}`.\n","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,\nmetadata, capabilities).\n","protocol":"amqp","address":"exchange: account.events / routing key: account.service_account.updated","operations":[{"type":"publish","summary":"Service account updated","description":"Envelope `{\"ServiceAccountUpdated\": { ... }}`. Lean payload —\nthis event carries no per-field snapshot. Fetch via HTTP if\nyour mirror needs the new values.\n","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\nwithout revoking its API keys. Consumers that gate on\n`status=active` should stop honoring the principal.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.service_account.paused","operations":[{"type":"publish","summary":"Service account paused","description":"Envelope `{\"ServiceAccountPaused\": { ... }}`.\n","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`.\nCompanion to `service_account.paused`.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.service_account.resumed","operations":[{"type":"publish","summary":"Service account resumed","description":"Envelope `{\"ServiceAccountResumed\": { ... }}`.\n","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\nAPI keys are implicitly revoked. Consumers should drop the\nprincipal from any access path.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.service_account.archived","operations":[{"type":"publish","summary":"Service account archived","description":"Envelope `{\"ServiceAccountArchived\": { ... }}`.\n","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\nis **not** in the payload — only the `api_key_id` (the row id).\nPlaintext is returned once at create time on the HTTP response.\n","protocol":"amqp","address":"exchange: account.events / routing key: account.api_key.created","operations":[{"type":"publish","summary":"API key issued","description":"Envelope `{\"ApiKeyCreated\": { ... }}`.\n","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\nservice-account archive. Authentication using the key starts\nfailing immediately (cache invalidated as part of the same\nhandler).\n","protocol":"amqp","address":"exchange: account.events / routing key: account.api_key.revoked","operations":[{"type":"publish","summary":"API key revoked","description":"Envelope `{\"ApiKeyRevoked\": { ... }}`.\n","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":"🔐","primary_color":"#360185"}}