#!/usr/bin/env tsx /** * tools/federation-harness/seed.ts * * Provisions test data for the two-gateway federation harness. * Run via: tsx tools/federation-harness/seed.ts * * What this script does: * 1. (Optional) Boots the compose stack if --boot flag is passed. * 2. Waits for both gateways to be healthy. * 3. Bootstraps an admin user + token on each gateway via POST /api/bootstrap/setup. * 4. Creates three grants on Server B matching the M3 acceptance test scenarios: * - Scope variant A: tasks + notes, include_personal: true * - Scope variant B: tasks only, include_teams: ['T1'], exclude T2 * - Scope variant C: tasks + credentials in resources, credentials excluded (sanity) * 5. For each grant, walks the full enrollment flow: * a. Server B creates a peer keypair (represents the requesting side). * b. Server B creates the grant referencing that peer. * c. Server B issues an enrollment token. * d. Server A creates its own peer keypair (represents its view of B). * e. Server A redeems the enrollment token at Server B's enrollment endpoint, * submitting A's CSR → receives signed cert back. * f. Server A stores the cert on its peer record → peer becomes active. * 6. Inserts representative test tasks/notes/credentials on Server B. * * IMPORTANT: This script uses the real admin REST API — no direct DB writes. * It exercises the full enrollment flow as M3 acceptance tests will. * * ESM / NodeNext: all imports use .js extensions. */ import { execSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; // ─── Constants ─────────────────────────────────────────────────────────────── const __dirname = dirname(fileURLToPath(import.meta.url)); const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml'); /** Base URLs as seen from the host machine (mapped host ports). */ const SERVER_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001'; const SERVER_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002'; /** * Bootstrap passwords used when calling POST /api/bootstrap/setup on each * gateway. Each gateway starts with zero users and requires a one-time setup * call before any admin-guarded endpoints can be used. */ const ADMIN_BOOTSTRAP_PASSWORD_A = process.env['ADMIN_BOOTSTRAP_PASSWORD_A'] ?? 'harness-admin-password-a'; const ADMIN_BOOTSTRAP_PASSWORD_B = process.env['ADMIN_BOOTSTRAP_PASSWORD_B'] ?? 'harness-admin-password-b'; const READINESS_TIMEOUT_MS = 120_000; const READINESS_POLL_MS = 3_000; // ─── Scope variant definitions (for M3 acceptance tests) ───────────────────── /** Scope variant A — tasks + notes, personal data included. */ export const SCOPE_VARIANT_A = { resources: ['tasks', 'notes'], filters: { tasks: { include_personal: true }, notes: { include_personal: true }, }, excluded_resources: [] as string[], max_rows_per_query: 500, }; /** Scope variant B — tasks only, team T1 only, no personal. */ export const SCOPE_VARIANT_B = { resources: ['tasks'], filters: { tasks: { include_teams: ['T1'], include_personal: false }, }, excluded_resources: [] as string[], max_rows_per_query: 500, }; /** * Scope variant C — tasks + credentials in resources list, but credentials * explicitly in excluded_resources. Sanity test: credentials must still be * inaccessible even though they appear in resources. */ export const SCOPE_VARIANT_C = { resources: ['tasks', 'credentials'], filters: { tasks: { include_personal: true }, }, excluded_resources: ['credentials'], max_rows_per_query: 500, }; // ─── Inline types (no import from packages/types — M3-01 branch not yet merged) ─ interface AdminFetchOptions { method?: string; body?: unknown; adminToken: string; } interface PeerRecord { peerId: string; csrPem: string; } interface GrantRecord { id: string; status: string; scope: unknown; } interface EnrollmentTokenResult { token: string; expiresAt: string; enrollmentUrl: string; } interface EnrollmentRedeemResult { certPem: string; certChainPem: string; } interface BootstrapResult { adminUserId: string; adminToken: string; } export interface SeedResult { serverAUrl: string; serverBUrl: string; adminTokenA: string; adminTokenB: string; adminUserIdA: string; adminUserIdB: string; grants: { variantA: GrantRecord; variantB: GrantRecord; variantC: GrantRecord; }; peers: { variantA: PeerRecord & { grantId: string }; variantB: PeerRecord & { grantId: string }; variantC: PeerRecord & { grantId: string }; }; } // ─── HTTP helpers ───────────────────────────────────────────────────────────── /** * Authenticated admin fetch. Sends `Authorization: Bearer ` which * is the only path supported by AdminGuard (DB-backed sha256 token lookup). * No `x-admin-key` header path exists in the gateway. */ async function adminFetch(baseUrl: string, path: string, opts: AdminFetchOptions): Promise { const url = `${baseUrl}${path}`; const res = await fetch(url, { method: opts.method ?? 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.adminToken}`, }, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); if (!res.ok) { const text = await res.text().catch(() => '(no body)'); throw new Error(`${opts.method ?? 'GET'} ${url} → ${res.status}: ${text}`); } return res.json() as Promise; } // ─── Admin bootstrap ────────────────────────────────────────────────────────── /** * Bootstrap an admin user on a pristine gateway. * * Steps: * 1. GET /api/bootstrap/status — confirms needsSetup === true. * 2. POST /api/bootstrap/setup with { name, email, password } — returns * { user, token: { plaintext } }. * * The harness assumes a fresh DB. If needsSetup is false the harness fails * fast with a clear error rather than proceeding with an unknown token. */ async function bootstrapAdmin( baseUrl: string, label: string, password: string, ): Promise { console.log(`[seed] Bootstrapping admin on ${label} (${baseUrl})...`); // 1. Check status const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`); if (!statusRes.ok) { throw new Error(`[seed] GET ${baseUrl}/api/bootstrap/status → ${statusRes.status.toString()}`); } const status = (await statusRes.json()) as { needsSetup: boolean }; if (!status.needsSetup) { throw new Error( `[seed] ${label} at ${baseUrl} already has users (needsSetup=false). ` + `The harness requires a pristine database. Run 'docker compose down -v' to reset.`, ); } // 2. Bootstrap const setupRes = await fetch(`${baseUrl}/api/bootstrap/setup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `Harness Admin (${label})`, email: `harness-admin-${label.toLowerCase().replace(/\s+/g, '-')}@example.invalid`, password, }), }); if (!setupRes.ok) { const body = await setupRes.text().catch(() => '(no body)'); throw new Error( `[seed] POST ${baseUrl}/api/bootstrap/setup → ${setupRes.status.toString()}: ${body}`, ); } const result = (await setupRes.json()) as { user: { id: string }; token: { plaintext: string }; }; console.log(`[seed] ${label} admin user: ${result.user.id}`); console.log(`[seed] ${label} admin token: ${result.token.plaintext.slice(0, 8)}...`); return { adminUserId: result.user.id, adminToken: result.token.plaintext, }; } // ─── Readiness probe ────────────────────────────────────────────────────────── async function waitForGateway(baseUrl: string, label: string): Promise { const deadline = Date.now() + READINESS_TIMEOUT_MS; let lastError: string = ''; while (Date.now() < deadline) { try { const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) }); if (res.ok) { console.log(`[seed] ${label} is ready (${baseUrl})`); return; } lastError = `HTTP ${res.status.toString()}`; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } await new Promise((r) => setTimeout(r, READINESS_POLL_MS)); } throw new Error( `[seed] ${label} did not become ready within ${READINESS_TIMEOUT_MS.toString()}ms — last error: ${lastError}`, ); } // ─── Enrollment flow ────────────────────────────────────────────────────────── /** * Walk the full enrollment flow for one grant. * * The correct two-sided flow (matching the data model's FK semantics): * * 1. On Server B: POST /api/admin/federation/peers/keypair * → peerId_B (Server B's peer record representing the requesting side) * 2. On Server B: POST /api/admin/federation/grants with peerId: peerId_B * → grant (FK to Server B's own federation_peers table — no violation) * 3. On Server B: POST /api/admin/federation/grants/:id/tokens * → enrollmentUrl pointing back to Server B * 4. On Server A: POST /api/admin/federation/peers/keypair * → peerId_A + csrPem_A (Server A's local record of Server B) * 5. Server A → Server B: POST enrollmentUrl with { csrPem: csrPem_A } * → certPem signed by Server B's CA * 6. On Server A: PATCH /api/admin/federation/peers/:peerId_A/cert with certPem * → Server A's peer record transitions to active * * Returns the activated grant (from Server B) and Server A's peer record. */ async function enrollGrant(opts: { label: string; subjectUserId: string; scope: unknown; adminTokenA: string; adminTokenB: string; serverAUrl: string; serverBUrl: string; }): Promise<{ grant: GrantRecord; peer: PeerRecord & { grantId: string } }> { const { label, subjectUserId, scope, adminTokenA, adminTokenB, serverAUrl, serverBUrl } = opts; console.log(`\n[seed] Enrolling grant for scope variant ${label}...`); // 1. Create peer keypair on Server B (represents the requesting peer from B's perspective) const peerB = await adminFetch(serverBUrl, '/api/admin/federation/peers/keypair', { method: 'POST', adminToken: adminTokenB, body: { commonName: `harness-peer-${label.toLowerCase()}-from-b`, displayName: `Harness Peer ${label} (Server A as seen from B)`, endpointUrl: serverAUrl, }, }); console.log(`[seed] Created peer on B: ${peerB.peerId}`); // 2. Create grant on Server B referencing B's own peer record const grant = await adminFetch(serverBUrl, '/api/admin/federation/grants', { method: 'POST', adminToken: adminTokenB, body: { peerId: peerB.peerId, subjectUserId, scope, }, }); console.log(`[seed] Created grant on B: ${grant.id} (status: ${grant.status})`); // 3. Generate enrollment token on Server B const tokenResult = await adminFetch( serverBUrl, `/api/admin/federation/grants/${grant.id}/tokens`, { method: 'POST', adminToken: adminTokenB, body: { ttlSeconds: 900 } }, ); console.log(`[seed] Enrollment token: ${tokenResult.token.slice(0, 8)}...`); console.log(`[seed] Enrollment URL: ${tokenResult.enrollmentUrl}`); // 4. Create peer keypair on Server A (Server A's local record of Server B) const peerA = await adminFetch(serverAUrl, '/api/admin/federation/peers/keypair', { method: 'POST', adminToken: adminTokenA, body: { commonName: `harness-peer-${label.toLowerCase()}-from-a`, displayName: `Harness Peer ${label} (Server B as seen from A)`, endpointUrl: serverBUrl, }, }); console.log(`[seed] Created peer on A: ${peerA.peerId}`); // 5. Redeem token at Server B's enrollment endpoint with A's CSR. // The enrollment endpoint is not admin-guarded — the one-time token IS the credential. // // The enrollmentUrl returned by the gateway is built using BETTER_AUTH_URL which // resolves to the in-cluster Docker hostname (gateway-b:3000). That URL is only // reachable from other containers, not from the host machine running this script. // We rewrite the host portion to use the host-accessible serverBUrl so the // seed script can reach the endpoint from the host. const parsedEnrollment = new URL(tokenResult.enrollmentUrl); const tokenSegment = parsedEnrollment.pathname.split('/').pop()!; const redeemUrl = `${serverBUrl}/api/federation/enrollment/${tokenSegment}`; console.log(`[seed] Rewritten redeem URL (host-accessible): ${redeemUrl}`); const redeemRes = await fetch(redeemUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrPem: peerA.csrPem }), }); if (!redeemRes.ok) { const body = await redeemRes.text().catch(() => '(no body)'); throw new Error(`Enrollment redemption failed: ${redeemRes.status.toString()} — ${body}`); } const redeemResult = (await redeemRes.json()) as EnrollmentRedeemResult; console.log(`[seed] Cert issued (${redeemResult.certPem.length.toString()} bytes)`); // 6. Store cert on Server A's peer record → transitions to active await adminFetch(serverAUrl, `/api/admin/federation/peers/${peerA.peerId}/cert`, { method: 'PATCH', adminToken: adminTokenA, body: { certPem: redeemResult.certPem }, }); console.log(`[seed] Cert stored on A — peer ${peerA.peerId} is now active`); // Verify grant flipped to active on B const activeGrant = await adminFetch( serverBUrl, `/api/admin/federation/grants/${grant.id}`, { adminToken: adminTokenB }, ); console.log(`[seed] Grant status on B: ${activeGrant.status}`); return { grant: activeGrant, peer: { ...peerA, grantId: grant.id } }; } // ─── Test data insertion ────────────────────────────────────────────────────── /** * Insert representative test data on Server B via its admin APIs. * * NOTE: The gateway's task/note/credential APIs require an authenticated user * session. For the harness, we seed via admin-level endpoints if available, * or document the gap here for M3-11 to fill in with proper user session seeding. * * ASSUMPTION: Server B exposes POST /api/admin/tasks (or similar) for test data. * If that endpoint does not yet exist, this function logs a warning and skips * without failing — M3-11 will add the session-based seeding path. */ async function seedTestData( subjectUserId: string, scopeLabel: string, serverBUrl: string, adminTokenB: string, ): Promise { console.log(`\n[seed] Seeding test data on Server B for ${scopeLabel}...`); const testTasks = [ { title: `${scopeLabel} Task 1`, description: 'Federation harness test task', userId: subjectUserId, }, { title: `${scopeLabel} Task 2`, description: 'Team-scoped test task', userId: subjectUserId, teamId: 'T1', }, ]; const testNotes = [ { title: `${scopeLabel} Note 1`, content: 'Personal note for federation test', userId: subjectUserId, }, ]; // Attempt to insert — tolerate 404 (endpoint not yet implemented) for (const task of testTasks) { try { await adminFetch(serverBUrl, '/api/admin/tasks', { method: 'POST', adminToken: adminTokenB, body: task, }); console.log(`[seed] Inserted task: "${task.title}"`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('404') || msg.includes('Cannot POST')) { console.warn( `[seed] WARN: /api/admin/tasks not found — skipping task insertion (expected until M3-11)`, ); break; } throw err; } } for (const note of testNotes) { try { await adminFetch(serverBUrl, '/api/admin/notes', { method: 'POST', adminToken: adminTokenB, body: note, }); console.log(`[seed] Inserted note: "${note.title}"`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('404') || msg.includes('Cannot POST')) { console.warn( `[seed] WARN: /api/admin/notes not found — skipping note insertion (expected until M3-11)`, ); break; } throw err; } } console.log(`[seed] Test data seeding for ${scopeLabel} complete.`); } // ─── Main entrypoint ────────────────────────────────────────────────────────── export async function runSeed(opts?: { serverAUrl?: string; serverBUrl?: string; adminBootstrapPasswordA?: string; adminBootstrapPasswordB?: string; subjectUserIds?: { variantA: string; variantB: string; variantC: string }; }): Promise { const aUrl = opts?.serverAUrl ?? SERVER_A_URL; const bUrl = opts?.serverBUrl ?? SERVER_B_URL; const passwordA = opts?.adminBootstrapPasswordA ?? ADMIN_BOOTSTRAP_PASSWORD_A; const passwordB = opts?.adminBootstrapPasswordB ?? ADMIN_BOOTSTRAP_PASSWORD_B; // Use provided or default subject user IDs. // In a real run these would be real user UUIDs from Server B's DB. // For the harness, the admin bootstrap user on Server B is used as the subject. // These are overridden after bootstrap if opts.subjectUserIds is not provided. const subjectIds = opts?.subjectUserIds; console.log('[seed] Waiting for gateways to be ready...'); await Promise.all([waitForGateway(aUrl, 'Server A'), waitForGateway(bUrl, 'Server B')]); // Bootstrap admin users on both gateways (requires pristine DBs). console.log('\n[seed] Bootstrapping admin accounts...'); const [bootstrapA, bootstrapB] = await Promise.all([ bootstrapAdmin(aUrl, 'Server A', passwordA), bootstrapAdmin(bUrl, 'Server B', passwordB), ]); // Default subject user IDs to the admin user on Server B (guaranteed to exist). const resolvedSubjectIds = subjectIds ?? { variantA: bootstrapB.adminUserId, variantB: bootstrapB.adminUserId, variantC: bootstrapB.adminUserId, }; // Enroll all three scope variants sequentially to avoid race conditions on // the step-ca signing queue. Parallel enrollment would work too but // sequential is easier to debug when something goes wrong. console.log('\n[seed] Enrolling scope variants...'); const resultA = await enrollGrant({ label: 'A', subjectUserId: resolvedSubjectIds.variantA, scope: SCOPE_VARIANT_A, adminTokenA: bootstrapA.adminToken, adminTokenB: bootstrapB.adminToken, serverAUrl: aUrl, serverBUrl: bUrl, }); const resultB = await enrollGrant({ label: 'B', subjectUserId: resolvedSubjectIds.variantB, scope: SCOPE_VARIANT_B, adminTokenA: bootstrapA.adminToken, adminTokenB: bootstrapB.adminToken, serverAUrl: aUrl, serverBUrl: bUrl, }); const resultC = await enrollGrant({ label: 'C', subjectUserId: resolvedSubjectIds.variantC, scope: SCOPE_VARIANT_C, adminTokenA: bootstrapA.adminToken, adminTokenB: bootstrapB.adminToken, serverAUrl: aUrl, serverBUrl: bUrl, }); // Seed test data on Server B for each scope variant await Promise.all([ seedTestData(resolvedSubjectIds.variantA, 'A', bUrl, bootstrapB.adminToken), seedTestData(resolvedSubjectIds.variantB, 'B', bUrl, bootstrapB.adminToken), seedTestData(resolvedSubjectIds.variantC, 'C', bUrl, bootstrapB.adminToken), ]); const result: SeedResult = { serverAUrl: aUrl, serverBUrl: bUrl, adminTokenA: bootstrapA.adminToken, adminTokenB: bootstrapB.adminToken, adminUserIdA: bootstrapA.adminUserId, adminUserIdB: bootstrapB.adminUserId, grants: { variantA: resultA.grant, variantB: resultB.grant, variantC: resultC.grant, }, peers: { variantA: resultA.peer, variantB: resultB.peer, variantC: resultC.peer, }, }; console.log('\n[seed] Seed complete.'); console.log('[seed] Summary:'); console.log(` Variant A grant: ${result.grants.variantA.id} (${result.grants.variantA.status})`); console.log(` Variant B grant: ${result.grants.variantB.id} (${result.grants.variantB.status})`); console.log(` Variant C grant: ${result.grants.variantC.id} (${result.grants.variantC.status})`); return result; } // ─── CLI entry ──────────────────────────────────────────────────────────────── const isCli = process.argv[1] != null && fileURLToPath(import.meta.url).endsWith(process.argv[1]!.split('/').pop()!); if (isCli) { const shouldBoot = process.argv.includes('--boot'); if (shouldBoot) { console.log('[seed] --boot flag detected — starting compose stack...'); execSync(`docker compose -f "${COMPOSE_FILE}" up -d`, { stdio: 'inherit' }); } runSeed() .then(() => { process.exit(0); }) .catch((err) => { console.error('[seed] Fatal:', err); process.exit(1); }); }