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;
|
||||
});
|
||||
Reference in new issue
Block a user