Skip to content

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)

  1. Deps + Auth.js skeleton. Add next-auth@beta (v5) + @auth/drizzle-adapter + argon2. auth.ts config with the Drizzle adapter pointed at core. Verify: build compiles.
  2. Schema migration (data/workspace/migrations, Python-owned): add users.password_hash (nullable, for Google-only accounts) + Auth.js tables (accounts, sessions, verification_token) in core, or map the adapter to them. Apply via forge_workspace_migrate.py. Verify: tables exist; existing users unaffected.
  3. Google provider. OAuth client (reuse the forge-personal-assistant GCP project pattern or a new client for creatortrack.ai); redirect URIs for app.creatortrack.ai + dev. Verify: Google sign-in creates/links a user.
  4. 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.
  5. currentUser() swap. Read the Auth.js session; drop the CF-header path (keep a guarded dev fallback ONLY when NODE_ENV=development). RLS identity unchanged. Verify: authed requests resolve the right user; unauthed → null.
  6. 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.
  7. Login / signup UI (uses Tier 1 Form/Field/Button): /login, /signup, password reset request
  8. Google button. Verify: full flows in the browser.
  9. 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)

  1. Email verification DEFERRED. Signup/login work now; build the hooks, turn on a sender before public launch.
  2. DB sessions in core (revocable, "log out everywhere", fits "own it"). Not JWT.
  3. 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.