@blamejs/blamejs-shop 0.0.72 → 0.0.76
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 +8 -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,701 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.operatorSessions
|
|
4
|
+
* @title Operator sessions — staff login sessions for the admin console
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The cookie-bearing session that represents a logged-in admin /
|
|
8
|
+
* support / fulfillment user inside the operator console. Distinct
|
|
9
|
+
* from `customerPortal` (migration 0072), which is the customer-
|
|
10
|
+
* facing self-serve link. THIS primitive is the STAFF session
|
|
11
|
+
* manager — once a primary credential challenge succeeds (and, when
|
|
12
|
+
* policy demands, an MFA step), the row this primitive mints is the
|
|
13
|
+
* durable handle the console's session middleware validates on every
|
|
14
|
+
* subsequent request until logout / expiry / revoke / lockout.
|
|
15
|
+
*
|
|
16
|
+
* Flow:
|
|
17
|
+
*
|
|
18
|
+
* 1. Operator finishes a primary-credential challenge upstream.
|
|
19
|
+
* Caller invokes `createSession({ operator_id, ip_hash,
|
|
20
|
+
* mfa_required, ua_class?, ttl_seconds? })`. The primitive
|
|
21
|
+
* mints a 32-byte base64url plaintext bearer via
|
|
22
|
+
* `b.crypto.generateBytes(32)` + `b.crypto.toBase64Url`, hashes
|
|
23
|
+
* it via `b.crypto.namespaceHash("operator-session-token",
|
|
24
|
+
* plaintext)`, and writes the hash + the per-IP binding + the
|
|
25
|
+
* MFA-required flag with the 8-hour default ttl. The plaintext
|
|
26
|
+
* leaves this function ONCE; the database never stores it.
|
|
27
|
+
*
|
|
28
|
+
* 2. Every subsequent request on the operator console calls
|
|
29
|
+
* `verifyToken(plaintext, { ip_hash })`. The primitive:
|
|
30
|
+
* * re-hashes the plaintext via `namespaceHash`;
|
|
31
|
+
* * looks the row up by the hash (primary-key index);
|
|
32
|
+
* * runs `b.crypto.timingSafeEqual` against the stored hash
|
|
33
|
+
* (belt-and-braces around the index hit so the latency
|
|
34
|
+
* profile matches between hit and miss paths);
|
|
35
|
+
* * refuses when `status` is not `issued` / `active`;
|
|
36
|
+
* * refuses when `expires_at <= now`;
|
|
37
|
+
* * refuses when the presented `ip_hash` differs from the
|
|
38
|
+
* stored `ip_hash` (per-IP binding — a stolen cookie that
|
|
39
|
+
* leaves the operator's network is dropped);
|
|
40
|
+
* * returns `{ requires_mfa: true, session_id }` when the row
|
|
41
|
+
* still carries `mfa_required = 1` and `mfa_verified_at IS
|
|
42
|
+
* NULL`;
|
|
43
|
+
* * on first successful presentation flips `status` from
|
|
44
|
+
* `issued` → `active` and stamps `activated_at`.
|
|
45
|
+
*
|
|
46
|
+
* 3. When MFA is required (`mfa_required = 1` at create), the
|
|
47
|
+
* caller routes the operator through the step-up challenge,
|
|
48
|
+
* then calls `recordMfaVerification(session_id)` once the
|
|
49
|
+
* challenge succeeds. Subsequent `verifyToken` calls return the
|
|
50
|
+
* full session shape instead of the requires_mfa stub.
|
|
51
|
+
*
|
|
52
|
+
* 4. Failed credential / MFA / verify attempts are recorded via
|
|
53
|
+
* `recordFailedVerify({ ip_hash, operator_id?, reason })`. The
|
|
54
|
+
* scheduler / login-attempt middleware calls `lockoutCheck(
|
|
55
|
+
* ip_hash, { window_seconds?, threshold? })` to read the last
|
|
56
|
+
* N minutes of failures from that IP hash. When the count
|
|
57
|
+
* trips threshold the caller refuses further authentication
|
|
58
|
+
* attempts AND the operator console auto-revokes any live
|
|
59
|
+
* sessions from that IP — implemented by walking
|
|
60
|
+
* `listForOperator(...)` over the affected operators and
|
|
61
|
+
* calling `revokeSession(id, "lockout-trip")` on each live row.
|
|
62
|
+
*
|
|
63
|
+
* 5. Operators can revoke a live session early via
|
|
64
|
+
* `revokeSession(session_id, reason)` (logout, password reset,
|
|
65
|
+
* suspected compromise). The scheduler entry
|
|
66
|
+
* `expireOlderThan(seconds)` flips stale `issued` / `active`
|
|
67
|
+
* rows to `expired` so the FSM column stays durable for audit
|
|
68
|
+
* even when the runtime expiry gate already fires.
|
|
69
|
+
*
|
|
70
|
+
* Audit:
|
|
71
|
+
*
|
|
72
|
+
* Every meaningful state transition (createSession,
|
|
73
|
+
* recordMfaVerification, revokeSession, lockoutCheck threshold
|
|
74
|
+
* trip, verifyToken success on first activation) is sent to the
|
|
75
|
+
* optional `operatorAuditLog` peer via the duck-typed `.record(...)`
|
|
76
|
+
* interface (migration 0074). The audit body carries actor_type =
|
|
77
|
+
* "operator", actor_id = operator_id, action namespaced under
|
|
78
|
+
* `session.<event>`, and before / after snapshots of the row's
|
|
79
|
+
* status + mfa_verified_at columns. The peer is optional — absent
|
|
80
|
+
* wiring, the FSM transitions still land in the storage row, but
|
|
81
|
+
* the chained log doesn't.
|
|
82
|
+
*
|
|
83
|
+
* Composes:
|
|
84
|
+
* - `b.guardUuid` — UUID-shape validation on
|
|
85
|
+
* operator_id + session_id at the
|
|
86
|
+
* entry point.
|
|
87
|
+
* - `b.crypto.generateBytes` — 32-byte CSPRNG draw for the bearer.
|
|
88
|
+
* - `b.crypto.toBase64Url` — URL-safe encoding for the plaintext.
|
|
89
|
+
* - `b.crypto.namespaceHash` — keys the storage row off
|
|
90
|
+
* `("operator-session-token",
|
|
91
|
+
* plaintext)` so a dump never
|
|
92
|
+
* reveals live bearers.
|
|
93
|
+
* - `b.crypto.timingSafeEqual` — constant-time check on verify.
|
|
94
|
+
* - `b.uuid.v7` — row id + failed-login event id.
|
|
95
|
+
* - `operatorRoles` (opt) — when wired, the create call can
|
|
96
|
+
* consult `requireMfa(operator_id)`
|
|
97
|
+
* to derive `mfa_required` from
|
|
98
|
+
* the operator's role assignment
|
|
99
|
+
* rather than relying on the caller
|
|
100
|
+
* to pass the flag.
|
|
101
|
+
* - `operatorAuditLog` (opt) — chained-hash audit log.
|
|
102
|
+
*
|
|
103
|
+
* Surface:
|
|
104
|
+
* - `createSession({ operator_id, ip_hash, mfa_required?,
|
|
105
|
+
* ua_class?, ttl_seconds? })`
|
|
106
|
+
* → `{ session_id, plaintext_token, expires_at, mfa_required }`
|
|
107
|
+
* - `verifyToken(plaintext, { ip_hash })`
|
|
108
|
+
* → on miss: `null`
|
|
109
|
+
* → on requires_mfa: `{ requires_mfa: true, session_id,
|
|
110
|
+
* operator_id, expires_at }`
|
|
111
|
+
* → on hit: `{ session_id, operator_id, expires_at,
|
|
112
|
+
* mfa_verified_at }`
|
|
113
|
+
* - `requireMfa(operator_id)` → boolean (queries the optional
|
|
114
|
+
* `operatorRoles` peer; defaults to `true` when the peer is
|
|
115
|
+
* wired, `false` when absent so the primitive is testable in
|
|
116
|
+
* isolation).
|
|
117
|
+
* - `recordMfaVerification(session_id)`
|
|
118
|
+
* → `{ verified: boolean }`
|
|
119
|
+
* - `revokeSession(session_id, reason)`
|
|
120
|
+
* → `{ revoked: boolean }`
|
|
121
|
+
* - `listForOperator(operator_id, { from?, to?, status? })`
|
|
122
|
+
* → array of session rows, newest-first.
|
|
123
|
+
* - `expireOlderThan(seconds)`
|
|
124
|
+
* → `{ expired: <count> }`
|
|
125
|
+
* - `lockoutCheck(ip_hash, { window_seconds?, threshold? })`
|
|
126
|
+
* → `{ locked: boolean, count: <number>, threshold: <number>,
|
|
127
|
+
* window_seconds: <number> }`
|
|
128
|
+
* - `recordFailedVerify({ ip_hash, operator_id?, reason })`
|
|
129
|
+
* → `{ id, occurred_at }`
|
|
130
|
+
*
|
|
131
|
+
* Storage:
|
|
132
|
+
* - `operator_sessions` (migration `0165_operator_sessions.sql`)
|
|
133
|
+
* - `operator_failed_logins` (migration `0165_operator_sessions.sql`)
|
|
134
|
+
*
|
|
135
|
+
* @primitive operatorSessions
|
|
136
|
+
* @related b.crypto.generateBytes, b.crypto.namespaceHash,
|
|
137
|
+
* b.crypto.timingSafeEqual, b.guardUuid, b.uuid,
|
|
138
|
+
* operatorRoles, operatorAuditLog
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
var TOKEN_NAMESPACE = "operator-session-token";
|
|
142
|
+
var TOKEN_BYTES = 32;
|
|
143
|
+
var DEFAULT_TTL_SECONDS = 8 * 60 * 60; // 8-hour shift default
|
|
144
|
+
var MIN_TTL_SECONDS = 60; // refuse zero / sub-minute
|
|
145
|
+
var MAX_TTL_SECONDS = 60 * 60 * 24; // hard ceiling — one day
|
|
146
|
+
var MAX_REASON_LEN = 64;
|
|
147
|
+
var MAX_UA_CLASS_LEN = 64;
|
|
148
|
+
var MAX_IP_HASH_LEN = 256;
|
|
149
|
+
var MIN_IP_HASH_LEN = 1;
|
|
150
|
+
var DEFAULT_LOCKOUT_WINDOW = 15 * 60; // 15-minute rolling window
|
|
151
|
+
var DEFAULT_LOCKOUT_THRESH = 5; // 5 failures trips lockout
|
|
152
|
+
var MAX_LOCKOUT_WINDOW = 24 * 60 * 60; // sanity cap — one day
|
|
153
|
+
var MIN_LOCKOUT_THRESHOLD = 1;
|
|
154
|
+
var MAX_LOCKOUT_THRESHOLD = 1000;
|
|
155
|
+
|
|
156
|
+
var STATUS_VALUES = Object.freeze([
|
|
157
|
+
"issued",
|
|
158
|
+
"active",
|
|
159
|
+
"expired",
|
|
160
|
+
"revoked",
|
|
161
|
+
"locked_out",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// Lazy framework handle — matches the pattern used by the rest of the
|
|
165
|
+
// shop primitives; avoids the require cycle that would arise from
|
|
166
|
+
// importing `./index` at module-eval time.
|
|
167
|
+
var bShop;
|
|
168
|
+
function _b() {
|
|
169
|
+
if (!bShop) bShop = require("./index");
|
|
170
|
+
return bShop.framework;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- validators --------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function _uuid(s, label) {
|
|
176
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
177
|
+
catch (e) { throw new TypeError("operator-sessions: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _ttlSeconds(n) {
|
|
181
|
+
if (n == null) return DEFAULT_TTL_SECONDS;
|
|
182
|
+
if (!Number.isInteger(n) || n < MIN_TTL_SECONDS || n > MAX_TTL_SECONDS) {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
"operator-sessions: ttl_seconds must be an integer in [" +
|
|
185
|
+
MIN_TTL_SECONDS + ", " + MAX_TTL_SECONDS + "]"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return n;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _requiredString(s, label, minLen, maxLen) {
|
|
192
|
+
if (typeof s !== "string") {
|
|
193
|
+
throw new TypeError("operator-sessions: " + label + " must be a string");
|
|
194
|
+
}
|
|
195
|
+
if (s.length < minLen) {
|
|
196
|
+
throw new TypeError("operator-sessions: " + label + " must be a non-empty string");
|
|
197
|
+
}
|
|
198
|
+
if (s.length > maxLen) {
|
|
199
|
+
throw new TypeError("operator-sessions: " + label + " must be <= " + maxLen + " characters");
|
|
200
|
+
}
|
|
201
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
202
|
+
throw new TypeError("operator-sessions: " + label + " must not contain control bytes");
|
|
203
|
+
}
|
|
204
|
+
return s;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _optShortString(s, label, maxLen) {
|
|
208
|
+
if (s == null || s === "") return null;
|
|
209
|
+
if (typeof s !== "string") {
|
|
210
|
+
throw new TypeError("operator-sessions: " + label + " must be a string");
|
|
211
|
+
}
|
|
212
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
213
|
+
throw new TypeError("operator-sessions: " + label + " must not contain control bytes");
|
|
214
|
+
}
|
|
215
|
+
if (s.length > maxLen) {
|
|
216
|
+
throw new TypeError("operator-sessions: " + label + " must be <= " + maxLen + " characters");
|
|
217
|
+
}
|
|
218
|
+
return s;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _seconds(n, label) {
|
|
222
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
223
|
+
throw new TypeError("operator-sessions: " + label + " must be a non-negative integer (seconds)");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _optTsBound(n, label) {
|
|
228
|
+
if (n == null) return null;
|
|
229
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
230
|
+
throw new TypeError("operator-sessions: " + label + " must be a non-negative integer (ms epoch)");
|
|
231
|
+
}
|
|
232
|
+
return n;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _optStatusFilter(s) {
|
|
236
|
+
if (s == null) return null;
|
|
237
|
+
if (STATUS_VALUES.indexOf(s) === -1) {
|
|
238
|
+
throw new TypeError("operator-sessions: status filter must be one of " + STATUS_VALUES.join(", "));
|
|
239
|
+
}
|
|
240
|
+
return s;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function _bool(v, label) {
|
|
244
|
+
if (v == null) return false;
|
|
245
|
+
if (v === true || v === false) return v;
|
|
246
|
+
throw new TypeError("operator-sessions: " + label + " must be a boolean");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _lockoutWindow(n) {
|
|
250
|
+
if (n == null) return DEFAULT_LOCKOUT_WINDOW;
|
|
251
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_LOCKOUT_WINDOW) {
|
|
252
|
+
throw new TypeError(
|
|
253
|
+
"operator-sessions: window_seconds must be an integer in [1, " + MAX_LOCKOUT_WINDOW + "]"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return n;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _lockoutThreshold(n) {
|
|
260
|
+
if (n == null) return DEFAULT_LOCKOUT_THRESH;
|
|
261
|
+
if (!Number.isInteger(n) || n < MIN_LOCKOUT_THRESHOLD || n > MAX_LOCKOUT_THRESHOLD) {
|
|
262
|
+
throw new TypeError(
|
|
263
|
+
"operator-sessions: threshold must be an integer in [" +
|
|
264
|
+
MIN_LOCKOUT_THRESHOLD + ", " + MAX_LOCKOUT_THRESHOLD + "]"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return n;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function create(opts) {
|
|
271
|
+
opts = opts || {};
|
|
272
|
+
var query = opts.query;
|
|
273
|
+
if (!query) {
|
|
274
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
275
|
+
}
|
|
276
|
+
// Optional peers. Duck-typed — the tests stub these without
|
|
277
|
+
// importing the full primitive.
|
|
278
|
+
var operatorRoles = opts.operatorRoles || null;
|
|
279
|
+
var operatorAuditLog = opts.operatorAuditLog || null;
|
|
280
|
+
|
|
281
|
+
// Per-factory monotonic clock. Two staff actions in the same
|
|
282
|
+
// millisecond (createSession + recordMfaVerification, for instance)
|
|
283
|
+
// still produce strictly-increasing timestamps so the audit timeline
|
|
284
|
+
// sorts deterministically.
|
|
285
|
+
var _lastTs = 0;
|
|
286
|
+
function _now() {
|
|
287
|
+
var wall = Date.now();
|
|
288
|
+
if (wall > _lastTs) _lastTs = wall;
|
|
289
|
+
else _lastTs += 1;
|
|
290
|
+
return _lastTs;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function _audit(action, operatorId, sessionId, before, after) {
|
|
294
|
+
if (!operatorAuditLog || typeof operatorAuditLog.record !== "function") return;
|
|
295
|
+
await operatorAuditLog.record({
|
|
296
|
+
actor_type: "operator",
|
|
297
|
+
actor_id: operatorId,
|
|
298
|
+
action: "session." + action,
|
|
299
|
+
resource_kind: "operator_session",
|
|
300
|
+
resource_id: sessionId,
|
|
301
|
+
before: before,
|
|
302
|
+
after: after,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
STATUS_VALUES: STATUS_VALUES,
|
|
308
|
+
DEFAULT_TTL_SECONDS: DEFAULT_TTL_SECONDS,
|
|
309
|
+
DEFAULT_LOCKOUT_WINDOW: DEFAULT_LOCKOUT_WINDOW,
|
|
310
|
+
DEFAULT_LOCKOUT_THRESH: DEFAULT_LOCKOUT_THRESH,
|
|
311
|
+
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
312
|
+
|
|
313
|
+
// ---- createSession --------------------------------------------------
|
|
314
|
+
//
|
|
315
|
+
// Mints a fresh operator session. The plaintext bearer is returned
|
|
316
|
+
// ONCE; the database stores only the namespaceHash. The per-IP
|
|
317
|
+
// binding is REQUIRED (staff sessions defend access to PII / order
|
|
318
|
+
// history — a missing ip_hash is a primitive-layer error, not a
|
|
319
|
+
// permissive default). On a wired `operatorRoles` peer, the
|
|
320
|
+
// primitive can derive `mfa_required` via `requireMfa(operator_id)`
|
|
321
|
+
// when the caller leaves the flag unset.
|
|
322
|
+
createSession: async function (input) {
|
|
323
|
+
if (!input || typeof input !== "object") {
|
|
324
|
+
throw new TypeError("operator-sessions.createSession: input object required");
|
|
325
|
+
}
|
|
326
|
+
var operatorId = _uuid(input.operator_id, "operator_id");
|
|
327
|
+
var ipHash = _requiredString(input.ip_hash, "ip_hash", MIN_IP_HASH_LEN, MAX_IP_HASH_LEN);
|
|
328
|
+
var uaClass = _optShortString(input.ua_class, "ua_class", MAX_UA_CLASS_LEN);
|
|
329
|
+
var ttl = _ttlSeconds(input.ttl_seconds);
|
|
330
|
+
var mfaRequired;
|
|
331
|
+
if (input.mfa_required == null && operatorRoles && typeof operatorRoles.requireMfa === "function") {
|
|
332
|
+
// Peer-derived policy. The peer returns truthy when the
|
|
333
|
+
// operator's role assignment demands a step-up; the primitive
|
|
334
|
+
// stores the boolean. Bad peer return (non-boolean) is coerced
|
|
335
|
+
// through Boolean(...) so a stub returning 1 / 0 still works.
|
|
336
|
+
mfaRequired = Boolean(await operatorRoles.requireMfa(operatorId));
|
|
337
|
+
} else {
|
|
338
|
+
mfaRequired = _bool(input.mfa_required, "mfa_required");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
var plaintext = _b().crypto.toBase64Url(
|
|
342
|
+
_b().crypto.generateBytes(TOKEN_BYTES)
|
|
343
|
+
);
|
|
344
|
+
var tokenHash = _b().crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
345
|
+
var id = _b().uuid.v7();
|
|
346
|
+
var now = _now();
|
|
347
|
+
var expiresAt = now + (ttl * 1000);
|
|
348
|
+
|
|
349
|
+
await query(
|
|
350
|
+
"INSERT INTO operator_sessions " +
|
|
351
|
+
"(id, operator_id, token_hash, ip_hash, ua_class, status, " +
|
|
352
|
+
" mfa_required, mfa_verified_at, activated_at, " +
|
|
353
|
+
" revoked_at, revoke_reason, ttl_seconds, created_at, expires_at) " +
|
|
354
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'issued', ?6, NULL, NULL, " +
|
|
355
|
+
" NULL, NULL, ?7, ?8, ?9)",
|
|
356
|
+
[id, operatorId, tokenHash, ipHash, uaClass,
|
|
357
|
+
mfaRequired ? 1 : 0, ttl, now, expiresAt],
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
await _audit(
|
|
361
|
+
"create",
|
|
362
|
+
operatorId,
|
|
363
|
+
id,
|
|
364
|
+
null,
|
|
365
|
+
{ status: "issued", mfa_required: mfaRequired },
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
session_id: id,
|
|
370
|
+
plaintext_token: plaintext,
|
|
371
|
+
expires_at: expiresAt,
|
|
372
|
+
mfa_required: mfaRequired,
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
// ---- verifyToken ----------------------------------------------------
|
|
377
|
+
//
|
|
378
|
+
// Validates a presented bearer + ip_hash against the stored row.
|
|
379
|
+
// Returns null on every refuse path (unknown token, expired,
|
|
380
|
+
// consumed, revoked, locked_out, ip_hash mismatch, malformed
|
|
381
|
+
// input). Returns `{ requires_mfa: true, ... }` when the row is
|
|
382
|
+
// valid but the MFA gate has not been satisfied yet. Returns the
|
|
383
|
+
// full session shape on the first hit AND every subsequent hit
|
|
384
|
+
// until the row leaves a redeemable status.
|
|
385
|
+
//
|
|
386
|
+
// The first successful verify on an `issued` row flips it to
|
|
387
|
+
// `active` and stamps `activated_at` — the FSM column makes the
|
|
388
|
+
// "live session" set cheap to query for audit ("show me every
|
|
389
|
+
// operator currently signed in").
|
|
390
|
+
verifyToken: async function (plaintext, vopts) {
|
|
391
|
+
if (typeof plaintext !== "string" || !plaintext.length) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
if (!vopts || typeof vopts !== "object" || typeof vopts.ip_hash !== "string" || !vopts.ip_hash.length) {
|
|
395
|
+
// ip_hash is mandatory on verify — defending the per-IP
|
|
396
|
+
// binding is part of the primitive's contract. Treat a
|
|
397
|
+
// missing / malformed presentation as a miss rather than
|
|
398
|
+
// surface a TypeError; the calling middleware renders the
|
|
399
|
+
// sign-in page either way.
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
var presentedIp = vopts.ip_hash;
|
|
403
|
+
var tokenHash = _b().crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
404
|
+
var row = (await query(
|
|
405
|
+
"SELECT id, operator_id, token_hash, ip_hash, status, " +
|
|
406
|
+
" mfa_required, mfa_verified_at, expires_at " +
|
|
407
|
+
"FROM operator_sessions WHERE token_hash = ?1 LIMIT 1",
|
|
408
|
+
[tokenHash],
|
|
409
|
+
)).rows[0];
|
|
410
|
+
if (!row) {
|
|
411
|
+
// Burn the same compare work as the hit path so the latency
|
|
412
|
+
// profile matches between miss and hit. The compared values
|
|
413
|
+
// are equal-length and equal (the hash against itself); the
|
|
414
|
+
// result is discarded.
|
|
415
|
+
_b().crypto.timingSafeEqual(tokenHash, tokenHash);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
var matched = _b().crypto.timingSafeEqual(row.token_hash, tokenHash);
|
|
419
|
+
if (!matched) return null;
|
|
420
|
+
if (row.status !== "issued" && row.status !== "active") return null;
|
|
421
|
+
var now = _now();
|
|
422
|
+
if (Number(row.expires_at) <= now) return null;
|
|
423
|
+
if (row.ip_hash !== presentedIp) return null;
|
|
424
|
+
|
|
425
|
+
var mfaRequired = Number(row.mfa_required) === 1;
|
|
426
|
+
var mfaVerifiedAt = row.mfa_verified_at == null ? null : Number(row.mfa_verified_at);
|
|
427
|
+
|
|
428
|
+
if (mfaRequired && mfaVerifiedAt == null) {
|
|
429
|
+
// The bearer is valid, the IP is bound, the row is fresh, but
|
|
430
|
+
// the operator hasn't completed the step-up challenge yet.
|
|
431
|
+
// The caller routes them through MFA, then calls
|
|
432
|
+
// `recordMfaVerification(session_id)`.
|
|
433
|
+
return {
|
|
434
|
+
requires_mfa: true,
|
|
435
|
+
session_id: row.id,
|
|
436
|
+
operator_id: row.operator_id,
|
|
437
|
+
expires_at: Number(row.expires_at),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// First-hit activation. Flip `issued` → `active` and stamp
|
|
442
|
+
// `activated_at`. The WHERE clause re-asserts `status =
|
|
443
|
+
// 'issued'` so two racing verifies don't both stamp; the loser
|
|
444
|
+
// simply observes `status === 'active'` on the read below and
|
|
445
|
+
// falls through. We do NOT refuse on zero-row UPDATE — the
|
|
446
|
+
// session is already active, which is fine.
|
|
447
|
+
if (row.status === "issued") {
|
|
448
|
+
await query(
|
|
449
|
+
"UPDATE operator_sessions " +
|
|
450
|
+
"SET status = 'active', activated_at = ?1 " +
|
|
451
|
+
"WHERE id = ?2 AND status = 'issued'",
|
|
452
|
+
[now, row.id],
|
|
453
|
+
);
|
|
454
|
+
await _audit(
|
|
455
|
+
"activate",
|
|
456
|
+
row.operator_id,
|
|
457
|
+
row.id,
|
|
458
|
+
{ status: "issued" },
|
|
459
|
+
{ status: "active", activated_at: now },
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
session_id: row.id,
|
|
465
|
+
operator_id: row.operator_id,
|
|
466
|
+
expires_at: Number(row.expires_at),
|
|
467
|
+
mfa_verified_at: mfaVerifiedAt,
|
|
468
|
+
};
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
// ---- requireMfa -----------------------------------------------------
|
|
472
|
+
//
|
|
473
|
+
// Convenience wrapper around the optional `operatorRoles` peer.
|
|
474
|
+
// Returns the peer's boolean when wired; otherwise returns false
|
|
475
|
+
// so the primitive remains testable in isolation. The caller of
|
|
476
|
+
// `createSession` typically does NOT need to invoke this directly
|
|
477
|
+
// — the create call consults the peer itself when `mfa_required`
|
|
478
|
+
// is left unset.
|
|
479
|
+
requireMfa: async function (operatorId) {
|
|
480
|
+
var id = _uuid(operatorId, "operator_id");
|
|
481
|
+
if (!operatorRoles || typeof operatorRoles.requireMfa !== "function") {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
return Boolean(await operatorRoles.requireMfa(id));
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// ---- recordMfaVerification ------------------------------------------
|
|
488
|
+
//
|
|
489
|
+
// Stamps `mfa_verified_at` after the caller has finished the step-
|
|
490
|
+
// up challenge. Idempotent on already-verified rows (returns
|
|
491
|
+
// `{ verified: false }`); refuses on terminal rows the same way.
|
|
492
|
+
recordMfaVerification: async function (sessionId) {
|
|
493
|
+
var id = _uuid(sessionId, "session_id");
|
|
494
|
+
var now = _now();
|
|
495
|
+
// Pull the operator_id ahead of the flip so the audit row
|
|
496
|
+
// carries it even if the UPDATE matches zero rows. Failing here
|
|
497
|
+
// means the row doesn't exist — surface a null-ish return so
|
|
498
|
+
// the caller treats it identically to "already verified".
|
|
499
|
+
var existing = (await query(
|
|
500
|
+
"SELECT operator_id, mfa_verified_at, status FROM operator_sessions WHERE id = ?1",
|
|
501
|
+
[id],
|
|
502
|
+
)).rows[0];
|
|
503
|
+
if (!existing) {
|
|
504
|
+
return { verified: false };
|
|
505
|
+
}
|
|
506
|
+
var r = await query(
|
|
507
|
+
"UPDATE operator_sessions " +
|
|
508
|
+
"SET mfa_verified_at = ?1 " +
|
|
509
|
+
"WHERE id = ?2 AND mfa_verified_at IS NULL " +
|
|
510
|
+
" AND status IN ('issued', 'active')",
|
|
511
|
+
[now, id],
|
|
512
|
+
);
|
|
513
|
+
var flipped = Number(r.rowCount || 0) > 0;
|
|
514
|
+
if (flipped) {
|
|
515
|
+
await _audit(
|
|
516
|
+
"mfa_verify",
|
|
517
|
+
existing.operator_id,
|
|
518
|
+
id,
|
|
519
|
+
{ mfa_verified_at: null },
|
|
520
|
+
{ mfa_verified_at: now },
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
return { verified: flipped };
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
// ---- revokeSession --------------------------------------------------
|
|
527
|
+
//
|
|
528
|
+
// Operator-initiated kill (logout, password reset, suspected
|
|
529
|
+
// compromise) OR auto-revoke driven by lockoutCheck. Idempotent —
|
|
530
|
+
// calling on an already-terminal row returns `{ revoked: false }`
|
|
531
|
+
// rather than throwing, so the caller can safely loop over a list
|
|
532
|
+
// of session ids during a bulk eject.
|
|
533
|
+
revokeSession: async function (sessionId, reason) {
|
|
534
|
+
var id = _uuid(sessionId, "session_id");
|
|
535
|
+
var clean = _optShortString(reason, "reason", MAX_REASON_LEN);
|
|
536
|
+
if (clean == null) {
|
|
537
|
+
throw new TypeError(
|
|
538
|
+
"operator-sessions.revokeSession: reason required (non-empty string <= " +
|
|
539
|
+
MAX_REASON_LEN + " chars)"
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
var now = _now();
|
|
543
|
+
var existing = (await query(
|
|
544
|
+
"SELECT operator_id, status FROM operator_sessions WHERE id = ?1",
|
|
545
|
+
[id],
|
|
546
|
+
)).rows[0];
|
|
547
|
+
var r = await query(
|
|
548
|
+
"UPDATE operator_sessions " +
|
|
549
|
+
"SET status = 'revoked', revoked_at = ?1, revoke_reason = ?2 " +
|
|
550
|
+
"WHERE id = ?3 AND status IN ('issued', 'active')",
|
|
551
|
+
[now, clean, id],
|
|
552
|
+
);
|
|
553
|
+
var revoked = Number(r.rowCount || 0) > 0;
|
|
554
|
+
if (revoked && existing) {
|
|
555
|
+
await _audit(
|
|
556
|
+
"revoke",
|
|
557
|
+
existing.operator_id,
|
|
558
|
+
id,
|
|
559
|
+
{ status: existing.status },
|
|
560
|
+
{ status: "revoked", revoked_at: now, revoke_reason: clean },
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return { revoked: revoked };
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
// ---- listForOperator ------------------------------------------------
|
|
567
|
+
//
|
|
568
|
+
// Audit / debugging entry point. Returns every session ever
|
|
569
|
+
// minted for an operator, newest-first by created_at (id as
|
|
570
|
+
// tiebreak — both are uuid.v7-derived so the ordering is
|
|
571
|
+
// monotonic). Optional `from` / `to` bounds (ms epoch, inclusive
|
|
572
|
+
// lower / exclusive upper) constrain the window. Optional `status`
|
|
573
|
+
// filter narrows to a single FSM state.
|
|
574
|
+
listForOperator: async function (operatorId, listOpts) {
|
|
575
|
+
var oid = _uuid(operatorId, "operator_id");
|
|
576
|
+
listOpts = listOpts || {};
|
|
577
|
+
if (typeof listOpts !== "object") {
|
|
578
|
+
throw new TypeError("operator-sessions.listForOperator: opts must be an object");
|
|
579
|
+
}
|
|
580
|
+
var from = _optTsBound(listOpts.from, "from");
|
|
581
|
+
var to = _optTsBound(listOpts.to, "to");
|
|
582
|
+
var status = _optStatusFilter(listOpts.status);
|
|
583
|
+
|
|
584
|
+
var sql = "SELECT id, operator_id, ip_hash, ua_class, status, " +
|
|
585
|
+
"mfa_required, mfa_verified_at, activated_at, " +
|
|
586
|
+
"revoked_at, revoke_reason, ttl_seconds, " +
|
|
587
|
+
"created_at, expires_at " +
|
|
588
|
+
"FROM operator_sessions WHERE operator_id = ?1";
|
|
589
|
+
var params = [oid];
|
|
590
|
+
if (from != null) {
|
|
591
|
+
params.push(from);
|
|
592
|
+
sql += " AND created_at >= ?" + params.length;
|
|
593
|
+
}
|
|
594
|
+
if (to != null) {
|
|
595
|
+
params.push(to);
|
|
596
|
+
sql += " AND created_at < ?" + params.length;
|
|
597
|
+
}
|
|
598
|
+
if (status != null) {
|
|
599
|
+
params.push(status);
|
|
600
|
+
sql += " AND status = ?" + params.length;
|
|
601
|
+
}
|
|
602
|
+
sql += " ORDER BY created_at DESC, id DESC";
|
|
603
|
+
var r = await query(sql, params);
|
|
604
|
+
return r.rows;
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
// ---- expireOlderThan ------------------------------------------------
|
|
608
|
+
//
|
|
609
|
+
// Scheduler walk: flip every redeemable row (`issued` / `active`)
|
|
610
|
+
// whose `expires_at` is more than `seconds` in the past to
|
|
611
|
+
// `expired`. The lazy gate — verifyToken always re-checks
|
|
612
|
+
// `expires_at > now` regardless — but the durable FSM stamp keeps
|
|
613
|
+
// audit / "show me every live session" queries cheap. Returns the
|
|
614
|
+
// count of rows flipped so the cron / scheduled-worker layer can
|
|
615
|
+
// emit a metric.
|
|
616
|
+
expireOlderThan: async function (seconds) {
|
|
617
|
+
_seconds(seconds, "seconds");
|
|
618
|
+
var threshold = _now() - (seconds * 1000);
|
|
619
|
+
var r = await query(
|
|
620
|
+
"UPDATE operator_sessions " +
|
|
621
|
+
"SET status = 'expired' " +
|
|
622
|
+
"WHERE status IN ('issued', 'active') AND expires_at < ?1",
|
|
623
|
+
[threshold],
|
|
624
|
+
);
|
|
625
|
+
return { expired: Number(r.rowCount || 0) };
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
// ---- lockoutCheck ---------------------------------------------------
|
|
629
|
+
//
|
|
630
|
+
// Reads the last `window_seconds` of `operator_failed_logins` rows
|
|
631
|
+
// from `ip_hash`. Returns `{ locked: true }` when the count meets
|
|
632
|
+
// or exceeds threshold. Defaults: 5 failures in 15 minutes trips
|
|
633
|
+
// lockout. The caller is responsible for translating
|
|
634
|
+
// `locked: true` into an HTTP refusal AND for walking
|
|
635
|
+
// `listForOperator` to revoke any live sessions from the affected
|
|
636
|
+
// IP — this primitive does NOT auto-revoke on the lockoutCheck
|
|
637
|
+
// read itself (read-only contract). When the threshold is tripped
|
|
638
|
+
// the caller invokes `revokeSession` on each affected live row,
|
|
639
|
+
// and that revoke path lands an audit row via the standard
|
|
640
|
+
// composition.
|
|
641
|
+
lockoutCheck: async function (ipHash, lopts) {
|
|
642
|
+
var ip = _requiredString(ipHash, "ip_hash", MIN_IP_HASH_LEN, MAX_IP_HASH_LEN);
|
|
643
|
+
lopts = lopts || {};
|
|
644
|
+
if (typeof lopts !== "object") {
|
|
645
|
+
throw new TypeError("operator-sessions.lockoutCheck: opts must be an object");
|
|
646
|
+
}
|
|
647
|
+
var windowSec = _lockoutWindow(lopts.window_seconds);
|
|
648
|
+
var threshold = _lockoutThreshold(lopts.threshold);
|
|
649
|
+
var since = _now() - (windowSec * 1000);
|
|
650
|
+
var r = await query(
|
|
651
|
+
"SELECT COUNT(*) AS n FROM operator_failed_logins " +
|
|
652
|
+
"WHERE ip_hash = ?1 AND occurred_at >= ?2",
|
|
653
|
+
[ip, since],
|
|
654
|
+
);
|
|
655
|
+
var count = Number((r.rows[0] && r.rows[0].n) || 0);
|
|
656
|
+
return {
|
|
657
|
+
locked: count >= threshold,
|
|
658
|
+
count: count,
|
|
659
|
+
threshold: threshold,
|
|
660
|
+
window_seconds: windowSec,
|
|
661
|
+
};
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
// ---- recordFailedVerify ---------------------------------------------
|
|
665
|
+
//
|
|
666
|
+
// Append-only event log. The login middleware calls this on every
|
|
667
|
+
// primary-credential miss, MFA miss, or any other "authentication
|
|
668
|
+
// attempt refused" path. `operator_id` is optional — the miss may
|
|
669
|
+
// happen before the operator is even identified (unknown email,
|
|
670
|
+
// for instance). `reason` is a short free-form label
|
|
671
|
+
// ("bad-password", "mfa-fail", "unknown-operator",
|
|
672
|
+
// "ip-binding-mismatch") that the audit reader uses to bucket the
|
|
673
|
+
// timeline.
|
|
674
|
+
recordFailedVerify: async function (input) {
|
|
675
|
+
if (!input || typeof input !== "object") {
|
|
676
|
+
throw new TypeError("operator-sessions.recordFailedVerify: input object required");
|
|
677
|
+
}
|
|
678
|
+
var ip = _requiredString(input.ip_hash, "ip_hash", MIN_IP_HASH_LEN, MAX_IP_HASH_LEN);
|
|
679
|
+
var reason = _requiredString(input.reason, "reason", 1, MAX_REASON_LEN);
|
|
680
|
+
var operatorId = input.operator_id == null ? null : _uuid(input.operator_id, "operator_id");
|
|
681
|
+
var id = _b().uuid.v7();
|
|
682
|
+
var now = _now();
|
|
683
|
+
await query(
|
|
684
|
+
"INSERT INTO operator_failed_logins " +
|
|
685
|
+
"(id, ip_hash, operator_id, reason, occurred_at) " +
|
|
686
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
687
|
+
[id, ip, operatorId, reason, now],
|
|
688
|
+
);
|
|
689
|
+
return { id: id, occurred_at: now };
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
module.exports = {
|
|
695
|
+
create: create,
|
|
696
|
+
STATUS_VALUES: STATUS_VALUES,
|
|
697
|
+
DEFAULT_TTL_SECONDS: DEFAULT_TTL_SECONDS,
|
|
698
|
+
DEFAULT_LOCKOUT_WINDOW: DEFAULT_LOCKOUT_WINDOW,
|
|
699
|
+
DEFAULT_LOCKOUT_THRESH: DEFAULT_LOCKOUT_THRESH,
|
|
700
|
+
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
701
|
+
};
|