@objectstack/plugin-webhooks 17.0.0-rc.5 → 17.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2026 -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.d.cts +881 -557
- package/dist/schema.d.ts +881 -557
- 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.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
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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 (
|
|
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',
|
|
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',
|
|
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 (
|
|
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
|
|
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
|
}
|
|
@@ -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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
345
|
-
if (_optionalChain([reg, 'optionalAccess',
|
|
346
|
-
const items = (_nullishCoalesce(reg.listItems(type), () => ( []))).
|
|
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 (
|
|
795
|
+
} catch (e7) {
|
|
350
796
|
}
|
|
351
797
|
try {
|
|
352
|
-
const listed = _optionalChain([metadataService, 'optionalAccess',
|
|
353
|
-
const arr = typeof _optionalChain([listed, 'optionalAccess',
|
|
354
|
-
return Array.isArray(arr) ? arr.
|
|
355
|
-
} catch (
|
|
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',
|
|
371
|
-
name: _optionalChain([raw, 'optionalAccess',
|
|
372
|
-
error: _nullishCoalesce(_optionalChain([err, 'optionalAccess',
|
|
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',
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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',
|
|
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(
|
|
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
|
|
1025
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
452
1026
|
function bindWebhookProvenanceStamp(engine, logger) {
|
|
453
|
-
if (typeof _optionalChain([engine, 'optionalAccess',
|
|
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',
|
|
458
|
-
const id = _nullishCoalesce(_optionalChain([ctx, 'optionalAccess',
|
|
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',
|
|
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:
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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: [
|
|
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',
|
|
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-
|
|
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 (
|
|
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',
|
|
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',
|
|
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 (
|
|
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',
|
|
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 (
|
|
1176
|
+
} catch (e12) {
|
|
603
1177
|
}
|
|
604
1178
|
try {
|
|
605
1179
|
await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger);
|
|
606
1180
|
} catch (err) {
|
|
607
|
-
_optionalChain([ctx, 'access',
|
|
608
|
-
error: _nullishCoalesce(_optionalChain([err, 'optionalAccess',
|
|
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',
|
|
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',
|
|
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',
|
|
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 (
|
|
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',
|
|
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 (
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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 (
|
|
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
|
-
|
|
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
|