diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 2a0a579..5c1a07a 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -14,6 +14,7 @@ import { registerConfigCommand } from './commands/config.js'; import { registerMissionCommand } from './commands/mission.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; +import { registerAuthCommand } from './commands/auth.js'; import { registerGatewayCommand } from './commands/gateway.js'; import { backgroundUpdateCheck, @@ -328,6 +329,10 @@ sessionsCmd } }); +// ─── auth ──────────────────────────────────────────────────────────────── + +registerAuthCommand(program); + // ─── gateway ────────────────────────────────────────────────────────── registerGatewayCommand(program); diff --git a/packages/mosaic/src/commands/auth.spec.ts b/packages/mosaic/src/commands/auth.spec.ts new file mode 100644 index 0000000..8c5e425 --- /dev/null +++ b/packages/mosaic/src/commands/auth.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// These mocks prevent any real disk/network access during tests. + +vi.mock('./gateway/login.js', () => ({ + getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), +})); + +vi.mock('./gateway/token-ops.js', () => ({ + requireSession: vi.fn().mockResolvedValue('better-auth.session_token=test'), +})); + +// Global fetch is never called in smoke tests (no actions invoked). + +import { registerAuthCommand } from './auth.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function buildTestProgram(): Command { + const program = new Command('mosaic').exitOverride(); + registerAuthCommand(program); + return program; +} + +function findCommand(program: Command, ...path: string[]): Command | undefined { + let current: Command = program; + for (const name of path) { + const found = current.commands.find((c) => c.name() === name); + if (!found) return undefined; + current = found; + } + return current; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('registerAuthCommand', () => { + let program: Command; + + beforeEach(() => { + vi.clearAllMocks(); + program = buildTestProgram(); + }); + + it('registers the top-level auth command', () => { + const authCmd = findCommand(program, 'auth'); + expect(authCmd).toBeDefined(); + expect(authCmd?.name()).toBe('auth'); + }); + + describe('auth users', () => { + it('registers the users subcommand', () => { + const usersCmd = findCommand(program, 'auth', 'users'); + expect(usersCmd).toBeDefined(); + }); + + it('registers users list with --limit flag', () => { + const listCmd = findCommand(program, 'auth', 'users', 'list'); + expect(listCmd).toBeDefined(); + const limitOpt = listCmd?.options.find((o) => o.long === '--limit'); + expect(limitOpt).toBeDefined(); + }); + + it('registers users create', () => { + const createCmd = findCommand(program, 'auth', 'users', 'create'); + expect(createCmd).toBeDefined(); + }); + + it('registers users delete with --yes flag', () => { + const deleteCmd = findCommand(program, 'auth', 'users', 'delete'); + expect(deleteCmd).toBeDefined(); + const yesOpt = deleteCmd?.options.find((o) => o.long === '--yes'); + expect(yesOpt).toBeDefined(); + }); + }); + + describe('auth sso', () => { + it('registers the sso subcommand', () => { + const ssoCmd = findCommand(program, 'auth', 'sso'); + expect(ssoCmd).toBeDefined(); + }); + + it('registers sso list', () => { + const listCmd = findCommand(program, 'auth', 'sso', 'list'); + expect(listCmd).toBeDefined(); + }); + + it('registers sso test', () => { + const testCmd = findCommand(program, 'auth', 'sso', 'test'); + expect(testCmd).toBeDefined(); + }); + }); + + describe('auth sessions', () => { + it('registers the sessions subcommand', () => { + const sessCmd = findCommand(program, 'auth', 'sessions'); + expect(sessCmd).toBeDefined(); + }); + + it('registers sessions list', () => { + const listCmd = findCommand(program, 'auth', 'sessions', 'list'); + expect(listCmd).toBeDefined(); + }); + }); + + it('all top-level auth subcommand names are correct', () => { + const authCmd = findCommand(program, 'auth'); + expect(authCmd).toBeDefined(); + const names = authCmd!.commands.map((c) => c.name()).sort(); + expect(names).toEqual(['sessions', 'sso', 'users']); + }); +}); diff --git a/packages/mosaic/src/commands/auth.ts b/packages/mosaic/src/commands/auth.ts new file mode 100644 index 0000000..6d190b7 --- /dev/null +++ b/packages/mosaic/src/commands/auth.ts @@ -0,0 +1,331 @@ +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; + }); +}