@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/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