@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.
@@ -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
+ };