@blamejs/blamejs-shop 0.4.22 → 0.4.24

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.
@@ -87,9 +87,60 @@
87
87
  *
88
88
  * Storage: `migrations-d1/0213_operator_accounts.sql`.
89
89
  *
90
+ * Dual-control (two-operator approval) — evaluated, deliberately NOT
91
+ * wired in v1:
92
+ *
93
+ * `b.dualControl` raises a destructive op to "two distinct named
94
+ * operators must approve before it runs." It is the right control for
95
+ * a store run by a TEAM, but it is structurally a two-actor M-of-N
96
+ * gate — `create()` hard-validates `minApprovers >= 2`, and there is
97
+ * no auto-satisfy-on-single-operator path. The dominant deployment of
98
+ * this storefront is a SINGLE owner; on such a store a dual-control
99
+ * gate would DEADLOCK the destructive op forever — there is no second
100
+ * operator to approve, so the op can never consume its grant. Wiring
101
+ * it unconditionally is therefore a worse failure than the
102
+ * single-operator risk it would close. It is also a two-request async
103
+ * workflow (request → a different actor approves → consume), which
104
+ * does not fit the synchronous single-POST shape these admin actions
105
+ * have today.
106
+ *
107
+ * Per destructive op, the v1 stance:
108
+ *
109
+ * - gift-card void (POST /admin/gift-cards/:id/void): recoverable —
110
+ * void is a status flip that PRESERVES the balance (it burns no
111
+ * value), so re-issuing or restoring the card's status makes a
112
+ * mistaken void recoverable, and the giftcard ledger records the
113
+ * acting operator. The cost of a deadlock-on-single-owner
114
+ * outweighs the benefit. Stance: role-gate (`orders.write`) +
115
+ * audit, no dual-control.
116
+ *
117
+ * - refunds (POST /admin/orders/:id/refund, /admin/returns/:id/
118
+ * refund): real money out, but bounded by the order's captured
119
+ * amount (the payment primitive refuses to over-refund a PI), and
120
+ * every refund is idempotency-keyed + audited with the operator
121
+ * id. The damage ceiling is one order's total, not the whole
122
+ * book. Stance: role-gate (`orders.write`) + audit, no
123
+ * dual-control.
124
+ *
125
+ * - operator disable (POST /admin/operators/:id/disable): the
126
+ * highest-blast-radius op (it can revoke a co-owner), but it is
127
+ * REVERSIBLE in one click (`/enable`), gated on `operators.manage`
128
+ * (owner-only), and audited. A malicious self-lockout still leaves
129
+ * the `ADMIN_API_KEY` break-glass owner credential working.
130
+ * Stance: role-gate + audit, no dual-control.
131
+ *
132
+ * Re-open condition: when a store carries TWO OR MORE active `owner`/
133
+ * `manager` operators, dual-control becomes wire-able WITHOUT the
134
+ * deadlock risk — gate the approval flow on `listAccounts({ status:
135
+ * "active" })` length >= 2, auto-satisfy below that, and move the
136
+ * destructive POST to a request/approve/consume sequence keyed off a
137
+ * b.cache grant. That is the natural follow-up the moment a real
138
+ * multi-operator store exists; it is not defensible to ship a control
139
+ * that bricks the single-operator common case to pre-empt it.
140
+ *
90
141
  * @primitive operatorAccounts
91
142
  * @related operatorRoles, operatorSessions, operatorAuditLog,
92
- * b.password, b.crypto, b.guardEmail, b.uuid
143
+ * b.password, b.crypto, b.guardEmail, b.uuid, b.dualControl
93
144
  */
94
145
 
95
146
  var EMAIL_NAMESPACE = "operator-email";
@@ -84,6 +84,11 @@ var SHA3_512_HEX_LEN = 128;
84
84
 
85
85
  var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
86
86
 
87
+ // Canonical prefix for the bytes a checkpoint signs. Stable forever —
88
+ // changing it invalidates every prior checkpoint signature. Mirrors the
89
+ // framework audit chain's "blamejs-audit-checkpoint-v1" format.
90
+ var CHECKPOINT_FORMAT = "blamejs-operator-audit-checkpoint-v1";
91
+
87
92
  var ALLOWED_ACTOR_TYPES = Object.freeze(["operator", "system", "app"]);
