@blamejs/blamejs-shop 0.0.72 → 0.0.75

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,805 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.carrierAccounts
4
+ * @title Carrier accounts — per-operator API credentials for shipping
5
+ * carriers (UPS, FedEx, USPS, DHL, Canada Post, Royal Mail,
6
+ * Australia Post)
7
+ *
8
+ * @intro
9
+ * Every operator who prints a real shipping label needs a credentialed
10
+ * account on a carrier's API. This primitive holds those credentials
11
+ * at rest — but never in plaintext. On `defineAccount` the framework
12
+ * namespace-hashes every secret column
13
+ * (`account_number` / `api_key` / `api_secret` / `meter_number`)
14
+ * via `b.crypto.namespaceHash("carrier-account-<field>", raw)` and
15
+ * returns the plaintext bundle to the caller exactly once. The
16
+ * operator stores those plaintext values in their downstream label
17
+ * worker's secret store; subsequent reads of the row only ever see
18
+ * the hashes. The non-secret `ship_from_address_json` round-trips
19
+ * intact.
20
+ *
21
+ * Rotation:
22
+ *
23
+ * `rotateCredentials({ account_id })` mints a fresh `api_key` +
24
+ * `api_secret`, returns them to the caller exactly once, and slides
25
+ * the old `api_key_hash` into `api_key_previous_hash`. The row's
26
+ * status flips to `rotating` so `verifyCredentials` accepts EITHER
27
+ * hash for the next 24h (`ROTATION_GRACE_MS`). After that window
28
+ * the operator's worker is expected to have reloaded; the previous
29
+ * hash is no longer accepted and any caller still holding it gets
30
+ * a verify miss. Operators flip the row back to `active` by
31
+ * calling `defineAccount` again with the new label / address (a
32
+ * no-op semantic) — or simply leave it in `rotating`, since the
33
+ * primitive's verify accepts either hash during the grace and only
34
+ * the live hash after.
35
+ *
36
+ * Verification:
37
+ *
38
+ * `verifyCredentials({ account_id, plaintext_key })` hashes the
39
+ * supplied value under the `carrier-account-api-key` namespace and
40
+ * routes the hex digest through `b.crypto.timingSafeEqual` against
41
+ * `api_key_hash` (always) and `api_key_previous_hash` (only when
42
+ * the row is `rotating` AND `now - rotated_at` is within the 24h
43
+ * grace). The compare is constant-time so an attacker who can time
44
+ * the verify can't distinguish "wrong key" from "key matches
45
+ * previous but grace expired."
46
+ *
47
+ * Usage telemetry:
48
+ *
49
+ * `recordUsage({ account_id, operation, success, ms_elapsed })`
50
+ * appends to `carrier_usage_log`; `metricsForAccount({ account_id,
51
+ * from, to })` aggregates the window into
52
+ * `{ requests, successes, failures, success_rate, p50_ms, p95_ms,
53
+ * avg_ms }`. The percentile is computed against the sorted
54
+ * `ms_elapsed` column of the matched rows; the operator's
55
+ * observability surface reads from this without joining a separate
56
+ * event log.
57
+ *
58
+ * Composes:
59
+ * - `b.crypto.namespaceHash` — per-field SHA3-512 of every
60
+ * plaintext credential before
61
+ * storage.
62
+ * - `b.crypto.generateBytes` — 32-byte uniform draw rendered as
63
+ * URL-safe base64 (no padding) for
64
+ * the fresh api_key / api_secret on
65
+ * rotation.
66
+ * - `b.crypto.timingSafeEqual` — constant-time hex compare on
67
+ * verifyCredentials.
68
+ * - `b.guardUuid` — UUID-shape gate on every
69
+ * account_id at the entry point.
70
+ * - `b.uuid.v7` — row ids (lexicographic + monotonic
71
+ * so ties on created_at still sort
72
+ * deterministically).
73
+ *
74
+ * Surface:
75
+ * defineAccount({ carrier, account_number, api_key, api_secret?,
76
+ * meter_number?, account_label?, ship_from_address })
77
+ * getAccount(account_id)
78
+ * accountByCarrier({ carrier, label? })
79
+ * listAccounts({ carrier?, active_only? })
80
+ * rotateCredentials({ account_id })
81
+ * disableAccount({ account_id, reason })
82
+ * enableAccount({ account_id })
83
+ * verifyCredentials({ account_id, plaintext_key })
84
+ * recordUsage({ account_id, operation, success, ms_elapsed })
85
+ * metricsForAccount({ account_id, from, to })
86
+ *
87
+ * Storage:
88
+ * - `carrier_accounts` + `carrier_usage_log` (migration
89
+ * `0191_carrier_accounts.sql`).
90
+ *
91
+ * @primitive carrierAccounts
92
+ * @related b.crypto, b.guardUuid, b.uuid
93
+ */
94
+
95
+ var CARRIERS = Object.freeze([
96
+ "ups", "fedex", "usps", "dhl",
97
+ "canada_post", "royal_mail", "australia_post",
98
+ ]);
99
+
100
+ var STATUSES = Object.freeze(["active", "disabled", "rotating"]);
101
+
102
+ var ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
103
+
104
+ var NS_ACCOUNT_NUMBER = "carrier-account-account-number";
105
+ var NS_API_KEY = "carrier-account-api-key";
106
+ var NS_API_SECRET = "carrier-account-api-secret";
107
+ var NS_METER_NUMBER = "carrier-account-meter-number";
108
+
109
+ var SECRET_BYTE_LEN = 32;
110
+
111
+ var MAX_LABEL_LEN = 120;
112
+ var MAX_REASON_LEN = 280;
113
+ var MAX_OPERATION_LEN = 64;
114
+ var MAX_NORMALISED = 12;
115
+ var MIN_ACCOUNT_LEN = 1;
116
+ var MAX_ACCOUNT_LEN = 64;
117
+ var MIN_KEY_LEN = 8;
118
+ var MAX_KEY_LEN = 512;
119
+
120
+ // Control-byte / zero-width sweep on every operator-supplied label /
121
+ // reason / operation string. These columns surface on operator
122
+ // dashboards and inline into log lines — embedded control bytes are a
123
+ // slipping-class for header injection + visual spoofing.
124
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
125
+ var ZERO_WIDTH_RE = new RegExp(
126
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
127
+ );
128
+
129
+ // account_number alphabet — operators occasionally space-pad or
130
+ // hyphenate the printed form; we accept alnum + dash + space + dot and
131
+ // strip whitespace / dashes for the normalised display column. The
132
+ // alphabet is conservative so a typo'd punctuation byte is loud at the
133
+ // boundary rather than persisting silently.
134
+ var ACCOUNT_NUMBER_RE = /^[A-Za-z0-9][A-Za-z0-9 .-]{0,63}$/;
135
+
136
+ // api_key / api_secret alphabet — carrier API keys are routinely
137
+ // base64 / base64url / hex / dot-segmented JWTs. Conservative gate:
138
+ // printable ASCII excluding whitespace; the column itself never
139
+ // surfaces (only the hash does) so we don't lose much by being strict.
140
+ var API_KEY_RE = /^[\x21-\x7e]+$/;
141
+
142
+ var OPERATION_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
143
+
144
+ // Lazy framework handle — matches the pattern used by every other shop
145
+ // primitive; avoids the require cycle that would arise from importing
146
+ // `./index` at module-eval time.
147
+ var bShop;
148
+ function _b() {
149
+ if (!bShop) bShop = require("./index");
150
+ return bShop.framework;
151
+ }
152
+
153
+ // ---- monotonic clock ---------------------------------------------------
154
+ //
155
+ // Operator-driven rotations + disables can land in the same millisecond
156
+ // on fast machines (rotate immediately followed by recordUsage in a
157
+ // test, for instance). Bumping by 1ms on a tie keeps the timeline
158
+ // strictly increasing so a sort-by-timestamp read returns the events in
159
+ // the order they were issued.
160
+
161
+ var _lastTs = 0;
162
+ function _now() {
163
+ var t = Date.now();
164
+ if (t <= _lastTs) { t = _lastTs + 1; }
165
+ _lastTs = t;
166
+ return t;
167
+ }
168
+
169
+ // ---- validators --------------------------------------------------------
170
+
171
+ function _uuid(s, label) {
172
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
173
+ catch (e) {
174
+ throw new TypeError("carrier-accounts: " + label + " — " +
175
+ (e && e.message || "invalid UUID"));
176
+ }
177
+ }
178
+
179
+ function _carrier(s) {
180
+ if (typeof s !== "string" || CARRIERS.indexOf(s) === -1) {
181
+ throw new TypeError("carrier-accounts: carrier must be one of " +
182
+ CARRIERS.join(", ") + ", got " + JSON.stringify(s));
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _accountNumber(s) {
188
+ if (typeof s !== "string") {
189
+ throw new TypeError("carrier-accounts: account_number must be a string");
190
+ }
191
+ if (s.length < MIN_ACCOUNT_LEN || s.length > MAX_ACCOUNT_LEN) {
192
+ throw new TypeError("carrier-accounts: account_number must be " +
193
+ MIN_ACCOUNT_LEN + ".." + MAX_ACCOUNT_LEN + " characters");
194
+ }
195
+ if (!ACCOUNT_NUMBER_RE.test(s)) {
196
+ throw new TypeError("carrier-accounts: account_number must match alnum + space/dot/dash");
197
+ }
198
+ return s;
199
+ }
200
+
201
+ function _normaliseAccountNumber(s) {
202
+ // Strip whitespace + dashes + dots so the on-disk display column is
203
+ // a stable visible-digits string. CHECK(length <= 12) on storage
204
+ // refuses leaking more than the longest known carrier format.
205
+ var stripped = s.replace(/[\s.-]/g, "");
206
+ if (stripped.length === 0) {
207
+ throw new TypeError("carrier-accounts: account_number normalises to empty (only punctuation?)");
208
+ }
209
+ if (stripped.length > MAX_NORMALISED) {
210
+ // Visible display column caps at the longest known carrier format
211
+ // (DHL/USPS 9-10 digit). We keep the trailing N chars so the
212
+ // operator's UI shows the suffix they recognise.
213
+ stripped = stripped.slice(stripped.length - MAX_NORMALISED);
214
+ }
215
+ return stripped;
216
+ }
217
+
218
+ function _apiKey(s, label) {
219
+ label = label || "api_key";
220
+ if (typeof s !== "string") {
221
+ throw new TypeError("carrier-accounts: " + label + " must be a string");
222
+ }
223
+ if (s.length < MIN_KEY_LEN || s.length > MAX_KEY_LEN) {
224
+ throw new TypeError("carrier-accounts: " + label + " must be " +
225
+ MIN_KEY_LEN + ".." + MAX_KEY_LEN + " characters");
226
+ }
227
+ if (!API_KEY_RE.test(s)) {
228
+ throw new TypeError("carrier-accounts: " + label +
229
+ " must contain only printable ASCII excluding whitespace");
230
+ }
231
+ return s;
232
+ }
233
+
234
+ function _label(s) {
235
+ if (s == null) return null;
236
+ if (typeof s !== "string") {
237
+ throw new TypeError("carrier-accounts: account_label must be a string or null");
238
+ }
239
+ if (s.length === 0 || s.length > MAX_LABEL_LEN) {
240
+ throw new TypeError("carrier-accounts: account_label must be 1.." + MAX_LABEL_LEN + " characters");
241
+ }
242
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
243
+ throw new TypeError("carrier-accounts: account_label contains control / zero-width bytes");
244
+ }
245
+ return s;
246
+ }
247
+
248
+ function _reason(s) {
249
+ if (typeof s !== "string") {
250
+ throw new TypeError("carrier-accounts: reason must be a string");
251
+ }
252
+ var trimmed = s.trim();
253
+ if (!trimmed.length) {
254
+ throw new TypeError("carrier-accounts: reason must be non-empty after trim");
255
+ }
256
+ if (s.length > MAX_REASON_LEN) {
257
+ throw new TypeError("carrier-accounts: reason must be <= " + MAX_REASON_LEN + " characters");
258
+ }
259
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
260
+ throw new TypeError("carrier-accounts: reason contains control / zero-width bytes");
261
+ }
262
+ return s;
263
+ }
264
+
265
+ function _operation(s) {
266
+ if (typeof s !== "string") {
267
+ throw new TypeError("carrier-accounts: operation must be a string");
268
+ }
269
+ if (s.length === 0 || s.length > MAX_OPERATION_LEN) {
270
+ throw new TypeError("carrier-accounts: operation must be 1.." +
271
+ MAX_OPERATION_LEN + " characters");
272
+ }
273
+ if (!OPERATION_RE.test(s)) {
274
+ throw new TypeError("carrier-accounts: operation must match /^[a-z0-9][a-z0-9._:-]*$/");
275
+ }
276
+ return s;
277
+ }
278
+
279
+ function _msEpoch(n, label) {
280
+ if (!Number.isInteger(n) || n < 0) {
281
+ throw new TypeError("carrier-accounts: " + label +
282
+ " must be a non-negative integer (ms epoch)");
283
+ }
284
+ return n;
285
+ }
286
+
287
+ function _nonNegInt(n, label) {
288
+ if (!Number.isInteger(n) || n < 0) {
289
+ throw new TypeError("carrier-accounts: " + label +
290
+ " must be a non-negative integer");
291
+ }
292
+ return n;
293
+ }
294
+
295
+ function _bool(v, label) {
296
+ if (typeof v !== "boolean") {
297
+ throw new TypeError("carrier-accounts: " + label + " must be a boolean");
298
+ }
299
+ return v;
300
+ }
301
+
302
+ function _shipFromAddress(a) {
303
+ if (!a || typeof a !== "object" || Array.isArray(a)) {
304
+ throw new TypeError("carrier-accounts: ship_from_address must be an object");
305
+ }
306
+ if (typeof a.line1 !== "string" || a.line1.length === 0 || a.line1.length > 200) {
307
+ throw new TypeError("carrier-accounts: ship_from_address.line1 must be a non-empty string ≤ 200 chars");
308
+ }
309
+ if (typeof a.city !== "string" || a.city.length === 0 || a.city.length > 100) {
310
+ throw new TypeError("carrier-accounts: ship_from_address.city must be a non-empty string ≤ 100 chars");
311
+ }
312
+ if (typeof a.country !== "string" || a.country.length !== 2) {
313
+ throw new TypeError("carrier-accounts: ship_from_address.country must be a 2-letter ISO code");
314
+ }
315
+ // JSON round-trip check — operator may add region / postal /
316
+ // company / phone. We don't enforce shape beyond the three required
317
+ // bits; the address_json contract is "round-trips cleanly through
318
+ // JSON.parse(JSON.stringify(...))".
319
+ try { JSON.parse(JSON.stringify(a)); }
320
+ catch (_e) { throw new TypeError("carrier-accounts: ship_from_address must be JSON-serialisable"); }
321
+ return a;
322
+ }
323
+
324
+ // ---- secret generation + hashing ---------------------------------------
325
+
326
+ // 32 bytes -> 43 chars base64url (no padding). Used for rotated api_key
327
+ // / api_secret. We render manually so the primitive doesn't depend on
328
+ // a Buffer-side flag rename across Node minors.
329
+ function _generateSecret() {
330
+ var buf = _b().crypto.generateBytes(SECRET_BYTE_LEN);
331
+ return buf.toString("base64")
332
+ .replace(/\+/g, "-")
333
+ .replace(/\//g, "_")
334
+ .replace(/=+$/, "");
335
+ }
336
+
337
+ function _hashAccountNumber(plain) {
338
+ return _b().crypto.namespaceHash(NS_ACCOUNT_NUMBER, plain);
339
+ }
340
+ function _hashApiKey(plain) {
341
+ return _b().crypto.namespaceHash(NS_API_KEY, plain);
342
+ }
343
+ function _hashApiSecret(plain) {
344
+ return _b().crypto.namespaceHash(NS_API_SECRET, plain);
345
+ }
346
+ function _hashMeterNumber(plain) {
347
+ return _b().crypto.namespaceHash(NS_METER_NUMBER, plain);
348
+ }
349
+
350
+ // ---- percentile --------------------------------------------------------
351
+
352
+ function _percentile(sorted, p) {
353
+ if (!sorted.length) return 0;
354
+ // Nearest-rank percentile against the sorted array. p in [0..1].
355
+ var idx = Math.ceil(p * sorted.length) - 1;
356
+ if (idx < 0) idx = 0;
357
+ if (idx >= sorted.length) idx = sorted.length - 1;
358
+ return sorted[idx];
359
+ }
360
+
361
+ // ---- factory -----------------------------------------------------------
362
+
363
+ function create(opts) {
364
+ opts = opts || {};
365
+ var query = opts.query;
366
+ if (!query) {
367
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
368
+ }
369
+
370
+ async function _getRaw(id) {
371
+ var r = await query("SELECT * FROM carrier_accounts WHERE id = ?1", [id]);
372
+ return r.rows[0] || null;
373
+ }
374
+
375
+ function _project(row) {
376
+ if (!row) return null;
377
+ return {
378
+ id: row.id,
379
+ carrier: row.carrier,
380
+ account_label: row.account_label,
381
+ account_number_hash: row.account_number_hash,
382
+ account_number_normalised: row.account_number_normalised,
383
+ api_key_hash: row.api_key_hash,
384
+ api_key_previous_hash: row.api_key_previous_hash,
385
+ api_secret_hash: row.api_secret_hash,
386
+ meter_number_hash: row.meter_number_hash,
387
+ ship_from_address: _parseAddressJSON(row.ship_from_address_json),
388
+ status: row.status,
389
+ disabled_reason: row.disabled_reason,
390
+ disabled_at: row.disabled_at != null ? Number(row.disabled_at) : null,
391
+ rotated_at: row.rotated_at != null ? Number(row.rotated_at) : null,
392
+ created_at: Number(row.created_at),
393
+ updated_at: Number(row.updated_at),
394
+ active: row.status === "active",
395
+ };
396
+ }
397
+
398
+ function _parseAddressJSON(raw) {
399
+ if (raw == null) return null;
400
+ try {
401
+ var parsed = JSON.parse(raw);
402
+ return (parsed && typeof parsed === "object") ? parsed : null;
403
+ } catch (_e) {
404
+ // Drop-silent — the write path always serialises a validated
405
+ // object, so a parse failure means the row was hand-edited.
406
+ // Surfacing as null keeps the caller's downstream code from
407
+ // crashing on a corrupted row.
408
+ return null;
409
+ }
410
+ }
411
+
412
+ return {
413
+
414
+ CARRIERS: CARRIERS,
415
+ STATUSES: STATUSES,
416
+ ROTATION_GRACE_MS: ROTATION_GRACE_MS,
417
+ NS_ACCOUNT_NUMBER: NS_ACCOUNT_NUMBER,
418
+ NS_API_KEY: NS_API_KEY,
419
+ NS_API_SECRET: NS_API_SECRET,
420
+ NS_METER_NUMBER: NS_METER_NUMBER,
421
+
422
+ // Register a carrier account. Hashes every secret column at the
423
+ // boundary; the plaintext returns to the caller exactly once on
424
+ // the return value (the operator hands it to their label worker's
425
+ // secret store). Subsequent reads only see the hash. The
426
+ // (carrier, account_label) tuple is the natural lookup key —
427
+ // re-defining the same pair updates the row in place.
428
+ defineAccount: async function (input) {
429
+ if (!input || typeof input !== "object") {
430
+ throw new TypeError("carrier-accounts.defineAccount: input object required");
431
+ }
432
+ var carrier = _carrier(input.carrier);
433
+ var rawAcct = _accountNumber(input.account_number);
434
+ var normAcct = _normaliseAccountNumber(rawAcct);
435
+ var apiKey = _apiKey(input.api_key, "api_key");
436
+ var apiSecret = null;
437
+ if (input.api_secret != null) apiSecret = _apiKey(input.api_secret, "api_secret");
438
+ var meter = null;
439
+ if (input.meter_number != null) {
440
+ // meter numbers are short alnum strings — reuse the
441
+ // account-number alphabet so a typo'd char is loud.
442
+ meter = _accountNumber(input.meter_number);
443
+ }
444
+ var label = _label(input.account_label);
445
+ _shipFromAddress(input.ship_from_address);
446
+
447
+ var now = _now();
448
+ var hashAN = _hashAccountNumber(rawAcct);
449
+ var hashAK = _hashApiKey(apiKey);
450
+ var hashAS = apiSecret != null ? _hashApiSecret(apiSecret) : null;
451
+ var hashMN = meter != null ? _hashMeterNumber(meter) : null;
452
+ var addrJson = JSON.stringify(input.ship_from_address);
453
+
454
+ // Upsert on (carrier, account_label). The label may be NULL,
455
+ // which collapses every unlabelled row for a given carrier into
456
+ // a single "primary" account — operators with multiple
457
+ // production accounts must label them. SQLite's UNIQUE doesn't
458
+ // enforce equality on NULL, so we do the lookup manually.
459
+ var lookup = await query(
460
+ label == null
461
+ ? "SELECT * FROM carrier_accounts WHERE carrier = ?1 AND account_label IS NULL"
462
+ : "SELECT * FROM carrier_accounts WHERE carrier = ?1 AND account_label = ?2",
463
+ label == null ? [carrier] : [carrier, label],
464
+ );
465
+ var existing = lookup.rows[0] || null;
466
+
467
+ var id;
468
+ if (existing) {
469
+ id = existing.id;
470
+ await query(
471
+ "UPDATE carrier_accounts SET account_number_hash = ?1, " +
472
+ "account_number_normalised = ?2, api_key_hash = ?3, " +
473
+ "api_key_previous_hash = NULL, api_secret_hash = ?4, " +
474
+ "meter_number_hash = ?5, ship_from_address_json = ?6, " +
475
+ "status = 'active', disabled_reason = NULL, disabled_at = NULL, " +
476
+ "rotated_at = NULL, updated_at = ?7 WHERE id = ?8",
477
+ [hashAN, normAcct, hashAK, hashAS, hashMN, addrJson, now, id],
478
+ );
479
+ } else {
480
+ id = _b().uuid.v7();
481
+ await query(
482
+ "INSERT INTO carrier_accounts " +
483
+ "(id, carrier, account_label, account_number_hash, " +
484
+ " account_number_normalised, api_key_hash, api_key_previous_hash, " +
485
+ " api_secret_hash, meter_number_hash, ship_from_address_json, " +
486
+ " status, disabled_reason, disabled_at, rotated_at, created_at, updated_at) " +
487
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 'active', NULL, " +
488
+ " NULL, NULL, ?10, ?11)",
489
+ [id, carrier, label, hashAN, normAcct, hashAK, hashAS, hashMN,
490
+ addrJson, now, now],
491
+ );
492
+ }
493
+
494
+ var projected = _project(await _getRaw(id));
495
+ // The plaintext bundle is returned ONCE alongside the projected
496
+ // row. Subsequent reads via getAccount / listAccounts return
497
+ // only the hashes — the operator hands the plaintext directly
498
+ // to their downstream label worker's secret store.
499
+ projected.plaintext = {
500
+ account_number: rawAcct,
501
+ api_key: apiKey,
502
+ api_secret: apiSecret,
503
+ meter_number: meter,
504
+ };
505
+ return projected;
506
+ },
507
+
508
+ getAccount: async function (accountId) {
509
+ var id = _uuid(accountId, "account_id");
510
+ return _project(await _getRaw(id));
511
+ },
512
+
513
+ // Lookup by (carrier, label?). When label is omitted, returns the
514
+ // unlabelled "primary" row for the carrier — operators with
515
+ // multiple labelled accounts must pass `label`.
516
+ accountByCarrier: async function (input) {
517
+ if (!input || typeof input !== "object") {
518
+ throw new TypeError("carrier-accounts.accountByCarrier: input object required");
519
+ }
520
+ var carrier = _carrier(input.carrier);
521
+ var label = _label(input.label);
522
+ var r = await query(
523
+ label == null
524
+ ? "SELECT * FROM carrier_accounts WHERE carrier = ?1 AND account_label IS NULL"
525
+ : "SELECT * FROM carrier_accounts WHERE carrier = ?1 AND account_label = ?2",
526
+ label == null ? [carrier] : [carrier, label],
527
+ );
528
+ return _project(r.rows[0] || null);
529
+ },
530
+
531
+ listAccounts: async function (listOpts) {
532
+ listOpts = listOpts || {};
533
+ var clauses = [];
534
+ var params = [];
535
+ var idx = 1;
536
+ if (listOpts.carrier != null) {
537
+ clauses.push("carrier = ?" + idx);
538
+ params.push(_carrier(listOpts.carrier));
539
+ idx += 1;
540
+ }
541
+ if (listOpts.active_only != null) {
542
+ _bool(listOpts.active_only, "active_only");
543
+ if (listOpts.active_only) {
544
+ clauses.push("status = ?" + idx);
545
+ params.push("active");
546
+ idx += 1;
547
+ }
548
+ }
549
+ var sql = "SELECT * FROM carrier_accounts";
550
+ if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
551
+ sql += " ORDER BY carrier ASC, account_label ASC, id ASC";
552
+ var r = await query(sql, params);
553
+ return r.rows.map(_project);
554
+ },
555
+
556
+ // Rotate api_key + api_secret. Returns the fresh plaintext pair
557
+ // exactly once; slides the old api_key_hash into
558
+ // api_key_previous_hash so deployed workers running the old key
559
+ // keep verifying for the 24h grace. The row's status flips to
560
+ // `rotating` to advertise the grace window. A disabled row
561
+ // refuses to rotate — re-enable it first.
562
+ rotateCredentials: async function (input) {
563
+ if (!input || typeof input !== "object") {
564
+ throw new TypeError("carrier-accounts.rotateCredentials: input object required");
565
+ }
566
+ var id = _uuid(input.account_id, "account_id");
567
+ var current = await _getRaw(id);
568
+ if (!current) {
569
+ var miss = new Error("carrier-accounts.rotateCredentials: account not found");
570
+ miss.code = "CARRIER_ACCOUNT_NOT_FOUND";
571
+ throw miss;
572
+ }
573
+ if (current.status === "disabled") {
574
+ var refused = new Error(
575
+ "carrier-accounts.rotateCredentials: refused — account is disabled"
576
+ );
577
+ refused.code = "CARRIER_ACCOUNT_DISABLED";
578
+ throw refused;
579
+ }
580
+
581
+ var newKey = _generateSecret();
582
+ var newSecret = _generateSecret();
583
+ var hashKey = _hashApiKey(newKey);
584
+ var hashSec = _hashApiSecret(newSecret);
585
+ var prevHash = current.api_key_hash;
586
+ var now = _now();
587
+
588
+ await query(
589
+ "UPDATE carrier_accounts SET api_key_hash = ?1, " +
590
+ "api_key_previous_hash = ?2, api_secret_hash = ?3, " +
591
+ "status = 'rotating', rotated_at = ?4, updated_at = ?5 " +
592
+ "WHERE id = ?6",
593
+ [hashKey, prevHash, hashSec, now, now, id],
594
+ );
595
+
596
+ var projected = _project(await _getRaw(id));
597
+ projected.plaintext = {
598
+ api_key: newKey,
599
+ api_secret: newSecret,
600
+ };
601
+ projected.rotation_grace_ms = ROTATION_GRACE_MS;
602
+ return projected;
603
+ },
604
+
605
+ disableAccount: async function (input) {
606
+ if (!input || typeof input !== "object") {
607
+ throw new TypeError("carrier-accounts.disableAccount: input object required");
608
+ }
609
+ var id = _uuid(input.account_id, "account_id");
610
+ var why = _reason(input.reason);
611
+ var current = await _getRaw(id);
612
+ if (!current) {
613
+ var miss = new Error("carrier-accounts.disableAccount: account not found");
614
+ miss.code = "CARRIER_ACCOUNT_NOT_FOUND";
615
+ throw miss;
616
+ }
617
+ if (current.status === "disabled") {
618
+ // Idempotent — re-disable returns the existing row.
619
+ return _project(current);
620
+ }
621
+ var now = _now();
622
+ await query(
623
+ "UPDATE carrier_accounts SET status = 'disabled', " +
624
+ "disabled_reason = ?1, disabled_at = ?2, " +
625
+ "api_key_previous_hash = NULL, rotated_at = NULL, updated_at = ?3 " +
626
+ "WHERE id = ?4",
627
+ [why, now, now, id],
628
+ );
629
+ return _project(await _getRaw(id));
630
+ },
631
+
632
+ enableAccount: async function (input) {
633
+ if (!input || typeof input !== "object") {
634
+ throw new TypeError("carrier-accounts.enableAccount: input object required");
635
+ }
636
+ var id = _uuid(input.account_id, "account_id");
637
+ var current = await _getRaw(id);
638
+ if (!current) {
639
+ var miss = new Error("carrier-accounts.enableAccount: account not found");
640
+ miss.code = "CARRIER_ACCOUNT_NOT_FOUND";
641
+ throw miss;
642
+ }
643
+ if (current.status === "active") {
644
+ return _project(current);
645
+ }
646
+ var now = _now();
647
+ await query(
648
+ "UPDATE carrier_accounts SET status = 'active', " +
649
+ "disabled_reason = NULL, disabled_at = NULL, updated_at = ?1 " +
650
+ "WHERE id = ?2",
651
+ [now, id],
652
+ );
653
+ return _project(await _getRaw(id));
654
+ },
655
+
656
+ // Constant-time hex compare. Match against `api_key_hash` always;
657
+ // additionally match against `api_key_previous_hash` when the row
658
+ // is `rotating` AND `now - rotated_at` is within the 24h grace.
659
+ // Disabled rows refuse immediately.
660
+ verifyCredentials: async function (input) {
661
+ if (!input || typeof input !== "object") {
662
+ throw new TypeError("carrier-accounts.verifyCredentials: input object required");
663
+ }
664
+ var id = _uuid(input.account_id, "account_id");
665
+ _apiKey(input.plaintext_key, "plaintext_key");
666
+
667
+ var current = await _getRaw(id);
668
+ if (!current) return { ok: false, reason: "not_found" };
669
+ if (current.status === "disabled") return { ok: false, reason: "disabled" };
670
+
671
+ var supplied = _hashApiKey(input.plaintext_key);
672
+ var live = _b().crypto.timingSafeEqual(current.api_key_hash, supplied);
673
+ if (live) {
674
+ return { ok: true, matched: "live", account_id: id };
675
+ }
676
+
677
+ // Previous-hash branch only kicks in for rotating rows within
678
+ // the grace window. The branch reads `rotated_at` and the
679
+ // previous hash before deciding to call `timingSafeEqual` —
680
+ // the live-hash compare above always runs first, so a verify
681
+ // miss on a non-rotating row takes the same path regardless of
682
+ // status.
683
+ var prev = current.api_key_previous_hash;
684
+ var now;
685
+ if (input.now != null) now = _msEpoch(input.now, "now");
686
+ else now = _now();
687
+
688
+ var rotatedAt = current.rotated_at != null ? Number(current.rotated_at) : 0;
689
+ var withinGrace = current.status === "rotating" &&
690
+ rotatedAt > 0 &&
691
+ (now - rotatedAt) <= ROTATION_GRACE_MS;
692
+
693
+ if (prev != null && withinGrace) {
694
+ var matchedPrev = _b().crypto.timingSafeEqual(prev, supplied);
695
+ if (matchedPrev) {
696
+ return { ok: true, matched: "previous", account_id: id,
697
+ grace_expires_at: rotatedAt + ROTATION_GRACE_MS };
698
+ }
699
+ }
700
+ return { ok: false, reason: "mismatch" };
701
+ },
702
+
703
+ // Append-only usage row. The route layer calls this on every
704
+ // carrier API request — success / failure / latency feed the
705
+ // metricsForAccount aggregation downstream.
706
+ recordUsage: async function (input) {
707
+ if (!input || typeof input !== "object") {
708
+ throw new TypeError("carrier-accounts.recordUsage: input object required");
709
+ }
710
+ var id = _uuid(input.account_id, "account_id");
711
+ _operation(input.operation);
712
+ if (typeof input.success !== "boolean") {
713
+ throw new TypeError("carrier-accounts.recordUsage: success must be a boolean");
714
+ }
715
+ _nonNegInt(input.ms_elapsed, "ms_elapsed");
716
+
717
+ var current = await _getRaw(id);
718
+ if (!current) {
719
+ var miss = new Error("carrier-accounts.recordUsage: account not found");
720
+ miss.code = "CARRIER_ACCOUNT_NOT_FOUND";
721
+ throw miss;
722
+ }
723
+ var ts = _now();
724
+ var rowId = _b().uuid.v7();
725
+ await query(
726
+ "INSERT INTO carrier_usage_log (id, account_id, operation, " +
727
+ "success, ms_elapsed, occurred_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
728
+ [rowId, id, input.operation, input.success ? 1 : 0, input.ms_elapsed, ts],
729
+ );
730
+ return {
731
+ id: rowId,
732
+ account_id: id,
733
+ operation: input.operation,
734
+ success: input.success,
735
+ ms_elapsed: input.ms_elapsed,
736
+ occurred_at: ts,
737
+ };
738
+ },
739
+
740
+ // Aggregate the usage log for an account over a closed window.
741
+ // Returns request / success / failure counts, success rate, and
742
+ // p50 / p95 / avg latency. An empty window returns zero counts
743
+ // and zero latencies rather than NaN so the operator's
744
+ // observability surface can render a stable shape.
745
+ metricsForAccount: async function (input) {
746
+ if (!input || typeof input !== "object") {
747
+ throw new TypeError("carrier-accounts.metricsForAccount: input object required");
748
+ }
749
+ var id = _uuid(input.account_id, "account_id");
750
+ var from = _msEpoch(input.from, "from");
751
+ var to = _msEpoch(input.to, "to");
752
+ if (from > to) {
753
+ throw new TypeError("carrier-accounts.metricsForAccount: from must be <= to");
754
+ }
755
+ var r = await query(
756
+ "SELECT success, ms_elapsed FROM carrier_usage_log " +
757
+ "WHERE account_id = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3 " +
758
+ "ORDER BY ms_elapsed ASC",
759
+ [id, from, to],
760
+ );
761
+ var rows = r.rows;
762
+ var requests = rows.length;
763
+ var successes = 0;
764
+ var sumMs = 0;
765
+ var latencies = [];
766
+ for (var i = 0; i < rows.length; i += 1) {
767
+ var row = rows[i];
768
+ if (Number(row.success) === 1) successes += 1;
769
+ var ms = Number(row.ms_elapsed);
770
+ sumMs += ms;
771
+ latencies.push(ms);
772
+ }
773
+ // The SELECT already sorted ASC on ms_elapsed, so the same
774
+ // array is the percentile source — no second sort needed.
775
+ var failures = requests - successes;
776
+ var successRate = requests > 0 ? successes / requests : 0;
777
+ var avgMs = requests > 0 ? sumMs / requests : 0;
778
+ var p50 = _percentile(latencies, 0.50);
779
+ var p95 = _percentile(latencies, 0.95);
780
+ return {
781
+ account_id: id,
782
+ from: from,
783
+ to: to,
784
+ requests: requests,
785
+ successes: successes,
786
+ failures: failures,
787
+ success_rate: successRate,
788
+ avg_ms: avgMs,
789
+ p50_ms: p50,
790
+ p95_ms: p95,
791
+ };
792
+ },
793
+ };
794
+ }
795
+
796
+ module.exports = {
797
+ create: create,
798
+ CARRIERS: CARRIERS,
799
+ STATUSES: STATUSES,
800
+ ROTATION_GRACE_MS: ROTATION_GRACE_MS,
801
+ NS_ACCOUNT_NUMBER: NS_ACCOUNT_NUMBER,
802
+ NS_API_KEY: NS_API_KEY,
803
+ NS_API_SECRET: NS_API_SECRET,
804
+ NS_METER_NUMBER: NS_METER_NUMBER,
805
+ };