diff --git a/db/auth-hook.sql b/db/auth-hook.sql new file mode 100644 index 0000000..3797382 --- /dev/null +++ b/db/auth-hook.sql @@ -0,0 +1,79 @@ +-- Custom Access Token Hook +-- Implements AD-2 from docs/03-architecture.md: +-- "A Postgres function copies role/org_id into JWT claims (custom +-- access token hook) so RLS can check them cheaply." +-- +-- Run this AFTER schema.sql (needs the profiles table to exist). +-- After running, enable the hook in the Supabase dashboard (see bottom +-- of this file for the exact steps) — running the SQL alone does not +-- activate it. + +create or replace function public.custom_access_token_hook(event jsonb) +returns jsonb +language plpgsql +stable +as $$ + declare + claims jsonb; + user_role public.user_role; + user_org_id uuid; + begin + -- Look up this user's role and org_id from profiles. + select role, org_id + into user_role, user_org_id + from public.profiles + where id = (event->>'user_id')::uuid; + + claims := event->'claims'; + + if user_role is not null then + claims := jsonb_set(claims, '{role}', to_jsonb(user_role)); + claims := jsonb_set(claims, '{org_id}', to_jsonb(user_org_id)); + else + claims := jsonb_set(claims, '{role}', 'null'); + claims := jsonb_set(claims, '{org_id}', 'null'); + end if; + + event := jsonb_set(event, '{claims}', claims); + + return event; + end; +$$; + +-- The hook runs as the supabase_auth_admin role, not as the logged-in +-- user, so it needs explicit permission to call the function and read +-- profiles. Everyone else is explicitly blocked from calling it directly. + +grant usage on schema public to supabase_auth_admin; + +grant execute + on function public.custom_access_token_hook + to supabase_auth_admin; + +revoke execute + on function public.custom_access_token_hook + from authenticated, anon, public; + +grant select + on table public.profiles + to supabase_auth_admin; + +create policy "Allow auth admin to read profiles for JWT hook" + on public.profiles + as permissive + for select + to supabase_auth_admin + using (true); + +-- ============================================================ +-- Dashboard steps to enable this hook (run the SQL above first): +-- +-- 1. Supabase dashboard → Authentication → Hooks (left sidebar, +-- under "Configuration"). +-- 2. Find "Customize Access Token (JWT) Claims hook". +-- 3. Choose "Postgres function" as the hook type. +-- 4. Select public.custom_access_token_hook from the dropdown. +-- 5. Enable the hook (toggle on) and save. +-- 6. Existing logged-in sessions keep their OLD token until they +-- refresh/re-login — log out and back in to see new claims. +-- ============================================================ diff --git a/db/policies.sql b/db/policies.sql new file mode 100644 index 0000000..1311b51 --- /dev/null +++ b/db/policies.sql @@ -0,0 +1,275 @@ +-- RLS Policies — all 12 tables +-- Source: docs/04-database-schema.md section 3 (role matrix below), extended +-- consistently to 4 tables the matrix doesn't cover (orgs, departments, +-- sop_assignments, ai_log) — marked "(not in matrix)" at each one. +-- +-- Coverage table (docs/04-database-schema.md section 3): +-- +-- | Table | staff | editor | approver | admin | +-- |--------------------|--------------------------------|--------------------|---------------------|-------| +-- | sops (read) | published + assigned dept only | all in org | all in org | all | +-- | sops (write) | - | insert/update drafts | - | all | +-- | sop_versions | read if assigned | read | read | all | +-- | acknowledgements | insert own; read own | read | read | read | +-- | profiles | read own + names in org | read org | read org | all | +-- | audit_log | - | - | read | read | +-- | approvals | - | read | insert/read | all | +-- | translations | read | read/write | read | all | +-- | incidents | insert own; read own | read org | read/update org | all | +-- +-- Workflow mutations (publish, approve, assign) run via the service-role +-- client inside /api routes so drafts can be snapshotted and audit rows +-- written in one transaction; RLS remains the safety net for direct reads. +-- Default pattern in this file: RLS grants READ access per role; WRITE +-- access via RLS is only granted where the matrix explicitly says so +-- (insert own, read/write, etc). Everything else is mutated exclusively +-- by service-role /api routes, which bypass RLS entirely. + +-- ============================================================ +-- orgs (not in matrix — read own org only, no client write) +-- ============================================================ +alter table orgs enable row level security; + +create policy orgs_read on orgs + for select using ( + id = (auth.jwt() ->> 'org_id')::uuid + ); +-- no insert/update/delete policy: single org, managed manually in Stage 1. + +-- ============================================================ +-- departments (not in matrix — read all in org, admin writes, FR-1.3) +-- ============================================================ +alter table departments enable row level security; + +create policy departments_read on departments + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +create policy departments_insert_admin on departments + for insert with check ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') = 'admin' + ); + +create policy departments_update_admin on departments + for update using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') = 'admin' + ); + +create policy departments_delete_admin on departments + for delete using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') = 'admin' + ); +-- "delete blocked if department has users" (FR-1.3) is enforced by the +-- profiles.department_id foreign key, not by RLS. + +-- ============================================================ +-- profiles — own row always; org-wide read (table has no sensitive +-- fields beyond role/department, so org-wide read is a low-risk +-- simplification); admin writes via RLS as a safety net (actual +-- invites/role-changes go through the service-role /api/users route). +-- ============================================================ +alter table profiles enable row level security; + +create policy profiles_read on profiles + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +create policy profiles_update_admin on profiles + for update using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') = 'admin' + ); +-- no insert policy: profile rows are created by the signup flow +-- (service-role), not inserted directly by a client. + +-- ============================================================ +-- sops +-- ============================================================ +alter table sops enable row level security; + +create policy sops_read on sops + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and ( + (auth.jwt() ->> 'role') in ('admin','approver','editor') + or ( + (auth.jwt() ->> 'role') = 'staff' + and status = 'published' + and exists ( + select 1 + from sop_assignments sa + join profiles p on p.department_id = sa.department_id + where sa.sop_id = sops.id + and p.id = auth.uid() + ) + ) + ) + ); + +create policy sops_insert_editor on sops + for insert with check ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('editor','admin') + ); + +create policy sops_update_editor on sops + for update using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and ( + (auth.jwt() ->> 'role') = 'admin' + or ( (auth.jwt() ->> 'role') = 'editor' and status = 'draft' ) + ) + ); + +-- ============================================================ +-- sop_versions — read only via RLS; every version is written by the +-- service-role publish route (AD-4), never directly, not even by admin. +-- ============================================================ +alter table sop_versions enable row level security; + +create policy sop_versions_read on sop_versions + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and ( + (auth.jwt() ->> 'role') in ('admin','approver','editor') + or ( + (auth.jwt() ->> 'role') = 'staff' + and exists ( + select 1 + from sop_assignments sa + join profiles p on p.department_id = sa.department_id + where sa.sop_id = sop_versions.sop_id + and p.id = auth.uid() + ) + ) + ) + ); + +-- ============================================================ +-- approvals — a decision, once made, is never editable or deletable. +-- ============================================================ +alter table approvals enable row level security; + +create policy approvals_read on approvals + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('editor','approver','admin') + ); + +create policy approvals_insert on approvals + for insert with check ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('approver','admin') + and decided_by = auth.uid() + ); + +-- ============================================================ +-- sop_assignments (not in matrix — read all in org, no client write; +-- set only via the service-role /api/sops/:id/assign route) +-- ============================================================ +alter table sop_assignments enable row level security; + +create policy sop_assignments_read on sop_assignments + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +-- ============================================================ +-- acknowledgements — immutable once written (FR-4.3): insert only, +-- never update or delete. +-- ============================================================ +alter table acknowledgements enable row level security; + +create policy ack_insert_own on acknowledgements + for insert with check ( + user_id = auth.uid() + and org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +create policy ack_read on acknowledgements + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and ( user_id = auth.uid() + or (auth.jwt() ->> 'role') in ('admin','approver','editor') ) + ); + +-- ============================================================ +-- sop_translations +-- ============================================================ +alter table sop_translations enable row level security; + +create policy translations_read on sop_translations + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +create policy translations_insert_editor on sop_translations + for insert with check ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('editor','admin') + ); + +create policy translations_update_editor on sop_translations + for update using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('editor','admin') + ); + +-- ============================================================ +-- audit_log — append-only. INSERT + SELECT policies only. +-- No UPDATE policy. No DELETE policy. Ever. (FR-3.4) +-- ============================================================ +alter table audit_log enable row level security; + +create policy audit_read on audit_log + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('approver','admin') + ); + +create policy audit_insert on audit_log + for insert with check ( + org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +-- ============================================================ +-- ai_log (not in matrix — admin-only read for cost tracking, no FRD +-- screen for it; no client write, only the service-role AI routes write) +-- ============================================================ +alter table ai_log enable row level security; + +create policy ai_log_read_admin on ai_log + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') = 'admin' + ); + +-- ============================================================ +-- incidents — FR-7.1 says "staff+" (any role) can report, so insert-own +-- is granted to every role, not just staff. +-- ============================================================ +alter table incidents enable row level security; + +create policy incidents_insert_own on incidents + for insert with check ( + reporter_id = auth.uid() + and org_id = (auth.jwt() ->> 'org_id')::uuid + ); + +create policy incidents_read on incidents + for select using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and ( reporter_id = auth.uid() + or (auth.jwt() ->> 'role') in ('editor','approver','admin') ) + ); + +create policy incidents_update on incidents + for update using ( + org_id = (auth.jwt() ->> 'org_id')::uuid + and (auth.jwt() ->> 'role') in ('approver','admin') + ); diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..f5ffc42 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,157 @@ +create type user_role as enum ('admin','approver','editor','staff'); +create type sop_status as enum ('draft','submitted','approved','published','archived'); +create type lang_code as enum ('en','ms','zh'); + +create table orgs ( + id uuid primary key default gen_random_uuid(), + name text not null, + created_at timestamptz default now() +); + +create table departments ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + name text not null, + unique (org_id, name) +); + +create table profiles ( + id uuid primary key references auth.users(id) on delete cascade, + org_id uuid not null references orgs(id), + full_name text not null, + role user_role not null default 'staff', + department_id uuid references departments(id), + preferred_language lang_code not null default 'en', + active boolean not null default true, + created_at timestamptz default now() +); + +create table sops ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + code text not null, -- e.g. WH-PICK-001 + title text not null, + department_id uuid references departments(id), + category text, + owner_id uuid references profiles(id), + status sop_status not null default 'draft', + review_months int not null default 12 check (review_months in (3,6,12,24)), + draft_content jsonb not null default '{}'::jsonb, -- working copy (sections, steps) + current_version_id uuid, -- FK added after sop_versions exists + published_at timestamptz, + created_by uuid references profiles(id), + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique (org_id, code) +); + +create table sop_versions ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + sop_id uuid not null references sops(id) on delete cascade, + version_label text not null, -- '1.0', '1.1', '2.0' + content jsonb not null, -- FROZEN snapshot of sections at publish + change_note text, + is_major boolean not null default false, + published_by uuid references profiles(id), + published_at timestamptz default now(), + unique (sop_id, version_label) +); + +alter table sops + add constraint fk_current_version + foreign key (current_version_id) references sop_versions(id); + +create table approvals ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + sop_id uuid not null references sops(id) on delete cascade, + decision text not null check (decision in ('approved','rejected')), + comment text, + decided_by uuid not null references profiles(id), + decided_at timestamptz default now() +); + +create table sop_assignments ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + sop_id uuid not null references sops(id) on delete cascade, + department_id uuid not null references departments(id), + assigned_by uuid references profiles(id), + assigned_at timestamptz default now(), + unique (sop_id, department_id) +); + +create table acknowledgements ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + sop_version_id uuid not null references sop_versions(id), + user_id uuid not null references profiles(id), + language_viewed lang_code not null, + typed_name text not null, -- the "signature" + acknowledged_at timestamptz default now(), + unique (sop_version_id, user_id) -- one ack per user per version +); + +create table sop_translations ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + sop_version_id uuid not null references sop_versions(id) on delete cascade, + language lang_code not null, + content jsonb not null, + machine boolean not null default true, + reviewed_by uuid references profiles(id), + reviewed_at timestamptz, + created_at timestamptz default now(), + unique (sop_version_id, language) +); + +create table audit_log ( + id bigint generated always as identity primary key, + org_id uuid not null references orgs(id), + actor_id uuid references profiles(id), + action text not null, -- 'sop.published', 'ack.recorded', ... + entity_type text not null, -- 'sop','user','acknowledgement' + entity_id uuid, + detail jsonb, + created_at timestamptz default now() +); +-- Append-only: grant INSERT/SELECT; never UPDATE/DELETE (enforced by grants + no policy). + +create table ai_log ( + id bigint generated always as identity primary key, + org_id uuid not null references orgs(id), + user_id uuid references profiles(id), + kind text not null check (kind in ('draft','translate')), + input_chars int, output_chars int, + input_tokens int, output_tokens int, + model text, success boolean, error text, + created_at timestamptz default now() +); + +create type incident_severity as enum ('low','medium','high'); +create type incident_status as enum ('open','reviewed','closed'); + +create table incidents ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references orgs(id), + reporter_id uuid not null references profiles(id), + department_id uuid references departments(id), -- copied from reporter at insert + sop_id uuid references sops(id), -- optional link + sop_version_id uuid references sop_versions(id), -- optional, set when reported from viewer + description text not null, + severity incident_severity not null default 'medium', + photo_path text, -- Storage path, optional + status incident_status not null default 'open', + resolution_note text, + reviewed_by uuid references profiles(id), + closed_at timestamptz, + created_at timestamptz default now() +); + +create index idx_incidents_status on incidents(org_id, status, severity); +create index idx_incidents_sop on incidents(sop_id); + +create index idx_sops_org_status on sops(org_id, status); +create index idx_ack_version on acknowledgements(sop_version_id); +create index idx_audit_entity on audit_log(entity_type, entity_id); diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..fe11cb7 --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,50 @@ +-- Seed data +-- Source: docs/04-database-schema.md section 4: +-- "One org (''), 3 departments (Inbound, Outbound, Admin), +-- the founder as admin, 2 test staff, 2 sample SOPs (one draft, one +-- published v1.0 with BM translation), a few acknowledgements — +-- enough for the dashboard to show real numbers on day one." +-- +-- NOTE: profiles.id references auth.users(id). Auth users don't exist yet +-- (M1 builds login/signup). Part 1 below is safe to run now (M0). Part 2 +-- is provided for later — after M1 creates real auth users via Supabase +-- Auth, replace the placeholder UUIDs with their real auth.users ids and +-- run it then. Do not run Part 2 yet. + +-- ============================================================ +-- Part 1 — safe to run now (no auth dependency) +-- ============================================================ + +insert into orgs (id, name) +values ('00000000-0000-0000-0000-000000000001', 'Demo Warehouse Co'); + +insert into departments (id, org_id, name) values + ('00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000001', 'Inbound'), + ('00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000001', 'Outbound'), + ('00000000-0000-0000-0000-000000000013', '00000000-0000-0000-0000-000000000001', 'Admin'); + +-- ============================================================ +-- Part 2 — run after M1 (auth users exist). Replace the id values +-- below with the real auth.users.id for each account. +-- ============================================================ + +-- insert into profiles (id, org_id, full_name, role, department_id, preferred_language) values +-- ('', '00000000-0000-0000-0000-000000000001', 'Founder Name', 'admin', '00000000-0000-0000-0000-000000000013', 'en'), +-- ('', '00000000-0000-0000-0000-000000000001', 'Staff One', 'staff', '00000000-0000-0000-0000-000000000011', 'ms'), +-- ('', '00000000-0000-0000-0000-000000000001', 'Staff Two', 'staff', '00000000-0000-0000-0000-000000000012', 'en'); + +-- insert into sops (id, org_id, code, title, department_id, category, owner_id, status, review_months, draft_content, created_by) values +-- ('00000000-0000-0000-0000-000000000021', '00000000-0000-0000-0000-000000000001', 'WH-PICK-001', 'Order Picking', '00000000-0000-0000-0000-000000000012', 'Warehouse', '', 'draft', 12, '{}'::jsonb, ''), +-- ('00000000-0000-0000-0000-000000000022', '00000000-0000-0000-0000-000000000001', 'WH-INB-001', 'Inbound Receiving', '00000000-0000-0000-0000-000000000011', 'Warehouse', '', 'published', 12, '{}'::jsonb, ''); + +-- insert into sop_versions (id, org_id, sop_id, version_label, content, published_by) values +-- ('00000000-0000-0000-0000-000000000031', '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000022', '1.0', '{"purpose":"...","steps":[]}'::jsonb, ''); + +-- update sops set current_version_id = '00000000-0000-0000-0000-000000000031', published_at = now() +-- where id = '00000000-0000-0000-0000-000000000022'; + +-- insert into sop_translations (org_id, sop_version_id, language, content, machine) values +-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000031', 'ms', '{"purpose":"...","steps":[]}'::jsonb, true); + +-- insert into acknowledgements (org_id, sop_version_id, user_id, language_viewed, typed_name) values +-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000031', '', 'ms', 'Staff One'); diff --git a/package-lock.json b/package-lock.json index 1218b4d..3891e33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.6.0", + "@supabase/ssr": "^0.12.4", + "@supabase/supabase-js": "^2.111.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", @@ -1913,6 +1915,115 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@supabase/auth-js": { + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.111.0.tgz", + "integrity": "sha512-hbRLgyQZEX0SDyF4LYXpv94qOIQyFATfpT5SIs2V0SisHnisVRkYgiPQWzv80l4S2mO3qof78rmoH9j5zoFsYQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.111.0.tgz", + "integrity": "sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.111.0.tgz", + "integrity": "sha512-pcqeDsnWP0lx9GawduYxNZJHeuTm53O7L0SC8RF8tniV3GWIPY6me6OTdnwzdwNUmNy1dzUVtSyIfE6+OflzPQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.111.0.tgz", + "integrity": "sha512-6oRf/vZyRwg8f8GbFSJkrD2w4HAu/yTvyMViHXHS+H5hNJzdXCrUR7cP5oW7daT3YlRnzRPY9LcGSJKZAmfMSg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.4.tgz", + "integrity": "sha512-xHzcgI8cC1TpBKSwJcR5Yd8CCwfIq0SBc5yb4yz/YFw5tbCrEQ0QT3a+2jymCxHgQWLfzwN93HZ6eRbcoMkOlA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.111.0" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.111.0.tgz", + "integrity": "sha512-UEViNmTzVOxE8dqUA81wls+n9xgmlvSFfhfwo6QxrO4kQOytCYyw3ciYFoi4XoD4Jl95NJ3jnndHN5iIudWzqw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.111.0.tgz", + "integrity": "sha512-9q0/AULthQnWeiDh1vGyjoJZbSY04bu6qHcWit70pqEYn5Kv/dkCPY62Ja1123jEnJbB9Vd2pjY7Kvk/lK3peA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.111.0", + "@supabase/functions-js": "2.111.0", + "@supabase/postgrest-js": "2.111.0", + "@supabase/realtime-js": "2.111.0", + "@supabase/storage-js": "2.111.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5623,6 +5734,15 @@ "node": ">=18.18.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", diff --git a/package.json b/package.json index 35afd21..280edc5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@supabase/ssr": "^0.12.4", + "@supabase/supabase-js": "^2.111.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", diff --git a/src/app/db-check/page.tsx b/src/app/db-check/page.tsx new file mode 100644 index 0000000..df80a41 --- /dev/null +++ b/src/app/db-check/page.tsx @@ -0,0 +1,24 @@ +import { createClient } from "@/lib/supabase/server"; + +export default async function DbCheckPage() { + const supabase = await createClient(); + const { data: org, error } = await supabase + .from("orgs") + .select("name") + .single(); + + return ( +
+

Supabase connection check

+ {org ? ( +

Org name: {org.name}

+ ) : ( +

+ No org row returned (expected — no login yet, RLS is correctly + denying an anonymous read). Error detail:{" "} + {error?.message ?? "none"} +

+ )} +
+ ); +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..9f2891b --- /dev/null +++ b/src/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..df1149a --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,28 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // setAll was called from a Server Component (cookies are read-only there). + // Safe to ignore as long as session refresh also runs in middleware. + } + }, + }, + } + ); +} diff --git a/src/lib/supabase/service.ts b/src/lib/supabase/service.ts new file mode 100644 index 0000000..9521f74 --- /dev/null +++ b/src/lib/supabase/service.ts @@ -0,0 +1,19 @@ +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; + +// Service-role client. Bypasses RLS entirely. +// Only ever import this inside /app/api/* route handlers, after the +// route has manually verified the caller's session and role (AD-3). +// Never import this in a client component or a plain read path. +export function createServiceClient() { + return createSupabaseClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + } + ); +}