@objectstack/plugin-webhooks 17.0.0-rc.6 → 17.0.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.d.ts CHANGED
@@ -16,6 +16,15 @@ type WebhookTrigger = WebhookTriggerType;
16
16
  * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).
17
17
  * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no
18
18
  * longer own a delivery outbox/dispatcher — they share the generic substrate.
19
+ *
20
+ * [#8069] It MUST be `MessagingService.enqueueHttp`, not `IHttpOutbox.enqueue`.
21
+ * The enqueuer now emits two kinds of input through this one door — an ordinary
22
+ * delivery, and a PARKED event whose subscription lost its credentials — and
23
+ * only the messaging seam routes the second to `recordUndeliverable()`. Wired
24
+ * to the raw outbox instead, the parked input is refused at the delivery door
25
+ * (correctly — the alternative is a `pending` unsigned row) and the durable
26
+ * record is lost; {@link AutoEnqueuer} reports that at `error` rather than
27
+ * letting it pass as an ordinary enqueue failure.
19
28
  */
20
29
  type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;
21
30
  /**
@@ -42,6 +51,20 @@ interface CachedSubscription {
42
51
  headers?: Record<string, string>;
43
52
  secret?: string;
44
53
  timeoutMs?: number;
54
+ /**
55
+ * [#8069] Set when a credential this subscription needs could not be
56
+ * recovered. The subscription stays CACHED — that is the change — but every
57
+ * event it matches is written to `sys_http_delivery` as a parked `dead` row
58
+ * carrying this text, instead of being discarded with nothing to find.
59
+ *
60
+ * Before this, `attachCredentials` returning false removed the row from the
61
+ * cache entirely, so matching events found no subscription and vanished:
62
+ * fail-closed and correct, but leaving an operator with a log line (#8043)
63
+ * and no durable trace. A parked subscription is still fail-closed —
64
+ * {@link secret} and {@link headers} stay unset, so nothing can be sent —
65
+ * it is merely no longer silent.
66
+ */
67
+ parkedReason?: string;
45
68
  }
