@10x-media/webhooks 0.1.0-beta.0
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/exports/client.d.ts +14 -0
- package/dist/exports/client.js +83 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/i18n.d.ts +37 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/types.d.ts +2 -0
- package/dist/exports/types.js +1 -0
- package/dist/index-CpsTtHO1.d.ts +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +849 -0
- package/dist/index.js.map +1 -0
- package/dist/keys-BdNiD3rC.js +31 -0
- package/dist/keys-BdNiD3rC.js.map +1 -0
- package/dist/translations-CnkwrCJ3.js +51 -0
- package/dist/translations-CnkwrCJ3.js.map +1 -0
- package/package.json +105 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import { t as keys } from "./keys-BdNiD3rC.js";
|
|
2
|
+
import { t as translations } from "./translations-CnkwrCJ3.js";
|
|
3
|
+
import { definePlugin } from "payload";
|
|
4
|
+
import { deepMergeSimple } from "payload/shared";
|
|
5
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
6
|
+
//#region src/plugin/registerTranslations.ts
|
|
7
|
+
/**
|
|
8
|
+
* Merge this plugin's translations into the host config. A host value wins on
|
|
9
|
+
* conflict (`deepMergeSimple` lets the second argument override), so projects can
|
|
10
|
+
* override any string.
|
|
11
|
+
*/
|
|
12
|
+
const registerTranslations = (config) => {
|
|
13
|
+
config.i18n ??= {};
|
|
14
|
+
config.i18n.translations = deepMergeSimple(translations, config.i18n.translations ?? {});
|
|
15
|
+
};
|
|
16
|
+
const ADMIN_GROUP = "Webhooks";
|
|
17
|
+
const WEBHOOK_DELIVER_TASK = "webhooksDeliver";
|
|
18
|
+
/** Placeholder returned for the signing secret on every read after its single create reveal. */
|
|
19
|
+
const SECRET_MASK = "__redacted__";
|
|
20
|
+
/**
|
|
21
|
+
* `req.context` flags that opt a subscription read into seeing the raw signing secret.
|
|
22
|
+
* The field `afterRead` mask runs even under `overrideAccess`, so internal signing reads
|
|
23
|
+
* must set `revealSecretForSigning` to recover the raw value; `revealSecretOnce` is set by
|
|
24
|
+
* the create `beforeChange` so the create response shows the secret exactly once.
|
|
25
|
+
*/
|
|
26
|
+
const SECRET_REVEAL_CONTEXT = {
|
|
27
|
+
forSigning: "webhooksRevealSecretForSigning",
|
|
28
|
+
once: "webhooksRevealSecretOnce"
|
|
29
|
+
};
|
|
30
|
+
const RESERVED_SLUGS = [
|
|
31
|
+
"payload-jobs",
|
|
32
|
+
"payload-locks",
|
|
33
|
+
"payload-preferences",
|
|
34
|
+
"payload-migrations"
|
|
35
|
+
];
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/translations/server.ts
|
|
38
|
+
/**
|
|
39
|
+
* Adapt a Payload request `t` (typed to core keys only) so it also accepts this
|
|
40
|
+
* plugin's keys. They are registered at config time and resolve at runtime; the
|
|
41
|
+
* cast only widens the compile-time key domain.
|
|
42
|
+
*/
|
|
43
|
+
const asTranslate = (t) => t;
|
|
44
|
+
/** A field `label`/`description` backed by a typed key, resolved per request. */
|
|
45
|
+
const labelForKey = (key) => ({ t }) => asTranslate(t)(key);
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/collections/deliveries.ts
|
|
48
|
+
const STATUS_CELL = "@10x-media/webhooks/client#DeliveryStatusCell";
|
|
49
|
+
const loggedIn$1 = ({ req }) => Boolean(req.user);
|
|
50
|
+
/** Runner-written delivery audit log; read-only in admin, server writes use overrideAccess. */
|
|
51
|
+
const buildDeliveriesCollection = (args) => ({
|
|
52
|
+
slug: args.slug,
|
|
53
|
+
labels: {
|
|
54
|
+
singular: labelForKey(keys.deliverySingular),
|
|
55
|
+
plural: labelForKey(keys.deliveryPlural)
|
|
56
|
+
},
|
|
57
|
+
admin: {
|
|
58
|
+
group: ADMIN_GROUP,
|
|
59
|
+
useAsTitle: "event",
|
|
60
|
+
defaultColumns: [
|
|
61
|
+
"event",
|
|
62
|
+
"endpoint",
|
|
63
|
+
"status",
|
|
64
|
+
"responseStatus",
|
|
65
|
+
"attempt",
|
|
66
|
+
"createdAt"
|
|
67
|
+
],
|
|
68
|
+
hidden: args.hidden
|
|
69
|
+
},
|
|
70
|
+
access: {
|
|
71
|
+
read: loggedIn$1,
|
|
72
|
+
create: () => false,
|
|
73
|
+
update: () => false,
|
|
74
|
+
delete: loggedIn$1
|
|
75
|
+
},
|
|
76
|
+
fields: [
|
|
77
|
+
{
|
|
78
|
+
name: "subscriptionId",
|
|
79
|
+
type: "text",
|
|
80
|
+
index: true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "endpoint",
|
|
84
|
+
type: "text"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "event",
|
|
88
|
+
type: "text",
|
|
89
|
+
index: true
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "status",
|
|
93
|
+
type: "select",
|
|
94
|
+
defaultValue: "pending",
|
|
95
|
+
options: [
|
|
96
|
+
"pending",
|
|
97
|
+
"success",
|
|
98
|
+
"failed",
|
|
99
|
+
"dead"
|
|
100
|
+
],
|
|
101
|
+
admin: { components: { Cell: STATUS_CELL } }
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "attempt",
|
|
105
|
+
type: "number",
|
|
106
|
+
defaultValue: 0
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "responseStatus",
|
|
110
|
+
type: "number"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "responseBody",
|
|
114
|
+
type: "textarea"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "error",
|
|
118
|
+
type: "textarea"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "durationMs",
|
|
122
|
+
type: "number"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "jobId",
|
|
126
|
+
type: "text"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "payload",
|
|
130
|
+
type: "json"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "redeliver",
|
|
134
|
+
type: "ui",
|
|
135
|
+
admin: { components: { Field: "@10x-media/webhooks/client#RedeliverButton" } }
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
});
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/collections/subscriptions.ts
|
|
141
|
+
const generateSecret = ({ data, operation, req }) => {
|
|
142
|
+
if (operation === "create" && !data.secret) {
|
|
143
|
+
req.context[SECRET_REVEAL_CONTEXT.once] = true;
|
|
144
|
+
return {
|
|
145
|
+
...data,
|
|
146
|
+
secret: randomBytes(24).toString("hex")
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return data;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* The DB always stores the raw secret; this mask only shapes read output. It runs even under
|
|
153
|
+
* `overrideAccess` (field hooks are not gated by access), so the raw value reaches a reader only
|
|
154
|
+
* when a reveal flag is set: `once` on the create response, `forSigning` on internal delivery reads.
|
|
155
|
+
*/
|
|
156
|
+
const maskSecret = ({ value, req }) => {
|
|
157
|
+
if (req.context[SECRET_REVEAL_CONTEXT.forSigning] || req.context[SECRET_REVEAL_CONTEXT.once]) return value;
|
|
158
|
+
return value == null ? value : SECRET_MASK;
|
|
159
|
+
};
|
|
160
|
+
const clearRevealOnce = ({ doc, req }) => {
|
|
161
|
+
req.context[SECRET_REVEAL_CONTEXT.once] = false;
|
|
162
|
+
return doc;
|
|
163
|
+
};
|
|
164
|
+
const loggedIn = ({ req }) => Boolean(req.user);
|
|
165
|
+
/** Admin-managed subscriptions collection; `events` options come from the catalog. */
|
|
166
|
+
const buildSubscriptionsCollection = (args) => ({
|
|
167
|
+
slug: args.slug,
|
|
168
|
+
labels: {
|
|
169
|
+
singular: labelForKey(keys.subscriptionSingular),
|
|
170
|
+
plural: labelForKey(keys.subscriptionPlural)
|
|
171
|
+
},
|
|
172
|
+
admin: {
|
|
173
|
+
group: ADMIN_GROUP,
|
|
174
|
+
useAsTitle: "name",
|
|
175
|
+
defaultColumns: [
|
|
176
|
+
"name",
|
|
177
|
+
"url",
|
|
178
|
+
"enabled"
|
|
179
|
+
],
|
|
180
|
+
hidden: args.hidden
|
|
181
|
+
},
|
|
182
|
+
access: {
|
|
183
|
+
read: loggedIn,
|
|
184
|
+
create: loggedIn,
|
|
185
|
+
update: loggedIn,
|
|
186
|
+
delete: loggedIn
|
|
187
|
+
},
|
|
188
|
+
hooks: {
|
|
189
|
+
beforeChange: [generateSecret],
|
|
190
|
+
afterChange: [clearRevealOnce]
|
|
191
|
+
},
|
|
192
|
+
fields: [
|
|
193
|
+
{
|
|
194
|
+
name: "name",
|
|
195
|
+
type: "text",
|
|
196
|
+
required: true,
|
|
197
|
+
label: labelForKey(keys.fieldName)
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "url",
|
|
201
|
+
type: "text",
|
|
202
|
+
required: true,
|
|
203
|
+
label: labelForKey(keys.fieldUrl)
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "enabled",
|
|
207
|
+
type: "checkbox",
|
|
208
|
+
defaultValue: true,
|
|
209
|
+
label: labelForKey(keys.fieldEnabled)
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "events",
|
|
213
|
+
type: "select",
|
|
214
|
+
hasMany: true,
|
|
215
|
+
label: labelForKey(keys.fieldEvents),
|
|
216
|
+
options: args.events.length ? args.events.map((e) => ({
|
|
217
|
+
label: e,
|
|
218
|
+
value: e
|
|
219
|
+
})) : [{
|
|
220
|
+
label: "(none)",
|
|
221
|
+
value: "__none__"
|
|
222
|
+
}]
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "secret",
|
|
226
|
+
type: "text",
|
|
227
|
+
label: labelForKey(keys.fieldSecret),
|
|
228
|
+
admin: {
|
|
229
|
+
readOnly: true,
|
|
230
|
+
description: labelForKey(keys.fieldSecretHelp)
|
|
231
|
+
},
|
|
232
|
+
access: { update: () => false },
|
|
233
|
+
hooks: { afterRead: [maskSecret] }
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "headers",
|
|
237
|
+
type: "array",
|
|
238
|
+
label: labelForKey(keys.fieldHeaders),
|
|
239
|
+
fields: [{
|
|
240
|
+
name: "key",
|
|
241
|
+
type: "text",
|
|
242
|
+
required: true
|
|
243
|
+
}, {
|
|
244
|
+
name: "value",
|
|
245
|
+
type: "text"
|
|
246
|
+
}]
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "description",
|
|
250
|
+
type: "textarea",
|
|
251
|
+
label: labelForKey(keys.fieldDescription)
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
});
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/plugin/resolveSubscriptions.ts
|
|
257
|
+
const rowHeaders = (headers) => {
|
|
258
|
+
if (!headers?.length) return;
|
|
259
|
+
const out = {};
|
|
260
|
+
for (const h of headers) if (h.key) out[h.key] = h.value ?? "";
|
|
261
|
+
return Object.keys(out).length ? out : void 0;
|
|
262
|
+
};
|
|
263
|
+
/** Normalize a subscriptions-collection document. */
|
|
264
|
+
const fromCollectionRow = (row) => ({
|
|
265
|
+
id: String(row.id),
|
|
266
|
+
source: "collection",
|
|
267
|
+
url: row.url,
|
|
268
|
+
events: row.events ?? [],
|
|
269
|
+
secret: row.secret ?? void 0,
|
|
270
|
+
headers: rowHeaders(row.headers),
|
|
271
|
+
enabled: row.enabled !== false
|
|
272
|
+
});
|
|
273
|
+
/** Normalize a code-defined subscription. */
|
|
274
|
+
const fromCodeSubscription = (sub) => ({
|
|
275
|
+
id: sub.id,
|
|
276
|
+
source: "code",
|
|
277
|
+
url: sub.url,
|
|
278
|
+
events: sub.events,
|
|
279
|
+
secret: sub.secret,
|
|
280
|
+
headers: sub.headers,
|
|
281
|
+
enabled: sub.enabled !== false
|
|
282
|
+
});
|
|
283
|
+
/** Enabled subscriptions listening for `event`. */
|
|
284
|
+
const matchSubscriptions = (subs, event) => subs.filter((s) => s.enabled && s.events.includes(event));
|
|
285
|
+
/** Look up one subscription by id (code first, then the collection). */
|
|
286
|
+
const resolveSubscriptionById = async (args) => {
|
|
287
|
+
const code = args.codeSubscriptions.find((s) => s.id === args.id);
|
|
288
|
+
if (code) return fromCodeSubscription(code);
|
|
289
|
+
args.req.context[SECRET_REVEAL_CONTEXT.forSigning] = true;
|
|
290
|
+
try {
|
|
291
|
+
const row = (await args.payload.find({
|
|
292
|
+
collection: args.subscriptionsSlug,
|
|
293
|
+
where: { id: { equals: args.id } },
|
|
294
|
+
limit: 1,
|
|
295
|
+
depth: 0,
|
|
296
|
+
overrideAccess: true,
|
|
297
|
+
req: args.req
|
|
298
|
+
})).docs[0];
|
|
299
|
+
return row ? fromCollectionRow(row) : null;
|
|
300
|
+
} finally {
|
|
301
|
+
args.req.context[SECRET_REVEAL_CONTEXT.forSigning] = false;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/delivery/deriveDeliveryStatus.ts
|
|
306
|
+
/** Delivery status from the attempt outcome. `attempt` is 1-based; total tries = maxRetries + 1. */
|
|
307
|
+
const deriveDeliveryStatus = (args) => {
|
|
308
|
+
if (args.ok) return "success";
|
|
309
|
+
return args.attempt > args.maxRetries ? "dead" : "failed";
|
|
310
|
+
};
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/delivery/deliver.ts
|
|
313
|
+
/** Max response-body characters retained for the delivery log. */
|
|
314
|
+
const MAX_RESPONSE_BODY = 2e3;
|
|
315
|
+
/** POST `body` to `url` with a hard timeout; never throws. */
|
|
316
|
+
const deliver = async (args) => {
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const timer = setTimeout(() => controller.abort(), args.timeoutMs);
|
|
319
|
+
const start = Date.now();
|
|
320
|
+
try {
|
|
321
|
+
const res = await fetch(args.url, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: args.headers,
|
|
324
|
+
body: args.body,
|
|
325
|
+
signal: controller.signal
|
|
326
|
+
});
|
|
327
|
+
const text = await res.text();
|
|
328
|
+
return {
|
|
329
|
+
ok: res.ok,
|
|
330
|
+
responseStatus: res.status,
|
|
331
|
+
responseBody: text.slice(0, MAX_RESPONSE_BODY),
|
|
332
|
+
durationMs: Date.now() - start
|
|
333
|
+
};
|
|
334
|
+
} catch (err) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: err instanceof Error ? err.message : String(err),
|
|
338
|
+
durationMs: Date.now() - start
|
|
339
|
+
};
|
|
340
|
+
} finally {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/delivery/sign.ts
|
|
346
|
+
/** HMAC-SHA256 over `${timestamp}.${body}`, hex-encoded. */
|
|
347
|
+
const signPayload = (args) => createHmac("sha256", args.secret).update(`${args.timestamp}.${args.body}`).digest("hex");
|
|
348
|
+
/** Wrap a signature hex in the versioned header value. */
|
|
349
|
+
const signatureHeader = (hex) => `v1=${hex}`;
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/delivery/sendDelivery.ts
|
|
352
|
+
const USER_AGENT = "10x-media-webhooks";
|
|
353
|
+
/** Assemble headers (+ signature) and POST the body to the subscription's URL. */
|
|
354
|
+
const sendDelivery = (args) => {
|
|
355
|
+
const { subscription, deliveryId, event, body, timeoutMs, now } = args;
|
|
356
|
+
const timestamp = Math.floor(now / 1e3);
|
|
357
|
+
const headers = {
|
|
358
|
+
"Content-Type": "application/json",
|
|
359
|
+
"User-Agent": USER_AGENT,
|
|
360
|
+
"X-Webhook-Id": deliveryId,
|
|
361
|
+
"X-Webhook-Event": event,
|
|
362
|
+
"X-Webhook-Timestamp": String(timestamp),
|
|
363
|
+
...subscription.headers
|
|
364
|
+
};
|
|
365
|
+
if (subscription.secret) headers["X-Webhook-Signature"] = signatureHeader(signPayload({
|
|
366
|
+
secret: subscription.secret,
|
|
367
|
+
timestamp,
|
|
368
|
+
body
|
|
369
|
+
}));
|
|
370
|
+
return deliver({
|
|
371
|
+
url: subscription.url,
|
|
372
|
+
body,
|
|
373
|
+
headers,
|
|
374
|
+
timeoutMs
|
|
375
|
+
});
|
|
376
|
+
};
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/delivery/deliverTask.ts
|
|
379
|
+
/** Native Payload jobs task that performs one queued delivery attempt. */
|
|
380
|
+
const buildDeliverTask = (deps) => ({
|
|
381
|
+
slug: WEBHOOK_DELIVER_TASK,
|
|
382
|
+
retries: deps.retries,
|
|
383
|
+
inputSchema: [{
|
|
384
|
+
name: "deliveryId",
|
|
385
|
+
type: "text",
|
|
386
|
+
required: true
|
|
387
|
+
}],
|
|
388
|
+
handler: async ({ input, job, req }) => {
|
|
389
|
+
const { payload } = req;
|
|
390
|
+
const deliveryId = input.deliveryId;
|
|
391
|
+
const delivery = await payload.findByID({
|
|
392
|
+
collection: deps.deliveriesSlug,
|
|
393
|
+
id: deliveryId,
|
|
394
|
+
overrideAccess: true,
|
|
395
|
+
req
|
|
396
|
+
});
|
|
397
|
+
const subscription = await resolveSubscriptionById({
|
|
398
|
+
id: String(delivery.subscriptionId),
|
|
399
|
+
codeSubscriptions: deps.codeSubscriptions,
|
|
400
|
+
subscriptionsSlug: deps.subscriptionsSlug,
|
|
401
|
+
payload,
|
|
402
|
+
req
|
|
403
|
+
});
|
|
404
|
+
if (!subscription) {
|
|
405
|
+
await payload.update({
|
|
406
|
+
collection: deps.deliveriesSlug,
|
|
407
|
+
id: deliveryId,
|
|
408
|
+
data: {
|
|
409
|
+
status: "dead",
|
|
410
|
+
error: "subscription not found"
|
|
411
|
+
},
|
|
412
|
+
overrideAccess: true,
|
|
413
|
+
req
|
|
414
|
+
});
|
|
415
|
+
return { output: {} };
|
|
416
|
+
}
|
|
417
|
+
const attempt = Number(job.totalTried ?? 0) + 1;
|
|
418
|
+
const result = await sendDelivery({
|
|
419
|
+
subscription,
|
|
420
|
+
deliveryId,
|
|
421
|
+
event: String(delivery.event),
|
|
422
|
+
body: JSON.stringify(delivery.payload),
|
|
423
|
+
timeoutMs: deps.timeoutMs,
|
|
424
|
+
now: Date.now()
|
|
425
|
+
});
|
|
426
|
+
const status = deriveDeliveryStatus({
|
|
427
|
+
ok: result.ok,
|
|
428
|
+
attempt,
|
|
429
|
+
maxRetries: deps.retries
|
|
430
|
+
});
|
|
431
|
+
await payload.update({
|
|
432
|
+
collection: deps.deliveriesSlug,
|
|
433
|
+
id: deliveryId,
|
|
434
|
+
data: {
|
|
435
|
+
status,
|
|
436
|
+
attempt,
|
|
437
|
+
responseStatus: result.responseStatus,
|
|
438
|
+
responseBody: result.responseBody,
|
|
439
|
+
error: result.error,
|
|
440
|
+
durationMs: result.durationMs,
|
|
441
|
+
jobId: String(job.id)
|
|
442
|
+
},
|
|
443
|
+
overrideAccess: true,
|
|
444
|
+
req
|
|
445
|
+
});
|
|
446
|
+
if (!result.ok) throw new Error(`Webhook delivery failed: ${result.error ?? result.responseStatus}`);
|
|
447
|
+
return { output: {} };
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/delivery/redeliver.ts
|
|
452
|
+
/** Re-dispatch a past delivery from its stored payload, creating a new linked row. */
|
|
453
|
+
const redeliverDelivery = async (args) => {
|
|
454
|
+
const { deps, deliveryId, payload, req } = args;
|
|
455
|
+
const original = await payload.findByID({
|
|
456
|
+
collection: deps.deliveriesSlug,
|
|
457
|
+
id: deliveryId,
|
|
458
|
+
overrideAccess: true,
|
|
459
|
+
req
|
|
460
|
+
});
|
|
461
|
+
const created = await payload.create({
|
|
462
|
+
collection: deps.deliveriesSlug,
|
|
463
|
+
data: {
|
|
464
|
+
subscriptionId: original.subscriptionId,
|
|
465
|
+
endpoint: original.endpoint,
|
|
466
|
+
event: original.event,
|
|
467
|
+
payload: original.payload,
|
|
468
|
+
status: "pending",
|
|
469
|
+
attempt: 0
|
|
470
|
+
},
|
|
471
|
+
overrideAccess: true,
|
|
472
|
+
req
|
|
473
|
+
});
|
|
474
|
+
const newId = String(created.id);
|
|
475
|
+
if (deps.mode === "queue") {
|
|
476
|
+
await payload.jobs.queue({
|
|
477
|
+
task: WEBHOOK_DELIVER_TASK,
|
|
478
|
+
input: { deliveryId: newId },
|
|
479
|
+
queue: deps.queue
|
|
480
|
+
});
|
|
481
|
+
return { id: newId };
|
|
482
|
+
}
|
|
483
|
+
const subscription = await resolveSubscriptionById({
|
|
484
|
+
id: String(original.subscriptionId),
|
|
485
|
+
codeSubscriptions: deps.codeSubscriptions,
|
|
486
|
+
subscriptionsSlug: deps.subscriptionsSlug,
|
|
487
|
+
payload,
|
|
488
|
+
req
|
|
489
|
+
});
|
|
490
|
+
if (!subscription) {
|
|
491
|
+
await payload.update({
|
|
492
|
+
collection: deps.deliveriesSlug,
|
|
493
|
+
id: newId,
|
|
494
|
+
data: {
|
|
495
|
+
status: "dead",
|
|
496
|
+
error: "subscription not found"
|
|
497
|
+
},
|
|
498
|
+
overrideAccess: true,
|
|
499
|
+
req
|
|
500
|
+
});
|
|
501
|
+
return { id: newId };
|
|
502
|
+
}
|
|
503
|
+
const result = await sendDelivery({
|
|
504
|
+
subscription,
|
|
505
|
+
deliveryId: newId,
|
|
506
|
+
event: String(original.event),
|
|
507
|
+
body: JSON.stringify(original.payload),
|
|
508
|
+
timeoutMs: deps.timeoutMs,
|
|
509
|
+
now: Date.now()
|
|
510
|
+
});
|
|
511
|
+
await payload.update({
|
|
512
|
+
collection: deps.deliveriesSlug,
|
|
513
|
+
id: newId,
|
|
514
|
+
data: {
|
|
515
|
+
status: result.ok ? "success" : "dead",
|
|
516
|
+
attempt: 1,
|
|
517
|
+
responseStatus: result.responseStatus,
|
|
518
|
+
responseBody: result.responseBody,
|
|
519
|
+
error: result.error,
|
|
520
|
+
durationMs: result.durationMs
|
|
521
|
+
},
|
|
522
|
+
overrideAccess: true,
|
|
523
|
+
req
|
|
524
|
+
});
|
|
525
|
+
return { id: newId };
|
|
526
|
+
};
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/events/eventTypes.ts
|
|
529
|
+
const OP_TO_EVENT = {
|
|
530
|
+
create: "created",
|
|
531
|
+
update: "updated",
|
|
532
|
+
delete: "deleted"
|
|
533
|
+
};
|
|
534
|
+
/** Past-tense public event suffix for a Payload operation. */
|
|
535
|
+
const operationToEvent = (op) => OP_TO_EVENT[op];
|
|
536
|
+
/** Public event id, e.g. `posts.created`. */
|
|
537
|
+
const eventId = (collectionSlug, op) => `${collectionSlug}.${operationToEvent(op)}`;
|
|
538
|
+
/** Every event id the given opt-in collections can emit, in declaration order. */
|
|
539
|
+
const eventCatalog = (collections) => {
|
|
540
|
+
const out = [];
|
|
541
|
+
for (const [slug, cfg] of Object.entries(collections)) {
|
|
542
|
+
const ops = (cfg === true ? void 0 : cfg.operations) ?? [
|
|
543
|
+
"create",
|
|
544
|
+
"update",
|
|
545
|
+
"delete"
|
|
546
|
+
];
|
|
547
|
+
for (const op of ops) out.push(eventId(slug, op));
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
};
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/delivery/buildPayload.ts
|
|
553
|
+
/** Build the outbound body, applying any per-collection transform/redaction. */
|
|
554
|
+
const buildPayload = (args) => {
|
|
555
|
+
const { deliveryId, collection, operation, doc, previousDoc, occurredAt, config, req } = args;
|
|
556
|
+
const data = config?.transform ? config.transform({
|
|
557
|
+
doc,
|
|
558
|
+
previousDoc,
|
|
559
|
+
operation,
|
|
560
|
+
req
|
|
561
|
+
}) : doc;
|
|
562
|
+
const body = {
|
|
563
|
+
id: deliveryId,
|
|
564
|
+
event: eventId(collection, operation),
|
|
565
|
+
collection,
|
|
566
|
+
operation,
|
|
567
|
+
occurredAt,
|
|
568
|
+
data
|
|
569
|
+
};
|
|
570
|
+
if (operation === "update" && config?.includePreviousData && previousDoc) body.previousData = previousDoc;
|
|
571
|
+
return body;
|
|
572
|
+
};
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/events/hooks.ts
|
|
575
|
+
/** Max collection subscriptions scanned per event (pagination is a future enhancement). */
|
|
576
|
+
const SUBSCRIPTION_SCAN_LIMIT = 1e3;
|
|
577
|
+
const resolveListening = async (args) => {
|
|
578
|
+
const code = args.deps.codeSubscriptions.map(fromCodeSubscription);
|
|
579
|
+
args.req.context[SECRET_REVEAL_CONTEXT.forSigning] = true;
|
|
580
|
+
let res;
|
|
581
|
+
try {
|
|
582
|
+
res = await args.req.payload.find({
|
|
583
|
+
collection: args.deps.subscriptionsSlug,
|
|
584
|
+
where: { enabled: { not_equals: false } },
|
|
585
|
+
limit: SUBSCRIPTION_SCAN_LIMIT,
|
|
586
|
+
depth: 0,
|
|
587
|
+
overrideAccess: true,
|
|
588
|
+
req: args.req
|
|
589
|
+
});
|
|
590
|
+
} finally {
|
|
591
|
+
args.req.context[SECRET_REVEAL_CONTEXT.forSigning] = false;
|
|
592
|
+
}
|
|
593
|
+
if (res.docs.length >= SUBSCRIPTION_SCAN_LIMIT) args.req.payload.logger.warn(`@10x-media/webhooks: subscription scan hit the ${SUBSCRIPTION_SCAN_LIMIT} cap; some subscriptions may be skipped for ${args.event}.`);
|
|
594
|
+
const collection = res.docs.map((d) => fromCollectionRow(d));
|
|
595
|
+
return matchSubscriptions([...code, ...collection], args.event);
|
|
596
|
+
};
|
|
597
|
+
const dispatch = async (args) => {
|
|
598
|
+
const { deps, operation, doc, previousDoc, req } = args;
|
|
599
|
+
if (!deps.operations.includes(operation)) return;
|
|
600
|
+
const { payload } = req;
|
|
601
|
+
const event = eventId(deps.collectionSlug, operation);
|
|
602
|
+
const subscriptions = await resolveListening({
|
|
603
|
+
deps,
|
|
604
|
+
event,
|
|
605
|
+
req
|
|
606
|
+
});
|
|
607
|
+
if (!subscriptions.length) return;
|
|
608
|
+
const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
609
|
+
for (const subscription of subscriptions) {
|
|
610
|
+
const created = await payload.create({
|
|
611
|
+
collection: deps.deliveriesSlug,
|
|
612
|
+
data: {
|
|
613
|
+
subscriptionId: subscription.id,
|
|
614
|
+
endpoint: subscription.url,
|
|
615
|
+
event,
|
|
616
|
+
status: "pending",
|
|
617
|
+
attempt: 0
|
|
618
|
+
},
|
|
619
|
+
overrideAccess: true,
|
|
620
|
+
req
|
|
621
|
+
});
|
|
622
|
+
const deliveryId = String(created.id);
|
|
623
|
+
const body = buildPayload({
|
|
624
|
+
deliveryId,
|
|
625
|
+
collection: deps.collectionSlug,
|
|
626
|
+
operation,
|
|
627
|
+
doc,
|
|
628
|
+
previousDoc,
|
|
629
|
+
occurredAt,
|
|
630
|
+
config: deps.config,
|
|
631
|
+
req
|
|
632
|
+
});
|
|
633
|
+
await payload.update({
|
|
634
|
+
collection: deps.deliveriesSlug,
|
|
635
|
+
id: deliveryId,
|
|
636
|
+
data: { payload: body },
|
|
637
|
+
overrideAccess: true,
|
|
638
|
+
req
|
|
639
|
+
});
|
|
640
|
+
if (deps.mode === "queue") {
|
|
641
|
+
await payload.jobs.queue({
|
|
642
|
+
task: WEBHOOK_DELIVER_TASK,
|
|
643
|
+
input: { deliveryId },
|
|
644
|
+
queue: deps.queue
|
|
645
|
+
});
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const result = await sendDelivery({
|
|
650
|
+
subscription,
|
|
651
|
+
deliveryId,
|
|
652
|
+
event,
|
|
653
|
+
body: JSON.stringify(body),
|
|
654
|
+
timeoutMs: deps.timeoutMs,
|
|
655
|
+
now: Date.now()
|
|
656
|
+
});
|
|
657
|
+
await payload.update({
|
|
658
|
+
collection: deps.deliveriesSlug,
|
|
659
|
+
id: deliveryId,
|
|
660
|
+
data: {
|
|
661
|
+
status: result.ok ? "success" : "dead",
|
|
662
|
+
attempt: 1,
|
|
663
|
+
responseStatus: result.responseStatus,
|
|
664
|
+
responseBody: result.responseBody,
|
|
665
|
+
error: result.error,
|
|
666
|
+
durationMs: result.durationMs
|
|
667
|
+
},
|
|
668
|
+
overrideAccess: true,
|
|
669
|
+
req
|
|
670
|
+
});
|
|
671
|
+
} catch (err) {
|
|
672
|
+
payload.logger.error(`@10x-media/webhooks: inline delivery ${deliveryId} threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
/** afterChange hook factory for an opt-in source collection. */
|
|
677
|
+
const makeAfterChange = (deps) => async ({ doc, previousDoc, operation, req }) => {
|
|
678
|
+
await dispatch({
|
|
679
|
+
deps,
|
|
680
|
+
operation: operation === "create" ? "create" : "update",
|
|
681
|
+
doc,
|
|
682
|
+
previousDoc,
|
|
683
|
+
req
|
|
684
|
+
});
|
|
685
|
+
return doc;
|
|
686
|
+
};
|
|
687
|
+
/** afterDelete hook factory for an opt-in source collection. */
|
|
688
|
+
const makeAfterDelete = (deps) => async ({ doc, req }) => {
|
|
689
|
+
await dispatch({
|
|
690
|
+
deps,
|
|
691
|
+
operation: "delete",
|
|
692
|
+
doc,
|
|
693
|
+
req
|
|
694
|
+
});
|
|
695
|
+
return doc;
|
|
696
|
+
};
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/options.ts
|
|
699
|
+
const resolveDeliveryOptions = (delivery) => {
|
|
700
|
+
const opts = delivery === void 0 ? {} : typeof delivery === "string" ? { mode: delivery } : delivery;
|
|
701
|
+
return {
|
|
702
|
+
mode: opts.mode ?? "auto",
|
|
703
|
+
timeoutMs: opts.timeoutMs ?? 1e4,
|
|
704
|
+
retries: opts.retries ?? 4,
|
|
705
|
+
queue: opts.queue ?? "default"
|
|
706
|
+
};
|
|
707
|
+
};
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/plugin/resolveMode.ts
|
|
710
|
+
/** Resolve the effective execution mode, warning when `queue` is forced with no runner. */
|
|
711
|
+
const resolveMode = (args) => {
|
|
712
|
+
const runnerLikely = args.hasAutoRun || args.hasJobsPlugin;
|
|
713
|
+
if (args.configured === "queue") {
|
|
714
|
+
if (!runnerLikely) args.warn("@10x-media/webhooks: delivery.mode=\"queue\" but no job runner detected (config.jobs.autoRun unset and @10x-media/jobs not installed); queued deliveries stay pending until a worker runs.");
|
|
715
|
+
return "queue";
|
|
716
|
+
}
|
|
717
|
+
if (args.configured === "inline") return "inline";
|
|
718
|
+
return runnerLikely ? "queue" : "inline";
|
|
719
|
+
};
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/plugin/registerWebhooks.ts
|
|
722
|
+
/** Register collections, the delivery task, source hooks, and the redeliver endpoint. */
|
|
723
|
+
const registerWebhooks = (args) => {
|
|
724
|
+
const { config, options } = args;
|
|
725
|
+
const sources = options.collections ?? {};
|
|
726
|
+
const subscriptionsSlug = options.subscriptionsCollection?.slug ?? "webhook-subscriptions";
|
|
727
|
+
const deliveriesSlug = options.deliveriesLog?.slug ?? "webhook-deliveries";
|
|
728
|
+
const reserved = new Set([
|
|
729
|
+
...RESERVED_SLUGS,
|
|
730
|
+
subscriptionsSlug,
|
|
731
|
+
deliveriesSlug
|
|
732
|
+
]);
|
|
733
|
+
const sourceSlugs = Object.keys(sources).filter((s) => !reserved.has(s));
|
|
734
|
+
const delivery = resolveDeliveryOptions(options.delivery);
|
|
735
|
+
const codeSubscriptions = options.subscriptions ?? [];
|
|
736
|
+
const catalog = eventCatalog(Object.fromEntries(sourceSlugs.map((s) => [s, sources[s] ?? true])));
|
|
737
|
+
const mode = resolveMode({
|
|
738
|
+
configured: delivery.mode,
|
|
739
|
+
hasAutoRun: Boolean(config.jobs?.autoRun),
|
|
740
|
+
hasJobsPlugin: args.hasJobsPlugin,
|
|
741
|
+
warn: (m) => console.warn(m)
|
|
742
|
+
});
|
|
743
|
+
config.collections ??= [];
|
|
744
|
+
config.collections.push(buildSubscriptionsCollection({
|
|
745
|
+
slug: subscriptionsSlug,
|
|
746
|
+
events: catalog,
|
|
747
|
+
hidden: options.subscriptionsCollection?.hidden ?? false
|
|
748
|
+
}));
|
|
749
|
+
const deliveries = buildDeliveriesCollection({
|
|
750
|
+
slug: deliveriesSlug,
|
|
751
|
+
hidden: options.deliveriesLog?.hidden ?? false
|
|
752
|
+
});
|
|
753
|
+
const redeliverEndpoint = {
|
|
754
|
+
path: "/:id/redeliver",
|
|
755
|
+
method: "post",
|
|
756
|
+
handler: async (req) => {
|
|
757
|
+
if (!req.user) return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
758
|
+
const id = req.routeParams?.id;
|
|
759
|
+
if (typeof id !== "string") return Response.json({ error: "missing id" }, { status: 400 });
|
|
760
|
+
const result = await redeliverDelivery({
|
|
761
|
+
deps: {
|
|
762
|
+
deliveriesSlug,
|
|
763
|
+
subscriptionsSlug,
|
|
764
|
+
codeSubscriptions,
|
|
765
|
+
mode,
|
|
766
|
+
timeoutMs: delivery.timeoutMs,
|
|
767
|
+
queue: delivery.queue
|
|
768
|
+
},
|
|
769
|
+
deliveryId: id,
|
|
770
|
+
payload: req.payload,
|
|
771
|
+
req
|
|
772
|
+
});
|
|
773
|
+
return Response.json(result, { status: 202 });
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
deliveries.endpoints = [...deliveries.endpoints || [], redeliverEndpoint];
|
|
777
|
+
config.collections.push(deliveries);
|
|
778
|
+
config.jobs ??= {};
|
|
779
|
+
config.jobs.tasks ??= [];
|
|
780
|
+
config.jobs.tasks.push(buildDeliverTask({
|
|
781
|
+
deliveriesSlug,
|
|
782
|
+
subscriptionsSlug,
|
|
783
|
+
codeSubscriptions,
|
|
784
|
+
timeoutMs: delivery.timeoutMs,
|
|
785
|
+
retries: delivery.retries
|
|
786
|
+
}));
|
|
787
|
+
for (let i = 0; i < config.collections.length; i++) {
|
|
788
|
+
const collection = config.collections[i];
|
|
789
|
+
if (!collection || !sourceSlugs.includes(collection.slug)) continue;
|
|
790
|
+
const cfg = sources[collection.slug];
|
|
791
|
+
const collectionConfig = cfg === true || cfg === void 0 ? {} : cfg;
|
|
792
|
+
const deps = {
|
|
793
|
+
collectionSlug: collection.slug,
|
|
794
|
+
config: collectionConfig,
|
|
795
|
+
operations: collectionConfig.operations ?? [
|
|
796
|
+
"create",
|
|
797
|
+
"update",
|
|
798
|
+
"delete"
|
|
799
|
+
],
|
|
800
|
+
deliveriesSlug,
|
|
801
|
+
subscriptionsSlug,
|
|
802
|
+
codeSubscriptions,
|
|
803
|
+
mode,
|
|
804
|
+
timeoutMs: delivery.timeoutMs,
|
|
805
|
+
queue: delivery.queue
|
|
806
|
+
};
|
|
807
|
+
config.collections[i] = {
|
|
808
|
+
...collection,
|
|
809
|
+
hooks: {
|
|
810
|
+
...collection.hooks,
|
|
811
|
+
afterChange: [...collection.hooks?.afterChange ?? [], makeAfterChange(deps)],
|
|
812
|
+
afterDelete: [...collection.hooks?.afterDelete ?? [], makeAfterDelete(deps)]
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region src/index.ts
|
|
819
|
+
/** The trigger slug webhooks contributes to the automations catalog. */
|
|
820
|
+
const WEBHOOK_TRIGGER_SLUG = "webhook";
|
|
821
|
+
/**
|
|
822
|
+
* Webhooks plugin for Payload v3. Runs before automations (`order: 10`) so it can
|
|
823
|
+
* push its `webhook` trigger into automations when present, and builds outbound
|
|
824
|
+
* delivery: opt-in collections emit signed HTTP POSTs to subscribed endpoints,
|
|
825
|
+
* delivered via native Payload jobs or bounded-await inline.
|
|
826
|
+
*/
|
|
827
|
+
const webhooks = definePlugin({
|
|
828
|
+
slug: "@10x-media/webhooks",
|
|
829
|
+
order: 10,
|
|
830
|
+
plugin: ({ config, plugins, ...options }) => {
|
|
831
|
+
if (options.disabled === true) return config;
|
|
832
|
+
registerTranslations(config);
|
|
833
|
+
const automationsPlugin = plugins["@10x-media/automations"];
|
|
834
|
+
if (automationsPlugin?.options) {
|
|
835
|
+
const opts = automationsPlugin.options;
|
|
836
|
+
opts.triggers = [...opts.triggers ?? [], WEBHOOK_TRIGGER_SLUG];
|
|
837
|
+
}
|
|
838
|
+
registerWebhooks({
|
|
839
|
+
config,
|
|
840
|
+
options,
|
|
841
|
+
hasJobsPlugin: Boolean(plugins["@10x-media/jobs"])
|
|
842
|
+
});
|
|
843
|
+
return config;
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
//#endregion
|
|
847
|
+
export { WEBHOOK_TRIGGER_SLUG, webhooks };
|
|
848
|
+
|
|
849
|
+
//# sourceMappingURL=index.js.map
|