@blamejs/blamejs-shop 0.0.59 → 0.0.61

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,565 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.webhookSubscriptions
4
+ * @title Webhook subscription registry — owner-scoped fan-out targets
5
+ *
6
+ * @intro
7
+ * The webhook subscription registry is the registration layer for
8
+ * outbound webhook fan-out. Operators (or third-party apps wiring
9
+ * integrations on behalf of the operator, or customers opting in to
10
+ * self-service notifications) call `subscribe(...)` to register an
11
+ * endpoint + event-type filter + signing-secret pair; when the
12
+ * framework emits an event, the delivery primitive
13
+ * (`lib/webhooks.js`) calls `subscriptionsForEvent(eventType)` to
14
+ * build the fan-out set.
15
+ *
16
+ * Distinct from the `webhooks` primitive — that primitive owns
17
+ * delivery, retry, DLQ, and signature signing. This primitive owns
18
+ * the registration FSM (active / paused), the signing-secret rotation
19
+ * surface (with a 24h grace where the previous secret is also
20
+ * accepted), and the owner-scoped listing surface.
21
+ *
22
+ * Signing-secret posture — `signing_secret` is generated by the
23
+ * framework via `b.crypto.generateBytes(32)` (base64url-encoded). The
24
+ * plaintext is returned ONCE on `subscribe(...)` and ONCE on
25
+ * `rotateSecret(...)`; at rest the framework keeps a SHA3-512
26
+ * namespace hash (`b.crypto.namespaceHash("webhook-signing-secret",
27
+ * plaintext)`). The delivery primitive does not need the plaintext
28
+ * at sign time — it composes the hash + an HMAC binding the row id
29
+ * into the signed envelope. Storing the hash (not the plaintext)
30
+ * means a database compromise doesn't leak active signing material.
31
+ *
32
+ * Rotation grace — `rotateSecret(...)` stores the new hash in
33
+ * `signing_secret_hash` AND preserves the prior hash in
34
+ * `signing_secret_previous_hash` with `signing_secret_rotated_at`
35
+ * stamped to the rotation moment. Operators rolling a secret give
36
+ * their downstream receivers 24h to switch over; after the grace
37
+ * expires, the previous hash is cleared lazily on the next mutation
38
+ * of the row (or eagerly via `expireRotationGrace`).
39
+ *
40
+ * Composes only blamejs primitives:
41
+ * - `b.guardUuid` — UUID-shape validation
42
+ * - `b.safeUrl.parse` — https-only endpoint validation
43
+ * - `b.crypto.generateBytes` — uniform draw for signing secrets
44
+ * - `b.crypto.namespaceHash` — SHA3-512 namespaced hashing
45
+ * - `b.uuid.v7` — row ids
46
+ *
47
+ * @primitive webhookSubscriptions
48
+ * @related webhooks, b.guardUuid, b.safeUrl, b.crypto
49
+ */
50
+
51
+ var OWNER_TYPES = Object.freeze(["operator", "app", "customer"]);
52
+ var MAX_OWNER_ID_LEN = 200;
53
+ var MAX_NAME_LEN = 200;
54
+ var MAX_EVENT_TYPE_LEN = 200;
55
+ var MAX_EVENT_TYPES = 64;
56
+ var MAX_ENDPOINT_URL_LEN = 2048;
57
+ var SECRET_BYTES = 32;
58
+ var SECRET_NAMESPACE = "webhook-signing-secret";
59
+ var ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
60
+
61
+ var WILDCARD_EVENT = "*";
62
+
63
+ // Control bytes + zero-width / direction-override family. Subscription
64
+ // names + event_type strings render in operator dashboards; embedded
65
+ // control bytes are a slipping-class for header injection + visual
66
+ // spoofing downstream. Spelled with \u-escapes so ESLint's
67
+ // no-irregular-whitespace stays happy.
68
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
69
+ var ZERO_WIDTH_RE = new RegExp(
70
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
71
+ );
72
+
73
+ // Event-type identifier shape — `domain.action[.qualifier]` segments
74
+ // of ASCII letters / digits / underscore, joined by `.`. The wildcard
75
+ // `*` is handled separately. The cap is generous (200 chars) but the
76
+ // segment shape is tight so a smuggled control byte / whitespace /
77
+ // path-traversal token never lands in the JSON blob.
78
+ var EVENT_TYPE_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
79
+
80
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
81
+ "endpoint_url", "event_types", "name",
82
+ ]);
83
+
84
+ // Lazy framework handle — matches the pattern every other shop
85
+ // primitive uses; avoids the require cycle that would arise from
86
+ // importing `./index` at module-eval time.
87
+ var bShop;
88
+ function _b() {
89
+ if (!bShop) bShop = require("./index");
90
+ return bShop.framework;
91
+ }
92
+
93
+ function _now() { return Date.now(); }
94
+
95
+ // ---- validators ---------------------------------------------------------
96
+
97
+ function _uuid(s, label) {
98
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
99
+ catch (e) { throw new TypeError("webhookSubscriptions: " + label + " — " + (e && e.message || "invalid UUID")); }
100
+ }
101
+
102
+ function _ownerType(s) {
103
+ if (typeof s !== "string" || OWNER_TYPES.indexOf(s) === -1) {
104
+ throw new TypeError("webhookSubscriptions: owner_type must be one of " + OWNER_TYPES.join(", "));
105
+ }
106
+ return s;
107
+ }
108
+
109
+ function _ownerId(s) {
110
+ if (typeof s !== "string") {
111
+ throw new TypeError("webhookSubscriptions: owner_id must be a string");
112
+ }
113
+ var trimmed = s.trim();
114
+ if (!trimmed.length) {
115
+ throw new TypeError("webhookSubscriptions: owner_id must be non-empty after trim");
116
+ }
117
+ if (s.length > MAX_OWNER_ID_LEN) {
118
+ throw new TypeError("webhookSubscriptions: owner_id must be <= " + MAX_OWNER_ID_LEN + " characters");
119
+ }
120
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
121
+ throw new TypeError("webhookSubscriptions: owner_id contains control / zero-width bytes");
122
+ }
123
+ return s;
124
+ }
125
+
126
+ function _endpointUrl(url) {
127
+ if (typeof url !== "string" || url.length === 0) {
128
+ throw new TypeError("webhookSubscriptions: endpoint_url must be a non-empty string");
129
+ }
130
+ if (url.length > MAX_ENDPOINT_URL_LEN) {
131
+ throw new TypeError("webhookSubscriptions: endpoint_url must be <= " + MAX_ENDPOINT_URL_LEN + " characters");
132
+ }
133
+ // safeUrl.parse defaults to ALLOW_HTTP_TLS (https only). The framework
134
+ // refuses http:// + non-http schemes + user:pass@ userinfo + bracketed
135
+ // raw-IP forms at this gate — no separate validation needed here.
136
+ try {
137
+ _b().safeUrl.parse(url);
138
+ } catch (e) {
139
+ throw new TypeError("webhookSubscriptions: endpoint_url — " + (e && e.message || "rejected"));
140
+ }
141
+ return url;
142
+ }
143
+
144
+ function _eventTypesArray(input) {
145
+ if (!Array.isArray(input)) {
146
+ throw new TypeError("webhookSubscriptions: event_types must be a non-empty array");
147
+ }
148
+ if (input.length === 0) {
149
+ throw new TypeError("webhookSubscriptions: event_types must list at least one event type");
150
+ }
151
+ if (input.length > MAX_EVENT_TYPES) {
152
+ throw new TypeError("webhookSubscriptions: event_types must be <= " + MAX_EVENT_TYPES + " entries");
153
+ }
154
+ var out = [];
155
+ var seen = {};
156
+ for (var i = 0; i < input.length; i += 1) {
157
+ var et = input[i];
158
+ if (typeof et !== "string" || et.length === 0) {
159
+ throw new TypeError("webhookSubscriptions: event_types[" + i + "] must be a non-empty string");
160
+ }
161
+ if (et.length > MAX_EVENT_TYPE_LEN) {
162
+ throw new TypeError("webhookSubscriptions: event_types[" + i + "] must be <= " + MAX_EVENT_TYPE_LEN + " characters");
163
+ }
164
+ if (et !== WILDCARD_EVENT && !EVENT_TYPE_RE.test(et)) {
165
+ throw new TypeError("webhookSubscriptions: event_types[" + i + "] must match domain.action segment shape or be '*'");
166
+ }
167
+ if (Object.prototype.hasOwnProperty.call(seen, et)) {
168
+ throw new TypeError("webhookSubscriptions: event_types[" + i + "] is a duplicate of an earlier entry");
169
+ }
170
+ seen[et] = true;
171
+ out.push(et);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function _name(s) {
177
+ if (s == null) return null;
178
+ if (typeof s !== "string") {
179
+ throw new TypeError("webhookSubscriptions: name must be a string or null");
180
+ }
181
+ if (s.length === 0) return null;
182
+ if (s.length > MAX_NAME_LEN) {
183
+ throw new TypeError("webhookSubscriptions: name must be <= " + MAX_NAME_LEN + " characters");
184
+ }
185
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
186
+ throw new TypeError("webhookSubscriptions: name contains control / zero-width bytes");
187
+ }
188
+ return s;
189
+ }
190
+
191
+ function _activeFlag(v) {
192
+ if (v === true || v === 1) return 1;
193
+ if (v === false || v === 0) return 0;
194
+ throw new TypeError("webhookSubscriptions: active must be boolean / 0 / 1");
195
+ }
196
+
197
+ function _signingSecret(s) {
198
+ // Operator-supplied secret on subscribe — optional override path.
199
+ // Must be at least 32 chars (256 bits at 8 bits/char minimum) so an
200
+ // accidentally-weak passphrase doesn't sneak past the registration
201
+ // gate. The framework's default (b.crypto.generateBytes) produces a
202
+ // 43-char base64url string; the floor lets operators paste a hex
203
+ // secret of equivalent strength without rejecting reasonable inputs.
204
+ if (typeof s !== "string") {
205
+ throw new TypeError("webhookSubscriptions: signing_secret must be a string when provided");
206
+ }
207
+ if (s.length < 32) {
208
+ throw new TypeError("webhookSubscriptions: signing_secret must be >= 32 characters");
209
+ }
210
+ if (s.length > 512) {
211
+ throw new TypeError("webhookSubscriptions: signing_secret must be <= 512 characters");
212
+ }
213
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
214
+ throw new TypeError("webhookSubscriptions: signing_secret contains control / zero-width bytes");
215
+ }
216
+ return s;
217
+ }
218
+
219
+ // ---- secret generation + hashing ----------------------------------------
220
+
221
+ function _generateSecret() {
222
+ // base64url alphabet so the operator can paste the secret into a
223
+ // header / curl invocation without shell-escaping. b.crypto.generateBytes
224
+ // returns a Node Buffer; the framework's standard base64url encoding
225
+ // path is .toString("base64url") which is unpadded by design.
226
+ return _b().crypto.generateBytes(SECRET_BYTES).toString("base64url");
227
+ }
228
+
229
+ function _hashSecret(plaintext) {
230
+ return _b().crypto.namespaceHash(SECRET_NAMESPACE, plaintext);
231
+ }
232
+
233
+ // ---- factory ------------------------------------------------------------
234
+
235
+ function create(opts) {
236
+ opts = opts || {};
237
+ var query = opts.query;
238
+ if (!query) {
239
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
240
+ }
241
+ // Injectable clock — tests fast-forward through the 24h rotation
242
+ // grace by passing a synthetic `now()` that jumps ahead by the
243
+ // grace interval. Production callers leave it undefined and pick up
244
+ // the wall clock.
245
+ var nowFn = (typeof opts.now === "function") ? opts.now : _now;
246
+
247
+ async function _getSubscriptionRaw(id) {
248
+ var r = await query("SELECT * FROM webhook_subscriptions WHERE id = ?1", [id]);
249
+ return r.rows[0] || null;
250
+ }
251
+
252
+ // Strip an expired rotation grace lazily — any mutation that touches
253
+ // a row whose `signing_secret_previous_hash` is older than the
254
+ // grace window discards the previous hash. The receiver was given a
255
+ // full grace cycle to roll forward; further reads of the previous
256
+ // hash would be a security smell (a longer-than-stated grace lets a
257
+ // leaked-old-secret attacker linger).
258
+ function _maybeExpireGrace(row, now) {
259
+ if (!row) return row;
260
+ if (row.signing_secret_previous_hash == null) return row;
261
+ var rotatedAt = Number(row.signing_secret_rotated_at);
262
+ if (!isFinite(rotatedAt)) return row;
263
+ if (now - rotatedAt < ROTATION_GRACE_MS) return row;
264
+ // The previous hash is expired but we have not yet been asked to
265
+ // mutate the row. Return a synthesized view that hides the
266
+ // expired previous hash; the persistent clear happens the next
267
+ // time the row is updated through the primitive's mutators.
268
+ var view = {};
269
+ var keys = Object.keys(row);
270
+ for (var i = 0; i < keys.length; i += 1) {
271
+ view[keys[i]] = row[keys[i]];
272
+ }
273
+ view.signing_secret_previous_hash = null;
274
+ return view;
275
+ }
276
+
277
+ return {
278
+ OWNER_TYPES: OWNER_TYPES.slice(),
279
+ MAX_OWNER_ID_LEN: MAX_OWNER_ID_LEN,
280
+ MAX_NAME_LEN: MAX_NAME_LEN,
281
+ MAX_EVENT_TYPES: MAX_EVENT_TYPES,
282
+ MAX_EVENT_TYPE_LEN: MAX_EVENT_TYPE_LEN,
283
+ MAX_ENDPOINT_URL_LEN: MAX_ENDPOINT_URL_LEN,
284
+ SECRET_BYTES: SECRET_BYTES,
285
+ SECRET_NAMESPACE: SECRET_NAMESPACE,
286
+ ROTATION_GRACE_MS: ROTATION_GRACE_MS,
287
+ WILDCARD_EVENT: WILDCARD_EVENT,
288
+
289
+ subscribe: async function (input) {
290
+ if (!input || typeof input !== "object") {
291
+ throw new TypeError("webhookSubscriptions.subscribe: input object required");
292
+ }
293
+ var ownerType = _ownerType(input.owner_type);
294
+ var ownerId = _ownerId(input.owner_id);
295
+ var endpointUrl = _endpointUrl(input.endpoint_url);
296
+ var eventTypes = _eventTypesArray(input.event_types);
297
+ var name = _name(input.name);
298
+ var active = 1;
299
+ if (Object.prototype.hasOwnProperty.call(input, "active")) {
300
+ active = _activeFlag(input.active);
301
+ }
302
+
303
+ var plaintext;
304
+ if (Object.prototype.hasOwnProperty.call(input, "signing_secret") &&
305
+ input.signing_secret != null) {
306
+ plaintext = _signingSecret(input.signing_secret);
307
+ } else {
308
+ plaintext = _generateSecret();
309
+ }
310
+ var secretHash = _hashSecret(plaintext);
311
+
312
+ var id = _b().uuid.v7();
313
+ var ts = nowFn();
314
+ await query(
315
+ "INSERT INTO webhook_subscriptions " +
316
+ "(id, owner_type, owner_id, endpoint_url, event_types_json, " +
317
+ " signing_secret_hash, signing_secret_previous_hash, " +
318
+ " signing_secret_rotated_at, name, active, paused_at, " +
319
+ " created_at, updated_at) " +
320
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, ?7, ?8, NULL, ?9, ?9)",
321
+ [id, ownerType, ownerId, endpointUrl, JSON.stringify(eventTypes),
322
+ secretHash, name, active, ts],
323
+ );
324
+ var row = await _getSubscriptionRaw(id);
325
+ // Return the row PLUS the plaintext secret — the operator must
326
+ // capture it now; the framework never reveals it again.
327
+ return {
328
+ subscription: row,
329
+ signing_secret: plaintext,
330
+ };
331
+ },
332
+
333
+ get: async function (subscriptionId) {
334
+ var id = _uuid(subscriptionId, "subscription_id");
335
+ var row = await _getSubscriptionRaw(id);
336
+ if (!row) return null;
337
+ return _maybeExpireGrace(row, nowFn());
338
+ },
339
+
340
+ listForOwner: async function (listOpts) {
341
+ if (!listOpts || typeof listOpts !== "object") {
342
+ throw new TypeError("webhookSubscriptions.listForOwner: input object required");
343
+ }
344
+ var ownerType = _ownerType(listOpts.owner_type);
345
+ var ownerId = _ownerId(listOpts.owner_id);
346
+ var r = await query(
347
+ "SELECT * FROM webhook_subscriptions " +
348
+ "WHERE owner_type = ?1 AND owner_id = ?2 " +
349
+ "ORDER BY created_at DESC, id DESC",
350
+ [ownerType, ownerId],
351
+ );
352
+ var now = nowFn();
353
+ return r.rows.map(function (row) { return _maybeExpireGrace(row, now); });
354
+ },
355
+
356
+ // Fan-out helper — the delivery primitive calls this at emit time
357
+ // to build the subscription set whose event_types_json contains
358
+ // either the wildcard `*` or the literal event_type being sent.
359
+ // Returns only active subscriptions (paused / inactive rows do
360
+ // NOT receive deliveries). Returns the rows in insertion order
361
+ // so a multi-subscription receiver sees a stable fan-out shape.
362
+ subscriptionsForEvent: async function (eventType) {
363
+ if (typeof eventType !== "string" || eventType.length === 0) {
364
+ throw new TypeError("webhookSubscriptions.subscriptionsForEvent: eventType must be a non-empty string");
365
+ }
366
+ // Don't validate against EVENT_TYPE_RE here — operators may emit
367
+ // a custom event type from a future primitive and we'd rather
368
+ // route it through subscribed receivers (whose subscription was
369
+ // already validated at subscribe time) than reject the emit.
370
+ // The cheap shape gate (non-empty string) is enough to keep the
371
+ // SQL safe; the JSON-membership match below is exact-string.
372
+ var r = await query(
373
+ "SELECT * FROM webhook_subscriptions WHERE active = 1",
374
+ [],
375
+ );
376
+ var out = [];
377
+ var now = nowFn();
378
+ for (var i = 0; i < r.rows.length; i += 1) {
379
+ var row = r.rows[i];
380
+ var list;
381
+ try { list = JSON.parse(row.event_types_json); }
382
+ catch (_e) { continue; } // allow:empty-catch-swallow — drop-silent: a malformed JSON cell never reached us through `subscribe()`, but if the operator manually edited the DB we skip the row rather than crash the fan-out
383
+ if (!Array.isArray(list)) continue;
384
+ var match = false;
385
+ for (var j = 0; j < list.length; j += 1) {
386
+ if (list[j] === WILDCARD_EVENT || list[j] === eventType) {
387
+ match = true;
388
+ break;
389
+ }
390
+ }
391
+ if (match) out.push(_maybeExpireGrace(row, now));
392
+ }
393
+ return out;
394
+ },
395
+
396
+ pauseSubscription: async function (subscriptionId) {
397
+ var id = _uuid(subscriptionId, "subscription_id");
398
+ var current = await _getSubscriptionRaw(id);
399
+ if (!current) return null;
400
+ var ts = nowFn();
401
+ var prevHash = current.signing_secret_previous_hash;
402
+ var rotatedAt = current.signing_secret_rotated_at;
403
+ if (prevHash != null && rotatedAt != null &&
404
+ (ts - Number(rotatedAt)) >= ROTATION_GRACE_MS) {
405
+ prevHash = null;
406
+ rotatedAt = null;
407
+ }
408
+ await query(
409
+ "UPDATE webhook_subscriptions SET active = 0, paused_at = ?1, " +
410
+ "signing_secret_previous_hash = ?2, signing_secret_rotated_at = ?3, " +
411
+ "updated_at = ?1 WHERE id = ?4",
412
+ [ts, prevHash, rotatedAt, id],
413
+ );
414
+ return await _getSubscriptionRaw(id);
415
+ },
416
+
417
+ resumeSubscription: async function (subscriptionId) {
418
+ var id = _uuid(subscriptionId, "subscription_id");
419
+ var current = await _getSubscriptionRaw(id);
420
+ if (!current) return null;
421
+ var ts = nowFn();
422
+ var prevHash = current.signing_secret_previous_hash;
423
+ var rotatedAt = current.signing_secret_rotated_at;
424
+ if (prevHash != null && rotatedAt != null &&
425
+ (ts - Number(rotatedAt)) >= ROTATION_GRACE_MS) {
426
+ prevHash = null;
427
+ rotatedAt = null;
428
+ }
429
+ await query(
430
+ "UPDATE webhook_subscriptions SET active = 1, paused_at = NULL, " +
431
+ "signing_secret_previous_hash = ?1, signing_secret_rotated_at = ?2, " +
432
+ "updated_at = ?3 WHERE id = ?4",
433
+ [prevHash, rotatedAt, ts, id],
434
+ );
435
+ return await _getSubscriptionRaw(id);
436
+ },
437
+
438
+ // Patch-style update — only ALLOWED_UPDATE_COLUMNS can be set.
439
+ // owner_type / owner_id are immutable post-subscribe (changing the
440
+ // owner would orphan downstream receivers + invalidate auth on the
441
+ // app surface). Signing secret rotation goes through rotateSecret;
442
+ // active flag goes through pause / resume.
443
+ update: async function (subscriptionId, patch) {
444
+ var id = _uuid(subscriptionId, "subscription_id");
445
+ if (!patch || typeof patch !== "object") {
446
+ throw new TypeError("webhookSubscriptions.update: patch object required");
447
+ }
448
+ var keys = Object.keys(patch);
449
+ if (!keys.length) {
450
+ throw new TypeError("webhookSubscriptions.update: patch must contain at least one column");
451
+ }
452
+ for (var i = 0; i < keys.length; i += 1) {
453
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
454
+ throw new TypeError("webhookSubscriptions.update: column '" + keys[i] + "' not updatable");
455
+ }
456
+ }
457
+
458
+ var current = await _getSubscriptionRaw(id);
459
+ if (!current) return null;
460
+
461
+ var sets = [];
462
+ var params = [];
463
+ var idx = 1;
464
+ function _set(col, val) {
465
+ sets.push(col + " = ?" + idx);
466
+ params.push(val);
467
+ idx += 1;
468
+ }
469
+ if (Object.prototype.hasOwnProperty.call(patch, "endpoint_url")) {
470
+ _set("endpoint_url", _endpointUrl(patch.endpoint_url));
471
+ }
472
+ if (Object.prototype.hasOwnProperty.call(patch, "event_types")) {
473
+ var nextTypes = _eventTypesArray(patch.event_types);
474
+ _set("event_types_json", JSON.stringify(nextTypes));
475
+ }
476
+ if (Object.prototype.hasOwnProperty.call(patch, "name")) {
477
+ _set("name", _name(patch.name));
478
+ }
479
+
480
+ var ts = nowFn();
481
+ // Lazy grace-expiry — if the previous hash is past its window,
482
+ // clear it as part of this mutation so the operator doesn't see
483
+ // a longer-than-stated grace via direct row inspection.
484
+ if (current.signing_secret_previous_hash != null &&
485
+ current.signing_secret_rotated_at != null &&
486
+ (ts - Number(current.signing_secret_rotated_at)) >= ROTATION_GRACE_MS) {
487
+ _set("signing_secret_previous_hash", null);
488
+ _set("signing_secret_rotated_at", null);
489
+ }
490
+ _set("updated_at", ts);
491
+ params.push(id);
492
+ var sql = "UPDATE webhook_subscriptions SET " + sets.join(", ") +
493
+ " WHERE id = ?" + idx;
494
+ await query(sql, params);
495
+ return await _getSubscriptionRaw(id);
496
+ },
497
+
498
+ unsubscribe: async function (subscriptionId) {
499
+ var id = _uuid(subscriptionId, "subscription_id");
500
+ var r = await query(
501
+ "DELETE FROM webhook_subscriptions WHERE id = ?1", [id]
502
+ );
503
+ return r.rowCount > 0;
504
+ },
505
+
506
+ // Rotate the signing secret — generate a new plaintext, hash it
507
+ // into `signing_secret_hash`, preserve the prior hash in
508
+ // `signing_secret_previous_hash` with the rotation timestamp.
509
+ // Returns the new plaintext ONCE; the framework never reveals it
510
+ // again. The grace window means the delivery primitive can
511
+ // verify with EITHER hash for the next 24h — receivers rolling
512
+ // forward to the new secret don't see a hard cutover.
513
+ rotateSecret: async function (subscriptionId) {
514
+ var id = _uuid(subscriptionId, "subscription_id");
515
+ var current = await _getSubscriptionRaw(id);
516
+ if (!current) return null;
517
+ var plaintext = _generateSecret();
518
+ var newHash = _hashSecret(plaintext);
519
+ var ts = nowFn();
520
+ await query(
521
+ "UPDATE webhook_subscriptions SET signing_secret_hash = ?1, " +
522
+ "signing_secret_previous_hash = ?2, signing_secret_rotated_at = ?3, " +
523
+ "updated_at = ?3 WHERE id = ?4",
524
+ [newHash, current.signing_secret_hash, ts, id],
525
+ );
526
+ var row = await _getSubscriptionRaw(id);
527
+ return {
528
+ subscription: row,
529
+ signing_secret: plaintext,
530
+ };
531
+ },
532
+
533
+ // Operator-callable + scheduler-callable — sweeps any row whose
534
+ // rotation grace has expired and clears the previous hash + the
535
+ // rotation timestamp. The lazy expiry on read/mutation covers the
536
+ // common case; this sweep is for deployments wiring a periodic
537
+ // task to keep the at-rest schema tidy.
538
+ expireRotationGrace: async function (procOpts) {
539
+ var now = (procOpts && typeof procOpts.now === "number") ? procOpts.now : nowFn();
540
+ var cutoff = now - ROTATION_GRACE_MS;
541
+ await query(
542
+ "UPDATE webhook_subscriptions SET signing_secret_previous_hash = NULL, " +
543
+ "signing_secret_rotated_at = NULL, updated_at = ?1 " +
544
+ "WHERE signing_secret_previous_hash IS NOT NULL " +
545
+ "AND signing_secret_rotated_at IS NOT NULL " +
546
+ "AND signing_secret_rotated_at <= ?2",
547
+ [now, cutoff],
548
+ );
549
+ },
550
+ };
551
+ }
552
+
553
+ module.exports = {
554
+ create: create,
555
+ OWNER_TYPES: OWNER_TYPES.slice(),
556
+ MAX_OWNER_ID_LEN: MAX_OWNER_ID_LEN,
557
+ MAX_NAME_LEN: MAX_NAME_LEN,
558
+ MAX_EVENT_TYPES: MAX_EVENT_TYPES,
559
+ MAX_EVENT_TYPE_LEN: MAX_EVENT_TYPE_LEN,
560
+ MAX_ENDPOINT_URL_LEN: MAX_ENDPOINT_URL_LEN,
561
+ SECRET_BYTES: SECRET_BYTES,
562
+ SECRET_NAMESPACE: SECRET_NAMESPACE,
563
+ ROTATION_GRACE_MS: ROTATION_GRACE_MS,
564
+ WILDCARD_EVENT: WILDCARD_EVENT,
565
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {