Files
Dune/tools/admin-access.mjs
T
Quintine c2d392e155 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.
2026-09-22 09:36:11 +10:00

142 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
});