@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.
- package/CHANGELOG.md +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
+
};
|
package/lib/agent-event-bus.js
CHANGED
|
@@ -111,10 +111,11 @@ function create(opts) {
|
|
|
111
111
|
var topics = new Map();
|
|
112
112
|
|
|
113
113
|
return {
|
|
114
|
-
registerTopic:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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 = {
|
package/lib/agent-idempotency.js
CHANGED
|
@@ -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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// replayCount
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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:
|
|
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.
|
|
276
|
-
//
|
|
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();
|