@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.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|