@blamejs/blamejs-shop 0.0.65 → 0.0.70

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,768 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorRoles
4
+ * @title Operator roles — staff-side RBAC for the storefront's
5
+ * operator console (admin / support / fulfillment / accounting
6
+ * / marketing)
7
+ *
8
+ * @intro
9
+ * `customerRoles` (migration 0101) covers the BUYER side: a B2B
10
+ * company customer and its employee logins. This primitive covers
11
+ * the OPERATOR side: the humans (and machine actors) who run the
12
+ * storefront on the operator's behalf — admins, support agents,
13
+ * fulfillment clerks, accounting, marketing. They log in to the
14
+ * admin console, not to the storefront, and every gated action is
15
+ * audited through the composed `operatorAuditLog` peer (migration
16
+ * 0074).
17
+ *
18
+ * A role is an operator-authored bag of permission tokens drawn
19
+ * from a closed allow-list. The allow-list is closed at THIS
20
+ * primitive layer — `defineRole` refuses any token outside it, so
21
+ * a typo doesn't silently produce a role that grants nothing.
22
+ * Adding a new permission is a code change here plus the operator
23
+ * re-defining the affected roles.
24
+ *
25
+ * Permission allow-list:
26
+ *
27
+ * orders.read — view customer order rows
28
+ * orders.refund — issue a refund on a customer order
29
+ * orders.cancel — cancel an order before fulfillment
30
+ * customers.read — view customer rows
31
+ * customers.export — bulk-export customer data (PII grain)
32
+ * catalog.read — view product / variant / collection
33
+ * catalog.write — create / update / delete catalog rows
34
+ * inventory.write — adjust on-hand counts, post receipts
35
+ * vendors.manage — manage vendor registry + commissions
36
+ * settings.write — edit storefront-wide configuration
37
+ * billing.view — view operator-side billing & invoices
38
+ * reports.read — view sales / accounting / analytics
39
+ * users.invite — invite a new operator
40
+ * users.manage — change another operator's roles
41
+ * support.handle — assignee + state changes on support
42
+ * tickets
43
+ *
44
+ * Surface:
45
+ *
46
+ * - defineRole({ slug, title, permissions: [...], description? })
47
+ * Create a role. `slug` is the stable operator handle (PK).
48
+ * Redefinition is refused — operators mutate via `updateRole`.
49
+ *
50
+ * - assignRoleToOperator({ operator_id, role_slug, assigned_by,
51
+ * expires_at? })
52
+ * Attach the operator to the named role. Multi-role
53
+ * assignment is supported (an admin may also be a support
54
+ * handler). UNIQUE(operator_id, role_slug) holds while the
55
+ * edge is active — re-assignment of the same (operator,
56
+ * role) pair while still active is refused. After revoke,
57
+ * re-assignment creates a NEW row so the historical
58
+ * tombstone survives.
59
+ *
60
+ * - revokeRoleFromOperator({ operator_id, role_slug, revoked_by,
61
+ * reason })
62
+ * Stamp the tombstone (`revoked_at`, `revoked_by`,
63
+ * `revoke_reason`). Soft-delete preserves the audit grain.
64
+ * Idempotent — calling twice on an already-revoked edge
65
+ * throws. Time-based revocation through `expires_at` is
66
+ * honored without explicit revoke.
67
+ *
68
+ * - rolesForOperator(operator_id)
69
+ * Returns active assignment rows for the operator. Excludes
70
+ * rows whose `revoked_at IS NOT NULL` OR whose `expires_at`
71
+ * is in the past.
72
+ *
73
+ * - operatorsWithRole(role_slug)
74
+ * Inverse — every active operator carrying the named role.
75
+ *
76
+ * - hasPermission({ operator_id, permission })
77
+ * The fast-path gate. Returns true iff any active assignment
78
+ * the operator holds maps to a role whose `permissions`
79
+ * array contains the token. Expired and revoked assignments
80
+ * resolve false at query time without a separate sweep.
81
+ *
82
+ * - listRoles({ active_only? })
83
+ * Enumerate roles. `active_only: true` filters out archived.
84
+ *
85
+ * - updateRole(slug, patch)
86
+ * Patch `title` / `permissions` / `description`. Slug is
87
+ * immutable.
88
+ *
89
+ * - archiveRole(slug)
90
+ * Soft-delete. Existing assignments still resolve
91
+ * `hasPermission` so in-flight authority is preserved; new
92
+ * assignments are refused.
93
+ *
94
+ * - listPermissions()
95
+ * Returns the closed allow-list as a frozen array — UI
96
+ * renders the role-builder check-boxes from this.
97
+ *
98
+ * - recordPermissionUse({ operator_id, permission, context })
99
+ * Append-only per-use audit row. The caller composes
100
+ * `hasPermission(...)` then this — the primitive does NOT
101
+ * double-check authority so the same call records both
102
+ * allowed and denied attempts (the `context` payload carries
103
+ * the verdict if the caller wants it). Optional
104
+ * `operatorAuditLog` peer is invoked in parallel when wired
105
+ * so the chained audit also captures the action.
106
+ *
107
+ * - permissionUsageLog({ operator_id, permission, from, to })
108
+ * Read the per-use log clipped to an `[from, to)` epoch-ms
109
+ * window. Used for "show me every catalog.write the
110
+ * marketing team did this week" audits.
111
+ *
112
+ * Composition:
113
+ * - `b.uuid.v7` — assignment + audit row PKs
114
+ * - `shop.operatorAuditLog` — optional peer for chained audit
115
+ * (when wired, every
116
+ * `recordPermissionUse` also lands a
117
+ * row in `operator_audit_events`)
118
+ *
119
+ * Monotonic clock: a per-factory monotonic timestamp ensures that
120
+ * two assignments / revocations / permission-use rows landed within
121
+ * the same wall-clock millisecond carry strictly-increasing
122
+ * `assigned_at` / `revoked_at` / `occurred_at` values. Fast
123
+ * platforms collapse `Date.now()` to identical readings inside one
124
+ * tick; the monotonic bump keeps the audit ordering deterministic.
125
+ *
126
+ * Storage: `migrations-d1/0157_operator_roles.sql` — three tables
127
+ * (`operator_roles` + `operator_role_assignments` +
128
+ * `operator_permission_log`) with their indexes.
129
+ *
130
+ * @primitive operatorRoles
131
+ * @related operatorAuditLog, customerRoles, b.uuid
132
+ */
133
+
134
+ // ---- constants ----------------------------------------------------------
135
+
136
+ var MAX_SLUG_LEN = 80;
137
+ var MAX_TITLE_LEN = 200;
138
+ var MAX_DESC_LEN = 2000;
139
+ var MAX_OPERATOR_ID_LEN = 128;
140
+ var MAX_REASON_LEN = 500;
141
+ var MAX_CONTEXT_BYTES = 16 * 1024;
142
+ var MAX_LIST_LIMIT = 500;
143
+
144
+ // Slug shape matches the rest of the codebase — alnum-leading, alnum
145
+ // + hyphen + underscore + dot, capped length.
146
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
147
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
148
+
149
+ // Closed permission allow-list. Adding a new permission is a code
150
+ // change here AND the operator re-defining the affected roles.
151
+ var PERMISSIONS = Object.freeze([
152
+ "orders.read",
153
+ "orders.refund",
154
+ "orders.cancel",
155
+ "customers.read",
156
+ "customers.export",
157
+ "catalog.read",
158
+ "catalog.write",
159
+ "inventory.write",
160
+ "vendors.manage",
161
+ "settings.write",
162
+ "billing.view",
163
+ "reports.read",
164
+ "users.invite",
165
+ "users.manage",
166
+ "support.handle",
167
+ ]);
168
+
169
+ var ALLOWED_PATCH_COLUMNS = Object.freeze(["title", "permissions", "description"]);
170
+
171
+ var bShop;
172
+ function _b() {
173
+ if (!bShop) bShop = require("./index");
174
+ return bShop.framework;
175
+ }
176
+
177
+ // ---- validators ---------------------------------------------------------
178
+
179
+ function _slug(s, label) {
180
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
181
+ throw new TypeError("operatorRoles: " + (label || "slug") +
182
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _title(s) {
188
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
189
+ throw new TypeError("operatorRoles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
190
+ }
191
+ if (CONTROL_BYTE_RE.test(s)) {
192
+ throw new TypeError("operatorRoles: title must not contain control bytes");
193
+ }
194
+ return s;
195
+ }
196
+
197
+ function _description(s) {
198
+ if (s == null) return null;
199
+ if (typeof s !== "string" || s.length > MAX_DESC_LEN) {
200
+ throw new TypeError("operatorRoles: description must be a string <= " + MAX_DESC_LEN + " chars");
201
+ }
202
+ if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
203
+ throw new TypeError("operatorRoles: description must not contain control bytes (except whitespace)");
204
+ }
205
+ return s;
206
+ }
207
+
208
+ function _permission(s) {
209
+ if (typeof s !== "string" || PERMISSIONS.indexOf(s) === -1) {
210
+ throw new TypeError("operatorRoles: permission " + JSON.stringify(s) +
211
+ " is not in the allow-list (" + PERMISSIONS.join(", ") + ")");
212
+ }
213
+ return s;
214
+ }
215
+
216
+ function _permissions(arr) {
217
+ if (!Array.isArray(arr) || !arr.length) {
218
+ throw new TypeError("operatorRoles: permissions must be a non-empty array of allow-list tokens");
219
+ }
220
+ var seen = Object.create(null);
221
+ var out = [];
222
+ for (var i = 0; i < arr.length; i += 1) {
223
+ var p = _permission(arr[i]);
224
+ if (seen[p]) {
225
+ throw new TypeError("operatorRoles: permissions contains duplicate " + JSON.stringify(p));
226
+ }
227
+ seen[p] = true;
228
+ out.push(p);
229
+ }
230
+ return out;
231
+ }
232
+
233
+ function _operatorId(s, label) {
234
+ if (typeof s !== "string" || !s.length || s.length > MAX_OPERATOR_ID_LEN) {
235
+ throw new TypeError("operatorRoles: " + label + " must be a non-empty string (<= " + MAX_OPERATOR_ID_LEN + " chars)");
236
+ }
237
+ if (CONTROL_BYTE_RE.test(s)) {
238
+ throw new TypeError("operatorRoles: " + label + " must not contain control bytes");
239
+ }
240
+ return s;
241
+ }
242
+
243
+ function _reason(s) {
244
+ if (typeof s !== "string" || !s.length || s.length > MAX_REASON_LEN) {
245
+ throw new TypeError("operatorRoles: reason must be a non-empty string <= " + MAX_REASON_LEN + " chars");
246
+ }
247
+ if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
248
+ throw new TypeError("operatorRoles: reason must not contain control bytes (except whitespace)");
249
+ }
250
+ return s;
251
+ }
252
+
253
+ function _epochMs(n, label) {
254
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
255
+ throw new TypeError("operatorRoles: " + label + " must be a non-negative integer (epoch ms)");
256
+ }
257
+ return n;
258
+ }
259
+
260
+ function _context(v) {
261
+ if (v == null) return null;
262
+ // Accept any JSON-serializable value; refuse non-serializable shapes
263
+ // (functions, symbols, BigInt) early.
264
+ var json;
265
+ try { json = JSON.stringify(v); }
266
+ catch (_e) {
267
+ throw new TypeError("operatorRoles: context must be JSON-serializable");
268
+ }
269
+ if (json === undefined) {
270
+ throw new TypeError("operatorRoles: context must be JSON-serializable");
271
+ }
272
+ if (json.length > MAX_CONTEXT_BYTES) {
273
+ throw new TypeError("operatorRoles: context must serialize to <= " + MAX_CONTEXT_BYTES + " bytes");
274
+ }
275
+ return v;
276
+ }
277
+
278
+ function _now() { return Date.now(); }
279
+
280
+ // ---- row hydration ------------------------------------------------------
281
+
282
+ function _safeParseArray(s) {
283
+ if (s == null) return [];
284
+ try {
285
+ var parsed = JSON.parse(s);
286
+ if (Array.isArray(parsed)) return parsed;
287
+ return [];
288
+ } catch (_e) {
289
+ return [];
290
+ }
291
+ }
292
+
293
+ function _hydrateRole(r) {
294
+ if (!r) return null;
295
+ // Re-filter permissions through the allow-list on read so a hand-
296
+ // edited or migration-era row carrying a stale token is silently
297
+ // dropped instead of surfacing a grant that the runtime doesn't
298
+ // honor.
299
+ var raw = _safeParseArray(r.permissions_json);
300
+ var perms = [];
301
+ for (var i = 0; i < raw.length; i += 1) {
302
+ if (typeof raw[i] === "string" && PERMISSIONS.indexOf(raw[i]) !== -1) {
303
+ perms.push(raw[i]);
304
+ }
305
+ }
306
+ return {
307
+ slug: r.slug,
308
+ title: r.title,
309
+ permissions: perms,
310
+ description: r.description == null ? null : r.description,
311
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
312
+ created_at: Number(r.created_at),
313
+ updated_at: Number(r.updated_at),
314
+ };
315
+ }
316
+
317
+ function _hydrateAssignment(r) {
318
+ if (!r) return null;
319
+ return {
320
+ id: r.id,
321
+ operator_id: r.operator_id,
322
+ role_slug: r.role_slug,
323
+ assigned_by: r.assigned_by,
324
+ assigned_at: Number(r.assigned_at),
325
+ expires_at: r.expires_at == null ? null : Number(r.expires_at),
326
+ revoked_at: r.revoked_at == null ? null : Number(r.revoked_at),
327
+ revoked_by: r.revoked_by == null ? null : r.revoked_by,
328
+ revoke_reason: r.revoke_reason == null ? null : r.revoke_reason,
329
+ };
330
+ }
331
+
332
+ function _hydratePermissionLog(r) {
333
+ if (!r) return null;
334
+ var ctx = null;
335
+ if (r.context != null) {
336
+ try { ctx = JSON.parse(r.context); }
337
+ catch (_e) { ctx = null; }
338
+ }
339
+ return {
340
+ id: r.id,
341
+ operator_id: r.operator_id,
342
+ permission: r.permission,
343
+ context: ctx,
344
+ occurred_at: Number(r.occurred_at),
345
+ };
346
+ }
347
+
348
+ // ---- factory ------------------------------------------------------------
349
+
350
+ function create(opts) {
351
+ opts = opts || {};
352
+ var query = opts.query;
353
+ if (!query) {
354
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
355
+ }
356
+ // Optional peer — when wired, every `recordPermissionUse` also lands
357
+ // a row in the chained `operator_audit_events` log. The primitive
358
+ // composes via duck-typed `.record(...)` so the tests can stub it.
359
+ var operatorAuditLog = opts.operatorAuditLog || null;
360
+
361
+ // Per-factory monotonic clock. Fast platforms collapse `Date.now()`
362
+ // to identical readings inside one tick; the bump keeps the audit
363
+ // ordering deterministic — two assignments / revocations /
364
+ // permission-use rows landed in the same millisecond still carry
365
+ // strictly-increasing timestamps.
366
+ var _lastTs = 0;
367
+ function _monotonicTs() {
368
+ var wall = _now();
369
+ if (wall > _lastTs) _lastTs = wall;
370
+ else _lastTs += 1;
371
+ return _lastTs;
372
+ }
373
+
374
+ // ---- defineRole ----------------------------------------------------
375
+
376
+ async function defineRole(input) {
377
+ if (!input || typeof input !== "object") {
378
+ throw new TypeError("operatorRoles.defineRole: input object required");
379
+ }
380
+ var slug = _slug(input.slug);
381
+ var title = _title(input.title);
382
+ var perms = _permissions(input.permissions);
383
+ var desc = _description(input.description);
384
+
385
+ var existing = (await query(
386
+ "SELECT slug FROM operator_roles WHERE slug = ?1 LIMIT 1",
387
+ [slug],
388
+ )).rows[0];
389
+ if (existing) {
390
+ throw new TypeError("operatorRoles.defineRole: slug " + JSON.stringify(slug) +
391
+ " already exists - use updateRole");
392
+ }
393
+
394
+ var ts = _monotonicTs();
395
+ await query(
396
+ "INSERT INTO operator_roles (slug, title, permissions_json, description, archived_at, created_at, updated_at) " +
397
+ "VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
398
+ [slug, title, JSON.stringify(perms), desc, ts],
399
+ );
400
+ return await getRole(slug);
401
+ }
402
+
403
+ // ---- getRole / listRoles -------------------------------------------
404
+
405
+ async function getRole(slug) {
406
+ _slug(slug);
407
+ var r = (await query(
408
+ "SELECT * FROM operator_roles WHERE slug = ?1 LIMIT 1",
409
+ [slug],
410
+ )).rows[0];
411
+ return _hydrateRole(r);
412
+ }
413
+
414
+ async function listRoles(listOpts) {
415
+ listOpts = listOpts || {};
416
+ var activeOnly = false;
417
+ if (listOpts.active_only != null) {
418
+ if (typeof listOpts.active_only !== "boolean") {
419
+ throw new TypeError("operatorRoles.listRoles: active_only must be a boolean");
420
+ }
421
+ activeOnly = listOpts.active_only;
422
+ }
423
+ var limit = listOpts.limit == null ? 100 : listOpts.limit;
424
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
425
+ throw new TypeError("operatorRoles.listRoles: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
426
+ }
427
+ var sql, params;
428
+ if (activeOnly) {
429
+ sql = "SELECT * FROM operator_roles WHERE archived_at IS NULL " +
430
+ "ORDER BY created_at ASC, slug ASC LIMIT ?1";
431
+ params = [limit];
432
+ } else {
433
+ sql = "SELECT * FROM operator_roles ORDER BY created_at ASC, slug ASC LIMIT ?1";
434
+ params = [limit];
435
+ }
436
+ var rows = (await query(sql, params)).rows;
437
+ var out = [];
438
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRole(rows[i]));
439
+ return out;
440
+ }
441
+
442
+ // ---- updateRole ----------------------------------------------------
443
+
444
+ async function updateRole(slug, patch) {
445
+ _slug(slug);
446
+ if (!patch || typeof patch !== "object") {
447
+ throw new TypeError("operatorRoles.updateRole: patch object required");
448
+ }
449
+ var keys = Object.keys(patch);
450
+ if (!keys.length) {
451
+ throw new TypeError("operatorRoles.updateRole: patch must include at least one column");
452
+ }
453
+ var current = await getRole(slug);
454
+ if (!current) {
455
+ throw new TypeError("operatorRoles.updateRole: slug " + JSON.stringify(slug) + " not found");
456
+ }
457
+
458
+ var sets = [];
459
+ var params = [];
460
+ var idx = 1;
461
+ for (var i = 0; i < keys.length; i += 1) {
462
+ var col = keys[i];
463
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
464
+ throw new TypeError("operatorRoles.updateRole: unsupported column " + JSON.stringify(col));
465
+ }
466
+ if (col === "title") {
467
+ sets.push("title = ?" + idx);
468
+ params.push(_title(patch[col]));
469
+ } else if (col === "permissions") {
470
+ sets.push("permissions_json = ?" + idx);
471
+ params.push(JSON.stringify(_permissions(patch[col])));
472
+ } else /* description */ {
473
+ sets.push("description = ?" + idx);
474
+ params.push(_description(patch[col]));
475
+ }
476
+ idx += 1;
477
+ }
478
+ sets.push("updated_at = ?" + idx);
479
+ params.push(_monotonicTs());
480
+ idx += 1;
481
+ params.push(slug);
482
+
483
+ var r = await query(
484
+ "UPDATE operator_roles SET " + sets.join(", ") + " WHERE slug = ?" + idx,
485
+ params,
486
+ );
487
+ if (Number(r.rowCount || 0) === 0) {
488
+ throw new TypeError("operatorRoles.updateRole: slug " + JSON.stringify(slug) + " not found");
489
+ }
490
+ return await getRole(slug);
491
+ }
492
+
493
+ // ---- archiveRole ---------------------------------------------------
494
+
495
+ async function archiveRole(slug) {
496
+ _slug(slug);
497
+ var current = await getRole(slug);
498
+ if (!current) {
499
+ throw new TypeError("operatorRoles.archiveRole: slug " + JSON.stringify(slug) + " not found");
500
+ }
501
+ // Idempotent — re-archive returns the existing tombstone.
502
+ if (current.archived_at != null) return current;
503
+ var ts = _monotonicTs();
504
+ await query(
505
+ "UPDATE operator_roles SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
506
+ [ts, slug],
507
+ );
508
+ return await getRole(slug);
509
+ }
510
+
511
+ // ---- assignRoleToOperator ------------------------------------------
512
+
513
+ async function assignRoleToOperator(input) {
514
+ if (!input || typeof input !== "object") {
515
+ throw new TypeError("operatorRoles.assignRoleToOperator: input object required");
516
+ }
517
+ var operatorId = _operatorId(input.operator_id, "operator_id");
518
+ var roleSlug = _slug(input.role_slug, "role_slug");
519
+ var assignedBy = _operatorId(input.assigned_by, "assigned_by");
520
+ var expiresAt = input.expires_at == null ? null : _epochMs(input.expires_at, "expires_at");
521
+
522
+ var role = await getRole(roleSlug);
523
+ if (!role) {
524
+ throw new TypeError("operatorRoles.assignRoleToOperator: role " +
525
+ JSON.stringify(roleSlug) + " not found");
526
+ }
527
+ if (role.archived_at != null) {
528
+ throw new TypeError("operatorRoles.assignRoleToOperator: role " +
529
+ JSON.stringify(roleSlug) + " is archived - new assignments are refused");
530
+ }
531
+
532
+ var ts = _monotonicTs();
533
+ if (expiresAt != null && expiresAt <= ts) {
534
+ throw new TypeError("operatorRoles.assignRoleToOperator: expires_at must be in the future");
535
+ }
536
+
537
+ // UNIQUE(operator_id, role_slug) holds for ACTIVE edges. An
538
+ // already-revoked tombstone leaves the historical row in place; a
539
+ // NEW assignment after revoke creates a new row so the audit
540
+ // trail survives.
541
+ var active = (await query(
542
+ "SELECT id FROM operator_role_assignments " +
543
+ "WHERE operator_id = ?1 AND role_slug = ?2 AND revoked_at IS NULL LIMIT 1",
544
+ [operatorId, roleSlug],
545
+ )).rows[0];
546
+ if (active) {
547
+ throw new TypeError("operatorRoles.assignRoleToOperator: operator " +
548
+ JSON.stringify(operatorId) + " already holds role " +
549
+ JSON.stringify(roleSlug) + " - revoke first to re-assign");
550
+ }
551
+
552
+ var id = _b().uuid.v7();
553
+ await query(
554
+ "INSERT INTO operator_role_assignments " +
555
+ "(id, operator_id, role_slug, assigned_by, assigned_at, expires_at, revoked_at, revoked_by, revoke_reason) " +
556
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, NULL)",
557
+ [id, operatorId, roleSlug, assignedBy, ts, expiresAt],
558
+ );
559
+ return {
560
+ id: id,
561
+ operator_id: operatorId,
562
+ role_slug: roleSlug,
563
+ assigned_by: assignedBy,
564
+ assigned_at: ts,
565
+ expires_at: expiresAt,
566
+ revoked_at: null,
567
+ revoked_by: null,
568
+ revoke_reason: null,
569
+ };
570
+ }
571
+
572
+ // ---- revokeRoleFromOperator ----------------------------------------
573
+
574
+ async function revokeRoleFromOperator(input) {
575
+ if (!input || typeof input !== "object") {
576
+ throw new TypeError("operatorRoles.revokeRoleFromOperator: input object required");
577
+ }
578
+ var operatorId = _operatorId(input.operator_id, "operator_id");
579
+ var roleSlug = _slug(input.role_slug, "role_slug");
580
+ var revokedBy = _operatorId(input.revoked_by, "revoked_by");
581
+ var reason = _reason(input.reason);
582
+
583
+ var active = (await query(
584
+ "SELECT id FROM operator_role_assignments " +
585
+ "WHERE operator_id = ?1 AND role_slug = ?2 AND revoked_at IS NULL LIMIT 1",
586
+ [operatorId, roleSlug],
587
+ )).rows[0];
588
+ if (!active) {
589
+ throw new TypeError("operatorRoles.revokeRoleFromOperator: no active assignment for operator " +
590
+ JSON.stringify(operatorId) + " role " + JSON.stringify(roleSlug));
591
+ }
592
+
593
+ var ts = _monotonicTs();
594
+ await query(
595
+ "UPDATE operator_role_assignments SET revoked_at = ?1, revoked_by = ?2, revoke_reason = ?3 " +
596
+ "WHERE id = ?4",
597
+ [ts, revokedBy, reason, active.id],
598
+ );
599
+ var updated = (await query(
600
+ "SELECT * FROM operator_role_assignments WHERE id = ?1",
601
+ [active.id],
602
+ )).rows[0];
603
+ return _hydrateAssignment(updated);
604
+ }
605
+
606
+ // ---- rolesForOperator ----------------------------------------------
607
+
608
+ async function rolesForOperator(operatorId) {
609
+ var id = _operatorId(operatorId, "operator_id");
610
+ var now = _now();
611
+ var rows = (await query(
612
+ "SELECT * FROM operator_role_assignments " +
613
+ "WHERE operator_id = ?1 AND revoked_at IS NULL " +
614
+ "AND (expires_at IS NULL OR expires_at > ?2) " +
615
+ "ORDER BY assigned_at ASC",
616
+ [id, now],
617
+ )).rows;
618
+ var out = [];
619
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateAssignment(rows[i]));
620
+ return out;
621
+ }
622
+
623
+ // ---- operatorsWithRole ---------------------------------------------
624
+
625
+ async function operatorsWithRole(roleSlug) {
626
+ _slug(roleSlug, "role_slug");
627
+ var now = _now();
628
+ var rows = (await query(
629
+ "SELECT * FROM operator_role_assignments " +
630
+ "WHERE role_slug = ?1 AND revoked_at IS NULL " +
631
+ "AND (expires_at IS NULL OR expires_at > ?2) " +
632
+ "ORDER BY assigned_at ASC",
633
+ [roleSlug, now],
634
+ )).rows;
635
+ var out = [];
636
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateAssignment(rows[i]));
637
+ return out;
638
+ }
639
+
640
+ // ---- hasPermission -------------------------------------------------
641
+
642
+ async function hasPermission(input) {
643
+ if (!input || typeof input !== "object") {
644
+ throw new TypeError("operatorRoles.hasPermission: input object required");
645
+ }
646
+ var operatorId = _operatorId(input.operator_id, "operator_id");
647
+ var permission = _permission(input.permission);
648
+
649
+ var now = _now();
650
+ var rows = (await query(
651
+ "SELECT role_slug FROM operator_role_assignments " +
652
+ "WHERE operator_id = ?1 AND revoked_at IS NULL " +
653
+ "AND (expires_at IS NULL OR expires_at > ?2)",
654
+ [operatorId, now],
655
+ )).rows;
656
+ if (!rows.length) return false;
657
+
658
+ for (var i = 0; i < rows.length; i += 1) {
659
+ var role = await getRole(rows[i].role_slug);
660
+ if (!role) continue;
661
+ // Archived roles still resolve — the in-flight authority that an
662
+ // operator already carries is honored. New assignments to an
663
+ // archived role are refused upstream in `assignRoleToOperator`.
664
+ if (role.permissions.indexOf(permission) !== -1) return true;
665
+ }
666
+ return false;
667
+ }
668
+
669
+ // ---- listPermissions -----------------------------------------------
670
+
671
+ function listPermissions() {
672
+ return PERMISSIONS;
673
+ }
674
+
675
+ // ---- recordPermissionUse + permissionUsageLog ----------------------
676
+
677
+ async function recordPermissionUse(input) {
678
+ if (!input || typeof input !== "object") {
679
+ throw new TypeError("operatorRoles.recordPermissionUse: input object required");
680
+ }
681
+ var operatorId = _operatorId(input.operator_id, "operator_id");
682
+ var permission = _permission(input.permission);
683
+ var context = _context(input.context);
684
+
685
+ var id = _b().uuid.v7();
686
+ var ts = _monotonicTs();
687
+ var ctxJson = context == null ? null : JSON.stringify(context);
688
+ await query(
689
+ "INSERT INTO operator_permission_log (id, operator_id, permission, context, occurred_at) " +
690
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
691
+ [id, operatorId, permission, ctxJson, ts],
692
+ );
693
+
694
+ // Chained audit through the optional peer. The duck-typed `.record`
695
+ // call mirrors the `operatorAuditLog.record` shape (migration 0074).
696
+ // Failures here are surfaced — the chained audit is part of the
697
+ // primitive's contract when the operator wires it in.
698
+ if (operatorAuditLog && typeof operatorAuditLog.record === "function") {
699
+ await operatorAuditLog.record({
700
+ actor_type: "operator",
701
+ actor_id: operatorId,
702
+ action: "permission.use:" + permission,
703
+ resource_kind: "operator_permission",
704
+ resource_id: permission,
705
+ before: null,
706
+ after: context == null ? null : { context: context },
707
+ });
708
+ }
709
+
710
+ return {
711
+ id: id,
712
+ operator_id: operatorId,
713
+ permission: permission,
714
+ context: context,
715
+ occurred_at: ts,
716
+ };
717
+ }
718
+
719
+ async function permissionUsageLog(input) {
720
+ if (!input || typeof input !== "object") {
721
+ throw new TypeError("operatorRoles.permissionUsageLog: input object required");
722
+ }
723
+ var operatorId = _operatorId(input.operator_id, "operator_id");
724
+ var permission = _permission(input.permission);
725
+ var from = _epochMs(input.from, "from");
726
+ var to = _epochMs(input.to, "to");
727
+ if (to < from) {
728
+ throw new TypeError("operatorRoles.permissionUsageLog: to must be >= from");
729
+ }
730
+ var limit = input.limit == null ? 100 : input.limit;
731
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
732
+ throw new TypeError("operatorRoles.permissionUsageLog: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
733
+ }
734
+ var rows = (await query(
735
+ "SELECT * FROM operator_permission_log " +
736
+ "WHERE operator_id = ?1 AND permission = ?2 AND occurred_at >= ?3 AND occurred_at < ?4 " +
737
+ "ORDER BY occurred_at DESC LIMIT ?5",
738
+ [operatorId, permission, from, to, limit],
739
+ )).rows;
740
+ var out = [];
741
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydratePermissionLog(rows[i]));
742
+ return out;
743
+ }
744
+
745
+ return {
746
+ PERMISSIONS: PERMISSIONS,
747
+ defineRole: defineRole,
748
+ getRole: getRole,
749
+ listRoles: listRoles,
750
+ updateRole: updateRole,
751
+ archiveRole: archiveRole,
752
+ assignRoleToOperator: assignRoleToOperator,
753
+ revokeRoleFromOperator: revokeRoleFromOperator,
754
+ rolesForOperator: rolesForOperator,
755
+ operatorsWithRole: operatorsWithRole,
756
+ hasPermission: hasPermission,
757
+ listPermissions: listPermissions,
758
+ recordPermissionUse: recordPermissionUse,
759
+ permissionUsageLog: permissionUsageLog,
760
+ };
761
+ }
762
+
763
+ module.exports = {
764
+ create: create,
765
+ PERMISSIONS: PERMISSIONS,
766
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
767
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
768
+ };