Add mosaic gateway login subcommand with meta.json URL default, config rotate-token and recover-token subcommands for admin token minting via BetterAuth session cookie, fix the bootstrapFirstUser dead-end when admin exists but no token is on file, and add Vitest tests for all new flows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
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<MintedToken> {
|
|
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<string> {
|
|
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<string> {
|
|
// 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<string> => 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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|