@blamejs/blamejs-shop 0.0.72 → 0.0.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,942 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorApprovals
4
+ * @title Operator approvals — multi-step approval workflows for
5
+ * high-risk operator actions
6
+ *
7
+ * @intro
8
+ * Some operator actions are too risky to execute on the strength
9
+ * of a single operator's intent — large refunds (the customer
10
+ * trust + chargeback-window exposure exceeds the single agent's
11
+ * blast radius), bulk catalog edits (a typo in a bulk-price-cut
12
+ * propagates to every variant before anyone notices), payment-
13
+ * method changes (the operator's own banking destination shifts
14
+ * under the chargeback queue), vendor payout overrides (real
15
+ * money to a third party). The `operatorApprovals` primitive
16
+ * models these as multi-step workflows: the requesting operator
17
+ * submits the action, named approvers cast votes, and the
18
+ * application only executes once the workflow's threshold is met.
19
+ *
20
+ * A workflow is operator-authored once via `defineWorkflow` and
21
+ * carries the gating policy: how many approvers are required to
22
+ * pass, whether approvers must hold a named capability token
23
+ * from the `operatorRoles` allow-list, how long pending requests
24
+ * wait before escalation, and an optional `auto_approve_threshold`
25
+ * that lets the application short-circuit small-payload requests
26
+ * without a vote (e.g. refunds under $100 still record an audit
27
+ * row but skip the approval step).
28
+ *
29
+ * The FSM per request is closed:
30
+ *
31
+ * pending -> approved (votes_for >= required_approvers)
32
+ * pending -> rejected (any single `reject` vote)
33
+ * pending -> escalated (operator records the escalation)
34
+ * pending -> cancelled (requester withdraws before resolve)
35
+ * approved -> executed (markExecuted records the outcome)
36
+ * approved -> cancelled (operator can still cancel after
37
+ * approval but before execution)
38
+ * escalated -> approved (vote-driven; the escalation flag is
39
+ * metadata, not a terminal state)
40
+ * escalated -> rejected
41
+ * escalated -> cancelled
42
+ *
43
+ * Multi-approver semantics: ANY single `reject` vote flips the
44
+ * request to `rejected`, which matches "any one approver can veto
45
+ * a money move" — the bar an operator wants for the pre-execution
46
+ * gate. Approvers can leave an `abstain` vote that counts neither
47
+ * for nor against; abstentions are recorded so an auditor can see
48
+ * who saw the request and declined to weigh in.
49
+ *
50
+ * Surface:
51
+ *
52
+ * - defineWorkflow({ slug, action_kind, required_approvers,
53
+ * required_capability?, escalation_after_hours?,
54
+ * auto_approve_threshold? })
55
+ * Create / update the workflow definition. Re-defining the
56
+ * same slug refreshes the policy in place (the workflow
57
+ * identity is stable — the rules are operator-tunable).
58
+ *
59
+ * - requestApproval({ workflow_slug, requested_by, payload,
60
+ * justification })
61
+ * Submit a new approval request. `payload` is the
62
+ * operator-supplied structured description of the action
63
+ * (refund amount, vendor id, catalog patch); the primitive
64
+ * does NOT interpret it but does JSON-validate +
65
+ * size-clamp. Returns the persisted row at `status:
66
+ * "pending"`. Refused if the workflow is archived.
67
+ *
68
+ * - castVote({ request_id, approver_id, decision, comment? })
69
+ * Record a vote. `decision` is one of approve / reject /
70
+ * abstain. UNIQUE(request_id, approver_id) holds at the
71
+ * schema level; the primitive surfaces the collision as a
72
+ * friendly refusal. When `operatorRoles` is wired and the
73
+ * workflow declares a `required_capability`, the approver's
74
+ * active roles are checked through `hasPermission` and the
75
+ * vote is refused if the approver doesn't carry the
76
+ * capability. Crossing the `required_approvers` threshold
77
+ * flips the request to `approved`; a `reject` vote flips it
78
+ * to `rejected` immediately.
79
+ *
80
+ * - recordEscalation({ request_id, escalated_to, reason })
81
+ * Stamp the escalation columns on a pending request. The
82
+ * primitive does NOT itself enforce the
83
+ * `escalation_after_hours` deadline — the application sweeps
84
+ * pending requests (or wires a scheduler) and calls this
85
+ * when the deadline passes. The `escalated_to` field is the
86
+ * operator-id (or role slug) of the escalation target; the
87
+ * primitive does NOT validate it against the operator
88
+ * registry, leaving that to the wiring layer.
89
+ *
90
+ * - markExecuted({ request_id, executed_by, result })
91
+ * Final state transition: an approved request has been
92
+ * executed by the application. `result` is JSON-validated +
93
+ * size-clamped. Refused if the request is not at
94
+ * `approved`.
95
+ *
96
+ * - cancelRequest({ request_id, reason })
97
+ * Operator-initiated withdrawal. Allowed from pending /
98
+ * approved / escalated; refused from executed / rejected
99
+ * (the action is already done one way or the other) and
100
+ * from already-cancelled.
101
+ *
102
+ * - getRequest(request_id)
103
+ * Single-row read with denormalized vote tally + nested
104
+ * votes array.
105
+ *
106
+ * - pendingForApprover({ approver_id, workflow_slug? })
107
+ * Pending (or escalated) requests the named approver has
108
+ * not yet voted on. When `workflow_slug` is supplied the
109
+ * filter narrows to a single workflow. Used by the operator
110
+ * console "approvals awaiting you" badge.
111
+ *
112
+ * - myRequests({ requester_id, status?, limit? })
113
+ * The requesting operator's own submission history.
114
+ *
115
+ * - metricsForWorkflow({ slug, from, to })
116
+ * Aggregate counters over an [from, to) epoch-ms window —
117
+ * total / by_status / median_time_to_resolve_ms /
118
+ * auto_approved / escalated.
119
+ *
120
+ * Composes ONLY blamejs (zero npm deps):
121
+ * - `b.uuid.v7` — request + vote row PKs
122
+ * - `shop.operatorRoles` — optional peer for the
123
+ * required_capability gate +
124
+ * audit trail
125
+ * - `shop.operatorAuditLog` — optional peer; every
126
+ * request / vote / execute /
127
+ * cancel / escalate records a row
128
+ * via the duck-typed `.record(...)`
129
+ * surface
130
+ * - `shop.operatorInbox` — optional peer; requestApproval
131
+ * enqueues a role-broadcast
132
+ * message addressed to the
133
+ * workflow's `required_capability`
134
+ * when wired
135
+ *
136
+ * Monotonic clock: a per-factory monotonic timestamp ensures that
137
+ * two votes / state-transitions landing in the same wall-clock
138
+ * millisecond carry strictly-increasing `occurred_at` /
139
+ * `updated_at` values. The audit ordering is total even on fast
140
+ * runners that collapse `Date.now()` inside one tick.
141
+ *
142
+ * Storage: `migrations-d1/0192_operator_approvals.sql` — three
143
+ * tables (`approval_workflows` + `approval_requests` +
144
+ * `approval_votes`) with their indexes.
145
+ *
146
+ * @primitive operatorApprovals
147
+ * @related operatorRoles, operatorAuditLog, operatorInbox, b.uuid
148
+ */
149
+
150
+ // ---- constants ----------------------------------------------------------
151
+
152
+ var MAX_SLUG_LEN = 80;
153
+ var MAX_ACTION_KIND_LEN = 80;
154
+ var MAX_OPERATOR_ID_LEN = 128;
155
+ var MAX_REASON_LEN = 500;
156
+ var MAX_COMMENT_LEN = 2000;
157
+ var MAX_JUSTIFICATION_LEN = 4000;
158
+ var MAX_PAYLOAD_BYTES = 64 * 1024;
159
+ var MAX_RESULT_BYTES = 64 * 1024;
160
+ var MAX_REQUIRED_APPROVERS = 32;
161
+ var MAX_LIST_LIMIT = 500;
162
+ var DEFAULT_LIST_LIMIT = 100;
163
+ var MAX_HOURS = 24 * 365; // one-year cap
164
+
165
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
166
+ var ACTION_KIND_RE = /^[a-z][a-z0-9_.\-]{0,79}$/;
167
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
168
+
169
+ var DECISIONS = Object.freeze(["approve", "reject", "abstain"]);
170
+ var STATUSES = Object.freeze(["pending", "approved", "rejected",
171
+ "executed", "cancelled", "escalated"]);
172
+
173
+ var bShop;
174
+ function _b() {
175
+ if (!bShop) bShop = require("./index");
176
+ return bShop.framework;
177
+ }
178
+
179
+ // ---- validators ---------------------------------------------------------
180
+
181
+ function _slug(s, label) {
182
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
183
+ throw new TypeError("operatorApprovals: " + (label || "slug") +
184
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
185
+ }
186
+ return s;
187
+ }
188
+
189
+ function _actionKind(s) {
190
+ if (typeof s !== "string" || !ACTION_KIND_RE.test(s)) {
191
+ throw new TypeError("operatorApprovals: action_kind must be lowercase " +
192
+ "alnum / underscore / dot / dash, 1.." + MAX_ACTION_KIND_LEN + " chars");
193
+ }
194
+ return s;
195
+ }
196
+
197
+ function _operatorId(s, label) {
198
+ if (typeof s !== "string" || !s.length || s.length > MAX_OPERATOR_ID_LEN) {
199
+ throw new TypeError("operatorApprovals: " + label +
200
+ " must be a non-empty string (<= " + MAX_OPERATOR_ID_LEN + " chars)");
201
+ }
202
+ if (CONTROL_BYTE_RE.test(s)) {
203
+ throw new TypeError("operatorApprovals: " + label + " must not contain control bytes");
204
+ }
205
+ return s;
206
+ }
207
+
208
+ function _reason(s) {
209
+ if (typeof s !== "string" || !s.length || s.length > MAX_REASON_LEN) {
210
+ throw new TypeError("operatorApprovals: reason must be a non-empty string <= " +
211
+ MAX_REASON_LEN + " chars");
212
+ }
213
+ if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
214
+ throw new TypeError("operatorApprovals: reason must not contain control bytes (except whitespace)");
215
+ }
216
+ return s;
217
+ }
218
+
219
+ function _justification(s) {
220
+ if (typeof s !== "string" || !s.length || s.length > MAX_JUSTIFICATION_LEN) {
221
+ throw new TypeError("operatorApprovals: justification must be a non-empty string <= " +
222
+ MAX_JUSTIFICATION_LEN + " chars");
223
+ }
224
+ if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
225
+ throw new TypeError("operatorApprovals: justification must not contain control bytes (except whitespace)");
226
+ }
227
+ return s;
228
+ }
229
+
230
+ function _comment(s) {
231
+ if (s == null) return null;
232
+ if (typeof s !== "string" || s.length > MAX_COMMENT_LEN) {
233
+ throw new TypeError("operatorApprovals: comment must be a string <= " +
234
+ MAX_COMMENT_LEN + " chars");
235
+ }
236
+ if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
237
+ throw new TypeError("operatorApprovals: comment must not contain control bytes (except whitespace)");
238
+ }
239
+ return s;
240
+ }
241
+
242
+ function _decision(s) {
243
+ if (typeof s !== "string" || DECISIONS.indexOf(s) < 0) {
244
+ throw new TypeError("operatorApprovals: decision must be one of " + DECISIONS.join(", "));
245
+ }
246
+ return s;
247
+ }
248
+
249
+ function _status(s, label) {
250
+ if (typeof s !== "string" || STATUSES.indexOf(s) < 0) {
251
+ throw new TypeError("operatorApprovals: " + (label || "status") +
252
+ " must be one of " + STATUSES.join(", "));
253
+ }
254
+ return s;
255
+ }
256
+
257
+ function _requiredApprovers(n) {
258
+ if (!Number.isInteger(n) || n < 1 || n > MAX_REQUIRED_APPROVERS) {
259
+ throw new TypeError("operatorApprovals: required_approvers must be an integer in [1, " +
260
+ MAX_REQUIRED_APPROVERS + "]");
261
+ }
262
+ return n;
263
+ }
264
+
265
+ function _hours(n, label) {
266
+ if (n == null) return null;
267
+ if (!Number.isInteger(n) || n < 1 || n > MAX_HOURS) {
268
+ throw new TypeError("operatorApprovals: " + label +
269
+ " must be a positive integer (hours) <= " + MAX_HOURS);
270
+ }
271
+ return n;
272
+ }
273
+
274
+ function _autoApprove(n) {
275
+ if (n == null) return null;
276
+ if (!Number.isInteger(n) || n < 1) {
277
+ throw new TypeError("operatorApprovals: auto_approve_threshold must be a positive integer or null");
278
+ }
279
+ return n;
280
+ }
281
+
282
+ function _epochMs(n, label) {
283
+ if (!Number.isInteger(n) || n < 0) {
284
+ throw new TypeError("operatorApprovals: " + label +
285
+ " must be a non-negative integer (epoch ms)");
286
+ }
287
+ return n;
288
+ }
289
+
290
+ function _limit(n) {
291
+ if (n == null) return DEFAULT_LIST_LIMIT;
292
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
293
+ throw new TypeError("operatorApprovals: limit must be an integer in [1, " +
294
+ MAX_LIST_LIMIT + "]");
295
+ }
296
+ return n;
297
+ }
298
+
299
+ function _json(v, label, maxBytes) {
300
+ if (v == null) {
301
+ throw new TypeError("operatorApprovals: " + label + " must be a plain object");
302
+ }
303
+ if (typeof v !== "object" || Array.isArray(v)) {
304
+ throw new TypeError("operatorApprovals: " + label + " must be a plain object");
305
+ }
306
+ var json;
307
+ try { json = JSON.stringify(v); }
308
+ catch (_e) { throw new TypeError("operatorApprovals: " + label + " must be JSON-serializable"); }
309
+ if (json === undefined) {
310
+ throw new TypeError("operatorApprovals: " + label + " must be JSON-serializable");
311
+ }
312
+ if (json.length > maxBytes) {
313
+ throw new TypeError("operatorApprovals: " + label + " must serialize to <= " +
314
+ maxBytes + " bytes");
315
+ }
316
+ return json;
317
+ }
318
+
319
+ function _capability(s) {
320
+ if (s == null) return null;
321
+ if (typeof s !== "string" || !s.length || s.length > MAX_ACTION_KIND_LEN) {
322
+ throw new TypeError("operatorApprovals: required_capability must be a non-empty string or null");
323
+ }
324
+ if (CONTROL_BYTE_RE.test(s)) {
325
+ throw new TypeError("operatorApprovals: required_capability must not contain control bytes");
326
+ }
327
+ return s;
328
+ }
329
+
330
+ // ---- row hydration ------------------------------------------------------
331
+
332
+ function _safeParseObject(s) {
333
+ if (s == null) return null;
334
+ try {
335
+ var parsed = JSON.parse(s);
336
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
337
+ return null;
338
+ } catch (_e) { return null; }
339
+ }
340
+
341
+ function _hydrateWorkflow(r) {
342
+ if (!r) return null;
343
+ return {
344
+ slug: r.slug,
345
+ action_kind: r.action_kind,
346
+ required_approvers: Number(r.required_approvers),
347
+ required_capability: r.required_capability == null ? null : r.required_capability,
348
+ escalation_after_hours: r.escalation_after_hours == null ? null : Number(r.escalation_after_hours),
349
+ auto_approve_threshold: r.auto_approve_threshold == null ? null : Number(r.auto_approve_threshold),
350
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
351
+ created_at: Number(r.created_at),
352
+ updated_at: Number(r.updated_at),
353
+ };
354
+ }
355
+
356
+ function _hydrateRequest(r) {
357
+ if (!r) return null;
358
+ return {
359
+ id: r.id,
360
+ workflow_slug: r.workflow_slug,
361
+ requested_by: r.requested_by,
362
+ payload: _safeParseObject(r.payload_json) || {},
363
+ justification: r.justification,
364
+ status: r.status,
365
+ votes_for: Number(r.votes_for),
366
+ votes_against: Number(r.votes_against),
367
+ executed_by: r.executed_by == null ? null : r.executed_by,
368
+ executed_at: r.executed_at == null ? null : Number(r.executed_at),
369
+ result: _safeParseObject(r.result_json),
370
+ cancelled_at: r.cancelled_at == null ? null : Number(r.cancelled_at),
371
+ cancel_reason: r.cancel_reason == null ? null : r.cancel_reason,
372
+ escalated_to: r.escalated_to == null ? null : r.escalated_to,
373
+ escalated_at: r.escalated_at == null ? null : Number(r.escalated_at),
374
+ escalation_reason: r.escalation_reason == null ? null : r.escalation_reason,
375
+ created_at: Number(r.created_at),
376
+ updated_at: Number(r.updated_at),
377
+ };
378
+ }
379
+
380
+ function _hydrateVote(r) {
381
+ if (!r) return null;
382
+ return {
383
+ id: r.id,
384
+ request_id: r.request_id,
385
+ approver_id: r.approver_id,
386
+ decision: r.decision,
387
+ comment: r.comment == null ? null : r.comment,
388
+ occurred_at: Number(r.occurred_at),
389
+ };
390
+ }
391
+
392
+ // ---- factory ------------------------------------------------------------
393
+
394
+ function create(opts) {
395
+ opts = opts || {};
396
+ var query = opts.query;
397
+ if (!query) {
398
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
399
+ }
400
+
401
+ // Optional peer — when wired, `castVote` consults
402
+ // `hasPermission({ operator_id, permission })` against the workflow's
403
+ // `required_capability`. Without it, capability is advisory metadata.
404
+ var operatorRoles = opts.operatorRoles || null;
405
+ if (operatorRoles != null) {
406
+ if (typeof operatorRoles !== "object" ||
407
+ typeof operatorRoles.hasPermission !== "function") {
408
+ throw new TypeError("operatorApprovals.create: operatorRoles must expose hasPermission(...)");
409
+ }
410
+ }
411
+
412
+ // Optional peer — every state-changing call records an audit row
413
+ // via duck-typed `.record(...)` matching the operatorAuditLog
414
+ // surface (migration 0074).
415
+ var operatorAuditLog = opts.operatorAuditLog || null;
416
+ if (operatorAuditLog != null) {
417
+ if (typeof operatorAuditLog !== "object" ||
418
+ typeof operatorAuditLog.record !== "function") {
419
+ throw new TypeError("operatorApprovals.create: operatorAuditLog must expose record(...)");
420
+ }
421
+ }
422
+
423
+ // Optional peer — `requestApproval` enqueues a role-broadcast
424
+ // inbox message addressed to the workflow's `required_capability`
425
+ // when this is wired.
426
+ var operatorInbox = opts.operatorInbox || null;
427
+ if (operatorInbox != null) {
428
+ if (typeof operatorInbox !== "object" ||
429
+ typeof operatorInbox.enqueueMessage !== "function") {
430
+ throw new TypeError("operatorApprovals.create: operatorInbox must expose enqueueMessage(...)");
431
+ }
432
+ }
433
+
434
+ // Per-factory monotonic clock. Fast platforms collapse `Date.now()`
435
+ // to identical readings inside one tick; the bump keeps the audit
436
+ // ordering deterministic — two votes / state transitions landing in
437
+ // the same millisecond still carry strictly-increasing timestamps.
438
+ var _lastTs = 0;
439
+ function _monotonicTs() {
440
+ var wall = Date.now();
441
+ if (wall > _lastTs) _lastTs = wall;
442
+ else _lastTs += 1;
443
+ return _lastTs;
444
+ }
445
+
446
+ // ---- audit helper --------------------------------------------------
447
+
448
+ async function _audit(action, actorId, resourceId, before, after) {
449
+ if (!operatorAuditLog) return;
450
+ try {
451
+ await operatorAuditLog.record({
452
+ actor_type: "operator",
453
+ actor_id: actorId,
454
+ action: action,
455
+ resource_kind: "approval_request",
456
+ resource_id: resourceId,
457
+ before: before,
458
+ after: after,
459
+ });
460
+ } catch (_e) {
461
+ // Audit failure does not roll back the state change — the
462
+ // primitive's contract is "best-effort chain to the peer". The
463
+ // peer's own internals carry the hard guarantees.
464
+ }
465
+ }
466
+
467
+ // ---- defineWorkflow -----------------------------------------------
468
+
469
+ async function defineWorkflow(input) {
470
+ if (!input || typeof input !== "object") {
471
+ throw new TypeError("operatorApprovals.defineWorkflow: input object required");
472
+ }
473
+ var slug = _slug(input.slug);
474
+ var actionKind = _actionKind(input.action_kind);
475
+ var requiredApprovers = _requiredApprovers(input.required_approvers);
476
+ var requiredCapability = _capability(input.required_capability);
477
+ var escalationAfterHours = _hours(input.escalation_after_hours, "escalation_after_hours");
478
+ var autoApproveThreshold = _autoApprove(input.auto_approve_threshold);
479
+
480
+ var ts = _monotonicTs();
481
+
482
+ var existing = (await query(
483
+ "SELECT slug FROM approval_workflows WHERE slug = ?1 LIMIT 1",
484
+ [slug],
485
+ )).rows[0];
486
+
487
+ if (existing) {
488
+ // In-place policy refresh — the workflow identity is stable;
489
+ // operator-tunable rules ride the updated_at bump.
490
+ await query(
491
+ "UPDATE approval_workflows " +
492
+ "SET action_kind = ?1, required_approvers = ?2, required_capability = ?3, " +
493
+ "escalation_after_hours = ?4, auto_approve_threshold = ?5, updated_at = ?6 " +
494
+ "WHERE slug = ?7",
495
+ [actionKind, requiredApprovers, requiredCapability,
496
+ escalationAfterHours, autoApproveThreshold, ts, slug],
497
+ );
498
+ } else {
499
+ await query(
500
+ "INSERT INTO approval_workflows " +
501
+ "(slug, action_kind, required_approvers, required_capability, " +
502
+ " escalation_after_hours, auto_approve_threshold, archived_at, created_at, updated_at) " +
503
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
504
+ [slug, actionKind, requiredApprovers, requiredCapability,
505
+ escalationAfterHours, autoApproveThreshold, ts],
506
+ );
507
+ }
508
+
509
+ return await _getWorkflow(slug);
510
+ }
511
+
512
+ async function _getWorkflow(slug) {
513
+ var r = (await query(
514
+ "SELECT * FROM approval_workflows WHERE slug = ?1 LIMIT 1",
515
+ [slug],
516
+ )).rows[0];
517
+ return _hydrateWorkflow(r);
518
+ }
519
+
520
+ // ---- requestApproval ----------------------------------------------
521
+
522
+ async function requestApproval(input) {
523
+ if (!input || typeof input !== "object") {
524
+ throw new TypeError("operatorApprovals.requestApproval: input object required");
525
+ }
526
+ var workflowSlug = _slug(input.workflow_slug, "workflow_slug");
527
+ var requestedBy = _operatorId(input.requested_by, "requested_by");
528
+ var payloadJson = _json(input.payload, "payload", MAX_PAYLOAD_BYTES);
529
+ var justification = _justification(input.justification);
530
+
531
+ var wf = await _getWorkflow(workflowSlug);
532
+ if (!wf) {
533
+ throw new TypeError("operatorApprovals.requestApproval: workflow " +
534
+ JSON.stringify(workflowSlug) + " not found");
535
+ }
536
+ if (wf.archived_at != null) {
537
+ throw new TypeError("operatorApprovals.requestApproval: workflow " +
538
+ JSON.stringify(workflowSlug) + " is archived - new requests are refused");
539
+ }
540
+
541
+ var id = _b().uuid.v7();
542
+ var ts = _monotonicTs();
543
+ await query(
544
+ "INSERT INTO approval_requests " +
545
+ "(id, workflow_slug, requested_by, payload_json, justification, status, " +
546
+ " votes_for, votes_against, executed_by, executed_at, result_json, " +
547
+ " cancelled_at, cancel_reason, escalated_to, escalated_at, escalation_reason, " +
548
+ " created_at, updated_at) " +
549
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'pending', 0, 0, NULL, NULL, NULL, " +
550
+ " NULL, NULL, NULL, NULL, NULL, ?6, ?6)",
551
+ [id, workflowSlug, requestedBy, payloadJson, justification, ts],
552
+ );
553
+
554
+ // Audit + inbox broadcast composition.
555
+ await _audit("approval.request", requestedBy, id, null,
556
+ { workflow_slug: workflowSlug, justification: justification });
557
+
558
+ if (operatorInbox && wf.required_capability) {
559
+ try {
560
+ await operatorInbox.enqueueMessage({
561
+ role: wf.required_capability,
562
+ kind: "approval_request",
563
+ severity: "warning",
564
+ subject: "Approval requested: " + wf.action_kind,
565
+ body: justification,
566
+ payload: { request_id: id, workflow_slug: workflowSlug },
567
+ });
568
+ } catch (_e) {
569
+ // Inbox failure does not roll back — the request is still
570
+ // pending; the caller can re-broadcast.
571
+ }
572
+ }
573
+
574
+ return await getRequest(id);
575
+ }
576
+
577
+ // ---- castVote -----------------------------------------------------
578
+
579
+ async function castVote(input) {
580
+ if (!input || typeof input !== "object") {
581
+ throw new TypeError("operatorApprovals.castVote: input object required");
582
+ }
583
+ var requestId = _operatorId(input.request_id, "request_id");
584
+ var approverId = _operatorId(input.approver_id, "approver_id");
585
+ var decision = _decision(input.decision);
586
+ var comment = _comment(input.comment);
587
+
588
+ var req = (await query(
589
+ "SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
590
+ [requestId],
591
+ )).rows[0];
592
+ if (!req) {
593
+ throw new TypeError("operatorApprovals.castVote: request " +
594
+ JSON.stringify(requestId) + " not found");
595
+ }
596
+ if (req.status !== "pending" && req.status !== "escalated") {
597
+ throw new TypeError("operatorApprovals.castVote: request " +
598
+ JSON.stringify(requestId) + " is " + req.status + " — votes refused");
599
+ }
600
+ if (req.requested_by === approverId) {
601
+ throw new TypeError("operatorApprovals.castVote: requester cannot vote on their own request");
602
+ }
603
+
604
+ var wf = await _getWorkflow(req.workflow_slug);
605
+ if (!wf) {
606
+ throw new TypeError("operatorApprovals.castVote: workflow " +
607
+ JSON.stringify(req.workflow_slug) + " not found");
608
+ }
609
+
610
+ // Capability gate via composed operatorRoles peer.
611
+ if (operatorRoles && wf.required_capability) {
612
+ var allowed = await operatorRoles.hasPermission({
613
+ operator_id: approverId,
614
+ permission: wf.required_capability,
615
+ });
616
+ if (!allowed) {
617
+ throw new TypeError("operatorApprovals.castVote: approver " +
618
+ JSON.stringify(approverId) + " does not carry required capability " +
619
+ JSON.stringify(wf.required_capability));
620
+ }
621
+ }
622
+
623
+ // UNIQUE(request_id, approver_id) — surface the dedup as a
624
+ // friendly refusal rather than a raw constraint error.
625
+ var existing = (await query(
626
+ "SELECT id FROM approval_votes WHERE request_id = ?1 AND approver_id = ?2 LIMIT 1",
627
+ [requestId, approverId],
628
+ )).rows[0];
629
+ if (existing) {
630
+ throw new TypeError("operatorApprovals.castVote: approver " +
631
+ JSON.stringify(approverId) + " has already voted on request " +
632
+ JSON.stringify(requestId));
633
+ }
634
+
635
+ var voteId = _b().uuid.v7();
636
+ var ts = _monotonicTs();
637
+ await query(
638
+ "INSERT INTO approval_votes (id, request_id, approver_id, decision, comment, occurred_at) " +
639
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
640
+ [voteId, requestId, approverId, decision, comment, ts],
641
+ );
642
+
643
+ // Tally bump under the same logical transaction.
644
+ var votesFor = Number(req.votes_for);
645
+ var votesAgainst = Number(req.votes_against);
646
+ if (decision === "approve") votesFor += 1;
647
+ if (decision === "reject") votesAgainst += 1;
648
+
649
+ // Threshold + veto resolution.
650
+ var newStatus = req.status;
651
+ if (decision === "reject") {
652
+ newStatus = "rejected";
653
+ } else if (decision === "approve" && votesFor >= Number(wf.required_approvers)) {
654
+ newStatus = "approved";
655
+ }
656
+
657
+ await query(
658
+ "UPDATE approval_requests SET votes_for = ?1, votes_against = ?2, status = ?3, updated_at = ?4 " +
659
+ "WHERE id = ?5",
660
+ [votesFor, votesAgainst, newStatus, ts, requestId],
661
+ );
662
+
663
+ await _audit("approval.vote", approverId, requestId,
664
+ { status: req.status, votes_for: Number(req.votes_for), votes_against: Number(req.votes_against) },
665
+ { status: newStatus, votes_for: votesFor, votes_against: votesAgainst, decision: decision });
666
+
667
+ return await getRequest(requestId);
668
+ }
669
+
670
+ // ---- recordEscalation --------------------------------------------
671
+
672
+ async function recordEscalation(input) {
673
+ if (!input || typeof input !== "object") {
674
+ throw new TypeError("operatorApprovals.recordEscalation: input object required");
675
+ }
676
+ var requestId = _operatorId(input.request_id, "request_id");
677
+ var escalatedTo = _operatorId(input.escalated_to, "escalated_to");
678
+ var reason = _reason(input.reason);
679
+
680
+ var req = (await query(
681
+ "SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
682
+ [requestId],
683
+ )).rows[0];
684
+ if (!req) {
685
+ throw new TypeError("operatorApprovals.recordEscalation: request " +
686
+ JSON.stringify(requestId) + " not found");
687
+ }
688
+ if (req.status !== "pending") {
689
+ throw new TypeError("operatorApprovals.recordEscalation: request " +
690
+ JSON.stringify(requestId) + " is " + req.status + " — escalation refused");
691
+ }
692
+
693
+ var ts = _monotonicTs();
694
+ await query(
695
+ "UPDATE approval_requests SET status = 'escalated', escalated_to = ?1, " +
696
+ "escalated_at = ?2, escalation_reason = ?3, updated_at = ?2 WHERE id = ?4",
697
+ [escalatedTo, ts, reason, requestId],
698
+ );
699
+
700
+ await _audit("approval.escalate", escalatedTo, requestId,
701
+ { status: req.status },
702
+ { status: "escalated", escalated_to: escalatedTo, reason: reason });
703
+
704
+ return await getRequest(requestId);
705
+ }
706
+
707
+ // ---- markExecuted ------------------------------------------------
708
+
709
+ async function markExecuted(input) {
710
+ if (!input || typeof input !== "object") {
711
+ throw new TypeError("operatorApprovals.markExecuted: input object required");
712
+ }
713
+ var requestId = _operatorId(input.request_id, "request_id");
714
+ var executedBy = _operatorId(input.executed_by, "executed_by");
715
+ var resultJson = _json(input.result, "result", MAX_RESULT_BYTES);
716
+
717
+ var req = (await query(
718
+ "SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
719
+ [requestId],
720
+ )).rows[0];
721
+ if (!req) {
722
+ throw new TypeError("operatorApprovals.markExecuted: request " +
723
+ JSON.stringify(requestId) + " not found");
724
+ }
725
+ if (req.status !== "approved") {
726
+ throw new TypeError("operatorApprovals.markExecuted: request " +
727
+ JSON.stringify(requestId) + " is " + req.status + " — execution refused");
728
+ }
729
+
730
+ var ts = _monotonicTs();
731
+ await query(
732
+ "UPDATE approval_requests SET status = 'executed', executed_by = ?1, " +
733
+ "executed_at = ?2, result_json = ?3, updated_at = ?2 WHERE id = ?4",
734
+ [executedBy, ts, resultJson, requestId],
735
+ );
736
+
737
+ await _audit("approval.execute", executedBy, requestId,
738
+ { status: req.status },
739
+ { status: "executed" });
740
+
741
+ return await getRequest(requestId);
742
+ }
743
+
744
+ // ---- cancelRequest -----------------------------------------------
745
+
746
+ async function cancelRequest(input) {
747
+ if (!input || typeof input !== "object") {
748
+ throw new TypeError("operatorApprovals.cancelRequest: input object required");
749
+ }
750
+ var requestId = _operatorId(input.request_id, "request_id");
751
+ var reason = _reason(input.reason);
752
+
753
+ var req = (await query(
754
+ "SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
755
+ [requestId],
756
+ )).rows[0];
757
+ if (!req) {
758
+ throw new TypeError("operatorApprovals.cancelRequest: request " +
759
+ JSON.stringify(requestId) + " not found");
760
+ }
761
+ if (req.status === "executed" || req.status === "rejected" || req.status === "cancelled") {
762
+ throw new TypeError("operatorApprovals.cancelRequest: request " +
763
+ JSON.stringify(requestId) + " is " + req.status + " — cancel refused");
764
+ }
765
+
766
+ var ts = _monotonicTs();
767
+ await query(
768
+ "UPDATE approval_requests SET status = 'cancelled', cancelled_at = ?1, " +
769
+ "cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
770
+ [ts, reason, requestId],
771
+ );
772
+
773
+ await _audit("approval.cancel", req.requested_by, requestId,
774
+ { status: req.status },
775
+ { status: "cancelled", reason: reason });
776
+
777
+ return await getRequest(requestId);
778
+ }
779
+
780
+ // ---- getRequest --------------------------------------------------
781
+
782
+ async function getRequest(requestId) {
783
+ _operatorId(requestId, "request_id");
784
+ var row = (await query(
785
+ "SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
786
+ [requestId],
787
+ )).rows[0];
788
+ if (!row) return null;
789
+ var req = _hydrateRequest(row);
790
+
791
+ var voteRows = (await query(
792
+ "SELECT * FROM approval_votes WHERE request_id = ?1 ORDER BY occurred_at ASC, id ASC",
793
+ [requestId],
794
+ )).rows;
795
+ var votes = [];
796
+ for (var i = 0; i < voteRows.length; i += 1) votes.push(_hydrateVote(voteRows[i]));
797
+ req.votes = votes;
798
+ return req;
799
+ }
800
+
801
+ // ---- pendingForApprover ------------------------------------------
802
+
803
+ async function pendingForApprover(input) {
804
+ if (!input || typeof input !== "object") {
805
+ throw new TypeError("operatorApprovals.pendingForApprover: input object required");
806
+ }
807
+ var approverId = _operatorId(input.approver_id, "approver_id");
808
+ var workflowSlug = input.workflow_slug == null ? null : _slug(input.workflow_slug, "workflow_slug");
809
+ var limit = _limit(input.limit);
810
+
811
+ var sql = "SELECT r.* FROM approval_requests r " +
812
+ "WHERE r.status IN ('pending', 'escalated') " +
813
+ "AND r.requested_by != ?1 " +
814
+ "AND NOT EXISTS (" +
815
+ " SELECT 1 FROM approval_votes v " +
816
+ " WHERE v.request_id = r.id AND v.approver_id = ?1" +
817
+ ")";
818
+ var params = [approverId];
819
+ var idx = 2;
820
+ if (workflowSlug) {
821
+ sql += " AND r.workflow_slug = ?" + idx;
822
+ params.push(workflowSlug);
823
+ idx += 1;
824
+ }
825
+ sql += " ORDER BY r.created_at ASC, r.id ASC LIMIT ?" + idx;
826
+ params.push(limit);
827
+
828
+ var rows = (await query(sql, params)).rows;
829
+ var out = [];
830
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRequest(rows[i]));
831
+ return out;
832
+ }
833
+
834
+ // ---- myRequests --------------------------------------------------
835
+
836
+ async function myRequests(input) {
837
+ if (!input || typeof input !== "object") {
838
+ throw new TypeError("operatorApprovals.myRequests: input object required");
839
+ }
840
+ var requesterId = _operatorId(input.requester_id, "requester_id");
841
+ var statusFilter = input.status == null ? null : _status(input.status);
842
+ var limit = _limit(input.limit);
843
+
844
+ var sql, params;
845
+ if (statusFilter) {
846
+ sql = "SELECT * FROM approval_requests WHERE requested_by = ?1 AND status = ?2 " +
847
+ "ORDER BY created_at DESC, id DESC LIMIT ?3";
848
+ params = [requesterId, statusFilter, limit];
849
+ } else {
850
+ sql = "SELECT * FROM approval_requests WHERE requested_by = ?1 " +
851
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
852
+ params = [requesterId, limit];
853
+ }
854
+ var rows = (await query(sql, params)).rows;
855
+ var out = [];
856
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRequest(rows[i]));
857
+ return out;
858
+ }
859
+
860
+ // ---- metricsForWorkflow ------------------------------------------
861
+
862
+ async function metricsForWorkflow(input) {
863
+ if (!input || typeof input !== "object") {
864
+ throw new TypeError("operatorApprovals.metricsForWorkflow: input object required");
865
+ }
866
+ var slug = _slug(input.slug);
867
+ var from = _epochMs(input.from, "from");
868
+ var to = _epochMs(input.to, "to");
869
+ if (to < from) {
870
+ throw new TypeError("operatorApprovals.metricsForWorkflow: to must be >= from");
871
+ }
872
+
873
+ var rows = (await query(
874
+ "SELECT status, created_at, updated_at, escalated_at " +
875
+ "FROM approval_requests " +
876
+ "WHERE workflow_slug = ?1 AND created_at >= ?2 AND created_at < ?3",
877
+ [slug, from, to],
878
+ )).rows;
879
+
880
+ var byStatus = { pending: 0, approved: 0, rejected: 0,
881
+ executed: 0, cancelled: 0, escalated: 0 };
882
+ var resolveLatencies = [];
883
+ var escalated = 0;
884
+ for (var i = 0; i < rows.length; i += 1) {
885
+ var s = rows[i].status;
886
+ if (byStatus[s] != null) byStatus[s] += 1;
887
+ if (rows[i].escalated_at != null) escalated += 1;
888
+ if (s === "approved" || s === "rejected" || s === "executed" || s === "cancelled") {
889
+ resolveLatencies.push(Number(rows[i].updated_at) - Number(rows[i].created_at));
890
+ }
891
+ }
892
+
893
+ var medianResolveMs = null;
894
+ if (resolveLatencies.length > 0) {
895
+ resolveLatencies.sort(function (a, b) { return a - b; });
896
+ var mid = resolveLatencies.length >> 1;
897
+ medianResolveMs = resolveLatencies.length % 2 === 1
898
+ ? resolveLatencies[mid]
899
+ : Math.round((resolveLatencies[mid - 1] + resolveLatencies[mid]) / 2);
900
+ }
901
+
902
+ return {
903
+ slug: slug,
904
+ from: from,
905
+ to: to,
906
+ total: rows.length,
907
+ by_status: byStatus,
908
+ escalated: escalated,
909
+ median_time_to_resolve_ms: medianResolveMs,
910
+ };
911
+ }
912
+
913
+ return {
914
+ DECISIONS: DECISIONS.slice(),
915
+ STATUSES: STATUSES.slice(),
916
+ MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
917
+ MAX_RESULT_BYTES: MAX_RESULT_BYTES,
918
+ MAX_REQUIRED_APPROVERS: MAX_REQUIRED_APPROVERS,
919
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
920
+
921
+ defineWorkflow: defineWorkflow,
922
+ requestApproval: requestApproval,
923
+ castVote: castVote,
924
+ recordEscalation: recordEscalation,
925
+ markExecuted: markExecuted,
926
+ cancelRequest: cancelRequest,
927
+ getRequest: getRequest,
928
+ pendingForApprover: pendingForApprover,
929
+ myRequests: myRequests,
930
+ metricsForWorkflow: metricsForWorkflow,
931
+ };
932
+ }
933
+
934
+ module.exports = {
935
+ create: create,
936
+ DECISIONS: DECISIONS,
937
+ STATUSES: STATUSES,
938
+ MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
939
+ MAX_RESULT_BYTES: MAX_RESULT_BYTES,
940
+ MAX_REQUIRED_APPROVERS: MAX_REQUIRED_APPROVERS,
941
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
942
+ };