@blamejs/blamejs-shop 0.0.64 → 0.0.65
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 +2 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +20 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.webhookReceiver
|
|
4
|
+
* @title Webhook receiver — inbound HMAC-SHA-256 signed events
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operator-side acceptor for inbound webhooks. The third-party
|
|
8
|
+
* platform (Stripe, GitHub, Slack, BigCommerce, Square, …) signs
|
|
9
|
+
* each delivery with `HMAC-SHA-256(secret, timestamp + "." + body)`
|
|
10
|
+
* and posts the body to the operator's `/<slug>` endpoint. This
|
|
11
|
+
* primitive verifies the signature, enforces the replay window on
|
|
12
|
+
* the timestamp, dedupes via the third-party's per-event
|
|
13
|
+
* idempotency key, and persists the raw body for the operator's
|
|
14
|
+
* downstream processor.
|
|
15
|
+
*
|
|
16
|
+
* Distinct from:
|
|
17
|
+
* - `webhooks` — DELIVERS outbound events to operator-
|
|
18
|
+
* registered third-party endpoints.
|
|
19
|
+
* - `webhookSubscriptions` — MANAGES per-customer delivery
|
|
20
|
+
* subscriptions for the outbound side.
|
|
21
|
+
* Receiver is the inbound complement.
|
|
22
|
+
*
|
|
23
|
+
* Secret-at-rest: the plaintext is returned exactly once at
|
|
24
|
+
* `defineSource` + `rotateSecret`. The row stores only the
|
|
25
|
+
* namespaceHash of the plaintext under the `webhook-receiver-
|
|
26
|
+
* secret` namespace. At request time the operator's route layer
|
|
27
|
+
* supplies the plaintext from its secret-store (env / KMS /
|
|
28
|
+
* Cloudflare secret) alongside the inbound headers; the primitive
|
|
29
|
+
* hashes the supplied plaintext under the same namespace and
|
|
30
|
+
* matches against `signing_secret_hash` (live) or
|
|
31
|
+
* `signing_secret_previous_hash` (24h grace), routing the hex
|
|
32
|
+
* compare through `b.crypto.timingSafeEqual`. The matched
|
|
33
|
+
* plaintext is what feeds the HMAC computation. The plaintext
|
|
34
|
+
* never reaches the database; the row's hash is the verification
|
|
35
|
+
* key for whatever plaintext the operator presents.
|
|
36
|
+
*
|
|
37
|
+
* FSM:
|
|
38
|
+
* received — initial state on verifyAndPersist.
|
|
39
|
+
* processed — terminal-success (markProcessed).
|
|
40
|
+
* failed — markFailed; retry=true keeps the row eligible for
|
|
41
|
+
* the operator's reprocess scheduler.
|
|
42
|
+
*
|
|
43
|
+
* Composes:
|
|
44
|
+
* - `b.crypto.namespaceHash` — secret-at-rest fingerprint.
|
|
45
|
+
* - `b.crypto.sha3Hash` — body fingerprint for audit.
|
|
46
|
+
* - `b.crypto.generateBytes` — secret plaintext draw.
|
|
47
|
+
* - `b.crypto.timingSafeEqual` — constant-time hex compare on
|
|
48
|
+
* secret-hash + signature match.
|
|
49
|
+
* - `b.webhook.sign` — HMAC-SHA-256 computation under
|
|
50
|
+
* the Stripe-shape algorithm
|
|
51
|
+
* (`t=<ts>,v1=<hex>`); we extract
|
|
52
|
+
* the hex portion.
|
|
53
|
+
* - `b.uuid.v7` — row ids.
|
|
54
|
+
*
|
|
55
|
+
* Surface:
|
|
56
|
+
* defineSource({ slug, secret_plaintext?, replay_window_seconds?,
|
|
57
|
+
* max_body_bytes?, signature_header_name?,
|
|
58
|
+
* timestamp_header_name?, active })
|
|
59
|
+
* verifyAndPersist({ source_slug, secret_plaintext, body,
|
|
60
|
+
* signature_header, timestamp_header,
|
|
61
|
+
* idempotency_key? })
|
|
62
|
+
* markProcessed({ event_id, outcome })
|
|
63
|
+
* markFailed({ event_id, reason, retry })
|
|
64
|
+
* unprocessedEvents({ source_slug?, limit })
|
|
65
|
+
* eventsForSource({ source_slug, limit, cursor? })
|
|
66
|
+
* getEvent(event_id)
|
|
67
|
+
* purgeOlderThan(days)
|
|
68
|
+
* rotateSecret(source_slug)
|
|
69
|
+
* archiveSource(source_slug)
|
|
70
|
+
*
|
|
71
|
+
* Storage:
|
|
72
|
+
* - `webhook_sources` + `webhook_received_events`
|
|
73
|
+
* (migration `0110_webhook_receiver.sql`).
|
|
74
|
+
*
|
|
75
|
+
* @primitive webhookReceiver
|
|
76
|
+
* @related b.crypto, b.webhook, b.uuid
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
var SECRET_NAMESPACE = "webhook-receiver-secret";
|
|
80
|
+
var SECRET_BYTE_LEN = 32;
|
|
81
|
+
// 32 bytes -> 43 chars of base64url (no padding).
|
|
82
|
+
var SECRET_PLAINTEXT_LEN = 43;
|
|
83
|
+
var SECRET_PLAINTEXT_RE = /^[A-Za-z0-9_\-]{43}$/;
|
|
84
|
+
|
|
85
|
+
var ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
var DEFAULT_REPLAY_WINDOW_SECONDS = 300; // 5 min — Stripe-shaped default
|
|
88
|
+
var MIN_REPLAY_WINDOW_SECONDS = 30; // floor — below this clock skew false-rejects
|
|
89
|
+
var MAX_REPLAY_WINDOW_SECONDS = 86400; // 24 h — anything larger is "no replay defense"
|
|
90
|
+
|
|
91
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
92
|
+
var MIN_MAX_BODY_BYTES = 256; // minimum useful payload
|
|
93
|
+
var ABSOLUTE_MAX_BODY_BYTES = 16 * 1024 * 1024; // 16 MiB — anything larger is misconfigured
|
|
94
|
+
|
|
95
|
+
var DEFAULT_SIGNATURE_HEADER_NAME = "X-Webhook-Signature";
|
|
96
|
+
var DEFAULT_TIMESTAMP_HEADER_NAME = "X-Webhook-Timestamp";
|
|
97
|
+
|
|
98
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9_\-]{0,63}$/;
|
|
99
|
+
|
|
100
|
+
var MAX_HEADER_NAME_LEN = 128;
|
|
101
|
+
var HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_\-]{0,127}$/;
|
|
102
|
+
|
|
103
|
+
var MAX_IDEMPOTENCY_KEY_LEN = 256;
|
|
104
|
+
// Idempotency keys are third-party-supplied opaque strings. Conservative
|
|
105
|
+
// alphabet: printable ASCII excluding controls + quote characters that
|
|
106
|
+
// could break a stored-comparison shape. Refuse zero-width / control
|
|
107
|
+
// bytes to defeat downstream visual-spoofing in the dashboard.
|
|
108
|
+
var IDEMPOTENCY_KEY_RE = /^[\x21-\x7e]{1,256}$/;
|
|
109
|
+
|
|
110
|
+
var MAX_OUTCOME_LEN = 280;
|
|
111
|
+
var MAX_REASON_LEN = 1024;
|
|
112
|
+
|
|
113
|
+
var SIGNATURE_HEX_RE = /^[0-9a-f]{64}$/i; // 32 bytes of HMAC-SHA-256 = 64 hex chars
|
|
114
|
+
|
|
115
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
116
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
117
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
var STATUSES = ["received", "processed", "failed"];
|
|
121
|
+
|
|
122
|
+
// Lazy framework handle — matches the pattern used by every other
|
|
123
|
+
// shop primitive; avoids the require cycle that would arise from
|
|
124
|
+
// importing `./index` at module-eval time.
|
|
125
|
+
var bShop;
|
|
126
|
+
function _b() {
|
|
127
|
+
if (!bShop) bShop = require("./index");
|
|
128
|
+
return bShop.framework;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- validators ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function _slug(s) {
|
|
134
|
+
if (typeof s !== "string") {
|
|
135
|
+
throw new TypeError("webhookReceiver: slug must be a string");
|
|
136
|
+
}
|
|
137
|
+
if (!SLUG_RE.test(s)) {
|
|
138
|
+
throw new TypeError(
|
|
139
|
+
"webhookReceiver: slug must match [a-z0-9][a-z0-9_-]{0,63} " +
|
|
140
|
+
"(lowercase, digits, underscore, hyphen; first char alphanumeric)"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _replayWindow(n) {
|
|
147
|
+
if (n == null) return DEFAULT_REPLAY_WINDOW_SECONDS;
|
|
148
|
+
if (!Number.isInteger(n) || n < MIN_REPLAY_WINDOW_SECONDS || n > MAX_REPLAY_WINDOW_SECONDS) {
|
|
149
|
+
throw new TypeError(
|
|
150
|
+
"webhookReceiver: replay_window_seconds must be an integer " +
|
|
151
|
+
MIN_REPLAY_WINDOW_SECONDS + ".." + MAX_REPLAY_WINDOW_SECONDS
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return n;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _maxBodyBytes(n) {
|
|
158
|
+
if (n == null) return DEFAULT_MAX_BODY_BYTES;
|
|
159
|
+
if (!Number.isInteger(n) || n < MIN_MAX_BODY_BYTES || n > ABSOLUTE_MAX_BODY_BYTES) {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
"webhookReceiver: max_body_bytes must be an integer " +
|
|
162
|
+
MIN_MAX_BODY_BYTES + ".." + ABSOLUTE_MAX_BODY_BYTES
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return n;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _headerName(s, fallback, label) {
|
|
169
|
+
if (s == null) return fallback;
|
|
170
|
+
if (typeof s !== "string") {
|
|
171
|
+
throw new TypeError("webhookReceiver: " + label + " must be a string");
|
|
172
|
+
}
|
|
173
|
+
if (!HEADER_NAME_RE.test(s)) {
|
|
174
|
+
throw new TypeError(
|
|
175
|
+
"webhookReceiver: " + label + " must match [A-Za-z0-9][A-Za-z0-9_-]{0,127}"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (s.length > MAX_HEADER_NAME_LEN) {
|
|
179
|
+
throw new TypeError(
|
|
180
|
+
"webhookReceiver: " + label + " must be <= " + MAX_HEADER_NAME_LEN + " characters"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _active(v) {
|
|
187
|
+
if (typeof v !== "boolean") {
|
|
188
|
+
throw new TypeError("webhookReceiver: active must be a boolean");
|
|
189
|
+
}
|
|
190
|
+
return v;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _outcome(s) {
|
|
194
|
+
if (typeof s !== "string") {
|
|
195
|
+
throw new TypeError("webhookReceiver: outcome must be a string");
|
|
196
|
+
}
|
|
197
|
+
var trimmed = s.trim();
|
|
198
|
+
if (!trimmed.length) {
|
|
199
|
+
throw new TypeError("webhookReceiver: outcome must be non-empty after trim");
|
|
200
|
+
}
|
|
201
|
+
if (s.length > MAX_OUTCOME_LEN) {
|
|
202
|
+
throw new TypeError("webhookReceiver: outcome must be <= " + MAX_OUTCOME_LEN + " characters");
|
|
203
|
+
}
|
|
204
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
205
|
+
throw new TypeError("webhookReceiver: outcome contains control / zero-width bytes");
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _reason(s) {
|
|
211
|
+
if (typeof s !== "string") {
|
|
212
|
+
throw new TypeError("webhookReceiver: reason must be a string");
|
|
213
|
+
}
|
|
214
|
+
var trimmed = s.trim();
|
|
215
|
+
if (!trimmed.length) {
|
|
216
|
+
throw new TypeError("webhookReceiver: reason must be non-empty after trim");
|
|
217
|
+
}
|
|
218
|
+
if (s.length > MAX_REASON_LEN) {
|
|
219
|
+
throw new TypeError("webhookReceiver: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
220
|
+
}
|
|
221
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
222
|
+
throw new TypeError("webhookReceiver: reason contains control / zero-width bytes");
|
|
223
|
+
}
|
|
224
|
+
return s;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _retry(v) {
|
|
228
|
+
if (typeof v !== "boolean") {
|
|
229
|
+
throw new TypeError("webhookReceiver: retry must be a boolean");
|
|
230
|
+
}
|
|
231
|
+
return v;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _idempotencyKey(s) {
|
|
235
|
+
if (s == null) return null;
|
|
236
|
+
if (typeof s !== "string") {
|
|
237
|
+
throw new TypeError("webhookReceiver: idempotency_key must be a string when provided");
|
|
238
|
+
}
|
|
239
|
+
if (s.length === 0 || s.length > MAX_IDEMPOTENCY_KEY_LEN) {
|
|
240
|
+
throw new TypeError(
|
|
241
|
+
"webhookReceiver: idempotency_key must be 1.." + MAX_IDEMPOTENCY_KEY_LEN + " printable ASCII characters"
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (!IDEMPOTENCY_KEY_RE.test(s)) {
|
|
245
|
+
throw new TypeError(
|
|
246
|
+
"webhookReceiver: idempotency_key must be printable ASCII (no control / non-ASCII bytes)"
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return s;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _eventId(s) {
|
|
253
|
+
if (typeof s !== "string" || s.length === 0) {
|
|
254
|
+
throw new TypeError("webhookReceiver: event_id required");
|
|
255
|
+
}
|
|
256
|
+
// UUID v7 + UUID v4 both validate under the strict guard. The
|
|
257
|
+
// primitive issues v7 on persist; we accept either at the entry
|
|
258
|
+
// point so a hand-issued UUID in tests / migrations doesn't trip.
|
|
259
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
260
|
+
catch (e) {
|
|
261
|
+
throw new TypeError("webhookReceiver: event_id — " + (e && e.message || "invalid UUID"));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _purgeDays(n) {
|
|
266
|
+
if (!Number.isInteger(n) || n < 1 || n > 36500) {
|
|
267
|
+
throw new TypeError("webhookReceiver: days must be an integer in [1, 36500]");
|
|
268
|
+
}
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _limit(n, label, max) {
|
|
273
|
+
max = max || 500;
|
|
274
|
+
if (n == null) return 50;
|
|
275
|
+
if (!Number.isInteger(n) || n < 1 || n > max) {
|
|
276
|
+
throw new TypeError(
|
|
277
|
+
"webhookReceiver: " + label + " must be an integer in [1, " + max + "]"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return n;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _coerceBodyBytes(body) {
|
|
284
|
+
if (Buffer.isBuffer(body)) return body;
|
|
285
|
+
if (typeof body === "string") return Buffer.from(body, "utf8");
|
|
286
|
+
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
287
|
+
throw new TypeError(
|
|
288
|
+
"webhookReceiver: body must be a Buffer, Uint8Array, or string"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- secret generation + hashing ----------------------------------------
|
|
293
|
+
|
|
294
|
+
function _generateSecret() {
|
|
295
|
+
var buf = _b().crypto.generateBytes(SECRET_BYTE_LEN);
|
|
296
|
+
return buf.toString("base64")
|
|
297
|
+
.replace(/\+/g, "-")
|
|
298
|
+
.replace(/\//g, "_")
|
|
299
|
+
.replace(/=+$/, "");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function _canonicalSecret(input) {
|
|
303
|
+
if (typeof input !== "string" || !input.length) {
|
|
304
|
+
throw new TypeError("webhookReceiver: secret must be a non-empty string");
|
|
305
|
+
}
|
|
306
|
+
if (!SECRET_PLAINTEXT_RE.test(input)) {
|
|
307
|
+
throw new TypeError(
|
|
308
|
+
"webhookReceiver: secret must be 43 base64url characters " +
|
|
309
|
+
"(32 bytes of entropy). Generate via defineSource without " +
|
|
310
|
+
"secret_plaintext to draw a fresh secret."
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return input;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function _hashSecret(canonical) {
|
|
317
|
+
return _b().crypto.namespaceHash(SECRET_NAMESPACE, canonical);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Extract the v1 hex tag from a `t=<ts>,v1=<hex>` Stripe-shape header
|
|
321
|
+
// produced by b.webhook.sign. We pin to this composition rather than
|
|
322
|
+
// reach for node:crypto so the receiver inherits whatever HMAC-SHA-256
|
|
323
|
+
// hardening lives in the vendored blamejs surface.
|
|
324
|
+
function _hmacSha256Hex(secretPlaintext, timestampSeconds, bodyString) {
|
|
325
|
+
var header = _b().webhook.sign({
|
|
326
|
+
alg: "hmac-sha256-stripe",
|
|
327
|
+
secret: secretPlaintext,
|
|
328
|
+
body: bodyString,
|
|
329
|
+
timestamp: timestampSeconds,
|
|
330
|
+
});
|
|
331
|
+
// Header shape is `t=<ts>,v1=<hex>` — the marker is the only place
|
|
332
|
+
// ",v1=" appears so a literal indexOf is the canonical parse.
|
|
333
|
+
var marker = ",v1=";
|
|
334
|
+
var i = header.indexOf(marker);
|
|
335
|
+
if (i < 0) {
|
|
336
|
+
// Defensive: a future blamejs version that changes the shape
|
|
337
|
+
// would slip past silently if we returned an empty string. Throw
|
|
338
|
+
// loudly so the operator notices at deploy time, not at the
|
|
339
|
+
// first inbound webhook.
|
|
340
|
+
throw new Error(
|
|
341
|
+
"webhookReceiver: b.webhook.sign returned an unexpected header shape " +
|
|
342
|
+
"(no ',v1=' marker) — vendored blamejs may need a refresh"
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return header.slice(i + marker.length);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- factory ------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
function create(opts) {
|
|
351
|
+
opts = opts || {};
|
|
352
|
+
var query = opts.query;
|
|
353
|
+
if (!query) {
|
|
354
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
355
|
+
}
|
|
356
|
+
var nowFn;
|
|
357
|
+
if (opts.now != null) {
|
|
358
|
+
if (typeof opts.now !== "function") {
|
|
359
|
+
throw new TypeError("webhookReceiver.create: now must be a function returning ms epoch");
|
|
360
|
+
}
|
|
361
|
+
nowFn = opts.now;
|
|
362
|
+
} else {
|
|
363
|
+
nowFn = function () { return Date.now(); };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _now() { return nowFn(); }
|
|
367
|
+
|
|
368
|
+
async function _getSourceRaw(slug) {
|
|
369
|
+
var r = await query("SELECT * FROM webhook_sources WHERE slug = ?1", [slug]);
|
|
370
|
+
return r.rows[0] || null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _projectSource(row) {
|
|
374
|
+
if (!row) return null;
|
|
375
|
+
return {
|
|
376
|
+
slug: row.slug,
|
|
377
|
+
signing_secret_hash: row.signing_secret_hash,
|
|
378
|
+
signing_secret_previous_hash: row.signing_secret_previous_hash,
|
|
379
|
+
signing_secret_rotated_at: row.signing_secret_rotated_at != null
|
|
380
|
+
? Number(row.signing_secret_rotated_at) : null,
|
|
381
|
+
replay_window_seconds: Number(row.replay_window_seconds),
|
|
382
|
+
max_body_bytes: Number(row.max_body_bytes),
|
|
383
|
+
signature_header_name: row.signature_header_name,
|
|
384
|
+
timestamp_header_name: row.timestamp_header_name,
|
|
385
|
+
active: Number(row.active) === 1,
|
|
386
|
+
archived_at: row.archived_at != null ? Number(row.archived_at) : null,
|
|
387
|
+
created_at: Number(row.created_at),
|
|
388
|
+
updated_at: Number(row.updated_at),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function _getEventRaw(id) {
|
|
393
|
+
var r = await query("SELECT * FROM webhook_received_events WHERE id = ?1", [id]);
|
|
394
|
+
return r.rows[0] || null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function _projectEvent(row) {
|
|
398
|
+
if (!row) return null;
|
|
399
|
+
return {
|
|
400
|
+
id: row.id,
|
|
401
|
+
source_slug: row.source_slug,
|
|
402
|
+
idempotency_key: row.idempotency_key,
|
|
403
|
+
body_sha3_512: row.body_sha3_512,
|
|
404
|
+
body_size: Number(row.body_size),
|
|
405
|
+
status: row.status,
|
|
406
|
+
outcome: row.outcome,
|
|
407
|
+
received_at: Number(row.received_at),
|
|
408
|
+
processed_at: row.processed_at != null ? Number(row.processed_at) : null,
|
|
409
|
+
failed_at: row.failed_at != null ? Number(row.failed_at) : null,
|
|
410
|
+
fail_reason: row.fail_reason,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Match a supplied plaintext against the source row's live /
|
|
415
|
+
// previous secret hash. Returns the matched-plaintext shape (the
|
|
416
|
+
// primitive only ever sees the plaintext in memory; the row never
|
|
417
|
+
// stores it) when one of the hashes matches inside the grace
|
|
418
|
+
// window — otherwise null. Constant-time on the hex compare via
|
|
419
|
+
// b.crypto.timingSafeEqual.
|
|
420
|
+
function _matchSecret(sourceRow, suppliedPlaintext, nowMs) {
|
|
421
|
+
var suppliedHash = _hashSecret(suppliedPlaintext);
|
|
422
|
+
var matchedLive = _b().crypto.timingSafeEqual(
|
|
423
|
+
sourceRow.signing_secret_hash, suppliedHash
|
|
424
|
+
);
|
|
425
|
+
if (matchedLive) {
|
|
426
|
+
return { plaintext: suppliedPlaintext, scope: "live" };
|
|
427
|
+
}
|
|
428
|
+
if (sourceRow.signing_secret_previous_hash != null) {
|
|
429
|
+
var matchedPrev = _b().crypto.timingSafeEqual(
|
|
430
|
+
sourceRow.signing_secret_previous_hash, suppliedHash
|
|
431
|
+
);
|
|
432
|
+
if (matchedPrev) {
|
|
433
|
+
var rotatedAt = sourceRow.signing_secret_rotated_at != null
|
|
434
|
+
? Number(sourceRow.signing_secret_rotated_at) : 0;
|
|
435
|
+
if (nowMs - rotatedAt > ROTATION_GRACE_MS) return null;
|
|
436
|
+
return { plaintext: suppliedPlaintext, scope: "previous" };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
SECRET_NAMESPACE: SECRET_NAMESPACE,
|
|
444
|
+
SECRET_BYTE_LEN: SECRET_BYTE_LEN,
|
|
445
|
+
SECRET_PLAINTEXT_LEN: SECRET_PLAINTEXT_LEN,
|
|
446
|
+
ROTATION_GRACE_MS: ROTATION_GRACE_MS,
|
|
447
|
+
DEFAULT_REPLAY_WINDOW_SECONDS: DEFAULT_REPLAY_WINDOW_SECONDS,
|
|
448
|
+
MIN_REPLAY_WINDOW_SECONDS: MIN_REPLAY_WINDOW_SECONDS,
|
|
449
|
+
MAX_REPLAY_WINDOW_SECONDS: MAX_REPLAY_WINDOW_SECONDS,
|
|
450
|
+
DEFAULT_MAX_BODY_BYTES: DEFAULT_MAX_BODY_BYTES,
|
|
451
|
+
MIN_MAX_BODY_BYTES: MIN_MAX_BODY_BYTES,
|
|
452
|
+
ABSOLUTE_MAX_BODY_BYTES: ABSOLUTE_MAX_BODY_BYTES,
|
|
453
|
+
DEFAULT_SIGNATURE_HEADER_NAME: DEFAULT_SIGNATURE_HEADER_NAME,
|
|
454
|
+
DEFAULT_TIMESTAMP_HEADER_NAME: DEFAULT_TIMESTAMP_HEADER_NAME,
|
|
455
|
+
STATUSES: STATUSES.slice(),
|
|
456
|
+
|
|
457
|
+
// Register a new inbound source. Operator supplies the slug + any
|
|
458
|
+
// header-name overrides + the active flag; the primitive draws a
|
|
459
|
+
// fresh 32-byte base64url secret (or accepts an operator-supplied
|
|
460
|
+
// plaintext when integrating with an existing third-party secret).
|
|
461
|
+
// The plaintext is returned exactly ONCE — store it in the
|
|
462
|
+
// operator's secret-store (env / KMS / Cloudflare secret) and
|
|
463
|
+
// configure the route layer to pass it to verifyAndPersist. The
|
|
464
|
+
// database row only ever holds the namespaceHash.
|
|
465
|
+
defineSource: async function (input) {
|
|
466
|
+
if (!input || typeof input !== "object") {
|
|
467
|
+
throw new TypeError("webhookReceiver.defineSource: input object required");
|
|
468
|
+
}
|
|
469
|
+
var slug = _slug(input.slug);
|
|
470
|
+
var replayWindow = _replayWindow(input.replay_window_seconds);
|
|
471
|
+
var maxBodyBytes = _maxBodyBytes(input.max_body_bytes);
|
|
472
|
+
var sigHeader = _headerName(input.signature_header_name, DEFAULT_SIGNATURE_HEADER_NAME, "signature_header_name");
|
|
473
|
+
var tsHeader = _headerName(input.timestamp_header_name, DEFAULT_TIMESTAMP_HEADER_NAME, "timestamp_header_name");
|
|
474
|
+
var active = _active(input.active);
|
|
475
|
+
|
|
476
|
+
var existing = await _getSourceRaw(slug);
|
|
477
|
+
if (existing) {
|
|
478
|
+
var collide = new Error("webhookReceiver.defineSource: slug already exists");
|
|
479
|
+
collide.code = "WEBHOOK_SOURCE_EXISTS";
|
|
480
|
+
throw collide;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
var plaintext;
|
|
484
|
+
if (input.secret_plaintext == null) {
|
|
485
|
+
plaintext = _generateSecret();
|
|
486
|
+
} else {
|
|
487
|
+
plaintext = _canonicalSecret(input.secret_plaintext);
|
|
488
|
+
}
|
|
489
|
+
var hash = _hashSecret(plaintext);
|
|
490
|
+
var ts = _now();
|
|
491
|
+
|
|
492
|
+
await query(
|
|
493
|
+
"INSERT INTO webhook_sources " +
|
|
494
|
+
"(slug, signing_secret_hash, signing_secret_previous_hash, " +
|
|
495
|
+
" signing_secret_rotated_at, replay_window_seconds, max_body_bytes, " +
|
|
496
|
+
" signature_header_name, timestamp_header_name, active, archived_at, " +
|
|
497
|
+
" created_at, updated_at) " +
|
|
498
|
+
"VALUES (?1, ?2, NULL, NULL, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
|
|
499
|
+
[slug, hash, replayWindow, maxBodyBytes, sigHeader, tsHeader, active ? 1 : 0, ts],
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
slug: slug,
|
|
504
|
+
secret_plaintext: plaintext,
|
|
505
|
+
replay_window_seconds: replayWindow,
|
|
506
|
+
max_body_bytes: maxBodyBytes,
|
|
507
|
+
signature_header_name: sigHeader,
|
|
508
|
+
timestamp_header_name: tsHeader,
|
|
509
|
+
active: active,
|
|
510
|
+
created_at: ts,
|
|
511
|
+
updated_at: ts,
|
|
512
|
+
};
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// Get an existing source by slug. The row never carries the
|
|
516
|
+
// plaintext — only its hash + the rotation pointers.
|
|
517
|
+
getSource: async function (slug) {
|
|
518
|
+
var s = _slug(slug);
|
|
519
|
+
return _projectSource(await _getSourceRaw(s));
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
// List every defined source. Sorted by created_at DESC so the
|
|
523
|
+
// most-recently-registered surfaces first in the operator
|
|
524
|
+
// dashboard.
|
|
525
|
+
listSources: async function () {
|
|
526
|
+
var r = await query(
|
|
527
|
+
"SELECT * FROM webhook_sources ORDER BY created_at DESC, slug ASC",
|
|
528
|
+
[],
|
|
529
|
+
);
|
|
530
|
+
return r.rows.map(_projectSource);
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
// Verify an inbound delivery and persist the body. Returns a flat
|
|
534
|
+
// result shape rather than throwing — the operator's route layer
|
|
535
|
+
// distinguishes signature_invalid (HTTP 401) from
|
|
536
|
+
// timestamp_out_of_window (HTTP 408) from replay (HTTP 200, no-op)
|
|
537
|
+
// from ok (HTTP 200, persisted) without a catch tree.
|
|
538
|
+
//
|
|
539
|
+
// The operator supplies `secret_plaintext` alongside the slug —
|
|
540
|
+
// pulled from the operator's secret-store at request time. The
|
|
541
|
+
// primitive hashes it under the secret namespace and matches
|
|
542
|
+
// against the live / previous hash on the row (24h grace);
|
|
543
|
+
// unmatched plaintext is reported as `signature_invalid` (the
|
|
544
|
+
// attacker can't distinguish "wrong secret" from "wrong sig" via
|
|
545
|
+
// the response).
|
|
546
|
+
verifyAndPersist: async function (input) {
|
|
547
|
+
if (!input || typeof input !== "object") {
|
|
548
|
+
throw new TypeError("webhookReceiver.verifyAndPersist: input object required");
|
|
549
|
+
}
|
|
550
|
+
var slug = _slug(input.source_slug);
|
|
551
|
+
if (typeof input.secret_plaintext !== "string" || input.secret_plaintext.length === 0) {
|
|
552
|
+
throw new TypeError("webhookReceiver.verifyAndPersist: secret_plaintext must be a non-empty string");
|
|
553
|
+
}
|
|
554
|
+
if (typeof input.signature_header !== "string" || input.signature_header.length === 0) {
|
|
555
|
+
throw new TypeError("webhookReceiver.verifyAndPersist: signature_header must be a non-empty string");
|
|
556
|
+
}
|
|
557
|
+
if (typeof input.timestamp_header !== "string" || input.timestamp_header.length === 0) {
|
|
558
|
+
throw new TypeError("webhookReceiver.verifyAndPersist: timestamp_header must be a non-empty string");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
var bodyBytes;
|
|
562
|
+
try { bodyBytes = _coerceBodyBytes(input.body); }
|
|
563
|
+
catch (e) { throw e; }
|
|
564
|
+
|
|
565
|
+
var idempotencyKey = _idempotencyKey(input.idempotency_key);
|
|
566
|
+
var nowMs = _now();
|
|
567
|
+
|
|
568
|
+
var sourceRow = await _getSourceRaw(slug);
|
|
569
|
+
if (!sourceRow) {
|
|
570
|
+
var miss = new Error("webhookReceiver.verifyAndPersist: source not registered");
|
|
571
|
+
miss.code = "WEBHOOK_SOURCE_NOT_FOUND";
|
|
572
|
+
throw miss;
|
|
573
|
+
}
|
|
574
|
+
if (sourceRow.archived_at != null || Number(sourceRow.active) !== 1) {
|
|
575
|
+
var inactive = new Error("webhookReceiver.verifyAndPersist: source is not active");
|
|
576
|
+
inactive.code = "WEBHOOK_SOURCE_INACTIVE";
|
|
577
|
+
throw inactive;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Body-size gate. The route layer SHOULD enforce this at the
|
|
581
|
+
// edge (Cloudflare / nginx body-size limit) — defending in
|
|
582
|
+
// depth at the primitive guarantees an oversized payload never
|
|
583
|
+
// reaches the persist path even when an operator misconfigures
|
|
584
|
+
// the edge. Refuse outright (throw) so the route layer can map
|
|
585
|
+
// to HTTP 413 Payload Too Large.
|
|
586
|
+
var maxBytes = Number(sourceRow.max_body_bytes);
|
|
587
|
+
if (bodyBytes.length > maxBytes) {
|
|
588
|
+
var oversize = new Error(
|
|
589
|
+
"webhookReceiver.verifyAndPersist: body exceeds max_body_bytes (" +
|
|
590
|
+
bodyBytes.length + " > " + maxBytes + ")"
|
|
591
|
+
);
|
|
592
|
+
oversize.code = "WEBHOOK_BODY_TOO_LARGE";
|
|
593
|
+
throw oversize;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Secret match — supplied plaintext against live / previous
|
|
597
|
+
// hash. We do this FIRST (before parsing the timestamp) so a
|
|
598
|
+
// hand-crafted request can't probe the source row's structure
|
|
599
|
+
// by varying the timestamp header.
|
|
600
|
+
var matched = _matchSecret(sourceRow, input.secret_plaintext, nowMs);
|
|
601
|
+
if (!matched) {
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
event_id: null,
|
|
605
|
+
replay: false,
|
|
606
|
+
signature_invalid: true,
|
|
607
|
+
timestamp_out_of_window: false,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Parse the operator-supplied timestamp header. The third-party
|
|
612
|
+
// signs in seconds (Stripe / GitHub) — we accept seconds; the
|
|
613
|
+
// operator's route layer is responsible for unit conversion if
|
|
614
|
+
// their third-party emits ms.
|
|
615
|
+
var tsStr = input.timestamp_header;
|
|
616
|
+
if (!/^\d{1,15}$/.test(tsStr)) {
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
event_id: null,
|
|
620
|
+
replay: false,
|
|
621
|
+
signature_invalid: true,
|
|
622
|
+
timestamp_out_of_window: false,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
var timestampSeconds = parseInt(tsStr, 10);
|
|
626
|
+
if (!Number.isFinite(timestampSeconds) || timestampSeconds < 0) {
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
event_id: null,
|
|
630
|
+
replay: false,
|
|
631
|
+
signature_invalid: true,
|
|
632
|
+
timestamp_out_of_window: false,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Replay-window check. The third-party's timestamp must land
|
|
637
|
+
// within ±replay_window_seconds of the receiver's current
|
|
638
|
+
// wall-clock. We accept future-skewed timestamps inside the
|
|
639
|
+
// window (a peer with a slightly fast clock shouldn't reject)
|
|
640
|
+
// but not arbitrarily-future timestamps that would let an
|
|
641
|
+
// attacker bypass replay protection by stamping a fresh ts.
|
|
642
|
+
var windowSec = Number(sourceRow.replay_window_seconds);
|
|
643
|
+
var nowSec = Math.floor(nowMs / 1000);
|
|
644
|
+
var ageSec = Math.abs(nowSec - timestampSeconds);
|
|
645
|
+
if (ageSec > windowSec) {
|
|
646
|
+
return {
|
|
647
|
+
ok: false,
|
|
648
|
+
event_id: null,
|
|
649
|
+
replay: false,
|
|
650
|
+
signature_invalid: false,
|
|
651
|
+
timestamp_out_of_window: true,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Signature validation. The third-party signs the literal
|
|
656
|
+
// `<timestamp>.<body>` string with HMAC-SHA-256 under the
|
|
657
|
+
// secret. We recompute via b.webhook.sign and constant-time-
|
|
658
|
+
// compare the supplied hex against the expected hex.
|
|
659
|
+
var supplied = input.signature_header.trim().toLowerCase();
|
|
660
|
+
if (!SIGNATURE_HEX_RE.test(supplied)) {
|
|
661
|
+
return {
|
|
662
|
+
ok: false,
|
|
663
|
+
event_id: null,
|
|
664
|
+
replay: false,
|
|
665
|
+
signature_invalid: true,
|
|
666
|
+
timestamp_out_of_window: false,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
var expected = _hmacSha256Hex(matched.plaintext, timestampSeconds, bodyBytes.toString("utf8"));
|
|
670
|
+
var sigOk = _b().crypto.timingSafeEqual(supplied, expected);
|
|
671
|
+
if (!sigOk) {
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
event_id: null,
|
|
675
|
+
replay: false,
|
|
676
|
+
signature_invalid: true,
|
|
677
|
+
timestamp_out_of_window: false,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Idempotency dedupe. When the third-party supplies an
|
|
682
|
+
// idempotency key (Stripe event id, GitHub delivery id, …) we
|
|
683
|
+
// check whether we've already persisted this (source, key) pair
|
|
684
|
+
// and return `{ replay: true }` without inserting a duplicate
|
|
685
|
+
// row. The returned event_id points at the existing row so the
|
|
686
|
+
// operator's downstream processor can resync without losing the
|
|
687
|
+
// delivery chain.
|
|
688
|
+
if (idempotencyKey != null) {
|
|
689
|
+
var dup = await query(
|
|
690
|
+
"SELECT id FROM webhook_received_events " +
|
|
691
|
+
"WHERE source_slug = ?1 AND idempotency_key = ?2",
|
|
692
|
+
[slug, idempotencyKey],
|
|
693
|
+
);
|
|
694
|
+
if (dup.rows.length) {
|
|
695
|
+
return {
|
|
696
|
+
ok: true,
|
|
697
|
+
event_id: dup.rows[0].id,
|
|
698
|
+
replay: true,
|
|
699
|
+
signature_invalid: false,
|
|
700
|
+
timestamp_out_of_window: false,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Persist. body_sha3_512 is the SHA3-512 fingerprint of the
|
|
706
|
+
// raw bytes — operators paste-compare against the third-party's
|
|
707
|
+
// console to verify the payload landed intact end-to-end.
|
|
708
|
+
var eventId = _b().uuid.v7();
|
|
709
|
+
var bodySha = _b().crypto.sha3Hash(bodyBytes);
|
|
710
|
+
try {
|
|
711
|
+
await query(
|
|
712
|
+
"INSERT INTO webhook_received_events " +
|
|
713
|
+
"(id, source_slug, idempotency_key, body_sha3_512, body_size, " +
|
|
714
|
+
" status, outcome, received_at, processed_at, failed_at, fail_reason) " +
|
|
715
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'received', NULL, ?6, NULL, NULL, NULL)",
|
|
716
|
+
[eventId, slug, idempotencyKey, bodySha, bodyBytes.length, nowMs],
|
|
717
|
+
);
|
|
718
|
+
} catch (e) {
|
|
719
|
+
// The UNIQUE(source_slug, idempotency_key) constraint races
|
|
720
|
+
// when two deliveries with the same key arrive simultaneously
|
|
721
|
+
// — the second insert collapses to a replay rather than a
|
|
722
|
+
// hard error. The caller saw a successful verify; we treat
|
|
723
|
+
// the late-arriving duplicate as a benign replay.
|
|
724
|
+
if (e && e.message && e.message.indexOf("UNIQUE") !== -1 && idempotencyKey != null) {
|
|
725
|
+
var late = await query(
|
|
726
|
+
"SELECT id FROM webhook_received_events " +
|
|
727
|
+
"WHERE source_slug = ?1 AND idempotency_key = ?2",
|
|
728
|
+
[slug, idempotencyKey],
|
|
729
|
+
);
|
|
730
|
+
if (late.rows.length) {
|
|
731
|
+
return {
|
|
732
|
+
ok: true,
|
|
733
|
+
event_id: late.rows[0].id,
|
|
734
|
+
replay: true,
|
|
735
|
+
signature_invalid: false,
|
|
736
|
+
timestamp_out_of_window: false,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
throw e;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
event_id: eventId,
|
|
746
|
+
replay: false,
|
|
747
|
+
signature_invalid: false,
|
|
748
|
+
timestamp_out_of_window: false,
|
|
749
|
+
};
|
|
750
|
+
},
|
|
751
|
+
|
|
752
|
+
// Mark a received event processed. The operator's downstream
|
|
753
|
+
// processor calls this once it has acted on the payload —
|
|
754
|
+
// `outcome` is an opaque operator-supplied label (`"order-
|
|
755
|
+
// created"`, `"customer-updated"`, etc.) that surfaces in the
|
|
756
|
+
// dashboard's event view. Refused on a non-`received` row so
|
|
757
|
+
// double-processing doesn't silently overwrite the prior
|
|
758
|
+
// outcome.
|
|
759
|
+
markProcessed: async function (input) {
|
|
760
|
+
if (!input || typeof input !== "object") {
|
|
761
|
+
throw new TypeError("webhookReceiver.markProcessed: input object required");
|
|
762
|
+
}
|
|
763
|
+
var eventId = _eventId(input.event_id);
|
|
764
|
+
var outcome = _outcome(input.outcome);
|
|
765
|
+
|
|
766
|
+
var current = await _getEventRaw(eventId);
|
|
767
|
+
if (!current) {
|
|
768
|
+
var miss = new Error("webhookReceiver.markProcessed: event not found");
|
|
769
|
+
miss.code = "WEBHOOK_EVENT_NOT_FOUND";
|
|
770
|
+
throw miss;
|
|
771
|
+
}
|
|
772
|
+
if (current.status !== "received") {
|
|
773
|
+
var refused = new Error(
|
|
774
|
+
"webhookReceiver.markProcessed: refused — event is " + current.status
|
|
775
|
+
);
|
|
776
|
+
refused.code = "WEBHOOK_EVENT_NOT_RECEIVED";
|
|
777
|
+
throw refused;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
var ts = _now();
|
|
781
|
+
await query(
|
|
782
|
+
"UPDATE webhook_received_events " +
|
|
783
|
+
"SET status = 'processed', outcome = ?1, processed_at = ?2 " +
|
|
784
|
+
"WHERE id = ?3",
|
|
785
|
+
[outcome, ts, eventId],
|
|
786
|
+
);
|
|
787
|
+
return _projectEvent(await _getEventRaw(eventId));
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
// Mark a received event failed. `retry=true` keeps the row
|
|
791
|
+
// eligible for the operator's reprocess scheduler (the worker
|
|
792
|
+
// can re-pull `failed` + retry rows and re-attempt); `retry=
|
|
793
|
+
// false` is the terminal-dead-letter state. Refused on a non-
|
|
794
|
+
// `received` row.
|
|
795
|
+
markFailed: async function (input) {
|
|
796
|
+
if (!input || typeof input !== "object") {
|
|
797
|
+
throw new TypeError("webhookReceiver.markFailed: input object required");
|
|
798
|
+
}
|
|
799
|
+
var eventId = _eventId(input.event_id);
|
|
800
|
+
var reason = _reason(input.reason);
|
|
801
|
+
var retry = _retry(input.retry);
|
|
802
|
+
|
|
803
|
+
var current = await _getEventRaw(eventId);
|
|
804
|
+
if (!current) {
|
|
805
|
+
var miss = new Error("webhookReceiver.markFailed: event not found");
|
|
806
|
+
miss.code = "WEBHOOK_EVENT_NOT_FOUND";
|
|
807
|
+
throw miss;
|
|
808
|
+
}
|
|
809
|
+
if (current.status !== "received") {
|
|
810
|
+
var refused = new Error(
|
|
811
|
+
"webhookReceiver.markFailed: refused — event is " + current.status
|
|
812
|
+
);
|
|
813
|
+
refused.code = "WEBHOOK_EVENT_NOT_RECEIVED";
|
|
814
|
+
throw refused;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
var ts = _now();
|
|
818
|
+
// When retry=true we still flip status to 'failed' so the row
|
|
819
|
+
// is no longer eligible for the unprocessed-events queue —
|
|
820
|
+
// operators distinguish "needs retry" from "fresh" by the
|
|
821
|
+
// retry flag encoded in fail_reason. (A separate retry_count
|
|
822
|
+
// column was considered and rejected: every operator's retry
|
|
823
|
+
// scheduler has different idempotency + backoff semantics; we
|
|
824
|
+
// surface the row state and let the operator's scheduler own
|
|
825
|
+
// its own counters.) The fail_reason carries the retry hint
|
|
826
|
+
// verbatim so a downstream re-pull can read it.
|
|
827
|
+
var encodedReason = (retry ? "[retry] " : "[dead-letter] ") + reason;
|
|
828
|
+
await query(
|
|
829
|
+
"UPDATE webhook_received_events " +
|
|
830
|
+
"SET status = 'failed', failed_at = ?1, fail_reason = ?2 " +
|
|
831
|
+
"WHERE id = ?3",
|
|
832
|
+
[ts, encodedReason, eventId],
|
|
833
|
+
);
|
|
834
|
+
return _projectEvent(await _getEventRaw(eventId));
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
// Single-event lookup. Returns null on miss (the row was already
|
|
838
|
+
// swept by purgeOlderThan, or the operator hand-typed a wrong
|
|
839
|
+
// id). The body itself is NOT stored on the row — only its
|
|
840
|
+
// digest + size. Operators integrating with the body need to
|
|
841
|
+
// capture it from the inbound request before calling
|
|
842
|
+
// verifyAndPersist (the primitive verifies + persists the
|
|
843
|
+
// metadata; the operator's storage layer owns the raw bytes
|
|
844
|
+
// when retention beyond the digest is needed).
|
|
845
|
+
getEvent: async function (eventId) {
|
|
846
|
+
var id = _eventId(eventId);
|
|
847
|
+
return _projectEvent(await _getEventRaw(id));
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
// Unprocessed-events queue. The downstream worker reads this on
|
|
851
|
+
// its pull schedule; `received` rows surface in oldest-first
|
|
852
|
+
// order so a backlog drains FIFO. Optional `source_slug` filter
|
|
853
|
+
// for operators running per-source workers.
|
|
854
|
+
unprocessedEvents: async function (input) {
|
|
855
|
+
input = input || {};
|
|
856
|
+
var limit = _limit(input.limit, "limit", 500);
|
|
857
|
+
var sql;
|
|
858
|
+
var params;
|
|
859
|
+
if (input.source_slug != null) {
|
|
860
|
+
var slug = _slug(input.source_slug);
|
|
861
|
+
sql =
|
|
862
|
+
"SELECT * FROM webhook_received_events " +
|
|
863
|
+
"WHERE status = 'received' AND source_slug = ?1 " +
|
|
864
|
+
"ORDER BY received_at ASC, id ASC LIMIT ?2";
|
|
865
|
+
params = [slug, limit];
|
|
866
|
+
} else {
|
|
867
|
+
sql =
|
|
868
|
+
"SELECT * FROM webhook_received_events " +
|
|
869
|
+
"WHERE status = 'received' " +
|
|
870
|
+
"ORDER BY received_at ASC, id ASC LIMIT ?1";
|
|
871
|
+
params = [limit];
|
|
872
|
+
}
|
|
873
|
+
var r = await query(sql, params);
|
|
874
|
+
return r.rows.map(_projectEvent);
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
// Per-source feed in time-DESC order. Keyset-paginated on
|
|
878
|
+
// (received_at, id) so operators stepping through a long history
|
|
879
|
+
// don't pay OFFSET-induced scan cost.
|
|
880
|
+
eventsForSource: async function (input) {
|
|
881
|
+
if (!input || typeof input !== "object") {
|
|
882
|
+
throw new TypeError("webhookReceiver.eventsForSource: input object required");
|
|
883
|
+
}
|
|
884
|
+
var slug = _slug(input.source_slug);
|
|
885
|
+
var limit = _limit(input.limit, "limit", 500);
|
|
886
|
+
|
|
887
|
+
var cursorAt = null;
|
|
888
|
+
var cursorId = null;
|
|
889
|
+
if (input.cursor != null) {
|
|
890
|
+
if (typeof input.cursor !== "string" || input.cursor.indexOf(":") === -1) {
|
|
891
|
+
throw new TypeError(
|
|
892
|
+
"webhookReceiver.eventsForSource: cursor must be a string of the form '<received_at>:<id>'"
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
var idx = input.cursor.indexOf(":");
|
|
896
|
+
var at = parseInt(input.cursor.slice(0, idx), 10);
|
|
897
|
+
var cid = input.cursor.slice(idx + 1);
|
|
898
|
+
if (!Number.isInteger(at) || at < 0 || cid.length === 0) {
|
|
899
|
+
throw new TypeError("webhookReceiver.eventsForSource: cursor parses to garbage");
|
|
900
|
+
}
|
|
901
|
+
cursorAt = at;
|
|
902
|
+
cursorId = cid;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
var sql;
|
|
906
|
+
var params;
|
|
907
|
+
if (cursorAt == null) {
|
|
908
|
+
sql =
|
|
909
|
+
"SELECT * FROM webhook_received_events " +
|
|
910
|
+
"WHERE source_slug = ?1 " +
|
|
911
|
+
"ORDER BY received_at DESC, id DESC LIMIT ?2";
|
|
912
|
+
params = [slug, limit + 1];
|
|
913
|
+
} else {
|
|
914
|
+
sql =
|
|
915
|
+
"SELECT * FROM webhook_received_events " +
|
|
916
|
+
"WHERE source_slug = ?1 " +
|
|
917
|
+
" AND (received_at < ?2 OR (received_at = ?2 AND id < ?3)) " +
|
|
918
|
+
"ORDER BY received_at DESC, id DESC LIMIT ?4";
|
|
919
|
+
params = [slug, cursorAt, cursorId, limit + 1];
|
|
920
|
+
}
|
|
921
|
+
var r = await query(sql, params);
|
|
922
|
+
var rows = r.rows.slice(0, limit).map(_projectEvent);
|
|
923
|
+
var nextCursor = null;
|
|
924
|
+
if (r.rows.length > limit && rows.length > 0) {
|
|
925
|
+
var last = rows[rows.length - 1];
|
|
926
|
+
nextCursor = last.received_at + ":" + last.id;
|
|
927
|
+
}
|
|
928
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
// Retention sweep. Deletes terminal (processed / failed) rows
|
|
932
|
+
// older than `days`. `received` rows are never swept — an
|
|
933
|
+
// unprocessed event sitting past the retention window points at
|
|
934
|
+
// a broken downstream worker, and silently deleting it would
|
|
935
|
+
// hide the breakage. Operators schedule this on a daily cron.
|
|
936
|
+
purgeOlderThan: async function (days) {
|
|
937
|
+
var d = _purgeDays(days);
|
|
938
|
+
var nowMs = _now();
|
|
939
|
+
var cutoff = nowMs - (d * 24 * 60 * 60 * 1000);
|
|
940
|
+
var r = await query(
|
|
941
|
+
"DELETE FROM webhook_received_events " +
|
|
942
|
+
"WHERE received_at < ?1 " +
|
|
943
|
+
" AND status IN ('processed', 'failed')",
|
|
944
|
+
[cutoff],
|
|
945
|
+
);
|
|
946
|
+
return { purged: r.rowCount != null ? Number(r.rowCount) : 0, cutoff: cutoff };
|
|
947
|
+
},
|
|
948
|
+
|
|
949
|
+
// Rotate the signing secret. The current hash slides into
|
|
950
|
+
// `signing_secret_previous_hash` with a 24h overlap — the third-
|
|
951
|
+
// party platform pulls the new plaintext (returned ONCE here),
|
|
952
|
+
// updates its sender, and we keep accepting deliveries signed
|
|
953
|
+
// under the old secret until rotated_at + 24h. The new plaintext
|
|
954
|
+
// is returned to the operator's secret-store; the row only ever
|
|
955
|
+
// holds the hash.
|
|
956
|
+
rotateSecret: async function (slug) {
|
|
957
|
+
var s = _slug(slug);
|
|
958
|
+
var current = await _getSourceRaw(s);
|
|
959
|
+
if (!current) {
|
|
960
|
+
var miss = new Error("webhookReceiver.rotateSecret: source not registered");
|
|
961
|
+
miss.code = "WEBHOOK_SOURCE_NOT_FOUND";
|
|
962
|
+
throw miss;
|
|
963
|
+
}
|
|
964
|
+
if (current.archived_at != null) {
|
|
965
|
+
var refused = new Error("webhookReceiver.rotateSecret: source is archived");
|
|
966
|
+
refused.code = "WEBHOOK_SOURCE_ARCHIVED";
|
|
967
|
+
throw refused;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
var plaintext = _generateSecret();
|
|
971
|
+
var newHash = _hashSecret(plaintext);
|
|
972
|
+
var ts = _now();
|
|
973
|
+
|
|
974
|
+
await query(
|
|
975
|
+
"UPDATE webhook_sources " +
|
|
976
|
+
"SET signing_secret_hash = ?1, " +
|
|
977
|
+
" signing_secret_previous_hash = ?2, " +
|
|
978
|
+
" signing_secret_rotated_at = ?3, " +
|
|
979
|
+
" updated_at = ?3 " +
|
|
980
|
+
"WHERE slug = ?4",
|
|
981
|
+
[newHash, current.signing_secret_hash, ts, s],
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
return {
|
|
985
|
+
slug: s,
|
|
986
|
+
secret_plaintext: plaintext,
|
|
987
|
+
rotated_at: ts,
|
|
988
|
+
rotation_grace_ms: ROTATION_GRACE_MS,
|
|
989
|
+
};
|
|
990
|
+
},
|
|
991
|
+
|
|
992
|
+
// Archive a source — terminal off-switch. The row stays for
|
|
993
|
+
// historical event-trail lookup; verifyAndPersist refuses every
|
|
994
|
+
// inbound delivery against an archived source. Idempotent — re-
|
|
995
|
+
// archiving returns the existing row.
|
|
996
|
+
archiveSource: async function (slug) {
|
|
997
|
+
var s = _slug(slug);
|
|
998
|
+
var current = await _getSourceRaw(s);
|
|
999
|
+
if (!current) {
|
|
1000
|
+
var miss = new Error("webhookReceiver.archiveSource: source not registered");
|
|
1001
|
+
miss.code = "WEBHOOK_SOURCE_NOT_FOUND";
|
|
1002
|
+
throw miss;
|
|
1003
|
+
}
|
|
1004
|
+
if (current.archived_at != null) {
|
|
1005
|
+
return _projectSource(current);
|
|
1006
|
+
}
|
|
1007
|
+
var ts = _now();
|
|
1008
|
+
await query(
|
|
1009
|
+
"UPDATE webhook_sources " +
|
|
1010
|
+
"SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
1011
|
+
"WHERE slug = ?2",
|
|
1012
|
+
[ts, s],
|
|
1013
|
+
);
|
|
1014
|
+
return _projectSource(await _getSourceRaw(s));
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
module.exports = {
|
|
1020
|
+
create: create,
|
|
1021
|
+
SECRET_NAMESPACE: SECRET_NAMESPACE,
|
|
1022
|
+
SECRET_BYTE_LEN: SECRET_BYTE_LEN,
|
|
1023
|
+
SECRET_PLAINTEXT_LEN: SECRET_PLAINTEXT_LEN,
|
|
1024
|
+
ROTATION_GRACE_MS: ROTATION_GRACE_MS,
|
|
1025
|
+
DEFAULT_REPLAY_WINDOW_SECONDS: DEFAULT_REPLAY_WINDOW_SECONDS,
|
|
1026
|
+
MIN_REPLAY_WINDOW_SECONDS: MIN_REPLAY_WINDOW_SECONDS,
|
|
1027
|
+
MAX_REPLAY_WINDOW_SECONDS: MAX_REPLAY_WINDOW_SECONDS,
|
|
1028
|
+
DEFAULT_MAX_BODY_BYTES: DEFAULT_MAX_BODY_BYTES,
|
|
1029
|
+
MIN_MAX_BODY_BYTES: MIN_MAX_BODY_BYTES,
|
|
1030
|
+
ABSOLUTE_MAX_BODY_BYTES: ABSOLUTE_MAX_BODY_BYTES,
|
|
1031
|
+
DEFAULT_SIGNATURE_HEADER_NAME: DEFAULT_SIGNATURE_HEADER_NAME,
|
|
1032
|
+
DEFAULT_TIMESTAMP_HEADER_NAME: DEFAULT_TIMESTAMP_HEADER_NAME,
|
|
1033
|
+
STATUSES: STATUSES.slice(),
|
|
1034
|
+
};
|