{"info":{"title":"Media Service","version":"0.1.0","description":"File upload, storage, and delivery for the platform. Provides\nGoogle-Drive-style folder hierarchies, per-user storage quotas,\norganisation-scoped visibility, and shared-link sharing with optional\npasswords and download caps.\n\nAuthentication is delegated to the **Account Service** — every protected\nendpoint accepts the same RS256-signed JWT it issues. A parallel\n**gRPC** surface on port `50052` (see `proto/media/v1/media.proto`)\ncovers the same domain for service-to-service calls; this page\ndocuments the REST transport only.\n","base_urls":[{"label":"Local","url":"http://localhost:3001"},{"label":"Production","url":"https://media.ikavia.com/api","default":true}],"overview_cards":[{"icon":"📤","title":"Streaming upload","description":"Multipart with magic-byte sniff and DB-enforced quota","content":"`POST /upload` streams the request body straight to disk, sniffs the\nmagic bytes, and rejects executables even when disguised by a fake\nMIME header. Storage quota is enforced at the DB layer\n(CHECK constraint, migration `20260424000001`) — the application\npre-check is a fast-path only.\n"},{"icon":"📁","title":"Folder tree","description":"Hierarchical, max depth 100, sibling-name unique","content":"Folders form a tree per owner (`parent_id` + `depth` + `path`).\nSibling names must be unique under the same parent\n(migration `20260424000002`). Recursive delete (`force=true`)\npurges every blob in the subtree before dropping rows.\n"},{"icon":"🔗","title":"Shared links","description":"Tokenised, optionally password-protected","content":"Generate an opaque token URL with optional **bcrypt** password,\nexpiry, and download cap. Anonymous accessors hit\n`GET /share/:token`. Legacy SHA-256 hashes are upgraded to bcrypt\non first successful access.\n"},{"icon":"🖼️","title":"Image variants","description":"Original kept; high/medium/low generated on demand","content":"Image uploads keep the **original** bytes intact and never\noverwrite them. Three derived variants — `high` (q=90),\n`medium` (q=75), `low` (q=50) — can be requested via\n`GET /media/{id}?variant=…`. The `medium` tier is generated\neagerly on upload (when `IMAGE_EAGER_MEDIUM=true`); `high` and\n`low` are generated lazily on first access and cached. All\nlevels live in `media_variants` (one row per tier) — see\nmigration `20260505000001`. Delete cascades clean every variant\nblob from disk, not just the DB rows.\n"},{"icon":"🎞️","title":"Video range streaming","description":"Browser scrubbing without full-file download","content":"For `video/*` MIMEs the download endpoint advertises\n`Accept-Ranges: bytes` and honours `Range:` request headers,\nresponding with **206 Partial Content** for the requested\nbyte window. Lets the HTML5 `\u003cvideo\u003e` element seek mid-file\nwithout buffering from byte 0. Out-of-bounds requests return\n416 with the canonical `Content-Range: bytes */\u003ctotal\u003e` hint.\nNon-video files don't advertise ranges and serve a full 200.\n"}]},"authentication":{"methods":[{"type":"JWT","header":"Authorization","format":"Bearer \u003ctoken\u003e","source":"Account Service","description":"RS256-signed JWT issued by the Account Service. The signature is\nverified against `JWT_PUBLIC_KEY_PATH` (defaults to\n`public_key.pem` in the working directory). Optional dynamic key\nrotation is supported via `ACCOUNT_SERVICE_URL`.\n","token_contains":["sub (user UUID)","org_id (organisation UUID, optional)","permissions (e.g. [\"media:upload\", \"folder:list\"])","exp (standard JWT expiry)"]}]},"flow_overview":{"methods":[{"type":"JWT","steps":[{"title":"Authenticate against Account Service","detail":"Out of scope for this service. Obtain a JWT from the configured Account Service login endpoint."},{"title":"Attach the token on every protected request","detail":"Send `Authorization: Bearer \u003ctoken\u003e` on every endpoint except `/health` and `/share/:token`."},{"title":"Permissions are claim-checked per route","detail":"Each route asserts a specific permission (e.g. `media:upload`). Tokens missing the required permission get HTTP 403."}]}],"note":"Public endpoints (`/health`, `/share/:token`) do not require a token."},"sections":[{"id":"folders","title":"Folders","description":"Hierarchical folder tree per owner. Root has `parent_id = null`\nand `depth = 0`. Sibling names must be unique under the same\nparent. Maximum nesting depth is 100.\n","endpoints":[{"name":"Create Folder","method":"POST","path":"/folders","auth":"JWT","permission":"folder:create","description":"Creates a folder owned by the caller. If `parent_id` is\nprovided, the folder is nested under that parent and inherits\na `depth` of `parent.depth + 1`. The DB enforces sibling-name\nuniqueness — duplicates return HTTP 409.\n","body":[{"name":"name","type":"string","required":true,"description":"Folder name. 1–255 chars; no `/` or control characters."},{"name":"parent_id","type":"uuid","description":"Parent folder. Omit for a root-level folder."},{"name":"visibility","type":"string","description":"`public` | `organization` | `private`.","default":"organization"}],"example_body":"{\n  \"name\": \"Vacation Photos\",\n  \"parent_id\": \"4f2c…\",\n  \"visibility\": \"organization\"\n}\n","example_response":"{\n  \"id\": \"f9a1…\",\n  \"name\": \"Vacation Photos\",\n  \"parent_id\": \"4f2c…\",\n  \"path\": \"Documents/Vacation Photos\",\n  \"depth\": 2,\n  \"created_at\": \"2026-04-30T08:14:22Z\"\n}\n"},{"name":"List Folders","method":"GET","path":"/folders","auth":"JWT","permission":"folder:list","description":"Lists folders owned by the caller. Default scope is the\ncurrent parent's children (or roots when `parent_id` is\nomitted). Set `recursive=true` to flatten the entire subtree.\n","query_params":[{"name":"parent_id","type":"uuid","description":"Listing scope. Omit for root-level folders."},{"name":"recursive","type":"bool","default":"false","description":"Walk the full subtree under `parent_id`."},{"name":"sort","type":"string","default":"name","description":"`name` | `created_at` | `updated_at`."},{"name":"order","type":"string","default":"asc","description":"`asc` | `desc`."}]},{"name":"Rename Folder","method":"PATCH","path":"/folders/{id}","auth":"JWT","permission":"folder:update","description":"Renames a folder. The DB trigger\n`trg_update_folder_path` rewrites every descendant's `path`\ninside the same transaction. Root folders cannot be renamed.\n","body":[{"name":"name","type":"string","required":true,"description":"New folder name."}],"example_body":"{ \"name\": \"Archive 2026\" }\n"},{"name":"List Folder Contents","method":"GET","path":"/folders/{id}/contents","auth":"JWT","permission":"folder:list","description":"Returns the immediate children: subfolders and media files in\na single paginated response. Useful for a Drive-style file\nbrowser UI.\n","query_params":[{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."}]},{"name":"Delete Folder","method":"DELETE","path":"/folders/{id}","auth":"JWT","permission":"folder:delete","description":"Deletes the folder. The non-force path requires the folder to\nbe empty (no media, no subfolders) and returns HTTP 412 if it\nis not. With `force=true` the entire subtree is purged: for\nevery media in the subtree, all derived variant blobs (medium\n/high/low) are removed first, then the original blob, then\nthe media row; finally the folder rows cascade. Variant\ncleanup failures are logged but never abort the purge — same\nreasoning as the single-media delete endpoint.\n","query_params":[{"name":"force","type":"bool","default":"false","description":"Recursively delete every file and subfolder in the subtree."}],"example_response":"{\n  \"deleted\": true,\n  \"folder_id\": \"f9a1…\",\n  \"deleted_items\": { \"folders\": 3, \"media\": 17 }\n}\n"}]},{"id":"health","title":"Health","description":"Liveness probe. No authentication, no rate limiting, safe for\nuptime checkers.\n","endpoints":[{"name":"Health Check","method":"GET","path":"/health","auth":"none","description":"Returns a JSON payload indicating the service process is up. Does\nnot check downstream dependencies (DB, storage).\n\n**`model_3d_enabled`** advertises whether the 3D model pipeline is\noperational — i.e. whether Blender (the primary converter) is\nconfigured and runnable on this host. Probed once at first call\n(`blender --background --version`) and cached. The frontend reads\nthis to disable the \"3D model\" upload action up-front when the\nadministrator hasn't installed/configured Blender, rather than\nletting a user upload and hit a `503 MODEL_3D_UNAVAILABLE`.\n","example_response":"{\n  \"status\": \"healthy\",\n  \"service\": \"media-service\",\n  \"model_3d_enabled\": true\n}\n"}]},{"id":"media","title":"Media","description":"Read, list, rename, change visibility on, and delete media files.\nVisibility rules (`public`/`organization`/`private`) gate read\naccess; ownership and `media:*` permissions gate writes.\n","endpoints":[{"name":"List Media","method":"GET","path":"/media","auth":"JWT","permission":"media:list","description":"Paginated listing. Supports filtering by folder, visibility,\nfile type, and a search term applied to `original_name`. The\nsearch input is parameterised at the SQL layer. Default sort is\nnewest-first (`created_at` desc).\n\nEach item carries **`model_3d`** (boolean): `true` when the file\nwas uploaded via `?as=3d-model` and has a renderable glTF. The UI\nuses it to offer the 3D viewer even when the stored MIME is\nambiguous (e.g. a `.zip` bundle reported as `application/zip`).\n","query_params":[{"name":"folder_id","type":"uuid","description":"Restrict to this folder; omit for all-folders view."},{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."},{"name":"visibility","type":"string","description":"Filter: `public` | `organization` | `private`."},{"name":"file_type","type":"string","description":"Filter: `image` | `video` | `document` | `audio` | `other`."},{"name":"search","type":"string","description":"Substring match on `original_name`."},{"name":"sort","type":"string","default":"created_at","description":"`created_at` | `name` | `size` | `updated_at`."},{"name":"order","type":"string","default":"desc","description":"`asc` | `desc`."}],"example_response":"{\n  \"items\": [\n    {\n      \"id\": \"c150…\",\n      \"original_name\": \"scene.zip\",\n      \"mime_type\": \"application/zip\",\n      \"size_bytes\": 29144542,\n      \"visibility\": \"organization\",\n      \"folder_id\": null,\n      \"created_at\": \"2026-05-23T13:46:49Z\",\n      \"model_3d\": true\n    },\n    {\n      \"id\": \"9e1c…\",\n      \"original_name\": \"photo.jpg\",\n      \"mime_type\": \"image/jpeg\",\n      \"size_bytes\": 248122,\n      \"visibility\": \"organization\",\n      \"folder_id\": \"4f2c…\",\n      \"created_at\": \"2026-04-30T08:14:22Z\",\n      \"model_3d\": false\n    }\n  ],\n  \"total\": 2,\n  \"page\": 1,\n  \"per_page\": 20,\n  \"total_pages\": 1,\n  \"quota\": { \"used_bytes\": 29392664, \"max_bytes\": 1073741824 }\n}\n"},{"name":"Download Media","method":"GET","path":"/media/{id}","auth":"JWT","permission":"media:download","description":"Streams the media bytes back with the original `Content-Type`\nand `Content-Disposition`. Visibility rules apply: `public` is\nunauthenticated, `organization` requires same-org JWT,\n`private` requires the owner's JWT.\n\n**Variant selection (`?variant=`)** — the service stores the\noriginal plus optional re-encoded variants. The variant tier\nis the same query string for every supported MIME class; what\nthe server produces depends on the source type:\n\n  * `original` (default) — uncompressed bytes, format as\n    uploaded. SHA-256 matches the upload-time checksum.\n  * `high` (q=90) — light recompression, near-original quality.\n  * `medium` (q=75, max 1280 px) — balanced; suitable for previews.\n  * `low` (q=50, max 320 px) — aggressive; suitable for thumbnails.\n\n**Per-MIME thumbnail behaviour** for `?variant=low` (and the\nsmaller-cap `?variant=medium`):\n\n  * `image/*` → resized + re-encoded in the original format\n    (JPEG/PNG/WebP/GIF). Aspect ratio preserved.\n  * `video/*` → JPEG poster frame extracted via ffmpeg at the\n    1-second mark, scaled to fit the cap.\n  * `application/pdf` → JPEG of the first page rendered via\n    pdftoppm at the cap resolution.\n  * `audio/*` → transparent PNG waveform (16:9), decoded with\n    symphonia (ffmpeg fallback for containers symphonia can't\n    read, e.g. Opus-in-WebM voice notes).\n  * 3D models (uploaded `?as=3d-model`) → 320×320 transparent\n    PNG rendered by f3d. Generated eagerly at upload.\n  * MIMEs without an adapter → HTTP 404 (no thumbnail). UI is\n    expected to fall back to a generic icon.\n\n**3D model viewer tiers** — for media uploaded with\n`?as=3d-model`, the glTF binary is served at three\nlevel-of-detail tiers so the viewer can stream a light model on\nmobile and the full one on desktop (the 3D analogue of image\ntiers). All three are meshopt-compressed glTF binary\n(`model/gltf-binary`):\n\n  * `web` — full geometry + full-resolution texture.\n  * `web-medium` — ~50% triangles, texture capped at 2048 px.\n  * `web-low` — ~20% triangles, texture capped at 1024 px (the\n    mobile default the viewer requests first).\n\nTexture downscaling happens in Blender (gltfpack only resizes\ntextures alongside KTX2, which we avoid to keep the viewer\ndecoder-free); gltfpack does the geometry decimation. These\ntiers are produced by the asynchronous post-upload pipeline (see\n`?as=3d-model` under **Upload**), never lazily on demand.\n\n**Three response states for a glb tier:**\n  * `200` — variant ready, body is the glb bytes.\n  * `202 Accepted` — model is still converting\n    (`metadata.model_3d_status = \"processing\"`). Body is a JSON\n    status `{\"error\":\"MODEL_3D_PROCESSING\", \"message\":…}`, NOT\n    media bytes. Clients should back off + refresh, not pass the\n    body to a model viewer.\n  * `404 Not Found` — no such 3D asset (legacy upload that\n    predates LODs, conversion `failed`, or the source was never\n    uploaded as 3D). Frontend falls back to `web` or shows a\n    \"no preview\" state.\n\nAll thumbnail responses ship `Content-Type: image/jpeg` or\n`image/png` regardless of the source MIME — a single `\u003cimg\u003e`\nelement renders every case.\n\nAliases also accepted: `orig` / `raw` for original, `hi` for\nhigh, `med` for medium, `lo` for low. Invalid values return\nHTTP 400 with the allowed list.\n\nThe `medium` and `low` variants for images are generated\neagerly on upload (when `IMAGE_EAGER_MEDIUM=true`); video /\nPDF / audio thumbnails plus all other levels are generated\nlazily on first request and cached for subsequent ones.\nGeneration is best-effort — adapters that can't decode the\nsource surface 404, not 500.\n\nThe `Content-Disposition` filename is always the upload's\ndisplay name regardless of variant — the tier is invisible\nto the user-facing UI.\n\n**Range / streaming (video only)** — when the underlying\nMIME is `video/*`, the response includes `Accept-Ranges: bytes`\nand the endpoint honours `Range:` request headers. This lets\nthe browser's HTML5 `\u003cvideo\u003e` element scrub mid-file without\ndownloading from byte 0. Supported forms:\n\n  * `Range: bytes=N-M`  → 206 Partial Content\n  * `Range: bytes=N-`   → 206 from N to EOF\n  * `Range: bytes=-M`   → 206 last M bytes\n  * Multi-range / unparseable → ignored, full 200 returned\n  * Out-of-bounds → 416 with `Content-Range: bytes */\u003ctotal\u003e`\n\nNon-video MIMEs do NOT advertise `Accept-Ranges` and ignore\nany `Range:` header — they always stream the full body.\n","query_params":[{"name":"variant","type":"string","default":"original","description":"`original` (default) | `high` | `medium` | `low` | `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\nwant size/MIME/checksum without paying for the download.\n"},{"name":"Rename Media","method":"PATCH","path":"/media/{id}","auth":"JWT","permission":"media:update","description":"Updates the user-facing `original_name`. The on-disk filename\nis unchanged; only the display name is rewritten.\n","body":[{"name":"original_name","type":"string","required":true,"description":"New display name. Client should preserve the extension."}],"example_body":"{ \"original_name\": \"vacation-2026-04.jpg\" }\n"},{"name":"Update Media Visibility","method":"PATCH","path":"/media/{id}/visibility","auth":"JWT","permission":"media:update","description":"Switches the media row's visibility level. Does not propagate\nto shared links; existing tokens keep working until they\nexpire or are deleted.\n","body":[{"name":"visibility","type":"string","required":true,"description":"`public` | `organization` | `private`."}],"example_body":"{ \"visibility\": \"private\" }\n"},{"name":"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\na media that was uploaded with `?as=3d-model`. Use when\n`metadata.model_3d_status` is `\"failed\"` (transient binary\nfailure, a corrupt input, etc.) or stuck `\"processing\"` after a\nservice restart had killed the prior detached task.\n\nOwner-only. Flips the row's status back to `\"processing\"` and\nspawns the pipeline asynchronously — the response is **202\nAccepted** as soon as the task is queued. The pipeline updates\nthe status to `\"ready\"` (variants on disk) or `\"failed\"` (+ a\nshort `model_3d_error`) when it finishes.\n\nIdempotent under concurrent calls: if the row is already\n`\"processing\"`, the second call is a no-op (returns 202 without\nspawning another task). Conversion is deterministic so even a\nracing double-spawn would converge to the same outcome.\n\nReject conditions:\n  * 404 `NOT_FOUND` — media does not exist\n  * 403 `FORBIDDEN` — caller is not the owner\n  * 422 `MODEL_3D_VALIDATION` — media was not uploaded as a 3D\n    model (no `metadata.model_3d` flag), so there is nothing to\n    re-convert. Re-upload via `POST /upload?as=3d-model`.\n  * 503 `MODEL_3D_UNAVAILABLE` — Blender is not configured on\n    this host (see `model_3d_enabled` on `/health`).\n","example_body":"# No body — the media id is in the path. Send an empty POST.\ncurl -X POST 'https://media.ikavia.com/api/media/c1502f8c-cd40-40a7-b8b9-ecd320974298/reprocess-3d' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e'\n","example_response":"# 202 Accepted — task spawned. Refresh /media/{id}/info or the\n# listing to observe model_3d_status change to ready/failed.\n{\n  \"status\": \"processing\",\n  \"message\": \"3D conversion restarted\"\n}\n"},{"name":"Delete Media","method":"DELETE","path":"/media/{id}","auth":"JWT","permission":"media:delete","description":"Removes every blob (original + each derived variant) from\nstorage, then deletes the `media` row. The matching\n`media_variants` rows go away via FK cascade. A\n`StorageError::NotFound` is tolerated per file so retries\non a partially-failed delete are idempotent.\n\nA failure cleaning a single variant blob is logged but does\nnot abort the delete — the user's media still goes away;\nworst case is one orphan blob on disk. Operators can sweep\norphans by walking the storage directory and reconciling\nagainst `media_variants.storage_path`.\n\n**Deletion code guard (optional, per row).** If the file was\nuploaded with `X-Deletion-Code: \u003csecret\u003e`, the row is\n\"guarded\" — every delete must echo the same plaintext via\nthe same header. Distinct 403 body codes signal *which*\nfailure mode so UIs can prompt vs re-prompt:\n\n  * `DELETION_CODE_REQUIRED` — guarded row, no header sent.\n  * `INVALID_DELETION_CODE`  — guarded row, header sent but\n    the hash didn't verify. Re-prompt the user.\n  * `FORBIDDEN`              — role check failed (not owner,\n    not org-admin). A code can't fix this.\n\nRows uploaded without the header (or before this feature was\nreleased) ignore the header on delete — behaviour is\nunchanged.\n","example_response":"{\n  \"deleted\": true,\n  \"media_id\": \"9e1c…\",\n  \"filename\": \"9e1c….jpg\",\n  \"freed_bytes\": 248122\n}\n"},{"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/*`,\n`application/json`, `application/xml`, `application/x-yaml`,\n`application/javascript`, plus extension-based fallback for\nmarkdown / source code: `.md`, `.mmd`, `.txt`, `.log`, `.csv`,\n`.tsv`, `.json`, `.yaml`, `.toml`, `.xml`, `.rs`, `.ts`,\n`.py`, `.go`, `.java`, `.sql`, `.sh`, etc.).\n\nReturns up to 8 KB (cap is hard). Non-text MIMEs → 415 with\nbody `UNSUPPORTED`. UTF-8 boundary safe via lossy decode so\na cut mid-codepoint yields `U+FFFD` instead of a 5xx.\n","query_params":[{"name":"bytes","type":"int","default":"8192","description":"How many bytes to return. Clamped server-side to 8192."}],"example_response":"{\n  \"id\": \"3ca7…\",\n  \"mime_type\": \"text/markdown\",\n  \"content\": \"# Title\\n\\nFirst paragraph…\",\n  \"truncated\": true,\n  \"bytes_returned\": 8192,\n  \"total_bytes\": 42130\n}\n"},{"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\nextracting any payload**. Reads only the central-directory\nheaders; bounded at 5 000 entries (zip-bomb cap) with a\n`truncated` flag past that.\n\nFormat is auto-detected from MIME, with a sensible extension\nfallback so `application/octet-stream` uploads ending in\n`.zip` / `.tgz` are still recognised. Non-archive MIMEs → 415.\n\nEntry paths are sanitised: any `..` segments and leading\nslashes are stripped before serialisation, so the UI can\nrender `name` verbatim without re-validating.\n","example_response":"{\n  \"format\": \"zip\",\n  \"entries\": [\n    { \"name\": \"README.md\", \"size_bytes\": 1234, \"is_dir\": false, \"modified_at\": \"2026-04-12T10:00:00Z\" },\n    { \"name\": \"src\", \"size_bytes\": 0, \"is_dir\": true, \"modified_at\": null }\n  ],\n  \"total_entries\": 42,\n  \"truncated\": false\n}\n"}]},{"id":"recent","title":"Recent Files","description":"Per-user \"recently accessed\" history. Tracks accesses pushed\nfrom the client and exposes a chronologically-ordered list. The\naccess event is recorded asynchronously of the underlying\ndownload, so a missing track call does not block playback.\n","endpoints":[{"name":"List Recent Files","method":"GET","path":"/recent","auth":"JWT","permission":"media:list","description":"Returns the caller's most recently accessed media,\nnewest-first. Capped at the value the client requests\n(default applied server-side).\n","query_params":[{"name":"limit","type":"int","description":"Max items to return; server applies a sane upper bound."}]},{"name":"Track Access","method":"POST","path":"/media/{id}/track","auth":"JWT","permission":"media:list","description":"Records an access event for the given media. Idempotent at\nthe row level — repeated tracks update the existing row's\ntimestamp instead of inserting duplicates.\n","body":[{"name":"access_type","type":"string","description":"Free-form classifier (e.g. `view`, `download`, `preview`)."}],"example_body":"{ \"access_type\": \"view\" }\n"},{"name":"Clear History","method":"DELETE","path":"/recent","auth":"JWT","permission":"media:list","description":"Wipes the caller's entire recent-files history. The\nunderlying media files are untouched.\n"}]},{"id":"s2s","title":"Service-to-service","description":"Endpoint introspeksi untuk **service backend lain** yang perlu\nmemvalidasi `media_id` sebelum menyimpannya sebagai foreign-key\n(mis. WACCA validate cover/photo outlet, planner attachment,\nsurvey upload reference). Berbeda dari `/media/{id}/info`:\n\n* Tidak menerapkan visibility filter — siapa pun caller\n  service-principal bisa melihat existence + ownership metadata.\n* Tidak menerima JWT human (`principal_type: \"human\"`) — 403.\n* Tidak mengembalikan content (size, checksum, dll.) — hanya\n  existence + visibility + organization + expiry, yaitu\n  minimum yang dibutuhkan untuk same-org / pre-expiry validation\n  di consumer.\n\n**Auth.** Pakai `X-API-Key` (di-exchange ke service-principal\nJWT lewat `ApiKeyConverter`) atau `Authorization: Bearer \u003cjwt\u003e`\ndengan claim `principal_type: \"service\"`. Token tanpa\n`principal_type` (format lama) diperlakukan sebagai non-service\n— fail-closed.\n\nPola ini cermin endpoint S2S account-service yang serupa.\n","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\nuntuk validasi single-field `cover_outlet_media_id` /\n`attachment_id` saat consumer menerima request dari owner.\n\nResponse shape:\n\n  * `exists` selalu `true` di body 200 — kalau tidak ada,\n    response-nya 404 dengan body error standar.\n  * `visibility` salah satu `public` / `organization` /\n    `private`. Consumer enforce same-org rule sendiri.\n  * `organization_id` `null` ketika row orphan (tidak ada\n    org-nya). Untuk media `visibility=organization`,\n    consumer bisa cocokkan dengan org-nya sendiri.\n  * `expired` dihitung server-side dari `expires_at`\n    vs `NOW()`. Tidak ada off-by-one antar service clock.\n","example_response":"{\n  \"exists\": true,\n  \"visibility\": \"organization\",\n  \"organization_id\": \"545c61c3-b211-406a-bd0a-0140e7bbe941\",\n  \"expired\": false\n}\n"},{"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\nsatu round-trip. Pakai ini saat consumer menyimpan record\ndengan beberapa media reference (mis. outlet dengan cover +\nphoto, atau survey response dengan multiple attachment).\n\n**Partial result by contract.** Response 200 berisi satu\nentry per id yang di-request — bahkan untuk id yang tidak\nditemukan (entry-nya `{id, exists: false}`). Consumer bisa\nsimpan yang valid + flag yang invalid dalam satu pass.\n\n**Order preserved.** `items[i].id == request.ids[i]`. Kalau\ncaller kirim duplicate id, response berisi duplikat juga.\n\n**Cap 100 ids.** Body di luar cap → 400 `VALIDATION_ERROR`.\nBody kosong → 400 dengan pesan \"ids must be a non-empty\narray\".\n","body":[{"name":"ids","type":"array\u003cuuid\u003e","required":true,"description":"1-100 media id. Duplicates allowed; their entries are duplicated 1:1 in the response."}],"example_body":"{\n  \"ids\": [\n    \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n    \"ffffffff-ffff-ffff-ffff-ffffffffffff\",\n    \"10cd8c18-8f45-45c5-8c0d-d7c545f2d729\"\n  ]\n}\n","example_response":"{\n  \"items\": [\n    {\n      \"id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n      \"exists\": true,\n      \"visibility\": \"public\",\n      \"organization_id\": \"545c61c3-b211-406a-bd0a-0140e7bbe941\",\n      \"expired\": false\n    },\n    {\n      \"id\": \"ffffffff-ffff-ffff-ffff-ffffffffffff\",\n      \"exists\": false\n    },\n    {\n      \"id\": \"10cd8c18-8f45-45c5-8c0d-d7c545f2d729\",\n      \"exists\": true,\n      \"visibility\": \"organization\",\n      \"organization_id\": null,\n      \"expired\": true\n    }\n  ]\n}\n"}]},{"id":"shared-links","title":"Shared Links","description":"Generate opaque token URLs that grant external (often\nanonymous) access to a single media file. Supports password\ngating (bcrypt), expiry, and a hard download cap.\n\n**URL shape:** `{base_url}/share/{token}` — note the path is\n`/share/`, **not** `/api/v1/share/`. The `/api/v1` prefix is\nreserved for the JSON management API (create, list, delete);\nthe public access endpoint sits at the top level so it can be\nshared verbatim without `/api/...` looking like an internal\nroute. Edge nginx proxies `^/share/` straight to the backend.\n","endpoints":[{"name":"Create Shared Link","method":"POST","path":"/media/{id}/share","auth":"JWT","permission":"media:download","description":"Mints a shared-link token for the given media. The caller\nmust own the media or have download access through it.\nVisibility defaults to `restricted` (creator-only) — set\n`public` for anonymous access or `organization` to gate by\nthe caller's org.\n","body":[{"name":"visibility","type":"string","description":"`public` | `organization` | `restricted`.","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":"{\n  \"visibility\": \"public\",\n  \"password\": \"spring-2026\",\n  \"expires_in_hours\": 168,\n  \"max_downloads\": 50\n}\n","example_response":"{\n  \"id\": \"ab12…\",\n  \"token\": \"8f3c2a1b…\",\n  \"share_url\": \"https://media.example.com/share/8f3c2a1b…\",\n  \"visibility\": \"public\",\n  \"has_password\": true,\n  \"expires_at\": \"2026-05-07T08:14:22Z\",\n  \"max_downloads\": 50,\n  \"created_at\": \"2026-04-30T08:14:22Z\"\n}\n"},{"name":"Access Shared Link","method":"GET","path":"/share/{token}","auth":"none","description":"Anonymous (or authenticated, for `restricted`/`organization`\nlinks) entry point. Streams the media bytes back. The\ndownload counter is incremented atomically.\n\nFailure modes (401/403/404):\n  * link not found / revoked → 404\n  * expired → 410\n  * download cap reached → 429\n  * password missing/wrong → 401\n  * caller not eligible for `restricted` / `organization` → 403\n","query_params":[{"name":"password","type":"string","description":"Required when the link was created with a password."}]},{"name":"List My Shared Links","method":"GET","path":"/shared-links","auth":"JWT","permission":"media:download","description":"Lists shared links the caller created. Paginated.\n","query_params":[{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."}]},{"name":"Delete Shared Link","method":"DELETE","path":"/shared-links/{id}","auth":"JWT","permission":"media:download","description":"Revokes a shared link. Subsequent `GET /share/:token`\nrequests return 404. The underlying media file is\nunaffected.\n"}]},{"id":"starred","title":"Starred Files","description":"Per-user bookmark list. Each star carries an optional free-form\n`notes` field. Operations are idempotent — re-starring a file\nupdates the notes/timestamp instead of duplicating rows.\n","endpoints":[{"name":"List Starred","method":"GET","path":"/starred","auth":"JWT","permission":"media:list","description":"Lists the caller's starred media in reverse-chronological\norder (most recently starred first). Each item includes the\nunderlying media metadata plus `starred_at` and `notes`.\n","query_params":[{"name":"page","type":"int","default":"1","description":"1-indexed page number."},{"name":"per_page","type":"int","default":"20","description":"Items per page."}]},{"name":"Star File","method":"POST","path":"/media/{id}/star","auth":"JWT","permission":"media:list","description":"Stars (or re-stars) a media file. Optional `notes` are\nstored alongside the star. Calling this on an\nalready-starred file is a no-error update.\n","body":[{"name":"notes","type":"string","description":"Optional annotation, surfaced in the listing."}],"example_body":"{ \"notes\": \"Reference for the launch deck\" }\n"},{"name":"Unstar File","method":"DELETE","path":"/media/{id}/star","auth":"JWT","permission":"media:list","description":"Removes the star. Idempotent — unstarring an already-unstarred\nfile returns success.\n"},{"name":"Toggle Star","method":"POST","path":"/media/{id}/toggle-star","auth":"JWT","permission":"media:list","description":"Convenience endpoint that flips the star state. Useful for\nUIs that bind a single button to \"star/unstar\" without\ntracking current state client-side.\n"},{"name":"Check Star Status (Bulk)","method":"POST","path":"/starred/check","auth":"JWT","permission":"media:list","description":"Bulk lookup: given a list of media IDs, returns which ones\nthe caller has starred. Lets a list view annotate every row\nin one round-trip.\n","body":[{"name":"media_ids","type":"array\u003cstring\u003e","required":true,"description":"Up to N media UUIDs to check at once."}],"example_body":"{ \"media_ids\": [\"9e1c…\", \"f9a1…\", \"ab12…\"] }\n","example_response":"{\n  \"items\": [\n    { \"media_id\": \"9e1c…\", \"is_starred\": true },\n    { \"media_id\": \"f9a1…\", \"is_starred\": false },\n    { \"media_id\": \"ab12…\", \"is_starred\": true }\n  ]\n}\n"}]},{"id":"upload","title":"Upload","description":"Streamed multipart upload. The body is consumed once and written\ndirectly to disk; quota is enforced at the database CHECK\nconstraint. The client-declared MIME is treated as a hint —\nmagic-byte sniff wins on conflict.\n","endpoints":[{"name":"Upload Media","method":"POST","path":"/upload","auth":"JWT","permission":"media:upload","description":"Streams a single multipart file part (field name `file`) into\nstorage. The response carries the canonical media metadata,\nincluding the SHA-256 checksum and the storage path of the\n**original** bytes.\n\n**Accepted credentials.** Any RS256 JWT carrying `media:upload`\nis accepted — both ordinary **user** tokens and **service-principal**\ntokens (`principal_type: service`), including the short-lived token\na headless backend obtains from the Account Service\n`POST /auth/system-token` flow. There is no anonymous path: a valid\ntoken is always required (missing/invalid → 401). Service-principal\nuploads are attributed to the token's `sub` as `owner_id`; set\n`visibility=public` if the resulting file must be readable without\nauth (e.g. an anonymous applicant's document fetched later via\n`GET /media/{id}`).\n\n**Storage quota by principal.** Human uploads draw on the per-user\nbucket (`MAX_STORAGE_PER_USER`, default 1 GiB). Service-principal\nuploads — which funnel many end-users through a single owner id —\ndraw on a separate, typically larger per-service bucket\n(`MAX_STORAGE_PER_SERVICE`, default 10 GiB). The limit is applied\nboth as the fast-path pre-check and as the authoritative\n`storage_quota.max_bytes` for the service owner; over-quota uploads\nfail with 413 and the staged blob is removed.\n\n**Cross-organisation attribution (trusted backends).** The\n`X-Organization-Id` header lets the caller pick which organisation\nthe resulting media belongs to. For ordinary user tokens that org\nmust be in the caller's own membership set — otherwise 403, no\nescalation. A **headless backend** that serves end-users across\nmany orgs (e.g. a careers portal uploading documents on behalf of\nanonymous applicants) can bypass the membership gate when ALL\nthree hold:\n\n  1. The token is `principal_type: service` (cryptographically\n     pinned by account-service; cannot be forged by a user token).\n  2. The token carries the `media:act_as_any_org` permission.\n  3. `X-Organization-Id: \u003ctarget-org-uuid\u003e` is sent on the\n     upload request.\n\nThe media's `organization_id` is then set to the supplied UUID;\n`owner_id` remains the service principal's `sub`. Without the\nheader the existing fallback applies (the token's `org_id` claim,\nor NULL if absent). A user token granted `media:act_as_any_org`\nalone is NOT elevated — the principal-type factor is a deliberate\ndefence-in-depth against accidental grant. The cross-org write is\nlogged with the service principal's `sub` and target org for\naudit.\n\n**Validation order:** filename → MIME allow/block → quota\nfast-path → stream to disk (max-size enforced authoritatively)\n→ magic-byte sniff → rebuild against policy → metadata probe\n→ DB row.\n\n**Original is never overwritten.** For images, the upload\npipeline additionally generates the `low` + `medium` variants\n(true thumbnails at 320 / 1280 px max) post-DB-save when\n`IMAGE_EAGER_MEDIUM=true`. Video / PDF / audio thumbnails are\ngenerated lazily on first `GET /media/{id}?variant=low`.\nVariant generation is best-effort — failure here logs a\nwarning but the upload itself succeeds with the original\ncommitted.\n\n**Metadata probe** runs at end of upload and writes\ntype-specific facts to `media.metadata`:\n  * `image/*` → `width`, `height` (via the `image` crate)\n  * `audio/*` → `duration_ms`, `bitrate_bps`, `sample_rate_hz`,\n    `channels` (via `lofty`)\n  * `video/*` → `duration_ms`, `width`, `height`, `bitrate_bps`\n    (via `ffprobe`)\n\nCaller-supplied `metadata` (in the form body) wins per-key;\nthe probe only fills keys the caller left empty.\n\n**Optional `X-Deletion-Code` header (per row).** When present,\nserver stores a `sha256:\u003chex\u003e` hash of the supplied secret.\nFuture deletes against this row must echo the same plaintext\nvia the same header, or be refused with 403\n`DELETION_CODE_REQUIRED` / `INVALID_DELETION_CODE`. Useful\nfor service-to-service uploads where the panel web UI / other\ncallers must not be able to delete files arbitrarily.\n\nCaller is responsible for storing the plaintext code — server\nprovides no recovery. Code must be 16-256 printable ASCII\ncharacters; out-of-range or non-printable input → 400.\n\n**Optional `?as=3d-model` (per upload — ASYNCHRONOUS).** Routes\nthe upload through the 3D pipeline: **Blender** import/export →\ngltfpack compress → f3d render preview. Blender is the primary\nconverter (accurate geometry + PBR materials + textures);\n**assimp is a per-format fallback** for inputs Blender can't read\n(e.g. `.3ds`) or transient Blender failures. Accepted inputs:\n  * `.glb` / `.gltf` (binary) — already self-contained\n  * `.stl` / `.ply` (ASCII or binary) — geometry-only\n  * `.obj` without `mtllib` declarations — bare geometry\n  * `.zip` / `.tar.gz` — must contain a recognisable entry\n    (`.obj`+`.mtl`+textures, or `.glb`, `.stl`, etc.)\n  * `.fbx` / `.dae` — Blender importers; `.3ds` via assimp fallback\n\n**Lifecycle.** The endpoint **returns 201 immediately** once the\noriginal bytes are saved — it does NOT block on conversion. The\nresponse carries `model_3d: true` and `model_3d_status:\n\"processing\"`. The pipeline runs detached (it survives client\ndisconnect) and writes the final state to the same row:\n\n  * `model_3d_status: \"ready\"` — variants `web` (full),\n    `web-medium`, `web-low` (mobile default), and `low` (PNG\n    thumbnail) are all on disk and listed.\n  * `model_3d_status: \"failed\"` — conversion errored; a short\n    `model_3d_error` reason is recorded; the original file is\n    kept (no rollback). Owner can re-trigger via\n    `POST /media/{id}/reprocess-3d`.\n\nConcurrency is capped by `MEDIA_3D_MAX_CONCURRENCY` (default 2);\nextra pipelines queue rather than thrash the host. On service\nrestart, any row stuck in `processing` is swept to `failed` at\nstartup so it's recoverable via reprocess.\n\n**Variant fetch while converting.** `GET /media/{id}?variant=web*`\nfor a model whose status is `processing` returns **`202 Accepted`**\nwith body `{\"error\":\"MODEL_3D_PROCESSING\", ...}` (distinct from\n`404` \"no such 3D asset\" and `200` ready). Clients should\nback off and retry after a refresh.\n\nReject conditions surfaced as `model_3d_status: \"failed\"` (the\nupload itself returns 201; the failure shows on the row):\n  * naked `.obj` with `mtllib material.mtl` — bundle it\n    in a zip alongside the referenced material + textures\n  * archive with no recognisable 3D file inside\n  * archive past size caps (50 MB / file, 200 MB cumulative,\n    5 000 entries) — zip-bomb defence\n  * unsupported extension entirely\n\n**Disabled-feature** (503 `MODEL_3D_UNAVAILABLE` returned\nsynchronously on the upload itself): the host has no runnable\nBlender (`MEDIA_BLENDER_BIN` unset / not installed). The frontend\nreads `model_3d_enabled` from `GET /health` to warn before the\nuser uploads.\n\nWhen `as` is NOT set, even `.glb` / `.stl` uploads are stored\nas plain bytes — no 3D processing (no variants, no status field).\n\nOn any pre-conversion failure (auth, MIME, quota, body-parse)\nthe staged blob is removed; the invariant\n**\"DB row ⇒ storage file exists\"** is preserved.\n","query_params":[{"name":"folder_id","type":"uuid","description":"Target folder. Omit for root."},{"name":"visibility","type":"string","default":"organization","description":"`public` | `organization` | `private`. Defaults to `organization`."},{"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\n# `visibility` knob only affects who can READ the file later,\n# not who can upload).\n\n# 1) Organization-scoped (default). Anyone in the same org as\n#    the uploader can download.\ncurl -X POST 'http://localhost:3001/upload?folder_id=4f2c…\u0026visibility=organization' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/photo.jpg'\n\n# 2) Public. Anyone with the media id can download anonymously\n#    via GET /media/{id} — no token required on the read side.\n#    Useful for assets that are meant to be embedded on a\n#    public web page.\ncurl -X POST 'http://localhost:3001/upload?visibility=public' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/banner.jpg'\n\n# 3) Private. Only the uploader can download (and org admins\n#    if your deployment grants them override).\ncurl -X POST 'http://localhost:3001/upload?visibility=private' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/secret.pdf'\n\n# 4) Guarded upload (X-Deletion-Code). The server hashes the\n#    secret; remember to keep the plaintext on the caller side\n#    or the file becomes undeleteable from outside ops/DB.\nCODE=$(openssl rand -base64 32 | tr -d '\\n')\ncurl -X POST 'http://localhost:3001/upload' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -H \"X-Deletion-Code: $CODE\" \\\n  -F 'file=@/path/to/asset.png'\n\n# 5) 3D model upload (?as=3d-model). Server runs Blender →\n#    gltfpack → f3d, persisting three meshopt-compressed glTF\n#    LOD tiers (?variant=web / web-medium / web-low) plus a\n#    320×320 PNG preview (?variant=low). Bundle OBJ + MTL +\n#    textures in a single zip; naked .obj with `mtllib` is\n#    rejected. 503 MODEL_3D_UNAVAILABLE if Blender isn't\n#    configured on the host.\ncurl -X POST 'http://localhost:3001/upload?as=3d-model' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/scene.zip'\n\n# 6) Standalone .glb — also goes through the pipeline when\n#    `?as=3d-model` is set; the server re-imports + re-packs\n#    into the LOD tiers for the viewer-served format.\ncurl -X POST 'http://localhost:3001/upload?as=3d-model' \\\n  -H 'Authorization: Bearer \u003ctoken\u003e' \\\n  -F 'file=@/path/to/model.glb'\n\n# 7) Service-principal upload (e.g. a headless backend acting for\n#    an anonymous applicant). The bearer is a System-account token\n#    minted by the Account Service POST /auth/system-token flow and\n#    carrying `media:upload`. `visibility=public` lets the file be\n#    fetched later without auth. Counts against the per-service\n#    quota (MAX_STORAGE_PER_SERVICE), not the per-user bucket.\ncurl -X POST 'http://localhost:3001/upload?visibility=public' \\\n  -H 'Authorization: Bearer \u003csystem-account-jwt\u003e' \\\n  -F 'file=@/path/to/cv.pdf'\n\n# 8) Cross-organisation upload by a trusted backend. The bearer is\n#    a System-account token carrying BOTH `media:upload` AND\n#    `media:act_as_any_org`. X-Organization-Id picks the target\n#    org — the membership gate is skipped because both factors\n#    hold (principal_type=service + permission). The resulting\n#    media is attributed to that org; members of the org can read\n#    it under `visibility=organization` without needing the file\n#    to be public. A user token granted `media:act_as_any_org`\n#    alone would still be gated by membership (403).\ncurl -X POST 'http://localhost:3001/upload?visibility=organization' \\\n  -H 'Authorization: Bearer \u003csystem-account-jwt\u003e' \\\n  -H 'X-Organization-Id: 660e8400-e29b-41d4-a716-446655440001' \\\n  -F 'file=@/path/to/applicant-cv.pdf'\n","example_response":"# Image / non-3D upload — `model_3d` is false, status omitted.\n{\n  \"id\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11\",\n  \"filename\": \"9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11.jpg\",\n  \"original_name\": \"photo.jpg\",\n  \"mime_type\": \"image/jpeg\",\n  \"size_bytes\": 248122,\n  \"checksum\": \"sha256:7e1f…\",\n  \"visibility\": \"organization\",\n  \"folder_id\": \"4f2c…\",\n  \"created_at\": \"2026-04-30T08:14:22Z\",\n  \"model_3d\": false,\n  \"model_3d_status\": null\n}\n\n# 3D upload (`?as=3d-model`) — returns immediately with status\n# `processing`. The detached pipeline writes `ready` / `failed`\n# to the same row when it finishes; clients refresh the list to\n# see the new state, or hit POST /media/{id}/reprocess-3d to\n# retry a failure.\n{\n  \"id\": \"c1502f8c-cd40-40a7-b8b9-ecd320974298\",\n  \"filename\": \"c1502f8c-cd40-40a7-b8b9-ecd320974298.zip\",\n  \"original_name\": \"scene.zip\",\n  \"mime_type\": \"application/zip\",\n  \"size_bytes\": 29144542,\n  \"checksum\": \"sha256:a3…\",\n  \"visibility\": \"organization\",\n  \"folder_id\": null,\n  \"created_at\": \"2026-05-23T13:46:49Z\",\n  \"model_3d\": true,\n  \"model_3d_status\": \"processing\"\n}\n"}]}],"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":"📱 Client","type":"client","color":"#0ea5e9","position":{}},{"id":"account","label":"🔐 Account Service","type":"service","color":"#4f46e5","position":{}},{"id":"media","label":"🎞️ Media Service","type":"service","color":"#10b981","position":{}},{"id":"jwt","label":"🔑 JWT","type":"data","color":"#f59e0b","position":{}},{"id":"storage","label":"💾 Local Storage","type":"external","color":"#64748b","position":{}},{"id":"postgres","label":"🐘 PostgreSQL","type":"external","color":"#64748b","position":{}}],"flow_diagram_edges":[{"source":"client","target":"account","label":"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":"📁","primary_color":"#BC3362"}}