Skip to content
All skills

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-dev bognwszwhxxyjafqzfuh. 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/)

ClientFileUse for
createPublicClient()public.tsPublic reads in queries.ts. Anon key, no session, server-only.
createServerClient()server.tsAdmin reads (editorial.ts) and every user write. Session cookies, RLS applies.
createServiceClient()service.tsNarrow server tasks only (Auth admin API, rate limits, public forms). Bypasses RLS.
createAnonBrowserClient()browser.tsClient code that needs anon access without a session.
  • @supabase/supabase-js is imported only inside src/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() inside unstable_cache with tags.* (atlas-architecture has 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") or rowsOf(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_rows in supabase/config.toml). Lists that can grow past that must page with .range(). The only pager today is the local all() in features/geography/queries.ts; when a second feature needs it, move it to lib/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:

  1. Validate with Zod (formAction(schema, handler, { arrays }) or idAction for one uuid). HTML fields sanitize inside the schema (.transform(sanitizeRichHtml)), then re-check the length (htmlTooLong), because sanitizing can grow the text.
  2. Authenticate: the wrappers call signedIn() (getUser()); signed out = NOT_SIGNED_IN.
  3. Write under RLS with the session client and end the write with .select("id").
  4. Check the result with affected(result, "You can't ..."): zero rows means RLS refused. DB errors become failed(error) → mapDbError (lib/db/errors.ts).
  5. Refresh: updateTag(tags.x) for public pages and revalidatePath(adminRoutes.x...) for admin screens. Only after a real write (tests/unit/cache-purge.test.ts guards this).
  6. 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 use useServerAction / ActionForm (components/ui/action-form.tsx, see atlas-admin).
  • Trigger and RPC errors meant for people: raise exception 'Human sentence.' using errcode = '42501' (or 22023, 23505, 23503). mapDbError shows such sentences as written and hides the rest.
  • Do not use revalidateTag; updateTag gives 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. Never update entries set status.
  • approve_entry takes the updated_at the 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 explicit grant execute ... to <role>. Add it to APP_RPCS in supabase/tests/search-helpers-hardening.test.mjs, or test:db fails.
  • 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.ts fails on a duplicate. With parallel sessions, agree on the number first.
  • Expand-only. deploy.yml runs 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/authenticated nothing. Grant columns to anon only for public data. A function used in a policy, CHECK, index or generated column needs EXECUTE for the role that reads or writes; helper functions called only by a definer RPC get no grant.
  • RLS: enable row level security on every table. One policy per command (select, insert, update, delete), never for all, each with to anon or to 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 + the stamp_row trigger.
  • CHECK constraints 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 via set_config or JWT flags.
  • Content edits in migrations (e.g. the privacy page) must be re-runnable and must not overwrite editors' later changes: guard the update with where ... like on the old text.
  • After the PR merges, migrate-dev.yml applies 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:db runs node --test with PGlite: all migrations + supabase-stubs.sql. A new test file must be added to the test:db script in package.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 the as(user, fn, { aal }) / asService(fn) helpers from the nearest test file.
  • The authorization matrix is covered by the "permission matrix" tests in rls.test.mjs and the e2e specs tests/admin-permissions.spec.ts and tests/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 (needs SUPABASE_DEV_PROJECT_REF and a Supabase login). Commit src/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).
  • allowRequest and allowEmail store an HMAC (RATE_LIMIT_SECRET), never the raw IP or email. A key passed to allowKey must hold no personal data; hash it first. Client IP: clientIp / rateLimitAddress (lib/security/ip.ts). Old windows are deleted daily by db_housekeeping().
  • Which endpoints need a limit: atlas-security.

Webhooks and payments

  • Stripe (ADR G6) is on only when STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are both set (stripeEnabled() in lib/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 marked metadata.atlas = "patron", one-time = payment (no membership).
  • Webhook: app/api/stripe/webhook/route.ts reads the raw body, verifies the signature, stores event.id (stripe_event_seen, forgotten on failure so the retry runs), re-fetches the subscription and writes through apply_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 in signInDestination.
  • 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, CHECK limits equal to Zod, search_path set, definer only when needed.
  • [ ] DB test with allowed and denied cases, listed in test:db; new RPC in APP_RPCS.
  • [ ] Action: formAction/idAction, .select("id") + affected, updateTag only after a write.
  • [ ] types.gen.ts regenerated; 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 error only: an RLS-filtered update returns no error and zero rows.
  • A direct update of status: 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

StepPRWhat
Expand1Add the new column / table / function. Old code keeps working.
Migrate1 or 2App writes the new shape (and the old one if still read), backfill in a migration.
ContractlaterDrop 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/delete returns zero rows, not an error. Assert on the returned rows (returning id) for those cases.
  • Add the file to the test:db script in package.json.
  • Run npm run test:db locally; 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

WhatProductionatlas-dev
Project homeopenopen
Applied migrationsopenopen
Security advisoropenopen
Performance advisoropenopen
SQL editor (read-only checks in prod)openopen
Logsopenopen

Never change schema through the production SQL editor; every change is a migration.

This site uses only the cookies it needs to work — no advertising or tracking cookies. Visits are counted without cookies. Privacy