@objectstack/plugin-webhooks 17.0.0-rc.5 → 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.js CHANGED
@@ -1,8 +1,178 @@
1
1
  import {
2
2
  SysWebhook
3
- } from "./chunk-3QGZLM3T.js";
3
+ } from "./chunk-GDCWDVDT.js";
4
+
5
+ // src/webhook-secret.ts
6
+ var WEBHOOK_SECRET_FIELD = "signing_secret";
7
+ var WEBHOOK_OBJECT = "sys_webhook";
8
+ var WEBHOOK_SECRET_REFUSAL_CODE = "INTERNAL_ERROR";
9
+ var WEBHOOK_SECRET_REFUSAL_STATUS = 500;
10
+ function isSecretProtectionFailure(err) {
11
+ const msg = String(err?.message ?? err ?? "");
12
+ return /Cannot persist secret field/i.test(msg);
13
+ }
14
+ var WebhookSecretUnresolvableError = class extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.code = WEBHOOK_SECRET_REFUSAL_CODE;
18
+ this.status = WEBHOOK_SECRET_REFUSAL_STATUS;
19
+ this.name = "WebhookSecretUnresolvableError";
20
+ }
21
+ };
22
+ function isWebhookSecretUnresolvable(err) {
23
+ return err instanceof WebhookSecretUnresolvableError;
24
+ }
25
+ function splitWebhookSecret(wh) {
26
+ const { secret, ...envelope } = wh;
27
+ const value = typeof secret === "string" && secret.length > 0 ? secret : void 0;
28
+ return { envelope, secret: value };
29
+ }
30
+ function readLegacySecret(definitionJson) {
31
+ if (typeof definitionJson !== "string" || definitionJson.length === 0) return void 0;
32
+ try {
33
+ const parsed = JSON.parse(definitionJson);
34
+ const secret = parsed?.secret;
35
+ return typeof secret === "string" && secret.length > 0 ? secret : void 0;
36
+ } catch {
37
+ return void 0;
38
+ }
39
+ }
40
+ var OBJECTQL_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
41
+ var OBJECTQL_SECRET_REF_PREFIX = "secret:";
42
+ function isOpaqueSecretForm(value) {
43
+ return typeof value === "string" && (value === OBJECTQL_SECRET_MASK || value.startsWith(OBJECTQL_SECRET_REF_PREFIX));
44
+ }
45
+ function canResolveSecrets(engine) {
46
+ return typeof engine?.resolveSecretField === "function";
47
+ }
48
+ function onCryptoProviderChange(engine, listener) {
49
+ const observable = engine;
50
+ if (typeof observable?.onCryptoProviderChange !== "function") return void 0;
51
+ return observable.onCryptoProviderChange(listener);
52
+ }
53
+ async function resolveWebhookSecret(engine, row, object = WEBHOOK_OBJECT) {
54
+ const stored = row[WEBHOOK_SECRET_FIELD];
55
+ if (stored == null || stored === "") return void 0;
56
+ const resolver = engine;
57
+ if (typeof resolver.resolveSecretField !== "function") {
58
+ if (!isOpaqueSecretForm(stored)) return String(stored);
59
+ throw new WebhookSecretUnresolvableError(
60
+ `Webhook "${String(row.name ?? row.id)}" stores an encrypted signing secret, but this data engine does not implement resolveSecretField() \u2014 the key cannot be recovered, so the subscription is dropped rather than delivered unsigned (#7799).`
61
+ );
62
+ }
63
+ const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_SECRET_FIELD);
64
+ if (typeof plain === "string" && plain.length > 0) return plain;
65
+ throw new WebhookSecretUnresolvableError(
66
+ `Webhook "${String(row.name ?? row.id)}" stores a signing secret in ${object}.${WEBHOOK_SECRET_FIELD} that resolved to nothing. A value IS stored \u2014 the read path returns the engine mask for it \u2014 so this is NOT an unsigned webhook, and delivering it unsigned would strip the receiver of its only proof of origin (#7799, #8542). Causes, in the order worth checking: the row was deleted while this refresh was reading it; the column holds something that is not a secret: ref (a hand-edited column, or a dump restored without its sys_secret rows); or the stored value decrypts to an empty string. Fix: re-save the webhook secret so the column holds a fresh ref, or CLEAR the field to null if this webhook is meant to be unsigned \u2014 an empty secret is not the same thing as no secret.`
67
+ );
68
+ }
69
+
70
+ // src/webhook-headers.ts
71
+ var WEBHOOK_HEADERS_FIELD = "headers_secret";
72
+ function isHeaderMap(value) {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
74
+ const entries = Object.entries(value);
75
+ if (entries.length === 0) return false;
76
+ return entries.every(([, v]) => typeof v === "string");
77
+ }
78
+ function splitWebhookHeaders(wh) {
79
+ const { headers, ...envelope } = wh;
80
+ return {
81
+ envelope,
82
+ headers: isHeaderMap(headers) ? headers : void 0
83
+ };
84
+ }
85
+ function serializeHeaders(headers) {
86
+ return JSON.stringify(headers);
87
+ }
88
+ function parseStoredHeaders(stored) {
89
+ if (typeof stored !== "string" || stored.length === 0) return void 0;
90
+ try {
91
+ const parsed = JSON.parse(stored);
92
+ return isHeaderMap(parsed) ? parsed : void 0;
93
+ } catch {
94
+ return void 0;
95
+ }
96
+ }
97
+ function readLegacyHeaders(definitionJson) {
98
+ if (typeof definitionJson !== "string" || definitionJson.length === 0) return void 0;
99
+ try {
100
+ const parsed = JSON.parse(definitionJson);
101
+ return isHeaderMap(parsed?.headers) ? parsed.headers : void 0;
102
+ } catch {
103
+ return void 0;
104
+ }
105
+ }
106
+ var WebhookHeadersUnresolvableError = class extends Error {
107
+ constructor(message) {
108
+ super(message);
109
+ this.code = WEBHOOK_SECRET_REFUSAL_CODE;
110
+ this.status = WEBHOOK_SECRET_REFUSAL_STATUS;
111
+ this.name = "WebhookHeadersUnresolvableError";
112
+ }
113
+ };
114
+ var HEADERS_REMEDY = 'Fix: re-save the webhook headers as a flat JSON object of string values so the column holds a fresh ref, or CLEAR the field to null if this webhook is meant to send no custom headers \u2014 an empty or unparseable header map is not the same thing as no header map, and only the second one means "send nothing extra".';
115
+ function requireHeaderMap(recovered, row, where) {
116
+ const parsed = parseStoredHeaders(recovered);
117
+ if (parsed) return parsed;
118
+ throw new WebhookHeadersUnresolvableError(
119
+ `Webhook "${String(row.name ?? row.id)}" stores custom headers in ${where} that came back but are not a flat JSON object of string values, so there is no header map to send. A value IS stored \u2014 the read path returns the engine mask for it \u2014 so this is NOT a webhook authored without headers, and delivering it without them would silently drop whatever the author put in that map, including an Authorization credential, on a delivery that is otherwise correctly signed and therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth checking: the value was typed into the Custom Headers field and is not valid JSON; it parses but is an array, an empty object, or has a non-string value ({"X-Count": 5}); or it is a nested object where the wire format allows only strings. ${HEADERS_REMEDY}`
120
+ );
121
+ }
122
+ async function resolveWebhookHeaders(engine, row, object) {
123
+ const stored = row[WEBHOOK_HEADERS_FIELD];
124
+ if (stored == null || stored === "") return void 0;
125
+ const resolver = engine;
126
+ if (typeof resolver.resolveSecretField !== "function") {
127
+ if (!isOpaqueSecretForm(stored)) {
128
+ return requireHeaderMap(stored, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);
129
+ }
130
+ throw new WebhookHeadersUnresolvableError(
131
+ `Webhook "${String(row.name ?? row.id)}" stores encrypted custom headers, but this data engine does not implement resolveSecretField() \u2014 they cannot be recovered, so the subscription is dropped rather than delivered without the headers it was authored with (#7986).`
132
+ );
133
+ }
134
+ const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);
135
+ if (plain == null || plain === "") {
136
+ throw new WebhookHeadersUnresolvableError(
137
+ `Webhook "${String(row.name ?? row.id)}" stores custom headers in ${object}.${WEBHOOK_HEADERS_FIELD} that resolved to nothing. A value IS stored \u2014 the read path returns the engine mask for it \u2014 so this is NOT a webhook authored without headers, and delivering it without them would silently drop whatever the author put in that map, including an Authorization credential, on a delivery that is otherwise correctly signed and therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth checking: the row was deleted while this refresh was reading it; the column holds something that is not a secret: ref (a hand-edited column, or a dump restored without its sys_secret rows); or the stored value decrypts to an empty string. ${HEADERS_REMEDY}`
138
+ );
139
+ }
140
+ return requireHeaderMap(plain, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);
141
+ }
142
+ async function headersPatch(engine, declared, row, object) {
143
+ const hasStored = row?.[WEBHOOK_HEADERS_FIELD] != null && row[WEBHOOK_HEADERS_FIELD] !== "";
144
+ if (!declared) return hasStored ? { [WEBHOOK_HEADERS_FIELD]: null } : {};
145
+ const serialized = serializeHeaders(declared);
146
+ const resolver = engine;
147
+ if (!hasStored || typeof resolver.resolveSecretField !== "function") {
148
+ return { [WEBHOOK_HEADERS_FIELD]: serialized };
149
+ }
150
+ try {
151
+ const current = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);
152
+ const stored = parseStoredHeaders(current);
153
+ return stored && serializeHeaders(stored) === serialized ? {} : { [WEBHOOK_HEADERS_FIELD]: serialized };
154
+ } catch {
155
+ return { [WEBHOOK_HEADERS_FIELD]: serialized };
156
+ }
157
+ }
4
158
 
