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