last1.id Migration Plan — Tier 2 Mode 2A (R940 Sovereign Identity)¶
Status: Tier 2 CLOSED — 2026-08-26. Sovereign production at https://auth.last1.id. Railway last1.id, hosted Supabase nztgswcpnwggymncrnyx, and legacy GCP OAuth client deleted. BWS cleaned to 33 live keys. Ops: LAST1_ID_MIGRATION_RUNBOOK.md.
Branch: docs/tier-2-last1-id-migration-plan → merged via PR #35
Audit date: 2026-08-24 (Gate A live read: 2026-08-24)
Mode: 2A — self-hosted Postgres + GoTrue + Kong on R940; MinIO for profile photos
Tier 2 closeout summary (2026-08-26)¶
| Phase | Outcome | Report |
|---|---|---|
| Weeks 1–5 | Infrastructure, migration, app deploy, OAuth, test matrix | W1–W5 reports, Gate A/B/C |
| Week 6 | Cutover to auth.last1.id; 24h soak 99/99 green |
W6B, W6C |
| W7-1 | Pre-teardown audit: no prod dependency on Railway/Supabase | W7-1 |
| W7-2 | Archive Railway env + Supabase pg_dump |
W7-2 |
| W7-3 | Delete hosted Supabase nztgswcpnwggymncrnyx |
W7-3 |
| W7-4 | Delete Railway last1.id service |
W7-4 |
| W7-5 | Delete legacy GCP OAuth client (645620802422-…) |
W7-5 |
| W7-6 | BWS cleanup (20 deleted); soak cron removed | W7-6 |
| W7-7 / Week 8 | Docs, muster, runbook, archive manifest | this document + runbook |
Archive: backups/last1id-railway-teardown-2026-08-26/ (env, dump, deletion records).
L3 — Kong SPA routing gap (2026-08-27)¶
Discovered during L2 marketing extraction: https://auth.last1.id/login returned Kong 404. GoTrue (/auth/v1/*) and partner OIDC (/.well-known, /oauth) were routed; the monolith Express SPA (last1id-app:5000) was deployed on R940 but not exposed for /login, /api/*, or static assets.
| Sub-task | Outcome |
|---|---|
| L3-1 Kong config | Added routes in scripts/last1-id/kong/kong.yml for /api, /assets, and SPA paths → last1id-app:5000. Preserved /auth/v1/*, /rest/v1/*, existing OIDC routes. |
| L3-2 Apply | apply-kong-config.sh applied to auth-last1id VM 114; Kong restart green. |
| L3-3 Curl matrix | /login, /signup, /api/config → 200 HTML/JSON; /api/identity/me → 401 (not 404); GoTrue + PostgREST + /oauth/authorize unchanged. |
| L3-4 Ryan browser test | PASS (2026-08-27) — login/signup render, Google OAuth, settings navigation. |
| L3-5 Partner OIDC regression | PASS — W6B + Week5 session cascade E2E green post-Kong routes. |
| L2-6 DNS cutover | COMPLETE — last1.id + www.last1.id → last1id-web Pages. |
| L2-7 Monolith cleanup PR | COMPLETE — last1.id#114 merged (c332db0); post-merge deploy verified on VM 114. |
Post-mortem (honest): Tier 2 closeout validated GoTrue API, partner OIDC scripts, and health endpoints. It did not walk each documented user journey in a browser (marketing CTA → /login → dashboard). W6B Kong work scoped to OIDC paths only; the hostname split docs assumed the full monolith app on auth.last1.id but ingress was never completed. Lesson: for hostname or ingress changes, verify every documented user-facing URL end-to-end in a browser, not only component-boundary checks (GoTrue health, OIDC discovery, synthetic API signup).
Rollback: Restore prior kong.yml from /opt/auth-last1id/volumes/kong/backups/ on VM 114 and run apply-kong-config.sh.
Deferred (explicit — not Tier 2 blockers)¶
| Item | Notes |
|---|---|
| iOS issuer | Still https://last1.id in app bundle. Rebuild when Ryan ready; prod auth is auth.last1.id. |
my.last.vet Last1 sign-in |
New product scope; separate muster item. |
| Provider portal staging issuer | Staging env may still reference old issuer; small cleanup task. |
| BWS duplicate-key hygiene | Guard copy-railway-secrets-week3.sh against re-creating duplicate key names. |
Ryan decisions locked (2026-08-24)¶
| # | Decision |
|---|---|
| Q1 — Mode | Mode 2A — full sovereign identity stack on R940 (Postgres + GoTrue + MinIO). Not Mode 1 interim. |
| Q2 — Tier | Tier 2 = last1.id (moved up from Tier 4). Tier 3 = last1-admin; Tier 4 = RealOutcomes; Tier 5 = Onset; Tier 6 = last1.app + RadioCheck + angel.report. |
| Q3 — Hostnames | Split: auth.last1.id = primary auth service (R940 tunnel). last1.id = marketing on Cloudflare Pages (not Railway app). Partner OIDC issuer = https://auth.last1.id. |
| Q4 — Google OAuth | Dedicated last1.id GCP project (client 889079898378-…). Legacy last1app client deleted W7-5. |
| Q5 — Soak | 24–48 hours (Ryan founder call; 0 production users). Keep Railway + hosted Supabase alive as fallback during soak. |
Target state (founding intent)¶
| Layer | Sovereign on R940 |
|---|---|
| Compute | auth-last1id VM — Node 22, last1.id Docker app |
| Database | sovereign-db-last1id VM — Postgres 17.6.x + pgcrypto |
| Auth service | GoTrue (self-hosted) + Kong API gateway |
| Object storage | MinIO — profile-photos bucket |
| Ingress | Cloudflare Tunnel → auth.last1.id |
| Marketing | Cloudflare Pages → last1.id |
| Partner OIDC | Custom layer unchanged; OAUTH_JWT_* PEM preserved |
Y1 — Service inventory (Ryan review checkpoint)¶
Repo¶
| Item | Finding |
|---|---|
| GitHub | Last-1-Enterprises/last1.id |
| Branch | main @ 358f083 (2026-08-10 push) |
| Language | TypeScript (strict), Node 22 |
| Backend | Express 5 (server/index.ts) |
| Frontend | React 19 + Vite 8 + Wouter + TanStack Query |
| Build | script/build.ts → dist/public (SPA) + dist/index.cjs (API) |
| Packages | packages/sdk, sdk-nextjs, sdk-react-native, login-button, evidence-sdk |
| Migrations | 71 SQL files in supabase/migrations/ (~2170 lines) |
Railway (live, read via GraphQL API)¶
| Item | Value |
|---|---|
| Project | disciplined-beauty (0cb8d8fa-5216-479a-b1f5-00d2dfa7c88b) |
| Service | last1.id (b84596ac-82b1-40b2-96ab-f4e4ec03a186) |
| Environment | production (12ba2dc7-8e11-4ab4-a4d9-9ecfecb47533) |
| Service instance | 5ed12f59-f57f-499b-add0-beb3cee55413 |
| Created | 2026-03-15 |
| Custom domains | last1.id, www.last1.id |
| Replicas | Not exposed via API (Railway default) |
| Memory/CPU | Unknown — not available via GraphQL; confirm in Railway UI |
| Deploy pattern | Dockerfile multi-stage (node:22-alpine), port 5000 |
| Start command | node dist/index.cjs |
| Health check | /api/health, 30s timeout, restart on_failure ×10 |
| Railway env var names | Not enumerable via API in this audit; names documented from repo below |
Supabase¶
| Item | Value |
|---|---|
| Project ref | nztgswcpnwggymncrnyx (linked in package.json, supabase/config.toml) |
| Postgres version | 17.6.x (per consolidation plan .temp/postgres-version) |
| Auth | Supabase GoTrue (hosted) — not self-hosted |
| Storage | Enabled (profile photos, exports) |
| RLS | Enabled on sensitive public tables |
Current URLs¶
| URL | Role |
|---|---|
https://last1.id |
Today: Railway app + OIDC issuer → After cutover: Cloudflare Pages (marketing only) |
https://www.last1.id |
Marketing alias (Cloudflare Pages) |
https://auth.last1.id |
Primary auth service + OIDC issuer (R940 via Cloudflare Tunnel) |
Deployment pattern¶
- Git push → Railway builds Dockerfile with build-args
VITE_SUPABASE_URL,VITE_SUPABASE_ANON_KEY - Single Node process serves API + static SPA in production
- Supabase hosts Postgres + GoTrue + Storage (external dependency)
- In-process jobs: maintenance cleanup, webhook drainer (
WEBHOOK_DRAINER_ENABLED)
Environment variable names (from repo; no values)¶
Required in production (server/lib/envValidation.ts + .env.example):
| Category | Names |
|---|---|
| Core | NODE_ENV, PORT, SITE_URL, CORS_ORIGINS |
| Supabase | SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, SUPABASE_DB_PASSWORD, DATABASE_URL, SUPABASE_ACCESS_TOKEN |
| OIDC issuer (last1.id as IdP) | OAUTH_JWT_PRIVATE_KEY, OAUTH_JWT_PUBLIC_KEY, OAUTH_JWT_KID, LAST1_ID_ISSUER_URL, PUBLIC_BASE_URL, OAUTH_AUDIENCE |
| Dev-only OIDC fallback | OAUTH_JWT_SECRET, OAUTH_ALLOW_HS256_FALLBACK |
| Credential / VC signing | CREDENTIAL_SIGNING_KEY, CREDENTIAL_SIGNING_KEY_PREVIOUS |
| Trust ingestion | LASTVET_APP_SECRET, LAST1_APP_SECRET, RADIOCHECK_APP_SECRET, MATR_IOS_APP_SECRET, MATR_BIZ_APP_SECRET (+ dynamic {APP_ID}_APP_SECRET) |
| Admin gateway | ADMIN_GATEWAY_SECRET, LAST1_ADMIN_GATEWAY_SECRET, LAST1ID_ADMIN_GATEWAY_SECRET |
| Optional | Turnstile, Twilio Verify, SendGrid, Sentry, maintenance alert webhooks, compliance bundle secrets, webhook drainer tuning |
Not in repo (Supabase Dashboard): Google/Microsoft/ID.me OAuth client IDs and secrets for end-user login.
Y2 — Auth method audit (current vs target)¶
Ryan-approved target: universal SSO IdP with Google + Apple + email + magic link + passkey.
Two auth layers (architecture)¶
| Layer | Purpose | Implementation |
|---|---|---|
| A. User login to last1.id | Dashboard, developer portal, consent UI | Supabase GoTrue via @supabase/supabase-js |
| B. Partner "Sign in with Last1 ID" | LastVet, RadioCheck, Onset, etc. | Custom OAuth 2.0 / OIDC (server/routes/oauth.ts) |
Layer A — end-user auth (Supabase)¶
| Method | Current state | Target | Notes |
|---|---|---|---|
| Email + password | Working | Keep | login.tsx / signup.tsx |
| Google OAuth | Working | Keep | Supabase provider; credentials in Supabase Dashboard only |
| Email confirmation / reset | Working | Keep | Standard Supabase flows |
| Magic link (OTP login) | Not implemented | Add | No signInWithOtp in codebase |
| Apple Sign-In | Not implemented | Add | No Apple provider code or env vars |
| Passkey / WebAuthn | Not implemented | Add | No WebAuthn references |
| Microsoft | Stub UI | TBD | Button disabled "Coming soon"; docs say enable in Supabase |
| ID.me | Stub UI | TBD | Same as Microsoft |
| MFA / 2FA | None | TBD | No Supabase MFA API usage |
Layer B — last1.id as OAuth/OIDC IdP (partner apps)¶
| Capability | Current state |
|---|---|
| OIDC discovery | /.well-known/openid-configuration |
| JWKS | /.well-known/jwks.json |
| Authorization code + PKCE | Working |
| Refresh tokens | oauth_refresh_tokens table |
| Token revoke | POST /oauth/revoke |
Silent auth (prompt=none) |
Server-side only |
| Partner webhooks | Outbound HMAC (app_clients.webhook_*) |
| Trust events | POST /api/trust-events(/batch) |
JWT signing¶
| Token type | Algorithm | Key management |
|---|---|---|
| OIDC id_tokens (issued by last1.id) | RS256 (prod required) | OAUTH_JWT_PRIVATE_KEY, OAUTH_JWT_PUBLIC_KEY, OAUTH_JWT_KID |
| Supabase session JWTs (user login) | Supabase-managed | Keys in Supabase project; app validates via supabase.auth.getUser() |
Migration critical path: OAUTH_JWT_* PEM keys must be copied byte-identically to R940 or all partner OIDC tokens break. Supabase JWT verification continues to use Supabase JWKS until auth stack moves.
Session pattern¶
| Session | Storage |
|---|---|
| last1.id user (Supabase) | Client-side Supabase session (localStorage default) |
| Partner OAuth refresh tokens | Postgres oauth_refresh_tokens |
| Server-side cookie session | None (explicit in oauth routes) |
| OAuth handshake state | Browser sessionStorage |
Zero users today: session invalidation on cutover is acceptable; document for future migrations.
Y3 — Dependency mapping¶
In-workspace integrations (verified code)¶
| Product | Integration surface | Key env / config | Breaks if issuer URL changes? |
|---|---|---|---|
| last.vet-api | OAuth client, JWKS verify, trust events OUT, webhooks IN, Onset proxy | LAST1_ID_API_BASE, LASTVET_APP_SECRET, redirect URIs, LAST1_WEBHOOK_SECRET |
Yes — iss must match base URL |
| last.vet-provider | Browser OAuth to /oauth/authorize |
NEXT_PUBLIC_LAST1_ID_ISSUER (build-time) |
Yes — rebuild required |
| last.vet-ios | PKCE OAuth | Hardcoded https://last1.id in Configuration.swift |
Yes — App Store rebuild |
| last.vet-web | Universal Link bridge only | /auth/last1/callback → lastvet:// |
No (redirect URI unchanged) |
Canonical spec: last.vet-api/docs/api/LAST1_ID_INTEGRATION.md
Out-of-workspace (documented; repos not cloned)¶
| Product | Integration | Redirect / callback examples |
|---|---|---|
| RadioCheck | OAuth + trust events + revoke webhook | radiocheck://last1-id/callback; Supabase edge last1-id-callback |
| Onset | OAuth + Connect assertion | onset://last1/callback, https://theonset.app/last1/callback |
| last1-admin | API gateway proxy | VITE_LAST1_API_BASE |
| last1.app | Planned SSO alignment | Phase 2 roadmap |
| RealOutcomes | Weak / admin-mediated | Optional cert registry |
Registered OAuth clients (DB seeds, not env)¶
From migrations: last1, radiocheck, matr-ios, matr-biz, realoutcomes, lastvet, onset.
Partner redirect URIs do NOT include the last1.id hostname (they point at product URLs). Migration changes issuer/authorize/token/JWKS host, not partner callback paths.
What breaks on last1.id → auth.last1.id¶
| Breaks | Does not break |
|---|---|
All LAST1_ID_API_BASE / issuer env vars |
https://provider.last.vet/auth/callback |
| iOS hardcoded issuer | https://last.vet/auth/last1/callback |
OIDC iss claim validation |
onset://last1/callback |
| JWKS URL if derived from issuer | Partner webhook URLs on api.last.vet |
| Supabase Auth Site URL / redirect allowlist | {APP_ID}_APP_SECRET values (unless rotated) |
Google authorized origins (if tied to last1.id) |
Y4 — Database schema audit¶
Live row counts¶
Gate A (2026-08-24): Read-only PostgREST + GoTrue admin audit completed. Not zero users: 12 auth.users, 543 public.* rows, 3 profile-photos objects. Full auth.* / storage.* / pg_policies inventory pending Postgres credential (see LAST1_ID_GATE_A_REPORT.md). Y4 “zero users” = no production veteran traffic; pentest/synthetic/founder accounts present.
Schema (from 71 migrations)¶
Auth-critical public tables:
| Table | Purpose |
|---|---|
identities |
Canonical identity UUID |
linked_auth_providers |
Sign-in methods linked to identity |
identities.auth_user_id |
FK → auth.users (Supabase managed) |
app_clients |
OAuth client registry + redirect URIs + webhook config |
identity_app_links |
Identity ↔ partner app linkage |
consent_grants |
Scoped consent |
oauth_authorization_codes |
Auth codes (PKCE fields) |
oauth_refresh_tokens |
Partner refresh token chains |
oauth_token_audit_log |
Token exchange audit |
consent_audit_log |
Consent audit |
webhook_deliveries |
Outbound webhook outbox |
trust_events |
Cross-product trust ledger |
credentials, issuer_keys, … |
VC / compliance surfaces |
Supabase-managed (not in public migrations): auth.users, auth.sessions, auth.identities, refresh token tables internal to GoTrue.
Total public tables: ~35 (see consolidation plan inventory).
Sensitive data categories¶
| Data | Location | Migration note |
|---|---|---|
| Password hashes | auth.users (Supabase) |
Requires GoTrue migration or Supabase export |
| OAuth provider tokens | Supabase auth schema | Supabase-managed |
| Partner refresh tokens | oauth_refresh_tokens.token_hash |
Migrate with Postgres dump |
| OIDC signing keys | Railway env OAUTH_JWT_* |
Copy via BWS; never commit |
| Credential issuer keys | issuer_keys (encrypted with CREDENTIAL_SIGNING_KEY) |
DB + KEK must move together |
| App client secrets | BWS + Railway ({APP}_APP_SECRET) |
Reconcile both sides |
Current backup strategy¶
| Layer | Finding |
|---|---|
| Supabase | Platform PITR/backups (plan-dependent); no custom runbook found in last1.id repo |
| Railway | Stateless compute; state in Supabase |
| Application exports | Identity export API exists; not a DR substitute |
Execution requirement: Full logical backup (pg_dump including auth schema) + verified restore on R940 before any destructive step. Follow Tier 3+ export hygiene: gitignore archives, no plaintext secrets in git.
Y5 — OAuth callback registration audit¶
A. End-user federated login (Supabase → Google)¶
| Console | What to update for auth.last1.id |
|---|---|
| Supabase Dashboard → Authentication → URL Configuration | Site URL, Redirect URLs |
| Google Cloud Console → OAuth client used by Supabase | Authorized JavaScript origins (https://auth.last1.id), authorized redirect URI remains Supabase callback (https://<project-ref>.supabase.co/auth/v1/callback) |
Google client ID: Not in last1.id repo. Lives in Supabase Dashboard → Authentication → Providers → Google.
Apple: Not configured. When added, requires Apple Developer Services ID + Supabase Apple provider + domain verification for auth.last1.id.
B. last1.id as IdP (partner OAuth)¶
Registered in app_clients.redirect_uris (Postgres). Production URIs from migrations:
| App | Production redirect URIs |
|---|---|
| lastvet | https://provider.last.vet/auth/callback, https://last.vet/auth/callback |
| onset | onset://last1/callback, https://theonset.app/last1/callback |
| radiocheck | radiocheck://last1-id/callback, radiocheck://callback, HTTPS variants per migrations |
| last1 | https://last1.id/dashboard, https://last1.app/api/auth/last1/callback, … |
| realoutcomes | https://realoutcomes.app/ |
| matr-* | Custom scheme + localhost dev URIs |
Post-migration: Update last1 app client URIs if any reference https://last1.id/... paths on the IdP host. Partner product URIs unchanged.
C. Issuer metadata (must change if hostname changes)¶
Set on R940:
SITE_URL=https://auth.last1.idPUBLIC_BASE_URL/LAST1_ID_ISSUER_URL=https://auth.last1.id- OIDC discovery + JWKS served from new host
- Consider keeping
last1.idas 301 →auth.last1.idduring transition (Open Question 3)
Y6 — Migration playbook draft¶
Extends Tier 1 n8n pattern with identity gates. Two execution modes (recommend Mode 1 first):
| Mode | Scope | Risk |
|---|---|---|
| Mode 1 — Compute only | Railway → R940; keep Supabase hosted | Medium |
| Mode 2 — Full stack | Compute + Supabase → self-hosted Postgres/GoTrue on R940 | High |
Ryan's task mentions sovereign-db VM; Mode 2 aligns with long-term consolidation plan. Do not combine Mode 2 with auth method expansion in the same cutover.
Phase 0 — Preconditions (sequential)¶
| Step | Owner | Blocker |
|---|---|---|
| Ryan approves this plan + open questions | Ryan | Yes |
| Export archive policy (gitignored JSON/metadata only) | Cursor | Tier 1 lesson |
BWS keys for R940 prod mapped (LAST1ID_R940_PROD_* or similar) |
Ryan | Naming approval |
| Observability design for last1.id (Prometheus, health, JWT expiry alerts) | Cursor | Parallel |
Phase 1 — Pre-migration (parallel where noted)¶
| # | Step | Sequential? | Notes |
|---|---|---|---|
| 1.1 | Provision R940 VM (last1id-r940 or consolidation name) |
Sequential | Docker, node 22, systemd, ufw |
| 1.2 | Cloudflare tunnel route auth.last1.id → VM (DNS-only or proxied per policy) |
After 1.1 | Mirror n8n lastvet-edge pattern |
| 1.3 | Full Supabase logical backup + restore test to scratch | Sequential | Includes auth schema |
| 1.4 | Copy signing material to BWS (OAUTH_JWT_*, CREDENTIAL_SIGNING_KEY, app secrets) |
Parallel with 1.3 | Byte-identical PEM |
| 1.5 | Document live Railway env var names snapshot (UI export) | Parallel | No values in git |
| 1.6 | Register R940 instance in observability | After 1.1 | Tier 1 pattern |
Phase 2 — Database (Mode 2 only; skip for Mode 1)¶
| # | Step | Notes |
|---|---|---|
| 2.1 | Provision Postgres VM (sovereign-db-* per consolidation plan) |
Dedicated instance; not shared with n8n |
| 2.2 | Restore Supabase dump OR logical replication | High risk — GoTrue compatibility |
| 2.3 | Self-host GoTrue / Supabase Auth stack OR replace auth layer | Major engineering — not scripted in repo today |
| 2.4 | Point SUPABASE_URL at self-hosted OR refactor off Supabase SDK |
Likely multi-week project |
Recommendation: Defer Phase 2 until Mode 1 is stable. Consolidation plan already treats last1.id Postgres as dedicated trust boundary.
Phase 3 — Application deployment (R940)¶
| # | Step | Notes |
|---|---|---|
| 3.1 | Build Docker image from main with same build-args |
Match Railway Dockerfile |
| 3.2 | Deploy with production env from BWS | SITE_URL, issuer URLs, Supabase keys |
| 3.3 | Run health checks: /api/health, OIDC discovery, JWKS |
Automated smoke script |
| 3.4 | Parallel-run on private hostname (last1id-r940.tailc3bbdc.ts.net) |
No public traffic |
Phase 4 — OAuth / provider console updates (before public cutover)¶
| # | Step | Must precede DNS |
|---|---|---|
| 4.1 | Update Supabase Auth Site URL + redirect allowlist | Yes |
| 4.2 | Update Google Cloud authorized origins for new SPA host | Yes |
| 4.3 | Update app_clients rows if any IdP-host redirect URIs |
Yes |
| 4.4 | Notify partner env updates (LAST1_ID_API_BASE, iOS rebuild) |
Before dependent testing |
| 4.5 | Apple / passkey consoles | Post-expansion only |
Phase 5 — Parallel run + cutover test¶
| # | Step | Notes |
|---|---|---|
| 5.1 | Railway stays live; R940 serves tailnet + synthetic tests | Both instances alive |
| 5.2 | Run LastVet provider OAuth E2E against R940 issuer (staging env) | PKCE + token + JWKS |
| 5.3 | Run trust-event batch dry-run / test app | LASTVET_APP_SECRET |
| 5.4 | Run webhook delivery test to api.last.vet/api/webhooks/last1 |
HMAC verify |
Phase 6 — Traffic cutover¶
| # | Step | Notes |
|---|---|---|
| 6.1 | Lower DNS TTL for auth.last1.id (and last1.id if redirecting) |
24h before |
| 6.2 | Switch auth.last1.id CNAME to Cloudflare tunnel |
Primary cutover |
| 6.3 | Optional: last1.id → 301 to auth.last1.id |
Preserves bookmarks |
| 6.4 | Update all partner LAST1_ID_API_BASE / issuer envs |
Coordinated deploy |
| 6.5 | Deploy LastVet iOS with updated issuer | App Store latency |
Phase 7 — Soak (7 days minimum)¶
| Check | Frequency |
|---|---|
| OIDC token issuance + JWKS rotation window | Daily |
| LastVet link/unlink + trust drainer | Daily |
| Webhook delivery lag | Alert-driven |
| Supabase connectivity from R940 | Continuous |
| Error budget / Sentry | Continuous |
Not Tier 1 skip-soak: identity outage breaks all dependents.
Phase 8 — Railway teardown¶
| Step | Notes |
|---|---|
Deactivate Railway last1.id service |
After soak |
| Verify cost delta | ~TBD/month |
| Archive env snapshot (gitignored) | Tier 1 pattern |
railway-teardown.sh or GraphQL delete |
BWS token ready |
Y7 — Auth method expansion sequencing¶
Rule: Preserve current behavior through migration; expand after R940 is stable.
| Method | When to implement | Rationale |
|---|---|---|
| Email + password | Migrate as-is | Already on Supabase |
| Google OAuth | Migrate as-is | Supabase provider config moves with Site URL |
| Magic link | After migration | New Supabase feature wiring |
| Apple Sign-In | After migration | New provider + Apple Developer setup |
| Passkey / WebAuthn | After migration | New feature; likely Supabase WebAuthn or custom |
| Microsoft / ID.me | Defer | Stub UI only |
Do not add Apple + passkey + hostname change + DB migration in one release.
Y8 — Risk register (Mode 2A execution)¶
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Partner OIDC JWT key rotation | Medium | Critical — LastVet link/sign-in breaks | Preserve OAUTH_JWT_PRIVATE_KEY, OAUTH_JWT_PUBLIC_KEY, OAUTH_JWT_KID byte-identically via BWS; verify JWKS match before cutover |
| Google OAuth callback mismatch | Medium | High — user login fails | Update GCP Last1.id client before DNS cutover: add origin https://auth.last1.id, callback https://auth.last1.id/auth/v1/callback; keep hosted Supabase callback during parallel run |
| LastVet integration break | Medium | High | PEM continuity + synthetic-account OIDC test in Week 5; coordinate LAST1_ID_API_BASE=https://auth.last1.id deploy with cutover |
| MinIO profile photo loss | Medium | Medium | Export storage.objects + verify every profile-photos object in MinIO before Supabase Storage delete; checksum spot-check |
| Database migration data loss | Low (0 users) | Critical if wrong | Full pg_dump (auth+public+storage schemas); restore drill Week 2; keep hosted Supabase alive through 24–48h soak |
Issuer URL / iss mismatch |
High | Critical | Set SITE_URL, PUBLIC_BASE_URL, LAST1_ID_ISSUER_URL to https://auth.last1.id; update LastVet provider + iOS builds |
| GoTrue SMTP misconfig | Medium | Medium — reset/confirm emails fail | Provision SMTP_* in BWS before Week 3; test password reset on staging |
| Railway fallback removed too early | Medium | High | Week 6: deactivate schedules only first; delete Railway service Week 7–8 after soak |
| Onset Automation OAuth duplicate (GCP) | Low | Low (hygiene) | Duplicate OAuth client under last1app — defer to post-Tier 2 GCP cleanup |
| Export archive secret leak | Medium | Medium | Gitignore migration dumps; encrypted off-repo only |
| CREDENTIAL_SIGNING_KEY rotation | Low | High | Never rotate during migration window |
Y9 — Effort estimate (summary)¶
Estimates are active engineering hours (Ryan gate reviews add calendar time). Detailed Mode 2 breakdown: Y11.
Mode 1 — Compute migration only (keep Supabase hosted)¶
| Workstream | Hours |
|---|---|
| Planning approval + BWS naming | 4–8 |
| VM + tunnel + observability | 8–12 |
| BWS env mapping + deploy scripts | 8–12 |
| Staging parallel run + OAuth console updates | 8–12 |
| LastVet E2E + partner smoke tests | 12–16 |
| Cutover + 7-day soak (monitoring) | 8–12 active + 7 calendar days |
| Railway teardown + docs | 4–6 |
| Total active | 52–78 hours (~2–3 weeks with reviews) |
Sovereignty: compute on R940 only. Identity data and auth JWT signing keys remain on Supabase SaaS.
Mode 2 — Full self-host (target state)¶
| Variant | Active hours | Calendar (1 FTE + Ryan gates) | Sovereignty |
|---|---|---|---|
| 2A — Self-hosted Postgres + GoTrue + MinIO (recommended) | 140–200 | 5–8 weeks | Data + auth + object storage on R940 |
| 1.5 — Self-hosted Postgres + Better Auth | 180–260 | 6–10 weeks | Data sovereign; drops Supabase SDK; moderate rewrite |
| 2B — Ory Kratos/Hydra or custom | 320–520 | 4–7 months | Full control; largest rewrite |
The earlier "2–4 months / multi-month" range applied to 2B-style auth replacement, not to 2A. There is a credible 5–8 week sovereign path if Ryan chooses self-hosted Supabase (GoTrue + Postgres + Storage) without replacing @supabase/supabase-js.
Y11 — Mode 2 detailed effort breakdown¶
Audit basis: last1.id @main shallow clone; 88 TypeScript files touch Supabase auth; server uses service role for DB (RLS enabled, bypassed via service_role policies); client login is 100% Supabase SDK; partner OIDC is custom Express (~1042 lines oauth.ts, unchanged by GoTrue host move).
1. GoTrue vs custom auth decision¶
last1.id today has two auth systems:
| System | Role | Migration sensitivity |
|---|---|---|
| Supabase GoTrue | End-user login (email, Google, sessions) | Host move or library swap |
Custom OIDC (OAUTH_JWT_*) |
Partner "Sign in with Last1 ID" | PEM key continuity only; not GoTrue |
Option comparison¶
| Option | Description | One-time hours | Ongoing maintenance | Keeps current code? |
|---|---|---|---|---|
| 2A — Self-host Supabase stack | Official Docker: Postgres + GoTrue + Storage API + Kong (+ optional Studio). Point SUPABASE_URL at R940. |
40–64 (infra + config) | 2–4 h/month ops (upgrades, backups, cert rotation) | Yes — @supabase/supabase-js unchanged |
| 2A-lite — GoTrue + Postgres only | Self-host GoTrue + Postgres; replace Storage with MinIO adapter | 48–72 | 3–5 h/month | Mostly yes; Storage API swap for profile photos |
| 1.5 — Better Auth in Express | Embed Better Auth in existing Node server; Postgres sessions | 80–120 auth layer | 2–3 h/month | Partial — rewrite client login + getAuthUserFromRequest |
| 1.5 — Auth.js (NextAuth v5) | Similar to Better Auth; less natural fit for Express monolith | 90–130 | 2–3 h/month | Partial |
| 2B — Ory Kratos + Hydra | Enterprise IAM split; replace GoTrue + part of OIDC | 200–320 | 4–8 h/month | No — major rewrite |
| 2C — Custom auth | Build email/OAuth/session from scratch | 400–600+ | 8–16 h/month | No |
Can we self-host GoTrue? Yes. GoTrue is open source (Apache 2.0). Supabase runs it; you can run the same binary via supabase/supabase self-hosting docs or a trimmed compose with supabase/gotrue, Postgres, and Storage.
Recommendation for sovereign founding intent: Mode 2A (full self-hosted Supabase stack on R940 sovereign-db-* VM + app on last1id-r940). Delivers data sovereignty and operational control without a 3-month auth rewrite. Auth method expansion (Apple, passkey, magic link) then layers on GoTrue features post-cutover.
Not recommended for speed: Ory/custom (2B/2C) unless Supabase self-host is rejected on policy grounds (e.g. refuse to run Kong/GoTrue stack at all).
2. Database migration real scope¶
Extensions (from 71 migrations)¶
| Extension | Required? | Notes |
|---|---|---|
| pgcrypto | Yes | Used in 6 migrations (issuer keys, org admin, webhooks) |
| uuid-ossp / pgcrypto gen_random_uuid | Implicit | UUID PKs throughout |
| pgvector | No | Not referenced in migrations |
| postgis | No | Not referenced |
auth schema (Supabase-managed; must migrate with GoTrue)¶
Typical GoTrue tables to include in dump/restore (exact set depends on Supabase version):
| Table | Purpose |
|---|---|
auth.users |
Email, password hash, metadata, confirmation state |
auth.identities |
Federated provider links (Google sub, etc.) |
auth.sessions |
Active refresh/session chains |
auth.refresh_tokens |
GoTrue refresh token storage |
auth.mfa_factors |
MFA (empty today; schema may exist) |
auth.mfa_challenges |
MFA challenges |
auth.mfa_amr_claims |
MFA AMR |
auth.audit_log_entries |
Auth audit trail |
auth.flow_state |
OAuth PKCE / SSO flow state |
auth.one_time_tokens |
Email confirm, password reset tokens |
auth.sso_providers / auth.sso_domains / auth.saml_providers |
SSO (likely empty) |
FK bridge: public.identities.auth_user_id → auth.users(id) (20260314100000_auth_and_onboarding.sql).
public schema (~35 application tables)¶
Auth-critical subset:
identities, linked_auth_providers, app_clients, identity_app_links, consent_grants, oauth_authorization_codes, oauth_refresh_tokens, oauth_token_audit_log, consent_audit_log, webhook_deliveries, trust_events, plus org/admin/VC tables.
Full table list in Y4 audit; all defined in supabase/migrations/.
storage schema¶
| Object | Purpose |
|---|---|
storage.buckets |
profile-photos bucket seeded in migration |
storage.objects |
Profile photo blobs |
App uses supabase.storage.from("profile-photos") in identity.ts and profilePhotoMirror.ts.
RLS policies¶
| Metric | Count |
|---|---|
Tables with ENABLE ROW LEVEL SECURITY |
~32 public tables |
| RLS-related migration statements | ~74 lines |
| Effective access pattern | Server uses service role (bypasses RLS); anon/authenticated denied by default |
Migration work: RLS definitions migrate with schema via pg_dump; verify service_role policies after restore. No RLS redesign required unless dropping Supabase roles.
Data migration hours (schema + data only)¶
| Task | Hours |
|---|---|
Pre-migration logical backup from hosted Supabase (pg_dump -Fc auth+public+storage) |
4–8 |
| Provision Postgres 17 on R940 (VM, disk, ufw, backup job) | 12–16 |
| Restore drill to scratch instance + migration replay test | 8–12 |
| Row-count / FK integrity validation scripts | 4–8 |
| Storage object export/import (profile photos) | 8–16 |
| Cutover window execution (read-only freeze, final dump, restore) | 4–8 |
| Post-cutover smoke queries | 4–6 |
| Subtotal (DB only) | 44–74 hours |
With zero users (Ryan): data volume is trivial; hours are dominated by process and verification, not transfer size.
3. OAuth flow reimplementation¶
Flows that exist today¶
| Flow | Implementation | Self-host impact |
|---|---|---|
| Email signup/login | supabase.auth.signUp / signInWithPassword |
GoTrue config + SMTP (Supabase handles mail hooks) |
| Google login | supabase.auth.signInWithOAuth({ provider: "google" }) |
Re-enter Google client in GoTrue env; Supabase callback URL changes to self-hosted auth URL |
| Password reset | resetPasswordForEmail |
GoTrue mail templates + Site URL |
| Email confirmation | signup emailRedirectTo |
Site URL on self-hosted auth |
| Partner OIDC authorize/token/revoke | Custom server/routes/oauth.ts |
No reimplementation if Postgres + signing keys move intact |
Legacy /authorize bridge |
SPA + identity.ts |
Unchanged |
| Trust events / webhooks | Bearer {APP}_APP_SECRET |
Unchanged |
Supabase-specific SDK usage to replace (only if not using 2A)¶
| Usage | Files | Replace effort (1.5 only) |
|---|---|---|
| Client auth session | AuthContext.tsx, login.tsx, signup.tsx, … |
24–40 h |
Server auth.getUser(token) |
server/lib/auth.ts + all protected routes |
16–24 h |
Server auth.admin.deleteUser |
identityDeletion.ts |
4–8 h |
| Storage profile photos | identity.ts, profilePhotoMirror.ts |
16–24 h (MinIO/S3 API) |
createClient service role (~20 route modules) |
Can keep postgres driver OR keep supabase-js pointed at self-hosted | 0 h (2A) / 8–16 h (direct pg) |
Mode 2A hours (OAuth reconfiguration, not reimplementation): 12–20
| Task | Hours |
|---|---|
| GoTrue Google provider env + Google Cloud console origins | 4–8 |
GoTrue Site URL / redirect allowlist for auth.last1.id |
2–4 |
| SMTP for auth emails (if not using Supabase hosted mail) | 4–8 |
| Regression: login, signup, reset, Google round-trip | 4–8 |
Mode 1.5 hours (SDK replacement): 60–96 (see table above)
4. Session management¶
| Aspect | Current (Supabase) | Mode 2A (self-host GoTrue) | Mode 1.5 (Better Auth) |
|---|---|---|---|
| User session | JWT access + refresh in client localStorage via SDK | Same pattern; JWT signed by your GoTrue JWT secret | Library-managed session cookies/tokens |
| API auth | Authorization: Bearer <supabase_access_token> → getUser() |
Same code path; different JWKS endpoint | Rewrite middleware |
| Partner sessions | oauth_refresh_tokens table (app-issued) |
Unchanged | Unchanged |
| Server cookie session | None | None | Optional in Better Auth |
JWT continuity concerns:
| JWT | Must preserve on cutover? |
|---|---|
OAUTH_JWT_PRIVATE_KEY (partner OIDC) |
Yes — copy PEM exactly |
| GoTrue JWT secret (user sessions) | No impact today (0 users); can rotate if desired |
| Supabase anon/service keys | Regenerate for self-hosted project |
Hours:
| Task | 2A | 1.5 |
|---|---|---|
| Export/import GoTrue JWT secret + configure | 2–4 | N/A |
| Session smoke tests (login → API call → refresh) | 4–8 | 8–12 |
| Document session invalidation policy | 2 | 2 |
| Subtotal | 8–14 | 10–14 |
5. Testing across auth methods¶
Current methods to test (migration scope)¶
| Method | Automated coverage today | Manual E2E needed |
|---|---|---|
| Email/password login | Vitest integration tests exist (identity.*.int.test.ts, ~59 test files repo-wide) |
Yes — full browser flow |
| Email signup + confirm | Partial | Yes |
| Password reset | None found | Yes |
| Google OAuth | None in CI (needs real Google) | Yes — required |
| Partner OIDC (LastVet) | API integration tests | Yes — provider portal + API |
| Trust events / webhooks | Integration tests | Smoke on cutover |
Testing hours¶
| Task | Hours |
|---|---|
| Extend CI against self-hosted Supabase (docker compose in GitHub Actions or R940 staging) | 12–16 |
| Manual E2E matrix: email login, signup, reset | 8–12 |
Google OAuth E2E on staging auth.last1.id |
4–8 |
| Partner OIDC: LastVet provider + API token exchange + JWKS | 12–16 |
| Webhook delivery + trust-event batch | 4–8 |
| Load/soak monitoring scripts (24–48h soak ops) | 4–8 |
| Subtotal | 44–68 |
Post-migration auth expansion (Apple, magic link, passkey) is out of scope for Mode 2 cutover testing; budget separately (+40–80 h per method).
6. Mode 2A total hours (recommended sovereign path)¶
| Component | Hours |
|---|---|
| A. Infrastructure | |
Postgres VM (sovereign-db-last1id) + backups + monitoring |
16–24 |
| Self-hosted Supabase stack (GoTrue + Kong) + MinIO | 24–40 |
App VM (auth-last1id) + Cloudflare tunnel auth.last1.id |
12–16 |
| BWS secret mapping + deploy systemd/docker | 8–12 |
| B. Database migration | 44–74 |
| C. OAuth / GoTrue config | 12–20 |
| D. Session / JWT continuity | 8–14 |
| E. Application deploy + parallel run | 12–16 |
| F. Partner coordination (LastVet env, iOS issuer, provider rebuild) | 16–24 |
| G. Testing (section 5) | 44–68 |
| H. Cutover + 24–48h soak (active monitoring) | 8–12 |
| I. Teardown (Railway + hosted Supabase project after verification) | 6–10 |
| J. Runbook + observability | 8–12 |
| Total active | 212–316 |
Realistic planning number: ~240 hours (6 weeks at 40 h/week, or 5–8 calendar weeks with Ryan gate reviews and 24–48h soak wall time).
Not multi-month unless Ryan chooses 1.5/2B auth library replacement.
Mode 1.5 hybrid analysis (Postgres self-host + Better Auth)¶
Concept: Migrate compute to R940, self-host Postgres with identity data, replace Supabase GoTrue with Better Auth embedded in existing Express app. Keep custom partner OIDC layer (oauth.ts) as-is.
| Advantage | Disadvantage |
|---|---|
| Data on R940 | 60–96 h auth layer rewrite |
| No Supabase SaaS dependency | Must reimplement Google OAuth, email flows in Better Auth |
| Cleaner long-term than operating full Supabase stack | Profile photo Storage → MinIO migration |
| Still "sovereign identity data" | Loses Supabase Dashboard for auth provider toggles |
| Component | Hours |
|---|---|
| Everything in Mode 2A infra + DB (minus Supabase stack) | 60–90 |
| Better Auth integration (server + client) | 80–120 |
| Storage → MinIO for profile photos | 16–24 |
| Testing | 40–56 |
| Total | 196–290 (~6–10 weeks) |
Verdict: Mode 1.5 is similar calendar time to 2A but more code risk. Prefer 2A unless policy rejects running GoTrue/Kong. Mode 1.5 wins if Ryan wants to exit Supabase-branded stack entirely while avoiding Ory-scale complexity.
Mode comparison — what unblocks what¶
| Milestone | Mode 1 (52–78 h) | Mode 2A (140–200 h) | Mode 1.5 (180–260 h) |
|---|---|---|---|
| Compute off Railway | ✅ ~2 weeks | ✅ ~2 weeks (parallel with DB) | ✅ ~3 weeks |
| Identity data off Supabase SaaS | ❌ | ✅ | ✅ |
| Sovereign founding story complete | ❌ (data on vendor) | ✅ | ✅ |
| Tier 3–6 identity-dependent cuts safe | Partial (vendor DB remains) | ✅ | ✅ |
@supabase/supabase-js dependency |
Keeps | Keeps (self-hosted) | Removes |
| Auth expansion (Apple/passkey) | After either mode | After 2A stable | After 1.5 stable |
| Ongoing vendor cost (Supabase) | ~$25+/mo continues | $0 after teardown | $0 |
Tier 3–6 blocked without last1.id migration:
| Tier | Blocker if last1.id stays on Railway+Supabase |
|---|---|
| 3 last1-admin | Low direct blocker |
| 4 RealOutcomes | Low direct blocker |
| 5 Onset | OAuth + Connect assertion depend on issuer |
| 6 last1.app + RadioCheck + angel.report | Hard dependency on last1.id OAuth + trust events |
Mode 1 interim: Unblocks compute cost and Railway reduction in weeks, but does not complete sovereignty. Ryan has rejected that as end state.
Tier 3–6 lessons (from Tier 2 Week 4 correction)¶
- Auth-gated credentials: When credentials live in a Google Doc, 1Password item, or other location Cursor cannot read, STOP and ask Ryan. Do not infer from generic
GOOGLE_OAUTH_*keys in unrelated BWS projects (last1-admin,lastvet, etc.). - BWS project ownership: Product-prefixed keys belong in the product's BWS project.
LAST1_ID_R940_*→Last1.idproject (cffb320a…);LASTVET_R940_*→lastvetproject. Cross-product inference is a rotation and blast-radius failure mode.
Updated recommendation (post Ryan sovereignty confirmation)¶
Target: Mode 2A direct (skip Mode 1 interim)
Timeline: 5–8 calendar weeks (~240 active hours)
Critical path: Postgres restore drill → GoTrue Google config → partner OIDC smoke → DNS cutover → 24–48h soak
Defer: Apple, magic link, passkey until 30 days post-cutover
Reject for speed: Mode 1 as final state; Ory/custom auth (2B/2C) unless 2A fails policy review
Consider: Mode 1.5 only if GoTrue self-host is unacceptable but Better Auth is approved
X — Mode 2A week-by-week execution plan¶
Calendar: 5–8 weeks · ~240 active hours · Ryan gate at Week 1 kickoff and Week 6 cutover.
| Week | Phase | Hours | Deliverables |
|---|---|---|---|
| 1 | Infrastructure setup | 60–92 | See checklist below |
| 2–3 | Database migration | 44–74 | Self-hosted Postgres populated; RLS verified |
| 3–4 | Application + OAuth deployment | 48–74 | GoTrue + app on R940; GCP callbacks staged |
| 5 | Testing | 44–68 | Email, Google, LastVet OIDC green |
| 6 | Cutover + soak | 8–12 active + 24–48h wall | DNS split; Railway deactivated (not deleted) |
| 7–8 | Teardown + playbook | 14–22 | Railway + Supabase delete; docs updated |
Week 1 — Infrastructure setup (60–92 h)¶
| Task | Notes |
|---|---|
Provision sovereign-db-last1id VM on R940 |
Postgres 17.6.x, pgcrypto, backups, Prometheus node exporter |
Provision auth-last1id VM on R940 |
Node 22, Docker: GoTrue + Kong; systemd for last1.id app |
| BWS lane (document only until Ryan approves Week 1) | Project prefix LAST1_ID_R940_PROD_* — see X-BWS below |
| Cloudflare Tunnel | auth.last1.id → auth-last1id VM |
| DNS | auth.last1.id CNAME/tunnel route (staging hostname OK before cutover) |
| MinIO on R940 | Bucket profile-photos; access keys → BWS (Week 1 execution) |
| Observability | Prometheus scrape + Loki ship (mirror n8n pattern) |
Gate: VMs healthy; tunnel returns 502/404 from placeholder (not DNS failure).
Weeks 2–3 — Database migration (44–74 h)¶
Week 2 gates: A pre-migration audit · B dry-run dump/restore + MinIO · C cutover readiness. All complete 2026-08-25.
| Task | Notes |
|---|---|
| Export hosted Supabase schema + data | Done via pooler aws-1-us-east-1.pooler.supabase.com (see Gate B) |
| Import to self-hosted Postgres | last1id_prod on VM 113; gate-b-dump.sh / gate-b-restore.sh |
| Migrate RLS policies | 35 live policies; restored with dump |
| Verify FK integrity | 0 orphan identities.auth_user_id |
Migrate profile-photos to MinIO |
3/3 objects; SHA-256 verified |
| GoTrue READ-READY | v2.196.0; signup/signin/refresh/admin API green (Gate C) |
Week 2 totals: 12 auth users, 543 public rows, 3 storage objects, 35 RLS policies. Hosted Supabase + Railway unchanged.
Gate C outcome: sovereign-db-last1id READ-READY for Week 3 app deploy. STOP for Ryan Week 3 approval.
Original Week 2 gate (met): Row counts match; RLS restored; MinIO object count = Supabase Storage count.
Weeks 3–4 — Application + OAuth deployment (48–74 h)¶
Week 3 outcome (2026-08-25): last1.id app on VM 114 (http://192.168.1.96:5000, LAN/Tailscale only). PostgREST + Caddy TLS bridge + BWS partner secrets. JWKS + LastVet client validation green. STOP for Ryan Week 4 approval. Report: LAST1_ID_WEEK3_REPORT.md.
| Task | Status |
|---|---|
Deploy PostgREST + Kong /rest/v1 |
Done |
Deploy last1.id app (358f083) |
Done — /opt/last1.id/ |
Copy OAUTH_JWT_* from Railway → BWS |
Done — PEM decode + SHA-256 audit |
| Partner env (LastVet, Turnstile, Twilio, KEK) | Done |
GOOGLE_OAUTH_*, SMTP_* |
Not in Railway env; Week 4 BWS + GoTrue (dedicated GCP client) |
| Public DNS | Not done (Week 6) |
| GCP OAuth (dedicated project) | Done Week 4 (old last1app client kept for parallel run) |
Week 4 entry condition: Ryan approves Week 3 report. Then GCP Console, OAuth staging E2E, optional SMTP.
Week 4 outcome (2026-08-25, corrected): Ryan's dedicated GCP OAuth client (889079898378-…) in Last1.id BWS project; GoTrue on VM 114 refreshed. Automated Google OAuth E2E PASS (authorize redirect uses Ryan's client_id). Full browser callback pending Ryan (no auth.last1.id DNS). LastVet synthetic OIDC E2E PASS; sovereign JWKS 200. Old last1app client (645620802422-…) active for hosted Supabase parallel run. STOP for Ryan Week 5 approval. Report: LAST1_ID_WEEK4_REPORT.md.
Week 4 correction: Initial agent pass wrongly inferred stale 911218996408-… from last1-admin BWS and stored all LAST1_ID_R940_* keys in lastvet project. Fixed by Ryan direct BWS update + migrate-last1id-bws-to-project.sh.
| Task | Status |
|---|---|
| Dedicated GCP OAuth client + BWS rotation | Done |
| GoTrue Google provider on R940 | Done |
| Google OAuth automated E2E (new client) | Done |
| Google OAuth full browser callback | Pending Ryan (hosts/tunnel; no DNS cutover) |
| LastVet partner OIDC E2E (synthetic) | Done |
Sovereign JWKS HTTP 200 + correct kid |
Done |
| SMTP / SendGrid identification | Done (defer config) |
Old last1app client parallel run |
Active (verified hosted Supabase 302) |
Week 5 entry condition: Ryan approves Week 4 report + confirms GCP OAuth updates. Then full test matrix including JWKS 200 on public issuer.
Week 5 outcome (2026-08-25): Full test matrix on VM 114. Email/password PASS; Google OAuth browser PASS (Ryan manual); partner OIDC core + session cascade PASS; sovereign JWKS 200 / Railway 500; light load 10/10 PASS; Kong/MinIO failure modes PASS. Ryan review: partner id_token.sub ≠ auth.users.id (likely intended pairwise subject); Postgres iptables probe inconclusive (Docker egress). Report: LAST1_ID_WEEK5_REPORT.md.
W6A outcome (2026-08-25): Pre-cutover audit complete. Finding 1: LastVet uses id_token.sub as canonical key — no code fix. URLs: coordinated switch to https://auth.last1.id at W6B (sovereign + API + provider; iOS deferred post-soak). Finding 2: Postgres stop → GoTrue no crash, signup 500, auto-recovery. Report: LAST1_ID_WEEK6A_REPORT.md.
W6B-1 outcome (2026-08-25): Alerting verified. Postgres exporter up; new PostgresLast1idDatabaseDown (pg_up=0) live-fired; Last1idAuth5xxRateHigh + Caddy scrape up. Critical route same as N8nHostDown. Report: LAST1_ID_WEEK6B1_REPORT.md.
W6B outcome (2026-08-25): Coordinated cutover to https://auth.last1.id complete. Synthetic OIDC E2E ALL PASS. LastVet API + provider portal updated. Report: LAST1_ID_WEEK6B_REPORT.md.
W6C outcome (2026-08-26): 24h soak green (99/99 post-cutover probes). Ryan browser check PASS (health, OIDC, real Google OAuth). Week 6 closed at 24h. iOS issuer + my.last.vet Last1 sign-in remain deferred.
Week 7 outcome (2026-08-26): W7-1 audit PASS. W7-2 archival complete. W7-3 hosted Supabase deleted. W7-4 Railway service deleted. W7-5 legacy GCP OAuth deleted. W7-6 BWS cleanup (20 secrets deleted, 51→33 keys) + soak cron removed. Tier 2 CLOSED.
Week 8 outcome (2026-08-26): Migration plan finalized, muster items closed, sovereign runbook published, archive manifest refreshed. See LAST1_ID_MIGRATION_RUNBOOK.md.
Week 5 — Testing (44–68 h) — executed 2026-08-25¶
| Flow | Requirement |
|---|---|
| Email + password | Signup, login, reset (SMTP live) |
| Google OAuth | Both callback URLs (hosted + self-hosted) during parallel run |
| Partner OIDC | LastVet provider portal + API with synthetic accounts |
| JWKS endpoint | /.well-known/jwks.json returns HTTP 200 with correct RS256 keys (regression vs Railway prod 500) |
| PEM continuity | Existing LastVet auth continues after issuer URL change (pre-cutover staging) |
| Profile photos | Upload + read via MinIO |
Gate: Ryan sign-off on test matrix before Week 6 DNS.
Week 6 — Cutover + 24–48h soak¶
| Step | Action |
|---|---|
| 1 | Deactivate Railway last1.id service schedules/traffic (keep service alive as fallback) |
| 2 | DNS: last1.id → Cloudflare Pages (marketing) |
| 3 | DNS: auth.last1.id → R940 tunnel (primary auth + OIDC issuer) |
| 4 | Deploy LastVet LAST1_ID_API_BASE=https://auth.last1.id (provider + API) |
| 5 | 24–48h soak — Ryan founder call; monitor alerts; hosted Supabase + Railway remain restorable |
Gate: No P1 auth failures; LastVet synthetic OIDC green for full soak window.
Weeks 7–8 — Teardown + playbook — COMPLETE 2026-08-26¶
| Task | Status | Record |
|---|---|---|
| Pre-teardown dependency audit (W7-1) | Done | W7-1 audit |
| Archive Railway env + Supabase dump (W7-2) | Done | backups/last1id-railway-teardown-2026-08-26/ |
Delete hosted Supabase nztgswcpnwggymncrnyx (W7-3) |
Done | supabase-deletion-record.json |
| Delete Railway last1.id service (W7-4) | Done | railway-deletion-record.json |
| GCP OAuth cleanup — legacy last1app client (W7-5) | Done | gcp-oauth-deletion-record.json |
| BWS cleanup + soak cron removal (W7-6) | Done | bws-deletion-record.json (20 deleted; 33 remain) |
| Sovereign ops runbook | Done | LAST1_ID_MIGRATION_RUNBOOK.md |
| Update consolidation plan + muster | Done | LAST1_INFRASTRUCTURE_CONSOLIDATION_PLAN.md |
X-BWS — Bitwarden Secrets lane (Tier 2 complete)¶
Project: Last1.id · ID: cffb320a-5f61-4e1d-85df-b480012151ec
Live keys (2026-08-26): 33 — all prefixed LAST1_ID_R940_PROD_* (+ LAST1_ID_R940_POSTGRES_EXPORTER_PASSWORD)
Legacy unprefixed keys (SUPABASE_URL, OAUTH_JWT_*, Railway-era names) deleted W7-6. Full key map: LAST1_ID_MIGRATION_RUNBOOK.md.
| Secret key | Purpose |
|---|---|
LAST1_ID_R940_PROD_DATABASE_URL |
Self-hosted Postgres (sovereign-db-last1id) |
LAST1_ID_R940_PROD_GOTRUE_JWT_SECRET |
GoTrue session JWT signing (distinct from partner OIDC PEM) |
LAST1_ID_R940_PROD_OAUTH_JWT_* |
Partner OIDC RS256 signing (preserved from Railway) |
LAST1_ID_R940_PROD_GOOGLE_OAUTH_* |
Dedicated GCP client 889079898378-… |
LAST1_ID_R940_PROD_SOVEREIGN_SERVICE_ROLE_KEY |
PostgREST service_role JWT |
LAST1_ID_R940_PROD_SUPABASE_TLS_URL |
Public HTTPS API base (https://auth.last1.id) |
LAST1_ID_R940_PROD_MINIO_* |
MinIO profile-photos bucket |
| Partner + gateway secrets | {LASTVET,LAST1,ONSET,RADIOCHECK}_APP_SECRET, admin/bridge HMAC, Turnstile, Twilio |
Hygiene follow-up: Prevent duplicate BWS entries if copy-railway-secrets-week3.sh is re-run (Week 3 created duplicate SUPABASE_URL key names).
Y10 — Recommended sequence (post-decision)¶
1. Ryan approves Week 1 kickoff date (STOP gate — this document)
2. Week 1: sovereign-db + auth-last1id VMs, tunnel, MinIO, BWS lane
3. Weeks 2–3: pg_dump restore drill → production import → MinIO photos
4. Weeks 3–4: GoTrue + app deploy; GCP OAuth callbacks BEFORE public cutover
5. Week 5: email, Google, LastVet OIDC synthetic tests; PEM/JWKS verify
6. Week 6: DNS split (last1.id → Pages, auth.last1.id → R940); 24–48h soak
7. Weeks 7–8: Railway + Supabase teardown; GCP callback cleanup; Tier 2 lessons
8. Post-stable: auth expansion (Apple, magic link, passkey) — one at a time
Deprecated: Mode 1 interim as end state; 7-day soak for Tier 2 (Ryan chose 24–48h).
STOP — Tier 2 closed¶
| Item | Status |
|---|---|
| Mode 2A execution | COMPLETE (2026-08-26) |
| Sovereign production | https://auth.last1.id |
| Legacy Railway + hosted Supabase | Deleted (archive retained) |
| BWS Last1.id project | 33 keys (cleaned) |
| Ops runbook | LAST1_ID_MIGRATION_RUNBOOK.md |
Next tier: Tier 3 (last1-admin) per LAST1_INFRASTRUCTURE_CONSOLIDATION_PLAN.md. Deferred items listed in Tier 2 closeout summary above.
Decisions log (formerly open questions)¶
| # | Question | Resolution (2026-08-24) |
|---|---|---|
| 1 | Tier numbering | Tier 2 = last1.id; admin → 3; RealOutcomes → 4; Onset → 5; bundle → 6 |
| 2 | Migration mode | Mode 2A (~240 h / 5–8 weeks) |
| 3 | Hostname | Split: auth.last1.id auth; last1.id marketing (Pages) |
| 4 | Google OAuth | GCP last1app, client "Last1.id" |
| 5 | Soak | 24–48h (0 users; Railway + Supabase fallback) |
| 6 | Storage | MinIO on R940 (not Supabase Storage API) |
References¶
| Document | Path |
|---|---|
| Consolidation plan | docs/architecture/LAST1_INFRASTRUCTURE_CONSOLIDATION_PLAN.md |
| Sovereign ops runbook | docs/architecture/LAST1_ID_MIGRATION_RUNBOOK.md |
| Tier 1 runbook | docs/architecture/N8N_MIGRATION_RUNBOOK.md |
| LastVet integration | last.vet-api/docs/api/LAST1_ID_INTEGRATION.md |
| last1.id repo | https://github.com/Last-1-Enterprises/last1.id |
| Linked providers doc | last1.id/docs/LINKED_PROVIDERS.md |
| OIDC integration doc | last1.id/docs/OAUTH_OIDC_INTEGRATION.md |
Audit limitations (honesty box)¶
| Source | Access |
|---|---|
| last1.id source | Shallow clone @ main |
| Railway service metadata | GraphQL (domains, service id); memory/replicas/env names not via API |
| Supabase live DB | Not accessed — schema from migrations only; row counts unverified |
| OAuth consoles | Not accessed — Google/Apple client IDs unknown |
| BWS secret values | Not read (constraint) |
| Off-repo products | Documented via consolidation plan + internal-docs sync only |
Tier 2 status: CLOSED 2026-08-26. Historical audit limitations above applied to pre-migration planning only.