{"info":{"title":"Ikavia Services","version":"1.0","description":"Hub dokumentasi API untuk seluruh ekosistem **ikavia.com**.\nPilih service di sidebar atau lewat parameter `?p=\u003cname\u003e`:\n\n- **[Account Service](/docs?p=account)** — Auth, RBAC, organizations (Rust, REST)\n- **[Media Service](/docs?p=media)** — Files, storage, sharing (Rust, REST)\n- **[Notification Service](/docs?p=notification)** — Email/Telegram/WhatsApp + AMQP events (Go, gRPC + RabbitMQ)\n","base_url":"amqp://localhost:5672","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"},{"label":"Local","url":"http://localhost:3001"},{"label":"Production","url":"https://media.ikavia.com/api","default":true},{"label":"gRPC (internal)","url":"grpc://127.0.0.1:13051","default":true},{"label":"AMQP","url":"amqp://localhost:5672"},{"label":"Health HTTP","url":"http://127.0.0.1:13080"}],"overview_cards":[{"icon":"🔐","title":"Account Service","description":"Authentication \u0026 RBAC source-of-truth","content":"Login, register, JWT/session, RBAC roles, organizations, audit logs,\nservice grants. SSO refresh-cookie scoped to `.ikavia.com`.\nREST API documented via OpenAPI 3.1.\n"},{"icon":"📁","title":"Media Service","description":"File upload, download, metadata","content":"Storage abstraction (local FS default), MIME blocklist, per-user quota,\nfolder hierarchy. Authenticates via JWT issued by account-service.\n"},{"icon":"🔔","title":"Notification Service","description":"Multi-channel async notifications","content":"Email/Telegram/WhatsApp/Webhook providers. Driven by RabbitMQ events\natau direct gRPC call. MongoDB-backed history \u0026 dedup.\n"},{"icon":"🔐","title":"Two principal types","description":"Human users sign in with email + password; services authenticate with API keys (or exchange a key for a JWT).","content":"- **Human** principals receive an access + refresh JWT pair on `/auth/login`. Refresh tokens rotate on every `/auth/refresh`.\n- **Service** principals authenticate with an API key (`X-API-Key`). Use `/auth/token-exchange` to swap a key for a 1-hour JWT and avoid per-request key validation.\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":"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":"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). |\n| `4003` | 401 | Authentication / authorization failure (invalid token, missing perm, revoked session). |\n| `4004` | 404 | Resource not found (account, organization, role, session). |\n| `4008` | 429 | Rate limit exceeded — `retry_after` field is included. |\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"},{"icon":"📤","title":"Streaming upload","description":"Multipart with magic-byte sniff and DB-enforced quota","content":"`POST /upload` streams the request body straight to disk, sniffs the\nmagic bytes, and rejects executables even when disguised by a fake\nMIME header. Storage quota is enforced at the DB layer\n(CHECK constraint, migration `20260424000001`) — the application\npre-check is a fast-path only.\n"},{"icon":"📁","title":"Folder tree","description":"Hierarchical, max depth 100, sibling-name unique","content":"Folders form a tree per owner (`parent_id` + `depth` + `path`).\nSibling names must be unique under the same parent\n(migration `20260424000002`). Recursive delete (`force=true`)\npurges every blob in the subtree before dropping rows.\n"},{"icon":"🔗","title":"Shared links","description":"Tokenised, optionally password-protected","content":"Generate an opaque token URL with optional **bcrypt** password,\nexpiry, and download cap. Anonymous accessors hit\n`GET /share/:token`. Legacy SHA-256 hashes are upgraded to bcrypt\non first successful access.\n"},{"icon":"🖼️","title":"Image variants","description":"Original kept; high/medium/low generated on demand","content":"Image uploads keep the **original** bytes intact and never\noverwrite them. Three derived variants — `high` (q=90),\n`medium` (q=75), `low` (q=50) — can be requested via\n`GET /media/{id}?variant=…`. The `medium` tier is generated\neagerly on upload (when `IMAGE_EAGER_MEDIUM=true`); `high` and\n`low` are generated lazily on first access and cached. All\nlevels live in `media_variants` (one row per tier) — see\nmigration `20260505000001`. Delete cascades clean every variant\nblob from disk, not just the DB rows.\n"},{"icon":"🎞️","title":"Video range streaming","description":"Browser scrubbing without full-file download","content":"For `video/*` MIMEs the download endpoint advertises\n`Accept-Ranges: bytes` and honours `Range:` request headers,\nresponding with **206 Partial Content** for the requested\nbyte window. Lets the HTML5 `\u003cvideo\u003e` element seek mid-file\nwithout buffering from byte 0. Out-of-bounds requests return\n416 with the canonical `Content-Range: bytes */\u003ctotal\u003e` hint.\nNon-video files don't advertise ranges and serve a full 200.\n"},{"icon":"📬","title":"Async (RabbitMQ)","description":"Primary integration path","content":"Service lain publish event ke exchange `notification.requests` (routing\nkey per provider: `email.send`, `telegram.send`, dst). Notification\nservice consume dari `notification.events.queue`.\n"},{"icon":"⚙️","title":"gRPC API","description":"Direct query \u0026 control","content":"Untuk fetch history, cancel/retry, dan stats. Tersedia 13 RPC methods\nterbagi atas `NotificationService` (7) dan `QueueMonitoringService` (6).\n"},{"icon":"🔌","title":"Providers","description":"Multi-channel delivery","content":"email (SMTP), telegram (Bot API), whatsapp (Business API), http (webhook),\ngrpc, rabbitmq (forward).\n"}]},"authentication":{"type":"internal","methods":[{"type":"JWT Bearer","header":"Authorization","format":"Bearer \u003caccess_token\u003e","source":"account-service","description":"Short-lived access token (default 15 min) issued by `/auth/login`,\n`/auth/register`, `/auth/refresh`, or `/auth/token-exchange`. RS256-signed;\nverifiers can fetch the public key from `/auth/public-key`.\n","note":"Refresh token (default 30 days) is sent separately as an HttpOnly cookie or in the `/auth/refresh` body.","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."},{"type":"JWT","header":"Authorization","format":"Bearer \u003ctoken\u003e","source":"Account Service","description":"RS256-signed JWT issued by the Account Service. The signature is\nverified against `JWT_PUBLIC_KEY_PATH` (defaults to\n`public_key.pem` in the working directory). Optional dynamic key\nrotation is supported via `ACCOUNT_SERVICE_URL`.\n","token_contains":["sub (user UUID)","org_id (organisation UUID, optional)","permissions (e.g. [\"media:upload\", \"folder:list\"])","exp (standard JWT expiry)"]}]},"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","steps":[{"title":"Authenticate against Account Service","detail":"Out of scope for this service. Obtain a JWT from the configured Account Service login endpoint."},{"title":"Attach the token on every protected request","detail":"Send `Authorization: Bearer \u003ctoken\u003e` on every endpoint except `/health` and `/share/:token`."},{"title":"Permissions are claim-checked per route","detail":"Each route asserts a specific permission (e.g. `media:upload`). Tokens missing the required permission get HTTP 403."}]}],"note":"Public endpoints (`/health`, `/share/:token`) do not require a token."},"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    \"display_name\": \"Jane Doe\",\n    \"avatar_url\": \"\",\n    \"timezone\": \"Asia/Jakarta\",\n    \"language\": \"id\",\n    \"email_verified\": true,\n    \"bio\": \"\",\n    \"metadata\": {},\n    \"created_at\": 1735689600\n  }\n}\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","body":[{"name":"display_name","type":"string","description":"Updated display name."},{"name":"bio","type":"string","description":"Free-form bio (markdown allowed downstream)."},{"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  \"display_name\": \"Jane D.\",\n  \"bio\": \"Building things.\",\n  \"timezone\": \"Asia/Singapore\",\n  \"language\": \"en\"\n}\n","example_response":"{ \"success\": true, \"data\": { \"account_id\": \"...\", \"account_type\": \"human\", \"email\": \"user@example.com\", \"display_name\": \"Jane D.\", \"timezone\": \"Asia/Singapore\", \"language\": \"en\", \"email_verified\": true, \"bio\": \"Building things.\", \"metadata\": {}, \"created_at\": 1735689600 } }\n"},{"name":"Change password","method":"POST","path":"/api/v1/me/password","auth":"JWT Bearer","description":"Change the authenticated user's password. Verifies the current\npassword, then revokes every active session **except** the one\nmaking this request (so the user isn't logged out of the tab\nthey're sitting in). Service accounts cannot call this — they\nhave no password.\n**Rate limit:** 5 / minute / IP.\n","body":[{"name":"current_password","type":"string","required":true,"description":"Existing password (re-verified server-side)."},{"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":"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":"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"}]},{"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","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"}],"example_body":"{\n  \"email\": \"user@example.com\",\n  \"password\": \"SecurePass123!\",\n  \"display_name\": \"Jane Doe\",\n  \"timezone\": \"Asia/Jakarta\",\n  \"language\": \"id\"\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":"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. Always\nreturns success regardless of whether the email exists, to avoid\nleaking account presence. **Rate limit:** 3 / minute / IP.\n","body":[{"name":"email","type":"string","required":true,"description":"Email to resend verification to."}],"example_body":"{ \"email\": \"user@example.com\" }\n","example_response":"{ \"success\": true, \"data\": { \"message\": \"If the account exists and is unverified, a new 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","body":[{"name":"email","type":"string","required":true,"description":"Email of the account to reset."}],"example_body":"{ \"email\": \"user@example.com\" }\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"}]},{"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":"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","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","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"}]},{"id":"folders","title":"Folders","description":"Hierarchical folder tree per owner. Root has `parent_id = null`\nand `depth = 0`. Sibling names must be unique under the same\nparent. Maximum nesting depth is 100.\n","endpoints":[{"name":"Create Folder","method":"POST","path":"/folders","auth":"JWT","permission":"folder:create","description":"Creates a folder owned by the caller. If `parent_id` is\nprovided, the folder is nested under that parent and inherits\na `depth` of `parent.depth + 1`. The DB enforces sibling-name\nuniqueness — duplicates return HTTP 409.\n","body":[{"name":"name","type":"string","required":true,"description":"Folder name. 1–255 chars; no `/` or control characters."},{"name":"parent_id","type":"uuid","description":"Parent folder. Omit for a root-level folder."},{"name":"visibility","type":"string","description":"`public` | `organization` | `private`."}],"example_body":"{\n  \"name\": \"Vacation Photos\",\n  \"parent_id\": \"4f2c…\",\n  \"visibility\": \"organization\"\n}\n","example_response":"{\n  \"id\": \"f9a1…\",\n  \"name\": \"Vacation Photos\",\n  \"parent_id\": \"4f2c…\",\n  \"path\": \"Documents/Vacation Photos\",\n  \"depth\": 2,\n  \"created_at\": \"2026-04-30T08:14:22Z\"\n}\n"},{"name":"List Folders","method":"GET","path":"/folders","auth":"JWT","permission":"folder:list","description":"Lists folders owned by the caller. Default scope is the\ncurrent parent's children (or roots when `parent_id` is\nomitted). Set `recursive=true` to flatten the entire subtree.\n","query_params":[{"name":"parent_id","type":"uuid","description":"Listing scope. Omit for root-level folders."},{"name":"recursive","type":"bool","default":"false","description":"Walk the full subtree under `parent_id`."},{"name":"sort","type":"string","default":"name","description":"`name` | `created_at` | `updated_at`."},{"name":"order","type":"string","default":"asc","description":"`asc` | `desc`."}]},{"name":"Rename Folder","method":"PATCH","path":"/folders/{id}","auth":"JWT","permission":"folder:update","description":"Renames a folder. The DB trigger\n`trg_update_folder_path` rewrites every descendant's `path`\ninside the same transaction. Root folders cannot be renamed.\n","body":[{"name":"name","type":"string","required":true,"description":"New folder name."}],"example_body":"{ \"name\": \"Archive 2026\" }\n"},{"name":"List Folder Contents","method":"GET","path":"/folders/{id}/contents","auth":"JWT","permission":"folder:list","description":"Returns the immediate children: subfolders and media files in\na single paginated response. Useful for a Drive-style file\nbrowser UI.\n","query_params":[{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."}]},{"name":"Delete Folder","method":"DELETE","path":"/folders/{id}","auth":"JWT","permission":"folder:delete","description":"Deletes the folder. The non-force path requires the folder to\nbe empty (no media, no subfolders) and returns HTTP 412 if it\nis not. With `force=true` the entire subtree is purged: for\nevery media in the subtree, all derived variant blobs (medium\n/high/low) are removed first, then the original blob, then\nthe media row; finally the folder rows cascade. Variant\ncleanup failures are logged but never abort the purge — same\nreasoning as the single-media delete endpoint.\n","query_params":[{"name":"force","type":"bool","default":"false","description":"Recursively delete every file and subfolder in the subtree."}],"example_response":"{\n  \"deleted\": true,\n  \"folder_id\": \"f9a1…\",\n  \"deleted_items\": { \"folders\": 3, \"media\": 17 }\n}\n"}]},{"id":"health","title":"Health","description":"Liveness probe. No authentication, no rate limiting, safe for\nuptime checkers.\n","endpoints":[{"name":"Health Check","method":"GET","path":"/health","auth":"none","description":"Returns a static JSON payload indicating the service process is\nup. Does not check downstream dependencies (DB, storage).\n","example_response":"{\n  \"status\": \"healthy\",\n  \"service\": \"media-service\"\n}\n"}]},{"id":"media","title":"Media","description":"Read, list, rename, change visibility on, and delete media files.\nVisibility rules (`public`/`organization`/`private`) gate read\naccess; ownership and `media:*` permissions gate writes.\n","endpoints":[{"name":"List Media","method":"GET","path":"/media","auth":"JWT","permission":"media:list","description":"Paginated listing. Supports filtering by folder, visibility,\nfile type, and a search term applied to `original_name`. The\nsearch input is parameterised at the SQL layer.\n","query_params":[{"name":"folder_id","type":"uuid","description":"Restrict to this folder; omit for all-folders view."},{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."},{"name":"visibility","type":"string","description":"Filter: `public` | `organization` | `private`."},{"name":"file_type","type":"string","description":"Filter: `image` | `video` | `document` | `audio` | `other`."},{"name":"search","type":"string","description":"Substring match on `original_name`."},{"name":"sort","type":"string","default":"created_at","description":"`created_at` | `name` | `size` | `updated_at`."},{"name":"order","type":"string","default":"desc","description":"`asc` | `desc`."}],"example_response":"{\n  \"items\": [\n    {\n      \"id\": \"9e1c…\",\n      \"original_name\": \"photo.jpg\",\n      \"mime_type\": \"image/jpeg\",\n      \"size_bytes\": 248122,\n      \"visibility\": \"organization\",\n      \"folder_id\": \"4f2c…\",\n      \"created_at\": \"2026-04-30T08:14:22Z\"\n    }\n  ],\n  \"total\": 1,\n  \"page\": 1,\n  \"per_page\": 20,\n  \"total_pages\": 1,\n  \"quota\": { \"used_bytes\": 248122, \"max_bytes\": 1073741824 }\n}\n"},{"name":"Download Media","method":"GET","path":"/media/{id}","auth":"JWT","permission":"media:download","description":"Streams the media bytes back with the original `Content-Type`\nand `Content-Disposition`. Visibility rules apply: `public` is\nunauthenticated, `organization` requires same-org JWT,\n`private` requires the owner's JWT.\n\n**Variant selection (`?variant=`)** — for image uploads, the\nservice stores the original plus optional re-encoded variants\nat decreasing quality. Use the query param to pick which one\nto download:\n\n  * `original` (default) — uncompressed bytes, format as\n    uploaded. SHA-256 matches the upload-time checksum.\n  * `high` (q=90) — light recompression, near-original quality.\n  * `medium` (q=75) — balanced; suitable for previews.\n  * `low` (q=50) — aggressive; suitable for thumbnails.\n\nAliases also accepted: `orig` / `raw` for original, `hi` for\nhigh, `med` for medium, `lo` for low. Invalid values return\nHTTP 400 with the allowed list.\n\nThe `medium` variant is generated eagerly on upload (when\n`IMAGE_EAGER_MEDIUM=true`); other levels are generated lazily\non first request and cached for subsequent ones. Generation\nis best-effort — if the original cannot be re-encoded\n(corrupt image, unsupported format), the request fails with\nHTTP 500 rather than silently returning the original.\n\nThe `Content-Disposition` filename is always the upload's\ndisplay name regardless of variant — the tier is invisible\nto the user-facing UI.\n\n**Range / streaming (video only)** — when the underlying\nMIME is `video/*`, the response includes `Accept-Ranges: bytes`\nand the endpoint honours `Range:` request headers. This lets\nthe browser's HTML5 `\u003cvideo\u003e` element scrub mid-file without\ndownloading from byte 0. Supported forms:\n\n  * `Range: bytes=N-M`  → 206 Partial Content\n  * `Range: bytes=N-`   → 206 from N to EOF\n  * `Range: bytes=-M`   → 206 last M bytes\n  * Multi-range / unparseable → ignored, full 200 returned\n  * Out-of-bounds → 416 with `Content-Range: bytes */\u003ctotal\u003e`\n\nNon-video MIMEs do NOT advertise `Accept-Ranges` and ignore\nany `Range:` header — they always stream the full body.\n","query_params":[{"name":"variant","type":"string","default":"original","description":"`original` (default) | `high` | `medium` | `low`. Aliases: `orig`/`raw`, `hi`, `med`, `lo`."}]},{"name":"Get Media Info","method":"GET","path":"/media/{id}/info","auth":"JWT","permission":"media:download","description":"Returns the metadata only (no bytes). Useful for clients that\nwant size/MIME/checksum without paying for the download.\n"},{"name":"Rename Media","method":"PATCH","path":"/media/{id}","auth":"JWT","permission":"media:update","description":"Updates the user-facing `original_name`. The on-disk filename\nis unchanged; only the display name is rewritten.\n","body":[{"name":"original_name","type":"string","required":true,"description":"New display name. Client should preserve the extension."}],"example_body":"{ \"original_name\": \"vacation-2026-04.jpg\" }\n"},{"name":"Update Media Visibility","method":"PATCH","path":"/media/{id}/visibility","auth":"JWT","permission":"media:update","description":"Switches the media row's visibility level. Does not propagate\nto shared links; existing tokens keep working until they\nexpire or are deleted.\n","body":[{"name":"visibility","type":"string","required":true,"description":"`public` | `organization` | `private`."}],"example_body":"{ \"visibility\": \"private\" }\n"},{"name":"Delete Media","method":"DELETE","path":"/media/{id}","auth":"JWT","permission":"media:delete","description":"Removes every blob (original + each derived variant) from\nstorage, then deletes the `media` row. The matching\n`media_variants` rows go away via FK cascade. A\n`StorageError::NotFound` is tolerated per file so retries\non a partially-failed delete are idempotent.\n\nA failure cleaning a single variant blob is logged but does\nnot abort the delete — the user's media still goes away;\nworst case is one orphan blob on disk. Operators can sweep\norphans by walking the storage directory and reconciling\nagainst `media_variants.storage_path`.\n","example_response":"{\n  \"deleted\": true,\n  \"media_id\": \"9e1c…\",\n  \"filename\": \"9e1c….jpg\",\n  \"freed_bytes\": 248122\n}\n"}]},{"id":"recent","title":"Recent Files","description":"Per-user \"recently accessed\" history. Tracks accesses pushed\nfrom the client and exposes a chronologically-ordered list. The\naccess event is recorded asynchronously of the underlying\ndownload, so a missing track call does not block playback.\n","endpoints":[{"name":"List Recent Files","method":"GET","path":"/recent","auth":"JWT","permission":"media:list","description":"Returns the caller's most recently accessed media,\nnewest-first. Capped at the value the client requests\n(default applied server-side).\n","query_params":[{"name":"limit","type":"int","description":"Max items to return; server applies a sane upper bound."}]},{"name":"Track Access","method":"POST","path":"/media/{id}/track","auth":"JWT","permission":"media:list","description":"Records an access event for the given media. Idempotent at\nthe row level — repeated tracks update the existing row's\ntimestamp instead of inserting duplicates.\n","body":[{"name":"access_type","type":"string","description":"Free-form classifier (e.g. `view`, `download`, `preview`)."}],"example_body":"{ \"access_type\": \"view\" }\n"},{"name":"Clear History","method":"DELETE","path":"/recent","auth":"JWT","permission":"media:list","description":"Wipes the caller's entire recent-files history. The\nunderlying media files are untouched.\n"}]},{"id":"shared-links","title":"Shared Links","description":"Generate opaque token URLs that grant external (often\nanonymous) access to a single media file. Supports password\ngating (bcrypt), expiry, and a hard download cap.\n","endpoints":[{"name":"Create Shared Link","method":"POST","path":"/media/{id}/share","auth":"JWT","permission":"media:download","description":"Mints a shared-link token for the given media. The caller\nmust own the media or have download access through it.\nVisibility defaults to `restricted` (creator-only) — set\n`public` for anonymous access or `organization` to gate by\nthe caller's org.\n","body":[{"name":"visibility","type":"string","description":"`public` | `organization` | `restricted`."},{"name":"password","type":"string","description":"If set, accessors must supply this password (bcrypt-hashed at rest)."},{"name":"expires_in_hours","type":"int","description":"Lifetime in hours. Omit for no expiry."},{"name":"max_downloads","type":"int","description":"Cap on successful downloads through this link."}],"example_body":"{\n  \"visibility\": \"public\",\n  \"password\": \"spring-2026\",\n  \"expires_in_hours\": 168,\n  \"max_downloads\": 50\n}\n","example_response":"{\n  \"id\": \"ab12…\",\n  \"token\": \"8f3c2a1b…\",\n  \"share_url\": \"https://media.example.com/share/8f3c2a1b…\",\n  \"visibility\": \"public\",\n  \"has_password\": true,\n  \"expires_at\": \"2026-05-07T08:14:22Z\",\n  \"max_downloads\": 50,\n  \"created_at\": \"2026-04-30T08:14:22Z\"\n}\n"},{"name":"Access Shared Link","method":"GET","path":"/share/{token}","auth":"none","description":"Anonymous (or authenticated, for `restricted`/`organization`\nlinks) entry point. Streams the media bytes back. The\ndownload counter is incremented atomically.\n\nFailure modes (401/403/404):\n  * link not found / revoked → 404\n  * expired → 410\n  * download cap reached → 429\n  * password missing/wrong → 401\n  * caller not eligible for `restricted` / `organization` → 403\n","query_params":[{"name":"password","type":"string","description":"Required when the link was created with a password."}]},{"name":"List My Shared Links","method":"GET","path":"/shared-links","auth":"JWT","permission":"media:download","description":"Lists shared links the caller created. Paginated.\n","query_params":[{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."}]},{"name":"Delete Shared Link","method":"DELETE","path":"/shared-links/{id}","auth":"JWT","permission":"media:download","description":"Revokes a shared link. Subsequent `GET /share/:token`\nrequests return 404. The underlying media file is\nunaffected.\n"}]},{"id":"starred","title":"Starred Files","description":"Per-user bookmark list. Each star carries an optional free-form\n`notes` field. Operations are idempotent — re-starring a file\nupdates the notes/timestamp instead of duplicating rows.\n","endpoints":[{"name":"List Starred","method":"GET","path":"/starred","auth":"JWT","permission":"media:list","description":"Lists the caller's starred media in reverse-chronological\norder (most recently starred first). Each item includes the\nunderlying media metadata plus `starred_at` and `notes`.\n","query_params":[{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."}]},{"name":"Star File","method":"POST","path":"/media/{id}/star","auth":"JWT","permission":"media:list","description":"Stars (or re-stars) a media file. Optional `notes` are\nstored alongside the star. Calling this on an\nalready-starred file is a no-error update.\n","body":[{"name":"notes","type":"string","description":"Optional annotation, surfaced in the listing."}],"example_body":"{ \"notes\": \"Reference for the launch deck\" }\n"},{"name":"Unstar File","method":"DELETE","path":"/media/{id}/star","auth":"JWT","permission":"media:list","description":"Removes the star. Idempotent — unstarring an already-unstarred\nfile returns success.\n"},{"name":"Toggle Star","method":"POST","path":"/media/{id}/toggle-star","auth":"JWT","permission":"media:list","description":"Convenience endpoint that flips the star state. Useful for\nUIs that bind a single button to \"star/unstar\" without\ntracking current state client-side.\n"},{"name":"Check Star Status (Bulk)","method":"POST","path":"/starred/check","auth":"JWT","permission":"media:list","description":"Bulk lookup: given a list of media IDs, returns which ones\nthe caller has starred. Lets a list view annotate every row\nin one round-trip.\n","body":[{"name":"media_ids","type":"array\u003cstring\u003e","required":true,"description":"Up to N media UUIDs to check at once."}],"example_body":"{ \"media_ids\": [\"9e1c…\", \"f9a1…\", \"ab12…\"] }\n","example_response":"{\n  \"items\": [\n    { \"media_id\": \"9e1c…\", \"is_starred\": true },\n    { \"media_id\": \"f9a1…\", \"is_starred\": false },\n    { \"media_id\": \"ab12…\", \"is_starred\": true }\n  ]\n}\n"}]},{"id":"upload","title":"Upload","description":"Streamed multipart upload. The body is consumed once and written\ndirectly to disk; quota is enforced at the database CHECK\nconstraint. The client-declared MIME is treated as a hint —\nmagic-byte sniff wins on conflict.\n","endpoints":[{"name":"Upload Media","method":"POST","path":"/upload","auth":"JWT","permission":"media:upload","description":"Streams a single multipart file part (field name `file`) into\nstorage. The response carries the canonical media metadata,\nincluding the SHA-256 checksum and the storage path of the\n**original** bytes.\n\n**Validation order:** filename → MIME allow/block → quota\nfast-path → stream to disk (max-size enforced authoritatively)\n→ magic-byte sniff → rebuild against policy → DB row.\n\n**Original is never overwritten.** For images, the upload\npipeline additionally generates the `medium` variant (quality\n75) post-DB-save when `IMAGE_EAGER_MEDIUM=true`. `high` (90)\nand `low` (50) are generated lazily on first\n`GET /media/{id}?variant=…`. Variant generation is\nbest-effort — failure here logs a warning but the upload\nitself succeeds with the original committed.\n\nOn any post-stream failure the staged blob is removed; the\ninvariant **\"DB row ⇒ storage file exists\"** is preserved.\n","query_params":[{"name":"folder_id","type":"uuid","description":"Target folder. Omit for root."},{"name":"visibility","type":"string","default":"organization","description":"`public` | `organization` | `private`. Defaults to `organization`."}],"body":[{"name":"file","type":"file (multipart)","required":true,"description":"The file part. Filename is taken from the part's `filename` attribute."}],"example_body":"# multipart/form-data — uploads always need a JWT (the\n# `visibility` knob only affects who can READ the file later,\n# not who can upload).\n\n# 1) Organization-scoped (default). Anyone in the same org as\n#    the uploader can download.\ncurl -X POST 'http://localhost:3001/upload?folder_id=4f2c…\u0026visibility=organization' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/photo.jpg'\n\n# 2) Public. Anyone with the media id can download anonymously\n#    via GET /media/{id} — no token required on the read side.\n#    Useful for assets that are meant to be embedded on a\n#    public web page.\ncurl -X POST 'http://localhost:3001/upload?visibility=public' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/banner.jpg'\n\n# 3) Private. Only the uploader can download (and org admins\n#    if your deployment grants them override).\ncurl -X POST 'http://localhost:3001/upload?visibility=private' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/secret.pdf'\n","example_response":"{\n  \"id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n  \"filename\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11.jpg\",\n  \"original_name\": \"photo.jpg\",\n  \"mime_type\": \"image/jpeg\",\n  \"size_bytes\": 248122,\n  \"checksum\": \"sha256:7e1f…\",\n  \"visibility\": \"organization\",\n  \"folder_id\": \"4f2c…\",\n  \"created_at\": \"2026-04-30T08:14:22Z\"\n}\n"}]},{"id":"notification-rpc","title":"NotificationService (gRPC)","description":"gRPC service for creating, querying, and managing notifications.\nProto: `proto/notification.proto`, package `notification`.\n","base_url":"grpc://127.0.0.1:13051","endpoints":[{"name":"CreateNotification","method":"gRPC","path":"/notification.NotificationService/CreateNotification","description":"Membuat satu notifikasi dengan satu atau banyak recipient. Akan dipush\nke provider sesuai `provider_type` (email/telegram/whatsapp/http/grpc/rabbitmq).\n","body":[{"name":"recipients","type":"array\u003cRecipient\u003e","required":true,"description":"List recipient. Recipient = {type, address, name}"},{"name":"content","type":"Content","required":true,"description":"{subject, body, is_html, html_body}"},{"name":"priority","type":"string","description":"low | normal | high | urgent"},{"name":"provider_type","type":"string","required":true,"description":"email | telegram | http | grpc | whatsapp | rabbitmq"},{"name":"metadata","type":"map\u003cstring,string\u003e"},{"name":"max_retries","type":"int32"},{"name":"scheduled_at","type":"timestamp","description":"Untuk delayed delivery"}],"example_response":"{\n  \"notification_id\": \"ntf_01HXYZ...\",\n  \"status\": \"queued\",\n  \"created_at\": \"2026-05-13T21:00:00Z\",\n  \"message\": \"queued for delivery\"\n}\n"},{"name":"CreateBulkNotification","method":"gRPC","path":"/notification.NotificationService/CreateBulkNotification","description":"Kirim banyak notifikasi sekaligus dengan batch_id tunggal.","body":[{"name":"notifications","type":"array\u003cBulkNotificationItem\u003e","required":true,"description":"Setiap item: {recipients, content, priority, provider_type}"}],"example_response":"{\n  \"batch_id\": \"btc_01HXYZ...\",\n  \"notification_ids\": [\"ntf_...\", \"ntf_...\"],\n  \"status\": \"queued\",\n  \"total_count\": 2\n}\n"},{"name":"GetNotification","method":"gRPC","path":"/notification.NotificationService/GetNotification","description":"Fetch detail satu notifikasi berdasarkan ID.","body":[{"name":"notification_id","type":"string","required":true}],"example_response":"{\n  \"id\": \"ntf_01HXYZ...\",\n  \"recipients\": [{\"type\": \"email\", \"address\": \"user@example.com\"}],\n  \"content\": {\"subject\": \"Welcome\", \"body\": \"Hi\"},\n  \"provider_type\": \"email\",\n  \"status\": \"delivered\",\n  \"priority\": \"normal\",\n  \"retry_count\": 0,\n  \"max_retries\": 3,\n  \"created_at\": \"2026-05-13T21:00:00Z\",\n  \"delivered_at\": \"2026-05-13T21:00:03Z\"\n}\n"},{"name":"GetNotificationHistory","method":"gRPC","path":"/notification.NotificationService/GetNotificationHistory","description":"Paginated history dengan filter optional (status, provider_type, date_from, date_to).","body":[{"name":"page","type":"int32","example":"1"},{"name":"page_size","type":"int32","example":"20"},{"name":"filters","type":"map\u003cstring,string\u003e","description":"status | provider_type | date_from | date_to"}],"example_response":"{\n  \"notifications\": [ /* Notification[] */ ],\n  \"total\": 1234,\n  \"page\": 1,\n  \"page_size\": 20,\n  \"total_pages\": 62\n}\n"},{"name":"CancelNotification","method":"gRPC","path":"/notification.NotificationService/CancelNotification","description":"Cancel notifikasi yang masih queued/processing.","body":[{"name":"notification_id","type":"string","required":true}],"example_response":"{\"success\": true, \"message\": \"cancelled\"}\n"},{"name":"RetryNotification","method":"gRPC","path":"/notification.NotificationService/RetryNotification","description":"Retry notifikasi yang failed.","body":[{"name":"notification_id","type":"string","required":true}],"example_response":"{\"success\": true, \"message\": \"queued for retry\"}\n"},{"name":"GetStats","method":"gRPC","path":"/notification.NotificationService/GetStats","description":"Aggregate counter per status + average delivery time.","example_response":"{\n  \"pending\": 42,\n  \"processing\": 5,\n  \"delivered\": 9821,\n  \"failed\": 17,\n  \"cancelled\": 3,\n  \"average_delivery_time\": 1.43\n}\n"}]},{"id":"queue-rpc","title":"QueueMonitoringService (gRPC)","description":"Operational RPC untuk monitor antrian RabbitMQ yang digunakan oleh service.\nProto: `proto/queue.proto`, package `queue`.\n","base_url":"grpc://127.0.0.1:13051","endpoints":[{"name":"GetQueueStats","method":"gRPC","path":"/queue.QueueMonitoringService/GetQueueStats","description":"Stats per queue (message_count, consumer_count, rate, avg_processing_time).","body":[{"name":"queue_name","type":"string","description":"Kosongkan untuk semua queue"}]},{"name":"GetQueueInfo","method":"gRPC","path":"/queue.QueueMonitoringService/GetQueueInfo","description":"Metadata queue (durable, auto_delete, arguments, created_at).","body":[{"name":"queue_name","type":"string","required":true}]},{"name":"GetConsumerInfo","method":"gRPC","path":"/queue.QueueMonitoringService/GetConsumerInfo","description":"Consumer list per queue dengan ack/unack count, prefetch.","body":[{"name":"queue_name","type":"string","required":true}]},{"name":"GetMessageDetails","method":"gRPC","path":"/queue.QueueMonitoringService/GetMessageDetails","description":"Detail satu message dari queue (payload, published_at, delivered_at, delivery_count).","body":[{"name":"message_id","type":"string","required":true},{"name":"queue_name","type":"string","required":true}]},{"name":"PurgeQueue","method":"gRPC","path":"/queue.QueueMonitoringService/PurgeQueue","description":"⚠️ Hapus semua message dari queue. Operasi destruktif — gunakan hanya untuk recovery atau testing.","body":[{"name":"queue_name","type":"string","required":true}]},{"name":"HealthCheck","method":"gRPC","path":"/queue.QueueMonitoringService/HealthCheck","description":"Status keseluruhan service + dependency check per komponen.","example_response":"{\n  \"healthy\": true,\n  \"status\": \"ok\",\n  \"version\": \"1.0.0\",\n  \"timestamp\": \"2026-05-13T21:00:00Z\",\n  \"services\": [\n    {\"name\": \"rabbitmq\", \"healthy\": true},\n    {\"name\": \"mongodb\", \"healthy\": true}\n  ]\n}\n"}]},{"id":"http","title":"HTTP Endpoints","description":"Minimal HTTP surface untuk health check (PM2 monitoring, uptime probes).","base_url":"http://127.0.0.1:13080","endpoints":[{"name":"Health Check","method":"GET","path":"/health","description":"Returns 200 OK kalau service hidup dan dependencies (RabbitMQ + MongoDB) reachable.","example_response":"{\"status\": \"ok\", \"uptime_seconds\": 604800}\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-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":"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."},{"name":"media:upload","description":"Upload new media files."},{"name":"media:download","description":"Download media files; required to create shared links."},{"name":"media:list","description":"List media; gates recent and starred listings."},{"name":"media:update","description":"Rename media or change its visibility."},{"name":"media:delete","description":"Delete media."},{"name":"folder:create","description":"Create folders."},{"name":"folder:list","description":"List folders and folder contents."},{"name":"folder:update","description":"Rename or move folders."},{"name":"folder:delete","description":"Delete folders (set `force=true` to delete recursively)."}],"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.","Per-user storage quota (default 1 GB) is enforced via a PostgreSQL CHECK constraint, not application code.","Maximum folder depth is 100; sibling folder names must be unique per parent.","Default upload size cap is 100 MB (override with `APP__MAX_FILE_SIZE`).","Effective MIME is derived from a magic-byte sniff; client-declared `Content-Type` is overridden if it conflicts and re-validated against the allow/block policy.","Shared-link tokens are opaque random strings. Passwords are bcrypt-hashed; legacy SHA-256 hashes are transparently upgraded on access.","All write operations on folders and media require ownership; cross-tenant reparenting is rejected.","Image originals are never overwritten. Compressed variants (`high`/`medium`/`low`) are stored separately in `media_variants` and selected via `?variant=` on the download endpoint.","At-least-once delivery — consumer wajib idempotent. Dedup pakai event `id`.","Max payload size 1 MiB per AMQP message (limit RabbitMQ guest config).","Retry policy default 3x dengan exponential backoff. Setelah failed final, masuk dead-letter queue (manual replay).","Provider unknown → message di-reject, masuk DLQ tanpa retry."],"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":{}},{"id":"client","label":"📱 Client","type":"client","color":"#0ea5e9","position":{}},{"id":"account","label":"🔐 Account Service","type":"service","color":"#4f46e5","position":{}},{"id":"media","label":"🎞️ Media Service","type":"service","color":"#10b981","position":{}},{"id":"jwt","label":"🔑 JWT","type":"data","color":"#f59e0b","position":{}},{"id":"storage","label":"💾 Local Storage","type":"external","color":"#64748b","position":{}},{"id":"postgres","label":"🐘 PostgreSQL","type":"external","color":"#64748b","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","animated":true,"color":"#4f46e5"},{"source":"rabbitmq","target":"email_service","label":"consume","color":"#8b5cf6"},{"source":"client","target":"account","label":"login","animated":true,"color":"#0ea5e9"},{"source":"account","target":"jwt","label":"issues","color":"#f59e0b"},{"source":"client","target":"media","label":"API calls","animated":true,"color":"#0ea5e9"},{"source":"media","target":"jwt","label":"verify RS256","color":"#f59e0b"},{"source":"media","target":"account","label":"key rotation","animated":true,"color":"#4f46e5","style":"dashed"},{"source":"media","target":"storage","label":"blobs","animated":true,"color":"#10b981"},{"source":"media","target":"postgres","label":"metadata","animated":true,"color":"#10b981"}],"api_tester_defaults":{"methods":["GET","POST","PUT","PATCH","DELETE","GET","POST","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"},{"name":"JWT","header":"Authorization","prefix":"Bearer ","placeholder":"YOUR_JWT_TOKEN_HERE"}]},"events":[{"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":"notification-events-queue","title":"notification.events.queue","description":"**Primary inbound queue.** Service lain publish event ke\nexchange `notification.requests` dengan routing key sesuai provider\n(`email.send`, `telegram.send`, `whatsapp.send`, `http.send`). Notification\nservice consume dari binding queue ini lalu memproses sesuai `provider_type`.\n","protocol":"amqp","address":"notification.events.queue (bound to exchange `notification.requests`)","operations":[{"type":"subscribe","summary":"Notification service consumes events","description":"At-least-once delivery. Service deduplicate berdasarkan `id` field.\nFailed delivery di-retry sampai `max_retries` (default 3).\n","payload":[{"name":"id","type":"string","required":true,"description":"Unique event ID untuk dedup"},{"name":"source_service","type":"string","required":true,"description":"Nama service yang publish (e.g. account-service)"},{"name":"timestamp","type":"string","required":true,"description":"ISO 8601"},{"name":"recipient","type":"object","required":true,"description":"{email, name, telegram_chat_id, whatsapp_number}"},{"name":"content","type":"object","required":true,"description":"{subject, body, template_data}"},{"name":"provider_type","type":"string","required":true,"description":"email | telegram | whatsapp | http | grpc | rabbitmq"},{"name":"provider_name","type":"string","required":true},{"name":"metadata","type":"map\u003cstring,string\u003e"},{"name":"created_at","type":"string","required":true}],"example":"{\n  \"id\": \"evt-user-reg-1700000000\",\n  \"source_service\": \"account-service\",\n  \"timestamp\": \"2026-05-13T21:00:00Z\",\n  \"recipient\": {\n    \"email\": \"user@example.com\",\n    \"name\": \"John\"\n  },\n  \"content\": {\n    \"subject\": \"Welcome to ikavia\",\n    \"body\": \"Hi John, your account is ready\"\n  },\n  \"provider_type\": \"email\",\n  \"provider_name\": \"smtp_email\",\n  \"metadata\": {\n    \"user_id\": \"acc_01HX...\",\n    \"event\": \"user_registered\"\n  },\n  \"created_at\": \"2026-05-13T21:00:00Z\"\n}\n"}]},{"id":"notification-outbound","title":"notification.outbound","description":"Exchange tempat notification service mempublish hasil pemrosesan\n(status update, audit log) untuk dikonsumsi service lain.\n","protocol":"amqp","address":"notification.outbound (topic exchange)","operations":[{"type":"publish","summary":"Notification service publishes delivery results","description":"Routing key bentuk `notification.\u003cstatus\u003e.\u003cprovider\u003e` misal\n`notification.delivered.email`. Subscriber bisa bind ke wildcard\n(e.g. `notification.failed.*`).\n","payload":[{"name":"notification_id","type":"string","required":true},{"name":"status","type":"string","required":true,"description":"delivered | failed | cancelled | retrying"},{"name":"provider_type","type":"string","required":true},{"name":"attempt","type":"int","required":true},{"name":"error","type":"string","description":"Hanya ada saat status=failed"},{"name":"timestamp","type":"string","required":true}],"example":"{\n  \"notification_id\": \"ntf_01HX...\",\n  \"status\": \"delivered\",\n  \"provider_type\": \"email\",\n  \"attempt\": 1,\n  \"timestamp\": \"2026-05-13T21:00:03Z\"\n}\n"}]}],"theme":{"title":"Ikavia Services","logo_icon":"🛠","primary_color":"#360185"}}