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:
1 parent
36a03bda9c
commit
c2d392e155
27 files changed
+3164
-13
No files matched your search
@@ -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;
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user