#!/usr/bin/env node /** * One attended Console login. * * Default: the helper compares and corrects the MAS IAM rule. * --mint-workload: start the attended OpenBao platform-admin login with the * publisher gate as its child. The console session is read and sent only * after the gate reports ready, so the OIDC login cannot age it out. */ import {spawn} from 'node:child_process'; import {existsSync, readFileSync, rmSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, join, resolve} from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; import {parseArgs} from 'node:util'; import {createInterface} from 'node:readline/promises'; const {values} = parseArgs({options: {'playwright-module': {type: 'string'}, 'mint-workload': {type: 'boolean'}}}); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const grant = join(root, 'scripts', 'mas_evidence_iam_grant.py'); const gate = join(root, 'scripts', 'mas_evidence_publisher_gate.py'); const ready = join(homedir(), '.local/state/railiance/receipts/mas-evidence-mint-ready.json'); const READY_TIMEOUT_MS = 10 * 60 * 1000; let browser; let child; async function sessionAuth(page) { if (new URL(page.url()).origin !== 'https://console.scaleway.com') { throw new Error('console_page_required'); } return page.evaluate(() => { const names = Object.keys(localStorage).filter((name) => name.startsWith('_scw_session_console_scaleway_com_')); if (names.length !== 1) throw new Error('unambiguous_session_required'); const session = JSON.parse(localStorage.getItem(names[0])); if (new Date(session.jwt.expiresAt).getTime() < Date.now() + 30000) { throw new Error('fresh_console_session_required'); } return {session_token: session.token, user_agent: session.jwt.userAgent}; }); } function runGrant(auth) { return new Promise((resolveResult, reject) => { child = spawn('python3', [grant, '--execute'], {stdio: ['pipe', 'pipe', 'pipe']}); let output = ''; child.stdout.on('data', (data) => { if (output.length < 65536) output += data.toString(); }); child.stderr.on('data', () => {}); child.on('error', () => reject(new Error('grant_child_failed'))); child.on('close', (code) => { child = null; resolveResult({exit_code: code, output}); }); child.stdin.end(JSON.stringify(auth)); }); } function runMint(page) { rmSync(ready, {force: true}); return new Promise((resolveResult, reject) => { const env = {...process.env}; env.BAO_ADDR = env.BAO_ADDR || 'http://127.0.0.1:18200'; env.VAULT_ADDR = env.VAULT_ADDR || env.BAO_ADDR; child = spawn('warden', [ 'access', 'openbao-platform-admin-login', '--exec', '--', 'python3', gate, '--mint-workload', ], {stdio: ['pipe', 'inherit', 'inherit'], env}); let sent = false; const started = Date.now(); const timer = setInterval(async () => { if (sent) return; if (Date.now() - started > READY_TIMEOUT_MS) { sent = true; clearInterval(timer); child?.stdin.end(''); return; } if (!existsSync(ready)) return; sent = true; clearInterval(timer); try { child?.stdin.end(JSON.stringify(await sessionAuth(page))); } catch { child?.stdin.end(''); } }, 1000); child.on('error', () => { clearInterval(timer); reject(new Error('warden_child_failed')); }); child.on('close', (code) => { clearInterval(timer); child = null; resolveResult({exit_code: code, output: `warden exit ${code}; receipts under ~/.local/state/railiance/receipts\n`}); }); }); } try { const modulePath = resolve(values['playwright-module'] || ''); const pkg = JSON.parse(readFileSync(join(dirname(modulePath), 'package.json'), 'utf8')); if (pkg.name !== 'playwright' || pkg.version !== '1.62.1') throw new Error('locked_playwright_required'); const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.chromium ? imported : imported.default; if (!playwright?.chromium?.launch) throw new Error('playwright_export_unusable'); const {chromium} = playwright; browser = await chromium.launch({headless: false}); const context = await browser.newContext(); const page = await context.newPage(); await page.goto('https://console.scaleway.com/', {waitUntil: 'domcontentloaded', timeout: 60000}); const reader = createInterface({input: process.stdin, output: process.stdout}); console.log('Console opened. Sign in, then press Enter. The session token stays in this process.'); await reader.question(''); reader.close(); const outcome = values['mint-workload'] ? await runMint(page) : await runGrant(await sessionAuth(page)); process.stdout.write(outcome.output.endsWith('\n') ? outcome.output : `${outcome.output}\n`); process.exitCode = outcome.exit_code === 0 ? 0 : 1; try { await page.goto('https://account.scaleway.com/logout', {waitUntil: 'domcontentloaded', timeout: 30000}); } catch {} await context.clearCookies(); } catch (error) { const code = typeof error?.message === 'string' && /^[a-z0-9_]+$/.test(error.message) ? error.message : (error?.name || 'Error'); console.error(`IAM grant session failed: ${code}; no credential values printed.`); process.exitCode = 1; } finally { if (child) child.kill('SIGTERM'); if (browser) await browser.close(); }