Skip to content

Tier 4 Onset T-3 Gate B Report — Clinic Portal Last1 ID Auth Refactor

Status: PR ready — STOP for Ryan code review (no deploy)
Date: 2026-08-29
Prior: TIER_4_ONSET_GATE_A_REPORT.md
Repo: Last-1-Enterprises/clinic.theonset.app
Branch: t3-last1id-auth


Executive summary

Gate B replaces clinic portal GoTrue (password, Google, MFA) with Last1 ID OIDC via @last1id/sdk + encrypted httpOnly session cookie. Build passes. No deploy. iOS untouched.

RLS verdict (critical): Clinic portal runtime is RealOutcomes-simple after this refactor — identity from Last1.id JWT session; all practitioner data access uses service-role Supabase (bypasses auth.uid() RLS). Complexity moves to Gate D user-ID preservation (32 FKs → auth.users.id), not RLS rewrites.


RLS assessment (CRITICAL)

Database facts (hosted + R940 Gate B restore)

Metric Count
RLS policies (public schema) 76
FK constraints → auth.users.id 32
Migration SQL references to auth.uid() ~85 across 11 migration files

RLS policies do exist and do use auth.uid() — heavily, for patient-facing tables (connections, protocols, checkins, Fullscript isolation, etc.).

Does clinic portal use auth.uid() RLS at runtime?

Before Gate B (mixed):

Path Auth DB client RLS
Route handlers (/api/clinic/*) GoTrue session Service role Bypassed
Portal SSR pages (/(portal)/*) GoTrue session Anon + JWT auth.uid() applied
Middleware GoTrue cookies n/a n/a

After Gate B (unified service-role for practitioners):

Path Auth DB client RLS
Login / callback Last1.id PKCE n/a n/a
Middleware clinic_last1_session cookie n/a n/a
Route handlers Last1 session → requireClinicPractitioner() Service role Bypassed
Portal SSR pages Last1 session → getClinicPortalContext() Service role Bypassed
/api/protocols/v2/[patientId]/latest Unchanged — GoTrue Bearer (iOS patients) Anon + JWT auth.uid() (patient path only)

Answer for Ryan: Retiring local GoTrue for clinic portal does not require rewriting the 76 RLS policies. The portal never depended on RLS for authorization logic at the API layer; SSR pages are migrated to the same service-role pattern as APIs. auth.uid() policies remain for iOS app + edge functions + the one dual-purpose protocol API.

Who still depends on auth.uid() RLS?

  • iOS app (hosted GoTrue until App Store sovereign) — unchanged in T-3
  • Supabase Edge Functions (patient/practitioner RPC paths)
  • /api/protocols/v2/[patientId]/latest patient self-access branch (GoTrue JWT)

Gate D: user-ID preservation (harder than RLS)

RealOutcomes lesson: id_token.sub must equal existing auth.users.id or FK rows break.

Operator Email Gate D action
Ryan curryrg@gmail.com Remap / link Last1 identity → existing auth.users.id
Test hello@unicornryda.com Same

Until Gate D: Last1 sign-in succeeds but practitioners.user_id lookup fails for real accounts.

T-3 complexity: Runtime RLS = RealOutcomes-simple. Gate D FK remap = meaningful work (same class as RealOutcomes Gate D).


Confirmations

Item Result
Clinic port 3000 (Dockerfile EXPOSE 3000, .env.example, render script PORT=3000). RealOutcomes uses 5000.
iOS untouched Yes. Zero changes in last.vet-ios / Onset iOS repo. iOS stays on hosted GoTrue (emxrmjysvxeboavhatry). Shared onset OAuth client preserved for iOS Last1 linking.
trust_events:write Zero hits in clinic codebase. Correctly omitted at Gate A.
Secret rotation Not rotated (Gate A). Shared with iOS.
Deploy None. PR only.

Auth architecture (Gate B)

Browser → Last1.id PKCE → /auth/callback (client)
        → POST /api/auth/last1/callback (token exchange + JWKS)
        → completeClinicAuth (service role)
        → Set clinic_last1_session cookie (AES-256-GCM)
Middleware → cookie presence check
Portal/API → getClinicPortalContext / requireClinicPractitioner → service role

Removed: password login, Google OAuth, Supabase MFA routes, GoTrue app_metadata tagging.

Deferred (Gate D): new owner signup (/api/clinic/register still GoTrue), team invite email confirm, /api/clinic/onboard owner-create path (403).


Files changed

Added

File Purpose
src/lib/last1-config.ts Env helpers
src/lib/last1Auth.ts Browser PKCE (@last1id/sdk)
src/lib/last1IdOAuth.ts Server token exchange + JWKS
src/lib/clinic-session.ts Encrypted session cookie
src/lib/clinic-session-constants.ts Edge-safe cookie name
src/app/api/auth/last1/callback/route.ts Token exchange handler
src/app/auth/callback/page.tsx OAuth redirect landing

Modified (high signal)

File Change
src/middleware.ts Last1 session cookie gate
src/lib/clinic-api.ts requireClinicPractitioner, getClinicPortalContext
src/app/login/page.tsx Last1 ID sign-in only
src/app/(portal)/layout.tsx + 12 SSR pages Last1 session + service role
src/app/api/clinic/*, stripe, billing Last1 session
package.json @last1id/sdk, jose
.env.example, Dockerfile Last1 OAuth + session secret keys

Removed

File
src/app/api/auth/login/route.ts
src/app/api/auth/mfa/*
src/app/auth/callback/route.ts (GoTrue exchange)
src/components/google-sign-in-button.tsx

Infra (lastvet-gates)

File Purpose
scripts/onset/render-onset-env-app.sh BWS → /opt/onset/.env (port 3000, NEXT_PUBLIC_* Last1 keys)

BWS keys still needed before Gate C deploy

Key Notes
ONSET_APP_PROD_CLINIC_SESSION_SECRET 32+ byte random; encrypts clinic_last1_session
Gate A keys Already in onset BWS (LAST1_OAUTH_*, LAST1_ID_ISSUER)

Build / test

Check Result
npm run build PASS
Deploy Not run (Gate B scope)
Live OIDC soak Gate C

Known gaps (Ryan review)

  1. /api/clinic/register — still creates GoTrue users; new signup UI stubbed
  2. /auth/confirm — legacy GoTrue links redirect to login
  3. /api/protocols/v2/* — patient branch still GoTrue (intentional for iOS)
  4. patients/new/page.tsx — client component still calls anon Supabase for profile bootstrap (uses API routes for writes)
  5. Gate D — identity remap before production login works for real practitioners

STOP — Ryan Gate B code review

Deliverable Status
Clinic @last1id/sdk refactor Done (PR)
RLS assessment Above — RealOutcomes-simple runtime; Gate D FK remap
Port 3000 Confirmed
iOS untouched Confirmed
trust_events:write Not present
Deploy None

Next after approval: Gate C deploy to VM 118 + LAN soak. Not before Ryan sign-off.