Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@
# NEVER commit real values — this repo is PUBLIC.
# ============================================================================

# ---- Supabase (Phase 0 schema lives in supabase/, live DB DEFERRED) ---------
# ---- Supabase (schema in supabase/; driver in src/lib/db/supabase.ts) -------
# Setting NEXT_PUBLIC_SUPABASE_URL + a key flips getPosDriver() from the
# in-memory mock to the LIVE Supabase driver — no code/call-site changes. Leave
# blank to stay on the mock (the zero-env default; build + tests pass with none).
# Apply the schema with `npm run db:apply` (DATABASE_URL set). See supabase/README.md.
# Public (browser) values:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Server-only. Bypasses RLS — never expose to the client; use with care.
# Server-only. Bypasses RLS — never expose to the client. The driver reads it
# only server-side and always filters tenant-scoped queries by tenant_id.
SUPABASE_SERVICE_ROLE_KEY=
# For psql / migrations / RLS tests:
# For psql / migrations / RLS tests (npm run db:apply, db:test:rls):
DATABASE_URL=

# ---- Stripe — Connect + Terminal + online (Phase 2) -------------------------
Expand Down
58 changes: 46 additions & 12 deletions docs/PRODUCTION_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ of the platform's correctness/security posture. It covers what is proven **now**
the **final live-wiring phase** (real Supabase + Stripe + crypto + DoorDash).

> Status of the build today: everything runs on the in-memory **mock driver**
> (`getPosDriver()`), every payment rail **simulates** settlement when its keys
> are absent, and the full automated test suite + production build pass with
> **no environment variables**. No live services are wired in this phase.
> (`getPosDriver()`) when no Supabase env is set, every payment rail **simulates**
> settlement when its keys are absent, and the full automated test suite +
> production build pass with **no environment variables**.
>
> **Persistence is now wired.** The real **Supabase driver** (`src/lib/db/supabase.ts`)
> implements the entire `PosDriver` contract over a complete Postgres schema
> (`supabase/migrations/20260605000000_domain_core.sql` + `..._domain_rls.sql`),
> with strict RLS on every table. Setting the Supabase env vars flips
> `getPosDriver()` to it with **no call-site changes**; absent them, the mock
> stays the default. The remaining go-live work is provisioning + credentials
> (below), not code.

---

Expand All @@ -28,14 +36,38 @@ tenant's driver calls. The SQL test proves a member of tenant A cannot read or
write tenant B's tenants/locations/memberships/users, that a blocked cross-tenant
write leaves no row, and that a platform admin sees everything.

The SQL isolation test now also covers the **operational tables** (orders,
payments) and the **public menu** surface: it asserts a member of tenant A sees
only tenant A's orders/payments, a cross-tenant order write is blocked and leaves
no row, the storefront `anon` role can read **both** tenants' menus (public) but
**cannot** read any orders/payments or write the menu.

**RLS/grants model for the new tables** (`..._domain_rls.sql`):
- Every domain table has RLS **enabled + FORCED**, keyed to `memberships` via the
same `is_tenant_member()` / `has_tenant_role()` / `is_platform_admin()` helpers.
- **Public menu read**: menu definition tables (categories/items/sizes/groups/
modifiers/links), `location_menu_overrides`, and `store_settings` grant
`SELECT` to `anon` (storefront renders for unauthenticated visitors). Writes
stay owner/manager-only. `tenants`/`locations` get an additive anon `SELECT`
policy for slug resolution.
- **Customer-owns-their-data**: a signed-in customer (`auth.uid() == customers.id`)
may read their own customer row + their own orders + those orders' line items,
modifiers, payments, and delivery (via `can_read_order()`). They may also
insert their own online order. All other order/payment writes are tenant-staff.
- Everything else (payments writes, inventory, staff/shifts, reports/close,
payment_settings, connect, subscriptions, onboarding) is tenant-member /
owner-manager scoped; `audit_log` is platform-admin only.
- Explicit `GRANT`s: `authenticated` gets full DML on every domain table (rows
gated by policies); `anon` gets `SELECT` only on the storefront-public surface.

**Go-live dependencies:**
- [ ] Provision Supabase; apply `supabase/migrations/*` and confirm RLS is ON +
FORCED on every table (existing + future orders/payments/etc. tables get
the same `memberships`-keyed policies before they hold tenant data).
- [x] Schema + RLS for orders/payments/menu/inventory/staff/settings/SaaS exist
with the same `memberships`-keyed policies (RLS ON + FORCED on all).
- [ ] Provision Supabase; `npm run db:apply` (or `supabase db push` + seed).
- [ ] Wire Supabase Auth so `auth.uid() == public.users.id` (the RLS assumption).
- [ ] Confirm the **service-role key is never** used for tenant-scoped
reads/writes without an explicit `tenant_id` filter (server code runs as
the `authenticated` role through PostgREST).
reads/writes without an explicit `tenant_id` filter — the Supabase driver
already filters every tenant-scoped query by `tenant_id`/`location_id`.
- [ ] Run `run-rls-isolation.sh` against the live DB; expect "RLS isolation test PASSED".

