@blamejs/blamejs-shop 0.0.70 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -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 +42 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/loyalty-earn-rules.js +786 -0
  22. package/lib/marketing-budget.js +792 -0
  23. package/lib/operator-activity-feed.js +977 -0
  24. package/lib/operator-approvals.js +942 -0
  25. package/lib/operator-help-center.js +1020 -0
  26. package/lib/operator-inbox.js +889 -0
  27. package/lib/operator-sessions.js +701 -0
  28. package/lib/order-exchanges.js +602 -0
  29. package/lib/product-compare.js +804 -0
  30. package/lib/pwa-manifest.js +1005 -0
  31. package/lib/referral-leaderboard.js +612 -0
  32. package/lib/sales-tax-filings.js +807 -0
  33. package/lib/search-ranking.js +859 -0
  34. package/lib/shipping-insurance.js +757 -0
  35. package/lib/shrinkage-report.js +1182 -0
  36. package/lib/sidebar-widgets.js +952 -0
  37. package/lib/smart-restocking.js +1048 -0
  38. package/lib/split-shipments.js +7 -1
  39. package/lib/stock-receipts.js +834 -0
  40. package/lib/subscription-analytics.js +1032 -0
  41. package/lib/suggestion-box.js +921 -0
  42. package/lib/tax-remittance.js +625 -0
  43. package/lib/vendor-invoices.js +1021 -0
  44. package/lib/winback-campaigns.js +1350 -0
  45. package/lib/wishlist-digest.js +1133 -0
  46. package/package.json +1 -1
