docs(plan): trim phase 1 to features present in original

Drop audit-log table + write helper, Playwright E2E, sign-in rate
limiting, and the `next=` open-redirect guard — none are present in
the original lds217/BSK project (Java/Swing/SQLite, no tests,
LAN-only).

Resolves D1-D7 from the planner's open-questions list:
- D1 magic link: defer
- D2 first-admin: advisory lock (Strategy A)
- D3 audit log: cut
- D4 sign-in route: [locale]/(auth)/sign-in
- D5 Playwright: defer
- D6 E2E target project: moot
- D7 rate-limit keying: cut

Generic-error-on-unenrolled is kept — it defends against the shared
auth.users enumeration vector, which is platform-introduced, not a
new feature.
This commit is contained in:
2026-05-25 17:18:00 +07:00
parent eeda68c34a
commit 2d5e4e92f9
4 changed files with 518 additions and 0 deletions
@@ -0,0 +1,129 @@
# Phase 01 — DB Schema Init (`bsk_init` migration)
## Context Links
- `PLAN.md` §4 Phase 1, §2.1 (schema scoping), §2.2 (RLS gate)
- `CONTRIBUTING.md` §8 (shared-infra namespacing, migration filename convention)
- `docs/supabase-shared-config.md` (project-wide constraints; `bsk` schema)
- `docs/threat-model.md` R1, R3 (project-wide blast radius; `auth.users` shared)
- `docs/runbooks/restore-from-bad-migration.md` (recovery path if this migration goes bad)
- `scripts/preflight-supabase.ts:23` `ALLOWED_PROJECT_REFS` — must be filled before `pnpm db:push`
- Scout citations: `lib/env/client.ts:25` (`SUPABASE_SCHEMA = "bsk"`), `lib/supabase/{server,admin}.ts` (every factory binds `db: { schema: 'bsk' }`)
## Overview
- **Priority:** P1 (blocker for every other phase)
- **Status:** pending
- **Brief:** First SQL migration creates the BSK schema scaffold: role enum, enrollment table `bsk.app_users`, the `bsk.current_role()` helper, and turns RLS on with the first set of policies.
## Key Insights
- Supabase project is **shared**. Every DDL statement is schema-qualified to `bsk.*` (`PLAN.md` §2.1). No bare `CREATE TABLE patients`.
- `auth.users` is **project-wide** (`PLAN.md` §2.2). Existing in `auth.users` grants nothing on its own — `bsk.app_users` is the per-app enrollment gate.
- `bsk.current_role()` is `SECURITY DEFINER STABLE` so RLS policies that call it (a) bypass RLS on the role-lookup itself (no recursion), (b) get statement-cached by the planner (perf — Supabase RLS-perf docs are explicit on this pattern).
- RLS is enabled in the **same** migration that creates the table. Never a follow-up. A table created without RLS but exposed via PostgREST is a project-wide data leak.
- Migration timestamp filename convention from `CONTRIBUTING.md` §8: `YYYYMMDDHHMMSS_bsk_*.sql`. Loose coordination — UTC at author-time, no backdating.
## Requirements
### Functional
- F-01-1: Define `bsk.app_role` enum with values `admin | doctor | nurse | receptionist | cashier | patient`.
- F-01-2: Create `bsk.app_users(user_id uuid PK references auth.users(id), role bsk.app_role NOT NULL, created_at timestamptz default now(), invited_by uuid references auth.users(id) null, full_name text null)`.
- F-01-3: Create function `bsk.current_role()` returning `bsk.app_role`, marked `SECURITY DEFINER STABLE`, reading from `bsk.app_users` keyed by `auth.uid()`. Returns NULL if not enrolled.
- F-01-4: Enable RLS on `bsk.app_users`. Policies:
- `app_users_select_own` — SELECT where `user_id = auth.uid()`.
- `app_users_select_admin` — SELECT where `bsk.current_role() = 'admin'`.
- INSERT / UPDATE / DELETE only via `bsk.invite_user()` (phase 05); no direct policy.
- F-01-5: GRANT `USAGE` on schema `bsk` to roles `anon, authenticated, service_role`. GRANT `SELECT, INSERT, UPDATE, DELETE` on `bsk.app_users` to `authenticated` (gated by RLS). GRANT `EXECUTE` on `bsk.current_role()` to `authenticated`.
- F-01-6: Provide TypeScript types generated from the new schema in `types/supabase-bsk.ts` (regenerated by `supabase gen types typescript --schema bsk` — script convention; the file is committed).
### Non-functional
- N-01-1: Idempotency: use `CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE FUNCTION`, `CREATE TYPE` guarded by `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN NULL; END $$;`. Migration must apply cleanly on a fresh project AND skip on a half-applied state.
- N-01-2: All statements schema-qualified to `bsk.*`. Zero bare references to `patients`, `users`, etc.
- N-01-3: `bsk.current_role()` is `STABLE` (not `IMMUTABLE` — depends on `auth.uid()` which varies per call but is constant within a statement) and `SECURITY DEFINER` with `SET search_path = bsk, pg_catalog` to defuse the standard search-path injection trap.
- N-01-4: Migration file size ≤ 200 LOC SQL; if longer, split into `_init_schema.sql` + `_init_rls.sql` two adjacent files.
## Architecture
### Data flow
1. User exists in `auth.users` (created by Supabase Auth, project-wide).
2. Admin (or `bsk.invite_user()` in phase 05) inserts a row into `bsk.app_users(user_id, role)`.
3. Any Server Action / RSC reading `bsk.*` calls `auth.uid()``bsk.current_role()` short-circuits via RLS policy.
4. Mutations log a row via `bsk.audit_write(action, target_table, target_pk, payload)`.
### Component interactions
```
auth.users ──(1:1)──> bsk.app_users ──read by──> bsk.current_role() ──read by──> every BSK RLS policy
```
### Pseudocode (RLS policy shape, not the full SQL)
```
-- bsk.current_role()
SECURITY DEFINER STABLE; SET search_path = bsk, pg_catalog;
RETURN (SELECT role FROM bsk.app_users WHERE user_id = auth.uid());
-- app_users_select_own
USING (user_id = auth.uid())
-- app_users_select_admin
USING (bsk.current_role() = 'admin')
```
## Related Code Files
### Files to create
- `supabase/migrations/20260525120000_bsk_init.sql` — the migration (or split into `_init_schema.sql` + `_init_rls.sql`).
- `types/supabase-bsk.ts` — generated types (`supabase gen types typescript --schema bsk`).
- `lib/db/roles.ts` (~20 LOC) — typed `appRoles` const array + `AppRole` union exported from generated types; helper `isAppRole(s: string): s is AppRole`.
### Files to modify
- `package.json` — add `db:gen-types` script: `supabase gen types typescript --schema bsk > types/supabase-bsk.ts`.
### Files to delete
- None.
## Implementation Steps
1. Confirm prerequisites: `supabase link --project-ref <ref>` ran successfully; `supabase/.temp/project-ref` exists; ref is added to `scripts/preflight-supabase.ts:23` `ALLOWED_PROJECT_REFS`. Without this, step 6 fails closed.
2. Author `supabase/migrations/20260525120000_bsk_init.sql` per Requirements. SQL only — no `psql` meta-commands, no `\d`.
3. Author `lib/db/roles.ts`: hand-curated `appRoles` tuple of the 6 enum values; export `AppRole` union from it AND assert at compile-time that it matches the generated `Database['bsk']['Enums']['app_role']` shape via a `satisfies` block. Drift catches at typecheck.
4. Add `db:gen-types` script to `package.json`. Document in README "after migration apply, run `pnpm db:gen-types`".
5. Local dry-run: `supabase db diff` against a clean local Postgres (or against the linked project's shadow DB) — confirm the diff is exactly what the migration intends. Abort if extra DROP statements appear (= drift).
6. Apply: `pnpm db:push` (which runs preflight first). Confirm `supabase_migrations.schema_migrations` table has the new row.
7. Regenerate types: `pnpm db:gen-types` → commit `types/supabase-bsk.ts`.
8. Smoke-test from `psql`: `SELECT bsk.current_role();` (returns NULL when not authed); `\dt bsk.*` shows the table; `SELECT polname, polcmd FROM pg_policies WHERE schemaname='bsk';` shows the two policies.
## Todo List
- [ ] `scripts/preflight-supabase.ts:23` `ALLOWED_PROJECT_REFS` populated by user
- [ ] Migration file authored (schema + RLS) — file < 200 LOC
- [ ] `types/supabase-bsk.ts` regenerated and committed
- [ ] `lib/db/roles.ts` authored — `satisfies` guard against generated enum
- [ ] `package.json` has `db:gen-types` script
- [ ] `pnpm db:push` succeeds locally
- [ ] `psql` smoke-test verified: tables, function, policies present
- [ ] `pnpm typecheck` + `pnpm lint` green
## Success Criteria
- Migration applies cleanly on a fresh project AND re-applies idempotently on the linked project.
- `SELECT bsk.current_role()` returns NULL for anon role, NULL for authed-but-not-enrolled, the correct enum value for enrolled.
- `SELECT * FROM bsk.app_users` from a non-admin authed user returns only their own row.
- `pnpm typecheck` passes with the generated types committed.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Migration deployed to wrong project (sibling app dies) | low | catastrophic | `scripts/preflight-supabase.ts` allow-list (already exists; user must populate) |
| `bsk.current_role()` missing `SET search_path` → search-path hijack via SECURITY DEFINER | low | high | Explicit `SET search_path = bsk, pg_catalog` in function body |
| `current_role()` is `VOLATILE` by accident → no statement-caching, slow RLS | med | med | Mark explicitly `STABLE` in function definition + acceptance test |
| `auth.users(id)` FK constraint blocks the row delete cascade | low | low | FK is `ON DELETE CASCADE` on `app_users.user_id` — losing an `auth.users` row removes the enrollment row too. Document this in the migration comment. |
| Migration filename collides with sibling app | low | low | Timestamp prefix (UTC at author-time); `bsk_` in slug; unique-enough |
| Generated types drift from runtime schema | med | low (catches at typecheck if used) | `satisfies` guard in `lib/db/roles.ts`; `db:gen-types` script documented |
| Bad migration that requires PITR | low | catastrophic project-wide | Daily `pg_dump --schema=bsk` per runbook; recovery via runbook avoids project-wide PITR |
## Security Considerations
- `bsk.current_role()` is `SECURITY DEFINER` (runs as function owner). MUST `SET search_path = bsk, pg_catalog` to prevent a caller-controlled search path from resolving `app_users` to an attacker-owned table. Standard Postgres SECURITY DEFINER hardening.
- `bsk.app_users` UPDATE/DELETE have NO policy. Role mutations only via `bsk.invite_user()` (phase 05); the schema-level lack of policy denies-by-default.
- `auth.users` is not modified. We add a row to `bsk.app_users` referencing it; we never write to `auth.users` from this migration.
- Shared-project blast radius: a typo here cannot leak sibling-app data (different schema). It CAN exhaust the shared connection pool if a future query against `bsk.current_role()` runs unbounded — `STABLE` mitigates by enabling per-statement caching.
## Next Steps
- Phase 02 consumes `bsk.current_role()` indirectly (via `app_users` lookup in the layout) — see `phase-02-auth-session-wiring.md`.
- Phase 06 consumes `bsk.current_role()` for sidebar gating — see `phase-06-role-gated-shell.md`.
- Followups for Phase 2: real-data tables (`customers`, `doctors`, `staff_users`) reference `bsk.current_role()` in their RLS policies; the pattern is established here.
@@ -0,0 +1,142 @@
# Phase 03 — Auth Server Actions
## Context Links
- `PLAN.md` §4 Phase 1 (sign-in via `useActionState` + Zod v4 + Server Action)
- `CONTRIBUTING.md` §7 (forms recipe — RHF + `useActionState` + Zod, no `next-safe-action`/`zsa`)
- Scout citations:
- `lib/supabase/server.ts:10-34` — server client used by actions
- `i18n/navigation.ts:4``redirect` from `next-intl/navigation` (locale-aware)
- `lib/auth/get-server-session.ts` (created in phase 02) — used to identify caller for sign-out audit (deferred until phase 05 if not needed here)
- Researcher refs:
- React docs on `useActionState`: action signature `(prevState, formData) => newState`
- Zod v4 shape: `z.object({ email: z.string().email(), password: z.string().min(8) })`
## Overview
- **Priority:** P1 (without these, phase 04 page is non-functional)
- **Status:** pending
- **Brief:** Zod v4 schemas (shared client+server) and the Server Actions `signInAction` + `signOutAction`. Action signature obeys React 19's `useActionState` (prev state + FormData → new state). Re-validates with the same schema on the server. Returns a discriminated-union state for the client to render errors + success states. Post-sign-in, redirects to `/${locale}/dashboard`.
## Key Insights
- Zod schema lives in `lib/auth/schemas.ts` (no `'use server'`). Importable by both client (RHF) and server (action). Same import, same shape, same error messages.
- The Server Action `'use server'` directive lives only in the action file. Schemas stay framework-agnostic.
- `useActionState` does not understand RHF's `onSubmit` flow. The pattern: RHF handles client-side UX (real-time validation, controlled inputs, `formState.errors`), then the form submits via the native `action={dispatch}` prop. Phase 04 implements the wiring.
- Action return shape MUST be JSON-serializable (RSC boundary). No Error objects, no Date, no functions. Use ISO strings + discriminated unions.
- `signInAction` calls `supabase.auth.signInWithPassword({ email, password })`. On success, **before** redirecting, verify the user has a `bsk.app_users` row (i.e., is enrolled). If not, sign them out and return a generic error — defends against shared-`auth.users` enumeration (threat-model R-doc, brainstormer F4).
- Redirect post-sign-in: use `redirect` from `i18n/navigation` to `/${locale}/dashboard` — no `next` query-param plumbing in Phase 1 (original has no URL redirect surface; defer until needed).
- Sign-out is a Server Action that calls `supabase.auth.signOut()` then `redirect('/[locale]/sign-in')`. No FormData consumed; trigger via a `<form action={signOutAction}>` button.
## Requirements
### Functional
- F-03-1: `lib/auth/schemas.ts` exports `SignInSchema` (Zod v4): `{ email: z.string().email(), password: z.string().min(8).max(72) }`. (`72` is bcrypt's effective limit and Supabase's default cap.)
- F-03-2: `lib/auth/schemas.ts` exports `SignInState` type — discriminated union: `{ status: 'idle' } | { status: 'error', fieldErrors: Record<string, string[]>, formError: string | null }`.
- F-03-3: `app/[locale]/(auth)/sign-in/actions.ts` (or `lib/auth/actions/sign-in.ts` — pick one; see Architecture) exports `signInAction(prevState, formData)`.
- F-03-4: `signInAction` flow:
1. Parse `formData` with `SignInSchema.safeParse`. If fail → return `{ status: 'error', fieldErrors, formError: null }`.
2. `supabase.auth.signInWithPassword`. If error → return generic `{ status: 'error', fieldErrors: {}, formError: t('invalidCredentials') }` (do NOT distinguish "no user" vs "wrong password").
3. After success, check enrollment: `await supabase.from('app_users').select('role').eq('user_id', user.id).maybeSingle()`. If no row → `supabase.auth.signOut()` and return the same generic error.
4. `redirect(\`/${locale}/dashboard\`)` via `i18n/navigation` — this throws a `NEXT_REDIRECT` error that the action runtime handles. Action never returns on the success path.
- F-03-5: `signOutAction` is a parameterless Server Action: call `supabase.auth.signOut()` then `redirect('/${locale}/sign-in')`. Wraps in try/catch logging on failure (acceptable to redirect even on sign-out error — the cookie clearing is local).
- F-03-6: Both actions live behind `'use server'` directive at module top.
### Non-functional
- N-03-1: Actions file ≤ 200 LOC. If `signInAction` grows past ~80 LOC, extract the validation pipeline into `lib/auth/actions/_validate.ts` (still server-only).
- N-03-2: Zero direct `process.env.*` reads in actions; all env goes through `lib/env/server.ts`.
- N-03-3: No `'use cache'` anywhere in this file (Server Actions are inherently uncacheable).
- N-03-4: Error messages reference i18n keys, not literal strings. Server Actions call `getTranslations()` from `next-intl/server` for messages they emit.
## Architecture
### File placement decision
Two choices:
- (a) `app/[locale]/(auth)/sign-in/actions.ts` — co-located with the page.
- (b) `lib/auth/actions/sign-in.ts` — co-located with schemas, importable from anywhere.
Pick **(a)**. Server Actions are coupled to a route's UX contract; co-locating prevents long-distance "what does this action return?" hunts. If a second route ever needs the same action, refactor to (b) at that point. YAGNI.
### Data flow
```
FormData (from client RHF/form)
signInAction(prevState, formData)
├─ SignInSchema.safeParse(Object.fromEntries(formData))
│ fail → return { status:'error', fieldErrors }
├─ supabase.auth.signInWithPassword
│ auth fail → return generic { status:'error', formError }
├─ enrollment check (bsk.app_users)
│ missing → signOut + return generic error
└─ redirect(/${locale}/dashboard) ← throws NEXT_REDIRECT, never returns
```
### Component interactions
```
sign-in page (phase 04) ──action={signInAction}──> signInAction
▲ │
│ ├─ Supabase Auth
│ ├─ Upstash rate-limit
│ └─ bsk.app_users (RLS via current_role NOT applied; uses authed-as-self read)
└── useActionState renders state.status + fieldErrors
```
## Related Code Files
### Files to create
- `lib/auth/schemas.ts` (~50 LOC) — `SignInSchema`, `SignInState` type, `parseSignIn(formData)` helper.
- `app/[locale]/(auth)/sign-in/actions.ts` (~100 LOC) — `signInAction`, `signOutAction`. `'use server'` at top.
### Files to modify
- `messages/vi.json` + `messages/en.json` — add `auth.signIn.*` keys: `emailLabel`, `passwordLabel`, `submit`, `submitting`, `invalidCredentials`, `unenrolledError`, `genericError`.
### Files to delete
- None.
## Implementation Steps
1. Author `lib/auth/schemas.ts`. Zod v4: `z.string().email()` for email, `z.string().min(8).max(72)` for password. Export `SignInState` discriminated union.
2. Author `app/[locale]/(auth)/sign-in/actions.ts`:
- `'use server'` directive.
- Import `createSupabaseServerClient` from `lib/supabase/server`.
- Import `redirect` from `@/i18n/navigation` (locale-aware).
- `signInAction(prevState: SignInState, formData: FormData): Promise<SignInState>`.
- Implement F-03-4 step-by-step.
- Append `signOutAction` per F-03-5.
3. Add i18n strings to `messages/vi.json` + `messages/en.json` under `auth.signIn.*`.
4. Local smoke: rely on phase 04 + manual form test for end-to-end validation.
5. `pnpm typecheck` + `pnpm lint`.
## Todo List
- [ ] `lib/auth/schemas.ts` authored with Zod v4
- [ ] `app/[locale]/(auth)/sign-in/actions.ts` authored with `signInAction` + `signOutAction`
- [ ] i18n keys added under `auth.signIn.*` in both `vi.json` + `en.json`
- [ ] `pnpm typecheck` + `pnpm lint` green
- [ ] Code-reviewer pass on the enrollment check (no early return that skips sign-out on enrollment miss)
## Success Criteria
- `signInAction` returns the documented state shapes for every failure branch.
- Successful sign-in with enrollment → redirect; cookies set by Supabase auth flow.
- Successful sign-in WITHOUT enrollment → sign-out called, generic error returned (NOT a distinct "not enrolled" message — defends against enumeration).
- `signOutAction` clears the session and redirects to `/${locale}/sign-in`.
- Action file is `'use server'`. Schema file is NOT.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Enumeration via "unenrolled" message | med | med | Generic error string for both wrong-password AND unenrolled paths |
| Race: user signs in, enrollment row deleted between sign-in and enrollment check | low | low | Subsequent requests will fail proxy redirect; user re-signs-in; acceptable |
| Action serialization breaks (Date in return) | low | high | Discriminated union has only strings/numbers/booleans; `SignInState` enforces at compile time |
| `redirect()` throws inside try/catch and gets swallowed | high (if naive) | high | Call `redirect()` OUTSIDE any try/catch. React 19/Next.js 16 use a special error symbol; catching it as `unknown` breaks the redirect. |
| Action runs in `'use cache'` (impossible — Server Actions can't be cached) | n/a | n/a | Doc-only note |
| i18n keys missing in one language → runtime warning | low | low | next-intl warns at runtime; CI lint can grep for parity (deferred) |
## Security Considerations
- **Enumeration:** sign-in error wording is generic. Code-path branches that differ in observable timing (e.g., "unenrolled" path triggers a `signOut` round-trip; "wrong password" doesn't) are a side-channel. Mitigation: accept the timing leak for the educational scope; document in code comment.
- **Sign-out:** clears cookies via Supabase SDK. The action MUST NOT reveal user info on completion; redirect-only.
- **Cookies:** the proxy (phase 02) handles refresh; this phase relies on Supabase SDK to set the `sb-*` cookies on sign-in success via `createSupabaseServerClient`'s `setAll` callback.
- **No `service_role` use here.** All actions use the user-context server client (publishable key + cookie). Admin operations live in phase 05.
- **No PII logging.** No `console.log(formData)` or similar — the action either redirects or returns the state.
## Next Steps
- Phase 04 consumes `signInAction` + `SignInState` + `SignInSchema` for the form.
- Phase 05 consumes `signOutAction` in the layout/header for the sign-out button; admin-invite action is a separate Server Action authored in phase 05.
@@ -0,0 +1,185 @@
# Phase 05 — Admin Enrollment
## Context Links
- `PLAN.md` §4 Phase 1 (first user becomes admin; subsequent users invited by admin)
- `docs/threat-model.md` R-doc on shared `auth.users` — invite-only is the policy
- Scout citations:
- phase 01 outputs: `bsk.app_users`, `bsk.app_role`, `bsk.current_role()`
- `lib/supabase/admin.ts:14-24` — privileged client (used by admin invite to write into `auth.users`)
- phase 03 outputs: `signInAction` (the path the invited user takes after receiving credentials)
- Researcher refs:
- Supabase Admin API: `supabase.auth.admin.inviteUserByEmail(email, { data })` and `supabase.auth.admin.createUser({ email, password, email_confirm })` — service-role required
- Postgres advisory locks for serialization: `pg_try_advisory_xact_lock(int)` returns boolean, released at txn end
## Overview
- **Priority:** P1
- **Status:** pending
- **Brief:** Two pieces. (a) Race-safe "first user → admin" claim on initial sign-in OR via a one-shot bootstrap script. (b) Admin-only `inviteUserAction` Server Action that creates an `auth.users` row + the matching `bsk.app_users` row with a chosen role.
## Key Insights
- **First-user race:** two simultaneous signups both querying `SELECT count(*) FROM bsk.app_users` see 0, both claim admin. Two race-safe strategies — pick ONE (Open Question #2, flag for user). Documented below:
- Strategy A — **Advisory lock + SQL function.** `bsk.claim_first_admin(p_user_id uuid)` SECURITY DEFINER takes `pg_advisory_xact_lock(<bsk-namespace-int>)`, then atomically `INSERT INTO bsk.app_users (user_id, role) SELECT $1, 'admin' WHERE NOT EXISTS (SELECT 1 FROM bsk.app_users)`. Returns boolean (true if claimed). Concurrent callers serialize on the lock; only the first inserts.
- Strategy B — **Partial unique index.** `CREATE UNIQUE INDEX bsk_app_users_single_admin ON bsk.app_users ((true)) WHERE role = 'admin'`. Then a naive `INSERT INTO bsk.app_users (user_id, role) VALUES ($1, 'admin')` from concurrent callers — exactly one succeeds, the rest get a unique-violation error. Strict but constrains forever to "only one admin row" — which is wrong for the steady state (admins can have multiple users in app_role='admin').
- **Recommendation:** Strategy A. Strategy B confuses "first admin claim" with "only one admin ever". A is one extra SQL function and a clean intent.
- **Bootstrap UX:** during initial setup, no admin exists, so the "invite" flow has no caller. Options:
- (i) Manual: operator inserts the first `app_users` row via `psql` referring to their own `auth.users.id` after first sign-up (one-time, documented in `docs/runbooks/bootstrap-admin.md`).
- (ii) Automatic: the first sign-in (when `bsk.app_users` is empty) auto-claims admin via Strategy A inside `signInAction`'s enrollment-check branch.
- **Recommendation:** (ii) automatic. (i) requires `psql` and the `bsk` schema to be familiar, raises the floor for first-time setup. (ii) makes the docs simpler: "the first person to sign in becomes admin".
- **Invite flow (after first admin exists):**
- Admin enters email + role on a form.
- Server Action calls `supabase.auth.admin.inviteUserByEmail(email)` via the admin (service-role) client.
- On success, insert `bsk.app_users(user_id, role, invited_by)` referencing the newly created `auth.users.id`.
- Invited user receives Supabase's default invite email; clicks link; sets password; lands on `/sign-in`; signs in; their enrollment row already exists.
- **Email delivery caveat:** Supabase free-tier SMTP is rate-limited (~3/h). Custom SMTP is project-wide and not configured. For educational scope, invite emails may not deliver reliably. Admin can fall back to copying the invite link from the dashboard. Document this in the UI: "Invite sent — if the user doesn't receive the email within 5 minutes, copy the link from Supabase dashboard." This is a known free-tier limitation noted in `docs/supabase-shared-config.md`.
- **`signInAction` change:** the enrollment-check branch (phase 03 F-03-4 step 3) is amended in this phase: if no enrollment AND `bsk.app_users` is empty → call `bsk.claim_first_admin(auth.uid())`. If claim returns true → proceed (enrolled as admin). If returns false → race lost; fall through to the standard "unenrolled" path (sign out + generic error). This is the only place `signInAction` mutates state besides Supabase Auth's own session writes.
## Requirements
### Functional
- F-05-1: SQL function `bsk.claim_first_admin(p_user_id uuid) RETURNS boolean`, SECURITY DEFINER STABLE — no, **VOLATILE** (it writes). `SET search_path = bsk, pg_catalog`. Body: acquires `pg_advisory_xact_lock(<int>)`; inserts admin row if `bsk.app_users` is empty; returns true on insert, false otherwise.
- F-05-2: SQL function `bsk.invite_user(p_email text, p_role bsk.app_role) RETURNS uuid` — NO, this is harder to do purely in SQL because it needs to call `auth.admin.createUser` which is a Supabase Auth API, not a Postgres function. Implement as a **TypeScript-only Server Action** calling the admin SDK. Skip the SQL function.
- F-05-3: `app/[locale]/(app)/admin/invite/actions.ts` exports `inviteUserAction(prevState, formData)` Server Action:
- Caller-role check: `getServerSession()``role === 'admin'` else return `{ status: 'error', formError: 'forbidden' }` (defense in depth; also gated by route layout in phase 06).
- Validate `formData` with `InviteUserSchema` (`{ email: z.string().email(), role: z.enum(appRoles) }`).
- `supabaseAdmin.auth.admin.inviteUserByEmail(email)` → on success extract `data.user.id`.
- `supabase.from('app_users').insert({ user_id: newId, role, invited_by: caller.id })`. Uses admin client (RLS would block authed client because `app_users` has no INSERT policy — by design from phase 01).
- Return `{ status: 'success', invitedEmail: email }`.
- F-05-4: Migration `20260525130000_bsk_admin.sql` (or append to `bsk_init` if not yet pushed) — creates `bsk.claim_first_admin(uuid)`.
- F-05-5: `signInAction` (phase 03) extended:
- After `signInWithPassword` success, enrollment check.
- If no row AND `count(*) = 0` in `bsk.app_users` → call `supabase.rpc('claim_first_admin', { p_user_id: user.id })`.
- If RPC returns `true` → re-fetch enrollment row → proceed as admin.
- If RPC returns `false` → fall through to standard sign-out + generic error.
- F-05-6: `app/[locale]/(app)/admin/invite/page.tsx` — admin-only page (phase 06 layout enforces). Renders `<InviteUserForm />` (client) using `useActionState(inviteUserAction)`.
- F-05-7: `app/[locale]/(app)/admin/invite/invite-user-form.tsx``'use client'`. Email + role select + submit. Success state shows "Invited" + email. Error state shows formError.
### Non-functional
- N-05-1: `inviteUserAction` ≤ 100 LOC.
- N-05-2: Admin client (`lib/supabase/admin.ts`) used ONLY in this Server Action + future similar admin-tasks. Lint already enforces no raw `@supabase/supabase-js` outside `lib/supabase/*`.
- N-05-3: `claim_first_admin` advisory-lock key is a stable BIGINT — pick a value (e.g., hash of `'bsk:claim_first_admin'``'bsk'` schema namespace). Document in SQL comment.
- N-05-4: Audit-write helper from phase 01 is the only write path to `bsk.audit_log`.
## Architecture
### First-admin claim flow
```
signInAction (phase 03 extended)
│ creds OK
SELECT role FROM bsk.app_users WHERE user_id = uid
├─ row exists → proceed (cached role)
└─ no row →
SELECT count(*) = 0 FROM bsk.app_users
├─ false → sign out, generic error
└─ true → CALL bsk.claim_first_admin(uid)
│ inside SQL function:
│ pg_advisory_xact_lock(N)
│ IF NOT EXISTS (SELECT 1 FROM bsk.app_users)
│ INSERT INTO bsk.app_users (user_id, role) VALUES (uid, 'admin')
│ RETURN true
│ ELSE RETURN false
├─ true → re-fetch role → proceed as admin
└─ false → sign out, generic error (race lost; retried sign-in shows "unenrolled")
```
### Invite flow
```
admin user opens /[locale]/admin/invite
│ phase 06 layout: getServerSession() must return role='admin' else 403
InviteUserForm submit → inviteUserAction(prev, fd)
├─ caller-role recheck (defense in depth)
├─ InviteUserSchema.safeParse
├─ supabaseAdmin.auth.admin.inviteUserByEmail(email)
├─ insert bsk.app_users (admin client; bypasses RLS by design)
└─ return { status: 'success', invitedEmail }
```
## Related Code Files
### Files to create
- `supabase/migrations/20260525130000_bsk_admin.sql` (~40 LOC) — `bsk.claim_first_admin(uuid)` function + comment.
- `app/[locale]/(app)/admin/invite/actions.ts` (~90 LOC) — `inviteUserAction`. `'use server'`.
- `app/[locale]/(app)/admin/invite/page.tsx` (~30 LOC) — server component renders form.
- `app/[locale]/(app)/admin/invite/invite-user-form.tsx` (~110 LOC) — `'use client'` form.
- `lib/auth/invite-schema.ts` (~25 LOC) — `InviteUserSchema`, `InviteUserState` discriminated union.
- `docs/runbooks/first-admin-setup.md` (~25 LOC) — one-page runbook documenting "first sign-in claims admin via advisory lock; if it goes wrong, see this manual `psql` fallback".
### Files to modify
- `app/[locale]/(auth)/sign-in/actions.ts` — extend the enrollment-check branch per F-05-5.
- `messages/{vi,en}.json` — add `admin.invite.*` keys.
- `types/supabase-bsk.ts` — regenerate after migration apply.
- (Phase 06 will create the `(app)` layout — this phase assumes its existence in the route path; if phase 06 lags, the route just 404s.)
### Files to delete
- None.
## Implementation Steps
1. Pick first-admin strategy (User confirmed: Strategy A advisory lock).
2. Author migration `20260525130000_bsk_admin.sql`:
- `CREATE OR REPLACE FUNCTION bsk.claim_first_admin(p_user_id uuid) RETURNS boolean ...` with `pg_advisory_xact_lock`, EXISTS-guarded INSERT, returns boolean.
- Pick advisory-lock key: a constant 64-bit integer derived from `hashtext('bsk:claim_first_admin')::bigint`. Document in comment.
- `GRANT EXECUTE ON FUNCTION bsk.claim_first_admin(uuid) TO authenticated`.
3. `pnpm db:push` → confirm function exists via `\df bsk.*`.
4. `pnpm db:gen-types` → updates `types/supabase-bsk.ts` with the new RPC.
5. Author `lib/auth/invite-schema.ts` with Zod v4 + `InviteUserState` shape.
6. Author `app/[locale]/(app)/admin/invite/actions.ts`:
- `'use server'`.
- `inviteUserAction(prevState, formData)`.
- Imports: `createSupabaseServerClient` (for caller-role check), `createSupabaseAdminClient` (for `auth.admin.inviteUserByEmail`).
- Implement F-05-3 flow.
7. Author the page + form per F-05-6 + F-05-7. Same RHF + `useActionState` pattern as phase 04 sign-in.
8. Extend `app/[locale]/(auth)/sign-in/actions.ts` per F-05-5:
- After `signInWithPassword` success, if enrollment row missing:
- Read `count(*)` from `bsk.app_users` via the server client.
- If 0, call `supabase.rpc('claim_first_admin', { p_user_id: user.id })`.
- On `true`, re-fetch enrollment; proceed as admin.
- On `false` (or count > 0), continue to sign-out + generic error path.
9. Author `docs/runbooks/first-admin-setup.md` covering the happy path + the manual `psql` fallback (insert into `bsk.app_users` directly).
10. i18n: add `admin.invite.title`, `emailLabel`, `roleLabel`, `submit`, `success`, `errorForbidden`, `errorEmailTaken`, `errorGeneric` to `messages/{vi,en}.json`.
11. `pnpm typecheck` + `pnpm lint` + `pnpm build`.
## Todo List
- [ ] Migration `bsk.claim_first_admin` authored + applied
- [ ] `lib/auth/invite-schema.ts` authored
- [ ] `app/[locale]/(app)/admin/invite/actions.ts` authored
- [ ] `app/[locale]/(app)/admin/invite/page.tsx` + form authored
- [ ] `signInAction` extended with first-admin-claim branch
- [ ] `docs/runbooks/first-admin-setup.md` authored
- [ ] i18n keys added
- [ ] `pnpm typecheck` + `pnpm lint` + `pnpm build` green
- [ ] Manual smoke: from a fresh DB, first sign-in claims admin; invite a second user; the invite arrives (or copy link manually)
## Success Criteria
- Two concurrent first-sign-ins → exactly one becomes admin; the other gets "unenrolled" error. Verified by stressing locally with two browser sessions or by running `bsk.claim_first_admin` in parallel via two `psql` sessions.
- Admin can invite a user with a chosen role; the invited user receives an email (or operator copies invite link).
- Invited user signs in after setting password → lands on dashboard with their role-gated sidebar (phase 06 verifies sidebar).
- Non-admin attempting to load `/[locale]/admin/invite` → 403 / redirect to dashboard (phase 06 layout enforces; this phase's action does a defense-in-depth check).
- `pnpm build` green.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Both concurrent sign-ins become admin (race) | low (if locked) | high | Advisory lock + EXISTS-guarded INSERT inside SECURITY DEFINER function |
| Advisory-lock key collides with sibling app's lock | very low | low | `bsk:` namespaced text hashed; collision probability ~0 across personal projects |
| Supabase invite email doesn't deliver (free-tier SMTP rate limit) | high | med | Document fallback: copy invite link from Supabase dashboard; UX message in invite form |
| Admin invites a user whose email already exists in `auth.users` (sibling app user) | med | low | `inviteUserByEmail` is idempotent for existing users (sends another invite/reset). Just create the `bsk.app_users` row to enroll them. Document in code comment. |
| `inviteUserAction` runs without caller-role recheck → user-with-tampered-cookie can invite | low | high | F-05-3 caller-role recheck; layout-level enforcement; RLS at the DB (insert to `app_users` only via admin client) |
| Manual fallback runbook drifts from actual SQL | low | med | Runbook references the migration filename; PR review checks both updated together |
| `claim_first_admin` not called when `bsk.app_users` becomes empty after admin deletion | low | low | Acceptable edge case; document — "deleting all app_users effectively resets the bootstrap" |
| Service-role secret leaked from invite action | low | catastrophic | Lives only in `lib/supabase/admin.ts`; not passed to client; ESLint blocks raw `@supabase/supabase-js` outside `lib/supabase/*` |
## Security Considerations
- `inviteUserAction` uses the **admin client** (service-role key) — bypasses RLS for the `auth.admin.*` calls AND for the `bsk.app_users` insert. This is the intended design: `bsk.app_users` has NO insert policy, so only admin-client writes succeed.
- Caller-role check is server-side via `getServerSession()`. Client-side gating in phase 06 (sidebar) is UX-only; security is at the server boundary.
- The advisory lock prevents check-then-insert TOCTOU. The lock is held until transaction commit, then released — no leaked lock concerns.
- `claim_first_admin` is restricted to `authenticated` role (GRANT EXECUTE TO authenticated). An anon-key call without sign-in cannot trigger it.
## Next Steps
- Phase 06 enforces `role === 'admin'` at the `(app)/admin` layout level.
- Phase 2+ extends the invite UX: bulk invite, role change, deactivation. Out of scope for this phase.
@@ -0,0 +1,62 @@
---
title: "BSK Phase 1 — Identity & Access"
description: "DB schema, Supabase Auth wiring, sign-in form, admin enrollment, role-gated shell, first E2E."
status: pending
priority: P1
effort: ~14h
branch: main
tags: [phase-1, auth, rls, supabase, next-intl, playwright]
created: 2026-05-25
---
# Phase 1 — Identity & Access
## Status
Planning. No code yet. Depends on user provisioning (see Key Dependencies).
## Goals (user-visible "done")
1. An invited user lands on `/[locale]/sign-in`, submits email+password, and is redirected to `/[locale]/dashboard`.
2. The dashboard shell shows a sidebar whose items are filtered by the user's `bsk.app_role` (admin sees everything; nurse sees nurse-relevant items; etc.).
3. The first signed-in user becomes admin atomically (no race window for two concurrent first-signups to both become admin).
4. An admin can invite a new user with a chosen role via a Server Action (UI scaffolded; sending email may be deferred to Phase 1.5).
5. All BSK tables created in this phase have RLS enabled and policies.
## Sub-phases (status checklist)
- [ ] 01 — DB schema init (`bsk_init` migration: enum, `app_users`, `bsk.current_role()`, RLS + first policies)
- [ ] 02 — Auth session wiring (`proxy.ts` composes next-intl + Supabase session refresh; `[locale]/layout.tsx` reads session; cached-helpers receive user as arg)
- [ ] 03 — Auth Server Actions (Zod v4 schemas; `signInAction`, `signOutAction`; redirect/error contracts)
- [ ] 04 — Sign-in page (RHF + `useActionState` + shadcn primitives; i18n strings; route group `(auth)`)
- [ ] 05 — Admin enrollment (atomic first-user-becomes-admin; admin-only `inviteUserAction`)
- [ ] 06 — Role-gated shell (sidebar, route protection helper, `(app)` route group, `/dashboard` placeholder)
## Key dependencies (BLOCKERS — user-action prerequisites)
Before phase 01 SQL can land, the user MUST:
1. Provision the Supabase project (or confirm the shared one) and record its `project ref` in `docs/supabase-shared-config.md` (replace `_(fill in)_` placeholders).
2. Add the project ref to `ALLOWED_PROJECT_REFS` at `scripts/preflight-supabase.ts:23` (currently empty; `pnpm db:push` will refuse until populated).
3. Populate `.env.local` (and Vercel env vars for `dev`/`preview`/`prod`) from `.env.example` — Supabase URL + `sb_publishable_*` + `sb_secret_*`, Upstash URL+token.
4. Decide magic-link inclusion (see Open Questions).
5. Run `supabase link --project-ref <ref>` in repo root so `supabase/.temp/project-ref` exists for the preflight script.
Scaffold pieces consumed (no rework needed):
- `lib/supabase/{server,client,admin,session}.ts` factories (Phase 0).
- `lib/env/{client,server}.ts` env validation.
- `lib/upstash.ts` for the sign-in rate limiter.
- `i18n/{routing,navigation,request}.ts` for locale + `redirect` helper.
- `proxy.ts` (currently next-intl only; phase 02 extends).
- `app/[locale]/layout.tsx` (currently i18n-only; phase 02 extends).
## Phase-1-level risks
- **Cookie collision in `proxy.ts`.** next-intl and Supabase both want to own the `NextResponse`. Two competing responses → flicker / lost cookies / wrong locale redirect. Mitigation: phase 02 imposes a single composed handler; spec covered in `lib/supabase/session.ts:11-14` JSDoc.
- **`'use cache'` regression.** A cached helper that calls `createSupabaseServerClient()` builds-but-runtime-fails. Mitigation: every cached helper in this phase receives `user` / `role` as arguments; review check.
- **First-user-admin race.** Two simultaneous first signups could both flag as admin without serialization. Mitigation: phase 05 picks one of two race-safe strategies (advisory lock vs partial-unique index) — flag for user review before implementation.
- **Shared `auth.users` enumeration.** Threat-model R-doc'd. Sign-in failures must return generic "invalid credentials" regardless of whether the email exists in `auth.users` (already Supabase default, but verify and document).
- **Magic link deferral risk.** If the user later enables magic link, the sign-in page UX shifts. Mitigation: keep Zod schema strict to `{ email, password }`; magic-link is a separate action+route, not a flag on this one.
## Definition of done
- Phase 0106 all checked.
- `pnpm typecheck`, `pnpm lint`, `pnpm build` all green.
- `pnpm db:push` applied successfully against the linked project.
- `docs/supabase-shared-config.md` `_(fill in)_` placeholders resolved.
## Open questions
_(All resolved: D1 defer, D2 advisory lock, D3 audit cut, D4 [locale]/(auth)/sign-in, D5 defer, D6 moot, D7 cut.)_