Source: ddx-documentation/adr/0010-media-path.md
> **Superseded by [ADR 0011](/en/docs/adr/0011-media-proxy)** (2026-10-23, owner decision): MinIO is > reached only by ddx-api; no presigned URL reaches a client. Kept as history. - **Status:** Superseded by ADR 0011 (was Accepted 2026-10-23) - **Binds:** WS4 G4.1-G4.3, WS4 NOT (no public bucket, no media without an org check, no CDN), invariants 1, 2, 3 - **Research:** [RQ8](/en/docs/research/rq8-media-path) · confirms the S3 client of [ADR 0005](/en/docs/adr/0005-frameworks) (`@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner`) ## Context ddx-web needs avatars, project cover images, audio and documents. MinIO runs locally with two private buckets (`ddx-images`, `ddx-avatars`). Bytes could go through the BFF and ddx-api, or straight to MinIO. Every object belongs to one org, and isolation must be testable. ## Decision 1. **Presigned URLs.** ddx-api mints a presigned PUT (upload) or GET (read) URL. The bytes go between the browser and MinIO. The BFF forwards only the JSON calls. 2. **Org check first.** Every call runs the guard chain, then `:orgSlug` = principal org (OrgContextInterceptor), then the service. A URL is minted only after that. A media id of another org reads as absent (TenantPrismaService) → **404, no URL**. 3. **Keys carry the org.** `org/<orgId>/<kind>/<mediaId>`; avatars `org/<orgId>/avatars/<userId>/<mediaId>`. ddx-api builds the key from the bound org (and the caller's user for avatars) on every call. A client never sends a key. 4. **Short TTL, private buckets.** `MEDIA_PRESIGN_TTL_SECONDS` (default 300, range 30-900). No public bucket policy, no permanent URL, no CDN. A presigned URL is a bearer credential: never logged, never stored. 5. **Two-step upload.** `POST /orgs/:orgSlug/media/presign` → client PUT → `POST /orgs/:orgSlug/media/:id/complete`. `complete` HEADs the object, re-checks size cap and content type (bad upload → deleted, 400), writes `main.MediaObject`, then links it: avatar → better-auth `user.image` = key; image with a project link → `project.coverImageKey` = key (through ProjectsService, so the `entity.updated` event goes on the one EVENTS_BUS). 6. **Read.** `GET /orgs/:orgSlug/media/:id/url` → presigned GET. The media id is the last key segment, so a stored key names its media id (`mediaIdFromKey`, `@ddx/contracts/media`). 7. **Buckets.** `avatar` → `ddx-avatars`; `image`, `audio`, `document` → `ddx-images`. 8. **Config.** `S3_ENDPOINT`, `S3_PUBLIC_ENDPOINT` (the host signed into the URL), `S3_REGION`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_BUCKET_IMAGES`, `S3_BUCKET_AVATARS`. A missing key fails the boot (no silent fallback). ## Consequences - No raised body limit on any ddx-api route for media (two-tier limit stays intact). - The browser talks to MinIO on `127.0.0.1:16350` with a signed URL. This does not break invariant 1: the browser still never calls ddx-api directly. The `ddx-docker-minio` compose comment "the browser never reaches MinIO" needs an update by its owner. - MinIO CORS must allow the ddx-web origin for browser PUT (default MinIO allows all); verified by the first ddx-web upload. - An upload that is never completed leaves an orphan object. Cleanup (a lifecycle rule on `org/*` objects with no MediaObject row) is open work, not done here. - Dev uses the MinIO root credentials. A non-dev target needs a scoped MinIO user with access to the two buckets only. - Replacing an avatar does not delete the old object (open work, same as orphan cleanup).
Superseded by (2026-10-23, owner decision): MinIO is reached only by ddx-api; no presigned URL reaches a client. Kept as history.
@aws-sdk/client-s3 + @aws-sdk/s3-request-presigner)ddx-web needs avatars, project cover images, audio and documents. MinIO runs locally with two
private buckets (ddx-images, ddx-avatars). Bytes could go through the BFF and ddx-api,
or straight to MinIO. Every object belongs to one org, and isolation must be testable.
:orgSlug = principal org
(OrgContextInterceptor), then the service. A URL is minted only after that. A media id of
another org reads as absent (TenantPrismaService) → 404, no URL.org/<orgId>/<kind>/<mediaId>; avatars
org/<orgId>/avatars/<userId>/<mediaId>. ddx-api builds the key from the bound org (and
the caller's user for avatars) on every call. A client never sends a key.MEDIA_PRESIGN_TTL_SECONDS (default 300, range 30-900).
No public bucket policy, no permanent URL, no CDN. A presigned URL is a bearer credential:
never logged, never stored.POST /orgs/:orgSlug/media/presign → client PUT →
POST /orgs/:orgSlug/media/:id/complete. complete HEADs the object, re-checks size cap
and content type (bad upload → deleted, 400), writes main.MediaObject, then links it:
avatar → better-auth user.image = key; image with a project link → project.coverImageKey
= key (through ProjectsService, so the entity.updated event goes on the one EVENTS_BUS).GET /orgs/:orgSlug/media/:id/url → presigned GET. The media id is the last key
segment, so a stored key names its media id (mediaIdFromKey, @ddx/contracts/media).avatar → ddx-avatars; image, audio, document → ddx-images.S3_ENDPOINT, S3_PUBLIC_ENDPOINT (the host signed into the URL),
S3_REGION, MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, S3_BUCKET_IMAGES,
S3_BUCKET_AVATARS. A missing key fails the boot (no silent fallback).127.0.0.1:16350 with a signed URL. This does not break
invariant 1: the browser still never calls ddx-api directly. The ddx-docker-minio
compose comment "the browser never reaches MinIO" needs an update by its owner.org/* objects with no MediaObject row) is open work, not done here.adr/
deploy/
research/