- imaging: downscale to 1280px before JPEG quality-stepping so real camera
photos fit the 200KB cap (was quality-only → rejected typical photos)
- billing: save_prescription/save_checkup_services refuse to modify a PAID
invoice, so recorded payment can't diverge from the invoice total
- staff: set_staff_role/remove_staff RPCs hold an advisory lock while enforcing
the last-admin invariant — race-safe vs the prior check-then-act
- imaging delete: remove the object at the row's stored path (DB lookup), not a
client-supplied path
- invoice PDF: filter deleted=false like every other checkup view
- sign-in: skip rate limit when no client IP is resolvable instead of bucketing
all requests under a shared "unknown" key
- reminders: bound to a [today-30d, today+7d] window so stale overdue rows
don't accumulate and bury upcoming ones
- react-pdf invoice route (Node runtime) — clinic header, medicines + services
tables, server-summed VND total, payment status
- bundled Be Vietnam Pro (Regular+Bold) so diacritics render; fonts registered
once per process
- "Print invoice" link on the prescription page (locale-prefix aware)
- reports i18n namespace (vi/en); xlsx + recharts deps added for next slices
- private bsk-checkup-media bucket + checkup_images table; RLS: enrolled read,
clinical write; storage.objects policies scoped to the bucket
- webcam (getUserMedia) + file capture, client canvas JPEG compression to
<=200KB; upload via browser client, metadata recorded server-side with a
path-ownership guard; audit-logged
- gallery with 1h signed URLs + soft-delete; code128 barcode of the checkup id
(no PII); linked from the checkup page; vi/en; bwip-js added
- 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
Preserves the multi-agent review trail produced across the session:
- code-reviewer-phase0-scaffold + code-reviewer-phase0-fixes
- brainstormer-architecture-redteam + brainstormer-fixes-closure-check
- researcher-plan-vs-impl-alignment
- planner-phase-1
- code-reviewer-phase-1-full
Useful as context for future phases (decisions, accepted residual
risks, original rationale).
- 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.
- app/[locale]/(auth)/layout.tsx: centered card layout for unauth routes
- app/[locale]/(auth)/sign-in/page.tsx: Server Component, redirects
authed users to dashboard, renders SignInForm
- app/[locale]/(auth)/sign-in/sign-in-form.tsx: Client form with RHF
(zodResolver, mode onBlur) + useActionState(signInAction). Form root
is <form action={dispatchAction}>. Server fieldErrors sync into RHF
via useEffect for consistent inline UX. aria-invalid/aria-describedby
set for accessibility.
- app/[locale]/layout.tsx: mounts <Toaster richColors position="top-right" />
inside NextIntlClientProvider
- components/ui/{button,input,label,sonner}.tsx: shadcn primitives
installed via shadcn CLI v4 (Tailwind v4 CSS-first)
- messages/{vi,en}.json: title + subtitle keys under auth.signIn
Scope honored: no next= plumbing, no rate-limit i18n keys (both cut from
phase 03), no unenrolledError key (action uses invalidCredentials for
both wrong-password and unenrolled paths per enumeration defense).
- 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.
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.
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
- env: cross-check VERCEL_ENV against NEXT_PUBLIC_APP_ENV at boot so prod
credentials cannot silently write into a dev keyspace
- upstash: tighten cache-key regex (kebab + colon only); split SCAN
patterns into their own validator so glob '*' is allowed only there
- eslint: forbid raw @upstash/redis, @upstash/ratelimit, @supabase/supabase-js
imports outside the named factory files
- supabase/admin: harmonize 'use cache' guidance with CONTRIBUTING.md
(safe inside cache; partition key on identity for user-specific reads)
- app/layout: clarify global-error.tsx vs error.tsx shell requirements
given the passthrough root layout
- readme: Next.js 15 -> 16 (matches scaffolded version)
Repin §1 to latest-stable versions as of 2026-05 (Next 16, React 19, TS 6,
Tailwind v4 + shadcn CLI v4, Zod v4 + useActionState, TanStack Table v8,
@react-pdf v4, next-intl v4, Vitest + Playwright) and add §3.1 capturing
the Next 16 cross-cutting rules (async params, 'use cache' constraints,
Supabase/Realtime interaction, new sb_publishable_*/sb_secret_* keys).
Expand §2 with the namespacing surfaces that matter when one Supabase
project + one Upstash DB are shared across multiple Vercel apps:
project-wide API keys (RLS is the only isolation), shared Auth/SMTP/JWT
settings, Realtime channel prefixing, Storage bucket prefixing, QStash
signature-based per-app isolation, and a do/don't operational cheat-sheet.
Phase 0/1/3/5 bullets and §7 risks updated to reflect the new versions.
Repositions the repo as an educational Next.js + Supabase + Upstash
rewrite of lds217/BSK-All-in-One-Clinic-Management-System.
- RESEARCH_REPORT.md: upstream analysis (Java/Swing/Netty/SQLite,
171 files, 25+ features, Vietnamese locale, no explicit license).
- PLAN.md: target stack, shared-infra design (schema-per-app on
Supabase, key-prefixed Upstash), 9-phase roadmap, divergences from
the original, risks, and open decision on BSK isolation.
- NOTICE: clean-room attribution to @lds217 and the upstream repo.
- README.md: project intent, stack summary, educational-only
disclaimers, and license stance.
No application code yet.