- 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.
Defense-in-depth check that fails the build (and the local pre-push
workflow) if a server secret value is assigned to a NEXT_PUBLIC_*
variable — those get bundled into the browser by Next.js.
- scripts/check-no-secret-leak.mjs: git grep for the assignment shape,
excluding lockfiles and the script itself
- package.json: pnpm check:no-secret-leak
- .github/workflows/ci.yml: run the guard right after install, before
format/lint/typecheck/build
- docs/threat-model.md: close the last Unresolved item
- Phase 5 imaging: keep, pin numbers — 200 KB/image, 1h signed-URL TTL,
7-day retention window (PLAN.md §4 Phase 5)
- Phase 7 reminders: keep QStash (free tier 1000 msgs/day covers
clinic-scale); document signature + Zod + DB-invariant validation
plan in threat-model R8
- sb_secret_* / sb_publishable_* rotation: event-driven only for the
current educational scope (solo author, no real users); switch to
quarterly when any real user exists
- threat-model Unresolved: drop the three items above; CI grep for
sb_secret_ in NEXT_PUBLIC_* lines remains the only open question