Source: ddx-documentation/adr/0011-media-proxy.md
- **Status:** Accepted (2026-10-23) - **Supersedes:** [ADR 0010](/en/docs/adr/0010-media-path) (presigned URLs) - **Binds:** WS4 G4.1-G4.3, WS4 NOT (no public bucket, no media without an org check, no CDN), invariants 1, 2, 3, 18 - **Code:** `ddx-api/src/media/**`, contract `packages/ddx-contracts/src/media.ts` ## Context ADR 0010 let the browser PUT and GET bytes directly against MinIO with short-lived presigned URLs. The owner decided on 2026-10-23 that this is not acceptable: - MinIO must be reached **only** by ddx-api. Upload, download and streaming are proxied browser / mobile → ddx-web BFF `/api/v1` → ddx-api → MinIO. - No presigned URL may reach a client. A presigned URL is a bearer credential: it can be copied, logged by a proxy, or shared, and it bypasses the org check for its lifetime. - MinIO has no CORS. The browser never knows the MinIO host. The RQ8 spike numbers (direct PUT 12.1 ms vs relay 18.8 ms median, 2 MiB) show the relay cost is small on one host. One path for every byte also keeps the trust boundary simple: the BFF session and the ddx-api guard chain run on every read and every write. ## Decision 1. **ddx-api is the only MinIO client.** `ObjectStoreService` holds one S3 client on `S3_ENDPOINT`. `S3_PUBLIC_ENDPOINT`, `MEDIA_PRESIGN_TTL_SECONDS` and the presigner are gone. Buckets stay private. 2. **Routes** (ddx-api; the browser calls the same paths under `/api/v1`): | Method | Path | Body / headers | Answer | |---|---|---|---| | `POST` | `/orgs/:orgSlug/media?kind=<kind>[&projectId=<id>]` | raw file bytes; `Content-Type` = file MIME; `Content-Length` required | `201 { data: MediaObject }` | | `GET` | `/orgs/:orgSlug/media/:id` | — | `200 { data: MediaObject }` | | `GET` | `/orgs/:orgSlug/media/:id/content` | optional `Range: bytes=a-b`, `If-None-Match` | `200` / `206` bytes, `304`, `416` | Upload errors: `400` empty, short, aborted body or bytes that are not the declared type · `403` foreign slug, service key on avatar, missing project-write role for `projectId` · `404` unknown project · `411` no `Content-Length` (chunked) · `413` over the kind cap · `415` MIME not allowed for the kind. Read errors: `404` for a foreign or unknown id — the row is looked up through `TenantPrismaService` first, so no byte of another org is ever read from MinIO. 3. **One upload route, raw body.** Metadata travels in the query (`kind`, `projectId`), the bytes are the request body. No multipart parser, no two-step create-then-PUT, no pending row. The role and project checks run **before** the body is read. 4. **Streaming, never buffering.** Upload: `req → UploadGuard → PutObject` with the exact `ContentLength` (one PutObject; no multipart, no `lib-storage`). `UploadGuard` counts the bytes against `Content-Length` and checks the file signature of binary types (PNG, JPEG, GIF, WEBP, PDF, WEBM, OGG, WAV, MP4, MPEG) on the first 16 bytes only. A violation fails the PutObject; the object is deleted and the cause is answered (`400` / `413`). Download: the S3 `GetObject` body stream is piped to the response. The handler does not await the pipe, so `TimeoutInterceptor` never cuts a long download. 5. **Range.** One byte range per request (`bytes=a-b`, `bytes=a-`, `bytes=-n`), resolved against the stored size and passed to S3 `GetObject` as `Range`. The answer is `206` with S3's `Content-Range`. An unsatisfiable or malformed `bytes=` range is `416` with `Content-Range: bytes */<size>`. A multi-range list or another unit is ignored (RFC 9110 allows it) and the whole object is sent. PDF, audio and video players can seek. 6. **Content headers.** `Content-Type` (stored MIME), `Content-Length`, `ETag`, `Accept-Ranges: bytes`, `Cache-Control: private`, `Content-Disposition: inline`. The content route skips the `{ data }` envelope (`@SkipResponseWrap` + `@Res`). 7. **Body limit (two tiers kept).** The global `express.json` / `urlencoded` parsers keep the 10 MB `BODY_LIMIT_MB`. They only consume their own content types, and no allowed media MIME is JSON or form data, so the raw upload stream reaches the handler unparsed. The upload route's own limit is the per-kind cap of `MEDIA_LIMITS` (avatar 2 MiB, image 10 MiB, document 25 MiB, audio 50 MiB), checked on `Content-Length` and on the bytes. The global limit is NOT raised. 8. **Keys and links unchanged from 0010.** `org/<orgId>/<kind>/<id>`, `org/<orgId>/avatars/<userId>/<id>`; `user.image` and `project.coverImageKey` hold the key. The media id is a server-minted uuid. 9. **Demo data.** `pnpm -C ddx-api db:seed` (step `media`) stores one PDF and one PNG for `acme-health` (fixed ids, idempotent). ## Why no presign - A presigned URL is a credential that leaves our control. With the proxy, every byte passes the session check and the org check at the moment it is read. - The browser would need the MinIO host and MinIO would need CORS. Both widen the surface. - Revocation: a presigned GET stays valid until it expires, even after the user loses access. A proxied read is re-checked on every request. - One path keeps mobile, browser and server callers the same. ## Consequences - Every byte crosses BFF and ddx-api: higher CPU and memory per transfer than direct MinIO (small on one host, RQ8). Streams are never buffered, so memory stays flat. - The BFF catch-all must pass `Range`, `If-None-Match`, `Content-Type`, `Content-Length` through on the request and `206`, `304`, `416`, `Content-Range`, `Accept-Ranges`, `ETag` on the response, and must stream both bodies (no `arrayBuffer()`). - A single upload is bounded by `REQUEST_TIMEOUT_MS` (30 s default). 50 MiB on one host is well inside it; slow mobile links may need a per-route timeout later. - ddx-web's presign client (`services/media-upload.ts`, `lib/di/presigned-put.client.ts`) must switch to one `POST` with the raw file body. - Open: orphan cleanup (an object whose row insert failed is deleted inline; a crash between the two leaves an orphan), old-avatar delete on replace.
ddx-api/src/media/**, contract packages/ddx-contracts/src/media.tsADR 0010 let the browser PUT and GET bytes directly against MinIO with short-lived presigned URLs. The owner decided on 2026-10-23 that this is not acceptable:
/api/v1 → ddx-api → MinIO.The RQ8 spike numbers (direct PUT 12.1 ms vs relay 18.8 ms median, 2 MiB) show the relay cost is small on one host. One path for every byte also keeps the trust boundary simple: the BFF session and the ddx-api guard chain run on every read and every write.
ObjectStoreService holds one S3 client on
S3_ENDPOINT. S3_PUBLIC_ENDPOINT, MEDIA_PRESIGN_TTL_SECONDS and the presigner are
gone. Buckets stay private.Routes (ddx-api; the browser calls the same paths under /api/v1):
Upload errors: 400 empty, short, aborted body or bytes that are not the declared type ·
403 foreign slug, service key on avatar, missing project-write role for projectId ·
404 unknown project · 411 no Content-Length (chunked) · 413 over the kind cap ·
415 MIME not allowed for the kind. Read errors: 404 for a foreign or unknown id —
the row is looked up through TenantPrismaService first, so no byte of another org is
ever read from MinIO.
kind, projectId),
the bytes are the request body. No multipart parser, no two-step create-then-PUT, no
pending row. The role and project checks run before the body is read.req → UploadGuard → PutObject with the exact
ContentLength (one PutObject; no multipart, no lib-storage). UploadGuard counts the
bytes against Content-Length and checks the file signature of binary types (PNG, JPEG,
GIF, WEBP, PDF, WEBM, OGG, WAV, MP4, MPEG) on the first 16 bytes only. A violation fails
the PutObject; the object is deleted and the cause is answered (400 / 413).
Download: the S3 GetObject body stream is piped to the response. The handler does not
await the pipe, so TimeoutInterceptor never cuts a long download.bytes=a-b, bytes=a-, bytes=-n), resolved
against the stored size and passed to S3 GetObject as Range. The answer is 206 with
S3's Content-Range. An unsatisfiable or malformed bytes= range is 416 with
Content-Range: bytes */<size>. A multi-range list or another unit is ignored
(RFC 9110 allows it) and the whole object is sent. PDF, audio and video players can seek.Content-Type (stored MIME), Content-Length, ETag,
Accept-Ranges: bytes, Cache-Control: private, Content-Disposition: inline. The
content route skips the { data } envelope (@SkipResponseWrap + @Res).express.json / urlencoded parsers keep the
10 MB BODY_LIMIT_MB. They only consume their own content types, and no allowed media
MIME is JSON or form data, so the raw upload stream reaches the handler unparsed. The
upload route's own limit is the per-kind cap of MEDIA_LIMITS (avatar 2 MiB, image
10 MiB, document 25 MiB, audio 50 MiB), checked on Content-Length and on the bytes.
The global limit is NOT raised.org/<orgId>/<kind>/<id>,
org/<orgId>/avatars/<userId>/<id>; user.image and project.coverImageKey hold the key.
The media id is a server-minted uuid.pnpm -C ddx-api db:seed (step media) stores one PDF and one PNG for
acme-health (fixed ids, idempotent).Range, If-None-Match, Content-Type, Content-Length
through on the request and 206, 304, 416, Content-Range, Accept-Ranges, ETag
on the response, and must stream both bodies (no arrayBuffer()).REQUEST_TIMEOUT_MS (30 s default). 50 MiB on one host is
well inside it; slow mobile links may need a per-route timeout later.services/media-upload.ts, lib/di/presigned-put.client.ts)
must switch to one POST with the raw file body.adr/
deploy/
research/