@crombolo/server 0.11.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/bundles.ts +2 -1
  3. package/src/adapters/content.ts +2 -0
  4. package/src/adapters/documents.ts +28 -25
  5. package/src/adapters/elements.ts +3 -0
  6. package/src/adapters/health.ts +333 -0
  7. package/src/adapters/media.ts +3 -0
  8. package/src/adapters/members.ts +10 -2
  9. package/src/adapters/references.ts +13 -0
  10. package/src/adapters/relations.ts +48 -0
  11. package/src/adapters/schema-files.ts +41 -11
  12. package/src/adapters/schema-proposals.ts +13 -6
  13. package/src/adapters/webhooks.ts +76 -0
  14. package/src/approvals-api.ts +189 -0
  15. package/src/approvals.ts +823 -0
  16. package/src/boot-failure.ts +147 -0
  17. package/src/bundle-install.ts +148 -13
  18. package/src/bundles.ts +14 -0
  19. package/src/busy.ts +13 -0
  20. package/src/config.ts +54 -2
  21. package/src/database.ts +9 -7
  22. package/src/delivery-members.ts +387 -0
  23. package/src/editor-types.ts +7 -2
  24. package/src/extensions-notice.ts +3 -2
  25. package/src/form-files.ts +38 -3
  26. package/src/form-submit.ts +62 -5
  27. package/src/form-types.ts +187 -0
  28. package/src/form-workflows.ts +66 -6
  29. package/src/forms.ts +15 -7
  30. package/src/index.ts +11 -0
  31. package/src/jobs.ts +17 -0
  32. package/src/marketplace.ts +18 -0
  33. package/src/media-files.ts +3 -1
  34. package/src/member-auth.ts +17 -1
  35. package/src/models-builder.ts +12 -8
  36. package/src/ports.ts +66 -2
  37. package/src/schema.ts +58 -11
  38. package/src/secrets.ts +36 -0
  39. package/src/server.ts +388 -57
  40. package/src/setup.ts +288 -0
  41. package/src/status.ts +5 -2
  42. package/src/transfer-dashboard.ts +219 -0
  43. package/src/upgrade-summary.ts +43 -0
  44. package/src/url-settings.ts +91 -0
  45. package/src/webhook-files.ts +138 -0
  46. package/src/webhooks.ts +544 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * A webhook as a file, `schema/webhooks/<key>.toml`, and the rules every webhook
