import type { Command } from 'commander'; import { getGatewayUrl } from './gateway/login.js'; import { requireSession } from './gateway/token-ops.js'; // ─── Types ─────────────────────────────────────────────────────────────────── interface UserDto { id: string; name: string; email: string; role: string; banned: boolean; banReason: string | null; createdAt: string; updatedAt: string; } interface UserListDto { users: UserDto[]; total: number; } // ─── HTTP helpers ──────────────────────────────────────────────────────────── async function adminGet(gatewayUrl: string, cookie: string, path: string): Promise { let res: Response; try { res = await fetch(`${gatewayUrl}${path}`, { headers: { Cookie: cookie, Origin: gatewayUrl }, }); } catch (err) { console.error( `Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } if (res.status === 401 || res.status === 403) { console.error(`Session rejected by the gateway (${res.status.toString()}).`); console.error('Run: mosaic gateway login'); process.exit(2); } if (!res.ok) { const body = await res.text().catch(() => ''); console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`); process.exit(3); } return res.json() as Promise; } async function adminPost( gatewayUrl: string, cookie: string, path: string, body: unknown, ): Promise { let res: Response; try { res = await fetch(`${gatewayUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: gatewayUrl, }, body: JSON.stringify(body), }); } catch (err) { console.error( `Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } if (res.status === 401 || res.status === 403) { console.error(`Session rejected by the gateway (${res.status.toString()}).`); console.error('Run: mosaic gateway login'); process.exit(2); } if (!res.ok) { const body = await res.text().catch(() => ''); console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`); process.exit(3); } return res.json() as Promise; } async function adminDelete(gatewayUrl: string, cookie: string, path: string): Promise { let res: Response; try { res = await fetch(`${gatewayUrl}${path}`, { method: 'DELETE', headers: { Cookie: cookie, Origin: gatewayUrl }, }); } catch (err) { console.error( `Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } if (res.status === 401 || res.status === 403) { console.error(`Session rejected by the gateway (${res.status.toString()}).`); console.error('Run: mosaic gateway login'); process.exit(2); } if (!res.ok && res.status !== 204) { const body = await res.text().catch(() => ''); console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`); process.exit(3); } } // ─── Formatters ────────────────────────────────────────────────────────────── function printUser(u: UserDto): void { console.log(` ID: ${u.id}`); console.log(` Name: ${u.name}`); console.log(` Email: ${u.email}`); console.log(` Role: ${u.role}`); console.log(` Banned: ${u.banned ? `yes (${u.banReason ?? 'no reason'})` : 'no'}`); console.log(` Created: ${new Date(u.createdAt).toLocaleString()}`); console.log(''); } // ─── Register function ─────────────────────────────────────────────────────── /** * Register `mosaic auth` subcommands on an existing Commander program. * * Location rationale: placed in packages/mosaic rather than packages/auth because * the CLI needs session helpers (loadSession, validateSession, requireSession) * and gateway URL resolution (getGatewayUrl) that live in packages/mosaic. * Keeping packages/auth as a pure server-side library avoids adding commander * and CLI tooling as dependencies there. */ export function registerAuthCommand(parent: Command): void { const auth = parent .command('auth') .description('Manage gateway authentication, users, SSO providers, and sessions') .configureHelp({ sortSubcommands: true }) .action(() => { auth.outputHelp(); }); // ─── users ────────────────────────────────────────────────────────────── const users = auth .command('users') .description('Manage gateway users') .configureHelp({ sortSubcommands: true }) .action(() => { users.outputHelp(); }); users .command('list') .description('List all users on the gateway') .option('-g, --gateway ', 'Gateway URL') .option('-l, --limit ', 'Maximum number of users to display', '100') .action(async (opts: { gateway?: string; limit: string }) => { const url = getGatewayUrl(opts.gateway); const cookie = await requireSession(url); const limit = parseInt(opts.limit, 10); const result = await adminGet(url, cookie, '/api/admin/users'); const subset = result.users.slice(0, limit); if (subset.length === 0) { console.log('No users found.'); return; } console.log(`Users (${subset.length.toString()} of ${result.total.toString()}):\n`); for (const u of subset) { printUser(u); } }); users .command('create') .description('Create a new gateway user (interactive prompts)') .option('-g, --gateway ', 'Gateway URL') .action(async (opts: { gateway?: string }) => { const url = getGatewayUrl(opts.gateway); const cookie = await requireSession(url); const { text, password: clackPassword, select, intro, outro, isCancel, } = await import('@clack/prompts'); intro('Create a new Mosaic gateway user'); const name = await text({ message: 'Full name:', placeholder: 'Jane Doe' }); if (isCancel(name)) { outro('Cancelled.'); process.exit(0); } const email = await text({ message: 'Email:', placeholder: 'jane@example.com' }); if (isCancel(email)) { outro('Cancelled.'); process.exit(0); } const pw = await clackPassword({ message: 'Password:' }); if (isCancel(pw)) { outro('Cancelled.'); process.exit(0); } const role = await select({ message: 'Role:', options: [ { value: 'member', label: 'member' }, { value: 'admin', label: 'admin' }, ], }); if (isCancel(role)) { outro('Cancelled.'); process.exit(0); } const created = await adminPost(url, cookie, '/api/admin/users', { name: name as string, email: email as string, password: pw as string, role: role as string, }); outro(`User created: ${created.email} (${created.id})`); }); users .command('delete ') .description('Delete a gateway user by ID') .option('-g, --gateway ', 'Gateway URL') .option('-y, --yes', 'Skip confirmation prompt') .action(async (id: string, opts: { gateway?: string; yes?: boolean }) => { const url = getGatewayUrl(opts.gateway); const cookie = await requireSession(url); if (!opts.yes) { const { confirm, isCancel } = await import('@clack/prompts'); const confirmed = await confirm({ message: `Delete user ${id}? This cannot be undone.`, }); if (isCancel(confirmed) || !confirmed) { console.log('Aborted.'); process.exit(0); } } await adminDelete(url, cookie, `/api/admin/users/${id}`); console.log(`User ${id} deleted.`); }); // ─── sso ──────────────────────────────────────────────────────────────── const sso = auth .command('sso') .description('Manage SSO provider configuration') .configureHelp({ sortSubcommands: true }) .action(() => { sso.outputHelp(); }); sso .command('list') .description('List configured SSO providers (reads gateway discovery endpoint if available)') .option('-g, --gateway ', 'Gateway URL') .action(async (opts: { gateway?: string }) => { // The admin SSO discovery endpoint is not yet wired server-side. // The buildSsoDiscovery helper in @mosaicstack/auth reads env-vars on the // server; there is no GET /api/admin/sso endpoint in apps/gateway/src/admin/. // Stub until a gateway admin route is wired. console.log( 'not yet wired — admin endpoint missing (GET /api/admin/sso not implemented server-side)', ); console.log( 'Hint: SSO providers are configured via environment variables (AUTHENTIK_*, WORKOS_*, KEYCLOAK_*).', ); // Suppress unused variable warning void opts; }); sso .command('test ') .description('Smoke-test a configured SSO provider') .option('-g, --gateway ', 'Gateway URL') .action(async (provider: string, opts: { gateway?: string }) => { // No server-side SSO smoke-test endpoint exists yet. console.log( `not yet wired — admin endpoint missing (POST /api/admin/sso/${provider}/test not implemented server-side)`, ); void opts; }); // ─── sessions ──────────────────────────────────────────────────────────── const authSessions = auth .command('sessions') .description('Manage BetterAuth user sessions stored on the gateway') .configureHelp({ sortSubcommands: true }) .action(() => { authSessions.outputHelp(); }); authSessions .command('list') .description('List active user sessions') .option('-g, --gateway ', 'Gateway URL') .action(async (opts: { gateway?: string }) => { // No GET /api/admin/auth-sessions endpoint exists in apps/gateway/src/admin/. // Stub until a gateway admin route is wired. console.log( 'not yet wired — admin endpoint missing (GET /api/admin/auth-sessions not implemented server-side)', ); void opts; }); }