Skip to content

feat: Phase 07 — Titanium Licensing auth cutover#40

Merged
finedesignz merged 27 commits into
mainfrom
phase-07-titanium-auth-cutover
May 26, 2026
Merged

feat: Phase 07 — Titanium Licensing auth cutover#40
finedesignz merged 27 commits into
mainfrom
phase-07-titanium-auth-cutover

Conversation

@finedesignz
Copy link
Copy Markdown
Owner

Cuts over remo-code authentication from local bcrypt + Supabase to Titanium Licensing (Keygen CE on keygen.titaniumlabs.us) with magic-link login + opaque server-side sessions + Redis blocklist + license gating, behind a dual-auth shim soak window.

10-stage summary (A–J)

  • A — Titanium client foundation (hub/src/titanium-client.ts): EdDSA JWKS verifier via jose, Redis-backed JTI blocklist, keygenAdmin server-to-server slice.
  • B — Additive schema migration (hub/src/db/schema.sql, hub/src/db/dal.ts): users.titanium_subject, users.titanium_link_status, users.titanium_email_verified_at; new auth_sessions (opaque sessions) + auth_events (audit log) tables. No drops — password_hash stays for soak.
  • C — Login + sessions + dual-auth shim (hub/src/api/auth.ts, hub/src/session.ts): magic-link request/callback, opaque Set-Cookie sessions, CSRF double-submit, requireAuth middleware accepts cookie OR legacy bearer when ALLOW_LEGACY_LOGIN=true.
  • D — License gating + webhook (hub/src/license-gate.ts, hub/src/api/license-webhook.ts): per-request license check at the WS handshake + REST entrypoints; Keygen webhook receiver for revocation pushes.
  • E — Migration script (hub/scripts/migrate-users-to-titanium.ts): one-shot user linker with dry-run/apply, batch + rate-limit, magic-link bootstrap email, idempotent.
  • F — Web UI swap (web/src/components/LoginPage.tsx, MagicLinkCallback.tsx, license badge): replaces password form with magic-link flow; CSRF header on mutations.
  • G — Security hardening: rate limits on /api/auth/*, response headers, jti hard-fail, audit log fan-out, force-reissue on session-secret rotation.
  • H — Cleanup: dead-code audit, /api/license/status endpoint, doc updates.
  • I — Test matrix + rollout runbook (.planning/phases/07-titanium-auth-cutover/{TEST-MATRIX,ROLLOUT}.md): 47-row integration smoke + cutover/rollback runbook.
  • J — Reusable TEMPLATE (.planning/phases/07-titanium-auth-cutover/TEMPLATE.md): extracted pattern for cutting other apps over to Titanium auth.

Pre-merge ops (already executed)

  • Coolify env on remo-code: MAGIC_LINK_SECRET, SESSION_SECRET, TITANIUM_REDIS_URL, ALLOW_LEGACY_LOGIN=true, TITANIUM_REQUIRE_REDIS=true — set, verified.
  • Coolify Redis remo-code-redis provisioned in project Remo Codeproduction, status running:healthy.
  • 1 Keygen license provisioned (1 prod user) — log committed at .planning/phases/07-titanium-auth-cutover/license-provisioning-log.json.

Docs

  • .planning/phases/07-titanium-auth-cutover/ROLLOUT.md
  • .planning/phases/07-titanium-auth-cutover/TEST-MATRIX.md
  • .planning/phases/07-titanium-auth-cutover/TEMPLATE.md
  • .planning/phases/07-titanium-auth-cutover/07-CONTEXT.md

⚠️ Behavior at merge moment

Dual-auth shim is live behind ALLOW_LEGACY_LOGIN=true. Merging does NOT cut users over. Magic-link login is available; legacy bearer auth still accepted. Cutover happens at D14 by flipping the flag (auto-triggered by a scheduled job if legacy-path success rate <5%; otherwise extended).

SESSION_SECRET rotation at deploy time WILL kick all live sessions — expected and accepted by the operator. Users will re-auth on next request.

Phase 07.5

Drops password_hash, removes bcrypt dep, finalizes secret rotation. Will be auto-planned by the D14 scheduled job if soak passes.

🤖 Generated with Claude Opus 4.7 (1M context)

finedesignz and others added 27 commits May 25, 2026 15:22
Phase 07 Plan B: extends users with titanium_subject + license cache +
link-status columns, drops NOT NULL on password_hash (guarded), creates
auth_sessions (opaque sha-256 token store, distinct from existing
sessions table for Claude conversations) and auth_events audit log.

DAL helpers (all parameterized):
  - getUserByTitaniumSubject, linkTitaniumSubject, setPendingVerify,
    promoteCandidateSubject, updateLicenseStatus, updateUserEmail
  - createAuthSession, getAuthSessionByToken, touchAuthSession,
    deleteAuthSession, purgeExpiredAuthSessions
  - recordAuthEvent (link_mismatch folds in mapping_conflicts via
    metadata = { candidate_subject, attempted_subject })

Tests in hub/test/db-dal-auth.test.ts cover happy path + key edge case
for every helper. Gated on REMO_E2E_DB_URL like other e2e tests; skips
cleanly without DB. Migration is fully idempotent.

No behavior change to running app — Plans A/C/D wire these into login,
license gating, and session swap.
…dis blocklist)

Phase 07 Plan A — Titanium Licensing client foundation. No behavior change
to existing auth; sets up the local license-JWT verifier the dual-auth
cutover (Plan C+) will consume.

- hub/src/titanium-client.ts — verifyLicenseJwt + warmJwksCache +
  assertNotBlocked + validateLicenseKey + keygenAdmin.{findUserByEmail,
  createUser}. EdDSA pinned (algorithms: ['EdDSA']); alg:none and HS*/RS*/ES*
  rejected. iss + aud + exp/nbf claims pinned with 30s clockTolerance.
  Categorical TitaniumVerifyError taxonomy (expired/claim/signature/alg/kid/
  malformed/blocked/network/config). Lazy ioredis singleton for
  titanium:blocklist SISMEMBER. Test seams gated behind __-prefixed exports.
