@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/CHANGELOG.md +1805 -0
- package/dist/{chunk-3QGZLM3T.js → chunk-GDCWDVDT.js} +78 -4
- package/dist/chunk-GDCWDVDT.js.map +1 -0
- package/dist/{chunk-JQUVS5KK.cjs → chunk-Q4FEMGD6.cjs} +78 -4
- package/dist/chunk-Q4FEMGD6.cjs.map +1 -0
- package/dist/index.cjs +700 -90
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +392 -1
- package/dist/index.d.ts +392 -1
- package/dist/index.js +639 -29
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -2
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +663 -254
- package/dist/schema.d.ts +663 -254
- package/dist/schema.js +1 -1
- package/dist/{translations-BWS57U2V.cjs → translations-H5ZYI6YP.cjs} +41 -9
- package/dist/translations-H5ZYI6YP.cjs.map +1 -0
- package/dist/{translations-2VGD4XRF.js → translations-U32QIEPB.js} +41 -9
- package/dist/translations-U32QIEPB.js.map +1 -0
- package/package.json +7 -5
- package/dist/chunk-3QGZLM3T.js.map +0 -1
- package/dist/chunk-JQUVS5KK.cjs.map +0 -1
- package/dist/translations-2VGD4XRF.js.map +0 -1
- package/dist/translations-BWS57U2V.cjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,178 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SysWebhook
|
|
3
|
-
} from "./chunk-
|
|
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
|
|
149
|
-
|
|
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) ?? []).
|
|
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.
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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-
|
|
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
|
-
|
|
1333
|
+
WEBHOOK_HEADERS_FIELD,
|
|
1334
|
+
WEBHOOK_SECRET_FIELD,
|
|
1335
|
+
WebhookOutboxPlugin,
|
|
1336
|
+
migrateLegacyWebhookSecrets
|
|
727
1337
|
};
|
|
728
1338
|
//# sourceMappingURL=index.js.map
|