88
93
  var ALLOWED_UA_CLASSES = Object.freeze([
89
94
  "browser",
@@ -304,6 +309,20 @@ function create(opts) {
304
309
  query = function (sql, params) { return b.externalDb.query(sql, params); };
305
310
  }
306
311
 
312
+ // Single-writer discipline for the append path. `record()` reads the
313
+ // chain head, derives prev_hash + row_hash from it, then INSERTs — a
314
+ // read-derive-write sequence that is NOT atomic across the three
315
+ // awaits. Two concurrent record() calls would otherwise both read the
316
+ // same head, both stamp the same prev_hash, and fork the chain — which
317
+ // verifyChain() then reports as a prev_hash mismatch, indistinguishable
318
+ // from a real tamper. Serializing the whole body behind a per-instance
319
+ // mutex collapses the window: the second writer blocks until the first
320
+ // has committed its row, so it reads the freshly-advanced head. In-
321
+ // process only — it narrows the fork window to a single isolate, the
322
+ // same bound the framework chain primitive carries; the checkpoint
323
+ // anchoring below remains the cross-isolate / full-rewrite defense.
324
+ var appendMutex = new b.safeAsync.Mutex();
325
+
307
326
  async function _currentHead() {
308
327
  var r = await query(
309
328
  "SELECT row_hash, occurred_at, id FROM operator_audit_events " +
@@ -326,6 +345,8 @@ function create(opts) {
326
345
  if (!input || typeof input !== "object") {
327
346
  throw new TypeError("operatorAuditLog.record: input object required");
328
347
  }
348
+ // Validate OUTSIDE the lock — a bad-input throw shouldn't hold the
349
+ // append serializer, and validation touches no shared chain state.
329
350
  var actorType = _actorType(input.actor_type);
330
351
  var actorId = _ident(input.actor_id, "actor_id", IDENT_RE, MAX_ACTOR_ID_LEN);
331
352
  var action = _ident(input.action, "action", ACTION_RE, MAX_ACTION_LEN);
@@ -336,6 +357,9 @@ function create(opts) {
336
357
  var ipHash = _ipHash(input.ip_hash);
337
358
  var uaClass = _uaClass(input.ua_class);
338
359
 
360
+ // Acquire the append mutex for the head-read → hash → INSERT body so
361
+ // the chain advances under a single writer.
362
+ return appendMutex.runExclusive(async function () {
339
363
  var head = await _currentHead();
340
364
  var prevHash = head.row_hash;
341
365
  var id = b.uuid.v7();
@@ -393,6 +417,7 @@ function create(opts) {
393
417
  prev_hash: prevHash,
394
418
  row_hash: rowHash,
395
419
  };
420
+ });
396
421
  }
397
422
 
398
423
  // -- listByActor -------------------------------------------------------
@@ -581,6 +606,152 @@ function create(opts) {
581
606
  return { ok: true, rows_verified: rows.length, last_hash: prevHash };
582
607
  }
583
608
 
609
+ // -- checkpoint anchoring ----------------------------------------------
610
+ //
611
+ // The hash chain is tamper-EVIDENT against a single edited/deleted row,
612
+ // but an attacker who can rewrite the whole table can re-hash from a
613
+ // forged genesis and leave the chain internally consistent. Anchoring
614
+ // the tip with a post-quantum signature over the head row_hash — keyed
615
+ // off the framework's b.auditSign keypair, whose private half never
616
+ // touches D1 — closes that hole: a full-chain rewrite can't reproduce a
617
+ // valid checkpoint signature over the rewritten tip.
618
+ //
619
+ // checkpoint() signs the CURRENT head and inserts an anchor row.
620
+ // verifyCheckpoints() re-derives every anchor's signature and confirms
621
+ // the anchored row still carries the signed hash. Both no-op gracefully
622
+ // when b.auditSign isn't initialized (the chain stays hash-linked; the
623
+ // signature layer is simply absent) so a deployment without the
624
+ // audit-signing keypair still records + verifies the linkage.
625
+
626
+ function _auditSignReady() {
627
+ try { b.auditSign.getPublicKeyFingerprint(); return true; }
628
+ catch (_e) { return false; }
629
+ }
630
+
631
+ // Canonical signed bytes for a checkpoint. STABLE FOREVER — changing
632
+ // this layout invalidates every prior checkpoint signature.
633
+ function _checkpointPayload(atRowId, atOccurredAt, atRowHash, createdAt) {
634
+ return Buffer.from(
635
+ CHECKPOINT_FORMAT + "\n" +
636
+ atRowId + "\n" +
637
+ String(atOccurredAt) + "\n" +
638
+ atRowHash + "\n" +
639
+ String(createdAt),
640
+ "utf8",
641
+ );
642
+ }
643
+
644
+ // Anchor the current chain tip with a fresh PQC signature. Returns the
645
+ // inserted checkpoint row, or null when the chain is empty (nothing to
646
+ // anchor) or `skipIfUnchanged` and the tip hasn't advanced since the
647
+ // last checkpoint. Throws OperatorAuditCheckpointSigningUnavailable-
648
+ // shaped TypeError only if asked to checkpoint without a signing key —
649
+ // callers that want a soft skip check `signingAvailable()` first.
650
+ async function checkpoint(checkpointOpts) {
651
+ checkpointOpts = checkpointOpts || {};
652
+ if (!_auditSignReady()) {
653
+ throw new TypeError("operatorAuditLog.checkpoint: b.auditSign is not initialized — " +
654
+ "no signing key to anchor the chain with");
655
+ }
656
+ var head = await _currentHead();
657
+ if (head.id === null) return null; // empty chain — nothing to anchor
658
+
659
+ if (checkpointOpts.skipIfUnchanged) {
660
+ var last = await query(
661
+ "SELECT at_row_hash FROM operator_audit_checkpoints " +
662
+ "ORDER BY created_at DESC, id DESC LIMIT 1",
663
+ [],
664
+ );
665
+ if (last.rows.length && last.rows[0].at_row_hash === head.row_hash) {
666
+ return null; // already anchored at this exact tip
667
+ }
668
+ }
669
+
670
+ var createdAt = _now();
671
+ var payload = _checkpointPayload(head.id, head.occurred_at, head.row_hash, createdAt);
672
+ var signature = b.auditSign.sign(payload).toString("base64");
673
+ var pubFp = b.auditSign.getPublicKeyFingerprint();
674
+ var id = b.uuid.v7();
675
+
676
+ await query(
677
+ "INSERT INTO operator_audit_checkpoints " +
678
+ "(id, created_at, at_row_id, at_occurred_at, at_row_hash, signature, public_key_fingerprint) " +
679
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
680
+ [id, createdAt, head.id, head.occurred_at, head.row_hash, signature, pubFp],
681
+ );
682
+
683
+ return {
684
+ id: id,
685
+ created_at: createdAt,
686
+ at_row_id: head.id,
687
+ at_occurred_at: head.occurred_at,
688
+ at_row_hash: head.row_hash,
689
+ public_key_fingerprint: pubFp,
690
+ };
691
+ }
692
+
693
+ // Walk every checkpoint oldest-first, re-derive its signature, and
694
+ // confirm the anchored row still carries the signed hash. Reports the
695
+ // first divergence — a forged signature, a key-rotation fingerprint
696
+ // mismatch, a vanished anchor row, or a rewritten tip whose hash no
697
+ // longer matches what was signed.
698
+ async function verifyCheckpoints() {
699
+ if (!_auditSignReady()) {
700
+ return { ok: true, checkpoints_verified: 0, reason: "audit-sign-unavailable" };
701
+ }
702
+ var r = await query(
703
+ "SELECT * FROM operator_audit_checkpoints ORDER BY created_at ASC, id ASC",
704
+ [],
705
+ );
706
+ var rows = r.rows;
707
+ if (!rows.length) return { ok: true, checkpoints_verified: 0 };
708
+
709
+ var currentFp = b.auditSign.getPublicKeyFingerprint();
710
+ var currentPub = b.auditSign.getPublicKey();
711
+
712
+ for (var i = 0; i < rows.length; i += 1) {
713
+ var c = rows[i];
714
+ // Only the current key verifies (no key-history table). A rotation
715
+ // without re-signing surfaces here rather than silently failing.
716
+ if (c.public_key_fingerprint !== currentFp) {
717
+ return {
718
+ ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
719
+ reason: "public key fingerprint mismatch (key rotated without re-signing?)",
720
+ expected: currentFp, actual: c.public_key_fingerprint,
721
+ };
722
+ }
723
+ var payload = _checkpointPayload(c.at_row_id, Number(c.at_occurred_at), c.at_row_hash, Number(c.created_at));
724
+ var sigBuf;
725
+ try { sigBuf = Buffer.from(c.signature, "base64"); }
726
+ catch (_e) {
727
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
728
+ reason: "checkpoint signature not decodable" };
729
+ }
730
+ if (!b.auditSign.verify(payload, sigBuf, currentPub)) {
731
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
732
+ reason: "post-quantum signature failed" };
733
+ }
734
+ // The anchored row must still exist AND still carry the signed hash.
735
+ // A full-chain rewrite changes row_hash → this mismatch is the catch.
736
+ var anchored = await query(
737
+ "SELECT row_hash FROM operator_audit_events WHERE id = ?1 LIMIT 1",
738
+ [c.at_row_id],
739
+ );
740
+ if (!anchored.rows.length) {
741
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
742
+ reason: "anchored audit row missing (id=" + c.at_row_id + ")" };
743
+ }
744
+ if (anchored.rows[0].row_hash !== c.at_row_hash) {
745
+ return {
746
+ ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
747
+ reason: "anchored row_hash mismatch — operator_audit_events was rewritten",
748
+ expected: c.at_row_hash, actual: anchored.rows[0].row_hash,
749
+ };
750
+ }
751
+ }
752
+ return { ok: true, checkpoints_verified: rows.length };
753
+ }
754
+
584
755
  return {
585
756
  MAX_ACTOR_ID_LEN: MAX_ACTOR_ID_LEN,
586
757
  MAX_ACTION_LEN: MAX_ACTION_LEN,
@@ -593,12 +764,20 @@ function create(opts) {
593
764
  ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
594
765
  ZERO_HASH: ZERO_HASH,
595
766
 
596
- record: record,
597
- listByActor: listByActor,
598
- listByResource: listByResource,
599
- searchAction: searchAction,
600
- chainHead: chainHead,
601
- verifyChain: verifyChain,
767
+ record: record,
768
+ listByActor: listByActor,
769
+ listByResource: listByResource,
770
+ searchAction: searchAction,
771
+ chainHead: chainHead,
772
+ verifyChain: verifyChain,
773
+ // Checkpoint anchoring — sign the chain tip out-of-band so a
774
+ // full-chain rewrite (which verifyChain alone can't catch) becomes
775
+ // detectable. `signingAvailable()` lets callers soft-skip when the
776
+ // audit-signing key isn't initialized.
777
+ checkpoint: checkpoint,
778
+ verifyCheckpoints: verifyCheckpoints,
779
+ signingAvailable: _auditSignReady,
780
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
602
781
  };
603
782
  }
604
783
 
@@ -614,4 +793,5 @@ module.exports = {
614
793
  ALLOWED_ACTOR_TYPES: ALLOWED_ACTOR_TYPES,
615
794
  ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
616
795
  ZERO_HASH: ZERO_HASH,
796
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
617
797
  };
@@ -127,6 +127,25 @@
127
127
  * - unreadCount({ operator_id, severity_min? })
128
128
  * Cheap count for the navbar badge. Excludes archived.
129
129
  *
130
+ * - inboxForRole({ role, severity_min?, unread_only?,
131
+ * include_archived?, limit?, cursor? })
132
+ * Read the messages broadcast to a single role, newest-first.
133
+ * Returns ONLY `role = ?` rows (never operator-id-addressed
134
+ * ones), so it composes with `inboxForOperator` rather than
135
+ * duplicating it — the read side for a console that addresses
136
+ * notifications to a role and has no per-operator session to
137
+ * fold role membership through.
138
+ *
139
+ * - unreadCountForRole({ role, severity_min? })
140
+ * Role-scoped navbar-badge count. Excludes archived + read.
141
+ *
142
+ * - markReadForRole({ id, role }) / archiveForRole({ id, role })
143
+ * Clear / retire a role-broadcast row by its role rather than
144
+ * by an owning operator. Each asserts the row carries the
145
+ * supplied role before mutating (a caller can't clear an
146
+ * operator-id-addressed row, or another role's row, by
147
+ * guessing its id). Idempotent.
148
+ *
130
149
  * - cleanupOlderThan({ now?, age_ms })
131
150
  * Delete rows whose `created_at < (now - age_ms)`. Used to
132
151
  * keep the table bounded — the inbox is a feed, not an
@@ -771,6 +790,176 @@ function create(opts) {
771
790
  return Number(row.c || row.COUNT || 0);
772
791
  }
773
792
 
793
+ // ---- inboxForRole -----------------------------------------------------
794
+ //
795
+ // Read every message broadcast to a single role, newest-first. This is
796
+ // the read side for a console that addresses notifications to a role
797
+ // rather than to one owning operator — e.g. a single-credential admin
798
+ // where the "fulfillment" team is the audience but there's no per-
799
+ // operator session to fold role membership through. It returns ONLY
800
+ // `role = ?` rows (never operator-id-addressed rows), so it composes
801
+ // with `inboxForOperator` rather than duplicating it. Same severity_min
802
+ // / unread_only / include_archived / HMAC-cursor surface.
803
+ async function inboxForRole(input) {
804
+ if (!input || typeof input !== "object") {
805
+ throw new TypeError("operatorInbox.inboxForRole: input object required");
806
+ }
807
+ var role = _role(input.role, "role");
808
+ var severityMin = _severityMin(input.severity_min);
809
+ var unreadOnly = _bool(input.unread_only, "unread_only", false);
810
+ var includeArchived = _bool(input.include_archived, "include_archived", false);
811
+ var limit = _limit(input.limit);
812
+ var cursorState = _decodeCursor(input.cursor, "inboxForRole");
813
+
814
+ var params = [];
815
+ var idx = 1;
816
+ var pushP = function (v) { params.push(v); var k = idx; idx += 1; return "?" + k; };
817
+
818
+ var where = "role = " + pushP(role);
819
+ if (!includeArchived) where += " AND archived_at IS NULL";
820
+ if (unreadOnly) where += " AND read_at IS NULL";
821
+
822
+ if (severityMin) {
823
+ var minRank = SEVERITY_RANK[severityMin];
824
+ var allowed = [];
825
+ for (var si = 0; si < SEVERITIES.length; si += 1) {
826
+ if (SEVERITY_RANK[SEVERITIES[si]] >= minRank) {
827
+ allowed.push(pushP(SEVERITIES[si]));
828
+ }
829
+ }
830
+ where += " AND severity IN (" + allowed.join(", ") + ")";
831
+ }
832
+
833
+ if (cursorState) {
834
+ var caP = pushP(cursorState.created_at);
835
+ var idP = pushP(cursorState.id);
836
+ where += " AND (created_at < " + caP +
837
+ " OR (created_at = " + caP + " AND id < " + idP + "))";
838
+ }
839
+
840
+ var limitPlace = pushP(limit);
841
+ var sql = "SELECT * FROM operator_inbox_messages WHERE " + where +
842
+ " ORDER BY created_at DESC, id DESC LIMIT " + limitPlace;
843
+ var r = await query(sql, params);
844
+ var rows = [];
845
+ for (var ki = 0; ki < r.rows.length; ki += 1) rows.push(_decodeRow(r.rows[ki]));
846
+ var nextCursor = rows.length === limit
847
+ ? b.pagination.encodeCursor({
848
+ orderKey: CURSOR_ORDER_KEY,
849
+ vals: [rows[rows.length - 1].created_at, rows[rows.length - 1].id],
850
+ forward: true,
851
+ }, cursorSecret)
852
+ : null;
853
+ return { rows: rows, next_cursor: nextCursor };
854
+ }
855
+
856
+ // ---- unreadCountForRole -----------------------------------------------
857
+ //
858
+ // Cheap navbar-badge count of unread, un-archived messages broadcast to
859
+ // one role. The role-scoped sibling of `unreadCount` — used by a
860
+ // role-addressed console where the badge audience is a role, not a
861
+ // single operator.
862
+ async function unreadCountForRole(input) {
863
+ if (!input || typeof input !== "object") {
864
+ throw new TypeError("operatorInbox.unreadCountForRole: input object required");
865
+ }
866
+ var role = _role(input.role, "role");
867
+ var severityMin = _severityMin(input.severity_min);
868
+
869
+ var params = [];
870
+ var idx = 1;
871
+ var pushP = function (v) { params.push(v); var k = idx; idx += 1; return "?" + k; };
872
+
873
+ var where = "role = " + pushP(role) +
874
+ " AND read_at IS NULL AND archived_at IS NULL";
875
+
876
+ if (severityMin) {
877
+ var minRank = SEVERITY_RANK[severityMin];
878
+ var allowed = [];
879
+ for (var si = 0; si < SEVERITIES.length; si += 1) {
880
+ if (SEVERITY_RANK[SEVERITIES[si]] >= minRank) {
881
+ allowed.push(pushP(SEVERITIES[si]));
882
+ }
883
+ }
884
+ where += " AND severity IN (" + allowed.join(", ") + ")";
885
+ }
886
+
887
+ var sql = "SELECT COUNT(*) AS c FROM operator_inbox_messages WHERE " + where;
888
+ var r = await query(sql, params);
889
+ var row = r.rows[0] || {};
890
+ return Number(row.c || row.COUNT || 0);
891
+ }
892
+
893
+ // ---- markReadForRole / archiveForRole ---------------------------------
894
+ //
895
+ // The role-scoped write side. A console addressing notifications to a
896
+ // role (rather than to one owning operator) needs to clear a role-
897
+ // broadcast row WITHOUT a per-operator session to gate against — the
898
+ // role itself is the audience. Both assert the row carries the supplied
899
+ // role before mutating, so a caller can't clear an operator-id-addressed
900
+ // row (or a different role's row) by guessing its id. Idempotent.
901
+ async function _roleAddressableRow(messageId, role) {
902
+ var r = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [messageId]);
903
+ if (!r.rows.length) return { row: null, reason: "not_found" };
904
+ var row = r.rows[0];
905
+ if (row.role != null && row.role === role) return { row: row, reason: null };
906
+ return { row: row, reason: "not_addressable" };
907
+ }
908
+
909
+ async function markReadForRole(input) {
910
+ if (!input || typeof input !== "object") {
911
+ throw new TypeError("operatorInbox.markReadForRole: input object required");
912
+ }
913
+ var id = _messageId(input.id);
914
+ var role = _role(input.role, "role");
915
+ var gated = await _roleAddressableRow(id, role);
916
+ if (gated.reason === "not_found") {
917
+ var miss = new Error("operatorInbox.markReadForRole: message not found");
918
+ miss.code = "INBOX_MESSAGE_NOT_FOUND";
919
+ throw miss;
920
+ }
921
+ if (gated.reason === "not_addressable") {
922
+ var nope = new Error("operatorInbox.markReadForRole: message not addressed to this role");
923
+ nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
924
+ throw nope;
925
+ }
926
+ if (gated.row.read_at != null) return _decodeRow(gated.row); // idempotent
927
+ var ts = _now();
928
+ await query(
929
+ "UPDATE operator_inbox_messages SET read_at = ?1 WHERE id = ?2 AND read_at IS NULL",
930
+ [ts, id],
931
+ );
932
+ var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
933
+ return _decodeRow(fresh.rows[0]);
934
+ }
935
+
936
+ async function archiveForRole(input) {
937
+ if (!input || typeof input !== "object") {
938
+ throw new TypeError("operatorInbox.archiveForRole: input object required");
939
+ }
940
+ var id = _messageId(input.id);
941
+ var role = _role(input.role, "role");
942
+ var gated = await _roleAddressableRow(id, role);
943
+ if (gated.reason === "not_found") {
944
+ var miss = new Error("operatorInbox.archiveForRole: message not found");
945
+ miss.code = "INBOX_MESSAGE_NOT_FOUND";
946
+ throw miss;
947
+ }
948
+ if (gated.reason === "not_addressable") {
949
+ var nope = new Error("operatorInbox.archiveForRole: message not addressed to this role");
950
+ nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
951
+ throw nope;
952
+ }
953
+ if (gated.row.archived_at != null) return _decodeRow(gated.row); // idempotent
954
+ var ts = _now();
955
+ await query(
956
+ "UPDATE operator_inbox_messages SET archived_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
957
+ [ts, id],
958
+ );
959
+ var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
960
+ return _decodeRow(fresh.rows[0]);
961
+ }
962
+
774
963
  // ---- cleanupOlderThan -------------------------------------------------
775
964
 
776
965
  async function cleanupOlderThan(input) {
@@ -860,15 +1049,19 @@ function create(opts) {
860
1049
  MAX_LIMIT: MAX_LIMIT,
861
1050
  MAX_BULK_IDS: MAX_BULK_IDS,
862
1051
 
863
- enqueueMessage: enqueueMessage,
864
- inboxForOperator: inboxForOperator,
865
- markRead: markRead,
866
- markUnread: markUnread,
867
- archiveMessage: archiveMessage,
868
- bulkArchive: bulkArchive,
869
- unreadCount: unreadCount,
870
- cleanupOlderThan: cleanupOlderThan,
871
- metricsForKind: metricsForKind,
1052
+ enqueueMessage: enqueueMessage,
1053
+ inboxForOperator: inboxForOperator,
1054
+ inboxForRole: inboxForRole,
1055
+ markRead: markRead,
1056
+ markUnread: markUnread,
1057
+ markReadForRole: markReadForRole,
1058
+ archiveMessage: archiveMessage,
1059
+ archiveForRole: archiveForRole,
1060
+ bulkArchive: bulkArchive,
1061
+ unreadCount: unreadCount,
1062
+ unreadCountForRole: unreadCountForRole,
1063
+ cleanupOlderThan: cleanupOlderThan,
1064
+ metricsForKind: metricsForKind,
872
1065
  };
873
1066
  }
874
1067