0017 — Storage uploads route through the API, not direct from the browser
- Status: accepted
- Date: 2026-05-10
- Deciders: Derek
Context
Ark uses Supabase Storage as its canonical image store (ADR 0015). Supabase Storage supports two upload paths: the browser calls Supabase directly using the anon key and a row-level-security (RLS) policy approves or denies the write, or the browser calls ark’s own API which in turn calls Supabase using a privileged service-role key.
During Workstream D (branding — logo upload), we first implemented the direct path. RLS policies were written, tested locally, and passed manual checks against the predicate logic. In the live production environment, every upload attempt was rejected — even when the session looked correct and the policy predicates were satisfied in isolation.
The root cause is structural, not incidental: the JWT that the browser sends to Supabase Storage must carry a custom claim (org_roles) that identifies which organisations the member administers. That claim is stamped into the token by a database hook that fires at sign-in. Production JWT issuance, caching, and claim propagation have subtle timing and edge conditions. Any mismatch between what the claim says and what the storage policy expects — including clock skew, token age, or hook misconfiguration — silently rejects the write. The policy evaluates a stored value in the JWT; it cannot recover from a stale or missing claim by re-querying the database at upload time.
The API does not have that constraint. It authenticates the user, re-queries the current membership table to confirm the upload is authorised right now, and then calls Supabase using the service-role key — which bypasses Storage RLS entirely. The authorisation decision lives in code we own and can test with real database rows, not in a JWT claim we cannot introspect from the browser.
Decision
For every new storage upload surface in ark, the upload path goes through the ark API. The browser never calls Supabase Storage directly.
Concretely, for each surface that accepts an uploaded file:
- A dedicated API route (
POST /<surface>/<filename>) accepts the raw bytes in the request body. - The route resolves the caller’s organisation from their session, then re-queries
org_membersto confirm the required role. If the check fails, it returns a 403 before touching storage. - On success the route calls
supabase.storage.from('org-public').upload(path, bytes, { upsert: true })using the service-role client — no RLS evaluation in the storage layer. - Any authorisation logic is covered by an integration test that signs in as an unauthorised role and asserts a 403, and separately as an authorised role and asserts a 200. That test uses the real database; it does not mock the membership query.
The reference implementations are apps/api/src/routes/branding.ts (logo upload) and apps/api/src/routes/cms.ts + apps/api/src/routes/profile.ts (CMS hero images and member avatars, added in Workstream B). New upload surfaces mirror these files.
Consequences
Easier:
- Authorization logic lives in TypeScript that we can read, test, and reason about — not in a JWT claim that requires a running auth subsystem to inspect.
- Integration tests for upload routes can assert the full authorisation decision with a real database connection, matching ark’s no-DB-mocking posture (ADR 0009).
- Storage RLS policies remain as a defence-in-depth layer (the service-role key bypasses them, but the policies still exist for any path that might inadvertently reach storage without going through the API).
- Consistent with how mutations are handled elsewhere: the API is the single write boundary for all state changes.
Harder:
- Every upload surface needs a dedicated API route. CMS hero images, member avatars, and branding logos are three routes today; a fourth surface means a fourth route.
- There is one extra network hop: browser → API → Supabase Storage instead of browser → Supabase Storage. For the file sizes ark expects (logos, profile photos, small hero images), this is not a meaningful latency concern.
apps/apicarries the service-role key; it must never be exposed client-side. This is already true — the key lives in API environment variables and is not bundled.
Alternatives considered
Direct browser upload with RLS. The natural Supabase pattern; fewer moving parts in the happy path. Failed in production due to JWT claim propagation behaviour described above. Even if the immediate cause were fixed, it would remain fragile: production RLS evaluations happen inside the database with no visibility from the browser at the time of failure. Rejected.
Direct browser upload with a short-lived signed upload URL issued by the API. The API verifies auth, generates a time-limited Supabase Storage upload URL, and returns it to the browser. The browser uploads directly to that URL. Keeps the large-byte transfer off the API server. Reasonable at higher file volumes. Deferred — ark’s current asset sizes don’t justify the additional moving part (signed URL TTLs, presign/upload race conditions), and the simpler proxy path is easier to test. Revisit if CMS assets grow large enough to make API throughput a concern.
Trip-wires
We revisit this if:
- A tenant uploads assets large enough (video, high-res photo libraries) that routing bytes through the API creates a meaningful bottleneck or cost. The migration path is the signed-URL approach above.
- Supabase fixes JWT claim propagation in a verifiable, testable way and direct-upload RLS becomes reliably testable end-to-end in CI. At that point the extra hop could be eliminated for low-sensitivity surfaces.