Yama — Judge at the Threshold (Supabase)
Yama weighs every request at the door: who you are, what you may see, what you may touch. In this stack Supabase is the source of identity and the home of app data; nothing crosses the threshold unjudged.
The three iron rules
- RLS on every table, always.
alter table … enable row level securityis part of creating a table, not a hardening step for later. A table without RLS is publicly writable through the anon key — treat a missing policy as a P1 (seedhanvantari). - The anon key is public. It ships in the browser bundle; design as if it's printed on a billboard. All protection comes from RLS and auth, none from the key.
- The service-role key bypasses RLS and never leaves the server side. It may exist only in FastAPI/Render env vars — never in frontend env, never in a
VITE_/NEXT_PUBLIC_var, never in a repo (seekubera). If it ever reaches a browser, rotate it the same hour.
Auth
- Use Supabase Auth as the single identity provider for the whole stack;
auth.uid()is the one canonical user ID everywhere — Supabase tables, FastAPI claims, and Render Postgres rows all reference it. - Frontend: use the official client's session handling; subscribe to
onAuthStateChange; never persist tokens yourself, never decode the JWT client-side to make security decisions. - FastAPI verifies the Supabase access token on every request — signature against the project JWKS,
exp, and audience — and derives the user from the verified claims, never from a request body or query param (seevayu). - Access tokens are short-lived by design; the client refreshes automatically. Anything that caches a token (server jobs, tests) must handle expiry.
- Prefer OAuth providers + magic links over passwords where product allows; if passwords are enabled, turn on leaked-password protection in the dashboard.
RLS policy patterns
- Default deny: create the table, enable RLS, then grant the narrowest policies. No
using (true)on user data, ever. - Owner pattern (most tables):
using (auth.uid() = user_id)for select/update/delete,with check (auth.uid() = user_id)for insert. Both clauses —usingwithoutwith checklets users insert rows they can't read. - Separate policies per operation (
for select,for insert, …) instead of onefor all— auditability beats brevity. - Policies are code: they live in migration files in the repo, reviewed in PRs, never edited ad hoc in the dashboard.
- Test policies like logic: for each table, an automated check that user A cannot read or write user B's rows (see
agni). A policy that has never been tested against the negative case doesn't exist. security definerfunctions are RLS escape hatches — each one needs a comment justifying it and a review from themurukamindset.
Schema & migrations
- All schema changes go through the Supabase CLI as migration files (
supabase migration new …), committed and applied by CI — the dashboard SQL editor is for reading, not writing. Dashboard drift is schema you can't reproduce. - Keep a local dev stack (
supabase start) so migrations are exercised before they touch the hosted project;supabase db resetmust always succeed from zero. - Additive first: add nullable column → backfill → constrain → remove old (see
hanumanfor the heavy versions). - After every schema change, regenerate types (
supabase gen types typescript) in the same PR — the frontend compiles against the new truth or the PR doesn't merge.
Client usage
- One client instance per runtime, created in one module — not one per component, not one per call.
- Select exactly the columns you need;
select('*')in app code is a review comment. - Batch with
in()and range-paginate; N sequential single-row fetches from a component is an N+1 you shipped to the user's phone. - Handle the error member of every response:
const { data, error }whereerroris ignored is a silent failure.
Storage & Realtime
- Storage buckets are private by default; public buckets are an explicit, justified decision. Access via signed URLs with the shortest workable TTL; path convention
bucket/{user_id}/…so policies can match on ownership. - Realtime subscriptions: subscribe narrowly (per conversation, not per table), always unsubscribe on unmount (see
maya), and treat events as cache-invalidation hints, not as the source of truth.
The boundary — Supabase vs Render Postgres
- Supabase holds: identity, profiles, workspace/app domain data — anything the frontend reads directly under RLS.
- Render Postgres holds: conversations, messages, embeddings, AI run artifacts — anything written by FastAPI in the AI path (see
shesha). - The frontend never talks to Render Postgres; FastAPI never becomes a proxy for data RLS already protects. If a feature needs both stores in one view, the frontend makes two calls — no cross-store joins, no FK across databases; the shared
auth.uid()string is the only link. - Copying data across the boundary is a migration decision (see
hanuman), not a convenience.
Before merging Supabase work — checklist
- Every new table has RLS enabled with per-operation policies,
usingandwith check - Negative policy test exists: user A blocked from user B's rows
- Change is a CLI migration in the repo;
supabase db resetpasses - Types regenerated; frontend compiles
- No service-role key outside server-side env; no new
security definerwithout justification - Data placed on the correct side of the Supabase/Render boundary