@blamejs/core 0.9.46 → 0.10.1

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 (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
package/index.js CHANGED
@@ -89,6 +89,10 @@ var safeJsonPath = require("./lib/safe-jsonpath");
89
89
  var safeMime = require("./lib/safe-mime");
90
90
  var safeDns = require("./lib/safe-dns");
91
91
  var safeSmtp = require("./lib/safe-smtp");
92
+ var safeSieve = require("./lib/safe-sieve");
93
+ var safeIcap = require("./lib/safe-icap");
94
+ var safeIcal = require("./lib/safe-ical");
95
+ var safeVcard = require("./lib/safe-vcard");
92
96
  var mailStore = require("./lib/mail-store");
93
97
  var ntpCheck = require("./lib/ntp-check");
94
98
  var auditSign = require("./lib/audit-sign");
@@ -164,6 +168,10 @@ var guardSvg = require("./lib/guard-svg");
164
168
  var guardFilename = require("./lib/guard-filename");
165
169
  var guardMessageId = require("./lib/guard-message-id");
166
170
  var guardSmtpCommand = require("./lib/guard-smtp-command");
171
+ var guardImapCommand = require("./lib/guard-imap-command");
172
+ var guardPop3Command = require("./lib/guard-pop3-command");
173
+ var guardManageSieveCommand = require("./lib/guard-managesieve-command");
174
+ var guardJmap = require("./lib/guard-jmap");
167
175
  var guardEnvelope = require("./lib/guard-envelope");
168
176
  var guardDsn = require("./lib/guard-dsn");
169
177
  var guardListUnsubscribe = require("./lib/guard-list-unsubscribe");
@@ -261,8 +269,22 @@ var mail = require("./lib/mail");
261
269
  mail.rbl = require("./lib/mail-rbl");
262
270
  mail.greylist = require("./lib/mail-greylist");
263
271
  mail.helo = require("./lib/mail-helo");
272
+ mail.deploy = require("./lib/mail-deploy");
273
+ mail.sieve = require("./lib/mail-sieve");
274
+ mail.journal = require("./lib/mail-journal");
275
+ mail.scan = require("./lib/mail-scan");
276
+ mail.spamScore = require("./lib/mail-spam-score");
277
+ mail.crypto = require("./lib/mail-crypto");
278
+ mail.dav = require("./lib/mail-dav");
264
279
  mail.server = mail.server || {};
265
280
  mail.server.mx = require("./lib/mail-server-mx");
281
+ mail.server.submission = require("./lib/mail-server-submission");
282
+ mail.server.imap = require("./lib/mail-server-imap");
283
+ mail.server.jmap = require("./lib/mail-server-jmap");
284
+ mail.server.pop3 = require("./lib/mail-server-pop3");
285
+ mail.server.managesieve = require("./lib/mail-server-managesieve");
286
+ mail.server.tls = require("./lib/mail-server-tls");
287
+ mail.server.rateLimit = require("./lib/mail-server-rate-limit");
266
288
  var mailArf = require("./lib/mail-arf");
267
289
  var mailBounce = require("./lib/mail-bounce");
268
290
  var mailMdn = require("./lib/mail-mdn");
@@ -437,6 +459,10 @@ module.exports = {
437
459
  guardFilename: guardFilename,
438
460
  guardMessageId: guardMessageId,
439
461
  guardSmtpCommand: guardSmtpCommand,
462
+ guardImapCommand: guardImapCommand,
463
+ guardPop3Command: guardPop3Command,
464
+ guardManageSieveCommand: guardManageSieveCommand,
465
+ guardJmap: guardJmap,
440
466
  guardEnvelope: guardEnvelope,
441
467
  guardDsn: guardDsn,
442
468
  guardListUnsubscribe: guardListUnsubscribe,
@@ -539,6 +565,10 @@ module.exports = {
539
565
  safeMime: safeMime,
540
566
  safeDns: safeDns,
541
567
  safeSmtp: safeSmtp,
568
+ safeSieve: safeSieve,
569
+ safeIcap: safeIcap,
570
+ safeIcal: safeIcal,
571
+ safeVcard: safeVcard,
542
572
  mailStore: mailStore,
543
573
  safeSchema: safeSchema,
544
574
  pagination: pagination,
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * lib/_test/crypto-fixtures.js — test-only fixtures for crypto round-
4
+ * trip coverage. Not part of the public `b.*` surface; not loaded by
5
+ * `index.js`. Operator code never reaches here — the only callers are
6
+ * tests under `test/layer-0-primitives/crypto-*.test.js` and
7
+ * fuzz harnesses that exercise the legacy-envelope read path.
8
+ *
9
+ * The leading underscore in the directory name signals "internal" to
10
+ * downstream consumers walking the tree; the `_test` segment makes the
11
+ * intent explicit. `package.json#files` MUST NOT include `lib/_test/`
12
+ * so the published tarball doesn't ship these mints to operators.
13
+ */
14
+
15
+ var nodeCrypto = require("node:crypto");
16
+ var { xchacha20poly1305 } = require("../vendor/noble-ciphers.cjs");
17
+ var C = require("../constants");
18
+ var bCrypto = require("../crypto");
19
+
20
+ /**
21
+ * mintLegacyEnvelope0xE1 — produce a pre-bump 0xE1-shape envelope
22
+ * sealing `plaintext` against `recipient = { publicKey, ecPublicKey }`.
23
+ * The 0xE1 wire shape matches 0xE2 except the magic byte is 0xE1 AND
24
+ * the KDF input concatenates only (mlkemSs || ecSs), omitting the
25
+ * NIST SP 800-56C r2 §4.1 FixedInfo suite-binding bytes the v0.7.16
26
+ * bump introduced. Used by crypto-envelope tests to round-trip a
27
+ * known-shape 0xE1 blob through `b.crypto.decrypt(..., { allowLegacy: true })`.
28
+ *
29
+ * Production code NEVER calls this — operators with at-rest 0xE1 data
30
+ * sealed those bytes pre-bump and the framework's only contract today
31
+ * is READING them, never producing them.
32
+ */
33
+ function mintLegacyEnvelope0xE1(plaintext, recipient) {
34
+ var mlkemPub = nodeCrypto.createPublicKey(recipient.publicKey);
35
+ var kem = nodeCrypto.encapsulate(mlkemPub);
36
+ var ephEc = nodeCrypto.generateKeyPairSync("ec", {
37
+ namedCurve: "P-384",
38
+ publicKeyEncoding: { type: "spki", format: "der" },
39
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
40
+ });
41
+ var ecSs = nodeCrypto.diffieHellman({
42
+ privateKey: nodeCrypto.createPrivateKey(ephEc.privateKey),
43
+ publicKey: nodeCrypto.createPublicKey(recipient.ecPublicKey),
44
+ });
45
+ // KDF input: NO FixedInfo — that's the 0xE1 → 0xE2 difference.
46
+ var key = bCrypto.kdf(Buffer.concat([kem.sharedKey, ecSs]), C.BYTES.bytes(32));
47
+ var nonce = bCrypto.generateBytes(C.BYTES.bytes(24));
48
+ // Legacy 0xE1 envelope header — 4 bytes: magic, kemId, cipherId, kdfId.
49
+ var headerAad = Buffer.from([
50
+ 0xE1, // allow:raw-byte-literal — legacy 0xE1 envelope magic byte
51
+ C.KEM_IDS.ML_KEM_1024_P384,
52
+ C.CIPHER_IDS.XCHACHA20_POLY1305,
53
+ C.KDF_IDS.SHAKE256,
54
+ ]);
55
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
56
+ var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length); // allow:raw-byte-literal — 16-bit length-prefix field
57
+ var ecEphDer = ephEc.publicKey;
58
+ var ecEphLen = Buffer.alloc(2); ecEphLen.writeUInt16BE(ecEphDer.length); // allow:raw-byte-literal — 16-bit length-prefix field
59
+ return Buffer.concat([
60
+ headerAad,
61
+ kemCtLen, kem.ciphertext, ecEphLen, ecEphDer, nonce, Buffer.from(ct),
62
+ ]).toString("base64");
63
+ }
64
+
65
+ module.exports = {
66
+ mintLegacyEnvelope0xE1: mintLegacyEnvelope0xE1,
67
+ };
@@ -111,10 +111,11 @@ function create(opts) {
111
111
  var topics = new Map();
112
112
 
113
113
  return {
114
- registerTopic: function (name, topicOpts) { return _registerTopic(topics, name, topicOpts || {}, auditImpl); },
115
- publish: function (name, payload, pOpts) { return _publish(topics, opts.pubsub, name, payload, pOpts || {}, permissions, auditImpl); },
116
- subscribe: function (name, handler, sOpts) { return _subscribe(topics, opts.pubsub, name, handler, sOpts || {}, permissions, auditImpl); },
117
- listTopics: function (args) { return _listTopics(topics, args || {}, permissions); },
114
+ registerTopic: function (name, topicOpts) { return _registerTopic(topics, name, topicOpts || {}, auditImpl); },
115
+ unregisterTopic: function (name) { return _unregisterTopic(topics, name, auditImpl); },
116
+ publish: function (name, payload, pOpts) { return _publish(topics, opts.pubsub, name, payload, pOpts || {}, permissions, auditImpl); },
117
+ subscribe: function (name, handler, sOpts) { return _subscribe(topics, opts.pubsub, name, handler, sOpts || {}, permissions, auditImpl); },
118
+ listTopics: function (args) { return _listTopics(topics, args || {}, permissions); },
118
119
  AgentEventBusError: AgentEventBusError,
119
120
  guards: {
120
121
  topic: guardEventBusTopic,
@@ -135,8 +136,17 @@ function _registerTopic(topics, name, topicOpts, auditImpl) {
135
136
  throw new AgentEventBusError("agent-event-bus/bad-schema",
136
137
  "registerTopic: schema required (flat key→type map)");
137
138
  }
139
+ // BUG-12 — `kind` is now captured on register so listTopics's kind
140
+ // filter actually matches. Prior shape never set entry.kind, so the
141
+ // filter at args.kind was dead. Default value derives from the
142
+ // dotted topic name's first segment ("mail.scan.x" → "mail"), giving
143
+ // operators a free-by-default grouping without explicit annotation.
144
+ var kind = typeof topicOpts.kind === "string" && topicOpts.kind.length > 0
145
+ ? topicOpts.kind
146
+ : (name.indexOf(".") > 0 ? name.split(".")[0] : name);
138
147
  var entry = {
139
148
  name: name,
149
+ kind: kind,
140
150
  schema: Object.freeze(Object.assign({}, topicOpts.schema)),
141
151
  posture: topicOpts.posture || null,
142
152
  tenantScope: topicOpts.tenantScope === true,
@@ -150,18 +160,36 @@ function _registerTopic(topics, name, topicOpts, auditImpl) {
150
160
  };
151
161
  topics.set(name, entry);
152
162
  _safeAudit(auditImpl, "agent.event_bus.topic_registered", null, {
153
- name: name, posture: entry.posture, tenantScope: entry.tenantScope,
163
+ name: name, kind: kind, posture: entry.posture, tenantScope: entry.tenantScope,
154
164
  });
155
165
  }
156
166
 
167
+ // SUBSTRATE-22 — operators reloading a module (test runners between
168
+ // runs, hot-reload tools, multi-tenant onboarding flows that
169
+ // register-deregister topics) need a clean unregister path; without
170
+ // it the second register throws topic-duplicate and the operator is
171
+ // stuck. The reverse audit emit pairs with topic_registered for
172
+ // lifecycle traceability.
173
+ function _unregisterTopic(topics, name, auditImpl) {
174
+ guardEventBusTopic.validate(name);
175
+ if (!topics.has(name)) {
176
+ throw new AgentEventBusError("agent-event-bus/unknown-topic",
177
+ "unregisterTopic: '" + name + "' not registered");
178
+ }
179
+ topics.delete(name);
180
+ _safeAudit(auditImpl, "agent.event_bus.topic_unregistered", null, { name: name });
181
+ }
182
+
157
183
  function _listTopics(topics, args, permissions) {
158
184
  // Permission gate: list-topics requires no special scope by default;
159
185
  // operator can wrap with their own permissions instance for stricter.
186
+ // BUG-12 — kind filter now matches because register captures kind.
160
187
  var out = [];
161
188
  topics.forEach(function (entry) {
162
- if (args.kind && entry.kind && entry.kind !== args.kind) return;
189
+ if (args.kind && entry.kind !== args.kind) return;
163
190
  out.push({
164
191
  name: entry.name,
192
+ kind: entry.kind,
165
193
  schema: entry.schema,
166
194
  posture: entry.posture,
167
195
  tenantScope: entry.tenantScope,
@@ -201,6 +229,24 @@ async function _publish(topics, pubsub, name, payload, pOpts, permissions, audit
201
229
  }
202
230
  // Schema validation.
203
231
  guardEventBusPayload.validate(payload, entry.schema);
232
+ // SUBSTRATE-6 — when a topic is tenant-scoped, require the publisher
233
+ // to declare a tenantId BEFORE the event reaches the durable bus
234
+ // backend. Prior shape allowed `wrapped._tenantId: null` to land on
235
+ // the bus, and the receive-side drop only fired AFTER persistence —
236
+ // every tenant's queue / topic / Kafka log accumulated entries from
237
+ // unknown-tenant publishers. Refuse here so the durable record is
238
+ // always tagged with a real tenant.
239
+ if (entry.tenantScope) {
240
+ if (!pOpts.actor || !pOpts.actor.tenantId) {
241
+ _safeAudit(auditImpl, "agent.event_bus.publish_denied", pOpts.actor || null, {
242
+ topic: name, reason: "tenant-scoped-topic-requires-publisher-tenant-id",
243
+ });
244
+ throw new AgentEventBusError("agent-event-bus/publish-denied",
245
+ "publish: tenant-scoped topic '" + name +
246
+ "' requires actor.tenantId at publish time — refusing to write " +
247
+ "untenanted entries to a durable backend");
248
+ }
249
+ }
204
250
  // Wrap the payload with topic metadata so subscribers can see the
205
251
  // posture + tenantScope at delivery (re-validation).
206
252
  var wrapped = {
@@ -121,6 +121,13 @@ function create(opts) {
121
121
  return {
122
122
  get: function (method, actorId, key) { return _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes); },
123
123
  put: function (method, actorId, key, result, putOpts) { return _put(store, method, actorId, key, result, putOpts || {}, ttlMs, maxResultBytes, fingerprintArgs, auditImpl); },
124
+ // SUBSTRATE-4 — putIfAbsent gates concurrent retries at the cache
125
+ // boundary so only one consumer runs the handler. Operator wraps:
126
+ // var claim = await idem.putIfAbsent(method, actor, key, args);
127
+ // if (claim.alreadyClaimed) return claim.result; // another retry won
128
+ // var result = await agent[method](args);
129
+ // await idem.put(method, actor, key, result, { args });
130
+ putIfAbsent: function (method, actorId, key, putOpts) { return _putIfAbsent(store, method, actorId, key, putOpts || {}, ttlMs, maxResultBytes, fingerprintArgs, auditImpl); },
124
131
  invalidate: function (method, actorId, key) { return _invalidate(store, method, actorId, key, auditImpl); },
125
132
  gc: function (gcOpts) { return _gc(store, gcOpts || {}, auditImpl); },
126
133
  fingerprintArgs: _fingerprintArgs,
@@ -129,6 +136,97 @@ function create(opts) {
129
136
  };
130
137
  }
131
138
 
139
+ // SUBSTRATE-4 — atomic claim/check/run pattern. Returns one of:
140
+ // { alreadyClaimed: false, fingerprint } — caller runs the handler
141
+ // { alreadyClaimed: true, pending: true } — another in-flight claim holds the slot
142
+ // { alreadyClaimed: true, result: <cached> } — prior handler completed; cached result
143
+ //
144
+ // Per JMAP §3.7 + draft-ietf-httpapi-idempotency-key §5, exactly-once
145
+ // semantics require an atomic claim BEFORE running the handler — the
146
+ // prior get→put pattern raced when two consumers picked the same
147
+ // envelope off the queue at the same instant. Operators with a SQL/
148
+ // Redis backend implement `store.putIfAbsent(key, value) → boolean`
149
+ // (true on insert, false on existing row); the in-memory fallback
150
+ // emulates via a synchronous Map check.
151
+ async function _putIfAbsent(store, method, actorId, key, putOpts, ttlMs, maxResultBytes, fingerprintArgs, auditImpl) {
152
+ _checkArgs(method, actorId, key);
153
+ guardIdempotencyKey.validate(key);
154
+ var hash = _keyHash(method, actorId, key);
155
+ var requestFingerprint = putOpts.requestFingerprint ||
156
+ (fingerprintArgs && putOpts.args ? _fingerprintArgs(putOpts.args) : null);
157
+ var now = Date.now();
158
+ var pendingRow = {
159
+ method: method,
160
+ actorIdHash: _actorIdHash(actorId),
161
+ keyHash: hash,
162
+ requestFingerprint: requestFingerprint,
163
+ resultBlob: null,
164
+ firstAt: now,
165
+ lastWrittenAt: now,
166
+ replayCount: 0,
167
+ expiresAt: now + ttlMs,
168
+ status: "pending",
169
+ };
170
+ // Operator-supplied putIfAbsent path. Returns truthy when the row
171
+ // was inserted (we won the claim); falsy when the row already
172
+ // existed (another retry won OR a completed result is cached).
173
+ var inserted = false;
174
+ if (typeof store.putIfAbsent === "function") {
175
+ inserted = await store.putIfAbsent(method, actorId, hash, pendingRow);
176
+ } else {
177
+ // Backends without atomic putIfAbsent fall back to optimistic-
178
+ // get-then-put. The race window is narrow but real; operators
179
+ // with strict exactly-once requirements wire the atomic store.
180
+ var existing0 = await store.get(method, actorId, hash);
181
+ if (!existing0) {
182
+ await store.put(method, actorId, hash, pendingRow);
183
+ inserted = true;
184
+ }
185
+ }
186
+ if (inserted) {
187
+ _safeAudit(auditImpl, "agent.idempotency.claimed", null, {
188
+ method: method, actorIdHash: _truncHash(pendingRow.actorIdHash),
189
+ });
190
+ return { alreadyClaimed: false, fingerprint: requestFingerprint };
191
+ }
192
+ // We lost the claim — load the existing row.
193
+ var existing = await store.get(method, actorId, hash);
194
+ if (!existing) {
195
+ // Race: insert+immediate-delete. Tell the caller it's safe to
196
+ // retry; the caller's retry policy decides whether to back off.
197
+ return { alreadyClaimed: false, fingerprint: requestFingerprint };
198
+ }
199
+ // Defense-in-depth: caller's args must match. Operator MAY pass a
200
+ // different result type than originally cached only if the
201
+ // fingerprint matches — protects against logic-bug downgrade.
202
+ if (existing.requestFingerprint && requestFingerprint &&
203
+ existing.requestFingerprint !== requestFingerprint) {
204
+ _safeAudit(auditImpl, "agent.idempotency.key_reuse_different_args", null, {
205
+ method: method, actorIdHash: _truncHash(existing.actorIdHash),
206
+ });
207
+ throw new AgentIdempotencyError("agent-idempotency/key-reuse-different-args",
208
+ "putIfAbsent: key '" + key + "' reused with different args for method '" + method +
209
+ "' — refused per JMAP §3.7 semantics");
210
+ }
211
+ if (existing.status === "pending") {
212
+ return { alreadyClaimed: true, pending: true, firstAt: existing.firstAt };
213
+ }
214
+ // Completed cached result.
215
+ var result;
216
+ try { result = safeJson.parse(existing.resultBlob, { maxBytes: maxResultBytes }); }
217
+ catch (e) {
218
+ throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
219
+ "putIfAbsent: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
220
+ }
221
+ return {
222
+ alreadyClaimed: true,
223
+ pending: false,
224
+ result: result,
225
+ firstAt: existing.firstAt,
226
+ replayCount: existing.replayCount || 0,
227
+ };
228
+ }
229
+
132
230
  // ---- Core API -------------------------------------------------------------
133
231
 
134
232
  async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultBytes) {
@@ -144,11 +242,6 @@ async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultByte
144
242
  { method: method, actorIdHash: _truncHash(_actorIdHash(actorId)) });
145
243
  return null;
146
244
  }
147
- var nextReplayCount = (row.replayCount || 0) + 1;
148
- _safeAudit(auditImpl, "agent.idempotency.replay", null, {
149
- method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
150
- firstAt: row.firstAt, replayCount: nextReplayCount,
151
- });
152
245
  // Deserialize the sealed result blob. v0.9.22 ships a simple
153
246
  // safeJson re-parse since the result was JSON-stringified at put().
154
247
  // v0.9.25 tenant integration will swap this for per-tenant sealRow
@@ -164,17 +257,38 @@ async function _get(store, method, actorId, key, auditImpl, ttlMs, maxResultByte
164
257
  throw new AgentIdempotencyError("agent-idempotency/corrupt-result",
165
258
  "get: cached result failed to parse — " + (e && e.message ? e.message : String(e)));
166
259
  }
167
- // Persist the incremented replayCount + lastReplayedAt so subsequent
168
- // gets see the updated state. Operator audit pipelines rely on
169
- // replayCount to surface retry storms.
170
- row.replayCount = nextReplayCount;
171
- row.lastReplayedAt = Date.now();
172
- await store.put(method, actorId, hash, row);
260
+ // SUBSTRATE-12 atomic replayCount increment. The prior shape
261
+ // (read row mutate put row) raced two concurrent retries: each
262
+ // saw replayCount=N, both wrote replayCount=N+1, so the counter
263
+ // missed bumps and the put-with-fresh-result race-clobbered prior
264
+ // increments. Operators wire `store.incrementReplayCount` with
265
+ // `UPDATE ... SET replay_count = replay_count + 1 RETURNING *`
266
+ // (atomic at the DB layer); in-memory backend is naturally atomic.
267
+ // When the store doesn't expose the helper, fall back to read-
268
+ // modify-write with an audit emit so operators know the posture.
269
+ var updatedReplayCount;
270
+ if (typeof store.incrementReplayCount === "function") {
271
+ var updated = await store.incrementReplayCount(method, actorId, hash);
272
+ updatedReplayCount = updated && updated.replayCount ? updated.replayCount : (row.replayCount || 0) + 1;
273
+ } else {
274
+ _safeAudit(auditImpl, "agent.idempotency.non_atomic_increment", null, {
275
+ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
276
+ warning: "store lacks incrementReplayCount — counter may race under concurrent retries",
277
+ });
278
+ updatedReplayCount = (row.replayCount || 0) + 1;
279
+ row.replayCount = updatedReplayCount;
280
+ row.lastReplayedAt = Date.now();
281
+ await store.put(method, actorId, hash, row);
282
+ }
283
+ _safeAudit(auditImpl, "agent.idempotency.replay", null, {
284
+ method: method, actorIdHash: _truncHash(_actorIdHash(actorId)),
285
+ firstAt: row.firstAt, replayCount: updatedReplayCount,
286
+ });
173
287
  return {
174
288
  result: result,
175
289
  firstAt: row.firstAt,
176
- lastReplayedAt: row.lastReplayedAt,
177
- replayCount: nextReplayCount,
290
+ lastReplayedAt: row.lastReplayedAt || Date.now(),
291
+ replayCount: updatedReplayCount,
178
292
  requestFingerprint: row.requestFingerprint,
179
293
  };
180
294
  }
@@ -272,12 +386,26 @@ function _truncHash(hash) {
272
386
  function _fingerprintArgs(args) {
273
387
  // Strip the idempotencyKey itself out of fingerprint computation —
274
388
  // the key IS the cache lookup; including it would make every key a
275
- // unique fingerprint and defeat the args-mismatch defense. Also
276
- // strip framework-internal cross-cutting fields that vary per-hop.
389
+ // unique fingerprint and defeat the args-mismatch defense. Strip
390
+ // _traceContext (varies per-hop, doesn't change result).
391
+ //
392
+ // SUBSTRATE-11 — DO NOT strip _postureChain. The prior shape ignored
393
+ // _postureChain.postureSet, so a request made under
394
+ // postureSet:["hipaa","pci-dss"] cached the same result that a
395
+ // downgrade attempt under postureSet:["pci-dss"] would replay
396
+ // (elevated-posture cached output returned to a less-privileged
397
+ // caller). Including the sorted postureSet in the fingerprint binds
398
+ // the cached result to its compliance context — defense-in-depth
399
+ // alongside the boundary-time downgrade refusal in
400
+ // b.agent.postureChain._validate.
277
401
  var argsClone = Object.assign({}, args);
278
402
  delete argsClone.idempotencyKey;
279
- delete argsClone._postureChain;
280
403
  delete argsClone._traceContext;
404
+ if (argsClone._postureChain && typeof argsClone._postureChain === "object" &&
405
+ Array.isArray(argsClone._postureChain.postureSet)) {
406
+ argsClone._postureSet = argsClone._postureChain.postureSet.slice().sort();
407
+ }
408
+ delete argsClone._postureChain; // chainTrail + enteredAt vary per-hop; postureSet binds via _postureSet
281
409
  // Canonicalize via RFC 8785 JCS (key-sorted) so two semantically
282
410
  // identical args objects with different key insertion order produce
283
411
  // the same fingerprint. Cross-producer / cross-runtime retries (JMAP
@@ -313,6 +441,31 @@ function _inMemoryBackend() {
313
441
  map.set(_k(method, actorId, hash), row);
314
442
  return Promise.resolve();
315
443
  },
444
+ // SUBSTRATE-4 — atomic insert. Map.set is synchronous so the
445
+ // get+set pair below is naturally race-free within the in-memory
446
+ // backend (V8 single-threaded). Returns true when inserted, false
447
+ // when the row already exists.
448
+ putIfAbsent: function (method, actorId, hash, row) {
449
+ var k = _k(method, actorId, hash);
450
+ if (map.has(k)) return Promise.resolve(false);
451
+ map.set(k, row);
452
+ return Promise.resolve(true);
453
+ },
454
+ // SUBSTRATE-12 — atomic replayCount increment. Operators wiring
455
+ // a SQL backend implement this with `UPDATE ... SET
456
+ // replay_count = replay_count + 1 WHERE keyHash = $1 RETURNING *`
457
+ // — read-modify-write race-free. In-memory backend is naturally
458
+ // race-free; returns a SNAPSHOT of the row at the moment of
459
+ // increment so two concurrent callers each see their own
460
+ // replayCount (operator's SQL backend RETURNING * does the same).
461
+ incrementReplayCount: function (method, actorId, hash) {
462
+ var k = _k(method, actorId, hash);
463
+ var row = map.get(k);
464
+ if (!row) return Promise.resolve(null);
465
+ row.replayCount = (row.replayCount || 0) + 1;
466
+ row.lastReplayedAt = Date.now();
467
+ return Promise.resolve(Object.assign({}, row));
468
+ },
316
469
  delete: function (method, actorId, hash) {
317
470
  map.delete(_k(method, actorId, hash));
318
471
  return Promise.resolve();