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

info:
    title: Media Service
    version: 0.1.0
    description: |
        File upload, storage, and delivery for the platform. Provides
        Google-Drive-style folder hierarchies, per-user storage quotas,
        organisation-scoped visibility, and shared-link sharing with optional
        passwords and download caps.

        Authentication is delegated to the **Account Service** — every protected
        endpoint accepts the same RS256-signed JWT it issues. A parallel
        **gRPC** surface on port `50052` (see `proto/media/v1/media.proto`)
        covers the same domain for service-to-service calls; this page
        documents the REST transport only.
    base_urls:
        - label: Local
          url: http://localhost:3001
        - label: Production
          url: https://media.ikavia.com/api
          default: true
    overview_cards:
        - 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.
authentication:
    methods:
        - 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
          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: 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`.'
              default: organization
          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 JSON payload indicating the service process is up. Does
            not check downstream dependencies (DB, storage).

            **`model_3d_enabled`** advertises whether the 3D model pipeline is
            operational — i.e. whether Blender (the primary converter) is
            configured and runnable on this host. Probed once at first call
            (`blender --background --version`) and cached. The frontend reads
            this to disable the "3D model" upload action up-front when the
            administrator hasn't installed/configured Blender, rather than
            letting a user upload and hit a `503 MODEL_3D_UNAVAILABLE`.
          example_response: |
            {
              "status": "healthy",
              "service": "media-service",
              "model_3d_enabled": true
            }
    - 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. Default sort is
            newest-first (`created_at` desc).

            Each item carries **`model_3d`** (boolean): `true` when the file
            was uploaded via `?as=3d-model` and has a renderable glTF. The UI
            uses it to offer the 3D viewer even when the stored MIME is
            ambiguous (e.g. a `.zip` bundle reported as `application/zip`).
          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": "c150…",
                  "original_name": "scene.zip",
                  "mime_type": "application/zip",
                  "size_bytes": 29144542,
                  "visibility": "organization",
                  "folder_id": null,
                  "created_at": "2026-05-23T13:46:49Z",
                  "model_3d": true
                },
                {
                  "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",
                  "model_3d": false
                }
              ],
              "total": 2,
              "page": 1,
              "per_page": 20,
              "total_pages": 1,
              "quota": { "used_bytes": 29392664, "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=`)** — the service stores the
            original plus optional re-encoded variants. The variant tier
            is the same query string for every supported MIME class; what
            the server produces depends on the source type:

              * `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, max 1280 px) — balanced; suitable for previews.
              * `low` (q=50, max 320 px) — aggressive; suitable for thumbnails.

            **Per-MIME thumbnail behaviour** for `?variant=low` (and the
            smaller-cap `?variant=medium`):

              * `image/*` → resized + re-encoded in the original format
                (JPEG/PNG/WebP/GIF). Aspect ratio preserved.
              * `video/*` → JPEG poster frame extracted via ffmpeg at the
                1-second mark, scaled to fit the cap.
              * `application/pdf` → JPEG of the first page rendered via
                pdftoppm at the cap resolution.
              * `audio/*` → transparent PNG waveform (16:9), decoded with
                symphonia (ffmpeg fallback for containers symphonia can't
                read, e.g. Opus-in-WebM voice notes).
              * 3D models (uploaded `?as=3d-model`) → 320×320 transparent
                PNG rendered by f3d. Generated eagerly at upload.
              * MIMEs without an adapter → HTTP 404 (no thumbnail). UI is
                expected to fall back to a generic icon.

            **3D model viewer tiers** — for media uploaded with
            `?as=3d-model`, the glTF binary is served at three
            level-of-detail tiers so the viewer can stream a light model on
            mobile and the full one on desktop (the 3D analogue of image
            tiers). All three are meshopt-compressed glTF binary
            (`model/gltf-binary`):

              * `web` — full geometry + full-resolution texture.
              * `web-medium` — ~50% triangles, texture capped at 2048 px.
              * `web-low` — ~20% triangles, texture capped at 1024 px (the
                mobile default the viewer requests first).

            Texture downscaling happens in Blender (gltfpack only resizes
            textures alongside KTX2, which we avoid to keep the viewer
            decoder-free); gltfpack does the geometry decimation. These
            tiers are produced by the asynchronous post-upload pipeline (see
            `?as=3d-model` under **Upload**), never lazily on demand.

            **Three response states for a glb tier:**
              * `200` — variant ready, body is the glb bytes.
              * `202 Accepted` — model is still converting
                (`metadata.model_3d_status = "processing"`). Body is a JSON
                status `{"error":"MODEL_3D_PROCESSING", "message":…}`, NOT
                media bytes. Clients should back off + refresh, not pass the
                body to a model viewer.
              * `404 Not Found` — no such 3D asset (legacy upload that
                predates LODs, conversion `failed`, or the source was never
                uploaded as 3D). Frontend falls back to `web` or shows a
                "no preview" state.

            All thumbnail responses ship `Content-Type: image/jpeg` or
            `image/png` regardless of the source MIME — a single `<img>`
            element renders every case.

            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` and `low` variants for images are generated
            eagerly on upload (when `IMAGE_EAGER_MEDIUM=true`); video /
            PDF / audio thumbnails plus all other levels are generated
            lazily on first request and cached for subsequent ones.
            Generation is best-effort — adapters that can't decode the
            source surface 404, not 500.

            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` | `web` | `web-medium` | `web-low`. Aliases: `orig`/`raw`, `hi`, `med`, `lo`, `glb`. The `web*` tiers return the meshopt-compressed glTF binary for 3D uploads (only present when the upload used `?as=3d-model`): `web` = full, `web-medium`/`web-low` = level-of-detail (reduced geometry + texture). For images `low`/`medium` are size-reduced thumbnails.'
        - 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: Reprocess 3D Conversion
          method: POST
          path: /media/{id}/reprocess-3d
          auth: JWT
          permission: media:update
          description: |
            Re-runs the 3D conversion pipeline (Blender → gltfpack → f3d) for
            a media that was uploaded with `?as=3d-model`. Use when
            `metadata.model_3d_status` is `"failed"` (transient binary
            failure, a corrupt input, etc.) or stuck `"processing"` after a
            service restart had killed the prior detached task.

            Owner-only. Flips the row's status back to `"processing"` and
            spawns the pipeline asynchronously — the response is **202
            Accepted** as soon as the task is queued. The pipeline updates
            the status to `"ready"` (variants on disk) or `"failed"` (+ a
            short `model_3d_error`) when it finishes.

            Idempotent under concurrent calls: if the row is already
            `"processing"`, the second call is a no-op (returns 202 without
            spawning another task). Conversion is deterministic so even a
            racing double-spawn would converge to the same outcome.

            Reject conditions:
              * 404 `NOT_FOUND` — media does not exist
              * 403 `FORBIDDEN` — caller is not the owner
              * 422 `MODEL_3D_VALIDATION` — media was not uploaded as a 3D
                model (no `metadata.model_3d` flag), so there is nothing to
                re-convert. Re-upload via `POST /upload?as=3d-model`.
              * 503 `MODEL_3D_UNAVAILABLE` — Blender is not configured on
                this host (see `model_3d_enabled` on `/health`).
          example_body: |
            # No body — the media id is in the path. Send an empty POST.
            curl -X POST 'https://media.ikavia.com/api/media/c1502f8c-cd40-40a7-b8b9-ecd320974298/reprocess-3d' \
              -H 'Authorization: Bearer <token>'
          example_response: |
            # 202 Accepted — task spawned. Refresh /media/{id}/info or the
            # listing to observe model_3d_status change to ready/failed.
            {
              "status": "processing",
              "message": "3D conversion restarted"
            }
        - 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`.

            **Deletion code guard (optional, per row).** If the file was
            uploaded with `X-Deletion-Code: <secret>`, the row is
            "guarded" — every delete must echo the same plaintext via
            the same header. Distinct 403 body codes signal *which*
            failure mode so UIs can prompt vs re-prompt:

              * `DELETION_CODE_REQUIRED` — guarded row, no header sent.
              * `INVALID_DELETION_CODE`  — guarded row, header sent but
                the hash didn't verify. Re-prompt the user.
              * `FORBIDDEN`              — role check failed (not owner,
                not org-admin). A code can't fix this.

            Rows uploaded without the header (or before this feature was
            released) ignore the header on delete — behaviour is
            unchanged.
          example_response: |
            {
              "deleted": true,
              "media_id": "9e1c…",
              "filename": "9e1c….jpg",
              "freed_bytes": 248122
            }
        - name: Preview Media Text
          method: GET
          path: /media/{id}/preview
          auth: JWT
          permission: media:download
          description: |
            Head-of-file text preview for text-y MIMEs (`text/*`,
            `application/json`, `application/xml`, `application/x-yaml`,
            `application/javascript`, plus extension-based fallback for
            markdown / source code: `.md`, `.mmd`, `.txt`, `.log`, `.csv`,
            `.tsv`, `.json`, `.yaml`, `.toml`, `.xml`, `.rs`, `.ts`,
            `.py`, `.go`, `.java`, `.sql`, `.sh`, etc.).

            Returns up to 8 KB (cap is hard). Non-text MIMEs → 415 with
            body `UNSUPPORTED`. UTF-8 boundary safe via lossy decode so
            a cut mid-codepoint yields `U+FFFD` instead of a 5xx.
          query_params:
            - name: bytes
              type: int
              default: "8192"
              description: How many bytes to return. Clamped server-side to 8192.
          example_response: |
            {
              "id": "3ca7…",
              "mime_type": "text/markdown",
              "content": "# Title\n\nFirst paragraph…",
              "truncated": true,
              "bytes_returned": 8192,
              "total_bytes": 42130
            }
        - name: Manifest Archive Contents
          method: GET
          path: /media/{id}/manifest
          auth: JWT
          permission: media:download
          description: |
            Lists the entries inside a zip / tar / tar.gz **without
            extracting any payload**. Reads only the central-directory
            headers; bounded at 5 000 entries (zip-bomb cap) with a
            `truncated` flag past that.

            Format is auto-detected from MIME, with a sensible extension
            fallback so `application/octet-stream` uploads ending in
            `.zip` / `.tgz` are still recognised. Non-archive MIMEs → 415.

            Entry paths are sanitised: any `..` segments and leading
            slashes are stripped before serialisation, so the UI can
            render `name` verbatim without re-validating.
          example_response: |
            {
              "format": "zip",
              "entries": [
                { "name": "README.md", "size_bytes": 1234, "is_dir": false, "modified_at": "2026-04-12T10:00:00Z" },
                { "name": "src", "size_bytes": 0, "is_dir": true, "modified_at": null }
              ],
              "total_entries": 42,
              "truncated": false
            }
    - 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: s2s
      title: Service-to-service
      description: |
        Endpoint introspeksi untuk **service backend lain** yang perlu
        memvalidasi `media_id` sebelum menyimpannya sebagai foreign-key
        (mis. WACCA validate cover/photo outlet, planner attachment,
        survey upload reference). Berbeda dari `/media/{id}/info`:

        * Tidak menerapkan visibility filter — siapa pun caller
          service-principal bisa melihat existence + ownership metadata.
        * Tidak menerima JWT human (`principal_type: "human"`) — 403.
        * Tidak mengembalikan content (size, checksum, dll.) — hanya
          existence + visibility + organization + expiry, yaitu
          minimum yang dibutuhkan untuk same-org / pre-expiry validation
          di consumer.

        **Auth.** Pakai `X-API-Key` (di-exchange ke service-principal
        JWT lewat `ApiKeyConverter`) atau `Authorization: Bearer <jwt>`
        dengan claim `principal_type: "service"`. Token tanpa
        `principal_type` (format lama) diperlakukan sebagai non-service
        — fail-closed.

        Pola ini cermin endpoint S2S account-service yang serupa.
      endpoints:
        - name: Check existence
          method: GET
          path: /v1/media/{id}/exists
          auth: X-API-Key | Bearer (service principal)
          permission: n/a (gate by principal_type)
          description: |
            Lookup existence + ownership untuk satu `media_id`. Cocok
            untuk validasi single-field `cover_outlet_media_id` /
            `attachment_id` saat consumer menerima request dari owner.

            Response shape:

              * `exists` selalu `true` di body 200 — kalau tidak ada,
                response-nya 404 dengan body error standar.
              * `visibility` salah satu `public` / `organization` /
                `private`. Consumer enforce same-org rule sendiri.
              * `organization_id` `null` ketika row orphan (tidak ada
                org-nya). Untuk media `visibility=organization`,
                consumer bisa cocokkan dengan org-nya sendiri.
              * `expired` dihitung server-side dari `expires_at`
                vs `NOW()`. Tidak ada off-by-one antar service clock.
          example_response: |
            {
              "exists": true,
              "visibility": "organization",
              "organization_id": "545c61c3-b211-406a-bd0a-0140e7bbe941",
              "expired": false
            }
        - name: Bulk check existence
          method: POST
          path: /v1/media/bulk-check
          auth: X-API-Key | Bearer (service principal)
          permission: n/a (gate by principal_type)
          description: |
            Lookup existence + ownership untuk banyak `media_id` dalam
            satu round-trip. Pakai ini saat consumer menyimpan record
            dengan beberapa media reference (mis. outlet dengan cover +
            photo, atau survey response dengan multiple attachment).

            **Partial result by contract.** Response 200 berisi satu
            entry per id yang di-request — bahkan untuk id yang tidak
            ditemukan (entry-nya `{id, exists: false}`). Consumer bisa
            simpan yang valid + flag yang invalid dalam satu pass.

            **Order preserved.** `items[i].id == request.ids[i]`. Kalau
            caller kirim duplicate id, response berisi duplikat juga.

            **Cap 100 ids.** Body di luar cap → 400 `VALIDATION_ERROR`.
            Body kosong → 400 dengan pesan "ids must be a non-empty
            array".
          body:
            - name: ids
              type: array<uuid>
              required: true
              description: 1-100 media id. Duplicates allowed; their entries are duplicated 1:1 in the response.
          example_body: |
            {
              "ids": [
                "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
                "ffffffff-ffff-ffff-ffff-ffffffffffff",
                "10cd8c18-8f45-45c5-8c0d-d7c545f2d729"
              ]
            }
          example_response: |
            {
              "items": [
                {
                  "id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
                  "exists": true,
                  "visibility": "public",
                  "organization_id": "545c61c3-b211-406a-bd0a-0140e7bbe941",
                  "expired": false
                },
                {
                  "id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
                  "exists": false
                },
                {
                  "id": "10cd8c18-8f45-45c5-8c0d-d7c545f2d729",
                  "exists": true,
                  "visibility": "organization",
                  "organization_id": null,
                  "expired": true
                }
              ]
            }
    - 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.

        **URL shape:** `{base_url}/share/{token}` — note the path is
        `/share/`, **not** `/api/v1/share/`. The `/api/v1` prefix is
        reserved for the JSON management API (create, list, delete);
        the public access endpoint sits at the top level so it can be
        shared verbatim without `/api/...` looking like an internal
        route. Edge nginx proxies `^/share/` straight to the backend.
      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`.'
              default: 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.

            **Accepted credentials.** Any RS256 JWT carrying `media:upload`
            is accepted — both ordinary **user** tokens and **service-principal**
            tokens (`principal_type: service`), including the short-lived token
            a headless backend obtains from the Account Service
            `POST /auth/system-token` flow. There is no anonymous path: a valid
            token is always required (missing/invalid → 401). Service-principal
            uploads are attributed to the token's `sub` as `owner_id`; set
            `visibility=public` if the resulting file must be readable without
            auth (e.g. an anonymous applicant's document fetched later via
            `GET /media/{id}`).

            **Storage quota by principal.** Human uploads draw on the per-user
            bucket (`MAX_STORAGE_PER_USER`, default 1 GiB). Service-principal
            uploads — which funnel many end-users through a single owner id —
            draw on a separate, typically larger per-service bucket
            (`MAX_STORAGE_PER_SERVICE`, default 10 GiB). The limit is applied
            both as the fast-path pre-check and as the authoritative
            `storage_quota.max_bytes` for the service owner; over-quota uploads
            fail with 413 and the staged blob is removed.

            **Cross-organisation attribution (trusted backends).** The
            `X-Organization-Id` header lets the caller pick which organisation
            the resulting media belongs to. For ordinary user tokens that org
            must be in the caller's own membership set — otherwise 403, no
            escalation. A **headless backend** that serves end-users across
            many orgs (e.g. a careers portal uploading documents on behalf of
            anonymous applicants) can bypass the membership gate when ALL
            three hold:

              1. The token is `principal_type: service` (cryptographically
                 pinned by account-service; cannot be forged by a user token).
              2. The token carries the `media:act_as_any_org` permission.
              3. `X-Organization-Id: <target-org-uuid>` is sent on the
                 upload request.

            The media's `organization_id` is then set to the supplied UUID;
            `owner_id` remains the service principal's `sub`. Without the
            header the existing fallback applies (the token's `org_id` claim,
            or NULL if absent). A user token granted `media:act_as_any_org`
            alone is NOT elevated — the principal-type factor is a deliberate
            defence-in-depth against accidental grant. The cross-org write is
            logged with the service principal's `sub` and target org for
            audit.

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

            **Original is never overwritten.** For images, the upload
            pipeline additionally generates the `low` + `medium` variants
            (true thumbnails at 320 / 1280 px max) post-DB-save when
            `IMAGE_EAGER_MEDIUM=true`. Video / PDF / audio thumbnails are
            generated lazily on first `GET /media/{id}?variant=low`.
            Variant generation is best-effort — failure here logs a
            warning but the upload itself succeeds with the original
            committed.

            **Metadata probe** runs at end of upload and writes
            type-specific facts to `media.metadata`:
              * `image/*` → `width`, `height` (via the `image` crate)
              * `audio/*` → `duration_ms`, `bitrate_bps`, `sample_rate_hz`,
                `channels` (via `lofty`)
              * `video/*` → `duration_ms`, `width`, `height`, `bitrate_bps`
                (via `ffprobe`)

            Caller-supplied `metadata` (in the form body) wins per-key;
            the probe only fills keys the caller left empty.

            **Optional `X-Deletion-Code` header (per row).** When present,
            server stores a `sha256:<hex>` hash of the supplied secret.
            Future deletes against this row must echo the same plaintext
            via the same header, or be refused with 403
            `DELETION_CODE_REQUIRED` / `INVALID_DELETION_CODE`. Useful
            for service-to-service uploads where the panel web UI / other
            callers must not be able to delete files arbitrarily.

            Caller is responsible for storing the plaintext code — server
            provides no recovery. Code must be 16-256 printable ASCII
            characters; out-of-range or non-printable input → 400.

            **Optional `?as=3d-model` (per upload — ASYNCHRONOUS).** Routes
            the upload through the 3D pipeline: **Blender** import/export →
            gltfpack compress → f3d render preview. Blender is the primary
            converter (accurate geometry + PBR materials + textures);
            **assimp is a per-format fallback** for inputs Blender can't read
            (e.g. `.3ds`) or transient Blender failures. Accepted inputs:
              * `.glb` / `.gltf` (binary) — already self-contained
              * `.stl` / `.ply` (ASCII or binary) — geometry-only
              * `.obj` without `mtllib` declarations — bare geometry
              * `.zip` / `.tar.gz` — must contain a recognisable entry
                (`.obj`+`.mtl`+textures, or `.glb`, `.stl`, etc.)
              * `.fbx` / `.dae` — Blender importers; `.3ds` via assimp fallback

            **Lifecycle.** The endpoint **returns 201 immediately** once the
            original bytes are saved — it does NOT block on conversion. The
            response carries `model_3d: true` and `model_3d_status:
            "processing"`. The pipeline runs detached (it survives client
            disconnect) and writes the final state to the same row:

              * `model_3d_status: "ready"` — variants `web` (full),
                `web-medium`, `web-low` (mobile default), and `low` (PNG
                thumbnail) are all on disk and listed.
              * `model_3d_status: "failed"` — conversion errored; a short
                `model_3d_error` reason is recorded; the original file is
                kept (no rollback). Owner can re-trigger via
                `POST /media/{id}/reprocess-3d`.

            Concurrency is capped by `MEDIA_3D_MAX_CONCURRENCY` (default 2);
            extra pipelines queue rather than thrash the host. On service
            restart, any row stuck in `processing` is swept to `failed` at
            startup so it's recoverable via reprocess.

            **Variant fetch while converting.** `GET /media/{id}?variant=web*`
            for a model whose status is `processing` returns **`202 Accepted`**
            with body `{"error":"MODEL_3D_PROCESSING", ...}` (distinct from
            `404` "no such 3D asset" and `200` ready). Clients should
            back off and retry after a refresh.

            Reject conditions surfaced as `model_3d_status: "failed"` (the
            upload itself returns 201; the failure shows on the row):
              * naked `.obj` with `mtllib material.mtl` — bundle it
                in a zip alongside the referenced material + textures
              * archive with no recognisable 3D file inside
              * archive past size caps (50 MB / file, 200 MB cumulative,
                5 000 entries) — zip-bomb defence
              * unsupported extension entirely

            **Disabled-feature** (503 `MODEL_3D_UNAVAILABLE` returned
            synchronously on the upload itself): the host has no runnable
            Blender (`MEDIA_BLENDER_BIN` unset / not installed). The frontend
            reads `model_3d_enabled` from `GET /health` to warn before the
            user uploads.

            When `as` is NOT set, even `.glb` / `.stl` uploads are stored
            as plain bytes — no 3D processing (no variants, no status field).

            On any pre-conversion failure (auth, MIME, quota, body-parse)
            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`.'
            - name: as
              type: string
              description: 'Opt-in pipeline tag. Currently supported: `3d-model`. When set, upload is processed through the 3D converter + thumbnail renderer.'
          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'

            # 4) Guarded upload (X-Deletion-Code). The server hashes the
            #    secret; remember to keep the plaintext on the caller side
            #    or the file becomes undeleteable from outside ops/DB.
            CODE=$(openssl rand -base64 32 | tr -d '\n')
            curl -X POST 'http://localhost:3001/upload' \
              -H 'Authorization: Bearer <token>' \
              -H "X-Deletion-Code: $CODE" \
              -F 'file=@/path/to/asset.png'

            # 5) 3D model upload (?as=3d-model). Server runs Blender →
            #    gltfpack → f3d, persisting three meshopt-compressed glTF
            #    LOD tiers (?variant=web / web-medium / web-low) plus a
            #    320×320 PNG preview (?variant=low). Bundle OBJ + MTL +
            #    textures in a single zip; naked .obj with `mtllib` is
            #    rejected. 503 MODEL_3D_UNAVAILABLE if Blender isn't
            #    configured on the host.
            curl -X POST 'http://localhost:3001/upload?as=3d-model' \
              -H 'Authorization: Bearer <token>' \
              -F 'file=@/path/to/scene.zip'

            # 6) Standalone .glb — also goes through the pipeline when
            #    `?as=3d-model` is set; the server re-imports + re-packs
            #    into the LOD tiers for the viewer-served format.
            curl -X POST 'http://localhost:3001/upload?as=3d-model' \
              -H 'Authorization: Bearer <token>' \
              -F 'file=@/path/to/model.glb'

            # 7) Service-principal upload (e.g. a headless backend acting for
            #    an anonymous applicant). The bearer is a System-account token
            #    minted by the Account Service POST /auth/system-token flow and
            #    carrying `media:upload`. `visibility=public` lets the file be
            #    fetched later without auth. Counts against the per-service
            #    quota (MAX_STORAGE_PER_SERVICE), not the per-user bucket.
            curl -X POST 'http://localhost:3001/upload?visibility=public' \
              -H 'Authorization: Bearer <system-account-jwt>' \
              -F 'file=@/path/to/cv.pdf'

            # 8) Cross-organisation upload by a trusted backend. The bearer is
            #    a System-account token carrying BOTH `media:upload` AND
            #    `media:act_as_any_org`. X-Organization-Id picks the target
            #    org — the membership gate is skipped because both factors
            #    hold (principal_type=service + permission). The resulting
            #    media is attributed to that org; members of the org can read
            #    it under `visibility=organization` without needing the file
            #    to be public. A user token granted `media:act_as_any_org`
            #    alone would still be gated by membership (403).
            curl -X POST 'http://localhost:3001/upload?visibility=organization' \
              -H 'Authorization: Bearer <system-account-jwt>' \
              -H 'X-Organization-Id: 660e8400-e29b-41d4-a716-446655440001' \
              -F 'file=@/path/to/applicant-cv.pdf'
          example_response: |
            # Image / non-3D upload — `model_3d` is false, status omitted.
            {
              "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",
              "model_3d": false,
              "model_3d_status": null
            }

            # 3D upload (`?as=3d-model`) — returns immediately with status
            # `processing`. The detached pipeline writes `ready` / `failed`
            # to the same row when it finishes; clients refresh the list to
            # see the new state, or hit POST /media/{id}/reprocess-3d to
            # retry a failure.
            {
              "id": "c1502f8c-cd40-40a7-b8b9-ecd320974298",
              "filename": "c1502f8c-cd40-40a7-b8b9-ecd320974298.zip",
              "original_name": "scene.zip",
              "mime_type": "application/zip",
              "size_bytes": 29144542,
              "checksum": "sha256:a3…",
              "visibility": "organization",
              "folder_id": null,
              "created_at": "2026-05-23T13:46:49Z",
              "model_3d": true,
              "model_3d_status": "processing"
            }
permissions:
    - 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: media:act_as_any_org
      description: Attribute an upload to ANY organisation via `X-Organization-Id`, bypassing the membership gate. Effective ONLY when paired with `principal_type=service` (trusted headless backend).
    - 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:
    - Per-user storage quota (default 1 GB) is enforced via a PostgreSQL CHECK constraint, not application code.
    - 'Service-principal uploads (`principal_type: service` JWT) draw on a separate quota bucket (`MAX_STORAGE_PER_SERVICE`, default 10 GB) instead of the per-user 1 GB, since many end-users funnel through one owner id.'
    - 'A trusted headless backend may attribute an upload to ANY organisation via the `X-Organization-Id` header when its token is `principal_type: service` AND carries `media:act_as_any_org`. User tokens granted the permission alone are NOT elevated (defence-in-depth).'
    - 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.
flow_diagram_nodes:
    - 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: 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
        - PATCH
        - DELETE
    auth_modes:
        - name: JWT
          header: Authorization
          prefix: 'Bearer '
          placeholder: YOUR_JWT_TOKEN_HERE
theme:
    title: Media · Ikavia
    logo_icon: "\U0001F4C1"
    primary_color: '#BC3362'
