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
| Environment | Supabase project | Vercel | Data |
|---|---|---|---|
| Local | supabase start (Docker) | next dev | prisma/seed.ts |
| Preview / staging | ros-staging | preview deployments | seed + test tenants |
| Production | ros-prod | production | real 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.
| Variable | Where | Secret | Used by |
|---|---|---|---|
DATABASE_URL | server | 🔒 | Prisma at runtime — pooled (Supavisor transaction mode, port 6543, ?pgbouncer=true&connection_limit=1), user app_server.<project-ref> |
DIRECT_URL | CI / local | 🔒 | Prisma migrations only — direct port 5432, user postgres |
NEXT_PUBLIC_SUPABASE_URL | browser + server | — | Auth, Realtime, Storage clients |
NEXT_PUBLIC_SUPABASE_ANON_KEY | browser + server | — (public by design; RLS is what protects data) | same |
SUPABASE_SERVICE_ROLE_KEY | server | 🔒🔒 | Signed upload URLs, admin auth calls (invites). Never in a NEXT_PUBLIC_ var, never in a client component |
NEXT_PUBLIC_ROOT_DOMAIN | both | — | restaurant-os.com — proxy + getTenantOrigin (already read today) |
CRON_SECRET | server | 🔒 | /api/cron/* authorization |
UPSTASH_REDIS_REST_URL / _TOKEN | server | 🔒 | Rate limiting (docs/12 §3) |
VERCEL_API_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID | server | 🔒 | Custom domain attach/verify (docs/13 §6) |
RESEND_API_KEY (or Postmark) | server | 🔒 | Invites, resets, invoices |
SMS_API_KEY, SMS_SENDER_ID | server | 🔒 | docs/08 §2 |
BKASH_*, NAGAD_*, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | server | 🔒 | 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:
prisma migrate deploy— tables, enums, indexes, FKs (aspostgresviaDIRECT_URL).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:
| Principal | Connects as | Tenant identity | Can reach |
|---|---|---|---|
| Next.js server (Prisma) | app_server | SET LOCAL app.tenant_id per transaction (docs/15 §4), or app.platform = 'on' | All tables, filtered by policy |
| Browser (Supabase JS, signed in) | authenticated | its JWT's sub → staff_members.userId | SELECT 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:
Policy families in policies.sql:
| Family | Tables | Rule |
|---|---|---|
| Tenant roots | 26 tables with "tenantId" | for all using/with check can_access_tenant("tenantId") |
| Children | 13 tables (lines, variants, messages…) | same, through the parent row (exists … parent.tenantId) — meal_package_choice_options goes two hops |
| The tenant row | tenants | read/update own; insert/delete only in platform context (signup runs withPlatform) |
| Platform-owned, tenant-visible | invoices, vip_overrides, tenant_feature_overrides | tenant may read its own; only platform writes — a restaurant cannot grant itself a VIP deal |
| Nullable tenant | audit_log_entries, notification_templates, notification_dispatches | null tenant = platform row; audit is insert + select only, plus a trigger that blocks UPDATE/DELETE even for the owner |
| Catalog | subscription_plans, feature_flags | everyone reads (feature resolution needs them); platform writes |
| Platform only | platform_users | platform 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
| Who | Method | Session |
|---|---|---|
| Owner, manager | Email + password (Supabase Auth); magic link optional | Supabase cookies via @supabase/ssr |
| Platform staff | Email + password, MFA (TOTP) required | same; requirePlatform() checks AAL2 |
| Waiter / kitchen / rider / cashier on a shared device | The device is signed in by a manager once; people switch by 4-digit PIN | Supabase session = the device; a signed, httpOnly ros_actor cookie (15 min sliding) = the person |
| Invited staff with their own phone | Accept invite → set password → normal session | Supabase cookies |
| Guest | None | Table session id + QR token, verified per action |
A shared kitchen tablet across one shift:
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
| Bucket | Public | Path | Written by | Limits |
|---|---|---|---|---|
menu-images | ✅ | <tenantId>/<menuItemId>/<uuid>.webp | menu module | ≤ 1 MB after the existing client downscale (src/lib/image-downscale.ts), image/* only, ≤ 6 per dish |
package-images | ✅ | <tenantId>/<packageId>/<uuid>.webp | packages module | same |
branding | ✅ | `<tenantId>/logo | cover/<uuid>.webp` | tenants module |
invoices | ❌ | <tenantId>/<invoiceId>.pdf | billing 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.
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):
| Job | Schedule | Does |
|---|---|---|
expire-sessions | every 15 min | ends table_sessions idle > SESSION_IDLE_MS (3 h) |
verify-domains | every 10 min | re-checks pending custom domains via Vercel API; sets verified |
invoices | 1st of month 03:00 Asia/Dhaka | renewal invoices, respecting VIP overrides |
rollups | nightly | item 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:
| Assertion | Postgres 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 B | new row violates row-level security policy for table "orders" |
| ✅ | app_server with NO tenant set sees nothing | |
| ✅ | a tenant cannot mint itself a VIP deal | new row violates row-level security policy for table "vip_overrides" |
| ✅ | a tenant cannot create another tenant | new 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 orders | permission denied for table orders |
| ✅ | audit log is append-only even for the owner | audit_log_entries is append-only |
| ✅ | spice level is 0–3 | new row for relation "menu_items" violates check constraint "menu_items_spice_range" |
| ✅ | one open sitting per table | duplicate key value violates unique constraint "table_sessions_one_open" |
| ✅ | same order number twice in one tenant | duplicate key value violates unique constraint "orders_tenantId_orderNumber_key" |
| ✅ | custom domains are stored normalised | new 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 চালু।