diff --git a/packages/mosaic/src/commands/gateway.ts b/packages/mosaic/src/commands/gateway.ts index ea2a074..0bfb9ea 100644 --- a/packages/mosaic/src/commands/gateway.ts +++ b/packages/mosaic/src/commands/gateway.ts @@ -6,6 +6,7 @@ import { stopDaemon, waitForHealth, } from './gateway/daemon.js'; +import { getGatewayUrl } from './gateway/login.js'; interface GatewayParentOpts { host: string; @@ -119,9 +120,28 @@ export function registerGatewayCommand(program: Command): void { await runStatus(opts); }); + // ─── login ────────────────────────────────────────────────────────────── + + gw.command('login') + .description('Sign in to the gateway (defaults to URL from meta.json)') + .option('-g, --gateway ', 'Gateway URL (overrides meta.json)') + .option('-e, --email ', 'Email address') + .option('-p, --password ', 'Password') + .action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => { + const { runLogin } = await import('./gateway/login.js'); + const url = getGatewayUrl(cmdOpts.gateway); + try { + await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password }); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + // ─── config ───────────────────────────────────────────────────────────── - gw.command('config') + const configCmd = gw + .command('config') .description('View or modify gateway configuration') .option('--set ', 'Set a configuration value') .option('--unset ', 'Remove a configuration key') @@ -131,6 +151,24 @@ export function registerGatewayCommand(program: Command): void { await runConfig(cmdOpts); }); + configCmd + .command('rotate-token') + .description('Mint a new admin token using the stored BetterAuth session') + .option('-g, --gateway ', 'Gateway URL (overrides meta.json)') + .action(async (cmdOpts: { gateway?: string }) => { + const { runRotateToken } = await import('./gateway/token-ops.js'); + await runRotateToken(cmdOpts.gateway); + }); + + configCmd + .command('recover-token') + .description('Recover an admin token — prompts for login if no valid session exists') + .option('-g, --gateway ', 'Gateway URL (overrides meta.json)') + .action(async (cmdOpts: { gateway?: string }) => { + const { runRecoverToken } = await import('./gateway/token-ops.js'); + await runRecoverToken(cmdOpts.gateway); + }); + // ─── logs ─────────────────────────────────────────────────────────────── gw.command('logs') diff --git a/packages/mosaic/src/commands/gateway/install.ts b/packages/mosaic/src/commands/gateway/install.ts index 97c59e7..c63307d 100644 --- a/packages/mosaic/src/commands/gateway/install.ts +++ b/packages/mosaic/src/commands/gateway/install.ts @@ -388,10 +388,32 @@ async function bootstrapFirstUser( if (!status.needsSetup) { if (meta.adminToken) { console.log('Admin user already exists (token on file).'); - } else { - console.log('Admin user already exists — skipping setup.'); - console.log('(No admin token on file — sign in via the web UI to manage tokens.)'); + return; } + + // Admin user exists but no token — offer inline recovery when interactive. + console.log('Admin user already exists but no admin token is on file.'); + + if (process.stdin.isTTY) { + const answer = (await prompt(rl, 'Run token recovery now? [Y/n] ')).trim().toLowerCase(); + if (answer === '' || answer === 'y' || answer === 'yes') { + console.log(); + try { + const { ensureSession, mintAdminToken, persistToken } = await import('./token-ops.js'); + const cookie = await ensureSession(baseUrl); + const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`; + const minted = await mintAdminToken(baseUrl, cookie, label); + persistToken(baseUrl, minted); + } catch (err) { + console.error( + `Token recovery failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return; + } + } + + console.log('No admin token on file. Run: mosaic gateway config recover-token'); return; } } catch { diff --git a/packages/mosaic/src/commands/gateway/login.spec.ts b/packages/mosaic/src/commands/gateway/login.spec.ts new file mode 100644 index 0000000..1e7feaa --- /dev/null +++ b/packages/mosaic/src/commands/gateway/login.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock auth module +vi.mock('../../auth.js', () => ({ + signIn: vi.fn(), + saveSession: vi.fn(), +})); + +// Mock daemon to avoid file-system reads +vi.mock('./daemon.js', () => ({ + readMeta: vi.fn().mockReturnValue({ + host: 'localhost', + port: 14242, + version: '1.0.0', + installedAt: '', + entryPoint: '', + }), +})); + +import { runLogin, getGatewayUrl } from './login.js'; +import { signIn, saveSession } from '../../auth.js'; +import { readMeta } from './daemon.js'; + +const mockSignIn = vi.mocked(signIn); +const mockSaveSession = vi.mocked(saveSession); +const mockReadMeta = vi.mocked(readMeta); + +describe('getGatewayUrl', () => { + it('returns override URL when provided', () => { + expect(getGatewayUrl('http://my-gateway:9999')).toBe('http://my-gateway:9999'); + }); + + it('builds URL from meta.json when no override given', () => { + mockReadMeta.mockReturnValueOnce({ + host: 'myhost', + port: 8080, + version: '1.0.0', + installedAt: '', + entryPoint: '', + }); + expect(getGatewayUrl()).toBe('http://myhost:8080'); + }); + + it('falls back to default when meta is null', () => { + mockReadMeta.mockReturnValueOnce(null); + expect(getGatewayUrl()).toBe('http://localhost:14242'); + }); +}); + +describe('runLogin', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls signIn and saveSession on success', async () => { + const fakeAuth = { + cookie: 'better-auth.session_token=abc', + userId: 'u1', + email: 'admin@test.com', + }; + mockSignIn.mockResolvedValueOnce(fakeAuth); + + await runLogin({ + gatewayUrl: 'http://localhost:14242', + email: 'admin@test.com', + password: 'password123', + }); + + expect(mockSignIn).toHaveBeenCalledWith( + 'http://localhost:14242', + 'admin@test.com', + 'password123', + ); + expect(mockSaveSession).toHaveBeenCalledWith('http://localhost:14242', fakeAuth); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('admin@test.com')); + }); + + it('propagates signIn errors', async () => { + mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): invalid credentials')); + + await expect( + runLogin({ gatewayUrl: 'http://localhost:14242', email: 'bad@test.com', password: 'wrong' }), + ).rejects.toThrow('Sign-in failed (401)'); + }); +}); diff --git a/packages/mosaic/src/commands/gateway/login.ts b/packages/mosaic/src/commands/gateway/login.ts new file mode 100644 index 0000000..fd3730a --- /dev/null +++ b/packages/mosaic/src/commands/gateway/login.ts @@ -0,0 +1,39 @@ +import { createInterface } from 'node:readline'; +import { signIn, saveSession } from '../../auth.js'; +import { readMeta } from './daemon.js'; + +/** + * Shared login helper used by both `mosaic login` and `mosaic gateway login`. + * Prompts for email/password if not supplied, signs in, and persists the session. + */ +export async function runLogin(opts: { + gatewayUrl: string; + email?: string; + password?: string; +}): Promise { + let email = opts.email; + let password = opts.password; + + if (!email || !password) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + if (!email) email = await ask('Email: '); + if (!password) password = await ask('Password: '); + rl.close(); + } + + const auth = await signIn(opts.gatewayUrl, email, password); + saveSession(opts.gatewayUrl, auth); + console.log(`Signed in as ${auth.email} (${opts.gatewayUrl})`); +} + +/** + * Derive the gateway base URL from meta.json with a fallback. + */ +export function getGatewayUrl(overrideUrl?: string): string { + if (overrideUrl) return overrideUrl; + const meta = readMeta(); + if (meta) return `http://${meta.host}:${meta.port.toString()}`; + return 'http://localhost:14242'; +} diff --git a/packages/mosaic/src/commands/gateway/recover-token.spec.ts b/packages/mosaic/src/commands/gateway/recover-token.spec.ts new file mode 100644 index 0000000..aeb45d4 --- /dev/null +++ b/packages/mosaic/src/commands/gateway/recover-token.spec.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock('../../auth.js', () => ({ + loadSession: vi.fn(), + validateSession: vi.fn(), + signIn: vi.fn(), + saveSession: vi.fn(), +})); + +vi.mock('./daemon.js', () => ({ + readMeta: vi.fn(), + writeMeta: vi.fn(), +})); + +vi.mock('./login.js', () => ({ + getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), +})); + +// Mock readline so tests don't block on stdin +vi.mock('node:readline', () => ({ + createInterface: vi.fn().mockReturnValue({ + question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')), + close: vi.fn(), + }), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { runRecoverToken, ensureSession } from './token-ops.js'; +import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; +import { readMeta, writeMeta } from './daemon.js'; + +const mockLoadSession = vi.mocked(loadSession); +const mockValidateSession = vi.mocked(validateSession); +const mockSignIn = vi.mocked(signIn); +const mockSaveSession = vi.mocked(saveSession); +const mockReadMeta = vi.mocked(readMeta); +const mockWriteMeta = vi.mocked(writeMeta); + +const baseUrl = 'http://localhost:14242'; +const fakeCookie = 'better-auth.session_token=sess123'; +const fakeToken = { + id: 'tok-1', + label: 'CLI recovery token (2026-04-04 12:00)', + plaintext: 'abcdef1234567890', +}; +const fakeMeta = { + version: '1.0.0', + installedAt: '', + entryPoint: '', + host: 'localhost', + port: 14242, +}; + +describe('ensureSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('returns cookie from stored session when valid', async () => { + mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' }); + mockValidateSession.mockResolvedValueOnce(true); + + const cookie = await ensureSession(baseUrl); + expect(cookie).toBe(fakeCookie); + expect(mockSignIn).not.toHaveBeenCalled(); + }); + + it('prompts for credentials and signs in when stored session is invalid', async () => { + mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: 'a@b.com' }); + mockValidateSession.mockResolvedValueOnce(false); + const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' }; + mockSignIn.mockResolvedValueOnce(newAuth); + + const cookie = await ensureSession(baseUrl); + expect(cookie).toBe(fakeCookie); + expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth); + }); + + it('prompts for credentials when no session exists', async () => { + mockLoadSession.mockReturnValueOnce(null); + const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' }; + mockSignIn.mockResolvedValueOnce(newAuth); + + const cookie = await ensureSession(baseUrl); + expect(cookie).toBe(fakeCookie); + expect(mockSignIn).toHaveBeenCalled(); + }); + + it('exits non-zero when signIn fails', async () => { + mockLoadSession.mockReturnValueOnce(null); + mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): bad creds')); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(ensureSession(baseUrl)).rejects.toThrow('process.exit(2)'); + expect(processExitSpy).toHaveBeenCalledWith(2); + + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); +}); + +describe('runRecoverToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('prompts for login, mints a token, and persists it when no session exists', async () => { + mockLoadSession.mockReturnValueOnce(null); + const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'admin@test.com' }; + mockSignIn.mockResolvedValueOnce(newAuth); + mockReadMeta.mockReturnValue(fakeMeta); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => fakeToken, + }); + + await runRecoverToken(); + + expect(mockSignIn).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/api/admin/tokens`, + expect.objectContaining({ method: 'POST' }), + ); + expect(mockWriteMeta).toHaveBeenCalledWith( + expect.objectContaining({ adminToken: fakeToken.plaintext }), + ); + }); + + it('skips login when a valid session exists and mints a recovery token', async () => { + mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' }); + mockValidateSession.mockResolvedValueOnce(true); + mockReadMeta.mockReturnValue(fakeMeta); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => fakeToken, + }); + + await runRecoverToken(); + + expect(mockSignIn).not.toHaveBeenCalled(); + expect(mockWriteMeta).toHaveBeenCalledWith( + expect.objectContaining({ adminToken: fakeToken.plaintext }), + ); + }); + + it('uses label containing "recovery token"', async () => { + mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' }); + mockValidateSession.mockResolvedValueOnce(true); + mockReadMeta.mockReturnValue(fakeMeta); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => fakeToken, + }); + + await runRecoverToken(); + + const call = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(call[1].body as string) as { label: string }; + expect(body.label).toMatch(/CLI recovery token/); + }); +}); diff --git a/packages/mosaic/src/commands/gateway/rotate-token.spec.ts b/packages/mosaic/src/commands/gateway/rotate-token.spec.ts new file mode 100644 index 0000000..96f5888 --- /dev/null +++ b/packages/mosaic/src/commands/gateway/rotate-token.spec.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock('../../auth.js', () => ({ + loadSession: vi.fn(), + validateSession: vi.fn(), + signIn: vi.fn(), + saveSession: vi.fn(), +})); + +vi.mock('./daemon.js', () => ({ + readMeta: vi.fn(), + writeMeta: vi.fn(), +})); + +vi.mock('./login.js', () => ({ + getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), +})); + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { runRotateToken, mintAdminToken, persistToken } from './token-ops.js'; +import { loadSession, validateSession } from '../../auth.js'; +import { readMeta, writeMeta } from './daemon.js'; + +const mockLoadSession = vi.mocked(loadSession); +const mockValidateSession = vi.mocked(validateSession); +const mockReadMeta = vi.mocked(readMeta); +const mockWriteMeta = vi.mocked(writeMeta); + +const baseUrl = 'http://localhost:14242'; +const fakeCookie = 'better-auth.session_token=sess123'; +const fakeToken = { + id: 'tok-1', + label: 'CLI rotated token (2026-04-04)', + plaintext: 'abcdef1234567890', +}; +const fakeMeta = { + version: '1.0.0', + installedAt: '', + entryPoint: '', + host: 'localhost', + port: 14242, +}; + +describe('mintAdminToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the admin tokens endpoint with the session cookie and returns the token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => fakeToken, + }); + + const result = await mintAdminToken(baseUrl, fakeCookie, fakeToken.label); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/api/admin/tokens`, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Cookie: fakeCookie }), + }), + ); + expect(result).toEqual(fakeToken); + }); + + it('exits 2 on 401 from the server', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' }); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + + await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(2)'); + expect(processExitSpy).toHaveBeenCalledWith(2); + processExitSpy.mockRestore(); + }); + + it('exits 2 on 403 from the server', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: async () => 'Forbidden' }); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + + await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(2)'); + expect(processExitSpy).toHaveBeenCalledWith(2); + processExitSpy.mockRestore(); + }); + + it('exits 3 on other non-ok status', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'Internal Error' }); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + + await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(3)'); + expect(processExitSpy).toHaveBeenCalledWith(3); + processExitSpy.mockRestore(); + }); + + it('exits 1 on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('connection refused')); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + + await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(1)'); + expect(processExitSpy).toHaveBeenCalledWith(1); + processExitSpy.mockRestore(); + }); +}); + +describe('persistToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes the new token to meta.json', () => { + mockReadMeta.mockReturnValueOnce(fakeMeta); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + persistToken(baseUrl, fakeToken); + + expect(mockWriteMeta).toHaveBeenCalledWith( + expect.objectContaining({ adminToken: fakeToken.plaintext }), + ); + consoleSpy.mockRestore(); + }); + + it('prints a masked preview of the token', () => { + mockReadMeta.mockReturnValueOnce(fakeMeta); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + persistToken(baseUrl, fakeToken); + + const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allOutput).toContain('abcdef12...'); + consoleSpy.mockRestore(); + }); +}); + +describe('runRotateToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('exits 2 when there is no stored session', async () => { + mockLoadSession.mockReturnValueOnce(null); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + + await expect(runRotateToken()).rejects.toThrow('process.exit(2)'); + expect(processExitSpy).toHaveBeenCalledWith(2); + processExitSpy.mockRestore(); + }); + + it('exits 2 when session is invalid', async () => { + mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' }); + mockValidateSession.mockResolvedValueOnce(false); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((_code?: number | string | null | undefined) => { + throw new Error(`process.exit(${String(_code)})`); + }); + + await expect(runRotateToken()).rejects.toThrow('process.exit(2)'); + expect(processExitSpy).toHaveBeenCalledWith(2); + processExitSpy.mockRestore(); + }); + + it('mints and persists a new token when session is valid', async () => { + mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' }); + mockValidateSession.mockResolvedValueOnce(true); + mockReadMeta.mockReturnValue(fakeMeta); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => fakeToken, + }); + + await runRotateToken(); + + expect(mockWriteMeta).toHaveBeenCalledWith( + expect.objectContaining({ adminToken: fakeToken.plaintext }), + ); + }); +}); diff --git a/packages/mosaic/src/commands/gateway/token-ops.ts b/packages/mosaic/src/commands/gateway/token-ops.ts new file mode 100644 index 0000000..2fd6005 --- /dev/null +++ b/packages/mosaic/src/commands/gateway/token-ops.ts @@ -0,0 +1,149 @@ +import { createInterface } from 'node:readline'; +import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; +import { readMeta, writeMeta } from './daemon.js'; +import { getGatewayUrl } from './login.js'; + +interface MintedToken { + id: string; + label: string; + plaintext: string; +} + +/** + * Call POST /api/admin/tokens with the session cookie and return the minted token. + * Exits the process on network or auth errors. + */ +export async function mintAdminToken( + gatewayUrl: string, + cookie: string, + label: string, +): Promise { + let res: Response; + try { + res = await fetch(`${gatewayUrl}/api/admin/tokens`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookie, + Origin: gatewayUrl, + }, + body: JSON.stringify({ label, scope: 'admin' }), + }); + } 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()}) — your session may be expired.`, + ); + console.error('Run: mosaic gateway login'); + process.exit(2); + } + + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error( + `Gateway rejected token creation (${res.status.toString()}): ${body.slice(0, 200)}`, + ); + process.exit(3); + } + + const data = (await res.json()) as { id: string; label: string; plaintext: string }; + return { id: data.id, label: data.label, plaintext: data.plaintext }; +} + +/** + * Persist the new token into meta.json and print the confirmation banner. + */ +export function persistToken(gatewayUrl: string, minted: MintedToken): void { + const meta = readMeta() ?? { + version: 'unknown', + installedAt: new Date().toISOString(), + entryPoint: '', + host: new URL(gatewayUrl).hostname, + port: parseInt(new URL(gatewayUrl).port || '14242', 10), + }; + + writeMeta({ ...meta, adminToken: minted.plaintext }); + + const preview = `${minted.plaintext.slice(0, 8)}...`; + console.log(); + console.log(`Token minted: ${minted.label}`); + console.log(`Preview: ${preview}`); + console.log('Token saved to meta.json. Use it with admin endpoints.'); +} + +/** + * Require a valid session for the given gateway URL. + * Returns the session cookie or exits if not authenticated. + */ +export async function requireSession(gatewayUrl: string): Promise { + const session = loadSession(gatewayUrl); + if (session) { + const valid = await validateSession(gatewayUrl, session.cookie); + if (valid) return session.cookie; + } + console.error('Not signed in or session expired.'); + console.error('Run: mosaic gateway login'); + process.exit(2); +} + +/** + * Ensure a valid session for the gateway, prompting for credentials if needed. + * On sign-in failure, prints the error and exits non-zero. + * Returns the session cookie. + */ +export async function ensureSession(gatewayUrl: string): Promise { + // Try the stored session first + const session = loadSession(gatewayUrl); + if (session) { + const valid = await validateSession(gatewayUrl, session.cookie); + if (valid) return session.cookie; + console.log('Stored session is invalid or expired. Please sign in again.'); + } else { + console.log(`No session found for ${gatewayUrl}. Please sign in.`); + } + + // Prompt for credentials + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + const email = (await ask('Email: ')).trim(); + const password = (await ask('Password: ')).trim(); + rl.close(); + + const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(2); + }); + + saveSession(gatewayUrl, auth); + console.log(`Signed in as ${auth.email}`); + return auth.cookie; +} + +/** + * `mosaic gateway config rotate-token` — requires an existing valid session. + */ +export async function runRotateToken(gatewayUrl?: string): Promise { + const url = getGatewayUrl(gatewayUrl); + const cookie = await requireSession(url); + const label = `CLI rotated token (${new Date().toISOString().slice(0, 10)})`; + const minted = await mintAdminToken(url, cookie, label); + persistToken(url, minted); +} + +/** + * `mosaic gateway config recover-token` — prompts for login if no session exists. + */ +export async function runRecoverToken(gatewayUrl?: string): Promise { + const url = getGatewayUrl(gatewayUrl); + const cookie = await ensureSession(url); + const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`; + const minted = await mintAdminToken(url, cookie, label); + persistToken(url, minted); +}