@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.cjs CHANGED
@@ -1,8 +1,178 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
2
 
3
- var _chunkJQUVS5KKcjs = require('./chunk-JQUVS5KK.cjs');
3
+ var _chunkQ4FEMGD6cjs = require('./chunk-Q4FEMGD6.cjs');
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(_nullishCoalesce(_nullishCoalesce(_optionalChain([err, 'optionalAccess', _ => _.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 = _optionalChain([parsed, 'optionalAccess', _2 => _2.secret]);
35
+ return typeof secret === "string" && secret.length > 0 ? secret : void 0;
36
+ } catch (e) {
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 _optionalChain([engine, 'optionalAccess', _3 => _3.resolveSecretField]) === "function";
47
+ }
48
+ function onCryptoProviderChange(engine, listener) {
49
+ const observable = engine;
50
+ if (typeof _optionalChain([observable, 'optionalAccess', _4 => _4.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(_nullishCoalesce(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(_nullishCoalesce(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 (e2) {
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(_optionalChain([parsed, 'optionalAccess', _5 => _5.headers])) ? parsed.headers : void 0;
102
+ } catch (e3) {
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(_nullishCoalesce(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(_nullishCoalesce(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(_nullishCoalesce(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 = _optionalChain([row, 'optionalAccess', _6 => _6[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 (e4) {
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 = _nullishCoalesce(opts.subscriptionsObject, () => ( "sys_webhook"));
14
196
  this.refreshIntervalMs = _nullishCoalesce(opts.refreshIntervalMs, () => ( 6e4));
15
197
  this.logger = _nullishCoalesce(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",
@@ -33,10 +219,10 @@ var AutoEnqueuer = class {
33
219
  if (this.refreshIntervalMs > 0) {
34
220
  this.refreshTimer = setInterval(() => {
35
221
  this.refresh().catch(
36
- (err) => _optionalChain([this, 'access', _ => _.logger, 'access', _2 => _2.warn, 'optionalCall', _3 => _3("[webhook-auto-enqueuer] periodic refresh failed", err)])
222
+ (err) => _optionalChain([this, 'access', _7 => _7.logger, 'access', _8 => _8.warn, 'optionalCall', _9 => _9("[webhook-auto-enqueuer] periodic refresh failed", err)])
37
223
  );
38
224
  }, this.refreshIntervalMs);
39
- _optionalChain([this, 'access', _4 => _4.refreshTimer, 'access', _5 => _5.unref, 'optionalCall', _6 => _6()]);
225
+ _optionalChain([this, 'access', _10 => _10.refreshTimer, 'access', _11 => _11.unref, 'optionalCall', _12 => _12()]);
40
226
  }
41
227
  }
42
228
  async stop() {
@@ -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
+ _optionalChain([this, 'access', _13 => _13.unbindCryptoListener, 'optionalCall', _14 => _14()]);
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 = _nullishCoalesce(this.refreshing, () => ( Promise.resolve()));
254
+ void inFlight.catch(() => void 0).then(() => this.running ? this.refresh() : void 0).catch(
255
+ (err) => _optionalChain([this, 'access', _15 => _15.logger, 'access', _16 => _16.warn, 'optionalCall', _17 => _17(
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
@@ -67,7 +276,7 @@ var AutoEnqueuer = class {
67
276
  where: { active: true }
68
277
  });
69
278
  } catch (err) {
70
- _optionalChain([this, 'access', _7 => _7.logger, 'access', _8 => _8.warn, 'optionalCall', _9 => _9(
279
+ _optionalChain([this, 'access', _18 => _18.logger, 'access', _19 => _19.warn, 'optionalCall', _20 => _20(
71
280
  `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,
72
281
  err
73
282
  )]);
@@ -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 = _nullishCoalesce(sub.objectName, () => ( "*"));
81
291
  const arr = _nullishCoalesce(next.get(key), () => ( []));
82
292
  arr.push(sub);
@@ -84,13 +294,251 @@ var AutoEnqueuer = class {
84
294
  }
85
295
  this.subscriptions.clear();
86
296
  for (const [k, v] of next) this.subscriptions.set(k, v);
87
- _optionalChain([this, 'access', _10 => _10.logger, 'access', _11 => _11.debug, 'optionalCall', _12 => _12("[webhook-auto-enqueuer] cache refreshed", {
297
+ if (this.droppedForSecret.size > 0) {
298
+ const live = new Set(rows.map((r) => String(_optionalChain([r, 'optionalAccess', _21 => _21.id]))));
299
+ for (const id of this.droppedForSecret) {
300
+ if (!live.has(id)) this.droppedForSecret.delete(id);
301
+ }
302
+ }
303
+ _optionalChain([this, 'access', _22 => _22.logger, 'access', _23 => _23.debug, 'optionalCall', _24 => _24("[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: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _25 => _25.message]), () => ( err)) };
373
+ if (!sub.parkedReason) {
374
+ _optionalChain([this, 'access', _26 => _26.logger, 'access', _27 => _27.warn, 'optionalCall', _28 => _28(`[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
+ _optionalChain([this, 'access', _29 => _29.logger, 'access', _30 => _30.warn, 'optionalCall', _31 => _31(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: ${_nullishCoalesce(_optionalChain([err, 'optionalAccess', _32 => _32.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(_optionalChain([row, 'optionalAccess', _33 => _33.definition_json]));
432
+ if (legacy) {
433
+ _optionalChain([this, 'access', _34 => _34.logger, 'access', _35 => _35.warn, 'optionalCall', _36 => _36(
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(_optionalChain([row, 'optionalAccess', _37 => _37.definition_json]));
482
+ if (legacy) {
483
+ _optionalChain([this, 'access', _38 => _38.logger, 'access', _39 => _39.warn, 'optionalCall', _40 => _40(
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: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _41 => _41.message]), () => ( err))
524
+ };
525
+ if (this.droppedForSecret.has(sub.id)) {
526
+ _optionalChain([this, 'access', _42 => _42.logger, 'access', _43 => _43.debug, 'optionalCall', _44 => _44(
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
+ _optionalChain([this, 'access', _45 => _45.logger, 'access', _46 => _46.warn, 'optionalCall', _47 => _47(message, meta)]);
538
+ }
539
+ }
92
540
  parseRow(row) {
93
- if (!_optionalChain([row, 'optionalAccess', _13 => _13.id]) || !_optionalChain([row, 'optionalAccess', _14 => _14.url])) return null;
541
+ if (!_optionalChain([row, 'optionalAccess', _48 => _48.id]) || !_optionalChain([row, 'optionalAccess', _49 => _49.url])) return null;
94
542
  const rawTriggers = row.triggers;
95
543
  let triggerList;
96
544
  if (Array.isArray(rawTriggers)) {
@@ -101,7 +549,7 @@ var AutoEnqueuer = class {
101
549
  try {
102
550
  const parsed = JSON.parse(s);
103
551
  triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];
104
- } catch (e) {
552
+ } catch (e5) {
105
553
  triggerList = s.split(",");
106
554
  }
107
555
  } else {
@@ -111,7 +559,7 @@ var AutoEnqueuer = class {
111
559
  const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);
112
560
  const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));
113
561
  if (unknown.length > 0) {
114
- _optionalChain([this, 'access', _15 => _15.logger, 'access', _16 => _16.warn, 'optionalCall', _17 => _17(
562
+ _optionalChain([this, 'access', _50 => _50.logger, 'access', _51 => _51.warn, 'optionalCall', _52 => _52(
115
563
  `[webhook-auto-enqueuer] webhook '${_nullishCoalesce(row.name, () => ( row.id))}' declares trigger(s) the engine never emits: ${unknown.join(", ")} \u2014 ignored. Dispatchable triggers: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(", ")}.`,
116
564
  { id: row.id, unknown }
117
565
  )]);
@@ -120,7 +568,7 @@ var AutoEnqueuer = class {
120
568
  normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t))
121
569
  );
122
570
  if (triggers.size === 0) {
123
- _optionalChain([this, 'access', _18 => _18.logger, 'access', _19 => _19.warn, 'optionalCall', _20 => _20(
571
+ _optionalChain([this, 'access', _53 => _53.logger, 'access', _54 => _54.warn, 'optionalCall', _55 => _55(
124
572
  `[webhook-auto-enqueuer] webhook '${_nullishCoalesce(row.name, () => ( row.id))}' has no dispatchable triggers \u2014 it will NEVER fire (rule webhook/without-triggers): there is no manual fire path (#3196), so this row is dead while looking armed in Setup. Declare one of: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(", ")}, or set it inactive if it should be off.`,
125
573
  { id: row.id }
126
574
  )]);
@@ -130,7 +578,7 @@ var AutoEnqueuer = class {
130
578
  if (typeof row.definition_json === "string" && row.definition_json.length > 0) {
131
579
  try {
132
580
  defn = _nullishCoalesce(JSON.parse(row.definition_json), () => ( {}));
133
- } catch (e2) {
581
+ } catch (e6) {
134
582
  defn = {};
135
583
  }
136
584
  }
@@ -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(_nullishCoalesce(_nullishCoalesce(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
  }
@@ -160,11 +609,11 @@ var AutoEnqueuer = class {
160
609
  handleEvent(event) {
161
610
  if (!event.object) return;
162
611
  if (event.object === this.subscriptionsObject) return;
163
- if (_optionalChain([event, 'access', _21 => _21.type, 'optionalAccess', _22 => _22.startsWith, 'call', _23 => _23("data.records.")])) {
612
+ if (_optionalChain([event, 'access', _56 => _56.type, 'optionalAccess', _57 => _57.startsWith, 'call', _58 => _58("data.records.")])) {
164
613
  this.handleBulkEvent(event);
165
614
  return;
166
615
  }
167
- if (!_optionalChain([event, 'access', _24 => _24.type, 'optionalAccess', _25 => _25.startsWith, 'call', _26 => _26("data.record.")])) return;
616
+ if (!_optionalChain([event, 'access', _59 => _59.type, 'optionalAccess', _60 => _60.startsWith, 'call', _61 => _61("data.record.")])) return;
168
617
  const action = event.type.slice("data.record.".length);
169
618
  const trigger = mapActionToTrigger(action);
170
619
  if (!trigger) return;
@@ -176,7 +625,7 @@ var AutoEnqueuer = class {
176
625
  const payload = _nullishCoalesce(event.payload, () => ( {}));
177
626
  const recordId = payload.recordId;
178
627
  if (typeof recordId !== "string" || recordId === "") {
179
- _optionalChain([this, 'access', _27 => _27.logger, 'access', _28 => _28.warn, 'optionalCall', _29 => _29(
628
+ _optionalChain([this, 'access', _62 => _62.logger, 'access', _63 => _63.warn, 'optionalCall', _64 => _64(
180
629
  "[webhook-auto-enqueuer] dropping off-contract data event: payload is not a DataEvent (no top-level string `recordId`) \u2014 fix the producer",
181
630
  { type: event.type, object: event.object }
182
631
  )]);
@@ -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) => _optionalChain([this, 'access', _30 => _30.logger, 'access', _31 => _31.warn, 'optionalCall', _32 => _32("[webhook-auto-enqueuer] enqueue failed", {
217
- webhook: sub.name,
218
- eventId,
219
- err: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _33 => _33.message]), () => ( err))
220
- })])
221
- );
670
+ }).catch((err) => this.reportWriteFailure(sub, eventId, err, "enqueue"));
222
671
  }
223
672
  }
224
673
  /**
@@ -245,7 +694,7 @@ var AutoEnqueuer = class {
245
694
  const payload = _nullishCoalesce(event.payload, () => ( {}));
246
695
  const matched = payload.matched;
247
696
  if (typeof matched !== "number" || !Number.isInteger(matched) || matched < 0) {
248
- _optionalChain([this, 'access', _34 => _34.logger, 'access', _35 => _35.warn, 'optionalCall', _36 => _36(
697
+ _optionalChain([this, 'access', _65 => _65.logger, 'access', _66 => _66.warn, 'optionalCall', _67 => _67(
249
698
  "[webhook-auto-enqueuer] dropping off-contract bulk data event: payload is not a BulkDataEvent (no top-level non-negative integer `matched`) \u2014 fix the producer",
250
699
  { type: event.type, object: event.object }
251
700
  )]);
@@ -253,7 +702,7 @@ var AutoEnqueuer = class {
253
702
  }
254
703
  const eventUuid = payload.id;
255
704
  if (typeof eventUuid !== "string" || eventUuid === "") {
256
- _optionalChain([this, 'access', _37 => _37.logger, 'access', _38 => _38.warn, 'optionalCall', _39 => _39(
705
+ _optionalChain([this, 'access', _68 => _68.logger, 'access', _69 => _69.warn, 'optionalCall', _70 => _70(
257
706
  "[webhook-auto-enqueuer] dropping off-contract bulk data event: payload has no top-level string `id` to dedup on \u2014 fix the producer",
258
707
  { type: event.type, object: event.object }
259
708
  )]);
@@ -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,20 +732,14 @@ var AutoEnqueuer = class {
280
732
  action,
281
733
  timestamp: event.timestamp
282
734
  }
283
- }).catch(
284
- (err) => _optionalChain([this, 'access', _40 => _40.logger, 'access', _41 => _41.warn, 'optionalCall', _42 => _42("[webhook-auto-enqueuer] bulk enqueue failed", {
285
- webhook: sub.name,
286
- eventId,
287
- err: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _43 => _43.message]), () => ( err))
288
- })])
289
- );
735
+ }).catch((err) => this.reportWriteFailure(sub, eventId, err, "bulk enqueue"));
290
736
  }
291
737
  }
292
738
  handleSelfHealEvent(event) {
293
739
  if (event.object !== this.subscriptionsObject) return;
294
- if (!_optionalChain([event, 'access', _44 => _44.type, 'optionalAccess', _45 => _45.startsWith, 'call', _46 => _46("data.record.")]) && !_optionalChain([event, 'access', _47 => _47.type, 'optionalAccess', _48 => _48.startsWith, 'call', _49 => _49("data.records.")])) return;
740
+ if (!_optionalChain([event, 'access', _71 => _71.type, 'optionalAccess', _72 => _72.startsWith, 'call', _73 => _73("data.record.")]) && !_optionalChain([event, 'access', _74 => _74.type, 'optionalAccess', _75 => _75.startsWith, 'call', _76 => _76("data.records.")])) return;
295
741
  this.refresh().catch(
296
- (err) => _optionalChain([this, 'access', _50 => _50.logger, 'access', _51 => _51.warn, 'optionalCall', _52 => _52("[webhook-auto-enqueuer] self-heal refresh failed", err)])
742
+ (err) => _optionalChain([this, 'access', _77 => _77.logger, 'access', _78 => _78.warn, 'optionalCall', _79 => _79("[webhook-auto-enqueuer] self-heal refresh failed", err)])
297
743
  );
298
744
  }
299
745
  /** Test / admin accessor. */
@@ -336,23 +782,23 @@ var _automation = require('@objectstack/spec/automation');
336
782
  var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
337
783
  function uid(prefix) {
338
784
  const g = globalThis;
339
- if (_optionalChain([g, 'access', _53 => _53.crypto, 'optionalAccess', _54 => _54.randomUUID])) return `${prefix}_${g.crypto.randomUUID()}`;
785
+ if (_optionalChain([g, 'access', _80 => _80.crypto, 'optionalAccess', _81 => _81.randomUUID])) return `${prefix}_${g.crypto.randomUUID()}`;
340
786
  return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
341
787
  }
342
788
  function readDeclared(engine, metadataService, type) {
343
789
  try {
344
- const reg = _optionalChain([engine, 'optionalAccess', _55 => _55._registry]);
345
- if (_optionalChain([reg, 'optionalAccess', _56 => _56.listItems])) {
346
- const items = (_nullishCoalesce(reg.listItems(type), () => ( []))).map((i) => _nullishCoalesce(_optionalChain([i, 'optionalAccess', _57 => _57.content]), () => ( i))).filter(Boolean);
790
+ const reg = _optionalChain([engine, 'optionalAccess', _82 => _82._registry]);
791
+ if (_optionalChain([reg, 'optionalAccess', _83 => _83.listItems])) {
792
+ const items = (_nullishCoalesce(reg.listItems(type), () => ( []))).filter(Boolean);
347
793
  if (items.length > 0) return items;
348
794
  }
349
- } catch (e3) {
795
+ } catch (e7) {
350
796
  }
351
797
  try {
352
- const listed = _optionalChain([metadataService, 'optionalAccess', _58 => _58.list, 'optionalCall', _59 => _59(type)]);
353
- const arr = typeof _optionalChain([listed, 'optionalAccess', _60 => _60.then]) === "function" ? [] : _nullishCoalesce(listed, () => ( []));
354
- return Array.isArray(arr) ? arr.map((i) => _nullishCoalesce(_optionalChain([i, 'optionalAccess', _61 => _61.content]), () => ( i))).filter(Boolean) : [];
355
- } catch (e4) {
798
+ const listed = _optionalChain([metadataService, 'optionalAccess', _84 => _84.list, 'optionalCall', _85 => _85(type)]);
799
+ const arr = typeof _optionalChain([listed, 'optionalAccess', _86 => _86.then]) === "function" ? [] : _nullishCoalesce(listed, () => ( []));
800
+ return Array.isArray(arr) ? arr.filter(Boolean) : [];
801
+ } catch (e8) {
356
802
  return [];
357
803
  }
358
804
  }
@@ -367,9 +813,9 @@ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscr
367
813
  try {
368
814
  wh = _automation.WebhookSchema.parse(raw);
369
815
  } catch (err) {
370
- _optionalChain([logger, 'optionalAccess', _62 => _62.warn, 'optionalCall', _63 => _63("[webhook] declared webhook failed validation \u2014 skipped", {
371
- name: _optionalChain([raw, 'optionalAccess', _64 => _64.name]),
372
- error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _65 => _65.message]), () => ( String(err)))
816
+ _optionalChain([logger, 'optionalAccess', _87 => _87.warn, 'optionalCall', _88 => _88("[webhook] declared webhook failed validation \u2014 skipped", {
817
+ name: _optionalChain([raw, 'optionalAccess', _89 => _89.name]),
818
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _90 => _90.message]), () => ( String(err)))
373
819
  })]);
374
820
  skipped += 1;
375
821
  continue;
@@ -383,7 +829,7 @@ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscr
383
829
  const row = Array.isArray(existing) ? existing[0] : void 0;
384
830
  if (row) {
385
831
  if (row.managed_by === "admin") {
386
- _optionalChain([logger, 'optionalAccess', _66 => _66.warn, 'optionalCall', _67 => _67("[webhook] declared name collides with an admin-authored row \u2014 seed skipped", {
832
+ _optionalChain([logger, 'optionalAccess', _91 => _91.warn, 'optionalCall', _92 => _92("[webhook] declared name collides with an admin-authored row \u2014 seed skipped", {
387
833
  name: wh.name
388
834
  })]);
389
835
  skipped += 1;
@@ -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,21 +880,44 @@ 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
- _optionalChain([logger, 'optionalAccess', _68 => _68.warn, 'optionalCall', _69 => _69("[webhook] declared webhook seed failed", {
420
- name: wh.name,
421
- error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _70 => _70.message]), () => ( String(err)))
422
- })]);
883
+ const protection = isSecretProtectionFailure(err);
884
+ _optionalChain([logger, 'optionalAccess', _93 => _93.warn, 'optionalCall', _94 => _94(
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: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _95 => _95.message]), () => ( String(err)))
890
+ }
891
+ )]);
423
892
  skipped += 1;
424
893
  }
425
894
  }
426
- _optionalChain([logger, 'optionalAccess', _71 => _71.info, 'optionalCall', _72 => _72("[webhook] declared webhooks materialized into sys_webhook", {
895
+ _optionalChain([logger, 'optionalAccess', _96 => _96.info, 'optionalCall', _97 => _97("[webhook] declared webhooks materialized into sys_webhook", {
427
896
  seeded,
428
897
  skipped,
429
898
  total: declared.length
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 = _optionalChain([row, 'optionalAccess', _98 => _98[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 (e9) {
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: _nullishCoalesce(wh.label, () => ( wh.name)),
@@ -442,29 +929,116 @@ function mapWebhookToRow(wh) {
442
929
  method: String(_nullishCoalesce(wh.method, () => ( "POST"))).toLowerCase(),
443
930
  description: _nullishCoalesce(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 : _nullishCoalesce(_optionalChain([found, 'optionalAccess', _99 => _99.data]), () => ( []));
947
+ } catch (err) {
948
+ _optionalChain([logger, 'optionalAccess', _100 => _100.warn, 'optionalCall', _101 => _101("[webhook] legacy secret sweep skipped \u2014 could not read subscriptions", {
949
+ object: subscriptionsObject,
950
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _102 => _102.message]), () => ( String(err)))
951
+ })]);
952
+ return out;
953
+ }
954
+ for (const row of rows) {
955
+ if (!_optionalChain([row, 'optionalAccess', _103 => _103.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
+ _optionalChain([logger, 'optionalAccess', _104 => _104.warn, 'optionalCall', _105 => _105(
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: _nullishCoalesce(row.name, () => ( row.id)),
980
+ id: row.id,
981
+ code: WEBHOOK_SECRET_REFUSAL_CODE,
982
+ status: WEBHOOK_SECRET_REFUSAL_STATUS,
983
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _106 => _106.message]), () => ( String(err)))
984
+ }
985
+ )]);
986
+ }
987
+ }
988
+ if (out.found > 0) {
989
+ _optionalChain([logger, 'optionalAccess', _107 => _107.info, 'optionalCall', _108 => _108("[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(_nullishCoalesce(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
- if (typeof _optionalChain([engine, 'optionalAccess', _73 => _73.registerHook]) !== "function") return;
1027
+ if (typeof _optionalChain([engine, 'optionalAccess', _109 => _109.registerHook]) !== "function") return;
454
1028
  engine.registerHook(
455
1029
  "beforeUpdate",
456
1030
  async (ctx) => {
457
- if (_optionalChain([ctx, 'optionalAccess', _74 => _74.session, 'optionalAccess', _75 => _75.isSystem])) return;
458
- const id = _nullishCoalesce(_optionalChain([ctx, 'optionalAccess', _76 => _76.input, 'optionalAccess', _77 => _77.id]), () => ( _optionalChain([ctx, 'optionalAccess', _78 => _78.input, 'optionalAccess', _79 => _79.data, 'optionalAccess', _80 => _80.id])));
1031
+ if (_optionalChain([ctx, 'optionalAccess', _110 => _110.session, 'optionalAccess', _111 => _111.isSystem])) return;
1032
+ const id = _nullishCoalesce(_optionalChain([ctx, 'optionalAccess', _112 => _112.input, 'optionalAccess', _113 => _113.id]), () => ( _optionalChain([ctx, 'optionalAccess', _114 => _114.input, 'optionalAccess', _115 => _115.data, 'optionalAccess', _116 => _116.id])));
459
1033
  if (!id) return;
460
- const data = _optionalChain([ctx, 'optionalAccess', _81 => _81.input, 'optionalAccess', _82 => _82.data]);
1034
+ const data = _optionalChain([ctx, 'optionalAccess', _117 => _117.input, 'optionalAccess', _118 => _118.data]);
461
1035
  if (!data || typeof data !== "object") return;
462
1036
  try {
463
1037
  const rows = await engine.find("sys_webhook", {
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;
@@ -472,18 +1046,18 @@ function bindWebhookProvenanceStamp(engine, logger) {
472
1046
  data.customized = true;
473
1047
  }
474
1048
  } catch (err) {
475
- _optionalChain([logger, 'optionalAccess', _83 => _83.warn, 'optionalCall', _84 => _84("[webhook] provenance stamp failed (edit proceeds unstamped)", {
1049
+ _optionalChain([logger, 'optionalAccess', _119 => _119.warn, 'optionalCall', _120 => _120("[webhook] provenance stamp failed (edit proceeds unstamped)", {
476
1050
  id,
477
- error: _optionalChain([err, 'optionalAccess', _85 => _85.message])
1051
+ error: _optionalChain([err, 'optionalAccess', _121 => _121.message])
478
1052
  })]);
479
1053
  }
480
1054
  },
481
1055
  { object: "sys_webhook", packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 }
482
1056
  );
483
- _optionalChain([logger, 'optionalAccess', _86 => _86.info, 'optionalCall', _87 => _87("[webhook] provenance stamp hook bound")]);
1057
+ _optionalChain([logger, 'optionalAccess', _122 => _122.info, 'optionalCall', _123 => _123("[webhook] provenance stamp hook bound")]);
484
1058
  }
485
1059
  function unbindWebhookProvenanceStamp(engine) {
486
- if (typeof _optionalChain([engine, 'optionalAccess', _88 => _88.unregisterHooksByPackage]) === "function") {
1060
+ if (typeof _optionalChain([engine, 'optionalAccess', _124 => _124.unregisterHooksByPackage]) === "function") {
487
1061
  engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);
488
1062
  }
489
1063
  }
@@ -518,7 +1092,7 @@ var WebhookOutboxPlugin = class {
518
1092
  scope: "system",
519
1093
  name: "Webhook Schemas",
520
1094
  description: "Registers sys_webhook (configuration). Deliveries use messaging's sys_http_delivery outbox.",
521
- objects: [_chunkJQUVS5KKcjs.SysWebhook],
1095
+ objects: [_chunkQ4FEMGD6cjs.SysWebhook],
522
1096
  navigationContributions: [
523
1097
  {
524
1098
  app: "setup",
@@ -532,7 +1106,7 @@ var WebhookOutboxPlugin = class {
532
1106
  ]
533
1107
  });
534
1108
  } else {
535
- _optionalChain([ctx, 'access', _89 => _89.logger, 'access', _90 => _90.warn, 'optionalCall', _91 => _91(
1109
+ _optionalChain([ctx, 'access', _125 => _125.logger, 'access', _126 => _126.warn, 'optionalCall', _127 => _127(
536
1110
  "[webhook-outbox] manifest service unavailable \u2014 sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin."
537
1111
  )]);
538
1112
  }
@@ -541,12 +1115,12 @@ 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 Promise.resolve().then(() => _interopRequireWildcard(require("./translations-BWS57U2V.cjs")));
1118
+ const { WebhooksTranslations } = await Promise.resolve().then(() => _interopRequireWildcard(require("./translations-H5ZYI6YP.cjs")));
545
1119
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
546
1120
  i18n.loadTranslations(locale, data);
547
1121
  }
548
1122
  }
549
- } catch (e5) {
1123
+ } catch (e10) {
550
1124
  }
551
1125
  });
552
1126
  }
@@ -558,16 +1132,16 @@ var WebhookOutboxPlugin = class {
558
1132
  this.registerAdminRoutes(ctx);
559
1133
  });
560
1134
  }
561
- _optionalChain([ctx, 'access', _92 => _92.logger, 'access', _93 => _93.info, 'optionalCall', _94 => _94("[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)", {
1135
+ _optionalChain([ctx, 'access', _128 => _128.logger, 'access', _129 => _129.info, 'optionalCall', _130 => _130("[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)", {
562
1136
  autoEnqueue: autoEnqueueOpt !== false
563
1137
  })]);
564
1138
  }
565
1139
  async dispose() {
566
- await _optionalChain([this, 'access', _95 => _95.autoEnqueuer, 'optionalAccess', _96 => _96.stop, 'call', _97 => _97()]);
1140
+ await _optionalChain([this, 'access', _131 => _131.autoEnqueuer, 'optionalAccess', _132 => _132.stop, 'call', _133 => _133()]);
567
1141
  if (this.boundEngine) {
568
1142
  try {
569
1143
  unbindWebhookProvenanceStamp(this.boundEngine);
570
- } catch (e6) {
1144
+ } catch (e11) {
571
1145
  }
572
1146
  this.boundEngine = void 0;
573
1147
  }
@@ -591,7 +1165,7 @@ var WebhookOutboxPlugin = class {
591
1165
  async bootDeclaredWebhooks(ctx) {
592
1166
  const engine = this.tryGetService(ctx, ["objectql", "data"]);
593
1167
  if (!engine) {
594
- _optionalChain([ctx, 'access', _98 => _98.logger, 'access', _99 => _99.warn, 'optionalCall', _100 => _100("[webhook] declared-webhook bootstrap skipped \u2014 no data engine available")]);
1168
+ _optionalChain([ctx, 'access', _134 => _134.logger, 'access', _135 => _135.warn, 'optionalCall', _136 => _136("[webhook] declared-webhook bootstrap skipped \u2014 no data engine available")]);
595
1169
  return;
596
1170
  }
597
1171
  this.boundEngine = engine;
@@ -599,13 +1173,20 @@ var WebhookOutboxPlugin = class {
599
1173
  let metadataService;
600
1174
  try {
601
1175
  metadataService = ctx.getService("metadata");
602
- } catch (e7) {
1176
+ } catch (e12) {
603
1177
  }
604
1178
  try {
605
1179
  await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger);
606
1180
  } catch (err) {
607
- _optionalChain([ctx, 'access', _101 => _101.logger, 'access', _102 => _102.warn, 'optionalCall', _103 => _103("[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)", {
608
- error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _104 => _104.message]), () => ( String(err)))
1181
+ _optionalChain([ctx, 'access', _137 => _137.logger, 'access', _138 => _138.warn, 'optionalCall', _139 => _139("[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)", {
1182
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _140 => _140.message]), () => ( String(err)))
1183
+ })]);
1184
+ }
1185
+ try {
1186
+ await migrateLegacyWebhookSecrets(engine, ctx.logger);
1187
+ } catch (err) {
1188
+ _optionalChain([ctx, 'access', _141 => _141.logger, 'access', _142 => _142.warn, 'optionalCall', _143 => _143("[webhook] legacy signing-secret sweep failed (rows left unchanged)", {
1189
+ error: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _144 => _144.message]), () => ( String(err)))
609
1190
  })]);
610
1191
  }
611
1192
  }
@@ -615,18 +1196,19 @@ var WebhookOutboxPlugin = class {
615
1196
  const realtime = this.tryGetService(ctx, ["realtime"]);
616
1197
  const messaging = this.getMessaging(ctx);
617
1198
  if (!engine || !realtime || !messaging) {
618
- _optionalChain([ctx, 'access', _105 => _105.logger, 'access', _106 => _106.warn, 'optionalCall', _107 => _107(
1199
+ _optionalChain([ctx, 'access', _145 => _145.logger, 'access', _146 => _146.warn, 'optionalCall', _147 => _147(
619
1200
  "[webhook-auto-enqueuer] disabled \u2014 ObjectQL, Realtime, or Messaging service not available",
620
1201
  { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging }
621
1202
  )]);
622
1203
  return;
623
1204
  }
624
1205
  if (!messaging.isHttpDeliveryReady()) {
625
- _optionalChain([ctx, 'access', _108 => _108.logger, 'access', _109 => _109.warn, 'optionalCall', _110 => _110(
1206
+ _optionalChain([ctx, 'access', _148 => _148.logger, 'access', _149 => _149.warn, 'optionalCall', _150 => _150(
626
1207
  "[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) \u2014 webhook deliveries will not be durable"
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,
@@ -635,14 +1217,39 @@ var WebhookOutboxPlugin = class {
635
1217
  );
636
1218
  await this.autoEnqueuer.start();
637
1219
  ctx.registerService("webhook.autoEnqueuer", this.autoEnqueuer);
638
- _optionalChain([ctx, 'access', _111 => _111.logger, 'access', _112 => _112.info, 'optionalCall', _113 => _113("[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)")]);
1220
+ _optionalChain([ctx, 'access', _151 => _151.logger, 'access', _152 => _152.info, 'optionalCall', _153 => _153("[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)")]);
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
+ _optionalChain([ctx, 'access', _154 => _154.logger, 'access', _155 => _155.error, 'optionalCall', _156 => _156(
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
+ _optionalChain([ctx, 'access', _157 => _157.logger, 'access', _158 => _158.debug, 'optionalCall', _159 => _159("[webhook-outbox] redeliver guard installed for source=webhook")]);
639
1246
  }
640
1247
  tryGetService(ctx, names) {
641
1248
  for (const n of names) {
642
1249
  try {
643
1250
  const svc = ctx.getService(n);
644
1251
  if (svc) return svc;
645
- } catch (e8) {
1252
+ } catch (e13) {
646
1253
  }
647
1254
  }
648
1255
  return void 0;
@@ -655,7 +1262,7 @@ var WebhookOutboxPlugin = class {
655
1262
  registerAdminRoutes(ctx) {
656
1263
  const http = this.tryGetService(ctx, ["http-server"]);
657
1264
  if (!http || typeof http.getRawApp !== "function") {
658
- _optionalChain([ctx, 'access', _114 => _114.logger, 'access', _115 => _115.debug, 'optionalCall', _116 => _116("[webhook-outbox] HTTP server not available; redeliver endpoint not mounted")]);
1265
+ _optionalChain([ctx, 'access', _160 => _160.logger, 'access', _161 => _161.debug, 'optionalCall', _162 => _162("[webhook-outbox] HTTP server not available; redeliver endpoint not mounted")]);
659
1266
  return;
660
1267
  }
661
1268
  const rawApp = http.getRawApp();
@@ -672,10 +1279,10 @@ var WebhookOutboxPlugin = class {
672
1279
  let body;
673
1280
  try {
674
1281
  body = await c.req.json();
675
- } catch (e9) {
1282
+ } catch (e14) {
676
1283
  return c.json({ success: false, error: { code: "INVALID_REQUEST", message: "Request body must be JSON." } }, 400);
677
1284
  }
678
- const deliveryId = typeof _optionalChain([body, 'optionalAccess', _117 => _117.deliveryId]) === "string" ? body.deliveryId.trim() : "";
1285
+ const deliveryId = typeof _optionalChain([body, 'optionalAccess', _163 => _163.deliveryId]) === "string" ? body.deliveryId.trim() : "";
679
1286
  if (!deliveryId) {
680
1287
  return c.json(
681
1288
  { success: false, error: { code: "MISSING_REQUIRED_FIELD", message: "Body must include `deliveryId: string`." } },
@@ -684,24 +1291,24 @@ var WebhookOutboxPlugin = class {
684
1291
  }
685
1292
  try {
686
1293
  const row = await messaging.redeliverHttp(deliveryId);
687
- _optionalChain([ctx, 'access', _118 => _118.logger, 'access', _119 => _119.info, 'optionalCall', _120 => _120("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId })]);
1294
+ _optionalChain([ctx, 'access', _164 => _164.logger, 'access', _165 => _165.info, 'optionalCall', _166 => _166("[webhook-outbox] redelivered", { deliveryId, requestedBy: userId })]);
688
1295
  return c.json({ success: true, data: { id: row.id, status: row.status } });
689
1296
  } catch (err) {
690
- const code = _optionalChain([err, 'optionalAccess', _121 => _121.code]);
1297
+ const code = _optionalChain([err, 'optionalAccess', _167 => _167.code]);
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
- _optionalChain([ctx, 'access', _122 => _122.logger, 'access', _123 => _123.error, 'optionalCall', _124 => _124("[webhook-outbox] redeliver failed", err)]);
1304
+ _optionalChain([ctx, 'access', _168 => _168.logger, 'access', _169 => _169.error, 'optionalCall', _170 => _170("[webhook-outbox] redeliver failed", err)]);
698
1305
  return c.json(
699
- { success: false, error: { code: "INTERNAL_ERROR", message: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _125 => _125.message]), () => ( String(err))) } },
1306
+ { success: false, error: { code: "INTERNAL_ERROR", message: _nullishCoalesce(_optionalChain([err, 'optionalAccess', _171 => _171.message]), () => ( String(err))) } },
700
1307
  500
701
1308
  );
702
1309
  }
703
1310
  });
704
- _optionalChain([ctx, 'access', _126 => _126.logger, 'access', _127 => _127.info, 'optionalCall', _128 => _128("[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver")]);
1311
+ _optionalChain([ctx, 'access', _172 => _172.logger, 'access', _173 => _173.info, 'optionalCall', _174 => _174("[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver")]);
705
1312
  }
706
1313
  async resolveSessionUserId(ctx, c) {
707
1314
  try {
@@ -711,11 +1318,11 @@ var WebhookOutboxPlugin = class {
711
1318
  if (!api && typeof authService.getApi === "function") {
712
1319
  api = await authService.getApi();
713
1320
  }
714
- if (!_optionalChain([api, 'optionalAccess', _129 => _129.getSession])) return void 0;
1321
+ if (!_optionalChain([api, 'optionalAccess', _175 => _175.getSession])) return void 0;
715
1322
  const session = await api.getSession({ headers: c.req.raw.headers });
716
- const uid2 = _optionalChain([session, 'optionalAccess', _130 => _130.user, 'optionalAccess', _131 => _131.id]);
1323
+ const uid2 = _optionalChain([session, 'optionalAccess', _176 => _176.user, 'optionalAccess', _177 => _177.id]);
717
1324
  return typeof uid2 === "string" && uid2.length > 0 ? uid2 : void 0;
718
- } catch (e10) {
1325
+ } catch (e15) {
719
1326
  return void 0;
720
1327
  }
721
1328
  }
@@ -724,5 +1331,8 @@ var WebhookOutboxPlugin = class {
724
1331
 
725
1332
 
726
1333
 
727
- exports.AutoEnqueuer = AutoEnqueuer; exports.SysWebhook = _chunkJQUVS5KKcjs.SysWebhook; exports.WebhookOutboxPlugin = WebhookOutboxPlugin;
1334
+
1335
+
1336
+
1337
+ exports.AutoEnqueuer = AutoEnqueuer; exports.SysWebhook = _chunkQ4FEMGD6cjs.SysWebhook; exports.WEBHOOK_HEADERS_FIELD = WEBHOOK_HEADERS_FIELD; exports.WEBHOOK_SECRET_FIELD = WEBHOOK_SECRET_FIELD; exports.WebhookOutboxPlugin = WebhookOutboxPlugin; exports.migrateLegacyWebhookSecrets = migrateLegacyWebhookSecrets;
728
1338
  //# sourceMappingURL=index.cjs.map