M0: DB schema, RLS policies, JWT claims hook, seed, Supabase clients

Adds db/schema.sql (12 tables), db/policies.sql (RLS on all 12,
audit_log append-only), db/auth-hook.sql (role/org_id into JWT per
AD-2), db/seed.sql (org + 3 departments, part 2 deferred to M1 auth).
Wires lib/supabase/{client,server,service}.ts per AD-3 and adds
/db-check page confirming DB connectivity and RLS deny-by-default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcktbLXSSXzx23GCue813e
This commit is contained in:
Weei Han
2026-07-30 19:00:24 +08:00
co-authored by Claude Sonnet 5
parent 680b6a0194
commit f539d43136
10 changed files with 762 additions and 0 deletions
+79
View File
@@ -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.
-- ============================================================
+275
View File
@@ -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')
);
+157
View File
@@ -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);
+50
View File
@@ -0,0 +1,50 @@
-- Seed data
-- Source: docs/04-database-schema.md section 4:
-- "One org ('<Your Company>'), 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
-- ('<founder-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Founder Name', 'admin', '00000000-0000-0000-0000-000000000013', 'en'),
-- ('<staff-1-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Staff One', 'staff', '00000000-0000-0000-0000-000000000011', 'ms'),
-- ('<staff-2-auth-user-id>', '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', '<founder-auth-user-id>', 'draft', 12, '{}'::jsonb, '<founder-auth-user-id>'),
-- ('00000000-0000-0000-0000-000000000022', '00000000-0000-0000-0000-000000000001', 'WH-INB-001', 'Inbound Receiving', '00000000-0000-0000-0000-000000000011', 'Warehouse', '<founder-auth-user-id>', 'published', 12, '{}'::jsonb, '<founder-auth-user-id>');
-- 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, '<founder-auth-user-id>');
-- 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', '<staff-1-auth-user-id>', 'ms', 'Staff One');
+120
View File
@@ -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",
+2
View File
@@ -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",
+24
View File
@@ -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 (
<main style={{ padding: 24, fontFamily: "sans-serif" }}>
<h1>Supabase connection check</h1>
{org ? (
<p>Org name: {org.name}</p>
) : (
<p>
No org row returned (expected no login yet, RLS is correctly
denying an anonymous read). Error detail:{" "}
{error?.message ?? "none"}
</p>
)}
</main>
);
}
+8
View File
@@ -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!
);
}
+28
View File
@@ -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.
}
},
},
}
);
}
+19
View File
@@ -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,
},
}
);
}