feat: Phase 07 — Titanium Licensing auth cutover#40
Merged
Conversation
…aude session table)
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.
… + audit log + force reissue)
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
hub/src/titanium-client.ts): EdDSA JWKS verifier viajose, Redis-backed JTI blocklist,keygenAdminserver-to-server slice.hub/src/db/schema.sql,hub/src/db/dal.ts):users.titanium_subject,users.titanium_link_status,users.titanium_email_verified_at; newauth_sessions(opaque sessions) +auth_events(audit log) tables. No drops —password_hashstays for soak.hub/src/api/auth.ts,hub/src/session.ts): magic-link request/callback, opaqueSet-Cookiesessions, CSRF double-submit,requireAuthmiddleware accepts cookie OR legacy bearer whenALLOW_LEGACY_LOGIN=true.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.hub/scripts/migrate-users-to-titanium.ts): one-shot user linker with dry-run/apply, batch + rate-limit, magic-link bootstrap email, idempotent.web/src/components/LoginPage.tsx,MagicLinkCallback.tsx, license badge): replaces password form with magic-link flow; CSRF header on mutations.jtihard-fail, audit log fan-out, force-reissue on session-secret rotation./api/license/statusendpoint, doc updates..planning/phases/07-titanium-auth-cutover/{TEST-MATRIX,ROLLOUT}.md): 47-row integration smoke + cutover/rollback runbook..planning/phases/07-titanium-auth-cutover/TEMPLATE.md): extracted pattern for cutting other apps over to Titanium auth.Pre-merge ops (already executed)
MAGIC_LINK_SECRET,SESSION_SECRET,TITANIUM_REDIS_URL,ALLOW_LEGACY_LOGIN=true,TITANIUM_REQUIRE_REDIS=true— set, verified.remo-code-redisprovisioned in projectRemo Code→production, statusrunning:healthy..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.mdDual-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_SECRETrotation 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, removesbcryptdep, finalizes secret rotation. Will be auto-planned by the D14 scheduled job if soak passes.🤖 Generated with Claude Opus 4.7 (1M context)