Skip to content

14 — Supabase Setup: Projects, Auth, RLS, Storage, Realtime

Status: 📐 Target design — no Supabase project exists and the app has no Supabase dependency (CLAUDE.md, Rule 1). The SQL this doc describes is real and tested: docs/backend/supabase/policies.sql was applied on top of the tables generated from docs/backend/schema.prisma and the isolation assertions in §8 passed on Postgres 17.

🟢 বাংলা ভূমিকা: Supabase কীভাবে সেটআপ হবে — প্রজেক্ট, এনভায়রনমেন্ট ভেরিয়েবল, লগইন, Row-Level Security, ফাইল স্টোরেজ, রিয়েলটাইম। এখনো কোনো প্রজেক্ট নেই, কিন্তু SQL-টা শুধু কাগজে না — Postgres 17-এ চালিয়ে পরীক্ষা করা হয়েছে (§৮)।


1. Projects & environments

EnvironmentSupabase projectVercelData
Localsupabase start (Docker)next devprisma/seed.ts
Preview / stagingros-stagingpreview deploymentsseed + test tenants
Productionros-prodproductionreal restaurants only — seed refuses to run

Region: ap-south-1 (Mumbai) or ap-southeast-1 (Singapore) — the nearest to Dhaka. Set Vercel's function region to the same one (vercel.json → "regions": ["bom1"] or ["sin1"]); a Dhaka guest's order crossing to a US function and back to an Asian database pays that latency twice on every tap.

Plan: Pro from the first real restaurant — daily backups and point-in-time recovery are the difference between "we lost an evening's orders" and "we restored to 21:04".

🟢 বাংলা সারাংশ: তিনটা এনভায়রনমেন্ট — লোকাল (Docker), স্টেজিং, প্রোডাকশন। রিজিয়ন মুম্বাই বা সিঙ্গাপুর (ঢাকার সবচেয়ে কাছে), আর Vercel ফাংশনও একই রিজিয়নে — নইলে প্রতিটা ট্যাপে দুবার দূরত্বের খরচ। প্রথম আসল রেস্টুরেন্ট থেকেই Pro প্ল্যান, কারণ দৈনিক ব্যাকআপ আর point-in-time recovery।


2. Environment variables

The same list lives in .env.example at the repo root.

