From 40e9ef2fd33fef79eaa1419aaca1191b04669eec Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 26 May 2026 07:30:54 -0700 Subject: [PATCH] fix(hub): align Titanium env names with Keygen config --- docs/auth.md | 3 + hub/src/config.ts | 32 +++++++---- hub/src/titanium-client.ts | 4 +- hub/test/auth-middleware.test.ts | 4 +- hub/test/catchup-consolidate.test.ts | 8 +++ hub/test/config-env.test.ts | 76 ++++++++++++++++++++++++++ hub/test/csrf.test.ts | 2 +- hub/test/integration/auth-flow.test.ts | 4 +- hub/test/magic-link.test.ts | 2 +- hub/test/profile-license.test.ts | 4 +- hub/test/reauth.test.ts | 2 +- hub/test/session.test.ts | 2 +- hub/test/titanium-client.test.ts | 4 +- 13 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 hub/test/config-env.test.ts diff --git a/docs/auth.md b/docs/auth.md index 865e568..fdc4677 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -168,6 +168,9 @@ The one-shot script `hub/scripts/migrate-users-to-titanium.ts` (Plan E) backfill ### Pre-flight +- Runtime env uses the canonical Keygen-prefixed names: + `TITANIUM_KEYGEN_API_URL`, `TITANIUM_KEYGEN_ACCOUNT_ID`, + `TITANIUM_KEYGEN_PRODUCT_ID`, and `TITANIUM_KEYGEN_PORTAL_TOKEN`. - Have `TITANIUM_KEYGEN_PORTAL_TOKEN` (admin scope) in the shell env, NOT in `.env`. - Have a fresh `pg_dump` of the prod DB. - Coolify env: confirm `ALLOW_LEGACY_LOGIN=true` and the Titanium env vars are set. diff --git a/hub/src/config.ts b/hub/src/config.ts index 3b0f6e8..d776fb6 100644 --- a/hub/src/config.ts +++ b/hub/src/config.ts @@ -3,11 +3,11 @@ // New env vars validated at module-load (matches jwt.ts pattern). Boot fails // fast with a clear error naming the missing/invalid var. // -// Optional vars (TITANIUM_*): the entire titanium config block is OPTIONAL -// today. The hub stays bootable without it for the duration of Plan A — only -// when titanium-client is actually used (Plan C+) does the absence of these -// vars surface as a clear runtime error. Validation here ONLY fires when the -// var IS set (e.g. an invalid URL is rejected even if titanium is otherwise +// Optional vars (TITANIUM_KEYGEN_*): the entire titanium config block is +// OPTIONAL today. The hub stays bootable without it for the duration of Plan A. +// Only when titanium-client is actually used (Plan C+) does the absence of +// these vars surface as a clear runtime error. Validation here ONLY fires when +// the var IS set (e.g. an invalid URL is rejected even if titanium is otherwise // off). This mirrors the OPENAI_API_KEY pattern already in the file. function parseBool(v: string | undefined, dflt: boolean): boolean { @@ -48,10 +48,17 @@ const titaniumLicenseCacheTtlSeconds = parsePositiveInt( process.env.TITANIUM_LICENSE_CACHE_TTL_SECONDS, 300, ); +const titaniumAccountId = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || process.env.TITANIUM_ACCOUNT_ID || ""; +const titaniumProductId = process.env.TITANIUM_KEYGEN_PRODUCT_ID || process.env.TITANIUM_PRODUCT_ID || ""; +const titaniumPortalToken = process.env.TITANIUM_KEYGEN_PORTAL_TOKEN || process.env.TITANIUM_PORTAL_TOKEN || ""; +const titaniumAdminToken = + process.env.TITANIUM_KEYGEN_ADMIN_TOKEN || + process.env.TITANIUM_ADMIN_TOKEN || + titaniumPortalToken; const magicLinkSecret = requireMinLenIfSet("MAGIC_LINK_SECRET", process.env.MAGIC_LINK_SECRET, 32); const sessionSecret = requireMinLenIfSet("SESSION_SECRET", process.env.SESSION_SECRET, 32); const allowLegacyLogin = parseBool(process.env.ALLOW_LEGACY_LOGIN, true); -// Optional Titanium → hub webhook for license-state changes. Inert (route +// Optional Titanium -> hub webhook for license-state changes. Inert (route // returns 503) until Titanium ships the webhook and the secret is provisioned. const titaniumWebhookSecret = requireMinLenIfSet( "TITANIUM_WEBHOOK_SECRET", @@ -73,12 +80,13 @@ export const config = { // verifyLicenseJwt) must assert non-empty themselves and emit a clear error. titanium: { keygenApiUrl: titaniumKeygenApiUrl, - accountId: process.env.TITANIUM_ACCOUNT_ID || "", - productId: process.env.TITANIUM_PRODUCT_ID || "", - portalToken: process.env.TITANIUM_PORTAL_TOKEN || "", - // ADMIN_TOKEN is script-time only (migration job). Runtime callers MUST - // NOT depend on it. See 07-CONTEXT.md §specifics. - adminToken: process.env.TITANIUM_ADMIN_TOKEN || "", + accountId: titaniumAccountId, + productId: titaniumProductId, + portalToken: titaniumPortalToken, + // Admin token is script-time only (migration job). Runtime callers MUST + // NOT depend on it. The portal token is accepted as a fallback because the + // migration runbook provisions TITANIUM_KEYGEN_PORTAL_TOKEN with admin scope. + adminToken: titaniumAdminToken, redisUrl: process.env.TITANIUM_REDIS_URL || "", licenseCacheTtlSeconds: titaniumLicenseCacheTtlSeconds, }, diff --git a/hub/src/titanium-client.ts b/hub/src/titanium-client.ts index 45b0465..09c6ea3 100644 --- a/hub/src/titanium-client.ts +++ b/hub/src/titanium-client.ts @@ -10,7 +10,7 @@ * via jose's `createRemoteJWKSet` (in-memory cache + single-flight + * refetch on `kid` miss). Warmed during hub bootstrap BEFORE port bind. * - Claims pinned: `iss == TITANIUM_KEYGEN_API_URL`, `aud` includes - * `TITANIUM_PRODUCT_ID`, `exp/nbf/iat` (±30s skew). + * `TITANIUM_KEYGEN_PRODUCT_ID`, `exp/nbf/iat` (±30s skew). * - Revocation: Redis SISMEMBER on `titanium:blocklist` — checked on every * verify. Real-time. No cache. * @@ -102,7 +102,7 @@ function assertTitaniumConfigured(): void { if (!config.titanium.keygenApiUrl || !config.titanium.accountId || !config.titanium.productId) { throw new TitaniumVerifyError( 'config', - 'Titanium config missing: TITANIUM_KEYGEN_API_URL, TITANIUM_ACCOUNT_ID, TITANIUM_PRODUCT_ID required', + 'Titanium config missing: TITANIUM_KEYGEN_API_URL, TITANIUM_KEYGEN_ACCOUNT_ID, TITANIUM_KEYGEN_PRODUCT_ID required', ) } } diff --git a/hub/test/auth-middleware.test.ts b/hub/test/auth-middleware.test.ts index 8976418..7e082ea 100644 --- a/hub/test/auth-middleware.test.ts +++ b/hub/test/auth-middleware.test.ts @@ -5,8 +5,8 @@ process.env.MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET || 'magic-link-sec // Pre-set TITANIUM_* envs so a later-loaded titanium-client test in the same // bun process sees a configured config (config.ts captures env at module-load). process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us'; -process.env.TITANIUM_ACCOUNT_ID = process.env.TITANIUM_ACCOUNT_ID || 'acct_test_0000000000'; -process.env.TITANIUM_PRODUCT_ID = process.env.TITANIUM_PRODUCT_ID || 'prod_test_remo'; +process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || 'acct_test_0000000000'; +process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.TITANIUM_KEYGEN_PRODUCT_ID || 'prod_test_remo'; import { describe, test, expect, mock, beforeEach } from 'bun:test'; import { Hono } from 'hono'; diff --git a/hub/test/catchup-consolidate.test.ts b/hub/test/catchup-consolidate.test.ts index 3038241..7b3fe44 100644 --- a/hub/test/catchup-consolidate.test.ts +++ b/hub/test/catchup-consolidate.test.ts @@ -22,6 +22,14 @@ mock.module('../src/db/scheduled-tasks-dal.ts', () => ({ calls.push(input) return { id: `run_${calls.length}`, ...input } }, + getRun: async (_runId: string, _userId: string) => ({ + id: 'run_1', + user_id: 'user_1', + application_uuid: 'app-abc', + deployment_uuid: 'deploy-xyz', + git_repository: 'finedesignz/remo-code', + commit_sha: 'abc123', + }), })) // Import AFTER the mock so the module binds to the stub. diff --git a/hub/test/config-env.test.ts b/hub/test/config-env.test.ts new file mode 100644 index 0000000..0b3db6c --- /dev/null +++ b/hub/test/config-env.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test' + +const KEYS = [ + 'TITANIUM_KEYGEN_ACCOUNT_ID', + 'TITANIUM_ACCOUNT_ID', + 'TITANIUM_KEYGEN_PRODUCT_ID', + 'TITANIUM_PRODUCT_ID', + 'TITANIUM_KEYGEN_PORTAL_TOKEN', + 'TITANIUM_PORTAL_TOKEN', + 'TITANIUM_KEYGEN_ADMIN_TOKEN', + 'TITANIUM_ADMIN_TOKEN', +] as const + +async function withEnv(env: Record, fn: () => Promise): Promise { + const prior = new Map() + for (const key of KEYS) { + prior.set(key, process.env[key]) + delete process.env[key] + } + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + try { + return await fn() + } finally { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + +async function importConfig(caseName: string) { + return await import(`../src/config.ts?${caseName}-${Date.now()}-${Math.random()}`) +} + +describe('Titanium env config', () => { + test('prefers canonical TITANIUM_KEYGEN_* env names', async () => { + await withEnv( + { + TITANIUM_KEYGEN_ACCOUNT_ID: 'acct_keygen', + TITANIUM_ACCOUNT_ID: 'acct_legacy', + TITANIUM_KEYGEN_PRODUCT_ID: 'prod_keygen', + TITANIUM_PRODUCT_ID: 'prod_legacy', + TITANIUM_KEYGEN_PORTAL_TOKEN: 'portal_keygen', + TITANIUM_PORTAL_TOKEN: 'portal_legacy', + }, + async () => { + const { config } = await importConfig('canonical') + expect(config.titanium.accountId).toBe('acct_keygen') + expect(config.titanium.productId).toBe('prod_keygen') + expect(config.titanium.portalToken).toBe('portal_keygen') + expect(config.titanium.adminToken).toBe('portal_keygen') + }, + ) + }) + + test('keeps legacy unprefixed names as fallback', async () => { + await withEnv( + { + TITANIUM_ACCOUNT_ID: 'acct_legacy', + TITANIUM_PRODUCT_ID: 'prod_legacy', + TITANIUM_PORTAL_TOKEN: 'portal_legacy', + TITANIUM_ADMIN_TOKEN: 'admin_legacy', + }, + async () => { + const { config } = await importConfig('legacy') + expect(config.titanium.accountId).toBe('acct_legacy') + expect(config.titanium.productId).toBe('prod_legacy') + expect(config.titanium.portalToken).toBe('portal_legacy') + expect(config.titanium.adminToken).toBe('admin_legacy') + }, + ) + }) +}) diff --git a/hub/test/csrf.test.ts b/hub/test/csrf.test.ts index 973981f..7ae072e 100644 --- a/hub/test/csrf.test.ts +++ b/hub/test/csrf.test.ts @@ -2,7 +2,7 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-chars-long-aaaaaaaa'; process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'session-secret-at-least-32-chars-long-x'; process.env.MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET || 'magic-link-secret-at-least-32-chars-x'; -process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_ACCOUNT_ID = process.env.TITANIUM_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_PRODUCT_ID = process.env.TITANIUM_PRODUCT_ID || 'prod_test_remo'; +process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.TITANIUM_KEYGEN_PRODUCT_ID || 'prod_test_remo'; import { describe, test, expect } from 'bun:test'; import { Hono } from 'hono'; diff --git a/hub/test/integration/auth-flow.test.ts b/hub/test/integration/auth-flow.test.ts index 96cdc0b..b9078ec 100644 --- a/hub/test/integration/auth-flow.test.ts +++ b/hub/test/integration/auth-flow.test.ts @@ -50,9 +50,9 @@ if (!READY) { // Wire test env BEFORE importing the hub so module-load-time validation passes. process.env.DATABASE_URL = process.env.REMO_E2E_DB_URL!; process.env.TITANIUM_KEYGEN_API_URL = process.env.REMO_E2E_KEYGEN_URL!; - process.env.TITANIUM_ACCOUNT_ID = + process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.REMO_E2E_KEYGEN_ACCOUNT || 'acct_e2e_placeholder'; - process.env.TITANIUM_PRODUCT_ID = + process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.REMO_E2E_KEYGEN_PRODUCT || 'prod_e2e_placeholder'; process.env.JWT_SECRET = process.env.JWT_SECRET || 'e2e-jwt-secret-at-least-32-chars-long-x'; diff --git a/hub/test/magic-link.test.ts b/hub/test/magic-link.test.ts index 9a7bfd4..5930db8 100644 --- a/hub/test/magic-link.test.ts +++ b/hub/test/magic-link.test.ts @@ -2,7 +2,7 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-chars-long-aaaaaaaa'; process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'session-secret-at-least-32-chars-long-x'; process.env.MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET || 'magic-link-secret-at-least-32-chars-x'; -process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_ACCOUNT_ID = process.env.TITANIUM_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_PRODUCT_ID = process.env.TITANIUM_PRODUCT_ID || 'prod_test_remo'; +process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.TITANIUM_KEYGEN_PRODUCT_ID || 'prod_test_remo'; import { describe, test, expect, beforeAll } from 'bun:test'; diff --git a/hub/test/profile-license.test.ts b/hub/test/profile-license.test.ts index 3ea0b52..5df5cd1 100644 --- a/hub/test/profile-license.test.ts +++ b/hub/test/profile-license.test.ts @@ -11,8 +11,8 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-char process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'session-secret-at-least-32-chars-long-x'; process.env.MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET || 'magic-link-secret-at-least-32-chars-x'; process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us'; -process.env.TITANIUM_ACCOUNT_ID = process.env.TITANIUM_ACCOUNT_ID || 'acct_test_0000000000'; -process.env.TITANIUM_PRODUCT_ID = process.env.TITANIUM_PRODUCT_ID || 'prod_test_remo'; +process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || 'acct_test_0000000000'; +process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.TITANIUM_KEYGEN_PRODUCT_ID || 'prod_test_remo'; import { describe, test, expect, beforeEach } from 'bun:test'; import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; diff --git a/hub/test/reauth.test.ts b/hub/test/reauth.test.ts index cbe3eb9..5e10452 100644 --- a/hub/test/reauth.test.ts +++ b/hub/test/reauth.test.ts @@ -2,7 +2,7 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-chars-long-aaaaaaaa'; process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'session-secret-at-least-32-chars-long-x'; process.env.MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET || 'magic-link-secret-at-least-32-chars-x'; -process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_ACCOUNT_ID = process.env.TITANIUM_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_PRODUCT_ID = process.env.TITANIUM_PRODUCT_ID || 'prod_test_remo'; +process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.TITANIUM_KEYGEN_PRODUCT_ID || 'prod_test_remo'; import { describe, test, expect, mock, beforeEach } from 'bun:test'; import { Hono } from 'hono'; diff --git a/hub/test/session.test.ts b/hub/test/session.test.ts index b00e6c2..ea87959 100644 --- a/hub/test/session.test.ts +++ b/hub/test/session.test.ts @@ -3,7 +3,7 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-chars-long-aaaaaaaa'; process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'session-secret-at-least-32-chars-long-x'; process.env.MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET || 'magic-link-secret-at-least-32-chars-x'; -process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_ACCOUNT_ID = process.env.TITANIUM_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_PRODUCT_ID = process.env.TITANIUM_PRODUCT_ID || 'prod_test_remo'; +process.env.TITANIUM_KEYGEN_API_URL = process.env.TITANIUM_KEYGEN_API_URL || 'https://keygen.titaniumlabs.us';process.env.TITANIUM_KEYGEN_ACCOUNT_ID = process.env.TITANIUM_KEYGEN_ACCOUNT_ID || 'acct_test_0000000000';process.env.TITANIUM_KEYGEN_PRODUCT_ID = process.env.TITANIUM_KEYGEN_PRODUCT_ID || 'prod_test_remo'; if (process.env.REMO_E2E_DB_URL) process.env.DATABASE_URL = process.env.REMO_E2E_DB_URL; import { describe, test, expect } from 'bun:test'; diff --git a/hub/test/titanium-client.test.ts b/hub/test/titanium-client.test.ts index 6cd90fe..2072a33 100644 --- a/hub/test/titanium-client.test.ts +++ b/hub/test/titanium-client.test.ts @@ -23,8 +23,8 @@ import { importJWK } from 'jose' const ISSUER = 'https://keygen.titaniumlabs.us' const PRODUCT_ID = 'prod_test_remo' process.env.TITANIUM_KEYGEN_API_URL = ISSUER -process.env.TITANIUM_ACCOUNT_ID = 'acct_test_0000000000' -process.env.TITANIUM_PRODUCT_ID = PRODUCT_ID +process.env.TITANIUM_KEYGEN_ACCOUNT_ID = 'acct_test_0000000000' +process.env.TITANIUM_KEYGEN_PRODUCT_ID = PRODUCT_ID const { verifyLicenseJwt,