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.
142 lines
5.1 KiB
JavaScript
142 lines
5.1 KiB
JavaScript
#!/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;
|
||
});
|