5
159
  // src/auto-enqueuer.ts
160
+ var SIGNING_SECRET_CREDENTIAL = {
161
+ field: WEBHOOK_SECRET_FIELD,
162
+ noun: "signing secret",
163
+ article: "an encrypted signing secret",
164
+ ratherThan: "delivered unsigned",
165
+ issue: "#7799",
166
+ issues: "#7799/#8022"
167
+ };
168
+ var CUSTOM_HEADERS_CREDENTIAL = {
169
+ field: WEBHOOK_HEADERS_FIELD,
170
+ noun: "custom header map",
171
+ article: "encrypted custom headers",
172
+ ratherThan: "delivered without the headers it was authored with",
173
+ issue: "#7986",
174
+ issues: "#7986/#8022"
175
+ };
6
176
  var AutoEnqueuer = class {
7
177
  constructor(engine, realtime, enqueue, opts = {}) {
8
178
  this.engine = engine;
@@ -10,6 +180,18 @@ var AutoEnqueuer = class {
10
180
  this.enqueue = enqueue;
11
181
  this.subscriptions = /* @__PURE__ */ new Map();
12
182
  this.running = false;
183
+ /**
184
+ * [#8022] Webhook ids currently dropped for an unresolvable credential —
185
+ * the signing key (#7799) or, since #7986, the custom header map. ONE set
186
+ * for both on purpose: a subscription is either armed or dropped, so a
187
+ * per-credential ledger would let a row already silenced for its key report
188
+ * loudly again for its headers on the very next refresh.
189
+ * Held so the loud first report is said ONCE per outage (AGENTS.md
190
+ * "Degradation log levels": *say it once, at the first degradation*) and
191
+ * again if the same webhook breaks after recovering — not once per row per
192
+ * refresh, forever.
193
+ */
194
+ this.droppedForSecret = /* @__PURE__ */ new Set();
13
195
  this.subscriptionsObject = opts.subscriptionsObject ?? "sys_webhook";
14
196
  this.refreshIntervalMs = opts.refreshIntervalMs ?? 6e4;
15
197
  this.logger = opts.logger ?? {};
@@ -20,6 +202,10 @@ var AutoEnqueuer = class {
20
202
  async start() {
21
203
  if (this.running) return;
22
204
  this.running = true;
205
+ this.unbindCryptoListener = onCryptoProviderChange(
206
+ this.engine,
207
+ () => this.rearmAfterCryptoRegistered()
208
+ );
23
209
  await this.refresh();
24
210
  this.subId = await this.realtime.subscribe(
25
211
  "webhook-auto-enqueuer",
@@ -45,9 +231,32 @@ var AutoEnqueuer = class {
45
231
  if (this.subId) await this.realtime.unsubscribe(this.subId);
46
232
  if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);
47
233
  if (this.refreshTimer) clearInterval(this.refreshTimer);
234
+ this.unbindCryptoListener?.();
48
235
  this.subId = void 0;
49
236
  this.subIdSelfHeal = void 0;
50
237
  this.refreshTimer = void 0;
238
+ this.unbindCryptoListener = void 0;
239
+ }
240
+ /**
241
+ * [#8022] The engine just gained a CryptoProvider — rebuild the cache so
242
+ * subscriptions dropped for an unresolvable signing key re-arm now, instead
243
+ * of at the next periodic refresh up to {@link refreshIntervalMs} away.
244
+ *
245
+ * It deliberately does NOT call {@link refresh} directly. `refresh()`
246
+ * coalesces onto an in-flight build, and the build most likely to be in
247
+ * flight right now is the one from `start()` — the very build whose rows
248
+ * were read while there was no provider. Joining it would return "refreshed"
249
+ * having re-armed nothing, which is this issue with an extra step. So: let
250
+ * whatever is running finish, then read again.
251
+ */
252
+ rearmAfterCryptoRegistered() {
253
+ const inFlight = this.refreshing ?? Promise.resolve();
254
+ void inFlight.catch(() => void 0).then(() => this.running ? this.refresh() : void 0).catch(
255
+ (err) => this.logger.warn?.(
256
+ "[webhook-auto-enqueuer] re-arm after CryptoProvider registration failed",
257
+ err
258
+ )
259
+ );
51
260
  }
52
261
  /**
53
262
  * Force-refresh the subscription cache from storage. Concurrent
@@ -77,6 +286,7 @@ var AutoEnqueuer = class {
77
286
  for (const row of rows) {
78
287
  const sub = this.parseRow(row);
79
288
  if (!sub) continue;
289
+ await this.attachCredentials(sub, row);
80
290
  const key = sub.objectName ?? "*";
81
291
  const arr = next.get(key) ?? [];
82
292
  arr.push(sub);
@@ -84,11 +294,249 @@ var AutoEnqueuer = class {
84
294
  }
85
295
  this.subscriptions.clear();
86
296
  for (const [k, v] of next) this.subscriptions.set(k, v);
297
+ if (this.droppedForSecret.size > 0) {
298
+ const live = new Set(rows.map((r) => String(r?.id)));
299
+ for (const id of this.droppedForSecret) {
300
+ if (!live.has(id)) this.droppedForSecret.delete(id);
301
+ }
302
+ }
87
303
  this.logger.debug?.("[webhook-auto-enqueuer] cache refreshed", {
88
304
  objects: this.subscriptions.size,
89
305
  rows: rows.length
90
306
  });
91
307
  }
308
+ /**
309
+ * [#7799, #7986] Resolve BOTH encrypted credentials for one cached
310
+ * subscription. Returns `false` when the subscription must be dropped from
311
+ * the cache.
312
+ *
313
+ * The two halves are deliberately resolved on the SAME build rather than on
314
+ * separate schedules. #8022's re-arm rebuilds the whole cache when a
315
+ * CryptoProvider registers; a header map recovered on any other cadence
316
+ * would let the enqueuer re-arm into a delivery that is correctly signed and
317
+ * silently missing its `Authorization`, which is the failure mode of both
318
+ * cards at once.
319
+ *
320
+ * The drop ledger is cleared only when BOTH succeed — otherwise a row whose
321
+ * secret resolves and whose headers do not would clear its own "already
322
+ * reported" mark on every refresh and shout the same `error` every 60s,
323
+ * which is precisely the unreadable-error-channel failure #8022's say-once
324
+ * rule exists to prevent.
325
+ *
326
+ * Cost: up to two point reads + two decrypts per credential-bearing row per
327
+ * refresh (default 60s), off the write path entirely. Deliberately NOT
328
+ * memoised across refreshes — the only cheap cache key would be
329
+ * `updated_at`, which nothing guarantees is stamped when a credential is
330
+ * rotated, and a stale key signs every delivery with a signature the
331
+ * receiver rejects.
332
+ */
333
+ async attachCredentials(sub, row) {
334
+ if (!await this.attachSecret(sub, row)) return false;
335
+ if (!await this.attachHeaders(sub, row)) return false;
336
+ this.droppedForSecret.delete(sub.id);
337
+ return true;
338
+ }
339
+ /**
340
+ * [#8069] Mark a subscription parked and strip anything sendable off it.
341
+ *
342
+ * Called from the two `attachX` failure paths, which each already reported
343
+ * the drop at `error` (say-once, #8022). The credentials are cleared rather
344
+ * than merely "not set": `attachSecret` can succeed and `attachHeaders`
345
+ * fail, and a parked row must not carry the header map — that map is the
346
+ * ordinary place an `Authorization: Bearer …` goes (#7986), and copying it
347
+ * onto a row that will sit in `sys_http_delivery` for the full 30d
348
+ * retention window without ever being sent is a credential copy bought for
349
+ * nothing.
350
+ */
351
+ /**
352
+ * [#8069] Report a failed outbox write off the hot path, at the level the
353
+ * loss actually deserves.
354
+ *
355
+ * AGENTS.md decides that with one question — *does the system still look
356
+ * normal from the outside while something it claims is persisted has not
357
+ * landed?* For a PARKED subscription the answer is unambiguously yes, and
358
+ * worse than for an ordinary enqueue failure: the durable record is the
359
+ * only trace this event ever existed, so losing the write puts us back
360
+ * exactly where this issue started, silently. So `error` there, and the
361
+ * pre-existing `warn` for an ordinary enqueue, where the delivery itself is
362
+ * the thing that did not happen and the subscription is otherwise healthy.
363
+ *
364
+ * The realistic cause of the parked branch is a host that wired
365
+ * {@link HttpEnqueueFn} straight to `IHttpOutbox.enqueue` instead of
366
+ * `MessagingService.enqueueHttp`: only the messaging seam routes a parked
367
+ * input to `recordUndeliverable()`, and the raw delivery door refuses the
368
+ * discriminator rather than minting a `pending` unsigned row from it. The
369
+ * message names that, because it is not guessable from "enqueue failed".
370
+ */
371
+ reportWriteFailure(sub, eventId, err, verb) {
372
+ const meta = { webhook: sub.name, eventId, err: err?.message ?? err };
373
+ if (!sub.parkedReason) {
374
+ this.logger.warn?.(`[webhook-auto-enqueuer] ${verb} failed`, meta);
375
+ return;
376
+ }
377
+ const message = `[webhook-auto-enqueuer] could not record the undeliverable event for webhook '${sub.name}' \u2014 the subscription is parked for an unresolvable credential, and this event is now DISCARDED WITH NO TRACE in sys_http_delivery, which is the durability gap #8069 closes. Most likely cause: the enqueue callback was wired directly to IHttpOutbox.enqueue instead of MessagingService.enqueueHttp \u2014 only the messaging seam routes a parked event to recordUndeliverable(), and the delivery door refuses it rather than minting a pending row that would be sent UNSIGNED.`;
378
+ if (typeof this.logger.error === "function") {
379
+ this.logger.error(message, err, meta);
380
+ } else {
381
+ this.logger.warn?.(message, meta);
382
+ }
383
+ }
384
+ park(sub, err, credential) {
385
+ sub.secret = void 0;
386
+ sub.headers = void 0;
387
+ sub.parkedReason = `[${WEBHOOK_SECRET_REFUSAL_CODE}/${WEBHOOK_SECRET_REFUSAL_STATUS}] webhook '${sub.name}' holds ${credential.article} that could not be decrypted, so this event was NOT delivered \u2014 recording it here rather than ${credential.ratherThan} (${credential.issue}, #8069). This row was never sent and cannot be redelivered: it carries no HMAC signature, because the ${credential.noun} that would have produced one is exactly what is missing. Fix: register a CryptoProvider (engine.setCryptoProvider \u2014 LocalCryptoProvider in dev, KMS/Vault in production) with the same key the ${credential.noun} was written under, and make sure the sys_secret row is reachable; the subscription re-arms on registration (#8022) and at the next periodic refresh, and later events are delivered normally. Cause: ${err?.message ?? String(err)}`;
388
+ }
389
+ /**
390
+ * [#7799] Resolve `sub.secret`. Returns `false` when the subscription must
391
+ * be dropped.
392
+ *
393
+ * Three sources, in order:
394
+ * 1. `sys_webhook.signing_secret` — the encrypted column. The read path
395
+ * returns a mask, so presence is decidable here but the value is not;
396
+ * `resolveWebhookSecret` dereferences it server-side.
397
+ * 2. `definition_json.secret` — a row not yet swept by
398
+ * `migrateLegacyWebhookSecrets` (or hand-edited back in). Still honoured
399
+ * so an un-migrated deployment keeps signing, and warned about once per
400
+ * refresh so the exposure is visible rather than silently permanent.
401
+ * 3. Neither — an unsigned webhook, which is a legitimate authored choice
402
+ * (`secret` is optional on the envelope).
403
+ *
404
+ * A stored-but-unresolvable key DROPS the subscription instead of
405
+ * delivering unsigned. The signature is the receiver's only proof of
406
+ * origin (#7722, #7799): a webhook that stops arriving is visible and gets
407
+ * investigated, while one that keeps arriving unsigned is invisible and
408
+ * teaches the receiver to accept unauthenticated traffic.
409
+ *
410
+ * [#8542] Case 3 means what it says only because the seam was fixed to say
411
+ * it. `resolveWebhookSecret` used to answer `undefined` for BOTH "no key is
412
+ * stored" and "a key is stored and did not come back", so this method read
413
+ * the second as the third and armed the subscription — the invariant above
414
+ * failing OPEN, silently, on the producer path. Nothing here changed: the
415
+ * seam now raises for that case, so it lands in the `catch` below exactly
416
+ * the way a throwing resolver already did, and the drop, the say-once
417
+ * `error` and the #8069 park all apply to it unchanged.
418
+ */
419
+ async attachSecret(sub, row) {
420
+ try {
421
+ const stored = await resolveWebhookSecret(this.engine, row, this.subscriptionsObject);
422
+ if (stored) {
423
+ sub.secret = stored;
424
+ return true;
425
+ }
426
+ } catch (err) {
427
+ this.reportDrop(sub, err, SIGNING_SECRET_CREDENTIAL);
428
+ this.park(sub, err, SIGNING_SECRET_CREDENTIAL);
429
+ return false;
430
+ }
431
+ const legacy = readLegacySecret(row?.definition_json);
432
+ if (legacy) {
433
+ this.logger.warn?.(
434
+ `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its signing secret as CLEARTEXT in definition_json, readable over the data API (#7799). Signing continues from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired to move it into sys_secret.`,
435
+ { id: sub.id }
436
+ );
437
+ sub.secret = legacy;
438
+ }
439
+ return true;
440
+ }
441
+ /**
442
+ * [#7986] Resolve `sub.headers` from the encrypted column, with the same
443
+ * three-source shape as {@link attachSecret} and for the same reasons.
444
+ *
445
+ * A stored-but-unresolvable header map DROPS the subscription rather than
446
+ * delivering without it. That is the identical trade #7799 made for the
447
+ * signature, and it needs restating because the intuition runs the other
448
+ * way: a missing `Authorization` looks self-announcing, since the receiver
449
+ * answers 401 and the attempt lands in `sys_http_delivery` for anyone to
450
+ * find. But that is only the AUTHENTICATED case. Against an endpoint that
451
+ * does not require the header — a routing `X-Tenant-Id`, an
452
+ * `X-Environment: staging` — the delivery SUCCEEDS while quietly deviating
453
+ * from the configuration the author wrote, and nothing anywhere records
454
+ * that it went out incomplete. A subscription that stops is visible; a
455
+ * delivery that arrives subtly wrong is not.
456
+ *
457
+ * [#8558] And that is what this method used to do, for the same reason its
458
+ * signing sibling did (#8542): `resolveWebhookHeaders` answered `undefined`
459
+ * for BOTH "no headers are stored" and "a map is stored and did not come
460
+ * back as one", so this method read the second as the first and armed the
461
+ * subscription — the paragraph above failing OPEN. Measured, the delivery
462
+ * then went out SUCCESSFULLY and correctly SIGNED with the whole authored
463
+ * map missing, which is the worst available combination: the signature
464
+ * tells the receiver the request is genuinely ours. Nothing here changed:
465
+ * the seam now raises, so it lands in the `catch` below exactly the way a
466
+ * throwing resolver already did, and the drop, the say-once `error` and the
467
+ * #8069 park all apply to it unchanged.
468
+ */
469
+ async attachHeaders(sub, row) {
470
+ try {
471
+ const stored = await resolveWebhookHeaders(this.engine, row, this.subscriptionsObject);
472
+ if (stored) {
473
+ sub.headers = stored;
474
+ return true;
475
+ }
476
+ } catch (err) {
477
+ this.reportDrop(sub, err, CUSTOM_HEADERS_CREDENTIAL);
478
+ this.park(sub, err, CUSTOM_HEADERS_CREDENTIAL);
479
+ return false;
480
+ }
481
+ const legacy = readLegacyHeaders(row?.definition_json);
482
+ if (legacy) {
483
+ this.logger.warn?.(
484
+ `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its custom headers as CLEARTEXT in definition_json, readable over the data API (#7986) \u2014 that map is the ordinary place an Authorization header goes. Delivery continues from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired to move them into sys_secret.`,
485
+ { id: sub.id }
486
+ );
487
+ sub.headers = legacy;
488
+ }
489
+ return true;
490
+ }
491
+ /**
492
+ * [#8022] Report a subscription dropped for an unresolvable signing key.
493
+ *
494
+ * ## Why `error`, and why only the first time
495
+ * AGENTS.md decides the level with one question: *after the degradation,
496
+ * does the system still look normal from the outside while something the
497
+ * system claims is happening is not?* Here the answer is yes, and it is the
498
+ * whole defect — `GET /api/v1/data/sys_webhook` keeps reading
499
+ * `active: true`, Setup keeps showing the webhook armed, and every matching
500
+ * record change is discarded with no delivery and no `sys_http_delivery`
501
+ * row to find afterwards. That is a durability degradation wearing a
502
+ * functional degradation's clothes, so it owes the two things an `error`
503
+ * owes: the consequence, concretely, and the fix.
504
+ *
505
+ * Said ONCE per outage per webhook, per the same section. The cache is
506
+ * rebuilt every {@link refreshIntervalMs}; an unfixed misconfiguration would
507
+ * otherwise print this line every 60s forever, which is how an `error`
508
+ * channel becomes unreadable — the failure mode that made the founding
509
+ * incident's `warn` invisible. Repeats drop to `debug`; a recovery clears
510
+ * the id, so a re-break is loud again.
511
+ *
512
+ * ADR-0112: `code` + `status` travel in the meta so a consumer branches on
513
+ * the pair, not on message text. Same pair the seeder's refusal carries for
514
+ * the same underlying cause.
515
+ */
516
+ reportDrop(sub, err, credential = SIGNING_SECRET_CREDENTIAL) {
517
+ const meta = {
518
+ id: sub.id,
519
+ webhook: sub.name,
520
+ field: credential.field,
521
+ code: WEBHOOK_SECRET_REFUSAL_CODE,
522
+ status: WEBHOOK_SECRET_REFUSAL_STATUS,
523
+ err: err?.message ?? err
524
+ };
525
+ if (this.droppedForSecret.has(sub.id)) {
526
+ this.logger.debug?.(
527
+ `[webhook-auto-enqueuer] webhook '${sub.name}' is still dropped for an unresolvable ${credential.noun} (${credential.issues})`,
528
+ meta
529
+ );
530
+ return;
531
+ }
532
+ this.droppedForSecret.add(sub.id);
533
+ const message = `[webhook-auto-enqueuer] webhook '${sub.name}' holds ${credential.article} that could not be decrypted \u2014 the subscription is PARKED rather than ${credential.ratherThan} (${credential.issue}), so every matching record change is discarded with NO delivery, while the row keeps reading active:true in Setup. Each discarded event IS recorded in sys_http_delivery as a dead row with 0 attempts carrying this cause (#8069) \u2014 look there for the backlog; those rows can never be sent or redelivered, because a parked row has no HMAC signature. Fix: register a CryptoProvider (engine.setCryptoProvider \u2014 LocalCryptoProvider in dev, KMS/Vault in production) with the same key the ${credential.noun} was written under, and make sure the sys_secret row is reachable; the subscription re-arms on registration (#8022) and at the next periodic refresh.`;
534
+ if (typeof this.logger.error === "function") {
535
+ this.logger.error(message, err, meta);
536
+ } else {
537
+ this.logger.warn?.(message, meta);
538
+ }
539
+ }
92
540
  parseRow(row) {
93
541
  if (!row?.id || !row?.url) return null;
94
542
  const rawTriggers = row.triggers;
@@ -145,8 +593,9 @@ var AutoEnqueuer = class {
145
593
  // method regardless of whether the row was authored before or after
146
594
  // the select change (legacy rows stored 'POST').
147
595
  method: String(row.method ?? defn.method ?? "POST").toUpperCase(),
148
- headers: defn.headers,
149
- secret: defn.secret,
596
+ // `headers` and `secret` are both filled by attachCredentials()
597
+ // from their encrypted columns, NOT read off the row — see #7799
598
+ // (secret) and #7986 (headers).
150
599
  timeoutMs: defn.timeoutMs
151
600
  };
152
601
  }
@@ -194,6 +643,12 @@ var AutoEnqueuer = class {
194
643
  method: sub.method,
195
644
  headers: sub.headers,
196
645
  signingSecret: sub.secret,
646
+ // [#8069] Set only for a PARKED subscription, and then this is
647
+ // not an enqueue at all: the messaging seam routes it to
648
+ // `recordUndeliverable()`, which writes a terminal `dead` row
649
+ // with this reason and no signature. Undefined for every healthy
650
+ // subscription, so the delivery path is byte-identical to before.
651
+ undeliverableReason: sub.parkedReason,
197
652
  timeoutMs: sub.timeoutMs,
198
653
  // [#3946] Envelope keys are written LAST so the event payload
199
654
  // cannot rewrite them. Behaviour-neutral for the engine's own
@@ -212,13 +667,7 @@ var AutoEnqueuer = class {
212
667
  action,
213
668
  timestamp: event.timestamp
214
669
  }
215
- }).catch(
216
- (err) => this.logger.warn?.("[webhook-auto-enqueuer] enqueue failed", {
217
- webhook: sub.name,
218
- eventId,
219
- err: err?.message ?? err
220
- })
221
- );
670
+ }).catch((err) => this.reportWriteFailure(sub, eventId, err, "enqueue"));
222
671
  }
223
672
  }