---
Expand Down Expand Up @@ -194,10 +226,12 @@ secrets**). Everything is optional; the app builds + the suite passes with none.

## 10. Go-live checklist (depends on the final live-wiring phase)

- [ ] Provision Supabase; apply migrations; enable + verify **RLS/FORCE** on all
tenant tables; run the RLS isolation harness green.
- [ ] Wire Supabase Auth (`auth.uid() == users.id`); swap `getPosDriver()` to the
Supabase driver (single switch in `src/lib/db/client.ts`, no call-site changes).
- [ ] Provision Supabase; `npm run db:apply` (migrations + seed); enable + verify
**RLS/FORCE** on all tenant tables; run the RLS isolation harness green
(now covers orders/menu/payments).
- [ ] Wire Supabase Auth (`auth.uid() == users.id`). Set the Supabase env vars —
`getPosDriver()` auto-selects the Supabase driver when they are present
(no call-site changes; the mock is the no-env default).
- [ ] Enable **PITR/backups**; test a restore.
- [ ] Stripe: live keys + **Connect onboarding per tenant** (KYC); Billing Prices
per tier; verify webhooks (payments, Connect, Billing) signature-checked.
Expand Down
94 changes: 94 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
"test": "vitest",
"test:run": "vitest run",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"db:apply": "bash supabase/apply.sh",
"db:test:rls": "bash supabase/tests/run-rls-isolation.sh"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.1",
"@supabase/supabase-js": "^2.107.0",
"@tanstack/react-query": "^5.62.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
113 changes: 113 additions & 0 deletions plans/napoletana-99713-supabase-wiring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# napoletana-99713 — Supabase persistence layer (schema + RLS + driver)

The final live-wiring step: build the **real Supabase driver + complete database
schema** so the feature-complete app can flip from the in-memory mock to live
persistence by setting env vars — without requiring those env vars to build (the
mock stays the zero-env default).

## Goal & invariants

- `getPosDriver()` selects the Supabase driver **iff** `NEXT_PUBLIC_SUPABASE_URL`
+ a key are present; otherwise the mock. Selection is **lazy** (read at call
time, never at module load) so the build + the full Vitest suite + the preview
pass with **zero env vars**.
- No UI/behaviour changes; no payment/delivery rail logic touched. Every existing
call site is unchanged (both drivers implement the same `PosDriver`).
- Public repo: only blank `.env.example`. Money is integer minor units everywhere.

## Schema (migrations)

Two new timestamped migrations, after the tenancy core (`20260601*`):

- **`20260605000000_domain_core.sql`** — every domain table the mock implies,
with enums mirroring the TS unions, FKs cascading from tenant/location, money
as `integer` cents, jsonb for structured blobs (`totals`, `fulfillment`,
report/drawer snapshots, rail `raw`, KDS thresholds), and indexes on
`tenant_id`/`location_id` + common query paths. Tables:
- Menu: `menu_categories`, `menu_items`, `item_sizes`, `modifier_groups`,
`modifiers`, `item_modifier_groups`, `location_menu_overrides`.
- Settings: `store_settings`, `payment_settings` (PK `(tenant_id, location_id)`).
- Orders: `orders` (header + jsonb totals/fulfillment), `order_items`
(denormalized line snapshot), `order_item_modifiers`.
- Payments: `payments` (tender per row; client UUID PK = idempotency key),
`connect_accounts`.
- Online: `customers` (unique `(tenant_id, email)`), `magic_link_tokens`,
`deliveries` (jsonb dropoff).
- Inventory: `inventory_items`, `inventory_movements` (ledger),
`item_inventory_links`.
- Staff/cash: `staff`, `shifts`, `shift_cash_events`, `business_day_closes`
(idempotent Z-report, unique `(location_id, business_date)`).
- SaaS: `subscriptions` (one per tenant), `tenant_onboarding`, `audit_log`.
Ids are uuid (text PK only for `subscriptions.id`, matching the `sub_*` ids).
`orders.customer_id` FK is added after `customers` exists (forward-ref).

- **`20260605000100_domain_rls.sql`** — RLS **enabled + FORCED** on every table,
keyed to `memberships` via the existing `is_tenant_member()` /
`has_tenant_role()` / `is_platform_admin()` helpers, plus two new helpers:
`is_self_customer(uuid)` and `can_read_order(uuid)` (lets a customer read the
full graph of their own order).

## RLS / grants model for the new tables

