Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 20 additions & 12 deletions hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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,
},
Expand Down
4 changes: 2 additions & 2 deletions hub/src/titanium-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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',
)
}
}
Expand Down
4 changes: 2 additions & 2 deletions hub/test/auth-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions hub/test/catchup-consolidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions hub/test/config-env.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(env: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> {
const prior = new Map<string, string | undefined>()
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')
},
)
})
})
2 changes: 1 addition & 1 deletion hub/test/csrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions hub/test/integration/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion hub/test/magic-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 2 additions & 2 deletions hub/test/profile-license.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion hub/test/reauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion hub/test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions hub/test/titanium-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading