@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.
- package/CHANGELOG.md +4 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -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