@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|