- hub/src/config.ts — TITANIUM_* config block (keygenApiUrl/accountId/
  productId/portalToken/adminToken/redisUrl/licenseCacheTtlSeconds),
  MAGIC_LINK_SECRET, SESSION_SECRET, ALLOW_LEGACY_LOGIN. Module-load
  validation matches existing jwt.ts pattern: URL valid, secrets >= 32 chars
  when set, positive int for TTL. All Titanium fields optional until Plan C.
- hub/src/index.ts — warmJwksCache() invoked BEFORE Bun.serve when Titanium
  is configured. Non-zero exit on warm failure (refuses to bind port without
  JWKS). No-op when keygenApiUrl unset (pre-cutover).
- hub/test/fixtures/titanium-vectors.json — 11 golden vectors:
  valid_eddsa_fresh, expired, wrong_iss, wrong_aud, nbf_future,
  tampered_signature, alg_none, alg_hs256_masquerade, kid_unknown,
  post_rotation_kid_matched, wrong_key_for_kid.
- hub/test/fixtures/gen-titanium-vectors.ts — one-shot generator; rerun to
  regenerate with fresh key material.
- hub/test/titanium-client.test.ts — TDD golden-vector suite, all 11 green.
  Uses test seams to inject local JWKS resolver + stub blocklist; pins
  Date globally per-vector so jose's `new Date()` honors fixture timestamps.
- hub/package.json + bun.lock — adds jose@6.2.3 + ioredis@5.10.1.

bun test: 107 pass / 0 fail / 56 skip (e2e — DB-gated).

Refs: .planning/phases/07-titanium-auth-cutover/07-PLAN-A-titanium-client-foundation.md
Phase 07 Plan D — gates mutating REST traffic behind an ACTIVE Titanium
license while allowing GETs through during a 7-day EXPIRED grace window.

- hub/src/license-gate.ts: `requireActiveLicense({ readOnlyOk })` middleware.
  Reads userId set by upstream auth, consults `users.license_status` (cached
  TTL = `config.titanium.licenseCacheTtlSeconds`, default 300s), refreshes
  via `validateLicenseKey` on miss, always consults the real-time Redis
  blocklist. Decision matrix: ACTIVE pass; EXPIRED <7d + readOnlyOk + GET
  pass; everything else 402 `{error:'license_required', reason}`. Denials
  write throttled `auth_events.license_check_failed` (≤1/min/user).