224
673
  /**
@@ -271,6 +720,9 @@ var AutoEnqueuer = class {
271
720
  method: sub.method,
272
721
  headers: sub.headers,
273
722
  signingSecret: sub.secret,
723
+ // [#8069] See the per-record path — parked subscriptions record
724
+ // an undeliverable row instead of enqueuing a delivery.
725
+ undeliverableReason: sub.parkedReason,
274
726
  timeoutMs: sub.timeoutMs,
275
727
  // [#3946] Envelope keys last so the payload cannot rewrite them.
276
728
  payload: {
@@ -280,13 +732,7 @@ var AutoEnqueuer = class {
280
732
  action,
281
733
  timestamp: event.timestamp
282
734
  }
283
- }).catch(
284
- (err) => this.logger.warn?.("[webhook-auto-enqueuer] bulk enqueue failed", {
285
- webhook: sub.name,
286
- eventId,
287
- err: err?.message ?? err
288
- })
289
- );
735
+ }).catch((err) => this.reportWriteFailure(sub, eventId, err, "bulk enqueue"));
290
736
  }
291
737
  }
292
738
  handleSelfHealEvent(event) {
@@ -343,7 +789,7 @@ function readDeclared(engine, metadataService, type) {
343
789
  try {
344
790
  const reg = engine?._registry;
345
791
  if (reg?.listItems) {
346
- const items = (reg.listItems(type) ?? []).map((i) => i?.content ?? i).filter(Boolean);
792
+ const items = (reg.listItems(type) ?? []).filter(Boolean);
347
793
  if (items.length > 0) return items;
348
794
  }
349
795
  } catch {
@@ -351,7 +797,7 @@ function readDeclared(engine, metadataService, type) {
351
797
  try {
352
798
  const listed = metadataService?.list?.(type);
353
799
  const arr = typeof listed?.then === "function" ? [] : listed ?? [];
354
- return Array.isArray(arr) ? arr.map((i) => i?.content ?? i).filter(Boolean) : [];
800
+ return Array.isArray(arr) ? arr.filter(Boolean) : [];
355
801
  } catch {
356
802
  return [];
357
803
  }
@@ -396,6 +842,13 @@ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscr
396
842
  const patch = {
397
843
  id: row.id,
398
844
  ...mapWebhookToRow(wh),
845
+ ...await secretPatch(engine, wh, row, subscriptionsObject),
846
+ ...await headersPatch(
847
+ engine,
848
+ splitWebhookHeaders(wh).headers,
849
+ row,
850
+ subscriptionsObject
851
+ ),
399
852
  // Adopt pristine/legacy (pre-provenance) rows so future boots
400
853
  // recognize them as package-managed.
401
854
  managed_by: "package",
@@ -405,9 +858,20 @@ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscr
405
858
  seeded += 1;
406
859
  continue;
407
860
  }
861
+ const { secret } = splitWebhookSecret(wh);
862
+ const { headers } = splitWebhookHeaders(wh);
408
863
  const newRow = {
409
864
  id: uid("whk"),
410
865
  ...mapWebhookToRow(wh),
866
+ // Cleartext goes in exactly once, into the `secret`-typed column; the
867
+ // engine's write path wraps it into `sys_secret` and leaves an opaque
868
+ // ref behind. Omitted entirely when unauthored, so a webhook with no
869
+ // secret costs no crypto and needs no CryptoProvider.
870
+ ...secret ? { [WEBHOOK_SECRET_FIELD]: secret } : {},
871
+ // [#7986] Same channel, same rule, for the header map — omitted when
872
+ // unauthored so a header-less webhook still seeds on a host with no
873
+ // CryptoProvider wired.
874
+ ...headers ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(headers) } : {},
411
875
  managed_by: "package",
412
876
  customized: false,
413
877
  created_at: now,
@@ -416,10 +880,15 @@ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscr
416
880
  await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX });
417
881
  seeded += 1;
418
882
  } catch (err) {
419
- logger?.warn?.("[webhook] declared webhook seed failed", {
420
- name: wh.name,
421
- error: err?.message ?? String(err)
422
- });
883
+ const protection = isSecretProtectionFailure(err);
884
+ logger?.warn?.(
885
+ protection ? "[webhook] declared webhook NOT seeded \u2014 its signing secret cannot be stored encrypted (#7799)" : "[webhook] declared webhook seed failed",
886
+ {
887
+ name: wh.name,
888
+ ...protection ? { code: WEBHOOK_SECRET_REFUSAL_CODE, status: WEBHOOK_SECRET_REFUSAL_STATUS } : {},
889
+ error: err?.message ?? String(err)
890
+ }
891
+ );
423
892
  skipped += 1;
424
893
  }
425
894
  }
@@ -430,7 +899,25 @@ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscr
430
899
  });
431
900
  return { seeded, skipped };
432
901
  }
902
+ async function secretPatch(engine, wh, row, subscriptionsObject) {
903
+ const { secret } = splitWebhookSecret(wh);
904
+ const hasStored = row?.[WEBHOOK_SECRET_FIELD] != null && row[WEBHOOK_SECRET_FIELD] !== "";
905
+ if (!secret) return hasStored ? { [WEBHOOK_SECRET_FIELD]: null } : {};
906
+ if (!hasStored || !canResolveSecrets(engine)) return { [WEBHOOK_SECRET_FIELD]: secret };
907
+ try {
908
+ const current = await engine.resolveSecretField(
909
+ subscriptionsObject,
910
+ String(row.id),
911
+ WEBHOOK_SECRET_FIELD
912
+ );
913
+ return current === secret ? {} : { [WEBHOOK_SECRET_FIELD]: secret };
914
+ } catch {
915
+ return { [WEBHOOK_SECRET_FIELD]: secret };
916
+ }
917
+ }
433
918
  function mapWebhookToRow(wh) {
919
+ const { envelope: withoutSecret } = splitWebhookSecret(wh);
920
+ const { envelope } = splitWebhookHeaders(withoutSecret);
434
921
  return {
435
922
  name: wh.name,
436
923
  label: wh.label ?? wh.name,
@@ -442,13 +929,100 @@ function mapWebhookToRow(wh) {
442
929
  method: String(wh.method ?? "POST").toLowerCase(),
443
930
  description: wh.description ?? null,
444
931
  active: wh.isActive !== false,
445
- definition_json: JSON.stringify(wh)
932
+ definition_json: JSON.stringify(envelope)
933
+ };
934
+ }
935
+
936
+ // src/migrate-webhook-secrets.ts
937
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
938
+ var SYSTEM_QUERY = {
939
+ context: { isSystem: true, positions: [], permissions: [] }
940
+ };
941
+ async function migrateLegacyWebhookSecrets(engine, logger, subscriptionsObject = WEBHOOK_OBJECT) {
942
+ const out = { found: 0, migrated: 0, failed: 0 };
943
+ let rows;
944
+ try {
945
+ const found = await engine.find(subscriptionsObject, SYSTEM_QUERY);
946
+ rows = Array.isArray(found) ? found : found?.data ?? [];
947
+ } catch (err) {
948
+ logger?.warn?.("[webhook] legacy secret sweep skipped \u2014 could not read subscriptions", {
949
+ object: subscriptionsObject,
950
+ error: err?.message ?? String(err)
951
+ });
952
+ return out;
953
+ }
954
+ for (const row of rows) {
955
+ if (!row?.id) continue;
956
+ const legacySecret = readLegacySecret(row.definition_json);
957
+ const legacyHeaders = readLegacyHeaders(row.definition_json);
958
+ if (!legacySecret && !legacyHeaders) continue;
959
+ out.found += 1;
960
+ try {
961
+ await engine.update(
962
+ subscriptionsObject,
963
+ {
964
+ id: row.id,
965
+ ...legacySecret ? { [WEBHOOK_SECRET_FIELD]: legacySecret } : {},
966
+ ...legacyHeaders ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(legacyHeaders) } : {},
967
+ definition_json: stripCredentialsFromDefinition(row.definition_json)
968
+ },
969
+ { context: SYSTEM_CTX2 }
970
+ );
971
+ out.migrated += 1;
972
+ } catch (err) {
973
+ out.failed += 1;
974
+ const protection = isSecretProtectionFailure(err);
975
+ const what = legacySecret && legacyHeaders ? "signing secret and custom headers" : legacySecret ? "signing secret" : "custom headers";
976
+ logger?.warn?.(
977
+ protection ? `[webhook] ${what} STILL CLEARTEXT in definition_json \u2014 no CryptoProvider to encrypt them (#7799/#7986)` : `[webhook] ${what} migration failed \u2014 row left unchanged (#7799/#7986)`,
978
+ {
979
+ name: row.name ?? row.id,
980
+ id: row.id,
981
+ code: WEBHOOK_SECRET_REFUSAL_CODE,
982
+ status: WEBHOOK_SECRET_REFUSAL_STATUS,
983
+ error: err?.message ?? String(err)
984
+ }
985
+ );
986
+ }
987
+ }
988
+ if (out.found > 0) {
989
+ logger?.info?.("[webhook] legacy cleartext credentials swept into sys_secret", { ...out });
990
+ }
991
+ return out;
992
+ }
993
+ function stripCredentialsFromDefinition(definitionJson) {
994
+ const parsed = JSON.parse(definitionJson);
995
+ const { envelope: withoutSecret } = splitWebhookSecret(parsed);
996
+ const { envelope } = splitWebhookHeaders(withoutSecret);
997
+ return JSON.stringify(envelope);
998
+ }
999
+
1000
+ // src/redeliver-guard.ts
1001
+ function createWebhookRedeliverGuard(engine, subscriptionsObject = WEBHOOK_OBJECT) {
1002
+ return async (row) => {
1003
+ if (row.source !== "webhook") return void 0;
1004
+ const subscription = await engine.findOne(subscriptionsObject, {
1005
+ where: { id: row.refId }
1006
+ });
1007
+ if (!subscription) {
1008
+ return `the ${subscriptionsObject} subscription '${row.refId}' this delivery belongs to no longer exists, so there is nothing left to say whether it may still be signed and sent (#8069). Recreate the webhook if the endpoint should keep receiving events; new events are then delivered signed.`;
1009
+ }
1010
+ const storesSecret = subscription[WEBHOOK_SECRET_FIELD] != null && subscription[WEBHOOK_SECRET_FIELD] !== "";
1011
+ if (!storesSecret) return void 0;
1012
+ let plaintext;
1013
+ try {
1014
+ plaintext = await resolveWebhookSecret(engine, subscription, subscriptionsObject);
1015
+ } catch (err) {
1016
+ if (!isWebhookSecretUnresolvable(err)) throw err;
1017
+ }
1018
+ if (plaintext) return void 0;
1019
+ return `webhook '${String(subscription.name ?? row.refId)}' stores a signing secret that cannot be recovered, so this delivery cannot be authenticated as coming from us \u2014 refusing rather than sending (#7799, #8069). Fix: register a CryptoProvider with the same key the secret was written under and make sure the sys_secret row is reachable.`;
446
1020
  };
447
1021
  }
448
1022
 
449
1023
  // src/webhook-provenance.ts
450
1024
  var WEBHOOK_PROVENANCE_PACKAGE = "plugin-webhooks:provenance";
451
- var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
1025
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
452
1026
  function bindWebhookProvenanceStamp(engine, logger) {
453
1027
  if (typeof engine?.registerHook !== "function") return;
454
1028
  engine.registerHook(
@@ -464,7 +1038,7 @@ function bindWebhookProvenanceStamp(engine, logger) {
464
1038
  where: { id },
465
1039
  fields: ["id", "managed_by", "customized"],
466
1040
  limit: 1,
467
- context: SYSTEM_CTX2
1041
+ context: SYSTEM_CTX3
468
1042
  });
469
1043
  const row = Array.isArray(rows) ? rows[0] : void 0;
470
1044
  if (!row) return;
@@ -541,7 +1115,7 @@ var WebhookOutboxPlugin = class {
541
1115
  try {
542
1116
  const i18n = ctx.getService("i18n");
543
1117
  if (i18n && typeof i18n.loadTranslations === "function") {
544
- const { WebhooksTranslations } = await import("./translations-2VGD4XRF.js");
1118
+ const { WebhooksTranslations } = await import("./translations-U32QIEPB.js");
545
1119
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
546
1120
  i18n.loadTranslations(locale, data);
547
1121
  }
@@ -608,6 +1182,13 @@ var WebhookOutboxPlugin = class {
608
1182
  error: err?.message ?? String(err)
609
1183
  });
610
1184
  }
1185
+ try {
1186
+ await migrateLegacyWebhookSecrets(engine, ctx.logger);
1187
+ } catch (err) {
1188
+ ctx.logger.warn?.("[webhook] legacy signing-secret sweep failed (rows left unchanged)", {
1189
+ error: err?.message ?? String(err)
1190
+ });
1191
+ }
611
1192
  }
612
1193
  async bootAutoEnqueue(ctx, opt) {
613
1194
  if (opt === false) return;
@@ -627,6 +1208,7 @@ var WebhookOutboxPlugin = class {
627
1208
  );
628
1209
  }
629
1210
  const enqOpts = typeof opt === "object" ? opt : {};
1211
+ this.installRedeliverGuard(ctx, messaging, engine, enqOpts.subscriptionsObject);
630
1212
  this.autoEnqueuer = new AutoEnqueuer(
631
1213
  engine,
632
1214
  realtime,
@@ -637,6 +1219,31 @@ var WebhookOutboxPlugin = class {
637
1219
  ctx.registerService("webhook.autoEnqueuer", this.autoEnqueuer);
638
1220
  ctx.logger.info?.("[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)");
639
1221
  }
1222
+ /**
1223
+ * [#8069] Register {@link createWebhookRedeliverGuard} with messaging, so
1224
+ * `redeliver()` refuses a webhook row whose signing configuration is no
1225
+ * longer available — for EVERY caller, not just the
1226
+ * `POST /api/v1/webhooks/redeliver` route.
1227
+ *
1228
+ * Absence is loud, and `error` is the right level by AGENTS.md's one
1229
+ * question: with no guard installed the endpoint still answers 200 and the
1230
+ * dispatcher still reports a delivery, while the fail-closed signing
1231
+ * guarantee the system claims (#7799) is not actually being kept. That is a
1232
+ * durability/consistency degradation wearing a functional one's clothes.
1233
+ */
1234
+ installRedeliverGuard(ctx, messaging, engine, subscriptionsObject) {
1235
+ if (typeof messaging.registerRedeliverGuard !== "function") {
1236
+ ctx.logger.error?.(
1237
+ "[webhook-outbox] messaging service exposes no registerRedeliverGuard() \u2014 redelivery of a webhook whose signing configuration is gone CANNOT be refused, so an operator pressing redeliver may send a delivery that can no longer be authenticated (#7799, #8069). The POST /api/v1/webhooks/redeliver endpoint is reachable by any authenticated user. Fix: upgrade @objectstack/service-messaging to a build that implements registerRedeliverGuard."
1238
+ );
1239
+ return;
1240
+ }
1241
+ messaging.registerRedeliverGuard(
1242
+ "webhook",
1243
+ createWebhookRedeliverGuard(engine, subscriptionsObject)
1244
+ );
1245
+ ctx.logger.debug?.("[webhook-outbox] redeliver guard installed for source=webhook");
1246
+ }
640
1247
  tryGetService(ctx, names) {
641
1248
  for (const n of names) {
642
1249
  try {
@@ -691,7 +1298,7 @@ var WebhookOutboxPlugin = class {
691
1298
  if (code === "RESOURCE_NOT_FOUND") {
692
1299
  return c.json({ success: false, error: { code, message: err.message } }, 404);
693
1300
  }
694
- if (code === "DELIVERY_NOT_ELIGIBLE") {
1301
+ if (code === "DELIVERY_NOT_ELIGIBLE" || code === "DELIVERY_NEVER_SENT") {
695
1302
  return c.json({ success: false, error: { code, message: err.message } }, 409);
696
1303
  }
697
1304
  ctx.logger.error?.("[webhook-outbox] redeliver failed", err);
@@ -723,6 +1330,9 @@ var WebhookOutboxPlugin = class {
723
1330
  export {
724
1331
  AutoEnqueuer,
725
1332
  SysWebhook,
726
- WebhookOutboxPlugin
1333
+ WEBHOOK_HEADERS_FIELD,
1334
+ WEBHOOK_SECRET_FIELD,
1335
+ WebhookOutboxPlugin,
1336
+ migrateLegacyWebhookSecrets
727
1337
  };
728
1338
  //# sourceMappingURL=index.js.map