@blamejs/blamejs-shop 0.4.16 → 0.4.18

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/lib/index.js CHANGED
@@ -124,6 +124,7 @@ Object.assign(module.exports, {
124
124
  cartBulkOps: require("./cart-bulk-ops"),
125
125
  carrierRates: require("./carrier-rates"),
126
126
  operatorAuditLog: require("./operator-audit-log"),
127
+ operatorAccounts: require("./operator-accounts"),
127
128
  cmsBlocks: require("./cms-blocks"),
128
129
  giftCardLedger: require("./gift-card-ledger"),
129
130
  discountAnalytics: require("./discount-analytics"),
@@ -0,0 +1,543 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorAccounts
4
+ * @title Operator accounts — the staff person who signs in to the
5
+ * admin console, holding their OWN credential
6
+ *
7
+ * @intro
8
+ * Today a single shared `ADMIN_API_KEY` guards the whole admin
9
+ * console. This primitive adds per-operator identity: several humans
10
+ * can run the shop with distinct logins and distinct roles, and the
11
+ * audit trail names WHO did each thing. `ADMIN_API_KEY` keeps working
12
+ * as the bootstrap / break-glass credential mapped to the owner role,
13
+ * so an upgrade never locks the operator out — and with zero rows in
14
+ * `operator_accounts` the console behaves byte-for-byte as it did
15
+ * before.
16
+ *
17
+ * An operator account holds:
18
+ *
19
+ * - `email` (display) + `email_hash` (login lookup key). The hash
20
+ * is `namespaceHash("operator-email", canonical-email)` so a
21
+ * database dump never carries a plaintext login index.
22
+ * - `password_hash` — an Argon2id PHC string from the vendored
23
+ * `b.auth.password.hash`. The plaintext never reaches storage;
24
+ * `b.auth.password.verify` does the constant-time check.
25
+ * - `api_key_hash` (optional) — the namespaceHash of a per-operator
26
+ * bearer token (32-byte CSPRNG draw, base64url). The plaintext is
27
+ * returned ONCE at mint time and never stored. The verify path
28
+ * runs `b.crypto.timingSafeEqual` so a wrong key and an unknown
29
+ * operator are indistinguishable on the wire.
30
+ * - `role` — one of `owner` | `manager` | `viewer` (the v1
31
+ * built-in role set; see `lib/operator-roles.js` for the
32
+ * operator-authored custom-role surface that layers on top).
33
+ * - `status` — `active` | `disabled`. A disabled operator's
34
+ * password + API key stop authenticating immediately.
35
+ *
36
+ * Surface:
37
+ *
38
+ * - createAccount({ email, display_name, password, role,
39
+ * created_by, mint_api_key? })
40
+ * Hash the password (Argon2id), optionally mint a per-operator
41
+ * API key, insert the row. Returns the public account shape;
42
+ * when `mint_api_key` is set the freshly-minted plaintext key
43
+ * rides back as `api_key` (the ONLY time it is ever visible).
44
+ *
45
+ * - verifyPassword({ email, password })
46
+ * Resolve the account by email hash, refuse non-active rows,
47
+ * and run `b.auth.password.verify`. Returns the public account on a
48
+ * match, `null` on every refuse path (unknown email, disabled,
49
+ * wrong password) — no oracle distinguishing the three.
50
+ *
51
+ * - verifyApiKey(plaintext)
52
+ * Resolve the account by the bearer token's namespaceHash, run
53
+ * a timing-safe compare against the stored hash, refuse
54
+ * non-active rows. Returns the public account on a match,
55
+ * `null` otherwise. Burns the same compare work on a miss as a
56
+ * hit so the latency profile matches.
57
+ *
58
+ * - getById(id) / getByEmail(email)
59
+ * Read the public account shape (never the hashes).
60
+ *
61
+ * - listAccounts({ status?, limit? })
62
+ * Enumerate accounts, newest-first.
63
+ *
64
+ * - setStatus({ id, status, actor_id })
65
+ * Flip `active` <-> `disabled`. Disabling is the v1 way to
66
+ * revoke an operator's access (the row + its audit grain
67
+ * survive). Idempotent.
68
+ *
69
+ * - setRole({ id, role, actor_id })
70
+ * Change an operator's built-in role.
71
+ *
72
+ * - rotateApiKey({ id, actor_id })
73
+ * Mint a fresh per-operator bearer token, replacing any prior
74
+ * one. Returns the new plaintext once.
75
+ *
76
+ * Composition:
77
+ * - `b.auth.password.hash` / `b.auth.password.verify` — Argon2id, OWASP-2026
78
+ * floor params. The single password primitive; never hand-rolled.
79
+ * - `b.crypto.generateBytes` / `b.crypto.toBase64Url` — API-key
80
+ * plaintext (32-byte CSPRNG draw).
81
+ * - `b.crypto.namespaceHash` — email-hash + api-key-hash keying.
82
+ * - `b.crypto.timingSafeEqual` — constant-time API-key compare.
83
+ * - `b.guardEmail` — strict email validation + canonicalization.
84
+ * - `b.uuid.v7` — account id (sorts by creation time).
85
+ * - `operatorAuditLog` (opt) — chained-hash audit of every account
86
+ * mutation when wired.
87
+ *
88
+ * Storage: `migrations-d1/0213_operator_accounts.sql`.
89
+ *
90
+ * @primitive operatorAccounts
91
+ * @related operatorRoles, operatorSessions, operatorAuditLog,
92
+ * b.password, b.crypto, b.guardEmail, b.uuid
93
+ */
94
+
95
+ var EMAIL_NAMESPACE = "operator-email";
96
+ var API_KEY_NAMESPACE = "operator-api-key";
97
+ var API_KEY_BYTES = 32;
98
+
99
+ var MAX_EMAIL_LEN = 320;
100
+ var MAX_DISPLAY_NAME_LEN = 128;
101
+ var MIN_PASSWORD_LEN = 12;
102
+ var MAX_PASSWORD_BYTES = 4096; // Argon2id plaintext ceiling
103
+ var MAX_LIST_LIMIT = 200;
104
+
105
+ var ROLES = Object.freeze(["owner", "manager", "viewer"]);
106
+ var STATUS = Object.freeze(["active", "disabled"]);
107
+
108
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
109
+
110
+ var b = require("./vendor/blamejs");
111
+
112
+ // ---- validators ---------------------------------------------------------
113
+
114
+ function _uuid(s, label) {
115
+ try { return b.guardUuid.sanitize(s, { profile: "strict" }); }
116
+ catch (e) { throw new TypeError("operatorAccounts: " + label + " — " + (e && e.message || "invalid UUID")); }
117
+ }
118
+
119
+ function _actorId(s, label) {
120
+ // The bootstrap / break-glass actor is the sentinel "owner" string
121
+ // (ADMIN_API_KEY-authed, not a UUID); operator actors are UUIDs.
122
+ // Accept either shape so the bootstrap path can record itself.
123
+ if (typeof s !== "string" || !s.length || s.length > 128) {
124
+ throw new TypeError("operatorAccounts: " + (label || "actor_id") + " must be a non-empty string (<= 128 chars)");
125
+ }
126
+ if (CONTROL_BYTE_RE.test(s)) {
127
+ throw new TypeError("operatorAccounts: " + (label || "actor_id") + " must not contain control bytes");
128
+ }
129
+ return s;
130
+ }
131
+
132
+ function _normalizeEmail(input) {
133
+ if (typeof input !== "string" || !input.length || input.length > MAX_EMAIL_LEN) {
134
+ throw new TypeError("operatorAccounts: email must be a non-empty string (<= " + MAX_EMAIL_LEN + " chars)");
135
+ }
136
+ var guardEmail = b.guardEmail;
137
+ var report;
138
+ try {
139
+ report = guardEmail.validate(input, { profile: "strict" });
140
+ } catch (e) {
141
+ throw new TypeError("operatorAccounts: email — " + (e && e.message || "invalid email"));
142
+ }
143
+ if (!report || report.ok === false) {
144
+ var first = (report && report.issues && report.issues[0]) || {};
145
+ throw new TypeError("operatorAccounts: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
146
+ }
147
+ var canonical;
148
+ try {
149
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
150
+ } catch (e) {
151
+ throw new TypeError("operatorAccounts: email — " + (e && e.message || "refused"));
152
+ }
153
+ var at = canonical.lastIndexOf("@");
154
+ if (at !== -1) {
155
+ canonical = canonical.slice(0, at) + "@" + canonical.slice(at + 1).toLowerCase();
156
+ }
157
+ return canonical;
158
+ }
159
+
160
+ function _displayName(s) {
161
+ if (typeof s !== "string" || !s.length || s.length > MAX_DISPLAY_NAME_LEN) {
162
+ throw new TypeError("operatorAccounts: display_name must be a non-empty string (<= " + MAX_DISPLAY_NAME_LEN + " chars)");
163
+ }
164
+ if (CONTROL_BYTE_RE.test(s)) {
165
+ throw new TypeError("operatorAccounts: display_name must not contain control bytes");
166
+ }
167
+ return s;
168
+ }
169
+
170
+ function _password(s) {
171
+ if (typeof s !== "string" || s.length < MIN_PASSWORD_LEN) {
172
+ throw new TypeError("operatorAccounts: password must be a string of at least " + MIN_PASSWORD_LEN + " characters");
173
+ }
174
+ if (Buffer.byteLength(s, "utf8") > MAX_PASSWORD_BYTES) {
175
+ throw new TypeError("operatorAccounts: password exceeds " + MAX_PASSWORD_BYTES + " bytes");
176
+ }
177
+ return s;
178
+ }
179
+
180
+ function _role(s) {
181
+ if (typeof s !== "string" || ROLES.indexOf(s) === -1) {
182
+ throw new TypeError("operatorAccounts: role must be one of " + ROLES.join(", "));
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _statusValue(s) {
188
+ if (typeof s !== "string" || STATUS.indexOf(s) === -1) {
189
+ throw new TypeError("operatorAccounts: status must be one of " + STATUS.join(", "));
190
+ }
191
+ return s;
192
+ }
193
+
194
+ function _bool(v, label) {
195
+ if (v == null) return false;
196
+ if (v === true || v === false) return v;
197
+ throw new TypeError("operatorAccounts: " + label + " must be a boolean");
198
+ }
199
+
200
+ function _limit(n) {
201
+ if (n == null) return 100;
202
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
203
+ throw new TypeError("operatorAccounts: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
204
+ }
205
+ return n;
206
+ }
207
+
208
+ // ---- row hydration ------------------------------------------------------
209
+ //
210
+ // The public shape NEVER carries password_hash / api_key_hash / the raw
211
+ // email_hash — only the fields a console screen or the auth resolver
212
+ // needs.
213
+
214
+ function _hydrate(r) {
215
+ if (!r) return null;
216
+ return {
217
+ id: r.id,
218
+ email: r.email,
219
+ display_name: r.display_name,
220
+ role: r.role,
221
+ status: r.status,
222
+ has_api_key: r.api_key_hash != null,
223
+ created_by: r.created_by,
224
+ created_at: Number(r.created_at),
225
+ updated_at: Number(r.updated_at),
226
+ disabled_at: r.disabled_at == null ? null : Number(r.disabled_at),
227
+ disabled_by: r.disabled_by == null ? null : r.disabled_by,
228
+ };
229
+ }
230
+
231
+ // ---- factory ------------------------------------------------------------
232
+
233
+ function create(opts) {
234
+ opts = opts || {};
235
+ var query = opts.query;
236
+ if (!query) {
237
+ query = function (sql, params) { return b.externalDb.query(sql, params); };
238
+ }
239
+ // Optional chained-audit peer. Duck-typed `.record(...)` so the tests
240
+ // can stub it without importing the full primitive.
241
+ var operatorAuditLog = opts.operatorAuditLog || null;
242
+
243
+ // Per-factory monotonic clock — two account mutations in the same
244
+ // wall-clock millisecond still carry strictly-increasing timestamps so
245
+ // the audit timeline sorts deterministically.
246
+ var _lastTs = 0;
247
+ function _now() {
248
+ var wall = Date.now();
249
+ if (wall > _lastTs) _lastTs = wall;
250
+ else _lastTs += 1;
251
+ return _lastTs;
252
+ }
253
+
254
+ // A real Argon2id PHC hash burned against a presented password when no
255
+ // account matches the email, so the unknown-email path pays the same
256
+ // memory-hard cost as a known-email-wrong-password path — closing the
257
+ // timing oracle on account existence. Computed once, lazily, on the
258
+ // first unknown-email verify and memoized thereafter.
259
+ var _dummyHashPromise = null;
260
+ function _dummyHash() {
261
+ if (!_dummyHashPromise) {
262
+ _dummyHashPromise = b.auth.password.hash("operator-accounts-timing-equalizer");
263
+ }
264
+ return _dummyHashPromise;
265
+ }
266
+
267
+ function _hashEmail(email) {
268
+ return b.crypto.namespaceHash(EMAIL_NAMESPACE, email);
269
+ }
270
+ function _hashApiKey(plaintext) {
271
+ return b.crypto.namespaceHash(API_KEY_NAMESPACE, plaintext);
272
+ }
273
+ function _mintApiKey() {
274
+ var plaintext = b.crypto.toBase64Url(b.crypto.generateBytes(API_KEY_BYTES));
275
+ return { plaintext: plaintext, hash: _hashApiKey(plaintext) };
276
+ }
277
+
278
+ async function _audit(action, actorId, accountId, before, after) {
279
+ if (!operatorAuditLog || typeof operatorAuditLog.record !== "function") return;
280
+ await operatorAuditLog.record({
281
+ actor_type: "operator",
282
+ actor_id: actorId,
283
+ action: "operator_account." + action,
284
+ resource_kind: "operator_account",
285
+ resource_id: accountId,
286
+ before: before,
287
+ after: after,
288
+ });
289
+ }
290
+
291
+ async function _rowById(id) {
292
+ return (await query(
293
+ "SELECT * FROM operator_accounts WHERE id = ?1 LIMIT 1",
294
+ [id],
295
+ )).rows[0] || null;
296
+ }
297
+
298
+ // ---- createAccount -------------------------------------------------
299
+
300
+ async function createAccount(input) {
301
+ if (!input || typeof input !== "object") {
302
+ throw new TypeError("operatorAccounts.createAccount: input object required");
303
+ }
304
+ var email = _normalizeEmail(input.email);
305
+ var displayName = _displayName(input.display_name);
306
+ var password = _password(input.password);
307
+ var role = _role(input.role);
308
+ var createdBy = _actorId(input.created_by, "created_by");
309
+ var mintApiKey = _bool(input.mint_api_key, "mint_api_key");
310
+
311
+ var emailHash = _hashEmail(email);
312
+ var existing = (await query(
313
+ "SELECT id FROM operator_accounts WHERE email_hash = ?1 LIMIT 1",
314
+ [emailHash],
315
+ )).rows[0];
316
+ if (existing) {
317
+ throw new TypeError("operatorAccounts.createAccount: an operator with that email already exists");
318
+ }
319
+
320
+ var passwordHash = await b.auth.password.hash(password);
321
+ var apiKey = null;
322
+ var apiKeyHash = null;
323
+ if (mintApiKey) {
324
+ var minted = _mintApiKey();
325
+ apiKey = minted.plaintext;
326
+ apiKeyHash = minted.hash;
327
+ }
328
+
329
+ var id = b.uuid.v7();
330
+ var ts = _now();
331
+ await query(
332
+ "INSERT INTO operator_accounts " +
333
+ "(id, email, email_hash, display_name, password_hash, api_key_hash, " +
334
+ " role, status, created_by, created_at, updated_at, disabled_at, disabled_by) " +
335
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, ?9, NULL, NULL)",
336
+ [id, email, emailHash, displayName, passwordHash, apiKeyHash, role, createdBy, ts],
337
+ );
338
+
339
+ await _audit("create", createdBy, id, null, { role: role, status: "active", has_api_key: !!apiKeyHash });
340
+
341
+ var account = _hydrate(await _rowById(id));
342
+ if (apiKey) account.api_key = apiKey;
343
+ return account;
344
+ }
345
+
346
+ // ---- verifyPassword ------------------------------------------------
347
+
348
+ async function verifyPassword(input) {
349
+ if (!input || typeof input !== "object") return null;
350
+ var email;
351
+ try { email = _normalizeEmail(input.email); }
352
+ catch (_e) { return null; }
353
+ if (typeof input.password !== "string" || !input.password.length) return null;
354
+
355
+ var row = (await query(
356
+ "SELECT * FROM operator_accounts WHERE email_hash = ?1 LIMIT 1",
357
+ [_hashEmail(email)],
358
+ )).rows[0];
359
+ if (!row) {
360
+ // Burn a real Argon2id verify against a memoized dummy hash so an
361
+ // unknown email pays the same memory-hard cost as a known one — no
362
+ // timing oracle on account existence. The result is discarded.
363
+ try { await b.auth.password.verify(await _dummyHash(), input.password); }
364
+ catch (_e) { /* drop — verify tolerates malformed input */ }
365
+ return null;
366
+ }
367
+ var ok = false;
368
+ try { ok = await b.auth.password.verify(row.password_hash, input.password); }
369
+ catch (_e) { ok = false; }
370
+ if (!ok) return null;
371
+ if (row.status !== "active") return null;
372
+ return _hydrate(row);
373
+ }
374
+
375
+ // ---- verifyApiKey --------------------------------------------------
376
+
377
+ async function verifyApiKey(plaintext) {
378
+ if (typeof plaintext !== "string" || !plaintext.length) return null;
379
+ var hash = _hashApiKey(plaintext);
380
+ var row = (await query(
381
+ "SELECT * FROM operator_accounts WHERE api_key_hash = ?1 LIMIT 1",
382
+ [hash],
383
+ )).rows[0];
384
+ if (!row || row.api_key_hash == null) {
385
+ // Burn the same compare work as the hit path so the latency
386
+ // profile matches between miss and hit.
387
+ b.crypto.timingSafeEqual(hash, hash);
388
+ return null;
389
+ }
390
+ var matched = b.crypto.timingSafeEqual(row.api_key_hash, hash);
391
+ if (!matched) return null;
392
+ if (row.status !== "active") return null;
393
+ return _hydrate(row);
394
+ }
395
+
396
+ // ---- getById / getByEmail ------------------------------------------
397
+
398
+ async function getById(id) {
399
+ var clean = _uuid(id, "id");
400
+ return _hydrate(await _rowById(clean));
401
+ }
402
+
403
+ async function getByEmail(email) {
404
+ var canonical = _normalizeEmail(email);
405
+ var row = (await query(
406
+ "SELECT * FROM operator_accounts WHERE email_hash = ?1 LIMIT 1",
407
+ [_hashEmail(canonical)],
408
+ )).rows[0];
409
+ return _hydrate(row);
410
+ }
411
+
412
+ // ---- listAccounts --------------------------------------------------
413
+
414
+ async function listAccounts(listOpts) {
415
+ listOpts = listOpts || {};
416
+ if (typeof listOpts !== "object") {
417
+ throw new TypeError("operatorAccounts.listAccounts: opts must be an object");
418
+ }
419
+ var status = listOpts.status == null ? null : _statusValue(listOpts.status);
420
+ var limit = _limit(listOpts.limit);
421
+ var sql, params;
422
+ if (status != null) {
423
+ sql = "SELECT * FROM operator_accounts WHERE status = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2";
424
+ params = [status, limit];
425
+ } else {
426
+ sql = "SELECT * FROM operator_accounts ORDER BY created_at DESC, id DESC LIMIT ?1";
427
+ params = [limit];
428
+ }
429
+ var rows = (await query(sql, params)).rows;
430
+ var out = [];
431
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrate(rows[i]));
432
+ return out;
433
+ }
434
+
435
+ // ---- setStatus -----------------------------------------------------
436
+
437
+ async function setStatus(input) {
438
+ if (!input || typeof input !== "object") {
439
+ throw new TypeError("operatorAccounts.setStatus: input object required");
440
+ }
441
+ var id = _uuid(input.id, "id");
442
+ var status = _statusValue(input.status);
443
+ var actorId = _actorId(input.actor_id, "actor_id");
444
+
445
+ var current = await _rowById(id);
446
+ if (!current) {
447
+ throw new TypeError("operatorAccounts.setStatus: operator not found");
448
+ }
449
+ if (current.status === status) {
450
+ return _hydrate(current); // idempotent
451
+ }
452
+ var ts = _now();
453
+ if (status === "disabled") {
454
+ await query(
455
+ "UPDATE operator_accounts SET status = 'disabled', disabled_at = ?1, disabled_by = ?2, updated_at = ?1 WHERE id = ?3",
456
+ [ts, actorId, id],
457
+ );
458
+ } else {
459
+ await query(
460
+ "UPDATE operator_accounts SET status = 'active', disabled_at = NULL, disabled_by = NULL, updated_at = ?1 WHERE id = ?2",
461
+ [ts, id],
462
+ );
463
+ }
464
+ await _audit("set_status", actorId, id, { status: current.status }, { status: status });
465
+ return _hydrate(await _rowById(id));
466
+ }
467
+
468
+ // ---- setRole -------------------------------------------------------
469
+
470
+ async function setRole(input) {
471
+ if (!input || typeof input !== "object") {
472
+ throw new TypeError("operatorAccounts.setRole: input object required");
473
+ }
474
+ var id = _uuid(input.id, "id");
475
+ var role = _role(input.role);
476
+ var actorId = _actorId(input.actor_id, "actor_id");
477
+
478
+ var current = await _rowById(id);
479
+ if (!current) {
480
+ throw new TypeError("operatorAccounts.setRole: operator not found");
481
+ }
482
+ if (current.role === role) {
483
+ return _hydrate(current);
484
+ }
485
+ var ts = _now();
486
+ await query(
487
+ "UPDATE operator_accounts SET role = ?1, updated_at = ?2 WHERE id = ?3",
488
+ [role, ts, id],
489
+ );
490
+ await _audit("set_role", actorId, id, { role: current.role }, { role: role });
491
+ return _hydrate(await _rowById(id));
492
+ }
493
+
494
+ // ---- rotateApiKey --------------------------------------------------
495
+
496
+ async function rotateApiKey(input) {
497
+ if (!input || typeof input !== "object") {
498
+ throw new TypeError("operatorAccounts.rotateApiKey: input object required");
499
+ }
500
+ var id = _uuid(input.id, "id");
501
+ var actorId = _actorId(input.actor_id, "actor_id");
502
+
503
+ var current = await _rowById(id);
504
+ if (!current) {
505
+ throw new TypeError("operatorAccounts.rotateApiKey: operator not found");
506
+ }
507
+ var minted = _mintApiKey();
508
+ var ts = _now();
509
+ await query(
510
+ "UPDATE operator_accounts SET api_key_hash = ?1, updated_at = ?2 WHERE id = ?3",
511
+ [minted.hash, ts, id],
512
+ );
513
+ await _audit("rotate_api_key", actorId, id,
514
+ { has_api_key: current.api_key_hash != null }, { has_api_key: true });
515
+ var account = _hydrate(await _rowById(id));
516
+ account.api_key = minted.plaintext;
517
+ return account;
518
+ }
519
+
520
+ return {
521
+ ROLES: ROLES,
522
+ STATUS: STATUS,
523
+ createAccount: createAccount,
524
+ verifyPassword: verifyPassword,
525
+ verifyApiKey: verifyApiKey,
526
+ getById: getById,
527
+ getByEmail: getByEmail,
528
+ listAccounts: listAccounts,
529
+ setStatus: setStatus,
530
+ setRole: setRole,
531
+ rotateApiKey: rotateApiKey,
532
+ };
533
+ }
534
+
535
+ module.exports = {
536
+ create: create,
537
+ ROLES: ROLES,
538
+ STATUS: STATUS,
539
+ MIN_PASSWORD_LEN: MIN_PASSWORD_LEN,
540
+ MAX_DISPLAY_NAME_LEN: MAX_DISPLAY_NAME_LEN,
541
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
542
+ API_KEY_NAMESPACE: API_KEY_NAMESPACE,
543
+ };
@@ -90,6 +90,7 @@ var INTERNAL_BRIDGE_PATHS = [
90
90
  "/_/low-stock-alert",
91
91
  "/_/wishlist-alerts-sweep",
92
92
  "/_/wishlist-digest-sweep",
93
+ "/_/campaign-send-tick",
93
94
  "/_/customer-portal-expire",
94
95
  "/_/stale-order-reap",
95
96
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
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": {