CreatorTrack Auth , Scoping Plan¶
URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-auth-2026-06-18/
Status: SCOPING (security-sensitive , confirm decisions before build). 2026-06-18. Roadmap step 1. Parent: [[creatortrack-roadmap-2026-06-18]].
Finding: a build, not a wire-up¶
Current auth = Cloudflare Access only. lib/auth.ts reads the
cf-access-authenticated-user-email header (dev fallback = Justin) → core.login(email)
provisions/resolves the user + RLS identity. No NextAuth, no passwords, no in-app Google, no
sessions, no login UI, no middleware, no auth deps. That's an internal-tool gate, not public
multi-tenant signup. We build real auth and retire CF-Access-as-the-gate for the app surface.
Target¶
Auth.js (NextAuth v5), self-hosted, two providers linked by email:
- Credentials (email + password; hash with argon2/bcrypt).
- Google OAuth.
- Same email = one linked account (sign up either way, add the other later).
Sessions + users live in our core Postgres. currentUser() reads the Auth.js session instead
of the CF header; keep core.login() provisioning + the RLS identity contract intact.
Numbered tasks (each ends green)¶
- Deps + Auth.js skeleton. Add
next-auth@beta(v5) +@auth/drizzle-adapter+ argon2.auth.tsconfig with the Drizzle adapter pointed atcore. Verify: build compiles. - Schema migration (
data/workspace/migrations, Python-owned): addusers.password_hash(nullable, for Google-only accounts) + Auth.js tables (accounts,sessions,verification_token) incore, or map the adapter to them. Apply viaforge_workspace_migrate.py. Verify: tables exist; existing users unaffected. - Google provider. OAuth client (reuse the
forge-personal-assistantGCP project pattern or a new client for creatortrack.ai); redirect URIs for app.creatortrack.ai + dev. Verify: Google sign-in creates/links a user. - Credentials provider. Signup (email+password → argon2 hash) + login (verify hash). Email uniqueness; link to existing Google account on same email. Verify: signup→login round-trip.
currentUser()swap. Read the Auth.js session; drop the CF-header path (keep a guarded dev fallback ONLY whenNODE_ENV=development). RLS identity unchanged. Verify: authed requests resolve the right user; unauthed → null.- Middleware + route protection.
middleware.ts: app routes require a session → redirect to/login; marketing + auth routes public. Verify: signed-out hitting an app route → /login. - Login / signup UI (uses Tier 1 Form/Field/Button): /login, /signup, password reset request
- Google button. Verify: full flows in the browser.
- Signup → first workspace. New user → create their default workspace (the path-based
/{slug}). Verify: brand-new account lands in a usable workspace.
DECISIONS LOCKED (2026-06-18)¶
- Email verification DEFERRED. Signup/login work now; build the hooks, turn on a sender before public launch.
- DB sessions in
core(revocable, "log out everywhere", fits "own it"). Not JWT. - No CF Access on creatortrack , it's public by design (CF Access was a justinsforge internal-tool thing). Auth.js IS the gate. SECURITY REQUIREMENT: middleware must protect all app routes (unauthed → /login) BEFORE the app is reachable publicly; marketing + /login + /signup are the only public routes. No app data reachable without a session.
Out of scope¶
- Per-workspace subdomains (path-based per the roadmap).
- SSO/SAML, 2FA (future).
- The marketing one-pager (separate task; auth just needs /login + /signup).
Execution¶
Single-owner, security-sensitive → one focused Opus session, HIGH thinking, committing per task, verified live on dev :3060. NOT a fan-out.
COMPLETION NOTE (2026-06-18, done)¶
All 8 tasks built, committed per-task, and verified live on :3060. tsc clean throughout.
Key decision made at the fork (the plan didn't settle it): lib_app is NOBYPASSRLS and
core.users is RLS-locked, but Auth.js looks up users before an identity exists. Owner chose:
ONE users table (core.users + password_hash) with SECURITY DEFINER core.auth_* functions
(mirroring core.login) for the pre-identity ops, wrapped around @auth/drizzle-adapter. RLS
stays strict, no admin connection, no bypass role. The 3 plumbing tables
(auth_account/auth_session/auth_verification_token) are RLS-free, granted to lifeos_app.
Implementation deviations (forced, documented in commits):
- Email/password is a custom server action that mints a DB session + cookie, NOT the Auth.js
Credentials provider (which forces JWT and would break the LOCKED "DB sessions" decision).
- Dev fallback is OPT-IN (NODE_ENV=development AND WORKSPACE_DEV_AUTH=1, default OFF) so the
"signed-out → /login" gate is verifiable on the dev server. Plan said "dev fallback when
development"; made it opt-in so it can't silently defeat the gate.
- Next 16 renamed Middleware → proxy.ts (function proxy); route protection lives there.
Migrations (forge repo): 0033_auth.sql (columns + plumbing tables + SECURITY DEFINER user
fns), 0034_provision_workspace.sql (idempotent default-workspace provisioning).
forge-suite commits: T1 da007d5, T3 a7e98ec, T4 a071f74, T5 a7cf9ee, T6 0ae1cd8, T7 e5bdd34,
T8 1c3b63d. Secrets: ~/.forge-secrets/creatortrack-auth.env (AUTH_SECRET + Google id/secret),
chmod 600, never committed.
Security verified: unauthed app routes → 307 /login; unauthed API → 401; a FORGED session
cookie passes the optimistic proxy but currentUser()/RLS still 401s (all 21 data API routes guard
on null user); plain SELECT core.users with no identity returns 0 rows.
Deferred / follow-ups (NOT done, by design or out of scope):
- Email verification + sender still DEFERRED (reset action is a no-op hook;
allowDangerousEmailAccountLinking is only fully safe once the sender is on — flip before
public launch).
- Owner (Justin) must re-authenticate: the CF-header path is gone and he has NO password and
NO linked Google account yet. He gets in by either (a) "Continue with Google" with
[email protected] (auto-links to his existing user id 1, no dup workspace), (b) /signup with
his email to set a password (links to existing account), or (c) WORKSPACE_DEV_AUTH=1 on the
dev unit for no-login local dev. He is NOT locked out.
- Google end-to-end click-through not exercised by a real Google login (provider config verified:
correct client_id/scope/redirect). Ensure the GCP client authorizes the 192.168.86.50:3060,
dev.creatortrack.ai, and app.creatortrack.ai callback URIs.
- Pre-existing cosmetic bug surfaced by real multi-user auth: WorkspaceShell hardcodes
AccountMenu name="Justin"/plan="Owner" — now wrong for other users. Small follow-up
(pass the real display name/role through), outside this auth plan's scope.