- set_staff_role/remove_staff guards were NULL-unsafe: an authenticated but
unenrolled principal (possible on the shared auth pool) skipped the RAISE
and could promote or delete staff; rewritten with the null-safe form.
- Report/prescription PDFs now require a clinical role and the invoice PDF a
staff role, matching the screens that link to them; the role sets live in
lib/db/roles.js and are shared by layouts, actions, and route handlers.
- Storage policies on bsk-checkup-media scoped to clinical roles.
- Billing RPCs serialize on a per-checkup advisory lock so a line rewrite can
never commit after payment; mark_order_paid rejects deleted checkups and
zero-line invoices.
- PDF customer lookups skip soft-deleted patients; invite rate limiter fails
closed (SMTP spend is irrecoverable); patient search escapes LIKE wildcards.
- app/, components/, lib/, i18n/, tests/, proxy renamed via git mv
- types carried into JSDoc: @template generics, @typedef aliases,
/** @type */ casts where TS had as-casts or non-null assertions
- role tuple keeps its const assertion; the db-enum drift guard now
checks the tuple values against the generated enum type
- use client/use server directives and server-only imports preserved
- prettier-formatted; vitest suite unchanged
Replace pnpm-lock.yaml with package-lock.json. Four of the five overrides move
across unchanged, ranges intact.
postcss needed a different shape: npm rejects an override that disagrees with a
direct dependency (EOVERRIDE), and postcss was a devDependency at ^8.5.15 while
the override floored it at >=8.5.18. The devDependency now sits at the advisory
floor and the override references it as $postcss, so transitive copies follow
the same resolution the pnpm override produced.
brace-expansion is still deliberately not overridden. It resolves to 1.1.18 for
eslint's minimatch 3.x chain and 5.0.9 elsewhere; 1.1.18 is a patched 1.x, which
did not exist when the exception was written, so eslint runs clean and the
advisory is gone.
.npmrc held only pnpm-specific settings (verify-deps-before-run,
ignored-build-scripts-status-warn-only) and is removed. db:push, the Playwright
webServer command, and the secret-leak pathspec all referenced pnpm and would
have broken.
- admin staff list (email + role) over bsk.app_users; invite stays the
create path
- change role / remove access via admin-client Server Actions (audit-logged)
- guards: no self role-change or self-removal; the last admin cannot be
demoted or removed
- sidebar nav + vi/en; completes Phase 2 core entities
- bsk.doctors table (soft-delete) with RLS: reads for enrolled staff,
writes admin-only; user-client writes so RLS is the enforcement point
- add / edit / deactivate Server Actions — admin-gated, Zod-validated,
audit-logged via log_audit, revalidate the list
- doctors admin page (RSC list + inline edit + deactivate) and add form
- sidebar nav entry + vi/en strings
Establishes the Phase 2 CRUD pattern (RLS gate + defense-in-depth role
check + Zod + audit + revalidate) for the remaining core entities.
- claim_first_admin: no-arg, inserts auth.uid(), gated on an email allowlist
table so an arbitrary shared-pool principal can no longer claim admin
- revoke direct writes on app_users from authenticated (least privilege)
- rate-limit sign-in (by platform IP) and invite (by admin id); fail open
on Redis outage so an outage cannot lock staff out
- audit_log table + SECURITY DEFINER log_audit writer, admin-only reads
- invite: map existing-email to a clear error, roll back orphaned auth row
- session: read role + full_name in one own-row query
- supabase/migrations/20260525163400_bsk_admin.sql:
bsk.claim_first_admin(uuid) -> boolean, VOLATILE SECURITY DEFINER.
Advisory lock keyed by hashtext('bsk:claim_first_admin')::bigint
serializes concurrent first-sign-ins; EXISTS-guarded INSERT means
only the first caller wins.
- types/supabase-bsk.ts: added claim_first_admin to bsk.Functions.
- lib/auth/invite-schema.ts: InviteUserSchema (Zod v4: email + role
enum derived from appRoles) + InviteUserState discriminated union.
- app/[locale]/(app)/admin/invite/{actions,page,form}.tsx: admin-only
invite Server Action + page + RHF/useActionState client form.
Caller-role check via getServerSession() (defense in depth; the
(app)/admin layout in phase 06 will gate at the route level).
Insert uses createSupabaseAdminClient() because app_users has no
INSERT RLS policy by design.
- app/[locale]/(auth)/sign-in/actions.ts: extended enrollment-check
branch — when no row AND count == 0, calls claim_first_admin RPC.
On true, re-fetches enrollment row and proceeds; on false (race
lost) or count > 0, falls through to existing sign-out + generic
error (enumeration defense preserved).
- messages/{vi,en}.json: admin.invite.* keys (parity).
- docs/runbooks/first-admin-setup.md: happy path + manual psql
fallback bootstrap procedure.
No audit_log refs — trimmed plan respected.
- proxy.ts: composes Supabase session refresh + next-intl middleware
into a single NextResponse via copyCookies helper. Coarse auth gate
on /dashboard + /admin prefixes redirects unauth users to
/[locale]/sign-in (no ?next= per trimmed plan).
- lib/supabase/session.ts: implements updateSupabaseSession() returning
{ response, user }. Cookies written onto both request.cookies (for
downstream reads) and response.cookies (for browser). PROTECTED_PATH_PREFIXES
exported as the gate list.
- lib/proxy/copy-cookies.ts: small helper that ports Set-Cookie entries
between two NextResponses.
- lib/auth/get-server-session.ts: getServerSession() returning
{ user, role } | null. Derives User type from the factory's return
type so @supabase/supabase-js stays out of allow-listed lib/auth/*
per ESLint no-restricted-imports.
- lib/auth/session-provider.tsx: client-side context exposing user to
client components via useSession() — populated once per request in
the locale layout.
- app/[locale]/layout.tsx: reads user via getUser() outside any
'use cache' scope; wraps children in SessionProvider; explicit
'use cache' warning comment.