@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,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
|
+
};
|