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

info:
    title: Ikavia Services
    version: "1.0"
    description: |
        Hub dokumentasi API untuk seluruh ekosistem **ikavia.com**.
        Pilih service di sidebar atau lewat parameter `?p=<name>`:

        - **[Account Service](/docs?p=account)** — Auth, RBAC, organizations (Rust, REST)
        - **[Media Service](/docs?p=media)** — Files, storage, sharing (Rust, REST)
        - **[Notification Service](/docs?p=notification)** — Email/Telegram/WhatsApp + AMQP events (Go, gRPC + RabbitMQ)
    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: "\U0001F510"
          title: Account Service
          description: Authentication & RBAC source-of-truth
          content: |
            Login, register, JWT/session, RBAC roles, organizations, audit logs,
            service grants. SSO refresh-cookie scoped to `.ikavia.com`.
            REST API documented via OpenAPI 3.1.
        - icon: "\U0001F4C1"
          title: Media Service
          description: File upload, download, metadata
          content: |
            Storage abstraction (local FS default), MIME blocklist, per-user quota,
            folder hierarchy. Authenticates via JWT issued by account-service.
        - icon: "\U0001F514"
          title: Notification Service
          description: Multi-channel async notifications
          content: |
            Email/Telegram/WhatsApp/Webhook providers. Driven by RabbitMQ events
            atau direct gRPC call. MongoDB-backed history & dedup.
        - icon: "\U0001F510"
          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`.
            - **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.
        - icon: "\U0001F3E2"
          title: Organization-scoped
          description: Every authenticated request resolves to a current organization context.
          content: |
            - On login the user's `default_organization_id` becomes the current org.
            - Use `POST /organizations/{org_id}/switch` to issue a new token bound to a different org. The session id is preserved across the switch.
        - icon: "\U0001F6E1️"
          title: Defence in depth
          description: Brute-force lockout, refresh-token rotation with reuse detection, server-side revocation.
          content: |
            - Failed logins increment per-account and per-IP counters with progressive delay and lockout.
            - Refresh tokens are rotated on every `/auth/refresh`; presenting an old token after rotation revokes the session.
            - Logout / password-change writes the JTI to a Redis revocation list so stolen access tokens stop validating immediately.
        - icon: "\U0001F4E6"
          title: Standard response envelope
          description: Every JSON response follows the same wrapper shape — success, error, and 4xx/5xx alike.
          content: |
            Successful responses:
            ```json
            { "success": true, "code": 2010, "data": { ... } }
            ```
            - `code` is omitted (or absent) on most happy paths and is filled in for created (`2010`) and a few special cases.
            - `data` carries the endpoint's actual payload.

            Failure responses:
            ```json
            { "success": false, "code": 4003, "message": "...", "trace_id": "..." }
            ```
            - `code` follows the error-code table below — always check it before falling back to HTTP status.
            - `trace_id` is set on errors that originate from a use case so support can correlate logs.
        - icon: "\U0001F6A6"
          title: Error code table
          description: Numeric codes mirror HTTP status families (2xxx success, 4xxx client error, 5xxx server error).
          content: |
            | Code | HTTP | Meaning |
            |------|------|---------|
            | `2010` | 201 | Resource created (used on `/auth/register`). |
            | `4000` | 400 | Bad request (missing required header, malformed body). |
            | `4002` | 422 | Validation failed (e.g. invalid email format, weak password). |
            | `4003` | 401 | Authentication / authorization failure (invalid token, missing perm, revoked session). |
            | `4004` | 404 | Resource not found (account, organization, role, session). |
            | `4008` | 429 | Rate limit exceeded — `retry_after` field is included. |
            | `4030` | 403 | CSRF validation failed. |
            | `5000` | 500 | Internal error — usually transient (DB / Redis / RabbitMQ blip). Safe to retry with backoff. |

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

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

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

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

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

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

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

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

            The OpenAPI spec is the source of truth for client SDK generation; this docs page is the narrative companion.
        - icon: "\U0001F4E4"
          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
            magic bytes, and rejects executables even when disguised by a fake
            MIME header. Storage quota is enforced at the DB layer
            (CHECK constraint, migration `20260424000001`) — the application
            pre-check is a fast-path only.
        - icon: "\U0001F4C1"
          title: Folder tree
          description: Hierarchical, max depth 100, sibling-name unique
          content: |
            Folders form a tree per owner (`parent_id` + `depth` + `path`).
            Sibling names must be unique under the same parent
            (migration `20260424000002`). Recursive delete (`force=true`)
            purges every blob in the subtree before dropping rows.
        - icon: "\U0001F517"
          title: Shared links
          description: Tokenised, optionally password-protected
          content: |
            Generate an opaque token URL with optional **bcrypt** password,
            expiry, and download cap. Anonymous accessors hit
            `GET /share/:token`. Legacy SHA-256 hashes are upgraded to bcrypt
            on first successful access.
        - icon: "\U0001F5BC️"
          title: Image variants
          description: Original kept; high/medium/low generated on demand
          content: |
            Image uploads keep the **original** bytes intact and never
            overwrite them. Three derived variants — `high` (q=90),
            `medium` (q=75), `low` (q=50) — can be requested via
            `GET /media/{id}?variant=…`. The `medium` tier is generated
            eagerly on upload (when `IMAGE_EAGER_MEDIUM=true`); `high` and
            `low` are generated lazily on first access and cached. All
            levels live in `media_variants` (one row per tier) — see
            migration `20260505000001`. Delete cascades clean every variant
            blob from disk, not just the DB rows.
        - icon: "\U0001F39E️"
          title: Video range streaming
          description: Browser scrubbing without full-file download
          content: |
            For `video/*` MIMEs the download endpoint advertises
            `Accept-Ranges: bytes` and honours `Range:` request headers,
            responding with **206 Partial Content** for the requested
            byte window. Lets the HTML5 `<video>` element seek mid-file
            without buffering from byte 0. Out-of-bounds requests return
            416 with the canonical `Content-Range: bytes */<total>` hint.
            Non-video files don't advertise ranges and serve a full 200.
        - icon: "\U0001F4EC"
          title: Async (RabbitMQ)
          description: Primary integration path
          content: |
            Service lain publish event ke exchange `notification.requests` (routing
            key per provider: `email.send`, `telegram.send`, dst). Notification
            service consume dari `notification.events.queue`.
        - icon: ⚙️
          title: gRPC API
          description: Direct query & control
          content: |
            Untuk fetch history, cancel/retry, dan stats. Tersedia 13 RPC methods
            terbagi atas `NotificationService` (7) dan `QueueMonitoringService` (6).
        - icon: "\U0001F50C"
          title: Providers
          description: Multi-channel delivery
          content: |
            email (SMTP), telegram (Bot API), whatsapp (Business API), http (webhook),
            grpc, rabbitmq (forward).
authentication:
    type: internal
    methods:
        - type: JWT Bearer
          header: Authorization
          format: Bearer <access_token>
          source: account-service
          description: |
            Short-lived access token (default 15 min) issued by `/auth/login`,
            `/auth/register`, `/auth/refresh`, or `/auth/token-exchange`. RS256-signed;
            verifiers can fetch the public key from `/auth/public-key`.
          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: <api_key>
          source: account-service (`POST /service-accounts/{account_id}/api-keys`)
          description: |
            Long-lived credential bound to a service account. Send as `X-API-Key`
            header on any protected endpoint, or call `/auth/token-exchange` once
            an hour to swap it for a JWT (cheaper than per-request key lookup).
          note: Plaintext is returned only at creation time; only a sha-256 hash is stored server-side.
        - type: JWT
          header: Authorization
          format: Bearer <token>
          source: Account Service
          description: |
            RS256-signed JWT issued by the Account Service. The signature is
            verified against `JWT_PUBLIC_KEY_PATH` (defaults to
            `public_key.pem` in the working directory). Optional dynamic key
            rotation is supported via `ACCOUNT_SERVICE_URL`.
          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 <access_token> on every protected request. CSRF middleware does not apply when this header is present.'
            - title: Rotate before expiry
              detail: Call POST /auth/refresh (body or cookie carries the refresh token) to receive a fresh pair. The old refresh token is revoked atomically — replaying it later kills the session.
            - title: Switch organization (optional)
              detail: POST /organizations/{org_id}/switch reissues a token bound to the new org. The session id is preserved.
            - title: Log out
              detail: POST /auth/logout revokes the current session and adds the access JTI to the revocation list. POST /auth/logout-all sweeps every active session for the account.
        - type: API Key
          steps:
            - title: Create a key
              detail: Owner of the org calls POST /service-accounts/{account_id}/api-keys. The plaintext key is returned ONCE — store it client-side immediately.
            - title: Send X-API-Key on each call
              detail: 'Set X-API-Key: <key> as a header. The middleware accepts it on any protected endpoint.'
            - title: Or exchange for a JWT (recommended)
              detail: POST /auth/token-exchange with X-API-Key returns a 1-hour service JWT (principal_type=service). Verifiers can validate it locally with /auth/public-key, no per-request roundtrip to account-service.
            - title: Revoke when leaked
              detail: POST /api-keys/{api_key_id}/revoke marks the key dead immediately. Use DELETE /api-keys/{api_key_id} for permanent removal.
        - type: JWT
          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 <token>` 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 & Sessions
      description: |
        Per-user endpoints: profile, password change, sessions, activity feed.
        All require an authenticated principal — most are JWT-only because
        they read or mutate session-bound state.
      endpoints:
        - name: Get my profile
          method: GET
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Return the authenticated principal's profile (account details +
            email verification status). Works for both human and service
            principals.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "account_type": "human",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "avatar_url": "",
                "timezone": "Asia/Jakarta",
                "language": "id",
                "email_verified": true,
                "bio": "",
                "metadata": {},
                "created_at": 1735689600
              }
            }
        - name: Update my profile
          method: PUT
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Patch the authenticated user's profile. Omitted fields are left
            untouched. `model_config` and `capabilities` apply to service
            accounts only.
          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: |
            {
              "display_name": "Jane D.",
              "bio": "Building things.",
              "timezone": "Asia/Singapore",
              "language": "en"
            }
          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 } }
        - name: Change password
          method: POST
          path: /api/v1/me/password
          auth: JWT Bearer
          description: |
            Change the authenticated user's password. Verifies the current
            password, then revokes every active session **except** the one
            making this request (so the user isn't logged out of the tab
            they're sitting in). Service accounts cannot call this — they
            have no password.
            **Rate limit:** 5 / minute / IP.
          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: |
            {
              "current_password": "OldPass123!",
              "new_password": "NewSecurePass456!"
            }
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: List my sessions
          method: GET
          path: /api/v1/me/sessions
          auth: JWT Bearer
          permission: read:sessions
          description: |
            List every active session for the authenticated user. The session
            backing the current request is flagged with `is_current: true`.
            Also available at `/api/v1/sessions` (legacy path).
          example_response: |
            {
              "success": true,
              "data": {
                "sessions": [
                  {
                    "session_id": "550e8400-...",
                    "ip_address": "203.0.113.42",
                    "user_agent": "Mozilla/5.0 ...",
                    "device_fingerprint": "",
                    "created_at": 1735689600,
                    "last_used_at": 1735693200,
                    "expires_at": 1738281600,
                    "is_current": true
                  }
                ]
              }
            }
        - name: Revoke a session
          method: DELETE
          path: /api/v1/me/sessions/{session_id}
          auth: JWT Bearer
          description: |
            Revoke a specific session by id. The user can revoke any of their
            own sessions, including the current one (which terminates the
            calling session). Also available at `/api/v1/sessions/{session_id}`.
          example_response: |
            { "success": true, "data": null }
        - name: My activity feed
          method: GET
          path: /api/v1/me/activities
          auth: JWT Bearer
          description: |
            Audit-log entries where the authenticated user is the actor. Paged.
            The `actor_id` filter is forced server-side to the calling user, so
            callers cannot pivot to another user's feed.

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

            New actions may be added without a docs revision; consumers should
            tolerate unknown values (display as-is, don't reject).
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page number.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
            - name: since
              type: string
              description: ISO-8601 — filter `created_at >= since`.
            - name: until
              type: string
              description: ISO-8601 — filter `created_at < until`.
            - name: action
              type: string
              description: Dot-notation filter, e.g. `member.invited`.
            - name: org_id
              type: string
              description: Optional org scope. Org-side activities are also available at `/organizations/{org_id}/activities`.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "550e8400-...",
                    "actor_id": "550e8400-...",
                    "actor_email": "user@example.com",
                    "actor_display_name": null,
                    "organization_id": "550e8400-...",
                    "organization_name": null,
                    "action": "organization.created",
                    "target_type": "organization",
                    "target_id": "550e8400-...",
                    "target_label": null,
                    "metadata": {},
                    "ip_address": "203.0.113.42",
                    "user_agent": "Mozilla/5.0 ...",
                    "created_at": 1735689600
                  }
                ],
                "total": 42,
                "page": 1,
                "per_page": 20,
                "total_pages": 3
              }
            }
    - id: auth
      title: Authentication
      description: |
        Endpoints under `/api/v1/auth`. Issue, rotate, revoke, and verify
        credentials for both human users and service accounts. All endpoints
        are rate-limited per IP (limits noted on each endpoint).
      endpoints:
        - name: Register account
          method: POST
          path: /api/v1/auth/register
          auth: none
          description: |
            Create a new human account. A personal organization is created in
            the same transaction with the registrant as owner. A verification
            email is queued (event `email.requested`). Auto-logs in: returns
            access + refresh tokens and sets the SSO refresh cookie.
            **Rate limit:** 3 / minute / IP.
          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: |
            {
              "email": "user@example.com",
              "password": "SecurePass123!",
              "display_name": "Jane Doe",
              "timezone": "Asia/Jakarta",
              "language": "id"
            }
          example_response: |
            {
              "success": true,
              "code": 2010,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@example.com",
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
                "expires_in": 900,
                "personal_org": {
                  "org_id": "550e8400-e29b-41d4-a716-446655440001",
                  "name": "Jane Doe's Personal",
                  "slug": "jane-doe-personal-550e8400",
                  "status": "active",
                  "owner_account_id": "550e8400-e29b-41d4-a716-446655440000",
                  "plan": "free",
                  "created_at": 1735689600,
                  "updated_at": 1735689600,
                  "my_role": "owner",
                  "my_permissions": ["*"]
                }
              }
            }
        - name: Login
          method: POST
          path: /api/v1/auth/login
          auth: none
          description: |
            Authenticate with email + password. Returns access + refresh tokens
            and the user's full organization list. Sets refresh + CSRF cookies.
            Brute-force protection: per-IP and per-account counters with
            progressive delay and lockout.
            **Rate limit:** 5 / minute / IP.
          body:
            - name: email
              type: string
              required: true
              description: Account email.
            - name: password
              type: string
              required: true
              description: Account password.
          example_body: |
            {
              "email": "user@example.com",
              "password": "SecurePass123!"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-e29b-41d4-a716-446655440000",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
                "expires_in": 900,
                "organizations": [
                  { "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-...", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] }
                ],
                "current_org_id": "550e8400-e29b-41d4-a716-446655440001",
                "requires_email_verification": false,
                "csrf_token": "f3a1c0..."
              }
            }
        - name: Refresh tokens
          method: POST
          path: /api/v1/auth/refresh
          auth: none
          description: |
            Exchange a refresh token for a new access + refresh pair. The old
            refresh token's hash is rotated server-side; the old JTI is added
            to the revocation list once rotation is persisted. Replaying the
            old refresh token after this call kills the session (reuse
            detection). Refresh token may be in the body or in the
            `refresh_token` cookie (SSO flow).
          body:
            - name: refresh_token
              type: string
              description: Optional in body. Falls back to the `refresh_token` cookie when omitted.
          example_body: |
            { "refresh_token": "eyJhbGciOiJSUzI1NiIs..." }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "...",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "eyJhbGciOiJSUzI1NiIs... (new)",
                "refresh_token": "eyJhbGciOiJSUzI1NiIs... (new)",
                "expires_in": 900,
                "organizations": [ ... ],
                "current_org_id": "...",
                "requires_email_verification": false,
                "csrf_token": "..."
              }
            }
        - name: Logout (current session)
          method: POST
          path: /api/v1/auth/logout
          auth: JWT Bearer
          description: |
            Revoke the current session. The access token's JTI is also added
            to the revocation list so a token stolen before logout stops
            validating immediately. Cookies are always cleared on the response,
            even if the server-side revoke fails. **Service principals cannot
            call this endpoint** — they have no session.
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Logout from all devices
          method: POST
          path: /api/v1/auth/logout-all
          auth: JWT Bearer
          description: |
            Revoke every active session for the calling account. Useful for
            "I lost my phone" or post-password-change cleanups. Cookies are
            rotated/cleared on the response.
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Resend verification email
          method: POST
          path: /api/v1/auth/resend-verification
          auth: none
          description: |
            Re-emit the verification email for an unverified account. Always
            returns success regardless of whether the email exists, to avoid
            leaking account presence. **Rate limit:** 3 / minute / IP.
          body:
            - name: email
              type: string
              required: true
              description: Email to resend verification to.
          example_body: |
            { "email": "user@example.com" }
          example_response: |
            { "success": true, "data": { "message": "If the account exists and is unverified, a new verification email has been sent." } }
        - name: Request password reset
          method: POST
          path: /api/v1/auth/password-reset
          auth: none
          description: |
            Initiate a password reset. Always returns success, even when the
            email is unknown, to prevent email enumeration. Generates a
            1-hour JWT and emits an `email.requested` event with the
            reset link.
          body:
            - name: email
              type: string
              required: true
              description: Email of the account to reset.
          example_body: |
            { "email": "user@example.com" }
          example_response: |
            { "success": true, "data": null }
        - name: Confirm password reset
          method: POST
          path: /api/v1/auth/password-reset/confirm
          auth: none
          description: |
            Complete a password reset using the JWT from the email link.
            Validates the token (signature + jti not previously used), updates
            the password hash, and revokes all sessions for the account.
          body:
            - name: token
              type: string
              required: true
              description: Reset token from the email link (JWT).
            - name: new_password
              type: string
              required: true
              description: Same complexity rules as registration.
          example_body: |
            {
              "token": "eyJhbGciOiJSUzI1NiIs...",
              "new_password": "NewSecurePass456!"
            }
          example_response: |
            { "success": true, "data": { "success": true } }
        - name: Verify access token
          method: POST
          path: /api/v1/auth/verify
          auth: none
          description: |
            Introspect an access token (signature + exp + revocation). Used by
            downstream services that prefer a server-side check over local
            public-key verification. Returns the claims **as-is**: permissions
            come from the token, not re-derived from current state. Use
            `/auth/refresh` if you need fresh permissions.
            **Rate limit:** 100 / minute / IP.
          body:
            - name: token
              type: string
              required: true
              description: Access token to introspect.
          example_body: |
            { "token": "eyJhbGciOiJSUzI1NiIs..." }
          example_response: |
            {
              "success": true,
              "data": {
                "valid": true,
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "permissions": ["*"],
                "expires_at": 1735693200,
                "session_id": "550e8400-..."
              }
            }
        - name: Whoami
          method: GET
          path: /api/v1/auth/whoami
          auth: JWT Bearer
          description: |
            Return the principal identity carried by the request — exactly what
            the middleware extracted, with no extra DB lookups. Useful for SDKs
            and gateways debugging auth wiring.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "principal_type": "human",
                "session_id": "550e8400-...",
                "current_org_id": "550e8400-...",
                "organizations": [ { "id": "550e8400-...", "role": "owner" } ],
                "permissions": ["*"]
              }
            }
        - name: Get public key (RS256)
          method: GET
          path: /api/v1/auth/public-key
          auth: none
          description: |
            Return the RS256 public key (PEM) for local JWT verification by
            downstream services. Cache this for the lifetime of `expires_at`
            (default 90 days).

            **Key rotation strategy:**
            - Each issued JWT carries a `kid` claim in its header naming the
              key that signed it (e.g. `key-2026-03-01-v1`).
            - Verifiers should cache by `kid`. On a cache miss (unfamiliar
              `kid`), re-fetch this endpoint to learn the new key.
            - During rotation, account-service serves the **new** key here
              but continues to **accept** tokens signed by the previous key
              until those tokens naturally exp. Verifiers should retain the
              previous key entry until its tokens have aged out.
            - `expires_at` is the rough cache horizon — refresh before that
              even without a `kid` mismatch to pick up scheduled rotations.
          example_response: |
            {
              "success": true,
              "data": {
                "key_id": "key-2026-03-01-v1",
                "algorithm": "RS256",
                "public_key": "-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----\n",
                "expires_at": 1743465600
              }
            }
        - name: Exchange API key for JWT
          method: POST
          path: /api/v1/auth/token-exchange
          auth: API Key
          description: |
            Validate the supplied `X-API-Key` header and return a 1-hour
            service JWT (`principal_type: service`). Lets a service principal
            authenticate downstream calls with a verifiable JWT instead of
            re-presenting the API key on every hop. The JWT carries the
            service account's capabilities as `permissions` and `nil` as `sid`.
          example_response: |
            {
              "success": true,
              "data": {
                "access_token": "eyJhbGciOiJSUzI1NiIs...",
                "token_type": "Bearer",
                "expires_in": 3600,
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "permissions": ["museum:read", "artifact:write"],
                "principal_type": "service"
              }
            }
    - id: operations
      title: Operations
      description: |
        Operational endpoints — health probes for orchestrators (Kubernetes,
        Nomad, load balancers) and Prometheus scraping. Not part of the
        product API surface; safe to firewall off from public traffic.
      endpoints:
        - name: Liveness probe
          method: GET
          path: /health
          auth: none
          description: |
            Lightweight liveness check. Returns 200 with a small JSON when
            the process is up. Does not touch DB / Redis / RabbitMQ — use
            `/health/detailed` for that. Suitable for k8s `livenessProbe`.
          example_response: |
            { "status": "ok" }
        - name: Detailed health
          method: GET
          path: /health/detailed
          auth: none
          description: |
            Per-dependency health snapshot. Probes Postgres, Redis, and
            RabbitMQ and returns each dependency's state. A degraded
            dependency yields HTTP 503 so a load balancer can drop the pod
            from rotation. Heavier than `/health` — call from
            `readinessProbe`, not `livenessProbe`.
          example_response: |
            {
              "status": "ok",
              "dependencies": {
                "postgres": { "status": "ok", "latency_ms": 3 },
                "redis":    { "status": "ok", "latency_ms": 1 },
                "rabbitmq": { "status": "ok", "latency_ms": 5 }
              },
              "version": "1.0.0",
              "uptime_seconds": 12345
            }
        - name: Prometheus metrics
          method: GET
          path: /metrics
          auth: none
          description: |
            Prometheus exposition format. Includes HTTP request counters /
            latency histograms (sanitized path labels — id segments collapsed
            to `:id` so cardinality stays bounded), gRPC counters, brute-force
            counters, rate-limiter counters, and Redis connection pool stats.
            Should be firewalled to the metrics-collector subnet, not
            exposed publicly.
          example_response: |
            # HELP http_requests_total Total HTTP requests
            # TYPE http_requests_total counter
            http_requests_total{method="GET",path="/api/v1/me",status="200"} 142
            ...
    - id: organizations
      title: Organizations
      description: |
        Multi-tenant organization management — CRUD, switching context,
        invitations, role assignment, and per-org activity feed. Most
        endpoints require a permission within the targeted organization.
      endpoints:
        - name: Check organization (public)
          method: GET
          path: /api/v1/organizations/{org_id}/check
          auth: none
          description: |
            Lightweight existence + display info for an organization. Used by
            frontends to validate org-scoped invitation links before login.
            Returns minimal data — no member or role info is leaked.
          example_response: |
            {
              "success": true,
              "data": {
                "org_id": "550e8400-...",
                "name": "Acme Corp",
                "created_at": 1735689600
              }
            }
        - name: List my organizations
          method: GET
          path: /api/v1/organizations
          auth: JWT Bearer
          permission: read:organizations
          description: |
            List organizations the authenticated principal is a member of.
            Each entry includes the principal's role in that org.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page number.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 }
                ],
                "total": 1,
                "page": 1,
                "per_page": 20,
                "total_pages": 1
              }
            }
        - name: Create organization
          method: POST
          path: /api/v1/organizations
          auth: JWT Bearer
          description: |
            Create a new organization with the caller as owner. Slug is
            auto-derived from the name when omitted; if the slug collides
            a 4xx is returned and the caller should retry with an explicit
            slug.
          body:
            - name: name
              type: string
              required: true
              description: Display name of the organization.
            - name: slug
              type: string
              description: URL-safe slug. Auto-generated from name when omitted.
          example_body: |
            { "name": "Acme Corp", "slug": "acme-corp" }
          example_response: |
            {
              "success": true,
              "data": {
                "org_id": "550e8400-...",
                "name": "Acme Corp",
                "slug": "acme-corp",
                "description": null,
                "status": "active",
                "owner_id": "550e8400-...",
                "created_at": 1735689600,
                "updated_at": 1735689600,
                "member_count": 1
              }
            }
        - name: Get organization
          method: GET
          path: /api/v1/organizations/{org_id}
          auth: JWT Bearer
          description: Fetch a single organization by id. Caller must be a member.
          example_response: |
            { "success": true, "data": { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 } }
        - name: Update organization
          method: PUT
          path: /api/v1/organizations/{org_id}
          auth: JWT Bearer
          permission: organizations:update
          description: Update the organization's name or slug.
          body:
            - name: name
              type: string
              description: New display name.
            - name: slug
              type: string
              description: New URL-safe slug.
          example_body: |
            { "name": "Acme Inc.", "slug": "acme-inc" }
          example_response: |
            { "success": true, "data": { "org_id": "...", "name": "Acme Inc.", "slug": "acme-inc", "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735693200, "member_count": 12 } }
        - name: Delete organization
          method: DELETE
          path: /api/v1/organizations/{org_id}
          auth: JWT Bearer
          permission: '*'
          description: |
            Soft-delete (archive) an organization. Owner-only — protected by the
            owner role's `*` wildcard rather than a per-action permission. Members
            lose access on next token rotation; all sessions remain intact for
            other orgs the user belongs to.
          example_response: |
            { "success": true, "data": null }
        - name: Switch active organization
          method: POST
          path: /api/v1/organizations/{org_id}/switch
          auth: JWT Bearer
          description: |
            Issue a new token pair bound to a different organization. The
            existing session id is reused, so refresh continuity is preserved.
            The caller must already be a member of the target org.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "...",
                "email": "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "eyJ...new...",
                "refresh_token": "eyJ...new...",
                "expires_in": 900,
                "organizations": [ { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] } ],
                "current_org_id": "550e8400-...new..."
              }
            }
        - name: Leave organization
          method: POST
          path: /api/v1/organizations/{org_id}/leave
          auth: JWT Bearer
          description: |
            Remove the caller's own membership. Owners cannot leave — transfer
            ownership first or delete the organization.
          example_response: |
            { "success": true, "data": null }
        - name: List organization members
          method: GET
          path: /api/v1/organizations/{org_id}/members
          auth: JWT Bearer
          description: Paged member list with role + permissions.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "account_id": "...", "email": "user@example.com", "display_name": "Jane Doe", "role": "owner", "permissions": ["*"], "joined_at": 1735689600 }
                ],
                "total": 1,
                "page": 1,
                "per_page": 20,
                "total_pages": 1
              }
            }
        - name: Invite member
          method: POST
          path: /api/v1/organizations/{org_id}/members
          auth: JWT Bearer
          permission: members:invite
          description: |
            Send an invitation to an email address. If a role is omitted the
            org's default-member role is assigned on accept. The recipient
            gets an email via the `email.requested` event.
          body:
            - name: email
              type: string
              required: true
              description: Invitee email.
            - name: role_id
              type: string
              description: Role id to assign on accept. Defaults to org default member role.
          example_body: |
            { "email": "newmember@example.com", "role_id": "550e8400-..." }
          example_response: |
            {
              "success": true,
              "data": {
                "invitation_id": "550e8400-...",
                "message": "Invitation sent to newmember@example.com"
              }
            }
        - name: Remove member
          method: DELETE
          path: /api/v1/organizations/{org_id}/members/{account_id}
          auth: JWT Bearer
          permission: members:remove
          description: Remove a member from the org. Cannot remove the owner.
          example_response: |
            { "success": true, "data": null }
        - name: Update member role
          method: PUT
          path: /api/v1/organizations/{org_id}/members/{account_id}/role
          auth: JWT Bearer
          permission: members:update_role
          description: |
            Reassign a member to a different role. New role takes effect on
            their next token rotation.
          body:
            - name: role_id
              type: string
              required: true
              description: Target role id.
          example_body: |
            { "role_id": "550e8400-..." }
          example_response: |
            { "success": true, "data": null }
        - name: List roles
          method: GET
          path: /api/v1/organizations/{org_id}/roles
          auth: JWT Bearer
          description: List roles defined in the org (system + custom).
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "role_id": "...", "name": "owner", "description": "Organization owner with full access", "permissions": ["*"], "is_system": true, "created_at": 1735689600, "updated_at": 1735689600 }
                ],
                "total": 1,
                "page": 1,
                "per_page": 20,
                "total_pages": 1
              }
            }
        - name: Create role
          method: POST
          path: /api/v1/organizations/{org_id}/roles
          auth: JWT Bearer
          permission: roles:create
          description: |
            Create a custom role. Permissions are free-form strings (the
            permission service interprets them); see the Permissions section
            for the canonical names used by account-service itself.
          body:
            - name: name
              type: string
              required: true
              description: Display name (unique within the org).
            - name: description
              type: string
              description: Optional description.
            - name: permissions
              type: string[]
              required: true
              description: Permission strings granted by this role.
          example_body: |
            {
              "name": "auditor",
              "description": "Read-only access to audit logs",
              "permissions": ["read:organizations", "audit_logs:read"]
            }
          example_response: |
            { "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
        - name: Get role
          method: GET
          path: /api/v1/organizations/{org_id}/roles/{role_id}
          auth: JWT Bearer
          description: Fetch a single role.
          example_response: |
            { "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
        - name: Update role
          method: PUT
          path: /api/v1/organizations/{org_id}/roles/{role_id}
          auth: JWT Bearer
          permission: roles:update
          description: Patch a role. System roles (e.g. `owner`) are read-only.
          body:
            - name: name
              type: string
              description: New name.
            - name: description
              type: string
              description: New description (pass null to clear).
            - name: permissions
              type: string[]
              description: New permissions list (replaces, not merges).
          example_body: |
            { "permissions": ["read:organizations", "audit_logs:read", "members:invite"] }
          example_response: |
            { "success": true, "data": { "role_id": "...", "name": "auditor", "permissions": ["read:organizations","audit_logs:read","members:invite"], "is_system": false, "created_at": 1735689600, "updated_at": 1735693200 } }
        - name: Delete role
          method: DELETE
          path: /api/v1/organizations/{org_id}/roles/{role_id}
          auth: JWT Bearer
          permission: roles:delete
          description: |
            Delete a custom role. Fails if any member is currently assigned to
            it — reassign them first. System roles cannot be deleted.
          example_response: |
            { "success": true, "data": null }
        - name: Organization activity feed
          method: GET
          path: /api/v1/organizations/{org_id}/activities
          auth: JWT Bearer
          permission: audit_logs:read
          description: |
            Org-wide audit feed. Same shape as `/me/activities` but scoped to
            all actors within the organization. Requires `audit_logs:read`
            permission in the org.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
            - name: since
              type: string
              description: ISO-8601.
            - name: until
              type: string
              description: ISO-8601.
            - name: action
              type: string
              description: Dot-notation action filter.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "id": "...", "actor_id": "...", "actor_email": "user@example.com", "actor_display_name": null, "organization_id": "...", "organization_name": null, "action": "member.invited", "target_type": "member", "target_id": "...", "target_label": null, "metadata": {}, "ip_address": "203.0.113.42", "user_agent": "...", "created_at": 1735689600 }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
    - id: service-accounts
      title: Service Accounts
      description: |
        Non-human principals scoped to one organization. They authenticate
        via API keys (`X-API-Key`) or service JWTs obtained from
        `/auth/token-exchange`. Service accounts cannot log in interactively.
      endpoints:
        - name: List service accounts
          method: GET
          path: /api/v1/service-accounts
          auth: JWT Bearer
          permission: service_accounts:read
          description: |
            List service accounts in an organization. The org is supplied as a
            query parameter (not a path segment) because callers commonly
            enumerate across orgs they manage.
          query_params:
            - name: organization_id
              type: string
              required: true
              description: Organization id to filter by.
            - name: status
              type: string
              description: One of `active`, `paused`, `archived`.
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "account_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read"], "last_used_at": 1735693200, "created_at": 1735689600 }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
        - name: Create service account
          method: POST
          path: /api/v1/service-accounts
          auth: JWT Bearer
          permission: service_accounts:create
          description: |
            Create a new service account scoped to an organization. The
            returned account has no API key yet — call
            `POST /service-accounts/{account_id}/api-keys` afterwards.
          body:
            - name: organization_id
              type: string
              required: true
              description: Organization to attach the service account to.
            - name: display_name
              type: string
              required: true
              description: Human-readable name.
            - name: description
              type: string
              description: Optional description.
            - name: capabilities
              type: string[]
              required: true
              description: Capabilities granted (resource:action format).
            - name: allowed_tools
              type: string[]
              required: true
              description: Subset of tool ids the service may invoke. Empty list = none.
            - name: rate_limit_per_minute
              type: integer
              description: Per-key rate cap; clamped to 1-10000. Default 60.
          example_body: |
            {
              "organization_id": "550e8400-...",
              "display_name": "Render bot",
              "description": "Generates thumbnail previews",
              "capabilities": ["museum:read", "artifact:write"],
              "allowed_tools": ["thumbnailer"],
              "rate_limit_per_minute": 120
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "display_name": "Render bot",
                "description": "Generates thumbnail previews",
                "status": "active",
                "capabilities": ["museum:read", "artifact:write"],
                "allowed_tools": ["thumbnailer"],
                "rate_limit_per_minute": 120,
                "last_used_at": null,
                "created_at": 1735689600,
                "updated_at": 1735689600
              }
            }
        - name: Get service account
          method: GET
          path: /api/v1/service-accounts/{account_id}
          auth: JWT Bearer
          description: Fetch one service account by id.
          example_response: |
            {
              "success": true,
              "data": { "account_id": "...", "organization_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "rate_limit_per_minute": 120, "last_used_at": 1735693200, "created_at": 1735689600, "updated_at": 1735689600 }
            }
        - name: Update service account
          method: PUT
          path: /api/v1/service-accounts/{account_id}
          auth: JWT Bearer
          permission: service_accounts:update
          description: |
            Patch metadata, capabilities, or rate limit. Permission changes
            take effect on the next `/auth/token-exchange` call (or
            immediately for raw `X-API-Key` flows).
          body:
            - name: organization_id
              type: string
              required: true
              description: Org id (used as a safety check that the caller scoped the request correctly).
            - name: display_name
              type: string
              description: New display name.
            - name: description
              type: string
              description: New description.
            - name: capabilities
              type: string[]
              description: Replacement capabilities list.
            - name: allowed_tools
              type: string[]
              description: Replacement allowed-tools list.
            - name: rate_limit_per_minute
              type: integer
              description: New rate limit.
          example_body: |
            {
              "organization_id": "550e8400-...",
              "display_name": "Render bot v2",
              "rate_limit_per_minute": 240
            }
          example_response: |
            { "success": true, "data": { "account_id": "...", "display_name": "Render bot v2", "rate_limit_per_minute": 240, "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "organization_id": "...", "created_at": 1735689600, "updated_at": 1735693200 } }
        - name: Archive service account
          method: DELETE
          path: /api/v1/service-accounts/{account_id}
          auth: JWT Bearer
          permission: service_accounts:archive
          description: |
            Soft-delete (archive) a service account. All of its API keys are
            revoked. Existing service JWTs continue to validate until their
            natural exp (max 1 hour); add the JTI to the revocation list via
            re-issued tokens to shorten that further.
          example_response: |
            { "success": true, "data": null }
        - name: Pause service account
          method: POST
          path: /api/v1/service-accounts/{account_id}/pause
          auth: JWT Bearer
          permission: service_accounts:pause
          description: |
            Temporarily disable a service account. API keys stop
            authenticating until resumed; metadata and capabilities are
            preserved.
          example_response: |
            { "success": true, "data": null }
        - name: Resume service account
          method: POST
          path: /api/v1/service-accounts/{account_id}/resume
          auth: JWT Bearer
          permission: service_accounts:resume
          description: Re-enable a paused service account.
          example_response: |
            { "success": true, "data": null }
    - id: api-keys
      title: API Keys
      description: |
        API keys are issued under a service account and grant programmatic
        access. The plaintext key is returned **only at creation time** —
        account-service stores a sha-256 hash. Revoke leaked keys immediately
        via `/api-keys/{api_key_id}/revoke`.
      endpoints:
        - name: List API keys for service account
          method: GET
          path: /api/v1/service-accounts/{account_id}/api-keys
          auth: JWT Bearer
          permission: api_keys:read
          description: List metadata for every key on a service account. The plaintext is never returned here — only `key_prefix` for identification.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: 1-indexed page.
            - name: per_page
              type: integer
              default: "20"
              description: Page size, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "api_key_id": "...", "key_prefix": "ak_live_a1b2", "name": "prod render bot", "permissions": ["museum:read"], "expires_at": 1751155200, "last_used_at": 1735693200, "created_at": 1735689600, "is_revoked": false, "is_expired": false }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
        - name: Create API key
          method: POST
          path: /api/v1/service-accounts/{account_id}/api-keys
          auth: JWT Bearer
          permission: api_keys:create
          description: |
            Mint a new API key. **The `key` field in the response is the only
            time the plaintext is ever exposed** — store it client-side
            immediately. Subsequent calls see only the hash and `key_prefix`.
          body:
            - name: name
              type: string
              description: Free-form label for the key.
            - name: permissions
              type: string[]
              required: true
              description: Permission strings granted to this key (subset of the service account's capabilities).
            - name: expires_in_days
              type: integer
              description: TTL in days. Omit for no expiry.
          example_body: |
            {
              "name": "prod render bot",
              "permissions": ["museum:read"],
              "expires_in_days": 180
            }
          example_response: |
            {
              "success": true,
              "data": {
                "api_key_id": "...",
                "key": "ak_live_a1b2c3d4e5f6...REDACTED_RAW_KEY...",
                "key_prefix": "ak_live_a1b2",
                "name": "prod render bot",
                "permissions": ["museum:read"],
                "expires_at": 1751155200,
                "created_at": 1735689600
              }
            }
        - name: Revoke API key
          method: POST
          path: /api/v1/api-keys/{api_key_id}/revoke
          auth: JWT Bearer
          permission: api_keys:revoke
          description: |
            Mark a key as revoked. The key continues to exist for audit
            purposes but stops authenticating immediately.
          body:
            - name: reason
              type: string
              description: Optional human-readable reason recorded on the audit log.
          example_body: |
            { "reason": "leaked in public repo" }
          example_response: |
            { "success": true, "data": null }
        - name: Delete API key
          method: DELETE
          path: /api/v1/api-keys/{api_key_id}
          auth: JWT Bearer
          permission: api_keys:delete
          description: |
            Permanently remove a key (hard delete). Prefer revoke for
            auditable history.
          example_response: |
            { "success": true, "data": null }
    - id: folders
      title: Folders
      description: |
        Hierarchical folder tree per owner. Root has `parent_id = null`
        and `depth = 0`. Sibling names must be unique under the same
        parent. Maximum nesting depth is 100.
      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
            provided, the folder is nested under that parent and inherits
            a `depth` of `parent.depth + 1`. The DB enforces sibling-name
            uniqueness — duplicates return HTTP 409.
          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: |
            {
              "name": "Vacation Photos",
              "parent_id": "4f2c…",
              "visibility": "organization"
            }
          example_response: |
            {
              "id": "f9a1…",
              "name": "Vacation Photos",
              "parent_id": "4f2c…",
              "path": "Documents/Vacation Photos",
              "depth": 2,
              "created_at": "2026-04-30T08:14:22Z"
            }
        - name: List Folders
          method: GET
          path: /folders
          auth: JWT
          permission: folder:list
          description: |
            Lists folders owned by the caller. Default scope is the
            current parent's children (or roots when `parent_id` is
            omitted). Set `recursive=true` to flatten the entire subtree.
          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
            `trg_update_folder_path` rewrites every descendant's `path`
            inside the same transaction. Root folders cannot be renamed.
          body:
            - name: name
              type: string
              required: true
              description: New folder name.
          example_body: |
            { "name": "Archive 2026" }
        - 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
            a single paginated response. Useful for a Drive-style file
            browser UI.
          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
            be empty (no media, no subfolders) and returns HTTP 412 if it
            is not. With `force=true` the entire subtree is purged: for
            every media in the subtree, all derived variant blobs (medium
            /high/low) are removed first, then the original blob, then
            the media row; finally the folder rows cascade. Variant
            cleanup failures are logged but never abort the purge — same
            reasoning as the single-media delete endpoint.
          query_params:
            - name: force
              type: bool
              default: "false"
              description: Recursively delete every file and subfolder in the subtree.
          example_response: |
            {
              "deleted": true,
              "folder_id": "f9a1…",
              "deleted_items": { "folders": 3, "media": 17 }
            }
    - id: health
      title: Health
      description: |
        Liveness probe. No authentication, no rate limiting, safe for
        uptime checkers.
      endpoints:
        - name: Health Check
          method: GET
          path: /health
          auth: none
          description: |
            Returns a static JSON payload indicating the service process is
            up. Does not check downstream dependencies (DB, storage).
          example_response: |
            {
              "status": "healthy",
              "service": "media-service"
            }
    - id: media
      title: Media
      description: |
        Read, list, rename, change visibility on, and delete media files.
        Visibility rules (`public`/`organization`/`private`) gate read
        access; ownership and `media:*` permissions gate writes.
      endpoints:
        - name: List Media
          method: GET
          path: /media
          auth: JWT
          permission: media:list
          description: |
            Paginated listing. Supports filtering by folder, visibility,
            file type, and a search term applied to `original_name`. The
            search input is parameterised at the SQL layer.
          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: |
            {
              "items": [
                {
                  "id": "9e1c…",
                  "original_name": "photo.jpg",
                  "mime_type": "image/jpeg",
                  "size_bytes": 248122,
                  "visibility": "organization",
                  "folder_id": "4f2c…",
                  "created_at": "2026-04-30T08:14:22Z"
                }
              ],
              "total": 1,
              "page": 1,
              "per_page": 20,
              "total_pages": 1,
              "quota": { "used_bytes": 248122, "max_bytes": 1073741824 }
            }
        - name: Download Media
          method: GET
          path: /media/{id}
          auth: JWT
          permission: media:download
          description: |
            Streams the media bytes back with the original `Content-Type`
            and `Content-Disposition`. Visibility rules apply: `public` is
            unauthenticated, `organization` requires same-org JWT,
            `private` requires the owner's JWT.

            **Variant selection (`?variant=`)** — for image uploads, the
            service stores the original plus optional re-encoded variants
            at decreasing quality. Use the query param to pick which one
            to download:

              * `original` (default) — uncompressed bytes, format as
                uploaded. SHA-256 matches the upload-time checksum.
              * `high` (q=90) — light recompression, near-original quality.
              * `medium` (q=75) — balanced; suitable for previews.
              * `low` (q=50) — aggressive; suitable for thumbnails.

            Aliases also accepted: `orig` / `raw` for original, `hi` for
            high, `med` for medium, `lo` for low. Invalid values return
            HTTP 400 with the allowed list.

            The `medium` variant is generated eagerly on upload (when
            `IMAGE_EAGER_MEDIUM=true`); other levels are generated lazily
            on first request and cached for subsequent ones. Generation
            is best-effort — if the original cannot be re-encoded
            (corrupt image, unsupported format), the request fails with
            HTTP 500 rather than silently returning the original.

            The `Content-Disposition` filename is always the upload's
            display name regardless of variant — the tier is invisible
            to the user-facing UI.

            **Range / streaming (video only)** — when the underlying
            MIME is `video/*`, the response includes `Accept-Ranges: bytes`
            and the endpoint honours `Range:` request headers. This lets
            the browser's HTML5 `<video>` element scrub mid-file without
            downloading from byte 0. Supported forms:

              * `Range: bytes=N-M`  → 206 Partial Content
              * `Range: bytes=N-`   → 206 from N to EOF
              * `Range: bytes=-M`   → 206 last M bytes
              * Multi-range / unparseable → ignored, full 200 returned
              * Out-of-bounds → 416 with `Content-Range: bytes */<total>`

            Non-video MIMEs do NOT advertise `Accept-Ranges` and ignore
            any `Range:` header — they always stream the full body.
          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
            want size/MIME/checksum without paying for the download.
        - name: Rename Media
          method: PATCH
          path: /media/{id}
          auth: JWT
          permission: media:update
          description: |
            Updates the user-facing `original_name`. The on-disk filename
            is unchanged; only the display name is rewritten.
          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" }
        - 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
            to shared links; existing tokens keep working until they
            expire or are deleted.
          body:
            - name: visibility
              type: string
              required: true
              description: '`public` | `organization` | `private`.'
          example_body: |
            { "visibility": "private" }
        - name: Delete Media
          method: DELETE
          path: /media/{id}
          auth: JWT
          permission: media:delete
          description: |
            Removes every blob (original + each derived variant) from
            storage, then deletes the `media` row. The matching
            `media_variants` rows go away via FK cascade. A
            `StorageError::NotFound` is tolerated per file so retries
            on a partially-failed delete are idempotent.

            A failure cleaning a single variant blob is logged but does
            not abort the delete — the user's media still goes away;
            worst case is one orphan blob on disk. Operators can sweep
            orphans by walking the storage directory and reconciling
            against `media_variants.storage_path`.
          example_response: |
            {
              "deleted": true,
              "media_id": "9e1c…",
              "filename": "9e1c….jpg",
              "freed_bytes": 248122
            }
    - id: recent
      title: Recent Files
      description: |
        Per-user "recently accessed" history. Tracks accesses pushed
        from the client and exposes a chronologically-ordered list. The
        access event is recorded asynchronously of the underlying
        download, so a missing track call does not block playback.
      endpoints:
        - name: List Recent Files
          method: GET
          path: /recent
          auth: JWT
          permission: media:list
          description: |
            Returns the caller's most recently accessed media,
            newest-first. Capped at the value the client requests
            (default applied server-side).
          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
            the row level — repeated tracks update the existing row's
            timestamp instead of inserting duplicates.
          body:
            - name: access_type
              type: string
              description: Free-form classifier (e.g. `view`, `download`, `preview`).
          example_body: |
            { "access_type": "view" }
        - name: Clear History
          method: DELETE
          path: /recent
          auth: JWT
          permission: media:list
          description: |
            Wipes the caller's entire recent-files history. The
            underlying media files are untouched.
    - id: shared-links
      title: Shared Links
      description: |
        Generate opaque token URLs that grant external (often
        anonymous) access to a single media file. Supports password
        gating (bcrypt), expiry, and a hard download cap.
      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
            must own the media or have download access through it.
            Visibility defaults to `restricted` (creator-only) — set
            `public` for anonymous access or `organization` to gate by
            the caller's org.
          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: |
            {
              "visibility": "public",
              "password": "spring-2026",
              "expires_in_hours": 168,
              "max_downloads": 50
            }
          example_response: |
            {
              "id": "ab12…",
              "token": "8f3c2a1b…",
              "share_url": "https://media.example.com/share/8f3c2a1b…",
              "visibility": "public",
              "has_password": true,
              "expires_at": "2026-05-07T08:14:22Z",
              "max_downloads": 50,
              "created_at": "2026-04-30T08:14:22Z"
            }
        - name: Access Shared Link
          method: GET
          path: /share/{token}
          auth: none
          description: |
            Anonymous (or authenticated, for `restricted`/`organization`
            links) entry point. Streams the media bytes back. The
            download counter is incremented atomically.

            Failure modes (401/403/404):
              * link not found / revoked → 404
              * expired → 410
              * download cap reached → 429
              * password missing/wrong → 401
              * caller not eligible for `restricted` / `organization` → 403
          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.
          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`
            requests return 404. The underlying media file is
            unaffected.
    - id: starred
      title: Starred Files
      description: |
        Per-user bookmark list. Each star carries an optional free-form
        `notes` field. Operations are idempotent — re-starring a file
        updates the notes/timestamp instead of duplicating rows.
      endpoints:
        - name: List Starred
          method: GET
          path: /starred
          auth: JWT
          permission: media:list
          description: |
            Lists the caller's starred media in reverse-chronological
            order (most recently starred first). Each item includes the
            underlying media metadata plus `starred_at` and `notes`.
          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
            stored alongside the star. Calling this on an
            already-starred file is a no-error update.
          body:
            - name: notes
              type: string
              description: Optional annotation, surfaced in the listing.
          example_body: |
            { "notes": "Reference for the launch deck" }
        - name: Unstar File
          method: DELETE
          path: /media/{id}/star
          auth: JWT
          permission: media:list
          description: |
            Removes the star. Idempotent — unstarring an already-unstarred
            file returns success.
        - 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
            UIs that bind a single button to "star/unstar" without
            tracking current state client-side.
        - 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
            the caller has starred. Lets a list view annotate every row
            in one round-trip.
          body:
            - name: media_ids
              type: array<string>
              required: true
              description: Up to N media UUIDs to check at once.
          example_body: |
            { "media_ids": ["9e1c…", "f9a1…", "ab12…"] }
          example_response: |
            {
              "items": [
                { "media_id": "9e1c…", "is_starred": true },
                { "media_id": "f9a1…", "is_starred": false },
                { "media_id": "ab12…", "is_starred": true }
              ]
            }
    - id: upload
      title: Upload
      description: |
        Streamed multipart upload. The body is consumed once and written
        directly to disk; quota is enforced at the database CHECK
        constraint. The client-declared MIME is treated as a hint —
        magic-byte sniff wins on conflict.
      endpoints:
        - name: Upload Media
          method: POST
          path: /upload
          auth: JWT
          permission: media:upload
          description: |
            Streams a single multipart file part (field name `file`) into
            storage. The response carries the canonical media metadata,
            including the SHA-256 checksum and the storage path of the
            **original** bytes.

            **Validation order:** filename → MIME allow/block → quota
            fast-path → stream to disk (max-size enforced authoritatively)
            → magic-byte sniff → rebuild against policy → DB row.

            **Original is never overwritten.** For images, the upload
            pipeline additionally generates the `medium` variant (quality
            75) post-DB-save when `IMAGE_EAGER_MEDIUM=true`. `high` (90)
            and `low` (50) are generated lazily on first
            `GET /media/{id}?variant=…`. Variant generation is
            best-effort — failure here logs a warning but the upload
            itself succeeds with the original committed.

            On any post-stream failure the staged blob is removed; the
            invariant **"DB row ⇒ storage file exists"** is preserved.
          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
            # `visibility` knob only affects who can READ the file later,
            # not who can upload).

            # 1) Organization-scoped (default). Anyone in the same org as
            #    the uploader can download.
            curl -X POST 'http://localhost:3001/upload?folder_id=4f2c…&visibility=organization' \
              -H 'Authorization: Bearer <token>' \
              -F 'file=@/path/to/photo.jpg'

            # 2) Public. Anyone with the media id can download anonymously
            #    via GET /media/{id} — no token required on the read side.
            #    Useful for assets that are meant to be embedded on a
            #    public web page.
            curl -X POST 'http://localhost:3001/upload?visibility=public' \
              -H 'Authorization: Bearer <token>' \
              -F 'file=@/path/to/banner.jpg'

            # 3) Private. Only the uploader can download (and org admins
            #    if your deployment grants them override).
            curl -X POST 'http://localhost:3001/upload?visibility=private' \
              -H 'Authorization: Bearer <token>' \
              -F 'file=@/path/to/secret.pdf'
          example_response: |
            {
              "id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
              "filename": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11.jpg",
              "original_name": "photo.jpg",
              "mime_type": "image/jpeg",
              "size_bytes": 248122,
              "checksum": "sha256:7e1f…",
              "visibility": "organization",
              "folder_id": "4f2c…",
              "created_at": "2026-04-30T08:14:22Z"
            }
    - id: notification-rpc
      title: NotificationService (gRPC)
      description: |
        gRPC service for creating, querying, and managing notifications.
        Proto: `proto/notification.proto`, package `notification`.
      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
            ke provider sesuai `provider_type` (email/telegram/whatsapp/http/grpc/rabbitmq).
          body:
            - name: recipients
              type: array<Recipient>
              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<string,string>
            - name: max_retries
              type: int32
            - name: scheduled_at
              type: timestamp
              description: Untuk delayed delivery
          example_response: |
            {
              "notification_id": "ntf_01HXYZ...",
              "status": "queued",
              "created_at": "2026-05-13T21:00:00Z",
              "message": "queued for delivery"
            }
        - name: CreateBulkNotification
          method: gRPC
          path: /notification.NotificationService/CreateBulkNotification
          description: Kirim banyak notifikasi sekaligus dengan batch_id tunggal.
          body:
            - name: notifications
              type: array<BulkNotificationItem>
              required: true
              description: 'Setiap item: {recipients, content, priority, provider_type}'
          example_response: |
            {
              "batch_id": "btc_01HXYZ...",
              "notification_ids": ["ntf_...", "ntf_..."],
              "status": "queued",
              "total_count": 2
            }
        - 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: |
            {
              "id": "ntf_01HXYZ...",
              "recipients": [{"type": "email", "address": "user@example.com"}],
              "content": {"subject": "Welcome", "body": "Hi"},
              "provider_type": "email",
              "status": "delivered",
              "priority": "normal",
              "retry_count": 0,
              "max_retries": 3,
              "created_at": "2026-05-13T21:00:00Z",
              "delivered_at": "2026-05-13T21:00:03Z"
            }
        - 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<string,string>
              description: status | provider_type | date_from | date_to
          example_response: |
            {
              "notifications": [ /* Notification[] */ ],
              "total": 1234,
              "page": 1,
              "page_size": 20,
              "total_pages": 62
            }
        - 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"}
        - 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"}
        - name: GetStats
          method: gRPC
          path: /notification.NotificationService/GetStats
          description: Aggregate counter per status + average delivery time.
          example_response: |
            {
              "pending": 42,
              "processing": 5,
              "delivered": 9821,
              "failed": 17,
              "cancelled": 3,
              "average_delivery_time": 1.43
            }
    - id: queue-rpc
      title: QueueMonitoringService (gRPC)
      description: |
        Operational RPC untuk monitor antrian RabbitMQ yang digunakan oleh service.
        Proto: `proto/queue.proto`, package `queue`.
      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: |
            {
              "healthy": true,
              "status": "ok",
              "version": "1.0.0",
              "timestamp": "2026-05-13T21:00:00Z",
              "services": [
                {"name": "rabbitmq", "healthy": true},
                {"name": "mongodb", "healthy": true}
              ]
            }
    - 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}
guides:
    - id: lost-device
      icon: "\U0001F4F1"
      title: Lost device / session recovery
      description: |
        What to do when a user reports a lost or stolen device, or just
        wants to audit where they're signed in. Three patterns: review,
        revoke-one, revoke-all.
      flow:
        - step: 1
          title: Sign in from a trusted device
          description: |
            The user logs in from a device they still control. This issues a
            fresh access + refresh pair on a new session id. **Existing
            sessions are not affected** — the lost device's session is still
            alive at this point.
          endpoint:
            method: POST
            path: /api/v1/auth/login
            service: account-service
            auth: none
            fields:
                - name: email
                  type: string
                  required: true
                  description: Account email.
                - name: password
                  type: string
                  required: true
                  description: Account password.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/login \
              -H 'Content-Type: application/json' \
              -d '{ "email": "user@example.com", "password": "SecurePass123!" }'
        - step: 2
          title: Review active sessions
          description: |
            Lists every active session on the account, with last-used IP and
            user-agent so the user can spot the rogue one. The session
            backing the current request is flagged with `is_current: true`.
          endpoint:
            method: GET
            path: /api/v1/me/sessions
            service: account-service
            auth: JWT Bearer
            permission: read:sessions
          curl_example_jwt: |
            curl https://account.example.com/api/v1/me/sessions \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            {
              "success": true,
              "data": {
                "sessions": [
                  { "session_id": "550e8400-...", "ip_address": "203.0.113.42", "user_agent": "Mozilla/5.0 (iPhone)", "created_at": 1735689600, "last_used_at": 1735693200, "expires_at": 1738281600, "is_current": false },
                  { "session_id": "660e8400-...", "ip_address": "198.51.100.7",  "user_agent": "Mozilla/5.0 (Macintosh)", "created_at": 1735693300, "last_used_at": 1735693300, "expires_at": 1738285200, "is_current": true }
                ]
              }
            }
        - step: 3
          title: Revoke just the lost device's session (option A)
          description: |
            Targeted revoke. Pass the suspected `session_id` from the list.
            The next time that device tries to refresh its access token, it
            will get `4003 Session has been revoked or expired` and be
            forced back to the login screen.
          endpoint:
            method: DELETE
            path: /api/v1/me/sessions/{session_id}
            service: account-service
            auth: JWT Bearer
          curl_example_jwt: |
            curl -X DELETE https://account.example.com/api/v1/me/sessions/550e8400-... \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            { "success": true, "data": null }
        - step: 4
          title: Or revoke EVERYTHING (option B — paranoid)
          description: |
            Sweep every active session for the account, including the one
            making the request. Use when the user can't tell which session
            is the rogue one, or after a credential leak. The browser cookies
            are also cleared on the response.

            After this, the user is logged out everywhere — they'll need to
            re-login on each device they actually want to keep.
          endpoint:
            method: POST
            path: /api/v1/auth/logout-all
            service: account-service
            auth: JWT Bearer
          actions:
            - type: link
              description: Consider also rotating your password →
              endpoint: '#change-password'
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/auth/logout-all \
              -H 'Authorization: Bearer eyJ...'
          response_example: |
            { "success": true, "data": { "success": true } }
    - id: onboarding
      icon: "\U0001F680"
      title: Onboard a new tenant
      description: |
        End-to-end flow from a brand-new email address to a working
        multi-member organization. Covers registration, email verification,
        tenant org creation, and inviting the first teammate.
      flow:
        - step: 1
          title: Register
          description: |
            Creates a human account + a personal organization (the user is
            the owner) in a single transaction. Returns access + refresh
            tokens immediately — the user is auto-logged-in. A verification
            email is queued via the `email.requested` event.
          endpoint:
            method: POST
            path: /api/v1/auth/register
            service: account-service
            auth: none
            fields:
                - name: email
                  type: string
                  required: true
                  description: Unique across all accounts.
                - name: password
                  type: string
                  required: true
                  description: Min 8, must include upper/lower/digit/special.
                - name: display_name
                  type: string
                  required: true
                  description: Used for the personal org name.
                - name: timezone
                  type: string
                  description: IANA, default UTC.
                - name: language
                  type: string
                  description: ISO 639-1, default en.
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/register \
              -H 'Content-Type: application/json' \
              -d '{
                "email": "user@example.com",
                "password": "SecurePass123!",
                "display_name": "Jane Doe",
                "timezone": "Asia/Jakarta",
                "language": "id"
              }'
          response_example: |
            {
              "success": true, "code": 2010,
              "data": {
                "account_id": "550e8400-...",
                "email": "user@example.com",
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 900,
                "personal_org": { "org_id": "550e8400-...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "my_role": "owner", "my_permissions": ["*"], "...": "..." }
              }
            }
        - step: 2
          title: Verify email (out-of-band)
          description: |
            The user clicks the link in the verification email. Account
            service hosts the verification page itself at `GET /verify-email`
            (HTML, outside `/api/v1`). Once verified, the user's NEXT issued
            token (login, register, or refresh) carries `permissions: ["*"]`
            instead of the read-only fallback. **Existing tokens issued before
            verification keep their reduced perms** until refreshed.

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

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

            Token TTL is 1 hour. After expiry the user must restart from
            step 1.
        - step: 3
          title: Confirm with new password
          description: |
            Validates the token (signature + jti not previously used + not
            expired), updates the password hash, and **revokes every active
            session for the account** so any in-flight access tokens stop
            validating immediately. The user must log in again with the new
            password.
          endpoint:
            method: POST
            path: /api/v1/auth/password-reset/confirm
            service: account-service
            auth: none
            fields:
                - name: token
                  type: string
                  required: true
                  description: JWT from the email link.
                - name: new_password
                  type: string
                  required: true
                  description: Same complexity rules as registration.
          actions:
            - type: link
              description: Now log in with the new password →
              endpoint: '#login'
          curl_example: |
            curl -X POST https://account.example.com/api/v1/auth/password-reset/confirm \
              -H 'Content-Type: application/json' \
              -d '{
                "token": "eyJ...reset_token...",
                "new_password": "NewSecurePass456!"
              }'
          response_example: |
            { "success": true, "data": { "success": true } }
    - id: service-to-service
      icon: "\U0001F916"
      title: Service-to-service authentication
      description: |
        How a non-human consumer (job runner, ML pipeline, internal
        automation) authenticates against your APIs. Covers creating a
        service account, minting an API key, exchanging it for a JWT, and
        revoking on leak.
      flow:
        - step: 1
          title: Create the service account
          description: |
            A service account lives inside one organization and has
            capabilities (e.g. `museum:read`, `artifact:write`) that bound
            what its keys may grant. Requires `service_accounts:create`
            in the target org.
          endpoint:
            method: POST
            path: /api/v1/service-accounts
            service: account-service
            auth: JWT Bearer
            permission: service_accounts:create
            fields:
                - name: organization_id
                  type: string
                  required: true
                  description: Org to attach to.
                - name: display_name
                  type: string
                  required: true
                  description: Human-readable name.
                - name: description
                  type: string
                  description: Optional.
                - name: capabilities
                  type: string[]
                  required: true
                  description: resource:action format.
                - name: allowed_tools
                  type: string[]
                  required: true
                  description: Subset of tool ids the service may invoke.
                - name: rate_limit_per_minute
                  type: integer
                  description: 1-10000, default 60.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/service-accounts \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{
                "organization_id": "550e8400-...",
                "display_name": "Render bot",
                "capabilities": ["museum:read", "artifact:write"],
                "allowed_tools": ["thumbnailer"],
                "rate_limit_per_minute": 120
              }'
          response_example: |
            { "success": true, "data": { "account_id": "550e8400-...", "status": "active", "capabilities": ["museum:read","artifact:write"], "...": "..." } }
        - step: 2
          title: Mint an API key
          description: |
            The plaintext `key` is returned **only here, only once** — store
            it client-side immediately (e.g. in a secret manager). Subsequent
            list/get calls expose only `key_prefix` for identification.
            Requires `api_keys:create`.
          endpoint:
            method: POST
            path: /api/v1/service-accounts/{account_id}/api-keys
            service: account-service
            auth: JWT Bearer
            permission: api_keys:create
            fields:
                - name: name
                  type: string
                  description: Free-form label.
                - name: permissions
                  type: string[]
                  required: true
                  description: Subset of the service account's capabilities.
                - name: expires_in_days
                  type: integer
                  description: Omit for no expiry.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/service-accounts/550e8400-.../api-keys \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{
                "name": "prod render bot",
                "permissions": ["museum:read"],
                "expires_in_days": 180
              }'
          response_example: |
            {
              "success": true,
              "data": {
                "api_key_id": "550e8400-...",
                "key": "ak_live_a1b2c3d4...REDACTED...",
                "key_prefix": "ak_live_a1b2",
                "name": "prod render bot",
                "permissions": ["museum:read"],
                "expires_at": 1751155200,
                "created_at": 1735689600
              }
            }
        - step: 3
          title: Use the key directly (option A)
          description: |
            Cheapest integration: send `X-API-Key` on every protected request.
            The middleware accepts it identically to a Bearer JWT. Suitable
            for low-throughput automations.
          endpoint:
            method: GET
            path: /api/v1/me
            service: account-service
            auth: API Key
          curl_example_api_key: |
            curl https://account.example.com/api/v1/me \
              -H 'X-API-Key: ak_live_a1b2c3d4...'
          response_example: |
            { "success": true, "data": { "account_id": "550e8400-...", "account_type": "service", "display_name": "Render bot", "...": "..." } }
        - step: 4
          title: Or exchange for a JWT (option B — recommended)
          description: |
            For high-throughput callers, swap the key for a 1-hour service JWT
            and validate locally with `/auth/public-key`. Reduces account-service
            load from per-request to ~once an hour per pod. The JWT carries
            `principal_type: service` and `sid: <nil-uuid>`.
          endpoint:
            method: POST
            path: /api/v1/auth/token-exchange
            service: account-service
            auth: API Key
          curl_example_api_key: |
            curl -X POST https://account.example.com/api/v1/auth/token-exchange \
              -H 'X-API-Key: ak_live_a1b2c3d4...'
          response_example: |
            {
              "success": true,
              "data": {
                "access_token": "eyJ...service_jwt...",
                "token_type": "Bearer",
                "expires_in": 3600,
                "account_id": "550e8400-...",
                "organization_id": "550e8400-...",
                "permissions": ["museum:read"],
                "principal_type": "service"
              }
            }
        - step: 5
          title: Call downstream services with the JWT
          description: |
            Downstream services fetch `/auth/public-key` once, cache by `kid`,
            then verify the JWT locally on every request — no roundtrip to
            account-service per call. The `permissions` claim is derived from
            the API key's permissions at exchange time.
          endpoint:
            method: GET
            path: /v1/<resource>
            service: downstream-service
            auth: JWT Bearer
          curl_example_jwt: |
            curl https://media.example.com/v1/artifacts/123 \
              -H 'Authorization: Bearer eyJ...service_jwt...'
        - step: 6
          title: Revoke on leak
          description: |
            When a key leaks (committed to a public repo, posted in a Slack
            channel, etc.), revoke immediately. The key continues to exist
            for audit/forensics but stops authenticating. Use DELETE to hard-
            remove (loses audit trail).
          endpoint:
            method: POST
            path: /api/v1/api-keys/{api_key_id}/revoke
            service: account-service
            auth: JWT Bearer
            permission: api_keys:revoke
            fields:
                - name: reason
                  type: string
                  description: Recorded on the audit log.
          curl_example_jwt: |
            curl -X POST https://account.example.com/api/v1/api-keys/550e8400-.../revoke \
              -H 'Authorization: Bearer eyJ...' \
              -H 'Content-Type: application/json' \
              -d '{ "reason": "leaked in public repo" }'
          response_example: |
            { "success": true, "data": null }
permissions:
    - name: '*'
      description: Wildcard — all permissions. Carried by verified human access tokens. Membership/role grants in the org also use `*` for the system 'owner' role.
    - name: read:profile
      description: Read your own account profile. Issued on tokens for unverified-email users.
    - name: read:organizations
      description: List the organizations you belong to. Issued on tokens for unverified-email users.
    - name: read:sessions
      description: List your own active sessions. Issued on tokens for unverified-email users.
    - name: members:invite
      description: POST /organizations/{org_id}/members.
    - name: members:remove
      description: DELETE /organizations/{org_id}/members/{account_id}.
    - name: members:update_role
      description: PUT /organizations/{org_id}/members/{account_id}/role.
    - name: invitations:cancel
      description: Cancel a pending invitation (gRPC only today).
    - name: organizations:update
      description: PUT /organizations/{org_id}.
    - name: roles:create
      description: POST /organizations/{org_id}/roles.
    - name: roles:update
      description: PUT /organizations/{org_id}/roles/{role_id}.
    - name: roles:delete
      description: DELETE /organizations/{org_id}/roles/{role_id}.
    - name: service_accounts:create
      description: POST /service-accounts.
    - name: service_accounts:read
      description: GET /service-accounts and GET /service-accounts/{id}.
    - name: service_accounts:update
      description: PUT /service-accounts/{id}.
    - name: service_accounts:archive
      description: DELETE /service-accounts/{id}.
    - name: service_accounts:pause
      description: POST /service-accounts/{id}/pause.
    - name: service_accounts:resume
      description: POST /service-accounts/{id}/resume.
    - name: api_keys:create
      description: POST /service-accounts/{id}/api-keys.
    - name: api_keys:read
      description: GET /service-accounts/{id}/api-keys.
    - name: api_keys:revoke
      description: POST /api-keys/{id}/revoke.
    - name: api_keys:delete
      description: DELETE /api-keys/{id}.
    - name: audit_logs:read
      description: GET /organizations/{org_id}/activities. The /me/activities feed is filtered to the calling user and needs no extra permission.
    - name: password:reset
      description: Embedded only in the JWT issued by /auth/password-reset (1 h TTL). Not user-grantable.
    - 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: "\U0001F4BB Client (web/mobile)"
      type: client
      color: '#0ea5e9'
    - id: account
      label: "\U0001F464 Account Service"
      type: service
      color: '#4f46e5'
    - id: postgres
      label: "\U0001F418 PostgreSQL"
      type: external
      color: '#64748b'
    - id: redis
      label: "\U0001F7E5 Redis"
      type: external
      color: '#64748b'
    - id: rabbitmq
      label: "\U0001F407 RabbitMQ"
      type: queue
      color: '#8b5cf6'
    - id: email_service
      label: ✉️ Email Service
      type: service
      color: '#10b981'
    - id: jwt
      label: "\U0001F511 RS256 JWT"
      type: data
      color: '#f59e0b'
    - id: downstream
      label: "\U0001F9E9 Downstream services"
      type: service
      color: '#10b981'
    - id: client
      label: "\U0001F4F1 Client"
      type: client
      color: '#0ea5e9'
    - id: account
      label: "\U0001F510 Account Service"
      type: service
      color: '#4f46e5'
    - id: media
      label: "\U0001F39E️ Media Service"
      type: service
      color: '#10b981'
    - id: jwt
      label: "\U0001F511 JWT"
      type: data
      color: '#f59e0b'
    - id: storage
      label: "\U0001F4BE Local Storage"
      type: external
      color: '#64748b'
    - id: postgres
      label: "\U0001F418 PostgreSQL"
      type: external
      color: '#64748b'
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
        registration, resent verification, or password reset. The email service
        consumes this event and renders/sends the actual mail. Account service
        never talks to SMTP directly.
      protocol: amqp
      address: 'exchange: account.events / routing key: email.requested'
      operations:
        - type: publish
          summary: Outbound email request
          description: One event per outbound mail. The email service is the only documented subscriber today.
          payload:
            - name: event_type
              type: string
              required: true
              description: Always 'email.requested' for this channel.
              example: email.requested
            - name: to
              type: string
              required: true
              description: Recipient email address.
            - name: template
              type: string
              required: true
              description: 'One of: email_verification, password_reset.'
              example: email_verification
            - name: display_name
              type: string
              description: Used for greeting line.
            - name: token
              type: string
              required: true
              description: Verification or reset token (raw — hash is what's stored server-side).
            - name: base_url
              type: string
              required: true
              description: Frontend base URL used to construct the click-through link.
            - name: is_resend
              type: bool
              description: Only set for verification template; false on first send.
          example: |
            {
              "event_type": "email.requested",
              "to": "user@example.com",
              "template": "email_verification",
              "display_name": "Jane Doe",
              "token": "8x...redacted...",
              "base_url": "https://app.example.com",
              "is_resend": false
            }
    - id: notification-events-queue
      title: notification.events.queue
      description: |
        **Primary inbound queue.** Service lain publish event ke
        exchange `notification.requests` dengan routing key sesuai provider
        (`email.send`, `telegram.send`, `whatsapp.send`, `http.send`). Notification
        service consume dari binding queue ini lalu memproses sesuai `provider_type`.
      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.
            Failed delivery di-retry sampai `max_retries` (default 3).
          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<string,string>
            - name: created_at
              type: string
              required: true
          example: |
            {
              "id": "evt-user-reg-1700000000",
              "source_service": "account-service",
              "timestamp": "2026-05-13T21:00:00Z",
              "recipient": {
                "email": "user@example.com",
                "name": "John"
              },
              "content": {
                "subject": "Welcome to ikavia",
                "body": "Hi John, your account is ready"
              },
              "provider_type": "email",
              "provider_name": "smtp_email",
              "metadata": {
                "user_id": "acc_01HX...",
                "event": "user_registered"
              },
              "created_at": "2026-05-13T21:00:00Z"
            }
    - id: notification-outbound
      title: notification.outbound
      description: |
        Exchange tempat notification service mempublish hasil pemrosesan
        (status update, audit log) untuk dikonsumsi service lain.
      protocol: amqp
      address: notification.outbound (topic exchange)
      operations:
        - type: publish
          summary: Notification service publishes delivery results
          description: |
            Routing key bentuk `notification.<status>.<provider>` misal
            `notification.delivered.email`. Subscriber bisa bind ke wildcard
            (e.g. `notification.failed.*`).
          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: |
            {
              "notification_id": "ntf_01HX...",
              "status": "delivered",
              "provider_type": "email",
              "attempt": 1,
              "timestamp": "2026-05-13T21:00:03Z"
            }
theme:
    title: Ikavia Services
    logo_icon: "\U0001F6E0"
    primary_color: '#360185'