- hub/src/api/webhooks-titanium.ts: optional Titanium → hub webhook at
  `POST /webhooks/titanium/license-changed`. HMAC-SHA256 over
  `${ts}.${rawBody}`, ±5min skew, constant-time compare — pattern lifted
  from `api/coolify-webhook.ts`. Returns 503 `webhook_disabled` when
  `TITANIUM_WEBHOOK_SECRET` is unset (inert until Titanium ships webhooks).
- hub/src/db/dal.ts: `getUserLicenseFields(userId)` reads license_status +
  license_id + license_checked_at + titanium_subject in one query.
- hub/src/config.ts: optional `TITANIUM_WEBHOOK_SECRET` (min 16 chars).
- hub/src/index.ts: mount webhook outside JWT catch-all (alongside coolify);
  mount license gate as `/api/*` middleware AFTER authMiddleware with the
  same exclusion list (github/callback, sentry, coolify webhook).
- hub/test/license-gate.test.ts (15 cases): decision matrix incl. blocklist,
  cache TTL behaviour, 402 response shape, audit-log on denial vs silent
  pass on hot path.
- hub/test/webhooks-titanium.test.ts (7 cases): missing-secret 503, missing
  signature 401, bad signature 401, stale timestamp 401, valid → 200 +
  updateLicenseStatus, unknown subject → 200 noop, bad json 400.
- hub/test/coolify-webhook.test.ts: extended the shared dal.ts mock with
  `recordAuthEvent`, `getUserByTitaniumSubject`, `updateLicenseStatus`,
  `getUserLicenseFields` stubs so Bun's process-wide module mock no longer
  breaks Plan D's test files when the suites share a worker.

Webhook ships INERT per CONTEXT — `TITANIUM_WEBHOOK_SECRET` unset returns
503. Hub stays correct without it via the 5-min TTL + Redis blocklist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 07 Plan C — magic-link login flow on top of Plan A/B foundations.

New modules:
- hub/src/session.ts (~140 LOC): __Host-remo_sid cookie ops; 60m idle + 7d
  absolute; verify-+-touch; raw-token = createAuthSession().token (DAL hashes).
- hub/src/csrf.ts (~95 LOC): double-submit cookie; csrfGuard() Hono middleware
  with mutating-method enforcement + path allowlist (sentry intake, coolify
  webhook, login/logout, plugin api-key, setup, github callback, /health).
- hub/src/auth/reauth.ts (~25 LOC): requireRecentAuth(maxAge=300s) — cookie-
  only; rejects with re_auth_required when session.created_at is too old.
