Backend
Claude skill atlas-backend · for Backend
How Atlas talks to Supabase - the three clients, public queries vs editorial reads, must/rowsOf, the Server Action pattern (formAction, idAction, affected, updateTag), RPCs for content state, migrations (naming, expand-only, grants, RLS per command, FK indexes, CHECK limits), DB tests on PGlite, type generation, rate limits and webhooks. Use when adding a table, column, policy, RPC, migration, Server Action or query, or when a DB test fails.
Postgres in Supabase holds the data and the rules. The app reads through RLS, writes through Server Actions under the user's session, and changes content state only through RPCs. If the database would refuse it, the app must not try to allow it.
Before you start
- Read
atlas-architecture(layers, caching, the 10 rules). Security rules:atlas-security. - Projects: production ewbzkxialhtwuqlenjof
and
atlas-devbognwszwhxxyjafqzfuh. Tests and experiments go to atlas-dev, never production. - Commands:
npm run test:db(PGlite, no Docker),npm test,npm run db:types,npm run db:make-admin -- <email>. - Migration template and DB-test template: reference.
Supabase clients (src/lib/supabase/)
| Client | File | Use for |
|---|---|---|
createPublicClient() | public.ts | Public reads in queries.ts. Anon key, no session, server-only. |
createServerClient() | server.ts | Admin reads (editorial.ts) and every user write. Session cookies, RLS applies. |
createServiceClient() | service.ts | Narrow server tasks only (Auth admin API, rate limits, public forms). Bypasses RLS. |
createAnonBrowserClient() | browser.ts | Client code that needs anon access without a session. |
@supabase/supabase-jsis imported only insidesrc/lib/supabase/**(ESLint).- Env:
publicEnv(lib/env.ts),serverEnv(lib/env.server.ts, Zod-parsed,server-only). - Use the service client only after the same action has proved permission with an RLS-checked
write (see
features/accounts/actions.ts: profile update under RLS, then the Auth ban), or for a deliberately public, rate-limited endpoint (features/volunteers/actions.ts).
Reads
- Public:
features/x/queries.ts,createPublicClient()insideunstable_cachewithtags.*(atlas-architecturehas the caching rules). Admin:features/x/editorial.ts, session client, no cache. - Name the columns. Never
select *through the anon client: anon has column grants, and internal columns (owner_id,review_note,approved_by...) are not granted. - Unwrap results with
must(result, "scope")orrowsOf(result, "scope")(lib/db/result.ts). A failed read throws[scope] message, so the error boundary shows it. A DB error must never turn into an empty list or a 404. - PostgREST returns at most 1000 rows (
max_rowsinsupabase/config.toml). Lists that can grow past that must page with.range(). The only pager today is the localall()infeatures/geography/queries.ts; when a second feature needs it, move it tolib/db/. - Admin search across columns:
ilikeAny()(lib/db/filters.ts), never a hand-built.or().
Server Actions
Every mutation is a Server Action in features/<domain>/actions.ts (or *-actions.ts), with
"use server" and import "server-only". The fixed steps live in lib/actions.ts:
- Validate with Zod (
formAction(schema, handler, { arrays })oridActionfor one uuid). HTML fields sanitize inside the schema (.transform(sanitizeRichHtml)), then re-check the length (htmlTooLong), because sanitizing can grow the text. - Authenticate: the wrappers call
signedIn()(getUser()); signed out =NOT_SIGNED_IN. - Write under RLS with the session client and end the write with
.select("id"). - Check the result with
affected(result, "You can't ..."): zero rows means RLS refused. DB errors becomefailed(error)→mapDbError(lib/db/errors.ts). - Refresh:
updateTag(tags.x)for public pages andrevalidatePath(adminRoutes.x...)for admin screens. Only after a real write (tests/unit/cache-purge.test.tsguards this). - Return an
ActionState(lib/action-state.ts):{ ok, message }or{ ok: false, error, fieldErrors }. Return only what the UI needs, never raw rows.
export const saveSiteSettings = formAction(SettingsInput, async ({ data, supabase }) => {
const denied = affected(
await supabase.from("site_settings").update(data).eq("id", 1).select("id"),
"You can't change the site settings.",
);
if (denied) return denied;
updateTag(tags.site);
revalidatePath(adminRoutes.siteSettings, "layout");
return { ok: true, message: "Saved." };
});- Row buttons bind the id:
deleteX.bind(null, row.id). Forms useuseServerAction/ActionForm(components/ui/action-form.tsx, seeatlas-admin). - Trigger and RPC errors meant for people:
raise exception 'Human sentence.' using errcode = '42501'(or 22023, 23505, 23503).mapDbErrorshows such sentences as written and hides the rest. - Do not use
revalidateTag;updateTaggives read-your-own-writes in actions.
Content state goes through RPCs
- Entry state (planned → draft → pending → published) changes only via RPCs called from
features/entries/workflow-actions.ts:submit_entry,withdraw_entry,approve_entry(p_entry, p_expected_updated_at),unpublish_entry,send_back_entry,schedule_entry,unschedule_entry,create_preview_link. Neverupdate entries set status. approve_entrytakes theupdated_atthe approver saw, so a stale approval is refused.- After approve or unpublish:
refreshEntry(...)(features/entries/revalidate.ts) refreshes the page tags and pings IndexNow. - A new RPC: SECURITY DEFINER only when needed, permission check as its first statement,
set search_path = public, pg_temp,revoke execute ... from public, anon, then an explicitgrant execute ... to <role>. Add it toAPP_RPCSinsupabase/tests/search-helpers-hardening.test.mjs, ortest:dbfails. - Scheduled work runs in the DB via pg_cron (ADR-015):
publish_due_entries()every 5 min,db_housekeeping()daily. Not Vercel Cron.
Migrations (supabase/migrations/)
- New file only. Never edit a migration that has been applied anywhere (atlas-dev counts). Fix forward with a new file.
- Name:
YYYYMMDDNNNNNN_snake_name.sql- date + 6-digit sequence, usually steps of 10 (20261015000100_visuals.sql). Versions must be unique and increasing;tests/unit/migration-versions.test.tsfails on a duplicate. With parallel sessions, agree on the number first. - Expand-only.
deploy.ymlruns migrations before the new app is live, so the old code must still work: add nullable columns or defaults, new functions next to old ones. Drop the old thing in a later "contract" PR. State the phase in the file header and the PR. - Start the file with a comment: what, why, which test covers it.
- Grants are explicit. Default privileges are revoked (20261013000210): a new table, sequence
or function gives
anon/authenticatednothing. Grant columns to anon only for public data. A function used in a policy, CHECK, index or generated column needsEXECUTEfor the role that reads or writes; helper functions called only by a definer RPC get no grant. - RLS:
enable row level securityon every table. One policy per command (select,insert,update,delete), neverfor all, each withto anonorto authenticated. - Wrap constant-argument helpers in a sub-select so they run once per statement:
(select auth.uid()),(select public.has_perm('users','v')). Helpers that take a row value (can_edit_visual(region_slug, ...)) stay bare. - Every foreign key gets an index. Every table gets
updated_at+ thestamp_rowtrigger. CHECKconstraints for lengths, enums and^https://URLs, with the same limits as Zod (supabase/tests/check-limits.test.mjs).- Every function:
set search_path = public, pg_temp. - Guard triggers that read RLS-protected tables must be SECURITY DEFINER, or the check is
silently skipped. Guards are bypassed only by the service role or by a definer function via
internal_profile_grants(one transaction). Never viaset_configor JWT flags. - Content edits in migrations (e.g. the privacy page) must be re-runnable and must not overwrite
editors' later changes: guard the
updatewithwhere ... likeon the old text. - After the PR merges,
migrate-dev.ymlapplies it to atlas-dev. To rely on it before merge, run Migrate atlas-dev on your branch; after that, the file is frozen. - After it lands on atlas-dev, check the Security and Performance advisors and fix new findings.
DB tests (supabase/tests/)
npm run test:dbrunsnode --testwith PGlite: all migrations +supabase-stubs.sql. A new test file must be added to thetest:dbscript inpackage.json, or it never runs.- Every permission change gets allowed and denied cases: anon, a role with the right, a role
without it. RLS and default roles:
rls.test.mjs; column grants:column-grants.test.mjs; the section matrix:site-section.test.mjs. Copy theas(user, fn, { aal })/asService(fn)helpers from the nearest test file. - The authorization matrix is covered by the "permission matrix" tests in
rls.test.mjsand the e2e specstests/admin-permissions.spec.tsandtests/permission-matrix.spec.ts. - PGlite has no pg_cron: call the scheduled function directly in the test.
- Some unit tests read migration SQL to keep TS constants in sync (
src/lib/content-types.test.ts,src/lib/db/errors.test.ts). If one fails, align both sides.
Types
- After the migration is on atlas-dev:
npm run db:types(needsSUPABASE_DEV_PROJECT_REFand a Supabase login). Commitsrc/lib/db/types.gen.ts; never edit it by hand.
Rate limits (lib/security/rate-limit.ts)
- Shared table
rate_limits, written through the service client:allowRequest(scope, headers, { limit, windowSeconds }),allowEmail(scope, email, limits),allowKey(key, limits). allowRequestandallowEmailstore an HMAC (RATE_LIMIT_SECRET), never the raw IP or email. A key passed toallowKeymust hold no personal data; hash it first. Client IP:clientIp/rateLimitAddress(lib/security/ip.ts). Old windows are deleted daily bydb_housekeeping().- Which endpoints need a limit:
atlas-security.
Webhooks and payments
- Stripe (ADR G6) is on only when
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETare both set (stripeEnabled()inlib/stripe.ts); otherwise "Donate & Join" stays on the demo checkout. Local work and tests use test keys (sk_test_) only; live keys live only in GitHub secrets. - Checkout:
startCheckout→createCheckoutUrl(features/membership/stripe.ts), hosted page, monthly = subscription markedmetadata.atlas = "patron", one-time = payment (no membership). - Webhook:
app/api/stripe/webhook/route.tsreads the raw body, verifies the signature, storesevent.id(stripe_event_seen, forgotten on failure so the retry runs), re-fetches the subscription and writes throughapply_stripe_subscription()(service role). Membership state comes only from webhook-confirmed events, never from the success redirect. - Donors without an account wait in
pending_memberships(server-only) until they sign in with that confirmed e-mail:claim_my_donations()runs insignInDestination. - Events to subscribe in Stripe:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted. Locally:stripe listen --forward-to localhost:3000/api/stripe/webhook.
Checklist
- [ ] Migration: new file, unique version, expand-only, header comment, explicit grants.
- [ ] RLS on, one policy per command with
to <role>, helpers wrapped in(select ...). - [ ] FK indexes,
CHECKlimits equal to Zod,search_pathset, definer only when needed. - [ ] DB test with allowed and denied cases, listed in
test:db; new RPC inAPP_RPCS. - [ ] Action:
formAction/idAction,.select("id")+affected,updateTagonly after a write. - [ ]
types.gen.tsregenerated; migration applied to atlas-dev before e2e relies on it.
Anti-patterns
select *through the anon client: leaks internal columns or fails on column grants.- Checking
erroronly: an RLS-filtered update returns no error and zero rows. - A direct
updateofstatus: skips the workflow rules in the RPC. - Editing an applied migration: atlas-dev and production drift from the repo.
- Dropping or renaming a column in the same PR as the code change: the live app breaks for the minutes between migration and deploy.
Sources
- Supabase: Row Level Security - https://supabase.com/docs/guides/database/postgres/row-level-security
- Supabase: Database advisors - https://supabase.com/docs/guides/database/database-advisors
- Supabase: Managing environments - https://supabase.com/docs/guides/deployment/managing-environments
- PostgreSQL: CREATE FUNCTION (search_path, SECURITY DEFINER) - https://www.postgresql.org/docs/current/sql-createfunction.html
- PostgreSQL: Constraints (FK indexes) - https://www.postgresql.org/docs/current/ddl-constraints.html
- Martin Fowler: Parallel Change - https://martinfowler.com/bliki/ParallelChange.html
- Stripe: Webhooks - https://docs.stripe.com/webhooks
Files of this skill
reference.md
Backend reference
Templates and longer detail for atlas-backend. Rules are in SKILL.md; this file
only shows how they look in practice.
Migration template
Pick the next free version (ls supabase/migrations | tail -3) and name the file
YYYYMMDDNNNNNN_snake_name.sql. Real examples to copy from:
20261015000100_visuals.sql (new table with owners, grants, policies, definer helpers),
20261015000060_timelines.sql (expand an existing table, re-runnable),
20261013000360_gdpr_retention.sql (definer RPC scoped to auth.uid()).
-- =============================================================================
-- <What this adds> (<who asked>, <date>)
-- =============================================================================
-- Why: ...
-- Phase: expand (the running app ignores the new column / function).
-- Default privileges are revoked (20261013000210): every grant is explicit.
-- Tests: supabase/tests/<name>.test.mjs.
-- =============================================================================
create table public.things (
id uuid primary key default gen_random_uuid(),
region_slug text not null references public.regions (slug) on delete cascade,
title text not null check (char_length(title) between 1 and 200), -- = Zod text(200)
source_url text check (source_url ~ '^https://' and char_length(source_url) <= 1000),
updated_at timestamptz not null default now()
);
create index things_region_slug_idx on public.things (region_slug); -- every FK
create trigger things_stamp before update on public.things
for each row execute function public.stamp_row();
alter table public.things enable row level security;
create policy things_read on public.things for select to anon, authenticated using (true);
create policy things_add on public.things for insert to authenticated
with check ((select public.has_perm('regions', 'c')));
create policy things_edit on public.things for update to authenticated
using ((select public.has_perm('regions', 'e')))
with check ((select public.has_perm('regions', 'e')));
create policy things_remove on public.things for delete to authenticated
using ((select public.has_perm('regions', 'd')));
revoke all on public.things from anon, authenticated;
grant select (id, region_slug, title, source_url, updated_at) on public.things to anon;
grant select, delete on public.things to authenticated;
grant insert (region_slug, title, source_url) on public.things to authenticated;
grant update (title, source_url) on public.things to authenticated;Adapt the policy predicates to the real permission helpers of the section (look at the closest
existing table). Check that has_perm and similar helpers are called with constant arguments
inside (select ...), and that row-dependent helpers stay bare.
Definer RPC skeleton
create or replace function public.do_thing(p_id uuid)
returns void
language plpgsql
security definer
set search_path = public, pg_temp
as $$
begin
if not public.has_perm('regions', 'e') then
raise exception 'You don''t have permission to change this.' using errcode = '42501';
end if;
-- validate arguments here (length, enum, ownership)
...
end;
$$;
revoke execute on function public.do_thing(uuid) from public, anon;
grant execute on function public.do_thing(uuid) to authenticated;Then add do_thing: ["authenticated"] to APP_RPCS in
supabase/tests/search-helpers-hardening.test.mjs.
Expand → migrate → contract
| Step | PR | What |
|---|---|---|
| Expand | 1 | Add the new column / table / function. Old code keeps working. |
| Migrate | 1 or 2 | App writes the new shape (and the old one if still read), backfill in a migration. |
| Contract | later | Drop the old column / function once no deployed code reads it. |
deploy.yml applies migrations, then deploys the app. Between the two steps production runs
the old app against the new schema. Anything the old app needs must still exist.
DB test template
Start from the nearest file in supabase/tests/ (for example visuals.test.mjs): it loads every
migration into PGlite, adds fixed uuids per role, and defines as(user, fn) and refused().
test("data editor can't remove a thing without 'd'", async () => {
await refused(
as(id.dataEditor, () => q("delete from things where id = $1 returning id", [thingId])),
);
});
test("anon reads only the granted columns", async () => {
await refused(as(null, () => q("select updated_at, secret_note from things")), /permission/);
});- Remember: an RLS-filtered
update/deletereturns zero rows, not an error. Assert on the returned rows (returning id) for those cases. - Add the file to the
test:dbscript inpackage.json. - Run
npm run test:dblocally; the nightly Full tests run it again.
Permission sections
A new admin section needs, in one PR: the DB CHECK on role_permissions.section, the
my_permissions() function, src/config/sections.ts and src/features/roles/labels.ts, plus a
case in supabase/tests/site-section.test.mjs style. See atlas-security for the model.
Useful Supabase pages
| What | Production | atlas-dev |
|---|---|---|
| Project home | open | open |
| Applied migrations | open | open |
| Security advisor | open | open |
| Performance advisor | open | open |
| SQL editor (read-only checks in prod) | open | open |
| Logs | open | open |
Never change schema through the production SQL editor; every change is a migration.