#!/usr/bin/env node import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { once } from 'node:events'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { createSpacetimeWebIdentity, encodeSpacetimeCliOption, } from './spacetime-migration-common.mjs'; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ); const database = 'admin-account-smoke'; const expectedSpacetimeVersion = '2.8.3'; const commandTimeoutMs = 5 * 60 * 1000; function assert(condition, message) { if (!condition) { throw new Error(message); } } function appendOutput(current, chunk) { const next = `${current}${chunk}`; return next.length <= 24_000 ? next : next.slice(-24_000); } function runCommand(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd ?? repoRoot, env: options.env ?? process.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'], }); let output = ''; const timeout = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`${command} timed out after ${commandTimeoutMs}ms`)); }, options.timeoutMs ?? commandTimeoutMs); child.stdout.on('data', (chunk) => { output = appendOutput(output, chunk.toString()); }); child.stderr.on('data', (chunk) => { output = appendOutput(output, chunk.toString()); }); child.on('error', (error) => { clearTimeout(timeout); reject(error); }); child.on('exit', (code, signal) => { clearTimeout(timeout); if (signal) { reject(new Error(`${command} exited via ${signal}: ${output.trim()}`)); } else if (code !== 0) { reject(new Error(`${command} exited with ${code}: ${output.trim()}`)); } else { resolve(output); } }); }); } async function reservePort() { const server = net.createServer(); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); const address = server.address(); assert( address && typeof address === 'object', 'Failed to reserve a local port.', ); const port = address.port; await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); return port; } function startStandalone(dataDir, port) { const child = spawn( 'spacetime', [ 'start', '--data-dir', dataDir, '--listen-addr', `127.0.0.1:${port}`, '--non-interactive', ], { cwd: repoRoot, env: process.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'], }, ); let output = ''; child.stdout.on('data', (chunk) => { output = appendOutput(output, chunk.toString()); }); child.stderr.on('data', (chunk) => { output = appendOutput(output, chunk.toString()); }); return { child, output: () => output }; } async function waitForStandalone(serverUrl, processState) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { if (processState.child.exitCode !== null) { throw new Error( `SpacetimeDB exited during startup: ${processState.output().trim()}`, ); } try { const response = await fetch(`${serverUrl}/v1/ping`); if (response.ok) { return; } } catch { // Startup is still in progress. } await delay(200); } throw new Error( `Timed out waiting for SpacetimeDB: ${processState.output().trim()}`, ); } async function stopStandalone(child) { if (child.exitCode !== null) { return; } if (process.platform === 'win32') { await stopWindowsProcessTree(child); return; } child.kill('SIGTERM'); const exited = await Promise.race([ once(child, 'exit').then(() => true), delay(5_000).then(() => false), ]); if (!exited && child.exitCode === null) { child.kill('SIGKILL'); await once(child, 'exit'); } } async function stopWindowsProcessTree(child) { if (typeof child.pid === 'number') { await runTaskKill(child.pid); } const exited = await Promise.race([ once(child, 'exit').then(() => true), delay(5_000).then(() => false), ]); if (!exited && child.exitCode === null) { child.kill('SIGKILL'); await once(child, 'exit'); } } function runTaskKill(pid) { return new Promise((resolve, reject) => { const taskKill = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', shell: false, }); taskKill.once('error', reject); taskKill.once('exit', (code, signal) => { if (code === 0 || code === 128 || code === 1) { resolve(); return; } reject(new Error(`taskkill exited with ${signal ?? code}`)); }); }); } async function callProcedure(serverUrl, token, procedureName, input) { const response = await fetch( `${serverUrl}/v1/database/${database}/call/${procedureName}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(input === undefined ? [] : [input]), }, ); const text = await response.text(); if (!response.ok) { throw new Error( `${procedureName} returned HTTP ${response.status}: ${text}`, ); } return JSON.parse(text); } function decodeOption(value) { if (value === null || value === undefined) { return null; } if (Array.isArray(value) && value[0] === 0) { return value[1]; } if (Array.isArray(value) && value[0] === 1) { return null; } throw new Error(`Unexpected SATS option: ${JSON.stringify(value)}`); } function mapAccountSnapshot(value) { if (value && typeof value === 'object' && !Array.isArray(value)) { return value; } assert( Array.isArray(value) && value.length === 11, 'Invalid admin account snapshot.', ); return { account_id: value[0], username: value[1], display_name: value[2], tab_permissions_json: value[3], enabled: value[4], token_version: value[5], created_by: value[6], updated_by: value[7], created_at_micros: value[8], updated_at_micros: value[9], action_permissions_json: value[10], }; } function parseAccountResult(value) { if (value && typeof value === 'object' && !Array.isArray(value)) { return value; } assert( Array.isArray(value) && value.length === 4, 'Invalid admin account result.', ); const account = decodeOption(value[1]); return { ok: value[0], account: account === null ? null : mapAccountSnapshot(account), accounts: value[2].map(mapAccountSnapshot), error_message: decodeOption(value[3]), }; } function parseCredentialResult(value) { if (value && typeof value === 'object' && !Array.isArray(value)) { return value; } assert( Array.isArray(value) && value.length === 3, 'Invalid admin credential result.', ); const credential = decodeOption(value[1]); return { ok: value[0], account: credential === null ? null : { account: mapAccountSnapshot(credential[0]), password_hash: credential[1], }, error_message: decodeOption(value[2]), }; } function parsePricingResult(value) { if (value && typeof value === 'object' && !Array.isArray(value)) { return value; } assert(Array.isArray(value) && value.length === 3, 'Invalid pricing result.'); return { ok: value[0], record: decodeOption(value[1]), error_message: decodeOption(value[2]), }; } function assertDenied(result, procedureName) { assert( !result.ok, `${procedureName} unexpectedly allowed an ordinary identity.`, ); assert( result.error_message === '当前 identity 无权调用模型生成运行时服务', `${procedureName} returned an unexpected denial: ${result.error_message}`, ); } async function loadPricingModels() { const filePath = path.join( repoRoot, 'server-rs/crates/api-server/config/editor-generation-pricing.default.json', ); const config = JSON.parse(await readFile(filePath, 'utf8')); return Object.entries(config.models).map(([model, pricing]) => ({ model, unit: pricing.unit, price: encodeSpacetimeCliOption(pricing.price), prices: Object.entries(pricing.prices ?? {}).map(([key, price]) => ({ key, price, })), })); } async function runSmoke(serverUrl, bootstrapSecret) { const identityOptions = { database, serverUrl }; const runtime = await createSpacetimeWebIdentity(identityOptions); const ordinary = await createSpacetimeWebIdentity(identityOptions); const models = await loadPricingModels(); const pricing = parsePricingResult( await callProcedure( serverUrl, runtime.token, 'initialize_editor_generation_pricing_config_if_missing_and_return', { admin_user_id: 'admin-account-smoke', models, updated_at_micros: 1, bootstrap_secret: bootstrapSecret, }, ), ); assert( pricing.ok, `Failed to initialize runtime identity: ${pricing.error_message}`, ); const createInput = { account_id: 'member-1', username: 'operator', display_name: 'Operator', password_hash: '$argon2id$admin-account-smoke', tab_permissions_json: '["dashboard"]', enabled: true, created_by: 'admin-account-smoke', action_permissions_json: '[]', }; const updateInput = { account_id: 'member-1', display_name: 'Operator Updated', password_hash: null, tab_permissions_json: '["dashboard","tracking"]', enabled: false, updated_by: 'admin-account-smoke', action_permissions_json: '["profile-wallet-consumption-reconcile"]', }; assertDenied( parseCredentialResult( await callProcedure( serverUrl, ordinary.token, 'get_admin_account_by_username_and_return', { username: 'operator', }, ), ), 'get_admin_account_by_username_and_return', ); assertDenied( parseAccountResult( await callProcedure( serverUrl, ordinary.token, 'get_admin_account_by_id_and_return', { account_id: 'member-1', }, ), ), 'get_admin_account_by_id_and_return', ); assertDenied( parseAccountResult( await callProcedure( serverUrl, ordinary.token, 'list_admin_accounts_and_return', ), ), 'list_admin_accounts_and_return', ); assertDenied( parseAccountResult( await callProcedure( serverUrl, ordinary.token, 'create_admin_account_and_return', createInput, ), ), 'create_admin_account_and_return', ); assertDenied( parseAccountResult( await callProcedure( serverUrl, ordinary.token, 'update_admin_account_and_return', updateInput, ), ), 'update_admin_account_and_return', ); const created = parseAccountResult( await callProcedure( serverUrl, runtime.token, 'create_admin_account_and_return', createInput, ), ); assert( created.ok && created.account?.token_version === 1, 'Runtime create failed.', ); const duplicate = parseAccountResult( await callProcedure( serverUrl, runtime.token, 'create_admin_account_and_return', { ...createInput, account_id: 'member-duplicate', username: ' Operator ', }, ), ); assert( !duplicate.ok, 'Duplicate normalized username unexpectedly succeeded.', ); assert( duplicate.error_message === '后台账号用户名已存在', `Duplicate username error changed: ${duplicate.error_message}`, ); const credential = parseCredentialResult( await callProcedure( serverUrl, runtime.token, 'get_admin_account_by_username_and_return', { username: ' OPERATOR ', }, ), ); assert( credential.ok, `Runtime credential lookup failed: ${credential.error_message}`, ); assert( credential.account?.password_hash === createInput.password_hash, 'Credential lookup lost the password hash.', ); const byId = parseAccountResult( await callProcedure( serverUrl, runtime.token, 'get_admin_account_by_id_and_return', { account_id: 'member-1', }, ), ); assert( byId.ok && byId.account?.username === 'operator', 'Runtime ID lookup failed.', ); assert( !('password_hash' in byId.account), 'Public account snapshot exposed password_hash.', ); const updated = parseAccountResult( await callProcedure( serverUrl, runtime.token, 'update_admin_account_and_return', updateInput, ), ); assert( updated.ok && updated.account?.token_version === 2, 'State update did not increment once.', ); assert( updated.account?.action_permissions_json === '["profile-wallet-consumption-reconcile"]', 'Action permission was not persisted.', ); const displayOnly = parseAccountResult( await callProcedure( serverUrl, runtime.token, 'update_admin_account_and_return', { ...updateInput, display_name: 'Operator Display Only', }, ), ); assert( displayOnly.ok && displayOnly.account?.token_version === 2, 'Display-only update changed token_version.', ); const listed = parseAccountResult( await callProcedure( serverUrl, runtime.token, 'list_admin_accounts_and_return', ), ); assert( listed.ok && listed.accounts.length === 1, 'Failed create mutated the account table.', ); assert( listed.accounts[0].account_id === 'member-1', 'Unexpected account survived the smoke.', ); } let tempDir; let standalone; try { const versionOutput = await runCommand('spacetime', ['--version'], { timeoutMs: 30_000, }); assert( versionOutput.includes( `spacetimedb tool version ${expectedSpacetimeVersion}`, ), `Expected SpacetimeDB ${expectedSpacetimeVersion}, got: ${versionOutput.trim()}`, ); tempDir = await mkdtemp( path.join(os.tmpdir(), 'genarrative-admin-account-smoke-'), ); const port = await reservePort(); const serverUrl = `http://127.0.0.1:${port}`; standalone = startStandalone(path.join(tempDir, 'data'), port); await waitForStandalone(serverUrl, standalone); const bootstrapSecret = createHash('sha256') .update('genarrative-admin-account-procedure-smoke') .digest('hex'); const bootstrapSecretHash = createHash('sha256') .update(bootstrapSecret) .digest('hex'); await runCommand( 'spacetime', [ 'publish', database, '--server', serverUrl, '--module-path', 'server-rs/crates/spacetime-module', '--anonymous', '--yes=all', '--no-config', ], { env: { ...process.env, GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256: bootstrapSecretHash, }, }, ); await runSmoke(serverUrl, bootstrapSecret); console.log( '[admin-account-procedure-smoke] Passed identity guards, credential isolation, uniqueness, and token-version transactions.', ); } catch (error) { const standaloneOutput = standalone?.output().trim(); console.error( `[admin-account-procedure-smoke] Failed: ${error instanceof Error ? error.message : String(error)}`, ); if (standaloneOutput) { console.error(standaloneOutput); } process.exitCode = 1; } finally { if (standalone) { await stopStandalone(standalone.child); } if (tempDir) { await rm(tempDir, { recursive: true, force: true }); } }