- **Public storefront read** (least-privilege, deliberate): menu definition
tables + `location_menu_overrides` + `store_settings` grant `SELECT` to `anon`
with `using (true)` SELECT policies (the storefront renders for unauthenticated
visitors; these hold no PII and a tenant's menu is already public on its
storefront). `tenants`/`locations` get **additive** `anon`-only SELECT policies
(PostgreSQL OR's permissive policies) for slug resolution — the member/admin
policies from the tenancy core are untouched. All **writes** stay owner/manager.
- **Customer-owns-their-data**: `orders`/`payments`/`deliveries` add a read path
for the order's own customer (`is_self_customer` / `can_read_order`); child
tables (`order_items`, `order_item_modifiers`) are readable to whoever can read
the parent order. A customer may also INSERT their own online order. All other
order/payment/delivery writes are tenant-member only.
- **Everything else** (payment_settings, connect, inventory, staff, shifts,
reports/close, subscriptions, onboarding) is tenant-member read / owner-manager
write; `audit_log` is **platform-admin only**.
- **Explicit grants** (mirrors the tenancy migration): `authenticated` gets full
DML on every domain table (rows gated by policies); `anon` gets `SELECT` only
on the storefront-public surface.
- **Isolation test extended** (`supabase/tests/rls_isolation.sql`): adds menu +
order + payment fixtures for both tenants and asserts member-A-sees-only-A
orders/payments, blocked cross-tenant order write leaves no row, `anon` reads
both menus but no orders/payments and cannot write the menu. Runs green in the
optional non-blocking `rls-isolation` CI job (vanilla Postgres + auth shim).

## Driver design (`src/lib/db/supabase.ts`)

- `createSupabaseDriver(config)` builds one `@supabase/supabase-js` client lazily
from env (`readSupabaseConfig()` prefers the **service-role** key server-side,
else anon). Every tenant-scoped query carries an explicit `tenant_id`/
`location_id` filter, so service-role use never crosses tenants even though it
bypasses RLS (per supabase/README.md).
- Implements **every** `PosDriver` method (1:1 with the mock semantics):
idempotent upsert-by-UUID for orders/payments/deliveries/customers; menu
assembly folds per-location overrides + 86 exactly like `mock.assembleMenu`;
inventory depletion walks links → resolves the location row by name → writes a
signed movement + new level; reports reuse `buildSalesReport`/`isoDate` with
DB-resolved category/location label maps; `closeBusinessDay` is idempotent.
- Row↔domain mappers normalise nullable columns to `null` and round-trip jsonb
blobs verbatim, so both drivers return byte-identical objects to call sites.

## Apply + go-live

- `supabase/apply.sh` (+ `npm run db:apply`) applies all migrations in order
(timestamp-sorted) then the seed against a `DATABASE_URL`; auto-detects
`auth.uid()` (shim on vanilla Postgres, skip on real Supabase); `SKIP_SEED=1`
for production.
- `supabase/seed.sql` expanded to the full demo (Tony's Pizza, 2 locations, full
menu, owner+membership+platform admin, per-location store/payment settings with
fulfillment/zones, inventory + recipe links + staff, onboarding + Pro
subscription) — matches what the mock shows.
- Remaining go-live steps need live credentials only: provision project → set
envs → `npm run db:apply` → wire Supabase Auth (`auth.uid() == users.id`) →
run `run-rls-isolation.sh` green. Documented in `supabase/README.md` +
`docs/PRODUCTION_READINESS.md`.

## Verification

- Local: `npm run build`, `typecheck`, `lint`, `test:run` all green with **no env**.
- DB: migrations + seed + extended isolation test applied to a throwaway
Postgres via `supabase/tests/run-rls-isolation.sh` → "RLS isolation test PASSED".
- CI: required `build` + `test` green; optional `rls-isolation` job now covers
orders/menu/payments.
19 changes: 11 additions & 8 deletions src/lib/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/
import type { PosDriver } from "./driver";
import { mockDriver } from "./mock";
import { createSupabaseDriver, readSupabaseConfig } from "./supabase";

export interface DbClientConfig {
url: string;
Expand Down Expand Up @@ -55,21 +56,23 @@ export function resetDb(): void {
}

// ----------------------------------------------------------------------------
// PosDriver selection (Phase 1)
// PosDriver selection (live-wiring)
//
// The terminal talks to menu/order data ONLY through `getPosDriver()`. Today
// this always returns the in-memory mock driver (Supabase is deferred to the
// last phase). When a live Supabase project exists, swap the selection here
// based on `readDbConfig()` — NO call site changes required.
// The terminal talks to menu/order data ONLY through `getPosDriver()`. The
// selection is env-driven and LAZY (read at call time, never at module load):
// * Supabase env present (NEXT_PUBLIC_SUPABASE_URL + a SUPABASE_* key) → the
// real Supabase-backed driver.
// * Otherwise → the in-memory mock driver (the zero-env default, so the app
// builds/runs/tests with no configuration).
// NO call site changes are required either way — both implement `PosDriver`.
// ----------------------------------------------------------------------------

let cachedDriver: PosDriver | null = null;

export function getPosDriver(): PosDriver {
if (cachedDriver) return cachedDriver;
// Future: const config = readDbConfig();
// cachedDriver = config ? createSupabaseDriver(config) : mockDriver;
cachedDriver = mockDriver;
const config = readSupabaseConfig();
cachedDriver = config ? createSupabaseDriver(config) : mockDriver;
return cachedDriver;
}

Expand Down
Loading
Loading