@@ -0,0 +1,743 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.customerImpersonation
4
+ * @title Customer impersonation — operator login-as-customer with
5
+ * strict audit trail, automatic timeout, and customer
6
+ * notification
7
+ *
8
+ * @intro
9
+ * Support troubleshooting routinely requires the operator to see the
10
+ * storefront the way a specific customer sees it — to reproduce a
11
+ * broken cart, debug an address-validation refusal, or confirm a
12
+ * promo applies. The naive shape ("operator signs in as the
13
+ * customer") is the worst possible shape from an audit standpoint:
14
+ * every action looks like it came from the customer themselves,
15
+ * there's no out-of-band signal to the customer that someone else
16
+ * peered into their account, and a forgotten session keeps the
17
+ * elevated authority alive indefinitely.
18
+ *
19
+ * This primitive replaces that pattern with an explicit
20
+ * impersonation session:
21
+ *
22
+ * 1. The operator calls `startImpersonation({ operator_id,
23
+ * customer_id, reason })`. When the optional `operatorRoles`
24
+ * peer is wired, the call gates on `hasPermission(operator_id,
25
+ * "can_impersonate_customer")` — operators without the
26
+ * capability are refused at the primitive layer, not in
27
+ * application code. The primitive mints a 32-byte base64url
28
+ * plaintext bearer via `b.crypto.generateBytes(32)` +
29
+ * `b.crypto.toBase64Url`, hashes it via
30
+ * `b.crypto.namespaceHash("customer-impersonation-token",
31
+ * plaintext)`, and writes the hash + a 60-minute TTL. The
32
+ * plaintext leaves this function ONCE; the database never
33
+ * stores it.
34
+ *
35
+ * 2. Every action the operator takes while the session is live is
36
+ * captured via `actionsRecord({ impersonation_id, action,
37
+ * resource_kind, resource_id })`. The application layer wires
38
+ * this into its existing operator middleware so the audit log
39
+ * is automatic — operators do not have to remember to record
40
+ * each action manually. The actions table is append-only;
41
+ * rows are never updated or deleted.
42
+ *
43
+ * 3. `notifyCustomer({ impersonation_id })` enqueues the customer
44
+ * notification via the optional `notifications` peer. The
45
+ * primitive stamps `customer_notified_at` on the row so the
46
+ * audit reader can spot the (rare) case where notifications
47
+ * was down when the session opened. The notification is
48
+ * out-of-band — typically an email — so the customer learns
49
+ * an operator viewed their account even if the operator never
50
+ * tells them. The application calls `notifyCustomer`
51
+ * immediately after `startImpersonation`; this primitive
52
+ * does NOT auto-notify because some support flows (a customer
53
+ * physically present at a retail counter) supply the consent
54
+ * synchronously and the email becomes noise.
55
+ *
56
+ * 4. `endImpersonation({ impersonation_id, ended_by, reason })`
57
+ * terminates the session. The row's `status` flips from
58
+ * `active` to `ended` and `ended_at` + `end_reason` land. The
59
+ * operator's session bearer is no longer valid; any future
60
+ * `verifyImpersonationToken` returns null. Calling on an
61
+ * already-terminal row returns `{ ended: false }` rather than
62
+ * throwing — the caller can safely loop over a list of session
63
+ * ids during a bulk eject.
64
+ *
65
+ * 5. `cleanupExpired` walks the active set, flips elapsed rows
66
+ * to `expired`, and returns the count. Operators run this on
67
+ * a schedule (every minute is plenty; the verify path already
68
+ * refuses elapsed-but-not-yet-swept rows).
69
+ *
70
+ * FSM:
71
+ * active — session live, verifyImpersonationToken returns context
72
+ * ended — operator finished and called endImpersonation
73
+ * (terminal)
74
+ * expired — TTL elapsed before endImpersonation
75
+ * (terminal; cleanupExpired swept the row)
76
+ * revoked — operator-side kill (terminal)
77
+ *
78
+ * Composes:
79
+ * - `b.crypto.generateBytes` — 32-byte CSPRNG draw for the bearer.
80
+ * - `b.crypto.toBase64Url` — URL-safe encoding for plaintext.
81
+ * - `b.crypto.namespaceHash` — keys the storage row off
82
+ * `("customer-impersonation-token",
83
+ * plaintext)` so a dump never
84
+ * reveals live bearers.
85
+ * - `b.crypto.timingSafeEqual` — constant-time check on verify.
86
+ * - `b.guardUuid` — UUID-shape validation on every
87
+ * operator_id / customer_id /
88
+ * impersonation_id.
89
+ * - `b.uuid.v7` — row ids.
90
+ * - `operatorRoles` (opt) — `hasPermission(...)` gate at
91
+ * `startImpersonation`. Absent, the
92
+ * primitive trusts the caller (the
93
+ * application has already gated).
94
+ * - `operatorAuditLog` (opt) — `record(...)` on every meaningful
95
+ * state transition (start, end,
96
+ * revoke, expired sweep).
97
+ * - `notifications` (opt) — `enqueue(...)` from
98
+ * `notifyCustomer` to deliver the
99
+ * "an operator viewed your account"
100
+ * message.
101
+ *
102
+ * Surface:
103
+ * - `startImpersonation({ operator_id, customer_id, reason,
104
+ * requires_capability?, ttl_seconds? })`
105
+ * → `{ impersonation_id, plaintext_token, expires_at }`
106
+ * - `verifyImpersonationToken(plaintext)`
107
+ * → null on miss / expired / terminal
108
+ * → `{ impersonation_id, operator_id, customer_id, expires_at,
109
+ * reason }` on hit
110
+ * - `endImpersonation({ impersonation_id, ended_by, reason })`
111
+ * → `{ ended: boolean }`
112
+ * - `notifyCustomer({ impersonation_id })`
113
+ * → `{ notified: boolean, customer_notified_at: <ms> | null }`
114
+ * - `listForOperator(operator_id, { active_only? })`
115
+ * → array of session rows, newest-first.
116
+ * - `listForCustomer(customer_id)`
117
+ * → array of session rows, newest-first.
118
+ * - `currentlyImpersonating()`
119
+ * → array of active session rows.
120
+ * - `cleanupExpired({ now? })`
121
+ * → `{ swept: <count>, now: <ms> }`
122
+ * - `actionsRecord({ impersonation_id, action, resource_kind,
123
+ * resource_id })`
124
+ * → `{ id, occurred_at }`
125
+ * - `actionsForSession(impersonation_id)`
126
+ * → array of action rows, oldest-first (timeline order).
127
+ *
128
+ * Storage:
129
+ * - `impersonations` + `impersonation_actions`
130
+ * (migration `0190_customer_impersonation.sql`).
131
+ *
132
+ * @primitive customerImpersonation
133
+ * @related b.crypto.generateBytes, b.crypto.namespaceHash,
134
+ * b.crypto.timingSafeEqual, b.guardUuid, b.uuid,
135
+ * operatorRoles, operatorAuditLog, notifications
136
+ */
137
+
138
+ var TOKEN_NAMESPACE = "customer-impersonation-token";
139
+ var TOKEN_BYTES = 32;
140
+ var DEFAULT_TTL_SECONDS = 60 * 60; // 60-minute default
141
+ var MIN_TTL_SECONDS = 60; // refuse sub-minute
142
+ var MAX_TTL_SECONDS = 8 * 60 * 60; // hard ceiling — eight hours
143
+ var MAX_REASON_LEN = 280;
144
+ var MAX_END_REASON_LEN = 280;
145
+ var MAX_ENDED_BY_LEN = 64;
146
+ var MAX_ACTION_LEN = 128;
147
+ var MAX_RESOURCE_KIND_LEN = 64;
148
+ var MAX_RESOURCE_ID_LEN = 256;
149
+ var DEFAULT_CAPABILITY = "can_impersonate_customer";
150
+
151
+ var STATUS_VALUES = Object.freeze([
152
+ "active", "ended", "expired", "revoked",
153
+ ]);
154
+
155
+ // Lazy framework handle — matches the pattern used by the rest of the
156
+ // shop primitives; avoids the require cycle that would arise from
157
+ // importing `./index` at module-eval time.
158
+ var bShop;
159
+ function _b() {
160
+ if (!bShop) bShop = require("./index");
161
+ return bShop.framework;
162
+ }
163
+
164
+ // ---- validators --------------------------------------------------------
165
+
166
+ function _uuid(s, label) {
167
+ try {
168
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
169
+ } catch (e) {
170
+ throw new TypeError("customer-impersonation: " + label + " — " +
171
+ (e && e.message || "invalid UUID"));
172
+ }
173
+ }
174
+
175
+ function _requiredString(s, label, maxLen) {
176
+ if (typeof s !== "string") {
177
+ throw new TypeError("customer-impersonation: " + label + " must be a string");
178
+ }
179
+ if (s.length === 0) {
180
+ throw new TypeError("customer-impersonation: " + label + " must be a non-empty string");
181
+ }
182
+ if (s.length > maxLen) {
183
+ throw new TypeError("customer-impersonation: " + label + " must be <= " +
184
+ maxLen + " characters");
185
+ }
186
+ if (/[\x00-\x1f\x7f]/.test(s)) {
187
+ throw new TypeError("customer-impersonation: " + label +
188
+ " must not contain control bytes");
189
+ }
190
+ return s;
191
+ }
192
+
193
+ function _ttlSeconds(n) {
194
+ if (n == null) return DEFAULT_TTL_SECONDS;
195
+ if (!Number.isInteger(n) || n < MIN_TTL_SECONDS || n > MAX_TTL_SECONDS) {
196
+ throw new TypeError(
197
+ "customer-impersonation: ttl_seconds must be an integer in [" +
198
+ MIN_TTL_SECONDS + ", " + MAX_TTL_SECONDS + "]"
199
+ );
200
+ }
201
+ return n;
202
+ }
203
+
204
+ function _bool(v, label) {
205
+ if (v == null) return false;
206
+ if (v === true || v === false) return v;
207
+ throw new TypeError("customer-impersonation: " + label + " must be a boolean");
208
+ }
209
+
210
+ function _optMsEpoch(n, label) {
211
+ if (n == null) return null;
212
+ if (!Number.isInteger(n) || n < 0) {
213
+ throw new TypeError("customer-impersonation: " + label +
214
+ " must be a non-negative integer (ms epoch)");
215
+ }
216
+ return n;
217
+ }
218
+
219
+ // ---- factory -----------------------------------------------------------
220
+
221
+ function create(opts) {
222
+ opts = opts || {};
223
+ var query = opts.query;
224
+ if (!query) {
225
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
226
+ }
227
+
228
+ // Optional peers. Duck-typed — the tests stub these without
229
+ // importing the full primitive.
230
+ var operatorRoles = opts.operatorRoles || null;
231
+ if (operatorRoles && typeof operatorRoles.hasPermission !== "function") {
232
+ throw new TypeError("customer-impersonation.create: opts.operatorRoles must " +
233
+ "expose a hasPermission(input) method");
234
+ }
235
+ var operatorAuditLog = opts.operatorAuditLog || null;
236
+ if (operatorAuditLog && typeof operatorAuditLog.record !== "function") {
237
+ throw new TypeError("customer-impersonation.create: opts.operatorAuditLog must " +
238
+ "expose a record(input) method");
239
+ }
240
+ var notifications = opts.notifications || null;
241
+ if (notifications && typeof notifications.enqueue !== "function") {
242
+ throw new TypeError("customer-impersonation.create: opts.notifications must " +
243
+ "expose an enqueue(input) method");
244
+ }
245
+
246
+ // Per-factory monotonic clock. Two operator actions in the same
247
+ // millisecond (startImpersonation followed by actionsRecord, for
248
+ // instance) still produce strictly-increasing timestamps so the
249
+ // audit timeline sorts deterministically.
250
+ var _lastTs = 0;
251
+ function _now() {
252
+ var wall = Date.now();
253
+ if (wall > _lastTs) _lastTs = wall;
254
+ else _lastTs += 1;
255
+ return _lastTs;
256
+ }
257
+
258
+ async function _audit(action, operatorId, impersonationId, before, after) {
259
+ if (!operatorAuditLog) return;
260
+ await operatorAuditLog.record({
261
+ actor_type: "operator",
262
+ actor_id: operatorId,
263
+ action: "impersonation." + action,
264
+ resource_kind: "customer_impersonation",
265
+ resource_id: impersonationId,
266
+ before: before,
267
+ after: after,
268
+ });
269
+ }
270
+
271
+ async function _getRow(id) {
272
+ var r = await query(
273
+ "SELECT id, operator_id, customer_id, token_hash, reason, status, " +
274
+ " started_at, ended_at, end_reason, expires_at, customer_notified_at " +
275
+ "FROM impersonations WHERE id = ?1",
276
+ [id],
277
+ );
278
+ return r.rows.length ? r.rows[0] : null;
279
+ }
280
+
281
+ function _projectRow(row) {
282
+ if (!row) return null;
283
+ return {
284
+ id: row.id,
285
+ operator_id: row.operator_id,
286
+ customer_id: row.customer_id,
287
+ reason: row.reason,
288
+ status: row.status,
289
+ started_at: Number(row.started_at),
290
+ ended_at: row.ended_at == null ? null : Number(row.ended_at),
291
+ end_reason: row.end_reason,
292
+ expires_at: Number(row.expires_at),
293
+ customer_notified_at: row.customer_notified_at == null
294
+ ? null : Number(row.customer_notified_at),
295
+ };
296
+ }
297
+
298
+ return {
299
+
300
+ STATUS_VALUES: STATUS_VALUES,
301
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
302
+ DEFAULT_TTL_SECONDS: DEFAULT_TTL_SECONDS,
303
+ DEFAULT_CAPABILITY: DEFAULT_CAPABILITY,
304
+
305
+ // ---- startImpersonation --------------------------------------------
306
+ //
307
+ // Mints a fresh impersonation session. When `operatorRoles` is
308
+ // wired, gates on `hasPermission(operator_id,
309
+ // requires_capability ?? "can_impersonate_customer")` — operators
310
+ // without the capability are refused with a typed error. The
311
+ // plaintext bearer is returned ONCE; the database stores only the
312
+ // namespaceHash. Default 60-minute TTL caps the blast radius if
313
+ // the operator forgets to call endImpersonation.
314
+ startImpersonation: async function (input) {
315
+ if (!input || typeof input !== "object") {
316
+ throw new TypeError("customer-impersonation.startImpersonation: input object required");
317
+ }
318
+ var operatorId = _uuid(input.operator_id, "operator_id");
319
+ var customerId = _uuid(input.customer_id, "customer_id");
320
+ var reason = _requiredString(input.reason, "reason", MAX_REASON_LEN);
321
+ var ttl = _ttlSeconds(input.ttl_seconds);
322
+ var capability = input.requires_capability == null
323
+ ? DEFAULT_CAPABILITY
324
+ : _requiredString(input.requires_capability, "requires_capability", 128);
325
+
326
+ // Capability gate via the optional operatorRoles peer. Absent
327
+ // the peer, the primitive trusts the caller (the application has
328
+ // gated upstream). With the peer wired, a missing capability is
329
+ // a typed refusal.
330
+ if (operatorRoles) {
331
+ var allowed = await operatorRoles.hasPermission({
332
+ operator_id: operatorId,
333
+ permission: capability,
334
+ });
335
+ if (!allowed) {
336
+ var err = new Error(
337
+ "customer-impersonation.startImpersonation: operator " + operatorId +
338
+ " lacks capability " + capability,
339
+ );
340
+ err.code = "IMPERSONATION_CAPABILITY_REFUSED";
341
+ throw err;
342
+ }
343
+ }
344
+
345
+ var plaintext = _b().crypto.toBase64Url(
346
+ _b().crypto.generateBytes(TOKEN_BYTES)
347
+ );
348
+ var tokenHash = _b().crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
349
+ var id = _b().uuid.v7();
350
+ var now = _now();
351
+ var expiresAt = now + (ttl * 1000);
352
+
353
+ await query(
354
+ "INSERT INTO impersonations " +
355
+ "(id, operator_id, customer_id, token_hash, reason, status, " +
356
+ " started_at, ended_at, end_reason, expires_at, customer_notified_at) " +
357
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, NULL, NULL, ?7, NULL)",
358
+ [id, operatorId, customerId, tokenHash, reason, now, expiresAt],
359
+ );
360
+
361
+ await _audit(
362
+ "start",
363
+ operatorId,
364
+ id,
365
+ null,
366
+ { status: "active", customer_id: customerId, expires_at: expiresAt },
367
+ );
368
+
369
+ return {
370
+ impersonation_id: id,
371
+ plaintext_token: plaintext,
372
+ expires_at: expiresAt,
373
+ operator_id: operatorId,
374
+ customer_id: customerId,
375
+ reason: reason,
376
+ started_at: now,
377
+ };
378
+ },
379
+
380
+ // ---- verifyImpersonationToken ---------------------------------------
381
+ //
382
+ // Resolves a plaintext bearer to the underlying impersonation
383
+ // context. Returns null on every refuse path (unknown token,
384
+ // expired, ended, revoked, malformed input). The constant-time
385
+ // compare on the hex hash defends against a future schema change
386
+ // that introduces a collection scan; the SQL = on token_hash is
387
+ // the lookup.
388
+ verifyImpersonationToken: async function (plaintext) {
389
+ if (typeof plaintext !== "string" || !plaintext.length) {
390
+ return null;
391
+ }
392
+ var tokenHash = _b().crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
393
+ var r = await query(
394
+ "SELECT id, operator_id, customer_id, token_hash, reason, status, " +
395
+ " expires_at " +
396
+ "FROM impersonations WHERE token_hash = ?1 LIMIT 1",
397
+ [tokenHash],
398
+ );
399
+ if (!r.rows.length) {
400
+ // Burn the same compare work as the hit path so the latency
401
+ // profile matches between miss and hit.
402
+ _b().crypto.timingSafeEqual(tokenHash, tokenHash);
403
+ return null;
404
+ }
405
+ var row = r.rows[0];
406
+ var matched = _b().crypto.timingSafeEqual(row.token_hash, tokenHash);
407
+ if (!matched) return null;
408
+ if (row.status !== "active") return null;
409
+ var now = _now();
410
+ if (Number(row.expires_at) <= now) return null;
411
+
412
+ return {
413
+ impersonation_id: row.id,
414
+ operator_id: row.operator_id,
415
+ customer_id: row.customer_id,
416
+ reason: row.reason,
417
+ expires_at: Number(row.expires_at),
418
+ };
419
+ },
420
+
421
+ // ---- endImpersonation -----------------------------------------------
422
+ //
423
+ // Operator-initiated termination. Idempotent — calling on an
424
+ // already-terminal row returns `{ ended: false }`. `ended_by` is
425
+ // a short label ("operator", "supervisor", "auto-timeout") that
426
+ // distinguishes operator-driven endings from system-driven ones.
427
+ endImpersonation: async function (input) {
428
+ if (!input || typeof input !== "object") {
429
+ throw new TypeError("customer-impersonation.endImpersonation: input object required");
430
+ }
431
+ var id = _uuid(input.impersonation_id, "impersonation_id");
432
+ var endedBy = _requiredString(input.ended_by, "ended_by", MAX_ENDED_BY_LEN);
433
+ var reason = _requiredString(input.reason, "reason", MAX_END_REASON_LEN);
434
+ var existing = await _getRow(id);
435
+ if (!existing) {
436
+ var miss = new Error("customer-impersonation.endImpersonation: " +
437
+ "impersonation not found");
438
+ miss.code = "IMPERSONATION_NOT_FOUND";
439
+ throw miss;
440
+ }
441
+ if (existing.status !== "active") {
442
+ return { ended: false };
443
+ }
444
+ var now = _now();
445
+ var endReasonLabel = endedBy + ": " + reason;
446
+ if (endReasonLabel.length > MAX_END_REASON_LEN) {
447
+ endReasonLabel = endReasonLabel.slice(0, MAX_END_REASON_LEN);
448
+ }
449
+ var r = await query(
450
+ "UPDATE impersonations " +
451
+ "SET status = 'ended', ended_at = ?1, end_reason = ?2 " +
452
+ "WHERE id = ?3 AND status = 'active'",
453
+ [now, endReasonLabel, id],
454
+ );
455
+ var ended = Number(r.rowCount || 0) > 0;
456
+ if (ended) {
457
+ await _audit(
458
+ "end",
459
+ existing.operator_id,
460
+ id,
461
+ { status: "active" },
462
+ { status: "ended", ended_at: now, end_reason: endReasonLabel },
463
+ );
464
+ }
465
+ return { ended: ended };
466
+ },
467
+
468
+ // ---- notifyCustomer -------------------------------------------------
469
+ //
470
+ // Enqueues a customer-side "an operator viewed your account"
471
+ // notification via the optional `notifications` peer. Stamps
472
+ // `customer_notified_at` on the row so the audit reader can
473
+ // confirm out-of-band delivery happened. Returns `{ notified:
474
+ // false }` when no peer is wired so the caller can decide whether
475
+ // to surface that as an operational warning.
476
+ //
477
+ // Idempotent on already-notified rows — re-calling returns the
478
+ // existing stamp without re-enqueuing. A peer that throws is
479
+ // surfaced (the caller MUST retry or fail loud — silent dropping
480
+ // of the customer notification would weaken the audit contract).
481
+ notifyCustomer: async function (input) {
482
+ if (!input || typeof input !== "object") {
483
+ throw new TypeError("customer-impersonation.notifyCustomer: input object required");
484
+ }
485
+ var id = _uuid(input.impersonation_id, "impersonation_id");
486
+ var existing = await _getRow(id);
487
+ if (!existing) {
488
+ var miss = new Error("customer-impersonation.notifyCustomer: " +
489
+ "impersonation not found");
490
+ miss.code = "IMPERSONATION_NOT_FOUND";
491
+ throw miss;
492
+ }
493
+ if (existing.customer_notified_at != null) {
494
+ return {
495
+ notified: true,
496
+ customer_notified_at: Number(existing.customer_notified_at),
497
+ };
498
+ }
499
+ if (!notifications) {
500
+ return { notified: false, customer_notified_at: null };
501
+ }
502
+ await notifications.enqueue({
503
+ recipient_id: existing.customer_id,
504
+ channel: "account-impersonation",
505
+ event_type: "customer_impersonation_started",
506
+ title: "An operator viewed your account",
507
+ body: "",
508
+ payload: {
509
+ impersonation_id: id,
510
+ operator_id: existing.operator_id,
511
+ reason: existing.reason,
512
+ started_at: Number(existing.started_at),
513
+ expires_at: Number(existing.expires_at),
514
+ },
515
+ });
516
+ var now = _now();
517
+ await query(
518
+ "UPDATE impersonations SET customer_notified_at = ?1 WHERE id = ?2",
519
+ [now, id],
520
+ );
521
+ return { notified: true, customer_notified_at: now };
522
+ },
523
+
524
+ // ---- listForOperator ------------------------------------------------
525
+ //
526
+ // Returns every impersonation session the operator has ever
527
+ // started, newest-first. `active_only: true` filters to live
528
+ // sessions only.
529
+ listForOperator: async function (operatorId, listOpts) {
530
+ var oid = _uuid(operatorId, "operator_id");
531
+ listOpts = listOpts || {};
532
+ if (typeof listOpts !== "object") {
533
+ throw new TypeError("customer-impersonation.listForOperator: opts must be an object");
534
+ }
535
+ var activeOnly = _bool(listOpts.active_only, "active_only");
536
+ var sql, params;
537
+ if (activeOnly) {
538
+ sql = "SELECT * FROM impersonations WHERE operator_id = ?1 AND status = 'active' " +
539
+ "ORDER BY started_at DESC, id DESC";
540
+ params = [oid];
541
+ } else {
542
+ sql = "SELECT * FROM impersonations WHERE operator_id = ?1 " +
543
+ "ORDER BY started_at DESC, id DESC";
544
+ params = [oid];
545
+ }
546
+ var r = await query(sql, params);
547
+ return r.rows.map(_projectRow);
548
+ },
549
+
550
+ // ---- listForCustomer ------------------------------------------------
551
+ //
552
+ // Returns every impersonation session ever opened against the
553
+ // customer, newest-first. The customer-side "show me who's looked
554
+ // at my account" page reads this directly.
555
+ listForCustomer: async function (customerId) {
556
+ var cid = _uuid(customerId, "customer_id");
557
+ var r = await query(
558
+ "SELECT * FROM impersonations WHERE customer_id = ?1 " +
559
+ "ORDER BY started_at DESC, id DESC",
560
+ [cid],
561
+ );
562
+ return r.rows.map(_projectRow);
563
+ },
564
+
565
+ // ---- currentlyImpersonating -----------------------------------------
566
+ //
567
+ // The operator dashboard's "who's currently impersonating whom"
568
+ // surface. Returns every `active` row across all operators,
569
+ // newest-first.
570
+ currentlyImpersonating: async function () {
571
+ var r = await query(
572
+ "SELECT * FROM impersonations WHERE status = 'active' " +
573
+ "ORDER BY started_at DESC, id DESC",
574
+ [],
575
+ );
576
+ return r.rows.map(_projectRow);
577
+ },
578
+
579
+ // ---- cleanupExpired -------------------------------------------------
580
+ //
581
+ // Scheduler walk — flips every `active` row whose `expires_at` is
582
+ // in the past to `expired`. Returns the count of rows swept.
583
+ // Idempotent — calling twice in a row produces 0 on the second
584
+ // call. Operators run this on a schedule (every minute is plenty
585
+ // — `verifyImpersonationToken` already refuses elapsed-but-not-
586
+ // yet-swept rows).
587
+ cleanupExpired: async function (input) {
588
+ input = input || {};
589
+ var now = _optMsEpoch(input.now, "now");
590
+ if (now == null) now = _now();
591
+ var r = await query(
592
+ "UPDATE impersonations SET status = 'expired', ended_at = ?1, " +
593
+ "end_reason = 'auto-timeout: ttl elapsed' " +
594
+ "WHERE status = 'active' AND expires_at <= ?1",
595
+ [now],
596
+ );
597
+ return { swept: Number(r.rowCount || 0), now: now };
598
+ },
599
+
600
+ // ---- actionsRecord --------------------------------------------------
601
+ //
602
+ // Append-only event log of every operator action taken while the
603
+ // impersonation session was live. The application layer wires this
604
+ // into its existing operator middleware so the audit log is
605
+ // automatic. Refuses on non-active sessions so a stale token
606
+ // can't accumulate spurious action rows after the session ended.
607
+ actionsRecord: async function (input) {
608
+ if (!input || typeof input !== "object") {
609
+ throw new TypeError("customer-impersonation.actionsRecord: input object required");
610
+ }
611
+ var impId = _uuid(input.impersonation_id, "impersonation_id");
612
+ var action = _requiredString(input.action, "action", MAX_ACTION_LEN);
613
+ var rKind = _requiredString(input.resource_kind, "resource_kind", MAX_RESOURCE_KIND_LEN);
614
+ var rId = _requiredString(input.resource_id, "resource_id", MAX_RESOURCE_ID_LEN);
615
+
616
+ var existing = await _getRow(impId);
617
+ if (!existing) {
618
+ var miss = new Error("customer-impersonation.actionsRecord: " +
619
+ "impersonation not found");
620
+ miss.code = "IMPERSONATION_NOT_FOUND";
621
+ throw miss;
622
+ }
623
+ if (existing.status !== "active") {
624
+ var badStatus = new Error("customer-impersonation.actionsRecord: " +
625
+ "impersonation status is " + existing.status + ", actions only " +
626
+ "record on active sessions");
627
+ badStatus.code = "IMPERSONATION_NOT_ACTIVE";
628
+ throw badStatus;
629
+ }
630
+ // Defensive — the verify gate already refuses expired rows, but
631
+ // a clock-skewed scheduler could leave an unsweep'd `active` row
632
+ // past expires_at. Refuse the action so the audit log doesn't
633
+ // accumulate post-expiry rows.
634
+ var now = _now();
635
+ if (Number(existing.expires_at) <= now) {
636
+ var elapsed = new Error("customer-impersonation.actionsRecord: " +
637
+ "impersonation expired (expires_at elapsed before cleanupExpired sweep)");
638
+ elapsed.code = "IMPERSONATION_EXPIRED";
639
+ throw elapsed;
640
+ }
641
+
642
+ var rowId = _b().uuid.v7();
643
+ await query(
644
+ "INSERT INTO impersonation_actions " +
645
+ "(id, impersonation_id, action, resource_kind, resource_id, occurred_at) " +
646
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
647
+ [rowId, impId, action, rKind, rId, now],
648
+ );
649
+ return {
650
+ id: rowId,
651
+ impersonation_id: impId,
652
+ action: action,
653
+ resource_kind: rKind,
654
+ resource_id: rId,
655
+ occurred_at: now,
656
+ };
657
+ },
658
+
659
+ // ---- actionsForSession ----------------------------------------------
660
+ //
661
+ // Timeline read — every action recorded against an impersonation
662
+ // session, oldest-first.
663
+ actionsForSession: async function (impersonationId) {
664
+ var impId = _uuid(impersonationId, "impersonation_id");
665
+ var r = await query(
666
+ "SELECT id, impersonation_id, action, resource_kind, resource_id, occurred_at " +
667
+ "FROM impersonation_actions WHERE impersonation_id = ?1 " +
668
+ "ORDER BY occurred_at ASC, id ASC",
669
+ [impId],
670
+ );
671
+ return r.rows.map(function (row) {
672
+ return {
673
+ id: row.id,
674
+ impersonation_id: row.impersonation_id,
675
+ action: row.action,
676
+ resource_kind: row.resource_kind,
677
+ resource_id: row.resource_id,
678
+ occurred_at: Number(row.occurred_at),
679
+ };
680
+ });
681
+ },
682
+
683
+ // ---- revoke ---------------------------------------------------------
684
+ //
685
+ // Operator-side kill switch — suspected misuse, customer
686
+ // complained, supervisor override. Distinct from
687
+ // `endImpersonation` because the audit log distinguishes
688
+ // operator-driven completion from operator-side termination.
689
+ // Idempotent on already-terminal rows.
690
+ revoke: async function (input) {
691
+ if (!input || typeof input !== "object") {
692
+ throw new TypeError("customer-impersonation.revoke: input object required");
693
+ }
694
+ var id = _uuid(input.impersonation_id, "impersonation_id");
695
+ var reason = _requiredString(input.reason, "reason", MAX_END_REASON_LEN);
696
+ var existing = await _getRow(id);
697
+ if (!existing) {
698
+ var miss = new Error("customer-impersonation.revoke: impersonation not found");
699
+ miss.code = "IMPERSONATION_NOT_FOUND";
700
+ throw miss;
701
+ }
702
+ if (existing.status !== "active") {
703
+ return { revoked: false };
704
+ }
705
+ var now = _now();
706
+ var r = await query(
707
+ "UPDATE impersonations " +
708
+ "SET status = 'revoked', ended_at = ?1, end_reason = ?2 " +
709
+ "WHERE id = ?3 AND status = 'active'",
710
+ [now, reason, id],
711
+ );
712
+ var revoked = Number(r.rowCount || 0) > 0;
713
+ if (revoked) {
714
+ await _audit(
715
+ "revoke",
716
+ existing.operator_id,
717
+ id,
718
+ { status: "active" },
719
+ { status: "revoked", ended_at: now, end_reason: reason },
720
+ );
721
+ }
722
+ return { revoked: revoked };
723
+ },
724
+
725
+ // ---- getSession -----------------------------------------------------
726
+ //
727
+ // Hydrated read of a single session by id. Returns null on miss so
728
+ // the caller can map cleanly to HTTP 404.
729
+ getSession: async function (impersonationId) {
730
+ var impId = _uuid(impersonationId, "impersonation_id");
731
+ var row = await _getRow(impId);
732
+ return _projectRow(row);
733
+ },
734
+ };
735
+ }
736
+
737
+ module.exports = {
738
+ create: create,
739
+ STATUS_VALUES: STATUS_VALUES,
740
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
741
+ DEFAULT_TTL_SECONDS: DEFAULT_TTL_SECONDS,
742
+ DEFAULT_CAPABILITY: DEFAULT_CAPABILITY,
743
+ };