Files
Genarrative/scripts/check-admin-account-procedures.mjs
T
kdletters 8febb799f3 升级SpacetimeDB至2.6.1
统一Rust crates、CLI、容器镜像和生产provision版本
使用2.6.1 CLI重新生成Rust bindings
更新运行检查、架构运维文档和项目共享记忆
2026-07-17 13:44:12 +08:00

582 lines
14 KiB
JavaScript

#!/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.6.1';
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;
}
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 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 === 10,
'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],
};
}
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',
};
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',
};
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.',
);
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 });
}
}