VariableWhereSecretUsed by
DATABASE_URLserver🔒Prisma at runtime — pooled (Supavisor transaction mode, port 6543, ?pgbouncer=true&connection_limit=1), user app_server.<project-ref>
DIRECT_URLCI / local🔒Prisma migrations only — direct port 5432, user postgres
NEXT_PUBLIC_SUPABASE_URLbrowser + server—Auth, Realtime, Storage clients
NEXT_PUBLIC_SUPABASE_ANON_KEYbrowser + server— (public by design; RLS is what protects data)same
SUPABASE_SERVICE_ROLE_KEYserver🔒🔒Signed upload URLs, admin auth calls (invites). Never in a NEXT_PUBLIC_ var, never in a client component
NEXT_PUBLIC_ROOT_DOMAINboth—restaurant-os.com — proxy + getTenantOrigin (already read today)
CRON_SECRETserver🔒/api/cron/* authorization
UPSTASH_REDIS_REST_URL / _TOKENserver🔒Rate limiting (docs/12 §3)
VERCEL_API_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_IDserver🔒Custom domain attach/verify (docs/13 §6)
RESEND_API_KEY (or Postmark)server🔒Invites, resets, invoices
SMS_API_KEY, SMS_SENDER_IDserver🔒docs/08 §2
BKASH_*, NAGAD_*, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETserver🔒docs/08 §1

The app_server role. Created (NOLOGIN) by policies.sql; in the dashboard, give it a password and LOGIN:

alter role app_server with login password '<generated, 32+ chars>';

Prisma must connect as app_server, not postgres: postgres owns the tables and bypasses RLS, which would turn every policy in §4 into decoration.

🟢 বাংলা সারাংশ: সব এনভায়রনমেন্ট ভেরিয়েবল টেবিলে, আর রিপো রুটের .env.example-এ। সবচেয়ে গুরুত্বপূর্ণ: Prisma রানটাইমে app_server রোল দিয়ে কানেক্ট করবে, postgres দিয়ে না — কারণ postgres টেবিলের মালিক, RLS এড়িয়ে যায়, তাহলে সব পলিসি অর্থহীন হয়ে যাবে। SUPABASE_SERVICE_ROLE_KEY কখনো NEXT_PUBLIC_ ভেরিয়েবলে বা ক্লায়েন্ট কম্পোনেন্টে যাবে না।


3. Applying the schema

# once, day one (docs/16 phase 0)
git mv docs/backend/schema.prisma prisma/schema.prisma
supabase init
cp docs/backend/supabase/policies.sql supabase/migrations/20260101000100_security.sql

Order on every environment:

  1. prisma migrate deploy — tables, enums, indexes, FKs (as postgres via DIRECT_URL).
  2. supabase db push (or the migration runner in CI) — policies.sql: roles, helpers, CHECKs, partial unique indexes, grants, RLS, Realtime publication, buckets.

Any later Prisma migration that adds a table must ship with a matching security migration in the same PR — a new table with no enable row level security is readable by app_server for every tenant. The CI isolation suite (§8) includes a check that every table in public has RLS enabled.

🟢 বাংলা সারাংশ: প্রথমে prisma migrate deploy (টেবিল), তারপর policies.sql (সিকিউরিটি)। পরে কোনো মাইগ্রেশনে নতুন টেবিল যোগ হলে একই PR-এ তার RLS-ও যোগ করতে হবে — নইলে সেই টেবিল সব টেন্যান্টের জন্য খোলা থাকবে। CI চেক করবে public-এর প্রতিটা টেবিলে RLS চালু আছে কিনা।


4. Row-Level Security — the model

Two kinds of principal reach the database, and the same helper answers for both:

PrincipalConnects asTenant identityCan reach
Next.js server (Prisma)app_serverSET LOCAL app.tenant_id per transaction (docs/15 §4), or app.platform = 'on'All tables, filtered by policy
Browser (Supabase JS, signed in)authenticatedits JWT's sub → staff_members.userIdSELECT on orders and table_calls only — for Realtime
Browser (guest, not signed in)anon—Nothing. Guests go through server actions
-- the one question every policy asks
app.can_access_tenant(t) :=
     t = current_setting('app.tenant_id')              -- server path
  or app.platform_context()                            -- platform admin (server flag, or active platform_users row)
  or exists (staff_members where tenantId = t
             and userId = auth.uid() and not deactivated)  -- browser path

One tenant-scoped request, end to end:

Sequence diagram

Policy families in policies.sql:

FamilyTablesRule
Tenant roots26 tables with "tenantId"for all using/with check can_access_tenant("tenantId")
Children13 tables (lines, variants, messages…)same, through the parent row (exists … parent.tenantId) — meal_package_choice_options goes two hops
The tenant rowtenantsread/update own; insert/delete only in platform context (signup runs withPlatform)
Platform-owned, tenant-visibleinvoices, vip_overrides, tenant_feature_overridestenant may read its own; only platform writes — a restaurant cannot grant itself a VIP deal
Nullable tenantaudit_log_entries, notification_templates, notification_dispatchesnull tenant = platform row; audit is insert + select only, plus a trigger that blocks UPDATE/DELETE even for the owner
Catalogsubscription_plans, feature_flagseveryone reads (feature resolution needs them); platform writes
Platform onlyplatform_usersplatform context only

Why RLS on the server path at all, when the server already filters by tenant: because the filter is one forgotten where away from a leak, and a leak in a multi-restaurant product is one restaurant reading another's customers. With app.tenant_id set, the forgotten where returns that tenant's rows only. §8 proves it.

What RLS does not do: decide roles inside a tenant (can a waiter edit prices?). That is requireStaff(tenantId, ["owner","manager"]) in the action — RLS gives tenant isolation, guards give authorisation. app.has_tenant_role() exists for the few browser-path checks that need it (Storage).

🟢 বাংলা সারাংশ: ডাটাবেজে দুই ধরনের কলার আসে — সার্ভার (Prisma, app_server রোল, প্রতিটা transaction-এ app.tenant_id সেট) আর সাইন-ইন করা ব্রাউজার (শুধু রিয়েলটাইমের জন্য orders আর table_calls পড়তে পারে)। অতিথির ব্রাউজার (anon) কিছুই পড়তে পারে না। একটাই হেল্পার can_access_tenant() দুই পথেই উত্তর দেয়। সার্ভার নিজে ফিল্টার করলেও RLS কেন? কারণ একটা ভুলে যাওয়া where মানেই এক রেস্টুরেন্ট অন্যের কাস্টমার দেখে ফেলা — RLS থাকলে সেই ভুলেও শুধু নিজের সারি আসে। রেস্টুরেন্টের ভেতরে কে কী করতে পারবে (ওয়েটার দাম বদলাতে পারবে কিনা) সেটা RLS না, action-এর guard ঠিক করে।


5. Auth

WhoMethodSession
Owner, managerEmail + password (Supabase Auth); magic link optionalSupabase cookies via @supabase/ssr
Platform staffEmail + password, MFA (TOTP) requiredsame; requirePlatform() checks AAL2
Waiter / kitchen / rider / cashier on a shared deviceThe device is signed in by a manager once; people switch by 4-digit PINSupabase session = the device; a signed, httpOnly ros_actor cookie (15 min sliding) = the person
Invited staff with their own phoneAccept invite → set password → normal sessionSupabase cookies
GuestNoneTable session id + QR token, verified per action

A shared kitchen tablet across one shift:

Sequence diagram

Signup (/signup, the existing 4-step wizard): creates the auth user, then in one withPlatform transaction the tenant (status trial), its owner staff_members row, a main branch, the order counter and the default zones. Subdomain availability uses the same rule as today (src/lib/signup.ts), now checked against tenants.slug.

Shift PIN: pinHash is argon2id; verification is rate-limited per device and per staff member (5 tries → 5 min lock, Upstash). The demo screen that shows the PIN (src/lib/staff-pin.ts) is deleted with phase 2.

Staff invites: staff_invites row with tokenHash = SHA-256 of a 32-byte token; the email link carries the raw token; accept = find by hash, not expired/revoked → create auth user (service role) + staff_members row → mark accepted. Refusing an address already on the team is the rule staff-invite.ts already enforces.

Password reset keeps today's copy: it confirms "if an account exists" without saying whether one does (the E2E suite already asserts this).

🟢 বাংলা সারাংশ: মালিক/ম্যানেজার ইমেইল-পাসওয়ার্ডে; প্ল্যাটফর্ম স্টাফের জন্য MFA বাধ্যতামূলক; শেয়ার করা ডিভাইসে ম্যানেজার একবার সাইন-ইন করেন, তারপর স্টাফ ৪-অঙ্কের PIN দিয়ে বদলান (PIN argon2id হ্যাশ, ৫ বার ভুলে ৫ মিনিট লক)। অতিথির কোনো লগইন নেই — টেবিল সেশন আর QR টোকেন দিয়ে প্রতিটা action যাচাই হয়। সাইনআপ একটা transaction-এ টেন্যান্ট, মালিক, মেইন ব্রাঞ্চ, অর্ডার কাউন্টার আর ডিফল্ট জোন তৈরি করে। স্টাফ ইনভাইটের টোকেন শুধু ইমেইলে, ডাটাবেজে তার হ্যাশ।


6. Storage

BucketPublicPathWritten byLimits
menu-images✅<tenantId>/<menuItemId>/<uuid>.webpmenu module≤ 1 MB after the existing client downscale (src/lib/image-downscale.ts), image/* only, ≤ 6 per dish
package-images✅<tenantId>/<packageId>/<uuid>.webppackages modulesame
branding✅`<tenantId>/logocover/<uuid>.webp`tenants module
invoices❌<tenantId>/<invoiceId>.pdfbilling module (server-generated)read via invoices_read policy or a signed URL

Uploads never go browser → bucket with the user's own rights. The action validates (owner/manager, dish belongs to tenant, count limit), then returns a signed upload URL created with the service role; the browser PUTs the (already downscaled) file there; a second action records the path. So storage.objects has no INSERT policy for browsers at all. Public buckets are world-readable by design — a dish photo is on a public menu.

Deleting a dish removes its folder in the same action (after commit).

🟢 বাংলা সারাংশ: চারটা বাকেট — মেনুর ছবি, প্যাকেজের ছবি, ব্র্যান্ডিং (সবগুলো পাবলিক, কারণ মেনু পাবলিক) আর ইনভয়েস (প্রাইভেট)। ব্রাউজার নিজের অধিকারে সরাসরি আপলোড করে না: action আগে যাচাই করে, তারপর service role দিয়ে একটা signed upload URL দেয়, ব্রাউজার সেখানে ফাইল পাঠায়। তাই ব্রাউজারের জন্য কোনো INSERT পলিসিই নেই।


7. Realtime, proxy lookup, cron

Realtime replaces the storage event that syncs tabs today. Staff screens subscribe with the signed-in client:

supabase
  .channel(`orders:${tenantId}`)
  .on("postgres_changes",
      { event: "*", schema: "public", table: "orders", filter: `tenantId=eq.${tenantId}` },
      (payload) => refresh(payload.new.id))
  .subscribe();

RLS applies to Realtime: a subscription only receives rows can_access_tenant allows, so a tampered filter returns nothing. The payload is used as a signal — the hook refetches the full order (with lines) through a server action rather than trusting a partial row.

Sequence diagram

Guests have no Supabase session, so they don't subscribe to Postgres changes. Their tracking screen either polls getMyOrder(orderId, sessionId) every 10 s (phase 3 — simple, cheap), or later listens on a Realtime Broadcast topic order:<orderId> that the server publishes to after each status change.

Proxy custom-domain lookup: src/proxy.ts calls app.resolve_tenant_by_host(host, ROOT_DOMAIN) only for hosts that are neither the root domain nor a *.root subdomain it can already parse, and caches the answer (hit and miss) for 60 s in memory + Upstash. A verified domain resolves; a pending one does not — the rule docs/13 §6 already states.

Cron (vercel.json crons → /api/cron/*, CRON_SECRET):

JobScheduleDoes
expire-sessionsevery 15 minends table_sessions idle > SESSION_IDLE_MS (3 h)
verify-domainsevery 10 minre-checks pending custom domains via Vercel API; sets verified
invoices1st of month 03:00 Asia/Dhakarenewal invoices, respecting VIP overrides
rollupsnightlyitem ratings, CRM segments

🟢 বাংলা সারাংশ: আজ ট্যাবগুলো storage ইভেন্টে সিঙ্ক হয়; ব্যাকএন্ডে সেটা Supabase Realtime। স্টাফ স্ক্রিন orders টেবিলে সাবস্ক্রাইব করে — RLS রিয়েলটাইমেও কাজ করে, তাই ফিল্টার বদলালেও অন্যের ডেটা আসে না। অতিথির সেশন নেই, তাই প্রথমে ১০ সেকেন্ডে poll, পরে Broadcast। Proxy কাস্টম ডোমেইন ডাটাবেজ ফাংশন দিয়ে খোঁজে আর ৬০ সেকেন্ড cache রাখে। Cron: আইডল সেশন বন্ধ, ডোমেইন যাচাই, মাসিক ইনভয়েস, রাতের হিসাব।


8. What was verified, and how

policies.sql was applied to the DDL that prisma migrate diff --from-empty --to-schema-datamodel docs/backend/schema.prisma --script generates, on PGlite (Postgres 17.5) with stand-ins for Supabase's auth.uid(), storage.objects, storage.foldername() and the supabase_realtime publication. Two tenants were seeded, a staff user on one of them, and an active platform user. Every one of these held:

AssertionPostgres said
✅DDL + policies apply cleanly
✅app_server + tenant A sees only A's orders
✅…and only A's line items (child via parent)
✅…and only its own tenant row
✅…but the whole plan catalog
✅…and no platform users
✅app_server + tenant A cannot write an order into Bnew row violates row-level security policy for table "orders"
✅app_server with NO tenant set sees nothing
✅a tenant cannot mint itself a VIP dealnew row violates row-level security policy for table "vip_overrides"
✅a tenant cannot create another tenantnew row violates row-level security policy for table "tenants"
✅platform context sees every tenant's orders
✅staff of A (JWT) sees A's orders over Realtime
✅staff of A reads A's invoice PDF, not B's
✅browser session cannot read menu_items directly (no grant)permission denied for table menu_items
✅a signed-in stranger sees no orders
✅an active platform user sees all orders
✅anon (guest browser) cannot read orderspermission denied for table orders
✅audit log is append-only even for the owneraudit_log_entries is append-only
✅spice level is 0–3new row for relation "menu_items" violates check constraint "menu_items_spice_range"
✅one open sitting per tableduplicate key value violates unique constraint "table_sessions_one_open"
✅same order number twice in one tenantduplicate key value violates unique constraint "orders_tenantId_orderNumber_key"
✅custom domains are stored normalisednew row for relation "custom_domains" violates check constraint "domain_normalised"
✅order numbers are sequential per tenant#1001, #1002, B:#1001
✅verified custom domain resolves
✅pending custom domain does NOT resolve
✅subdomain resolves (port and case ignored)
✅root domain is the platform

The same harness becomes src/server/db/policies.test.ts in phase 0 and runs on every CI build — plus one more assertion that cannot be hand-listed: every table in public has relrowsecurity = true, so a new table without policies fails the build.

🟢 বাংলা সারাংশ: policies.sql আসল Postgres 17 (PGlite)-এ চালানো হয়েছে, Supabase-এর auth/storage অংশের নকল দিয়ে। দুটো টেন্যান্ট, একজন স্টাফ আর একজন প্ল্যাটফর্ম ইউজার বসিয়ে উপরের প্রতিটা দাবি যাচাই হয়েছে — যেমন সার্ভার এক টেন্যান্টে থেকে অন্যের অর্ডার দেখতে বা লিখতে পারে না, টেন্যান্ট সেট না থাকলে কিছুই দেখা যায় না, অডিট লগ মালিকও বদলাতে পারে না, অর্ডার নম্বর টেন্যান্ট অনুযায়ী পরপর। Phase 0-তে এই হার্নেসটাই CI-তে প্রতিবার চলবে, সাথে একটা চেক: public-এর প্রতিটা টেবিলে RLS চালু।