3
+ * is held to. Pure: reading and writing the directory is `webhooks.ts`'s job.
4
+ * docs/webhooks.md.
5
+ */
6
+ import { WEBHOOK_EVENT_ALIASES } from '@crombolo/api-management'
7
+
8
+ export type WebhookStore = 'files' | 'database'
9
+
10
+ export interface WebhookDefinition {
11
+ key: string
12
+ name: string | null
13
+ description: string | null
14
+ url: string
15
+ enabled: boolean
16
+ events: string[]
17
+ contentTypeKeys: string[]
18
+ /** A value is either `env:NAME` or, kept in the database only, the secret itself. */
19
+ headers: Record<string, string>
20
+ }
21
+
22
+ const ENV_REFERENCE = /^env:([A-Za-z_][A-Za-z0-9_]*)$/
23
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/
24
+ /** Set on every delivery, so a webhook may not set them itself. */
25
+ const RESERVED = new Set([
26
+ 'content-type',
27
+ 'content-length',
28
+ 'host',
29
+ 'webhook-event',
30
+ 'webhook-retry-count',
31
+ 'webhook-id',
32
+ 'webhook-delivery',
33
+ ])
34
+
35
+ /** The variable an `env:NAME` value names, or undefined for a typed-in value. */
36
+ export function envReference(value: string): string | undefined {
37
+ return ENV_REFERENCE.exec(value)?.[1]
38
+ }
39
+
40
+ /** Every reason this webhook cannot be kept in `store`, said as an editor reads it. Pure. */
41
+ export function webhookProblems(webhook: WebhookDefinition, store: WebhookStore): string[] {
42
+ const problems: string[] = []
43
+ if (!/^https?:\/\/[^\s/]+/.test(webhook.url))
44
+ problems.push('The URL must be a web address starting http:// or https://.')
45
+ if (webhook.events.length === 0) problems.push('Choose at least one event.')
46
+ for (const event of webhook.events)
47
+ if (!WEBHOOK_EVENT_ALIASES.has(event))
48
+ problems.push(`"${event}" is not an event this site raises.`)
49
+ for (const [name, value] of Object.entries(webhook.headers)) {
50
+ if (!HEADER_NAME.test(name)) problems.push(`"${name}" is not a header name.`)
51
+ else if (RESERVED.has(name.toLowerCase()))
52
+ problems.push(`The header "${name}" is set on every delivery, so it cannot be set here.`)
53
+ if (store === 'files' && !envReference(value))
54
+ problems.push(
55
+ `The header "${name}" would put its value in the site's files. Give it as env:NAME and set NAME on the server.`,
56
+ )
57
+ }
58
+ return problems
59
+ }
60
+
61
+ const quote = (value: string) => JSON.stringify(value)
62
+ const list = (values: readonly string[]) => `[${values.map(quote).join(', ')}]`
63
+ const key = (name: string) => (/^[A-Za-z0-9_-]+$/.test(name) ? name : quote(name))
64
+
65
+ /** Canonical TOML, so a backoffice edit is a small diff. */
66
+ export function writeWebhookFile(webhook: WebhookDefinition): string {
67
+ const out = ['[webhook]', `key = ${quote(webhook.key)}`]
68
+ if (webhook.name) out.push(`name = ${quote(webhook.name)}`)
69
+ if (webhook.description) out.push(`description = ${quote(webhook.description)}`)
70
+ out.push(`url = ${quote(webhook.url)}`)
71
+ if (!webhook.enabled) out.push('enabled = false')
72
+ out.push(`events = ${list(webhook.events)}`)
73
+ if (webhook.contentTypeKeys.length > 0)
74
+ out.push(`content-types = ${list(webhook.contentTypeKeys)}`)
75
+ const headers = Object.keys(webhook.headers).sort()
76
+ if (headers.length > 0) {
77
+ out.push('', '[webhook.headers]')
78
+ for (const name of headers) out.push(`${key(name)} = ${quote(webhook.headers[name] as string)}`)
79
+ }
80
+ return `${out.join('\n')}\n`
81
+ }
82
+
83
+ const KEYS = new Set([
84
+ 'key',
85
+ 'name',
86
+ 'description',
87
+ 'url',
88
+ 'enabled',
89
+ 'events',
90
+ 'content-types',
91
+ 'headers',
92
+ ])
93
+
94
+ /** A file back to a webhook, or what is wrong with it. Unknown keys are refused, as in every schema file. */
95
+ export function parseWebhookFile(
96
+ file: string,
97
+ source: string,
98
+ ): { webhook?: WebhookDefinition; problems: string[] } {
99
+ let root: Record<string, unknown>
100
+ try {
101
+ root = Bun.TOML.parse(source) as Record<string, unknown>
102
+ } catch (error) {
103
+ return { problems: [`${file}: ${error instanceof Error ? error.message : String(error)}`] }
104
+ }
105
+ const t = root.webhook
106
+ if (!t || typeof t !== 'object' || Array.isArray(t))
107
+ return { problems: [`${file}: a [webhook] table is required`] }
108
+ const table = t as Record<string, unknown>
109
+ const problems: string[] = []
110
+ for (const name of Object.keys(table))
111
+ if (!KEYS.has(name)) problems.push(`${file}: webhook.${name} is not a webhook setting`)
112
+ const text = (name: string): string | null =>
113
+ typeof table[name] === 'string' ? (table[name] as string) : null
114
+ const strings = (name: string): string[] =>
115
+ Array.isArray(table[name])
116
+ ? ((table[name] as unknown[]).filter((v) => typeof v === 'string') as string[])
117
+ : []
118
+ const headers: Record<string, string> = {}
119
+ if (table.headers && typeof table.headers === 'object')
120
+ for (const [name, value] of Object.entries(table.headers as Record<string, unknown>))
121
+ if (typeof value === 'string') headers[name] = value
122
+ else problems.push(`${file}: webhook.headers.${name} must be text`)
123
+ const webhookKey = text('key')
124
+ if (!webhookKey || !/^[0-9a-f-]{36}$/i.test(webhookKey))
125
+ problems.push(`${file}: webhook.key must be the webhook's key`)
126
+ const webhook: WebhookDefinition = {
127
+ key: (webhookKey ?? '').toLowerCase(),
128
+ name: text('name'),
129
+ description: text('description'),
130
+ url: text('url') ?? '',
131
+ enabled: table.enabled !== false,
132
+ events: strings('events'),
133
+ contentTypeKeys: strings('content-types').map((value) => value.toLowerCase()),
134
+ headers,
135
+ }
136
+ problems.push(...webhookProblems(webhook, 'files').map((message) => `${file}: ${message}`))
137
+ return problems.length > 0 ? { problems } : { webhook, problems }
138
+ }
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Webhooks: kept with the site's files or in this environment, raised by
3
+ * changes, delivered by a background job with retries, and every attempt
4
+ * logged. A server bundle's event handlers ride the same queue. docs/webhooks.md.
5
+ */
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { type RaisedWebhookEvent, WEBHOOK_EVENT_ALIASES } from '@crombolo/api-management'
9
+ import {
10
+ type Db,
11
+ fromDbBool,
12
+ type WebhookDelivery,
13
+ WebhookDeliveryRepository,
14
+ type WebhookLogEntry,
15
+ WebhookRepository,
16
+ type WebhookRow,
17
+ } from '@crombolo/data'
18
+ import { retryAfter, type WorkflowRunReport } from './form-workflows.ts'
19
+ import { logger } from './logging.ts'
20
+ import { openSecret, SECRET_KEY_VARIABLE, sealSecret, usableSecretKey } from './secrets.ts'
21
+ import {
22
+ envReference,
23
+ parseWebhookFile,
24
+ type WebhookDefinition,
25
+ type WebhookStore,
26
+ webhookProblems,
27
+ writeWebhookFile,
28
+ } from './webhook-files.ts'
29
+
30
+ const log = logger('webhooks')
31
+
32
+ /** What the backoffice shows instead of a typed-in secret, and sends back to keep it. */
33
+ export const MASKED = '********'
34
+
35
+ /** How long a delivery may take before it counts as failed and is retried. */
36
+ const DELIVERY_TIMEOUT_MS = 30_000
37
+
38
+ export interface WebhookView extends WebhookDefinition {
39
+ store: WebhookStore
40
+ }
41
+
42
+ /** What a bundle's event handler is given: the event, and narrow capabilities. */
43
+ export interface BundleEventContext {
44
+ event: string
45
+ /** Umbraco's Minimal payload: the item's key. */
46
+ payload: { Id: string }
47
+ deliveryId: string
48
+ /** From 1; the queue makes up to five. */
49
+ attempt: number
50
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>
51
+ log: {
52
+ info(message: string, properties?: Record<string, unknown>): void
53
+ warn(message: string, properties?: Record<string, unknown>): void
54
+ error(message: string, properties?: Record<string, unknown>): void
55
+ }
56
+ signal: AbortSignal
57
+ }
58
+
59
+ /** Done, or failed: `retry` tries again on the back-off; a throw counts as retryable. */
60
+ export type BundleEventResult = { ok: true } | { ok: false; error: string; retry: boolean }
61
+
62
+ export type BundleEventHandler = (
63
+ context: BundleEventContext,
64
+ ) => BundleEventResult | Promise<BundleEventResult>
65
+
66
+ /** Event aliases to handlers, as a server bundle declares them. */
67
+ export type BundleEvents = Readonly<Record<string, BundleEventHandler>>
68
+
69
+ const BUNDLE = 'bundle:'
70
+
71
+ /** Why these bundles' event declarations cannot be accepted. Pure. */
72
+ export function bundleEventProblems(
73
+ bundles: readonly { id: string; events?: BundleEvents }[],
74
+ ): string[] {
75
+ const problems: string[] = []
76
+ for (const bundle of bundles)
77
+ for (const [alias, handler] of Object.entries(bundle.events ?? {})) {
78
+ if (!WEBHOOK_EVENT_ALIASES.has(alias))
79
+ problems.push(
80
+ `The bundle "${bundle.id}" handles "${alias}", which is not an event this site raises.`,
81
+ )
82
+ if (typeof handler !== 'function')
83
+ problems.push(`The bundle "${bundle.id}"'s handler for "${alias}" is not a function.`)
84
+ }
85
+ return problems
86
+ }
87
+
88
+ /** Who should hear about an event: the matching webhooks, then the bundles. Pure. */
89
+ export function subscribersFor(
90
+ event: RaisedWebhookEvent,
91
+ contentTypeKey: string | undefined,
92
+ webhooks: readonly WebhookDefinition[],
93
+ bundles: readonly { id: string; events?: BundleEvents }[],
94
+ ): string[] {
95
+ const out: string[] = []
96
+ for (const webhook of webhooks) {
97
+ if (!webhook.enabled || !webhook.events.includes(event.alias)) continue
98
+ // A deleted item's type cannot be read back, and a missed delete is worse
99
+ // than an extra one, so an unknown type is not filtered out.
100
+ if (
101
+ webhook.contentTypeKeys.length > 0 &&
102
+ contentTypeKey !== undefined &&
103
+ !webhook.contentTypeKeys.includes(contentTypeKey)
104
+ )
105
+ continue
106
+ out.push(webhook.key)
107
+ }
108
+ for (const bundle of bundles) if (bundle.events?.[event.alias]) out.push(`${BUNDLE}${bundle.id}`)
109
+ return out
110
+ }
111
+
112
+ /** What the "Where it's kept" panel shows: where it is, and whether it may move. Pure. */
113
+ export function webhookStoreView(
114
+ store: WebhookStore,
115
+ available: { files: boolean; database: boolean },
116
+ ) {
117
+ return {
118
+ store,
119
+ available,
120
+ unavailable: {
121
+ files: available.files ? null : "This server cannot change the site's files.",
122
+ database: available.database
123
+ ? null
124
+ : `Keeping it in this environment needs ${SECRET_KEY_VARIABLE} set on the server.`,
125
+ },
126
+ }
127
+ }
128
+
129
+ export type WebhookWriteResult =
130
+ | { ok: true; key: string }
131
+ | { ok: false; status: 400 | 404 | 409; reason: string; problems?: string[] }
132
+
133
+ export interface WebhookServiceOptions {
134
+ db: Db
135
+ schemaDir: string
136
+ /** Whether this node may write the site's files. */
137
+ filesWritable: boolean
138
+ secretKey?: string
139
+ bundles?: readonly { id: string; events?: BundleEvents }[]
140
+ fetch?: typeof fetch
141
+ /** Publishes the schema directory to a shared store, when there is one. */
142
+ onFilesChanged?: () => Promise<void>
143
+ now?: () => Date
144
+ }
145
+
146
+ export interface WebhookService {
147
+ /** Which stores a new or moved webhook may use on this node. */
148
+ stores(): { files: boolean; database: boolean }
149
+ list(): Promise<WebhookView[]>
150
+ get(key: string): Promise<WebhookView | undefined>
151
+ create(definition: WebhookDefinition, store?: WebhookStore): Promise<WebhookWriteResult>
152
+ update(key: string, definition: WebhookDefinition): Promise<WebhookWriteResult>
153
+ remove(key: string): Promise<boolean>
154
+ move(key: string, store: WebhookStore): Promise<WebhookWriteResult>
155
+ /** Queues an event for everyone listening. Never throws into the caller. */
156
+ raise(event: RaisedWebhookEvent): Promise<number>
157
+ runDue(now?: Date, limit?: number): Promise<WorkflowRunReport>
158
+ logs(
159
+ subscriber: string | undefined,
160
+ paging: { skip: number; take: number },
161
+ ): Promise<{ total: number; items: WebhookLogEntry[] }>
162
+ }
163
+
164
+ const unavailable = (store: WebhookStore): WebhookWriteResult => ({
165
+ ok: false,
166
+ status: 400,
167
+ reason:
168
+ store === 'database'
169
+ ? `Keeping a webhook in this environment needs ${SECRET_KEY_VARIABLE} set on the server.`
170
+ : "This server cannot change the site's files, so a webhook cannot be kept with them here.",
171
+ })
172
+
173
+ export function createWebhookService(options: WebhookServiceOptions): WebhookService {
174
+ const rows = new WebhookRepository(options.db)
175
+ const deliveries = new WebhookDeliveryRepository(options.db)
176
+ const dir = join(options.schemaDir, 'webhooks')
177
+ const secret = usableSecretKey(options.secretKey)
178
+ const bundles = options.bundles ?? []
179
+ const now = options.now ?? (() => new Date())
180
+
181
+ const stores = () => ({ files: options.filesWritable, database: secret !== undefined })
182
+
183
+ const fromFiles = (): WebhookDefinition[] => {
184
+ if (!existsSync(dir)) return []
185
+ const out: WebhookDefinition[] = []
186
+ for (const name of readdirSync(dir)
187
+ .filter((file) => file.endsWith('.toml'))
188
+ .sort()) {
189
+ const parsed = parseWebhookFile(
190
+ `schema/webhooks/${name}`,
191
+ readFileSync(join(dir, name), 'utf8'),
192
+ )
193
+ if (parsed.webhook) out.push(parsed.webhook)
194
+ else log.warning('A webhook file was skipped: {problems}', { problems: parsed.problems })
195
+ }
196
+ return out
197
+ }
198
+
199
+ const headersOf = (row: WebhookRow): Record<string, string> => {
200
+ if (!row.sealedHeaders) return {}
201
+ if (!secret)
202
+ throw new Error(
203
+ `${SECRET_KEY_VARIABLE} is not set, so this webhook's headers cannot be read.`,
204
+ )
205
+ return JSON.parse(openSecret(row.sealedHeaders, secret)) as Record<string, string>
206
+ }
207
+
208
+ const fromDatabase = async (): Promise<WebhookDefinition[]> =>
209
+ (await rows.all()).map((row) => {
210
+ let headers: Record<string, string> = {}
211
+ try {
212
+ headers = headersOf(row)
213
+ } catch {
214
+ headers = {}
215
+ }
216
+ const { sealedHeaders: _, ...rest } = row
217
+ return { ...rest, headers }
218
+ })
219
+
220
+ const all = async (): Promise<WebhookView[]> => [
221
+ ...fromFiles().map((webhook) => ({ ...webhook, store: 'files' as const })),
222
+ ...(await fromDatabase()).map((webhook) => ({ ...webhook, store: 'database' as const })),
223
+ ]
224
+
225
+ const writeFile = async (webhook: WebhookDefinition) => {
226
+ mkdirSync(dir, { recursive: true })
227
+ writeFileSync(join(dir, `${webhook.key}.toml`), writeWebhookFile(webhook), 'utf8')
228
+ await options.onFilesChanged?.()
229
+ }
230
+ const removeFile = async (key: string) => {
231
+ rmSync(join(dir, `${key}.toml`), { force: true })
232
+ await options.onFilesChanged?.()
233
+ }
234
+ const writeRow = async (webhook: WebhookDefinition) => {
235
+ const headers = Object.keys(webhook.headers).length > 0 ? webhook.headers : undefined
236
+ if (headers && !secret) throw new Error('unreachable: a database write without a key')
237
+ await rows.save({
238
+ ...webhook,
239
+ sealedHeaders: headers && secret ? sealSecret(JSON.stringify(headers), secret) : null,
240
+ })
241
+ }
242
+
243
+ const store = async (
244
+ webhook: WebhookDefinition,
245
+ where: WebhookStore,
246
+ ): Promise<WebhookWriteResult> => {
247
+ if (!stores()[where]) return unavailable(where)
248
+ const problems = webhookProblems(webhook, where)
249
+ if (problems.length > 0)
250
+ return { ok: false, status: 400, reason: 'The webhook is not valid.', problems }
251
+ if (where === 'files') await writeFile(webhook)
252
+ else await writeRow(webhook)
253
+ return { ok: true, key: webhook.key }
254
+ }
255
+
256
+ /** A masked value sent back means "keep what is stored". */
257
+ const unmask = (incoming: Record<string, string>, stored: Record<string, string>) => {
258
+ const out: Record<string, string> = {}
259
+ for (const [name, value] of Object.entries(incoming))
260
+ out[name] = value === MASKED ? (stored[name] ?? '') : value
261
+ return out
262
+ }
263
+
264
+ const resolveHeaders = (headers: Record<string, string>): Record<string, string> => {
265
+ const out: Record<string, string> = {}
266
+ for (const [name, value] of Object.entries(headers)) {
267
+ const variable = envReference(value)
268
+ if (!variable) {
269
+ out[name] = value
270
+ continue
271
+ }
272
+ const found = Bun.env[variable]
273
+ if (found === undefined)
274
+ throw new Error(`The header "${name}" names ${variable}, which is not set on the server.`)
275
+ out[name] = found
276
+ }
277
+ return out
278
+ }
279
+
280
+ const deliverToWebhook = async (
281
+ delivery: WebhookDelivery,
282
+ webhook: WebhookDefinition,
283
+ ): Promise<{ ok: true } | { ok: false; error: string; retry: boolean }> => {
284
+ const body = JSON.stringify(delivery.payload)
285
+ const fixed = {
286
+ 'Content-Type': 'application/json',
287
+ 'Webhook-Event': delivery.eventAlias,
288
+ 'Webhook-Retry-Count': String(delivery.attempts - 1),
289
+ 'Webhook-Id': webhook.key,
290
+ 'Webhook-Delivery': delivery.id,
291
+ }
292
+ // The log shows which headers were sent, never what their values were.
293
+ const logged = JSON.stringify({
294
+ ...fixed,
295
+ ...Object.fromEntries(Object.keys(webhook.headers).map((name) => [name, MASKED])),
296
+ })
297
+ const entry = {
298
+ deliveryId: delivery.id,
299
+ subscriber: webhook.key,
300
+ eventAlias: delivery.eventAlias,
301
+ url: webhook.url,
302
+ retryCount: delivery.attempts - 1,
303
+ requestHeaders: logged,
304
+ requestBody: body,
305
+ }
306
+ let headers: Record<string, string>
307
+ try {
308
+ headers = { ...resolveHeaders(webhook.headers), ...fixed }
309
+ } catch (error) {
310
+ const message = error instanceof Error ? error.message : String(error)
311
+ await deliveries.log({
312
+ ...entry,
313
+ statusCode: null,
314
+ success: false,
315
+ exception: true,
316
+ responseHeaders: '{}',
317
+ responseBody: message,
318
+ })
319
+ return { ok: false, error: message, retry: false }
320
+ }
321
+ try {
322
+ const response = await (options.fetch ?? fetch)(webhook.url, {
323
+ method: 'POST',
324
+ headers,
325
+ body,
326
+ signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
327
+ redirect: 'manual',
328
+ })
329
+ const responseBody = await response.text().catch(() => '')
330
+ await deliveries.log({
331
+ ...entry,
332
+ statusCode: response.status,
333
+ success: response.ok,
334
+ exception: false,
335
+ responseHeaders: JSON.stringify(Object.fromEntries(response.headers)),
336
+ responseBody,
337
+ })
338
+ if (response.ok) return { ok: true }
339
+ // A refusal will refuse again; a fault or a busy receiver may not last.
340
+ const retry = response.status >= 500 || response.status === 408 || response.status === 429
341
+ return { ok: false, error: `The receiver answered ${response.status}.`, retry }
342
+ } catch (error) {
343
+ const message = error instanceof Error ? error.message : String(error)
344
+ await deliveries.log({
345
+ ...entry,
346
+ statusCode: null,
347
+ success: false,
348
+ exception: true,
349
+ responseHeaders: '{}',
350
+ responseBody: message,
351
+ })
352
+ return { ok: false, error: message, retry: true }
353
+ }
354
+ }
355
+
356
+ const deliverToBundle = async (
357
+ delivery: WebhookDelivery,
358
+ bundleId: string,
359
+ ): Promise<{ ok: true } | { ok: false; error: string; retry: boolean }> => {
360
+ const handler = bundles.find((bundle) => bundle.id === bundleId)?.events?.[delivery.eventAlias]
361
+ const entry = {
362
+ deliveryId: delivery.id,
363
+ subscriber: `${BUNDLE}${bundleId}`,
364
+ eventAlias: delivery.eventAlias,
365
+ url: `${BUNDLE}${bundleId}`,
366
+ retryCount: delivery.attempts - 1,
367
+ requestHeaders: '{}',
368
+ requestBody: JSON.stringify(delivery.payload),
369
+ statusCode: null,
370
+ responseHeaders: '{}',
371
+ }
372
+ if (!handler) {
373
+ const message = `The bundle "${bundleId}" no longer handles ${delivery.eventAlias}.`
374
+ await deliveries.log({ ...entry, success: false, exception: false, responseBody: message })
375
+ return { ok: false, error: message, retry: false }
376
+ }
377
+ const named = logger('webhooks', bundleId)
378
+ const signal = AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
379
+ let result: BundleEventResult
380
+ try {
381
+ result = await handler({
382
+ event: delivery.eventAlias,
383
+ payload: delivery.payload as { Id: string },
384
+ deliveryId: delivery.id,
385
+ attempt: delivery.attempts,
386
+ fetch: (input, init) => (options.fetch ?? fetch)(input, { signal, ...init }),
387
+ log: {
388
+ info: (message, properties) => named.info(message, properties),
389
+ warn: (message, properties) => named.warn(message, properties),
390
+ error: (message, properties) => named.error(message, properties),
391
+ },
392
+ signal,
393
+ })
394
+ } catch (error) {
395
+ result = {
396
+ ok: false,
397
+ error: error instanceof Error ? error.message : String(error),
398
+ retry: true,
399
+ }
400
+ }
401
+ await deliveries.log({
402
+ ...entry,
403
+ success: result.ok,
404
+ exception: false,
405
+ responseBody: result.ok ? '' : result.error,
406
+ })
407
+ return result
408
+ }
409
+
410
+ return {
411
+ stores,
412
+ list: all,
413
+ async get(key) {
414
+ return (await all()).find((webhook) => webhook.key === key.toLowerCase())
415
+ },
416
+ async create(definition, where) {
417
+ const key = definition.key.toLowerCase()
418
+ if (await this.get(key))
419
+ return { ok: false, status: 409, reason: 'A webhook with that key exists.' }
420
+ // A new webhook starts in this environment when it can, so a typed-in
421
+ // secret is never written to the files by default.
422
+ const chosen = where ?? (stores().database ? 'database' : 'files')
423
+ if (!stores().database && !stores().files)
424
+ return {
425
+ ok: false,
426
+ status: 400,
427
+ reason: `Webhooks cannot be added on this server: set ${SECRET_KEY_VARIABLE}, or add them where the site's files can be changed.`,
428
+ }
429
+ return store({ ...definition, key }, chosen)
430
+ },
431
+ async update(key, definition) {
432
+ const existing = await this.get(key)
433
+ if (!existing) return { ok: false, status: 404, reason: 'The webhook could not be found.' }
434
+ const next = {
435
+ ...definition,
436
+ key: existing.key,
437
+ headers: unmask(definition.headers, existing.headers),
438
+ }
439
+ return store(next, existing.store)
440
+ },
441
+ async remove(key) {
442
+ const existing = await this.get(key)
443
+ if (!existing) return false
444
+ if (existing.store === 'files') {
445
+ if (!options.filesWritable) return false
446
+ await removeFile(existing.key)
447
+ } else await rows.remove(existing.key)
448
+ return true
449
+ },
450
+ async move(key, where) {
451
+ const existing = await this.get(key)
452
+ if (!existing) return { ok: false, status: 404, reason: 'The webhook could not be found.' }
453
+ if (existing.store === where) return { ok: true, key: existing.key }
454
+ const { store: _, ...definition } = existing
455
+ const written = await store(definition, where)
456
+ if (!written.ok) return written
457
+ if (existing.store === 'files') await removeFile(existing.key)
458
+ else await rows.remove(existing.key)
459
+ return written
460
+ },
461
+ async raise(event) {
462
+ try {
463
+ const facts = await itemFacts(options.db, event.key)
464
+ if (!eventHappened(event.alias, facts.published)) return 0
465
+ const subscribers = subscribersFor(event, facts.contentTypeKey, await all(), bundles)
466
+ if (subscribers.length === 0) return 0
467
+ await deliveries.enqueue(
468
+ subscribers.map((subscriber) => ({
469
+ subscriber,
470
+ eventAlias: event.alias,
471
+ payload: { Id: event.key },
472
+ })),
473
+ now(),
474
+ )
475
+ return subscribers.length
476
+ } catch (error) {
477
+ log.error('Could not queue {event} for {key}: {error}', { ...event, error })
478
+ return 0
479
+ }
480
+ },
481
+ async runDue(at = now(), limit = 25) {
482
+ const report: WorkflowRunReport = { claimed: 0, done: 0, retrying: 0, failed: 0 }
483
+ for (const due of await deliveries.due(at, limit)) {
484
+ const delivery = await deliveries.claim(due.id)
485
+ if (!delivery) continue
486
+ report.claimed++
487
+ let result: { ok: true } | { ok: false; error: string; retry: boolean }
488
+ if (delivery.subscriber.startsWith(BUNDLE))
489
+ result = await deliverToBundle(delivery, delivery.subscriber.slice(BUNDLE.length))
490
+ else {
491
+ const webhook = await this.get(delivery.subscriber)
492
+ result = webhook
493
+ ? await deliverToWebhook(delivery, webhook)
494
+ : { ok: false, error: 'The webhook no longer exists.', retry: false }
495
+ }
496
+ if (result.ok) {
497
+ await deliveries.succeed(delivery.id)
498
+ report.done++
499
+ continue
500
+ }
501
+ const again = result.retry ? retryAfter(delivery.attempts, at) : undefined
502
+ await deliveries.fail(delivery.id, result.error, again)
503
+ if (again) report.retrying++
504
+ else report.failed++
505
+ }
506
+ return report
507
+ },
508
+ logs: (subscriber, paging) => deliveries.logs({ subscriber, ...paging }),
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Whether the change an event names really happened. A publish request that
514
+ * only schedules one answers 200 too, and must not tell anyone the page is live
515
+ * until the schedule makes it so. Pure; `published` is undefined for an item
516
+ * that is gone or not a document.
517
+ */
518
+ export function eventHappened(alias: string, published: boolean | undefined): boolean {
519
+ if (alias === 'Umbraco.ContentPublish') return published === true
520
+ if (alias === 'Umbraco.ContentUnpublish') return published !== true
521
+ return true
522
+ }
523
+
524
+ /** An item's type and whether it is published, while it still exists. */
525
+ async function itemFacts(
526
+ db: Db,
527
+ key: string,
528
+ ): Promise<{ contentTypeKey: string | undefined; published: boolean | undefined }> {
529
+ const rows = await db.query<{ type_key: string; published: unknown }>(
530
+ `SELECT tn.unique_id AS type_key, d.published FROM node n
531
+ JOIN content c ON c.node_id = n.id
532
+ JOIN node tn ON tn.id = c.content_type_id
533
+ LEFT JOIN document d ON d.node_id = n.id
534
+ WHERE n.unique_id = ?`,
535
+ [key],
536
+ )
537
+ const row = rows[0]
538
+ if (!row) return { contentTypeKey: undefined, published: undefined }
539
+ return {
540
+ contentTypeKey: String(row.type_key).toLowerCase(),
541
+ published:
542
+ row.published === null || row.published === undefined ? undefined : fromDbBool(row.published),
543
+ }
544
+ }