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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 15:25:38 +07:00
1 parent 042cb116ea
commit 0ef2d53a65
17 files changed
+1497 -79

No files matched your search

+40 -33
View File
@@ -1,18 +1,23 @@
/*
The hub's own sign-in. One user from the environment, sessions kept in memory with an
idle expiry, and the `info` object the console reads from `user/login` and
`user/session/get` (flat, as the real server sends it).
The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who
signed in and what that user looked like (role, hash, disabled flag) at that moment, so a
change to the users file ends the sessions of the users it touched. `info` is the object
the console reads from `user/login` and `user/session/get` (flat, as the real server sends it).
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
import { signature, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000
/* Compared as hashes so the lengths always match and the time does not depend on the content. */
function sameSecret(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest()
const hb = createHash('sha256').update(b).digest()
return timingSafeEqual(ha, hb)
export interface Identity {
user: string
role: Role
}
interface Session extends Identity {
last: number
sig: string
}
/*
@@ -28,39 +33,39 @@ const VIEWABLE = new Set(['Dashboard', 'Logs'])
export class Sessions {
#config: HubConfig
#users: Users
#now: () => number
#started: string
#seen = new Map<string, number>()
#seen = new Map<string, Session>()
constructor(config: HubConfig, now: () => number = Date.now) {
constructor(config: HubConfig, users: Users, now: () => number = Date.now) {
this.#config = config
this.#users = users
this.#now = now
this.#started = new Date(now()).toISOString()
}
/** A new session token, or null when the credentials are wrong. */
login(user: string, password: string): string | null {
// Both are always compared, so a wrong user and a wrong password cost the same.
const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase())
const passOk = sameSecret(password, this.#config.password)
if (!(userOk && passOk)) return null
/** Opens a session for a user already authenticated by `Users.authenticate`. */
open(user: UserRecord): string {
this.#sweep()
const token = randomBytes(32).toString('hex')
this.#seen.set(token, this.#now())
this.#seen.set(token, { user: user.name, role: user.role, last: this.#now(), sig: signature(user) })
return token
}
/** True while the token is valid; each use extends it. */
touch(token: string | null | undefined): boolean {
if (!token) return false
const last = this.#seen.get(token)
if (last === undefined) return false
if (this.#now() - last > IDLE_MS) {
/* The session's identity while it is valid (extending it), else null. A user who was removed,
disabled or edited since sign-in ends the session. */
touch(token: string | null | undefined): Identity | null {
if (!token) return null
const session = this.#seen.get(token)
if (session === undefined) return null
const record = this.#users.find(session.user)
if (this.#now() - session.last > IDLE_MS || record === undefined || signature(record) !== session.sig) {
this.#seen.delete(token)
return false
return null
}
this.#seen.set(token, this.#now())
return true
session.last = this.#now()
return { user: session.user, role: session.role }
}
logout(token: string): void {
@@ -69,10 +74,10 @@ export class Sessions {
#sweep(): void {
const now = this.#now()
for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token)
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
}
info() {
info(role: Role) {
const { servers, domain } = this.#config
return {
version: HUB_VERSION,
@@ -92,20 +97,22 @@ export class Sessions {
useSoaSerialDateScheme: true,
dnssecValidation: true,
hub: true,
hubRole: role,
}
}
/** The flat body of `user/login` and `user/session/get`. */
body(token: string) {
body(token: string, identity: Identity) {
return {
status: 'ok',
token,
username: this.#config.user,
displayName: this.#config.user,
username: identity.user,
displayName: identity.user,
type: 'Local',
isSsoUser: false,
totpEnabled: false,
info: this.info(),
hubRole: identity.role,
info: this.info(identity.role),
}
}
}