@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.
- package/CHANGELOG.md +4 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1273 -15
- package/lib/asset-manifest.json +5 -5
- package/lib/checkout.js +70 -0
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +186 -6
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +178 -69
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +27 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +1088 -129
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
package/lib/operator-accounts.js
CHANGED
|
@@ -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:
|
|
597
|
-
listByActor:
|
|
598
|
-
listByResource:
|
|
599
|
-
searchAction:
|
|
600
|
-
chainHead:
|
|
601
|
-
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
|
};
|
package/lib/operator-inbox.js
CHANGED
|
@@ -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:
|
|
864
|
-
inboxForOperator:
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|