@crombolo/server 0.10.0 → 0.12.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.
- package/package.json +12 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/search-index.ts +31 -0
- package/src/adapters/webhooks.ts +76 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +46 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +12 -0
- package/src/jobs.ts +91 -10
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +57 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -29
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- 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
|
+
}
|
package/src/webhooks.ts
ADDED
|
@@ -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
|
+
}
|