This directory holds the database schema as migration files and a full demo
seed. The Next.js app builds and runs with no Supabase env vars set (it falls
back to the in-memory mock driver); these files are applied once a Supabase
project is provisioned, at which point setting the env vars flips
getPosDriver() to the live Supabase driver (src/lib/db/supabase.ts) with
no call-site changes.
supabase/
migrations/
20260601000000_tenancy_core.sql # enums + tenancy tables (tenants, locations, users, memberships, platform_admins)
20260601000100_tenancy_rls.sql # strict RLS policies + helper functions for the tenancy core
20260605000000_domain_core.sql # ALL operational tables: menu, orders, payments, customers,
# deliveries, inventory, staff/shifts, reports/close, settings,
# subscriptions, onboarding, audit_log (+ enums + indexes)
20260605000100_domain_rls.sql # strict RLS + grants for every domain table (public menu read,
# customer-owns-their-orders, tenant-staff everything else)
20260605000200_least_privilege_grants.sql # CORRECTIVE: revoke the over-broad anon/authenticated
# table grants down to least privilege; drop anon's tenant-
# registry read; scope anon's location read to active tenants
20260606000000_auth_user_bridge.sql # REAL AUTH: trigger linking auth.users -> public.users
# (auth.uid() == users.id, links seed users by email);
# + staff.pin_hash + orders.staff_id columns
bootstrap-auth.mjs # ONE-TIME: create the owner + platform-admin Supabase Auth users
# (service-role; `npm run auth:bootstrap`) so you can log in
tests/
auth_shim.sql # auth.uid() shim so the migrations/test run on vanilla Postgres
rls_isolation.sql # proves isolation on tenancy + orders/menu/payments
run-rls-isolation.sh # one-command harness (shim + migrations + test)
apply.sh # turnkey: apply ALL migrations + seed to a DATABASE_URL
seed.sql # full demo tenant "Tony's Pizza" (2 locations, menu, inventory, staff, …)
README.md # this file
getPosDriver() (src/lib/db/client.ts) chooses lazily at call time:
- Supabase env present —
NEXT_PUBLIC_SUPABASE_URL+ a key (SUPABASE_SERVICE_ROLE_KEYserver-side, elseNEXT_PUBLIC_SUPABASE_ANON_KEY) → the real Supabase driver. - Otherwise — the in-memory mock driver (the zero-env default; the build + the full Vitest suite pass with no env). Nothing reads env at module load.
- Hierarchy:
tenants(a pizzeria business) →locations→ operational data. Every tenant-scoped row carries atenant_id. memberships(user ↔ tenant ↔ role) is the single source of truth for access. A row is visible/writable only if the current user holds a membership for that row'stenant_id. This is enforced by RLS on every tenant table.platform_adminsare super-admins (us), outside tenant scope. Every policy includes anis_platform_admin()bypass for support/billing.- Roles:
owner | manager | cashier | kitchen. Phase 0 policies gate writes by role (e.g. onlyowner/manageredit tenant/locations; onlyownermanages memberships). Finer per-location/role scoping arrives with later tables.
auth.uid()==public.users.id. When Supabase Auth is wired up, the app'susers.idmust equalauth.users.id(1:1). Policies and helper functions (is_tenant_member,has_tenant_role,is_platform_admin) rely on this.- The
service_rolekey bypasses RLS. It must NEVER be used for tenant-scoped reads/writes without an explicittenant_idfilter. App queries run as theauthenticatedrole through PostgREST so RLS is enforced. - Default-deny. RLS is enabled AND
FORCEd on tenant tables, so absent a matching policy, access is denied — including for the table owner. - Helper functions are
SECURITY DEFINERwith a pinnedsearch_pathso they can read membership tables without recursive policy evaluation.
GRANTs decide whether a role may touch a table at all; RLS decides which
rows. The two are independent defenses, and the public (anon) role is held to
the absolute minimum. This is enforced by
20260605000200_least_privilege_grants.sql, which corrects an earlier
over-grant that handed anon (and authenticated) effectively ALL privileges on
every table:
anon(the public/unauthenticated key) holdsSELECTonly, and only on the storefront-public read surface:menu_categories, menu_items, item_sizes, modifier_groups, modifiers, item_modifier_groups, location_menu_overrides, store_settings, locations. It has no grant ontenants, orders, payments, customers, memberships, staff, subscriptions, audit_logor any other operational/PII table — a read there is denied at the grant layer (permission denied), independent of RLS. anon never has INSERT/UPDATE/DELETE/TRUNCATE anywhere.anonpolicies: the menu/overrides/store_settingstables expose ausing (true)SELECT policy (no PII; a tenant's menu is already public on its storefront).locationsexposeslocations_public_selectscoped to active tenants (is_active_tenant(), a SECURITY DEFINER helper so anon never readstenantsdirectly) so a visitor can resolve ONE store's location by slug — not enumerate the registry. There is no anon policy ontenants(the old blankettenants_public_selectwas dropped); tenant resolution happens server-side via thelocationsrow (which carriestenant_id).authenticatedholds least-privilege DML —SELECT, INSERT, UPDATE, DELETEonly (noTRUNCATE/TRIGGER/REFERENCES) — on the tenant-operational tables. Every row is still gated by thememberships-keyed RLS policies.
The live data path is server-side via the service_role key (RLS-bypassing,
with explicit tenant_id/location_id filters in src/lib/db/supabase.ts).
readSupabaseConfig() prefers the service-role key; the storefront (/shop RSC +
/api/shop/* routes) resolves the location, menu, and settings server-side
through getPosDriver() and never uses the anon role for reads or writes.
The narrow anon SELECT grants above exist only so the storefront menu surface
could be read client-side with the anon key in a future iteration without
re-granting — tightening anon therefore cannot break the current app.
-
Provision a Supabase project; copy its URL + anon key + service-role key
- the Postgres connection string.
-
Apply the schema + RLS + demo seed. Two equivalent paths:
Turnkey script (plain
psqlagainst any Postgres/Supabase pooler URL):DATABASE_URL="postgres://postgres:<pw>@<host>:5432/postgres" npm run db:apply # = bash supabase/apply.sh (auto-detects auth.uid(); SKIP_SEED=1 to omit demo data)
Or the Supabase CLI (migrations only) + seed:
supabase link --project-ref <ref> supabase db push # applies all migrations in order psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/seed.sql
-
Set env on Vercel (and
.env.localfor local):NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,DATABASE_URL. Presence of these flipsgetPosDriver()to the Supabase driver automatically. -
Wire Supabase Auth so
auth.uid() == public.users.id— handled by the20260606000000_auth_user_bridge.sqltrigger (applied in step 1/2). Then:- In the Supabase dashboard → Authentication: enable the Email
provider (magic link), set Site URL to the production domain, and add
Redirect URLs for
/auth/callbackand/shop/*. - Run
npm run auth:bootstrap(withNEXT_PUBLIC_SUPABASE_URL+SUPABASE_SERVICE_ROLE_KEYset) to create the owner + platform-admin Auth users; the bridge links them to the seeded rows by email. Seedocs/PRODUCTION_READINESS.md§1a for the full walkthrough.
- In the Supabase dashboard → Authentication: enable the Email
provider (magic link), set Site URL to the production domain, and add
Redirect URLs for
-
Verify isolation against the live DB:
SKIP_AUTH_SHIM=1 DATABASE_URL=... bash supabase/tests/run-rls-isolation.sh→ expectRLS isolation test PASSED.
service-role key is server-only. The Supabase driver reads it only on the server and EVERY tenant-scoped query carries an explicit
tenant_id/location_idfilter, so it never crosses tenants even though it bypasses RLS. Never expose it to the client; keep it in a secret manager.
The realtime layer (src/lib/realtime/) flips from polling to Supabase
Realtime automatically when the public Supabase env is present
(getRealtimeProvider() mirrors the getPosDriver() env-guard; with no env it
stays on the poller, so the build/preview/Vitest suite remain green with zero
config). The browser client (@supabase/ssr) opens a postgres_changes
subscription on the orders table, scoped by location_id (KDS board) or
id (customer tracker), and re-fetches the same server-computed payload on each
change. This is client-side and runs as the signed-in user, so Realtime
enforces the same RLS SELECT policies as PostgREST — a user only ever receives
rows for tenants/locations they're a member of.
For Postgres changes to actually be streamed, the orders table must be added
to the supabase_realtime publication, and (so UPDATE/DELETE events carry
the full old row that the location_id/id server-side filter matches on) set
REPLICA IDENTITY FULL. Run this ONCE on the live DB (idempotent guards
included):
-- 1. Stream the full row on UPDATE/DELETE so Realtime's row filter
-- (location_id=eq.<id> / id=eq.<id>) can match the OLD row, not just NEW.
alter table public.orders replica identity full;
-- 2. Add orders to the realtime publication (create it first if the project
-- somehow lacks the default one).
do $$
begin
if not exists (select 1 from pg_publication where pubname = 'supabase_realtime') then
create publication supabase_realtime;
end if;
end$$;
alter publication supabase_realtime add table public.orders;To verify it took:
-- orders should appear in the publication's table list:
select schemaname, tablename
from pg_publication_tables
where pubname = 'supabase_realtime';
-- replica identity should be 'f' (FULL) for orders:
select relreplident from pg_class where oid = 'public.orders'::regclass;Notes:
- RLS is the security boundary, not the filter. The client-side
location_id/idfilter is a narrowing convenience; theordersRLS SELECT policies (customer-owns-their-orders / tenant-staff) already gate which rows a user may receive over Realtime. No policy changes are needed to enable Realtime — it reuses the existing SELECT policies. - Only
ordersis subscribed today. The KDS board derives station/age/ elapsed server-side and the tracker pulls live delivery state, both via the samefetcherthe poller used — so anordersstatus/row change is the single trigger that fans out to both surfaces. If a future feature needs row-level pushes from another table (e.g.deliveries), add it the same way:alter table public.deliveries replica identity full;thenalter publication supabase_realtime add table public.deliveries;. - Not required for the build. None of this is needed for the app to build, deploy, or pass tests — it only activates the websocket path once the live env vars are set. Until applied, an env-configured deployment still works because the client receives no change events and the initial-load fetch still renders; applying the SQL is what makes updates instant instead of on next navigation.
tests/rls_isolation.sql inserts two tenants + a member each + a platform admin,
then impersonates each user (via request.jwt.claims + the authenticated
role) and asserts:
- tenant A's member sees exactly tenant A's tenant/location and never tenant B's;
- tenant B's member sees only tenant B's;
- a cross-tenant write is blocked;
- a platform admin sees both tenants.
The whole script runs in a transaction and rolls back — it does not mutate persistent data.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/tests/rls_isolation.sql
# expect: result = "RLS isolation test PASSED"The script asserts:
- tenant A's member sees exactly tenant A's tenant/location and never tenant B's;
- tenant B's member sees only tenant B's;
- a cross-tenant write is blocked and leaves no row behind;
- memberships are tenant-scoped (no cross-tenant staff visibility);
- a member cannot read another tenant's user row but can read their own;
- a platform admin sees both tenants.
Run this as a role that owns the tables (e.g. the migration/
postgresrole). It switches toauthenticatedinternally to exercise RLS.
supabase/tests/run-rls-isolation.sh applies the migrations and runs the test
in one step against any Postgres. Point it at a fresh database (a throwaway
container or a provisioned Supabase project):
# Local throwaway Postgres:
docker run -d --name pos-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" \
bash supabase/tests/run-rls-isolation.shA non-blocking rls-isolation job in .github/workflows/ci.yml spins up a
Postgres service, applies the migrations, and runs this test on every PR. It is
marked continue-on-error: true because a live DB must NOT be a required CI
dependency for the platform (the app builds and the full Vitest suite passes
with zero env vars). The required gates are build + test (Vitest). The
app-layer complement to this DB-level test lives in
src/lib/db/tenant-isolation.test.ts and does run in the required suite.
The menu portion of the seed (categories, items, sizes, modifier groups with a
supports_half flag) targets tables introduced by a Phase 1 migration. The
seed guards that section with to_regclass(...), so it is a safe no-op until
those tables exist, and becomes a complete menu seed once they do.