Add administrator sign-in and private-safe room directory

Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit.

Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
This commit is contained in:
Quintine committed 2026-09-22 09:36:11 +10:00
1 parent 36a03bda9c
commit c2d392e155
27 files changed
+3164 -13

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const usage = `Create an administrator access key and provisioning SQL:
node tools/admin-access.mjs --name 'Owner' --role owner --out /private/new-directory
Revoke an account and all its sessions (the final enabled owner is protected):
node tools/admin-access.mjs --revoke <account-uuid> --out /private/new-directory
Roles: owner, operator, viewer. The output directory must be new, absolute and
outside the application checkout. Apply the generated SQL to the intended D1
database with your operator tooling. No database is contacted by this command.
The private access key is written only to access-key.txt, never to the SQL or stdout.
`;
function parseArgs(args) {
if (args.length === 1 && args[0] === '--help') return null;
const options = {};
for (let i = 0; i < args.length; i += 2) {
const option = args[i];
if (
!['--name', '--role', '--out', '--revoke'].includes(option) ||
Object.hasOwn(options, option) ||
!args[i + 1] ||
args[i + 1].startsWith('--')
)
throw new Error('Use --help for the required command options.');
options[option] = args[i + 1];
}
if (!options['--out'] || !isAbsolute(options['--out']))
throw new Error(
'--out must name a new absolute directory outside the checkout.',
);
if (options['--revoke']) {
if (
options['--name'] ||
options['--role'] ||
options['--revoke'].length !== 36 ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
options['--revoke'],
)
)
throw new Error(
'Revocation requires only --revoke with an account UUID and --out.',
);
} else {
const name = options['--name']?.trim();
if (
!name ||
name.length > 80 ||
[...name].some((character) => {
const code = character.charCodeAt(0);
return code < 32 || (code >= 127 && code <= 159);
})
)
throw new Error(
'--name must contain 1–80 characters without control characters.',
);
if (!['owner', 'operator', 'viewer'].includes(options['--role']))
throw new Error('--role must be owner, operator or viewer.');
options['--name'] = name;
}
return options;
}
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
async function main() {
const options = parseArgs(process.argv.slice(2));
if (!options) {
process.stdout.write(usage);
return;
}
const checkout = await realpath(
resolve(dirname(fileURLToPath(import.meta.url)), '..'),
);
const requested = resolve(options['--out']);
// Resolve the parent before checking containment, including symlink aliases.
const parent = await realpath(dirname(requested));
const output = resolve(parent, relative(dirname(requested), requested));
const within = relative(checkout, output);
if (
within === '' ||
(within !== '..' && !within.startsWith(`..${sep}`) && !isAbsolute(within))
)
throw new Error(
'The output directory must be outside the application checkout.',
);
await mkdir(output, { mode: 0o700 }); // Existing files/directories are never reused.
try {
const now = Date.now();
const revoked = options['--revoke'];
const id = revoked || randomUUID();
if (revoked) {
const sql = `-- Apply to the intended D1 database; this contains no access key.
-- The database trigger refuses to revoke the final enabled owner.
UPDATE admin_accounts SET enabled = 0, updated_at = ${now}
WHERE id = ${quote(id)} AND enabled = 1
RETURNING id, name, role, enabled;
`;
await writeFile(resolve(output, 'revoke.sql'), sql, {
mode: 0o600,
flag: 'wx',
});
} else {
const key = `dune-admin.${id}.${randomBytes(32).toString('hex')}`;
const keyHash = createHash('sha256').update(key).digest('hex');
const sql = `-- Apply to the intended D1 database; this contains only the access-key hash.
-- The database trigger records provisioning in the administrator audit history.
INSERT INTO admin_accounts (id, name, role, key_hash, enabled, session_generation, created_at, updated_at)
VALUES (${quote(id)}, ${quote(options['--name'])}, ${quote(options['--role'])}, ${quote(keyHash)}, 1, 0, ${now}, ${now});
`;
await writeFile(resolve(output, 'access-key.txt'), key + '\n', {
mode: 0o600,
flag: 'wx',
});
await writeFile(resolve(output, 'provision.sql'), sql, {
mode: 0o600,
flag: 'wx',
});
}
process.stdout.write(
`Created ${revoked ? 'revocation' : 'provisioning'} files for administrator ${id} in ${output}.\n`,
);
} catch (error) {
await rm(output, { recursive: true, force: true });
throw error;
}
}
main().catch((error) => {
// Filesystem errors may include operator paths; credential values never enter errors.
process.stderr.write(
`Administrator access setup failed: ${error instanceof Error ? error.message : 'unknown error'}\n`,
);
process.exitCode = 1;
});
+91
View File
@@ -0,0 +1,91 @@
// Explicit operator-run HTTP acceptance. Use a dedicated QA administrator key:
// this signs out ALL sessions for that account. Never use a human operator's key.
// Uses one new QA room; never modifies an existing game.
import assert from 'node:assert/strict';
import { readFile, mkdir, realpath, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const options = {};
for (let n = 2; n < process.argv.length; n += 2) {
const flag = process.argv[n];
if (!['--url', '--key-file', '--out', '--qa-account'].includes(flag) || options[flag] || !process.argv[n + 1]) throw new Error('Use --url ORIGIN --key-file PRIVATE_QA_FILE --qa-account ACCOUNT_UUID --out NEW_PRIVATE_DIRECTORY');
options[flag] = process.argv[n + 1];
}
const url = new URL(options['--url']);
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Provide an HTTP(S) origin.');
if (!options['--key-file'] || !isAbsolute(options['--out'] ?? '')) throw new Error('Provide a key file and absolute output directory.');
if (!/^[0-9a-f-]{36}$/.test(options['--qa-account'] ?? '')) throw new Error('Identify the dedicated QA account with --qa-account; all its sessions will be revoked.');
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
const out = resolve(await realpath(dirname(options['--out'])), relative(dirname(options['--out']), options['--out']));
const fromRoot = relative(root, out);
if (!fromRoot || (fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot))) throw new Error('Store evidence outside the checkout.');
await mkdir(out, { mode: 0o700 });
const key = (await readFile(options['--key-file'], 'utf8')).trim();
const base = url.origin;
const report = { origin: base, startedAt: new Date().toISOString(), passed: false, checks: [], room: null };
let session1, session2;
async function request(path, body, cookie, origin = base) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
});
report.lastRequest = { path, status: response.status };
const raw = await response.text();
let data;
try { data = JSON.parse(raw); } catch { data = null; }
return { status: response.status, data, cookie: response.headers.get('set-cookie')?.split(';')[0], rawCookie: response.headers.get('set-cookie'), cache: response.headers.get('cache-control') };
}
try {
const denied = await request('/api/admin/rooms');
assert.equal(denied.status, 401); assert.equal(denied.cache, 'no-store');
const login = await request('/api/admin/session', { action: 'login', key });
assert.equal(login.status, 200); assert.ok(login.cookie);
session1 = login.cookie;
assert.equal(login.data.admin.id, options['--qa-account'], 'Use the explicitly designated QA account.');
for (const attribute of ['HttpOnly', 'SameSite=Strict', 'Path=/api/admin']) assert.ok(login.rawCookie.includes(attribute));
if (url.protocol === 'https:') assert.ok(login.rawCookie.includes('; Secure'));
assert.equal((await request('/api/admin/session', undefined, session1)).data.admin.id, login.data.admin.id);
report.checks.push('Authenticated identity and cookie attributes; anonymous denial');
const second = await request('/api/admin/session', { action: 'login', key });
assert.equal(second.status, 200); session2 = second.cookie;
const name = 'Administration QA ' + Date.now();
const created = await request('/api/rooms', { name, faction: 'atreides', advanced: false, expansions: [] });
assert.equal(created.status, 201); report.room = created.data.code;
const path = '/api/rooms/' + report.room;
const before = (await request(path, undefined, created.cookie)).data;
const directory = await request('/api/admin/rooms?q=' + report.room, undefined, session1);
assert.equal(directory.status, 200); assert.equal(directory.data.total, 1);
const room = directory.data.rooms[0];
assert.equal(room.code, report.room); assert.equal(room.players[0].name, name);
assert.deepEqual(Object.keys(room.players[0]).sort(), ['control', 'faction', 'id', 'name']);
for (const field of ['state', 'log', 'hand', 'traitors', 'prediction', 'sessionHash', 'token', 'spice']) assert.ok(!Object.hasOwn(room, field));
assert.deepEqual((await request(path, undefined, created.cookie)).data, before);
assert.equal((await request('/api/admin/rooms', undefined, created.cookie)).status, 401);
report.checks.push('QA room search, strict public roster, host denial, unchanged saved game');
assert.equal((await request('/api/admin/session', { action: 'logoutAll' }, session1, 'https://foreign.invalid')).status, 403);
assert.equal((await request('/api/admin/session', undefined, session1)).status, 200);
assert.equal((await request('/api/admin/session', { action: 'logout' }, session1)).status, 200);
assert.equal((await request('/api/admin/rooms', undefined, session1)).status, 401);
assert.equal((await request('/api/admin/session', undefined, session2)).status, 200);
const third = await request('/api/admin/session', { action: 'login', key });
assert.equal(third.status, 200); session1 = third.cookie;
assert.equal((await request('/api/admin/session', { action: 'logoutAll' }, session2)).status, 200);
for (const cookie of [session1, session2]) assert.equal((await request('/api/admin/rooms', undefined, cookie)).status, 401);
assert.deepEqual((await request(path, undefined, created.cookie)).data, before);
report.checks.push('Foreign-origin denial; individual and all-session revocation; saved-seat continuity');
report.passed = true;
} catch (error) {
// Do not serialize assertion payloads: failed authentication responses might contain credentials.
report.failure = 'Administrator HTTP verification failed. Investigate the last completed check.';
report.failureRequest = report.lastRequest;
report.failureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('verify-admin.mjs'))?.trim() : undefined;
process.exitCode = 1;
} finally {
for (const cookie of [session1, session2].filter(Boolean)) {
try { await request('/api/admin/session', { action: 'logout' }, cookie); } catch { /* Report may already be incomplete; absolute expiry still applies. */ }
}
await writeFile(resolve(out, 'report.json'), JSON.stringify({ ...report, finishedAt: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
console.log(JSON.stringify({ passed: report.passed, completedChecks: report.checks.length, evidence: out }));
}
+31
View File
@@ -3,6 +3,9 @@ import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const image = process.argv[2];
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
@@ -10,6 +13,8 @@ const name = `dune-verify-${randomUUID()}`;
const volume = `${name}-data`;
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
let base;
const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-'));
let adminCookie;
async function ready() {
for (let attempt = 0; attempt < 90; attempt++) {
try {
@@ -31,6 +36,19 @@ try {
docker('volume', 'create', volume);
start();
await ready();
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', join(adminFiles, 'key')]);
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
input: readFileSync(join(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
});
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
const adminKey = readFileSync(join(adminFiles, 'key/access-key.txt'), 'utf8').trim();
const adminLogin = await fetch(`${base}/api/admin/session`, {
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
body: JSON.stringify({ action: 'login', key: adminKey }),
});
assert.equal(adminLogin.status, 200);
adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0];
assert.ok(adminCookie);
const headers = { 'content-type': 'application/json', origin: base };
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
@@ -46,6 +64,10 @@ try {
const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } });
assert.equal(result.status, 200);
assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement');
if (adminCookie) {
const admin = await fetch(`${base}/api/admin/session`, { headers: { cookie: adminCookie } });
assert.equal(admin.status, 200, 'Administrator session must survive replacement');
}
}
docker('restart', '--time', '60', name);
// Docker may assign a different ephemeral host port after a restart.
@@ -57,6 +79,8 @@ try {
start();
await ready();
await restored();
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', join(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', join(adminFiles, 'http')], { stdio: 'inherit' });
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
execFileSync('npm', ['run', 'test:integration'], {
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
});
@@ -66,6 +90,12 @@ try {
start(publicOrigin);
await ready();
await restored();
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
body: JSON.stringify({ action: 'login', key: adminKey }),
});
assert.equal(proxyAdmin.status, 200);
assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure'));
const proxyCreate = await fetch(`${base}/api/rooms`, {
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
});
@@ -98,4 +128,5 @@ try {
// Names are unique to this invocation; never remove an existing application.
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
rmSync(adminFiles, { recursive: true, force: true });
}