Hub stage 3: compare and sync app configs across servers

The hub keeps a baseline per app in /data/apps/<app>/baseline.json. Local
fields (overrides.json) are left out of the sync and out of the matrix
comparison. Each server gets the baseline plus its own values.

Applying goes target picker, preview with per-server diffs and warnings,
then apply. Apply refuses a server whose config changed since the
preview, saves the old config to history, then reads the new one back
to check it.

Large config bodies are read only for signed-in callers. The App config
dialog has Compare, Baseline and Local fields tabs and an empty state
that offers an import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 16:46:15 +07:00
1 parent 6acd2d656d
commit 0e05039202
22 files changed
+2434 -58

No files matched your search

+93 -5
View File
@@ -24,6 +24,7 @@ import { routeFor } from './routes.ts'
import { buildMatrix } from './apps-matrix.ts'
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
import { AppsConfig, UserError } from './apps-config.ts'
import {
mergeQueryLogs,
mergeSettled,
@@ -38,6 +39,8 @@ import {
type Json = Record<string, unknown>
const MAX_BODY = 64 * 1024
/* The config endpoints carry config texts (up to 1 MB). */
const MAX_CONFIG_BODY = 2 * 1024 * 1024
const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
@@ -72,17 +75,17 @@ function send(res: http.ServerResponse, code: number, body: unknown): void {
const fail = (errorMessage: string): Json => ({ status: 'error', errorMessage })
async function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
async function readForm(req: http.IncomingMessage, limit: number = MAX_BODY): Promise<URLSearchParams> {
if (req.method !== 'POST') return new URLSearchParams()
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
size += (chunk as Buffer).length
if (size > MAX_BODY) break
if (size > limit) break
chunks.push(chunk as Buffer)
}
const type = String(req.headers['content-type'] ?? '')
if (size > MAX_BODY) return new URLSearchParams()
if (size > limit) return new URLSearchParams()
const text = Buffer.concat(chunks).toString('utf8')
if (type.includes('application/x-www-form-urlencoded')) return new URLSearchParams(text)
if (type.includes('application/json')) {
@@ -93,6 +96,7 @@ async function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
for (const [k, v] of Object.entries(data)) {
if (Array.isArray(v)) out.set(k, v.map(String).join(','))
else if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out.set(k, String(v))
else if (v !== null && typeof v === 'object') out.set(k, JSON.stringify(v))
}
} catch {
// Not JSON: no parameters.
@@ -131,6 +135,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
const limiter = new LoginLimiter()
const audit = new Audit(config.dataDir)
const writer = new AppsWriter(config, upstream)
const appsConfig = new AppsConfig(config, upstream)
const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
@@ -243,6 +248,74 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
return file
}
/* A value from a JSON-encoded parameter, or the fallback. */
function jsonParam(value: string | undefined, fallback: unknown): unknown {
if (value === undefined || value === '') return fallback
try {
return JSON.parse(value)
} catch {
throw new UserError('A parameter is not valid JSON.')
}
}
function userFailure(res: http.ServerResponse, e: unknown): void {
if (e instanceof UserError) send(res, 400, fail(e.message))
else throw e
}
/* compare, preview and the live local values: no change is made. */
async function configRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const name = params.name ?? ''
try {
if (name === '') throw new UserError('An app name is required.')
if (apiPath === 'hub/apps/config/compare') send(res, 200, ok(await appsConfig.compare(name)))
else if (apiPath === 'hub/apps/config/preview') {
const preview = await appsConfig.preview(name, csv(params.targets))
audit.log({ user: who.user, role: who.role, ip, action: 'apps.config-preview', target: name, outcome: 'ok', detail: csv(params.targets).join(',') })
send(res, 200, ok(preview))
} else if (apiPath === 'hub/apps/overrides/current') {
send(res, 200, ok({ values: await appsConfig.currentLocalValues(name, csv(params.paths)) }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
userFailure(res, e)
}
}
/* baseline import and save, local fields save, apply. */
async function configWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const name = params.name ?? ''
const base = { user: who.user, role: who.role, ip, target: name }
const action = apiPath.replace('hub/apps/', 'apps.').replace(/\//g, '-')
try {
if (name === '' || name.length > 200) throw new UserError('An app name is required.')
if (apiPath === 'hub/apps/baseline/import') {
const done = await appsConfig.importBaseline(name, params.server ?? '', who.user)
audit.log({ ...base, action, server: params.server, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ baseline: appsConfig.getBaseline(name) }))
} else if (apiPath === 'hub/apps/baseline/save') {
const done = appsConfig.saveBaseline(name, params.text ?? '', who.user)
audit.log({ ...base, action, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ baseline: appsConfig.getBaseline(name) }))
} else if (apiPath === 'hub/apps/overrides/save') {
const done = appsConfig.saveOverrides(name, csv(params.paths), jsonParam(params.values, {}))
audit.log({ ...base, action, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ overrides: appsConfig.getOverrides(name) }))
} else if (apiPath === 'hub/apps/config/apply') {
const expect = jsonParam(params.expect, {}) as Record<string, string>
const done = await appsConfig.apply(name, csv(params.targets), expect)
for (const r of done.results) {
audit.log({ ...base, action: 'apps.config-apply', server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server]].filter(Boolean).join(' | ') })
}
send(res, 200, ok({ results: done.results }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
if (e instanceof UserError) {
audit.log({ ...base, action, outcome: 'refused', detail: apiPath.endsWith('baseline/save') ? 'invalid baseline' : e.message.slice(0, 120) })
}
userFailure(res, e)
}
}
/* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> {
if (kind === 'write-all') {
@@ -250,6 +323,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 405, fail('Use POST.'))
return
}
if (apiPath.startsWith('hub/apps/baseline/') || apiPath.startsWith('hub/apps/overrides/') || apiPath === 'hub/apps/config/apply') {
await configWrite(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/apps/run') {
const action = params.action
if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') {
@@ -324,7 +401,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
url = fromStore ?? undefined
}
response = await writer.perform(server, action, name, url, file)
response = await writer.perform(server, action, name, url, file, params.config)
result = { server: server.id, ok: true }
} catch (e) {
result = { server: server.id, ok: false, message: e instanceof Error ? e.message : 'failed' }
@@ -335,7 +412,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
// A multipart body (an app zip) is read later, once the caller is known to be allowed to send it.
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req)
// A config body may be large, but only for a signed-in caller (token in the header or the query).
const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set')
&& sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, large ? MAX_CONFIG_BODY : MAX_BODY)
const params: Params = {}
for (const [k, v] of url.searchParams) params[k] = v
for (const [k, v] of form) params[k] = v
@@ -409,6 +489,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await write(req, res, apiPath, route.kind, params, who, ip)
return
}
if (apiPath.startsWith('hub/apps/config/') || apiPath === 'hub/apps/overrides/current') {
await configRead(res, apiPath, params, who, ip)
return
}
if (apiPath === 'apps/config/get' && !byId.has(params.node ?? '')) {
send(res, 400, fail('Choose one server: a config is read from a single server.'))
return
}
if (apiPath === 'hub/apps/matrix') {
try {
const { matrix, warnings } = await buildMatrix(config, upstream)