Source: ddx-documentation/research/rq8-media-path.md
**Question** (task 024, WS4 G4.1-G4.3): should the browser move image bytes straight to MinIO with presigned URLs minted by ddx-api, or should the bytes stream through the ddx-web BFF and ddx-api? Decision record: [ADR 0010](/en/docs/adr/0010-media-path). ## Spike (2026-10-23, task 024) - Setup: one 2 MiB random payload, 10 runs per path, Node v26.12.0, all on one dev host. MinIO container `ddx-web-boot-minio` (S3 API `127.0.0.1:16350`), private bucket `ddx-images`, keys `org/rq8-spike/image/<uuid>` (all 20 deleted after the run). - **Direct** = ddx-api presigns (`@aws-sdk/s3-request-presigner`, TTL 300 s); the client PUTs / GETs MinIO itself. - **Relay** = two loopback HTTP hops: a BFF hop that pipes the stream, then an API hop that reads the body and calls `PutObject` / streams `GetObject`. This is the shape of "upload through `api/v1`". - Script: kept outside the repo (scratch). It reads the MinIO credentials from `ddx-docker-minio/.env` and never prints them. | Path | Median | Min | Max | |---|---|---|---| | presign (sign only, no network) | 0.6 ms | 0.5 ms | 7.3 ms | | PUT direct (presigned) | 12.1 ms | 10.8 ms | 35.3 ms | | PUT via 2 relay hops | 18.8 ms | 16.5 ms | 24.4 ms | | GET direct (presigned) | 9.3 ms | 8.1 ms | 10.3 ms | | GET via 2 relay hops | 10.3 ms | 9.3 ms | 129.3 ms | Extra check: an unsigned GET of a spike object on the private bucket answered **403**. ## Findings | # | Finding | |---|---| | F1 | Direct PUT is about 1.55× faster than the relay (12.1 vs 18.8 ms median) on loopback. Over a real network each relay hop adds one more full copy of the bytes, so the gap grows with distance and file size. | | F2 | The relay holds the whole body in ddx-api memory (the SDK `PutObject` needs a length) and counts against the API body limit. ddx-api's two-tier body limit (`ddx-api/CLAUDE.md`) would need a raised per-route limit for every upload route. Direct upload needs no raised limit. | | F3 | Signing costs under 1 ms and needs no network call. The org check (slug resolve + one tenant query) is the only real work ddx-api does per URL. | | F4 | A private bucket refuses an unsigned read (403), so access control stays in ddx-api: no URL without the org check, and every URL ends at its TTL. | | F5 | The SigV4 signature includes the host. ddx-api must sign for the host the browser uses (`S3_PUBLIC_ENDPOINT`), not a Docker-internal name. In dev both are `127.0.0.1:16350`. | | F6 | Not measured here: a browser PUT needs MinIO CORS for the ddx-web origin (16300). MinIO's API allows all origins by default; the first ddx-web upload (task 030 side) confirms it. | ## Answer Presigned URLs, minted by ddx-api **after** the org check, short TTL (300 s default, 900 s cap), private buckets. The BFF only forwards the small JSON calls (`presign`, `complete`, `url`); the bytes never cross the BFF or ddx-api.
Question (task 024, WS4 G4.1-G4.3): should the browser move image bytes straight to MinIO with presigned URLs minted by ddx-api, or should the bytes stream through the ddx-web BFF and ddx-api?
Decision record: .
ddx-web-boot-minio (S3 API 127.0.0.1:16350), private bucket
ddx-images, keys org/rq8-spike/image/<uuid> (all 20 deleted after the run).@aws-sdk/s3-request-presigner, TTL 300 s); the
client PUTs / GETs MinIO itself.PutObject / streams GetObject. This is the shape of
"upload through api/v1".ddx-docker-minio/.env and never prints them.Extra check: an unsigned GET of a spike object on the private bucket answered 403.
Presigned URLs, minted by ddx-api after the org check, short TTL (300 s default, 900 s
cap), private buckets. The BFF only forwards the small JSON calls (presign, complete,
url); the bytes never cross the BFF or ddx-api.
adr/
deploy/
research/