Files
technitium-console/hub/users-cli.ts
T
DenozordecandClaude Opus 5.5 0ef2d53a65 Hub stage 0: own users with roles, login limit, audit log, permission table
Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator,
apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a
bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock
the address or the name for 15 minutes. Logins, logouts and every refused request go
to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are
listed in routes.ts with their kind and capability; writes stay refused for now.
Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan
is in docs/hub-management-plan.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:25:38 +07:00

135 lines
4.4 KiB
TypeScript

/*
Manage the hub's users file.
node --experimental-strip-types hub/users-cli.ts add <name> <role>
node --experimental-strip-types hub/users-cli.ts passwd <name>
node --experimental-strip-types hub/users-cli.ts role <name> <role>
node --experimental-strip-types hub/users-cli.ts disable <name>
node --experimental-strip-types hub/users-cli.ts enable <name>
node --experimental-strip-types hub/users-cli.ts list
The file is `${HUB_DATA}/users.json` (default /data). On a terminal the password is asked
twice without echo; in a script, put it in HUB_NEW_PASSWORD. In the container:
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
ROLES,
addUser,
describeUsers,
readUsersFile,
setDisabled,
setPassword,
setRole,
writeUsersFile,
type UserRecord,
} from './users.ts'
const USAGE = `Usage: users-cli <command>
add <name> <role> roles: ${ROLES.join(', ')}
passwd <name>
role <name> <role>
disable <name>
enable <name>
list
The password is asked twice, or taken from HUB_NEW_PASSWORD.`
type Env = Record<string, string | undefined>
/* Reads one line from the terminal without echoing it. */
function askHidden(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const stdin = process.stdin
process.stderr.write(prompt)
let line = ''
stdin.setRawMode(true)
stdin.resume()
stdin.setEncoding('utf8')
const done = (): void => {
stdin.setRawMode(false)
stdin.pause()
stdin.off('data', onData)
process.stderr.write('\n')
}
const onData = (chunk: string): void => {
for (const ch of chunk) {
if (ch === '\r' || ch === '\n') {
done()
resolve(line)
return
}
if (ch === '\u0003') {
done()
reject(new Error('Cancelled.'))
return
}
if (ch === '\u007f' || ch === '\b') line = line.slice(0, -1)
else line += ch
}
}
stdin.on('data', onData)
})
}
async function newPassword(env: Env): Promise<string> {
if (env.HUB_NEW_PASSWORD !== undefined && env.HUB_NEW_PASSWORD !== '') return env.HUB_NEW_PASSWORD
if (!process.stdin.isTTY) throw new Error('No terminal: set HUB_NEW_PASSWORD.')
const first = await askHidden('New password: ')
const second = await askHidden('Repeat it: ')
if (first !== second) throw new Error('The passwords differ.')
return first
}
/*
Runs one command against the users in `dir` and returns the lines to print. `password`
supplies the password for add and passwd, so the tests need no terminal.
*/
export async function run(
args: string[],
dir: string,
password: () => Promise<string>,
): Promise<string[]> {
const [command, name, extra] = args
const users: UserRecord[] = readUsersFile(dir) ?? []
const need = (value: string | undefined, what: string): string => {
if (value === undefined || value === '') throw new Error(`Missing ${what}.\n${USAGE}`)
return value
}
switch (command) {
case 'add':
writeUsersFile(dir, addUser(users, need(name, 'name'), need(extra, 'role'), await password()))
return [`Added ${name} (${extra}).`]
case 'passwd':
writeUsersFile(dir, setPassword(users, need(name, 'name'), await password()))
return [`Password of ${name} changed. Their sessions end on their next request.`]
case 'role':
writeUsersFile(dir, setRole(users, need(name, 'name'), need(extra, 'role')))
return [`${name} is now ${extra}.`]
case 'disable':
writeUsersFile(dir, setDisabled(users, need(name, 'name'), true))
return [`Disabled ${name}.`]
case 'enable':
writeUsersFile(dir, setDisabled(users, need(name, 'name'), false))
return [`Enabled ${name}.`]
case 'list':
return users.length === 0 ? ['No users.'] : describeUsers(users)
default:
throw new Error(USAGE)
}
}
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const dir = process.env.HUB_DATA?.trim() || '/data'
run(process.argv.slice(2), dir, () => newPassword(process.env)).then(
(lines) => {
for (const line of lines) console.log(line)
},
(e: unknown) => {
console.error(`users: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
},
)
}