46
69
  interface AutoEnqueuerOptions {
47
70
  /**
@@ -112,18 +135,191 @@ declare class AutoEnqueuer {
112
135
  private refreshTimer;
113
136
  private running;
114
137
  private refreshing;
138
+ /** [#8022] Detach for the engine's crypto-registration listener. */
139
+ private unbindCryptoListener;
140
+ /**
141
+ * [#8022] Webhook ids currently dropped for an unresolvable credential —
142
+ * the signing key (#7799) or, since #7986, the custom header map. ONE set
143
+ * for both on purpose: a subscription is either armed or dropped, so a
144
+ * per-credential ledger would let a row already silenced for its key report
145
+ * loudly again for its headers on the very next refresh.
146
+ * Held so the loud first report is said ONCE per outage (AGENTS.md
147
+ * "Degradation log levels": *say it once, at the first degradation*) and
148
+ * again if the same webhook breaks after recovering — not once per row per
149
+ * refresh, forever.
150
+ */
151
+ private readonly droppedForSecret;
115
152
  constructor(engine: IDataEngine, realtime: IRealtimeService, enqueue: HttpEnqueueFn, opts?: AutoEnqueuerOptions);
116
153
  /**
117
154
  * Load the subscription cache and start listening for events.
118
155
  */
119
156
  start(): Promise<void>;
120
157
  stop(): Promise<void>;
158
+ /**
159
+ * [#8022] The engine just gained a CryptoProvider — rebuild the cache so
160
+ * subscriptions dropped for an unresolvable signing key re-arm now, instead
161
+ * of at the next periodic refresh up to {@link refreshIntervalMs} away.
162
+ *
163
+ * It deliberately does NOT call {@link refresh} directly. `refresh()`
164
+ * coalesces onto an in-flight build, and the build most likely to be in
165
+ * flight right now is the one from `start()` — the very build whose rows
166
+ * were read while there was no provider. Joining it would return "refreshed"
167
+ * having re-armed nothing, which is this issue with an extra step. So: let
168
+ * whatever is running finish, then read again.
169
+ */
170
+ private rearmAfterCryptoRegistered;
121
171
  /**
122
172
  * Force-refresh the subscription cache from storage. Concurrent
123
173
  * callers share a single in-flight refresh.
124
174
  */
125
175
  refresh(): Promise<void>;
126
176
  private doRefresh;
177
+ /**
178
+ * [#7799, #7986] Resolve BOTH encrypted credentials for one cached
179
+ * subscription. Returns `false` when the subscription must be dropped from
180
+ * the cache.
181
+ *
182
+ * The two halves are deliberately resolved on the SAME build rather than on
183
+ * separate schedules. #8022's re-arm rebuilds the whole cache when a
184
+ * CryptoProvider registers; a header map recovered on any other cadence
185
+ * would let the enqueuer re-arm into a delivery that is correctly signed and
186
+ * silently missing its `Authorization`, which is the failure mode of both
187
+ * cards at once.
188
+ *
189
+ * The drop ledger is cleared only when BOTH succeed — otherwise a row whose
190
+ * secret resolves and whose headers do not would clear its own "already
191
+ * reported" mark on every refresh and shout the same `error` every 60s,
192
+ * which is precisely the unreadable-error-channel failure #8022's say-once
193
+ * rule exists to prevent.
194
+ *
195
+ * Cost: up to two point reads + two decrypts per credential-bearing row per
196
+ * refresh (default 60s), off the write path entirely. Deliberately NOT
197
+ * memoised across refreshes — the only cheap cache key would be
198
+ * `updated_at`, which nothing guarantees is stamped when a credential is
199
+ * rotated, and a stale key signs every delivery with a signature the
200
+ * receiver rejects.
201
+ */
202
+ private attachCredentials;
203
+ /**
204
+ * [#8069] Mark a subscription parked and strip anything sendable off it.
205
+ *
206
+ * Called from the two `attachX` failure paths, which each already reported
207
+ * the drop at `error` (say-once, #8022). The credentials are cleared rather
208
+ * than merely "not set": `attachSecret` can succeed and `attachHeaders`
209
+ * fail, and a parked row must not carry the header map — that map is the
210
+ * ordinary place an `Authorization: Bearer …` goes (#7986), and copying it
211
+ * onto a row that will sit in `sys_http_delivery` for the full 30d
212
+ * retention window without ever being sent is a credential copy bought for
213
+ * nothing.
214
+ */
215
+ /**
216
+ * [#8069] Report a failed outbox write off the hot path, at the level the
217
+ * loss actually deserves.
218
+ *
219
+ * AGENTS.md decides that with one question — *does the system still look
220
+ * normal from the outside while something it claims is persisted has not
221
+ * landed?* For a PARKED subscription the answer is unambiguously yes, and
222
+ * worse than for an ordinary enqueue failure: the durable record is the
223
+ * only trace this event ever existed, so losing the write puts us back
224
+ * exactly where this issue started, silently. So `error` there, and the
225
+ * pre-existing `warn` for an ordinary enqueue, where the delivery itself is
226
+ * the thing that did not happen and the subscription is otherwise healthy.
227
+ *
228
+ * The realistic cause of the parked branch is a host that wired
229
+ * {@link HttpEnqueueFn} straight to `IHttpOutbox.enqueue` instead of
230
+ * `MessagingService.enqueueHttp`: only the messaging seam routes a parked
231
+ * input to `recordUndeliverable()`, and the raw delivery door refuses the
232
+ * discriminator rather than minting a `pending` unsigned row from it. The
233
+ * message names that, because it is not guessable from "enqueue failed".
234
+ */
235
+ private reportWriteFailure;
236
+ private park;
237
+ /**
238
+ * [#7799] Resolve `sub.secret`. Returns `false` when the subscription must
239
+ * be dropped.
240
+ *
241
+ * Three sources, in order:
242
+ * 1. `sys_webhook.signing_secret` — the encrypted column. The read path
243
+ * returns a mask, so presence is decidable here but the value is not;
244
+ * `resolveWebhookSecret` dereferences it server-side.
245
+ * 2. `definition_json.secret` — a row not yet swept by
246
+ * `migrateLegacyWebhookSecrets` (or hand-edited back in). Still honoured
247
+ * so an un-migrated deployment keeps signing, and warned about once per
248
+ * refresh so the exposure is visible rather than silently permanent.
249
+ * 3. Neither — an unsigned webhook, which is a legitimate authored choice
250
+ * (`secret` is optional on the envelope).
251
+ *
252
+ * A stored-but-unresolvable key DROPS the subscription instead of
253
+ * delivering unsigned. The signature is the receiver's only proof of
254
+ * origin (#7722, #7799): a webhook that stops arriving is visible and gets
255
+ * investigated, while one that keeps arriving unsigned is invisible and
256
+ * teaches the receiver to accept unauthenticated traffic.
257
+ *
258
+ * [#8542] Case 3 means what it says only because the seam was fixed to say
259
+ * it. `resolveWebhookSecret` used to answer `undefined` for BOTH "no key is
260
+ * stored" and "a key is stored and did not come back", so this method read
261
+ * the second as the third and armed the subscription — the invariant above
262
+ * failing OPEN, silently, on the producer path. Nothing here changed: the
263
+ * seam now raises for that case, so it lands in the `catch` below exactly
264
+ * the way a throwing resolver already did, and the drop, the say-once
265
+ * `error` and the #8069 park all apply to it unchanged.
266
+ */
267
+ private attachSecret;
268
+ /**
269
+ * [#7986] Resolve `sub.headers` from the encrypted column, with the same
270
+ * three-source shape as {@link attachSecret} and for the same reasons.
271
+ *
272
+ * A stored-but-unresolvable header map DROPS the subscription rather than
273
+ * delivering without it. That is the identical trade #7799 made for the
274
+ * signature, and it needs restating because the intuition runs the other
275
+ * way: a missing `Authorization` looks self-announcing, since the receiver
276
+ * answers 401 and the attempt lands in `sys_http_delivery` for anyone to
277
+ * find. But that is only the AUTHENTICATED case. Against an endpoint that
278
+ * does not require the header — a routing `X-Tenant-Id`, an
279
+ * `X-Environment: staging` — the delivery SUCCEEDS while quietly deviating
280
+ * from the configuration the author wrote, and nothing anywhere records
281
+ * that it went out incomplete. A subscription that stops is visible; a
282
+ * delivery that arrives subtly wrong is not.
283
+ *
284
+ * [#8558] And that is what this method used to do, for the same reason its
285
+ * signing sibling did (#8542): `resolveWebhookHeaders` answered `undefined`
286
+ * for BOTH "no headers are stored" and "a map is stored and did not come
287
+ * back as one", so this method read the second as the first and armed the
288
+ * subscription — the paragraph above failing OPEN. Measured, the delivery
289
+ * then went out SUCCESSFULLY and correctly SIGNED with the whole authored
290
+ * map missing, which is the worst available combination: the signature
291
+ * tells the receiver the request is genuinely ours. Nothing here changed:
292
+ * the seam now raises, so it lands in the `catch` below exactly the way a
293
+ * throwing resolver already did, and the drop, the say-once `error` and the
294
+ * #8069 park all apply to it unchanged.
295
+ */
296
+ private attachHeaders;
297
+ /**
298
+ * [#8022] Report a subscription dropped for an unresolvable signing key.
299
+ *
300
+ * ## Why `error`, and why only the first time
301
+ * AGENTS.md decides the level with one question: *after the degradation,
302
+ * does the system still look normal from the outside while something the
303
+ * system claims is happening is not?* Here the answer is yes, and it is the
304
+ * whole defect — `GET /api/v1/data/sys_webhook` keeps reading
305
+ * `active: true`, Setup keeps showing the webhook armed, and every matching
306
+ * record change is discarded with no delivery and no `sys_http_delivery`
307
+ * row to find afterwards. That is a durability degradation wearing a
308
+ * functional degradation's clothes, so it owes the two things an `error`
309
+ * owes: the consequence, concretely, and the fix.
310
+ *
311
+ * Said ONCE per outage per webhook, per the same section. The cache is
312
+ * rebuilt every {@link refreshIntervalMs}; an unfixed misconfiguration would
313
+ * otherwise print this line every 60s forever, which is how an `error`
314
+ * channel becomes unreadable — the failure mode that made the founding
315
+ * incident's `warn` invisible. Repeats drop to `debug`; a recovery clears
316
+ * the id, so a re-break is loud again.
317
+ *
318
+ * ADR-0112: `code` + `status` travel in the meta so a consumer branches on
319
+ * the pair, not on message text. Same pair the seeder's refusal carries for
320
+ * the same underlying cause.
321
+ */
322
+ private reportDrop;
127
323
  private parseRow;
128
324
  /**
129
325
  * Handler for the firehose subscription.
@@ -224,6 +420,19 @@ declare class WebhookOutboxPlugin implements Plugin {
224
420
  */
225
421
  private bootDeclaredWebhooks;
226
422
  private bootAutoEnqueue;
423
+ /**
424
+ * [#8069] Register {@link createWebhookRedeliverGuard} with messaging, so
425
+ * `redeliver()` refuses a webhook row whose signing configuration is no
426
+ * longer available — for EVERY caller, not just the
427
+ * `POST /api/v1/webhooks/redeliver` route.
428
+ *
429
+ * Absence is loud, and `error` is the right level by AGENTS.md's one
430
+ * question: with no guard installed the endpoint still answers 200 and the
431
+ * dispatcher still reports a delivery, while the fail-closed signing
432
+ * guarantee the system claims (#7799) is not actually being kept. That is a
433
+ * durability/consistency degradation wearing a functional one's clothes.
434
+ */
435
+ private installRedeliverGuard;
227
436
  private tryGetService;
228
437
  /**
229
438
  * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
@@ -234,4 +443,186 @@ declare class WebhookOutboxPlugin implements Plugin {
234
443
  private resolveSessionUserId;
235
444
  }
236
445
 
237
- export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, WebhookOutboxPlugin, type WebhookOutboxPluginOptions };
446
+ /**
447
+ * [#7799] The persistence seam for a webhook's HMAC signing secret.
448
+ *
449
+ * ## The defect
450
+ * `bootstrapDeclaredWebhooks` used to persist the whole validated `Webhook`
451
+ * envelope — `secret` included — as `definition_json: JSON.stringify(wh)`, and
452
+ * `AutoEnqueuer.parseRow` read `defn.secret` straight back out to sign
453
+ * deliveries. `definition_json` is an ordinary textarea on an admin-authorable
454
+ * object with no restrictive `enable.apiMethods`, so an ordinary
455
+ * `GET /api/v1/data/sys_webhook` returned the key to every persona that can read
456
+ * the object. That key is the receiver's ONLY proof a delivery came from us.
457
+ *
458
+ * #7722 removed the same secret's per-attempt copies from `sys_http_delivery`;
459
+ * this is the remaining cleartext location, and unlike the delivery table it is
460
+ * not bounded by a retention window.
461
+ *
462
+ * ## The seam
463
+ * Nothing about the AUTHORING envelope changes — authors still write
464
+ * `secret: '…'` on `defineWebhook()`, and `webhook.zod.ts` is untouched. What
465
+ * changes is where the value LANDS:
466
+ *
467
+ * authored `secret` → `sys_webhook.signing_secret` (`type: 'secret'`)
468
+ * → engine encrypts → `sys_secret` ciphertext row
469
+ * → row keeps only an opaque `secret:<id>` ref
470
+ * → every read path returns the mask
471
+ *
472
+ * `definition_json` → the same envelope MINUS `secret`
473
+ *
474
+ * and the enqueuer recovers the plaintext server-side, at cache-refresh time,
475
+ * through `engine.resolveSecretField()` — the privileged, driver-level
476
+ * dereference added alongside this change, because the encrypted channel masks
477
+ * its own ref on every supported read path and a server-side consumer
478
+ * previously had no way to get at it.
479
+ *
480
+ * ## Two things this file deliberately does NOT do
481
+ * - It does not invent a second cipher store. The engine owns the
482
+ * `ICryptoProvider` (the host injects it via `setCryptoProvider`, and it is
483
+ * not a kernel service), so the plugin cannot encrypt on its own — it writes
484
+ * cleartext INTO the `secret`-typed column exactly once and lets the engine's
485
+ * own write path do the wrapping. That also inherits the engine's fail-closed
486
+ * posture for free: no provider ⇒ the write throws ⇒ we skip the webhook
487
+ * loudly, rather than silently re-opening the hole in a new column.
488
+ * - It does not guess. When a row HAS a stored secret the enqueuer cannot
489
+ * resolve, the subscription is dropped rather than delivered unsigned — an
490
+ * undelivered webhook is visible and safe, an unsigned one is invisible and
491
+ * is precisely the failure this issue is about.
492
+ */
493
+
494
+ /** Column on `sys_webhook` holding the encrypted signing key. */
495
+ declare const WEBHOOK_SECRET_FIELD = "signing_secret";
496
+
497
+ /**
498
+ * [#7986] The persistence seam for a webhook's custom `headers` map — the
499
+ * sibling passenger #7799 left behind on the blob it emptied.
500
+ *
501
+ * ## The defect
502
+ * #7799 moved the signing secret out of `sys_webhook.definition_json` into an
503
+ * encrypted column. It did not move `headers`, and `headers` is the ordinary
504
+ * place an `Authorization: Bearer …` goes. Same column, same object with no
505
+ * `enable` block at all (so the FULL default data API), same unbounded
506
+ * retention — the only thing that differed was which key of the blob the card
507
+ * happened to name. `GET /api/v1/data/sys_webhook` handed the header map back
508
+ * to every persona that can read the object.
509
+ *
510
+ * That framing is the finding: the COLUMN was the problem and the secret was
511
+ * only one of its passengers.
512
+ *
513
+ * ## Why the WHOLE map moves, and not just the credential-looking entries
514
+ * A signing secret is one opaque value with one consumer. `headers` is an
515
+ * open-ended `Record<string, string>` in which only some entries are
516
+ * credentials — and **the platform cannot tell which**. Three ways to decide
517
+ * were on the table; this is why the map moves whole:
518
+ *
519
+ * - **Guess from the header NAME** (`authorization`, `x-api-key`, …). Rejected:
520
+ * it is fail-OPEN on precisely the names most likely to be a credential in
521
+ * practice — `X-Acme-Token`, `X-Vendor-Key` — and a heuristic that silently
522
+ * passes the one header that mattered is worse than no heuristic, because it
523
+ * reads as coverage. Every other credential decision in this repo fails
524
+ * closed; this one would not.
525
+ * - **Have the author DECLARE which are sensitive** (`secretHeaders: [...]`).
526
+ * That is a change to the authoring envelope (`webhook.zod.ts`), which is
527
+ * the spec seat's surface, not this one — and it would still leave the
528
+ * `source: 'flow'` half of the same exposure untouched, because a flow
529
+ * `http` node's headers are interpolated per run and never parsed through
530
+ * `WebhookSchema` at all. Escalated rather than attempted here (#7986).
531
+ * - **Move the whole map.** Fail-closed by construction, needs no authoring
532
+ * change, and the cost it is accused of — "it encrypts non-sensitive headers
533
+ * too" — is measured and small: `definition_json` is a raw JSON textarea
534
+ * pending a real builder (see `sys-webhook.object.ts`), so what an admin
535
+ * loses is the ability to READ back a `Content-Type` they typed, on a
536
+ * surface that was never the intended authoring UI.
537
+ *
538
+ * ## The seam
539
+ * Identical in shape to `webhook-secret.ts`, deliberately — one mechanism, two
540
+ * passengers, so a reader who has understood #7799 has already understood this:
541
+ *
542
+ * authored `headers` → `sys_webhook.headers_secret` (`type: 'secret'`)
543
+ * → engine encrypts the SERIALIZED map → `sys_secret`
544
+ * → row keeps only an opaque `secret:<id>` ref
545
+ * → every read path returns the mask
546
+ *
547
+ * `definition_json` → the same envelope MINUS `headers` (and MINUS
548
+ * `secret`, as #7799 already established)
549
+ *
550
+ * The map is serialized because the encrypted channel carries a string. That is
551
+ * an encoding detail and not a second format: {@link parseStoredHeaders} is the
552
+ * only reader, and it treats anything that is not a flat string map as absent
553
+ * rather than guessing.
554
+ *
555
+ * ## What this file deliberately does NOT do
556
+ * - It does not invent a second cipher store, for the same layering reason
557
+ * `webhook-secret.ts` gives: the engine owns the `ICryptoProvider`, so the
558
+ * plugin writes cleartext INTO the `secret`-typed column exactly once and
559
+ * lets the engine's write path wrap it. The fail-closed posture comes free.
560
+ * - It does not deliver partially. A row whose stored headers cannot be
561
+ * resolved DROPS the subscription rather than delivering it with the headers
562
+ * missing — see {@link resolveWebhookHeaders}.
563
+ *
564
+ * [#8558] That last line was a statement of intent this file did not keep. Only
565
+ * a THROWING resolver reached the caller's `catch`; a resolver that answered
566
+ * `null` — or handed back a value that was not a flat string map — folded onto
567
+ * the `undefined` this seam uses for "no headers stored", and the subscription
568
+ * armed and delivered without them. {@link WebhookHeadersUnresolvableError} is
569
+ * what makes the sentence true.
570
+ */
571
+
572
+ /** Column on `sys_webhook` holding the encrypted custom-header map. */
573
+ declare const WEBHOOK_HEADERS_FIELD = "headers_secret";
574
+
575
+ /**
576
+ * [#7799] One-shot boot sweep that moves already-persisted cleartext signing
577
+ * secrets out of `sys_webhook.definition_json` and into the encrypted
578
+ * `signing_secret` column.
579
+ *
580
+ * ## Why a sweep and not just the seeder
581
+ * `bootstrapDeclaredWebhooks` re-seeds package-declared rows on every boot, so
582
+ * those heal themselves the moment the new mapping lands. The rows that do NOT
583
+ * heal are exactly the ones most likely to hold a real production key:
584
+ *
585
+ * - `managed_by: 'admin'` — authored in Setup, never touched by the seeder;
586
+ * - `customized: true` — a package row an admin edited, deliberately frozen
587
+ * against re-seeding (seed-not-clobber, #3461 / #2909).
588
+ *
589
+ * Leaving those behind would make this a half-migration: the code path that
590
+ * created the exposure would be fixed while the exposed values stayed in the
591
+ * table. So the sweep is keyed off the DATA (does this blob contain a secret?),
592
+ * not off provenance.
593
+ *
594
+ * ## What it is careful about
595
+ * - **System context.** The provenance hook exempts `isSystem` writes, so
596
+ * migrating a package row does not stamp `customized: true` and freeze it
597
+ * against future seeding.
598
+ * - **Idempotent.** A row whose blob no longer carries a `secret` is skipped,
599
+ * so the sweep is free on every boot after the first.
600
+ * - **Fail-closed, per row.** With no CryptoProvider the encrypted write throws
601
+ * and the row is LEFT AS IT WAS — still exposed, but intact and still
602
+ * signing. It is reported with an ADR-0112 `code`/`status` pair so an
603
+ * operator can see exactly which rows are still cleartext and why, rather
604
+ * than the sweep quietly reporting success.
605
+ * - **Never widens the blast radius.** The cleartext is only removed from
606
+ * `definition_json` in the SAME update that stores the encrypted copy; a
607
+ * failure cannot land the strip without the store.
608
+ */
609
+
610
+ interface Logger {
611
+ info?: (msg: string, meta?: unknown) => void;
612
+ warn?: (msg: string, meta?: unknown) => void;
613
+ }
614
+ interface MigrateWebhookSecretsResult {
615
+ /** Rows whose blob carried a cleartext secret. */
616
+ found: number;
617
+ /** Rows now holding an encrypted secret and a secret-free blob. */
618
+ migrated: number;
619
+ /** Rows still holding cleartext because the encrypted write was refused. */
620
+ failed: number;
621
+ }
622
+ /**
623
+ * Move every cleartext `definition_json.secret` into the encrypted column.
624
+ * Safe to run on every boot; returns counts for the caller to log.
625
+ */
626
+ declare function migrateLegacyWebhookSecrets(engine: IDataEngine, logger?: Logger, subscriptionsObject?: string): Promise<MigrateWebhookSecretsResult>;
627
+
628
+ export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, type MigrateWebhookSecretsResult, WEBHOOK_HEADERS_FIELD, WEBHOOK_SECRET_FIELD, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, migrateLegacyWebhookSecrets };