Files
technitium-console/hub/apps-write.ts
T
DenozordecandClaude Opus 5.5 0e05039202 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 <[email protected]>
2026-10-31 16:46:15 +07:00

179 lines
7.7 KiB
TypeScript

/*
App writes: install, update and uninstall on one or several servers. The first part of the
hub that changes anything, so the rules are strict:
* only a server's ADMIN token writes (`<ID>_ADMIN_TOKEN`); a server without one is refused;
* a store download URL is never taken from the client: it is looked up in the target
server's own `apps/listStoreApps`, and a URL the client sends must match it;
* before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history/`, and
the uninstall is refused if that cannot be done;
* every target's result is audited (the caller does that with `Audit`).
*/
import type { HubConfig, HubServer } from './config.ts'
import type { UploadedFile } from './multipart.ts'
import { MAX_CONFIG } from './apps-sync.ts'
import { safeName, writeHistory } from './store.ts'
import { Upstream } from './upstream.ts'
export { safeName }
export type StoreAction = 'install-store' | 'update-store' | 'uninstall'
export type FileAction = 'install-file' | 'update-file'
export type AppAction = StoreAction | FileAction | 'config-set'
export interface TargetResult {
server: string
ok: boolean
message?: string
}
const API: Record<AppAction, string> = {
'install-store': 'apps/downloadAndInstall',
'update-store': 'apps/downloadAndUpdate',
uninstall: 'apps/uninstall',
'install-file': 'apps/install',
'update-file': 'apps/update',
'config-set': 'apps/config/set',
}
const DONE: Record<AppAction, string> = {
'install-store': 'Installed from the store.',
'update-store': 'Updated from the store.',
uninstall: 'Uninstalled.',
'install-file': 'Installed from the file.',
'update-file': 'Updated from the file.',
'config-set': 'Config saved.',
}
/** The audit/api action for an upstream write path, or null when it is not an app write. */
export function actionForPath(apiPath: string): AppAction | null {
for (const [action, p] of Object.entries(API)) if (p === apiPath) return action as AppAction
return null
}
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
export class AppsWriter {
#config: HubConfig
#upstream: Upstream
#now: () => number
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#now = now
}
/** The ids of the servers that have an admin token. */
writableIds(): string[] {
return this.#config.servers.filter((s) => s.adminToken !== undefined).map((s) => s.id)
}
/* The URL the server's own store gives for this app, or null. */
async storeUrl(server: HubServer, name: string): Promise<string | null> {
const response = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; url?: string }[] } | null
const found = (response?.storeApps ?? []).find((a) => a.name === name)
return typeof found?.url === 'string' && found.url !== '' ? found.url : null
}
/* Saves the app's current config next to the hub's data; throws if it cannot. */
async saveHistory(server: HubServer, name: string, kind: 'before-uninstall' | 'before-apply' = 'before-uninstall'): Promise<string> {
if (server.adminToken === undefined) throw new Error('no admin token configured')
this.#upstream.invalidate(server.id)
const response = (await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)) as { config?: unknown } | null
const text = typeof response?.config === 'string' ? response.config : ''
return writeHistory(this.#config.dataDir, name, server.id, text, kind, this.#now())
}
/*
One write on one server. `url` must already be checked against the store; `file` is the zip
for the two file actions. Returns the server's response, or throws.
*/
async perform(server: HubServer, action: AppAction, name: string, url?: string, file?: UploadedFile, config?: string): Promise<unknown> {
if (server.adminToken === undefined) throw new Error('no admin token configured')
switch (action) {
case 'install-store':
case 'update-store':
return await this.#upstream.write(server, API[action], { name, url: url ?? '' })
case 'uninstall':
try {
await this.saveHistory(server, name)
} catch (e) {
throw new Error(`the config could not be saved before the uninstall (${reason(e)}); nothing was uninstalled`)
}
return await this.#upstream.write(server, API[action], { name })
case 'config-set': {
if (config === undefined || config.length > MAX_CONFIG) throw new Error('the config is missing or larger than 1 MB')
try {
await this.saveHistory(server, name, 'before-apply')
} catch (e) {
throw new Error(`the old config could not be saved first (${reason(e)}); nothing was changed`)
}
return await this.#upstream.write(server, API[action], {}, new URLSearchParams({ name, config }))
}
case 'install-file':
case 'update-file': {
if (file === undefined) throw new Error('no zip file')
const form = new FormData()
form.append('fileAppZip', new Blob([file.data], { type: file.type }), file.filename)
return await this.#upstream.write(server, API[action], { name }, form)
}
}
}
/*
The same write on several servers, in parallel. A store action first resolves each target's
URL from its own store; `suppliedUrl` (if the client sent one) has to match every one of them.
Returns `{ error }` when the request itself is refused, else one result per target.
*/
async runMany(
action: AppAction,
name: string,
targets: string[],
options: { suppliedUrl?: string; file?: UploadedFile } = {},
): Promise<{ error: string } | { results: TargetResult[] }> {
const known = new Map(this.#config.servers.map((s) => [s.id, s]))
const unique = [...new Set(targets)]
if (name === '' || name.length > 200) return { error: 'An app name is required.' }
if (unique.length === 0) return { error: 'Choose at least one server.' }
const unknown = unique.filter((t) => !known.has(t))
if (unknown.length > 0) return { error: `Unknown server: ${unknown.join(', ')}.` }
const urls = new Map<string, string>()
const early = new Map<string, string>()
if (action === 'install-store' || action === 'update-store') {
await Promise.all(
unique.map(async (id) => {
const server = known.get(id) as HubServer
if (server.adminToken === undefined) return early.set(id, 'no admin token configured')
try {
const url = await this.storeUrl(server, name)
if (url === null) early.set(id, `"${name}" is not in this server's store listing`)
else urls.set(id, url)
} catch (e) {
early.set(id, `the store listing could not be read (${reason(e)})`)
}
}),
)
if (options.suppliedUrl !== undefined && options.suppliedUrl !== '' && [...urls.values()].some((u) => u !== options.suppliedUrl)) {
return { error: 'The url is not from the store listing; the hub only installs what a server offers.' }
}
}
const results = await Promise.all(
unique.map(async (id): Promise<TargetResult> => {
const server = known.get(id) as HubServer
const refused = early.get(id) ?? (server.adminToken === undefined ? 'no admin token configured' : undefined)
if (refused !== undefined) return { server: id, ok: false, message: refused }
try {
await this.perform(server, action, name, urls.get(id), options.file)
return { server: id, ok: true, message: DONE[action] }
} catch (e) {
return { server: id, ok: false, message: reason(e) }
}
}),
)
return { results }
}
}