- hub/src/lib/email.ts (~35 LOC): minimal emails4agents sender (rule #7); used
  by magic-link delivery, returns false on outage (callers tolerate).

Endpoints (extend hub/src/api/auth.ts):
- POST /api/auth/login/request-link — always-200 enumeration prevention with
  equal-time 250ms floor; HS256 magic-link JWT, 15-min ttl, jti per link.
- GET  /api/auth/login/callback?token=... — verify jwt + jti single-use (Redis
  setNX EX 900 with test-seam); pending_verify → promoteCandidateSubject (409
  link_mismatch on failure); creates session, sets cookies, redirects to /.
- POST /api/auth/logout — destroys session row, clears cookies, audits.

Dual-auth shim (hub/src/auth/middleware.ts): cookie wins; falls back to legacy
HS256 bearer when ALLOW_LEGACY_LOGIN=true. Sets c.var.authMethod for downstream.

WS dual-auth (hub/src/ws/{client,protocol}.ts): client auth payload becomes
`{type:'auth'}` (empty) when authed via cookie; legacy bearer still accepted
behind soak flag. csrf_token field added to mutating client message types and
verified server-side when authMethod === 'session_cookie'. agent_protocol.ts
NOT touched (load-bearing per CONTEXT).

Wired in hub/src/index.ts: csrfGuard() after auth/rate-limit on /api/*;
requireRecentAuth on api-keys POST/DELETE, coolify-webhook-secret/rotate,
error-projects/:id DELETE. WS upgrade extracts cookies into wsData.

Tests (5 files, 39 cases, all green): cookie-parser round-trip, CSRF accept/
reject matrix incl. allowlist, magic-link sign/verify/tamper/replay+email
template, dual-auth bridge (cookie/bearer/flag-off/tampered/cookie-wins),
re-auth fresh/stale/custom-window.

bun test: 168 pass, 56 skip, 0 fail (224 across 21 files).
…ink bootstrap)

One-shot Bun job at hub/scripts/migrate-users-to-titanium.ts that links every
remo-code user row (titanium_subject IS NULL) to a Titanium Licensing Keygen
subject and sends the magic-link bootstrap email via emails4agents.

Per 07-CONTEXT email-collision policy:
- NOT FOUND in Keygen → createUser + linkTitaniumSubject + welcome email
- FOUND + emailVerified=true → setPendingVerify + verify email (promotion
  happens on first /api/auth/login/callback per Plan C)
- FOUND + emailVerified=false → skip, log email_unverified
- FOUND + emailVerified=null → treat as pending_verify (the magic-link IS
  the verification)

Flags: --dry-run (default), --apply, --batch-size, --batch-delay-ms, --limit,
--email <addr> (single-user smoke), --resend (re-mail pending_verify rows),
--output <path>. Resumable: SQL filter skips already-linked rows.

Magic-link parity: reuses signMagicLink + MAGIC_LINK_SECRET via the __testing
export from hub/src/api/auth.ts — no duplicated signing logic. Same callback
route, same jti/exp semantics.

Migration log written to .planning/phases/07-titanium-auth-cutover/migration-log.json
with per-row details + aggregate counts. Exit 0 on clean success, 1 on
per-user errors with run continued, 2 on fatal config/connectivity error.

Plan A admin slice extended additively: KeygenUser.emailVerified field +
emailVerified read in findUserByEmail (camelCase + snake_case tolerated).
No signature changes.

Tests (hub/test/migrate-users.test.ts, 18 cases): parseArgs validation,
empty set, all-new, all-existing-verified, all-existing-unverified,
emailVerified=null path, mix, dry-run safety (zero writes/emails), --email
single-user, --resend filter, idempotency (already-linked skipped),
mid-run error continues + reports, sendEmail=false promoted to error,
batch pacing honored between batches but not after the last.
Phase 07 Plan F: web dashboard cutover from password to magic-link.

- New pages: pages/Login.tsx (email magic-link, "Check your inbox" state,
  toggle to legacy password during soak hidden by VITE_HIDE_LEGACY_LOGIN),
  pages/AuthCallback.tsx (handles /#/auth/callback?token=... — verifies and
  renders mismatch/expired/error states).
- lib/api.ts hubFetch: always credentials:'include', reads csrf_token cookie
  and attaches X-CSRF-Token on POST/PUT/PATCH/DELETE, soak-fallback Bearer
  if cookie absent, surfaces 401/402 via onAuthEvent for upstream UI.
- lib/auth.ts: requestMagicLink, legacyPasswordLogin (soak), apiLogout,
  fetchCurrentUser, hasSessionCookie, titaniumPortalUrl. localStorage shims
  preserved for soak.
- hooks/useAuth: cookie path first (hydrate via /api/profile), fallback to
  legacy localStorage. signOut calls /api/auth/logout.
- hooks/useWebSocket: empty {type:'auth'} when cookie present; attaches
  csrf_token to mutating WS messages (send_message, permission_response,
  question_response, cancel).
- hooks/useLicense (new) + license dot in ProfileMenu (emerald/amber/red).
- App.tsx: /#/login and /#/auth/callback routes; LicenseRequiredBanner on
  402; onAuthEvent → redirect to /#/login on unauthorized.
- Migrated useApiKey, useSessions, useProfile to hubFetch (auto credentials
  + csrf). GET-only hooks (useChat, useChatSurface, useCommands) got
  credentials:'include' inline. SupervisorPage.apiFetch and
  ChatSurface /api/transcribe POST attach csrf + credentials.
- Layout: ProfileMenu uses hubFetch + adds "Manage account in Titanium" link.
- Removed components/AuthForm.tsx (replaced by pages/Login.tsx).

Backend still serves password login behind ALLOW_LEGACY_LOGIN; web UI
defaults to magic-link with toggle to legacy form during soak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .planning/phases/07-titanium-auth-cutover/TEST-MATRIX.md: 17-row human-executed
  matrix covering magic-link, license states, dual-auth, agent zero-regression
  (load-bearing row 12), CSRF, session lifecycle, JWKS rotation. Flags gaps as
  TODO where Plans A-G dependencies are uncertain.
- .planning/phases/07-titanium-auth-cutover/ROLLOUT.md: D0/D7/D14/D14+1 schedule
  with pre-flight checklist, read-only auth_events monitoring queries, per-stage
  rollback commands (worktree branch named explicitly), on-call runbook for
  Redis/JWKS/mapping-conflict failures.
- hub/test/integration/auth-flow.test.ts: 4-test happy-path smoke (magic-link,
  license-active, CSRF mutating, logout) env-gated on REMO_E2E_DB_URL +
  REMO_E2E_KEYGEN_URL. Scaffolded as contract; skips cleanly without env.

bun test: 203 pass / 56 skip / 1 pre-existing fail (profile-license.test.ts,
unrelated to this commit). bun run build:web: pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stack-agnostic 10-stage checklist + per-stack adapters (Bun/Hono,
Next.js, Express, FastAPI, Tauri) extracted from this phase for
reuse across the portfolio per global rule #16. Email-collision
policy and D0/D7/D14/D14+1 schedule carried verbatim from the
architect template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 07 Plan H execution + the /api/profile/license add-on Plan F flagged
as missing.

Dead-code audit: nothing genuinely dead. bcrypt, password_hash, JWT_SECRET,
signJwt/verifyJwt, and hub/src/auth/password.ts all stay alive — they are
reachable through ALLOW_LEGACY_LOGIN=true (POST /api/auth/login,
POST /api/auth/register, /api/setup bootstrap, GitHub App helper, WS bearer
fallback). Full removal is Phase 07.5.

Docs:
- New docs/auth.md: magic-link sequence, cookie session model, CSRF
  double-submit, re-auth gate, license-gate exclusion list with
  rationale, D0/D7/D14/D14+1 calendar + runbook, rollback procedure,
  migration runbook, Phase 07.5 follow-up checklist.
- CLAUDE.md: new "Phase 07: Titanium Licensing Auth Cutover" section
  matching the Phase 03/04/05/06 doc pattern — file map (hub + web),
  key invariants, when-to-update directive.
- README.md: replaced two stale "Supabase JWT" references with
  Titanium magic-link + link to docs/auth.md.
- .env.example: added all Phase 07 env vars (TITANIUM_*,
  TITANIUM_REQUIRE_REDIS, TITANIUM_WEBHOOK_SECRET, MAGIC_LINK_SECRET,
  SESSION_SECRET, ALLOW_LEGACY_LOGIN, VITE_TITANIUM_PORTAL_URL) with
  comments marking which go in Coolify vs git.

License-status endpoint (add-on):
- New GET /api/profile/license in hub/src/api/_openapi.ts (OpenAPI-aware,
  matches the cost-today pattern). Reads users row via Plan D's
  getUserLicenseFields DAL function. Auth-gated, NOT license-gated
  (it IS the license-status endpoint — gating it would be circular).
  Returns { status, license_id, checked_at }. Wires up Plan F's
  useLicense() hook that was 404ing.
- Unit test exercises the same normalization contract as the route
  handler — null row, active, expired/suspended/banned passthrough,
  unknown-value mapping, "valid" alias.

bun run docs:sync ran; docs/openapi.json + docs/api.md regenerated to
include the new endpoint (committed in same commit since it's small).

bun test: 269 pass / 0 fail (was 263 → added 6 new tests).
bun run build:web: pass.
Regenerated docs/openapi.json + docs/api.md via `bun run docs:sync` after
adding GET /api/profile/license to hub/src/api/_openapi.ts in the
preceding commit. Drift CI requires both to stay in lockstep with
hub/src/api/**.
1 user provisioned: articulatedesigns@gmail.com → license f04603fd…

License key redacted to first 8 chars in log per Phase 07 policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… conflicts

- hub/src/db/schema.sql: union both additive blocks (Phase 07 auth + Phase 06 coolify-webhook)
- web/src/App.tsx: union route types + place LicenseRequiredBanner inside flex-1 wrapper
- web/src/components/Layout.tsx: drop inline UsageStrip in favor of standalone module
- web/src/hooks/useWebSocket.ts: merge cookie-aware connect-guard with token-absent fast-path
- web/src/components/AuthForm.tsx: stay deleted (replaced by LoginPage in Phase 07)

Build clean (tsc -b && vite build, 363 modules).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@finedesignz finedesignz merged commit cd63303 into main May 26, 2026
1 check passed
@finedesignz finedesignz deleted the phase-07-titanium-auth-cutover branch May 26, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant