Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3123
-106
No files matched your search
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body
|
||||
`{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited
|
||||
write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed:
|
||||
`X-Hub-Signature: sha256=<hex hmac>`.
|
||||
|
||||
A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and
|
||||
at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its
|
||||
query string) and never the error text. Items carry names and field names, never values.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import type { HubConfig } from './config.ts'
|
||||
|
||||
export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test'
|
||||
|
||||
export interface Delivery {
|
||||
event: WebhookEvent
|
||||
at: string
|
||||
ok: boolean
|
||||
/** `HTTP 500`, or an error code or name. Never a URL. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WebhookStatus {
|
||||
configured: boolean
|
||||
/** Host and port only. */
|
||||
host: string | null
|
||||
signed: boolean
|
||||
last: Delivery | null
|
||||
}
|
||||
|
||||
export interface WebhookOptions {
|
||||
fetch?: typeof fetch
|
||||
timeoutMs?: number
|
||||
retryDelayMs?: number
|
||||
/** How long write.failed items are collected into one notice. */
|
||||
batchMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
||||
|
||||
export class Webhook {
|
||||
#url: string | null
|
||||
#secret: string | null
|
||||
#domain: string
|
||||
#host: string | null
|
||||
#fetch: typeof fetch
|
||||
#timeoutMs: number
|
||||
#retryDelayMs: number
|
||||
#batchMs: number
|
||||
#now: () => number
|
||||
#last: Delivery | null = null
|
||||
#pending = new Set<Promise<unknown>>()
|
||||
#queued: Record<string, unknown>[] = []
|
||||
#timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(config: Pick<HubConfig, 'webhookUrl' | 'webhookSecret' | 'domain'>, options: WebhookOptions = {}) {
|
||||
this.#url = config.webhookUrl
|
||||
this.#secret = config.webhookSecret
|
||||
this.#domain = config.domain
|
||||
this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host
|
||||
this.#fetch = options.fetch ?? fetch
|
||||
this.#timeoutMs = options.timeoutMs ?? 5000
|
||||
this.#retryDelayMs = options.retryDelayMs ?? 1000
|
||||
this.#batchMs = options.batchMs ?? 250
|
||||
this.#now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
get configured(): boolean {
|
||||
return this.#url !== null
|
||||
}
|
||||
|
||||
status(): WebhookStatus {
|
||||
return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last }
|
||||
}
|
||||
|
||||
/** Resolves when every delivery started so far has ended (for the tests). */
|
||||
async settled(): Promise<void> {
|
||||
if (this.#timer !== null) this.#flush()
|
||||
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending])
|
||||
}
|
||||
|
||||
#body(event: WebhookEvent, items: Record<string, unknown>[]): string {
|
||||
return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items })
|
||||
}
|
||||
|
||||
async #attempt(body: string): Promise<string | null> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body)
|
||||
try {
|
||||
const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) })
|
||||
await res.body?.cancel().catch(() => undefined)
|
||||
return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}`
|
||||
} catch (e) {
|
||||
const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code
|
||||
return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/* Delivers one notice and records the result. Returns it. */
|
||||
async deliver(event: WebhookEvent, items: Record<string, unknown>[]): Promise<Delivery> {
|
||||
if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' }
|
||||
const body = this.#body(event, items)
|
||||
let error = await this.#attempt(body)
|
||||
if (error !== null) {
|
||||
if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs))
|
||||
error = await this.#attempt(body)
|
||||
}
|
||||
const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) }
|
||||
this.#last = result
|
||||
if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`)
|
||||
return result
|
||||
}
|
||||
|
||||
/* Fire and forget: never throws, never waits. */
|
||||
send(event: WebhookEvent, items: Record<string, unknown>[]): void {
|
||||
if (this.#url === null || items.length === 0) return
|
||||
const run = this.deliver(event, items).catch(() => undefined)
|
||||
this.#pending.add(run)
|
||||
void run.finally(() => this.#pending.delete(run))
|
||||
}
|
||||
|
||||
/* Collects `write.failed` items for a moment, so ten failed servers make one notice. */
|
||||
queueFailed(item: Record<string, unknown>): void {
|
||||
if (this.#url === null) return
|
||||
this.#queued.push(item)
|
||||
if (this.#timer === null) {
|
||||
this.#timer = setTimeout(() => this.#flush(), this.#batchMs)
|
||||
this.#timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
#flush(): void {
|
||||
if (this.#timer !== null) clearTimeout(this.#timer)
|
||||
this.#timer = null
|
||||
const items = this.#queued.splice(0, 50)
|
||||
this.#queued = []
|
||||
this.send('write.failed', items)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user