Hub stage 2: install, update and uninstall apps on several servers

Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability.
Store URLs are resolved from each server's own listing. The config is saved
to history before an uninstall. Zip uploads are fanned out to the servers.
Every target's result is audited. The console gets a row menu on the
matrix, a target picker, a results dialog with Retry failed, the store and
install from file in All servers mode, and single-server actions for writers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 16:11:40 +07:00
1 parent 3d8df08fa0
commit 6acd2d656d
27 files changed
+1991 -71

No files matched your search

+9 -4
View File
@@ -6,7 +6,7 @@ the console reads from `user/login` and `user/session/get` (flat, as the real se
*/
import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
import { signature, type Role, type UserRecord, type Users } from './users.ts'
import { can, signature, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000
@@ -23,8 +23,8 @@ interface Session extends Identity {
/*
Every key `src/app/sections.ts` reads, spelled out: the console hides a section only
when `canView === false`, so a missing key would leave it visible. The hub shows the
Dashboard, the Logs and the Apps; About has no permission and is always there. The hub is
read-only, so nothing can be modified or deleted.
Dashboard, the Logs and the Apps; About has no permission and is always there. Only the Apps can be
modified or deleted, and only by a role with `apps.write`.
*/
const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
@@ -84,7 +84,10 @@ export class Sessions {
uptimestamp: this.#started,
dnsServerDomain: domain,
permissions: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, { canView: VIEWABLE.has(key), canModify: false, canDelete: false }]),
PERMISSION_KEYS.map((key) => {
const writes = key === 'Apps' && can(role, 'apps.write')
return [key, { canView: VIEWABLE.has(key), canModify: writes, canDelete: writes }]
}),
),
/* The console offers its node selector when the cluster is initialised. The
first server plays the Primary (`primaryNodeName` looks for one). */
@@ -98,6 +101,8 @@ export class Sessions {
dnssecValidation: true,
hub: true,
hubRole: role,
/* The servers the hub holds an admin token for: the only ones a write can reach. */
hubWritable: servers.filter((s) => s.adminToken !== undefined).map((s) => s.id),
}
}