@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -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,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
+ };