@blamejs/core 0.8.82 → 0.8.86
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 +1 -1
- package/index.js +6 -0
- package/lib/a2a-tasks.js +598 -0
- package/lib/a2a.js +10 -0
- package/lib/acme.js +189 -5
- package/lib/audit.js +1 -0
- package/lib/cache-status.js +288 -0
- package/lib/compliance.js +36 -0
- package/lib/framework-error.js +19 -0
- package/lib/mcp-tool-registry.js +473 -0
- package/lib/mcp.js +3 -0
- package/lib/middleware/idempotency-key.js +424 -0
- package/lib/middleware/index.js +10 -0
- package/lib/middleware/no-cache.js +106 -0
- package/lib/problem-details.js +439 -0
- package/lib/server-timing.js +174 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.middleware.idempotencyKey
|
|
4
|
+
* @nav Middleware
|
|
5
|
+
* @title Idempotency-Key
|
|
6
|
+
* @order 400
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* draft-ietf-httpapi-idempotency-key middleware — replay-safe POST /
|
|
10
|
+
* PUT / PATCH / DELETE handling for retry-capable clients. A client
|
|
11
|
+
* sends `Idempotency-Key: <opaque>` on a mutating request; the
|
|
12
|
+
* middleware:
|
|
13
|
+
*
|
|
14
|
+
* 1. Looks up the key in the operator-supplied `store`. A hit
|
|
15
|
+
* replays the cached `{ statusCode, headers, body }` without
|
|
16
|
+
* invoking the handler (idempotent replay).
|
|
17
|
+
* 2. Compares the inbound request fingerprint (method + path +
|
|
18
|
+
* body hash) against the cached fingerprint. A mismatch is a
|
|
19
|
+
* client-side mistake — same key, different request — and
|
|
20
|
+
* refuses with 422 + RFC 9457 Problem Details
|
|
21
|
+
* `idempotency/key-reuse-mismatch` per the draft §4.3.
|
|
22
|
+
* 3. On miss, attaches a capture wrapper to `res.end` so the
|
|
23
|
+
* handler's response is intercepted, persisted, and replayed
|
|
24
|
+
* on every subsequent retry within `ttlMs`.
|
|
25
|
+
*
|
|
26
|
+
* `Idempotency-Key` is OPTIONAL — clients that don't send it skip
|
|
27
|
+
* the cache and the middleware is a no-op. Idempotency is a
|
|
28
|
+
* client-asserted contract; the server promises "if you send the
|
|
29
|
+
* same key + same body, you get the same answer." Operators
|
|
30
|
+
* wanting strict idempotency on a particular route compose with
|
|
31
|
+
* `requireIdempotencyKey: true` to refuse missing headers with
|
|
32
|
+
* `400 idempotency/missing-key`.
|
|
33
|
+
*
|
|
34
|
+
* Store interface is operator-supplied so cluster deployments can
|
|
35
|
+
* plug their distributed store (Redis, SQLite-cluster, etc.). The
|
|
36
|
+
* first-party `memoryStore` is included for single-instance
|
|
37
|
+
* testing — it accepts `{ ttlMs }` and exposes `_resetForTest()`.
|
|
38
|
+
*
|
|
39
|
+
* @card
|
|
40
|
+
* draft-ietf-httpapi-idempotency-key middleware — replay-safe POST/PUT/PATCH/DELETE handling for retry-capable clients with operator-supplied distributed store.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
var nodeCrypto = require("node:crypto");
|
|
44
|
+
var lazyRequire = require("../lazy-require");
|
|
45
|
+
var numericBounds = require("../numeric-bounds");
|
|
46
|
+
var safeBuffer = require("../safe-buffer");
|
|
47
|
+
var { defineClass } = require("../framework-error");
|
|
48
|
+
|
|
49
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
50
|
+
var problemDetails = lazyRequire(function () { return require("../problem-details"); });
|
|
51
|
+
var C = require("../constants");
|
|
52
|
+
|
|
53
|
+
var IdempotencyError = defineClass("IdempotencyError", { alwaysPermanent: true });
|
|
54
|
+
|
|
55
|
+
// Default applicable methods per draft-ietf-httpapi-idempotency-key §3:
|
|
56
|
+
// GET / HEAD / OPTIONS are inherently idempotent (RFC 9110 §9.2.2) so
|
|
57
|
+
// the middleware skips them by default. DELETE is included because
|
|
58
|
+
// the draft endorses it as the canonical "idempotent but not safe"
|
|
59
|
+
// retry surface.
|
|
60
|
+
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "PATCH", "DELETE"]);
|
|
61
|
+
|
|
62
|
+
// Idempotency-Key shape per the draft §2 — ASCII printable, no
|
|
63
|
+
// control chars, length 1..255 (typical client implementations cap
|
|
64
|
+
// at 36 for UUID + a few extra for vendor prefixes; 255 is the
|
|
65
|
+
// upper bound that still fits a single HTTP header line).
|
|
66
|
+
var KEY_RE = /^[\x21-\x7E]+$/; // allow:raw-byte-literal — printable ASCII codepoint range
|
|
67
|
+
var KEY_MAX_LEN = 255; // allow:raw-byte-literal — draft §2 upper bound
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @primitive b.middleware.idempotencyKey.memoryStore
|
|
71
|
+
* @signature b.middleware.idempotencyKey.memoryStore(opts?)
|
|
72
|
+
* @since 0.8.84
|
|
73
|
+
* @status stable
|
|
74
|
+
* @related b.middleware.idempotencyKey
|
|
75
|
+
*
|
|
76
|
+
* First-party in-memory store for `idempotencyKey` middleware.
|
|
77
|
+
* Single-instance only — cluster deployments compose against a
|
|
78
|
+
* distributed store (Redis / SQLite-cluster) matching the
|
|
79
|
+
* three-method interface: `get(key) → record | null`,
|
|
80
|
+
* `set(key, value, ttlMs)`, `delete(key)`. TTL is enforced lazily
|
|
81
|
+
* at read time; the store's resident size is operator-supplied via
|
|
82
|
+
* `opts.maxEntries` (default 10000) — when the cap is hit, the
|
|
83
|
+
* oldest entry is evicted (FIFO; the recorded request was
|
|
84
|
+
* idempotent anyway so re-running is correct, not just safe).
|
|
85
|
+
*
|
|
86
|
+
* @opts
|
|
87
|
+
* maxEntries: number, // default 10000 — FIFO eviction on overflow
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* var store = b.middleware.idempotencyKey.memoryStore({ maxEntries: 5000 });
|
|
91
|
+
* var mw = b.middleware.idempotencyKey({ store: store, ttlMs: C.TIME.hours(24) });
|
|
92
|
+
* app.use(mw);
|
|
93
|
+
*/
|
|
94
|
+
function memoryStore(opts) {
|
|
95
|
+
opts = opts || {};
|
|
96
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
97
|
+
opts.maxEntries, "memoryStore.maxEntries", IdempotencyError, "idempotency/bad-max-entries");
|
|
98
|
+
var maxEntries = opts.maxEntries !== undefined ? opts.maxEntries : 10000; // allow:raw-byte-literal — default in-memory cap, not bytes
|
|
99
|
+
var data = new Map();
|
|
100
|
+
return {
|
|
101
|
+
get: function (key) {
|
|
102
|
+
var rec = data.get(key);
|
|
103
|
+
if (!rec) return null;
|
|
104
|
+
if (rec.expiresAt < Date.now()) {
|
|
105
|
+
data.delete(key);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return rec.value;
|
|
109
|
+
},
|
|
110
|
+
set: function (key, value, ttlMs) {
|
|
111
|
+
if (data.size >= maxEntries) {
|
|
112
|
+
var oldest = data.keys().next().value;
|
|
113
|
+
data.delete(oldest);
|
|
114
|
+
}
|
|
115
|
+
data.set(key, { value: value, expiresAt: Date.now() + ttlMs });
|
|
116
|
+
},
|
|
117
|
+
delete: function (key) {
|
|
118
|
+
data.delete(key);
|
|
119
|
+
},
|
|
120
|
+
_resetForTest: function () {
|
|
121
|
+
data.clear();
|
|
122
|
+
},
|
|
123
|
+
_size: function () { return data.size; },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _validateStore(store, where) {
|
|
128
|
+
if (!store || typeof store !== "object") {
|
|
129
|
+
throw new IdempotencyError("idempotency/bad-store",
|
|
130
|
+
where + ": store must be an object", true);
|
|
131
|
+
}
|
|
132
|
+
if (typeof store.get !== "function" ||
|
|
133
|
+
typeof store.set !== "function" ||
|
|
134
|
+
typeof store.delete !== "function") {
|
|
135
|
+
throw new IdempotencyError("idempotency/bad-store",
|
|
136
|
+
where + ": store must implement { get, set, delete }", true);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _fingerprintRequest(req, bodyBytes) {
|
|
141
|
+
// Fingerprint = method + path + body sha3-256. Per the draft §4.3,
|
|
142
|
+
// a key+body mismatch is a client-side mistake; our fingerprint
|
|
143
|
+
// covers method + path so a client reusing a key across different
|
|
144
|
+
// endpoints is also caught. Body hash uses SHA3-256 to match the
|
|
145
|
+
// framework's PQC-first crypto posture (SHA-256 is fine for
|
|
146
|
+
// collision-resistance here but we use SHA3 for codebase
|
|
147
|
+
// uniformity).
|
|
148
|
+
var hash = nodeCrypto.createHash("sha3-256");
|
|
149
|
+
hash.update((req.method || "GET") + "\n");
|
|
150
|
+
hash.update((req.url || "/") + "\n");
|
|
151
|
+
if (bodyBytes && bodyBytes.length > 0) {
|
|
152
|
+
hash.update(bodyBytes);
|
|
153
|
+
}
|
|
154
|
+
return hash.digest("hex");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _emitAudit(action, metadata, outcome) {
|
|
158
|
+
try {
|
|
159
|
+
audit().safeEmit({
|
|
160
|
+
action: action,
|
|
161
|
+
outcome: outcome || "success",
|
|
162
|
+
metadata: metadata,
|
|
163
|
+
});
|
|
164
|
+
} catch (_e) { /* best-effort */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @primitive b.middleware.idempotencyKey
|
|
169
|
+
* @signature b.middleware.idempotencyKey(opts)
|
|
170
|
+
* @since 0.8.84
|
|
171
|
+
* @status stable
|
|
172
|
+
* @related b.middleware.idempotencyKey.memoryStore, b.problemDetails
|
|
173
|
+
*
|
|
174
|
+
* Build the Idempotency-Key middleware. Returns a connect-style
|
|
175
|
+
* `(req, res, next) => void` handler.
|
|
176
|
+
*
|
|
177
|
+
* - When `req.method` is not in `opts.methods` (default POST / PUT /
|
|
178
|
+
* PATCH / DELETE), the middleware is a pass-through.
|
|
179
|
+
* - When the request lacks an `Idempotency-Key` header and
|
|
180
|
+
* `opts.requireIdempotencyKey === true`, refuses with HTTP 400 +
|
|
181
|
+
* `application/problem+json` body
|
|
182
|
+
* `idempotency/missing-key`.
|
|
183
|
+
* - When the key is present but malformed (control chars, length
|
|
184
|
+
* out of range), refuses with HTTP 400 +
|
|
185
|
+
* `idempotency/bad-key`.
|
|
186
|
+
* - When the store has a hit AND the cached fingerprint matches the
|
|
187
|
+
* inbound request fingerprint, replays the cached
|
|
188
|
+
* `{ statusCode, headers, body }` and DOES NOT call `next()`.
|
|
189
|
+
* - When the store has a hit AND the fingerprint differs, refuses
|
|
190
|
+
* with HTTP 422 + `idempotency/key-reuse-mismatch`.
|
|
191
|
+
* - On a miss, wraps `res.end` to capture the handler's response
|
|
192
|
+
* and persist `{ fingerprint, statusCode, headers, body }` to
|
|
193
|
+
* the store with `ttlMs` (default 24h) after the handler
|
|
194
|
+
* finishes. The wrapper does NOT capture 5xx server-error
|
|
195
|
+
* responses — replaying a transient infrastructure failure is
|
|
196
|
+
* not idempotent.
|
|
197
|
+
*
|
|
198
|
+
* Per the draft §4.4, a concurrent-retry from the same client (two
|
|
199
|
+
* requests with the same key arriving in quick succession before
|
|
200
|
+
* the first has written to the store) is allowed to handler-execute
|
|
201
|
+
* twice and either response is acceptable; the framework does not
|
|
202
|
+
* lock the key. Operators wanting strict at-most-once execution
|
|
203
|
+
* implement a distributed-lock layer in their store's `set()`
|
|
204
|
+
* method (the interface is opaque to the middleware).
|
|
205
|
+
*
|
|
206
|
+
* @opts
|
|
207
|
+
* store: object, // required — get/set/delete interface
|
|
208
|
+
* ttlMs: number, // default: 24h
|
|
209
|
+
* methods: string[], // default: ["POST","PUT","PATCH","DELETE"]
|
|
210
|
+
* headerName: string, // default: "idempotency-key"
|
|
211
|
+
* requireIdempotencyKey: boolean, // default: false — refuse missing-key
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* var store = b.middleware.idempotencyKey.memoryStore({ maxEntries: 10000 });
|
|
215
|
+
* var mw = b.middleware.idempotencyKey({
|
|
216
|
+
* store: store,
|
|
217
|
+
* ttlMs: C.TIME.hours(24),
|
|
218
|
+
* methods: ["POST", "PUT", "PATCH"],
|
|
219
|
+
* });
|
|
220
|
+
* app.use(mw);
|
|
221
|
+
*/
|
|
222
|
+
function create(opts) {
|
|
223
|
+
if (!opts || typeof opts !== "object") {
|
|
224
|
+
throw new IdempotencyError("idempotency/bad-opts",
|
|
225
|
+
"idempotencyKey: opts must be a non-null object", true);
|
|
226
|
+
}
|
|
227
|
+
_validateStore(opts.store, "idempotencyKey");
|
|
228
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
229
|
+
opts.ttlMs, "idempotencyKey.ttlMs", IdempotencyError, "idempotency/bad-ttl");
|
|
230
|
+
var ttlMs = opts.ttlMs !== undefined ? opts.ttlMs : C.TIME.hours(24);
|
|
231
|
+
var methods = Array.isArray(opts.methods) && opts.methods.length > 0
|
|
232
|
+
? opts.methods.map(function (m) { return String(m).toUpperCase(); })
|
|
233
|
+
: DEFAULT_METHODS.slice();
|
|
234
|
+
var headerName = typeof opts.headerName === "string" && opts.headerName.length > 0
|
|
235
|
+
? opts.headerName.toLowerCase()
|
|
236
|
+
: "idempotency-key";
|
|
237
|
+
var requireKey = opts.requireIdempotencyKey === true;
|
|
238
|
+
|
|
239
|
+
// Per-response collector cap. Idempotency replay only makes sense
|
|
240
|
+
// for response bodies that fit in memory; the cap is operator-
|
|
241
|
+
// tunable via opts.maxBodyBytes (default 1 MiB).
|
|
242
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
243
|
+
opts.maxBodyBytes, "idempotencyKey.maxBodyBytes", IdempotencyError, "idempotency/bad-max-body");
|
|
244
|
+
var maxBodyBytes = opts.maxBodyBytes !== undefined ? opts.maxBodyBytes : C.BYTES.mib(1);
|
|
245
|
+
|
|
246
|
+
return function idempotencyMiddleware(req, res, next) {
|
|
247
|
+
var method = (req.method || "GET").toUpperCase();
|
|
248
|
+
if (methods.indexOf(method) === -1) return next();
|
|
249
|
+
|
|
250
|
+
var key = req.headers && req.headers[headerName];
|
|
251
|
+
if (Array.isArray(key)) key = key[0];
|
|
252
|
+
|
|
253
|
+
if (!key || typeof key !== "string" || key.length === 0) {
|
|
254
|
+
if (!requireKey) return next();
|
|
255
|
+
var missing = problemDetails().create({
|
|
256
|
+
type: problemDetails().getBase() + "/idempotency/missing-key",
|
|
257
|
+
title: "Idempotency-Key header required",
|
|
258
|
+
status: 400, // allow:raw-byte-literal — HTTP status 400 Bad Request
|
|
259
|
+
detail: "This endpoint requires an Idempotency-Key header (draft-ietf-httpapi-idempotency-key).",
|
|
260
|
+
});
|
|
261
|
+
_emitAudit("idempotency.missing_key", { method: method, path: req.url }, "denied");
|
|
262
|
+
return problemDetails().respond(res, missing);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (key.length > KEY_MAX_LEN || !KEY_RE.test(key)) {
|
|
266
|
+
var bad = problemDetails().create({
|
|
267
|
+
type: problemDetails().getBase() + "/idempotency/bad-key",
|
|
268
|
+
title: "Idempotency-Key malformed",
|
|
269
|
+
status: 400, // allow:raw-byte-literal — HTTP status 400
|
|
270
|
+
detail: "Idempotency-Key must be ASCII printable, length 1.." + KEY_MAX_LEN + " (draft §2).",
|
|
271
|
+
});
|
|
272
|
+
_emitAudit("idempotency.bad_key", { method: method, keyLen: key.length }, "denied");
|
|
273
|
+
return problemDetails().respond(res, bad);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
var bodyBytes = req._rawBody || req.body || null;
|
|
277
|
+
if (bodyBytes && typeof bodyBytes === "object" && !Buffer.isBuffer(bodyBytes)) {
|
|
278
|
+
// Buffer-ize a non-buffer body (already-parsed JSON, etc.) so the
|
|
279
|
+
// hash is stable. JSON.stringify with sorted keys would be more
|
|
280
|
+
// robust but the operator-attached body shape is whatever the
|
|
281
|
+
// upstream parser produced; canonicalization is operator-side.
|
|
282
|
+
try {
|
|
283
|
+
bodyBytes = Buffer.from(JSON.stringify(bodyBytes), "utf8");
|
|
284
|
+
} catch (_e) {
|
|
285
|
+
bodyBytes = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
var fingerprint = _fingerprintRequest(req, bodyBytes);
|
|
290
|
+
|
|
291
|
+
var cached = null;
|
|
292
|
+
try { cached = opts.store.get(key); }
|
|
293
|
+
catch (_storeErr) {
|
|
294
|
+
// Store-read failure — emit audit + treat as miss. Idempotency is
|
|
295
|
+
// a best-effort optimization; the handler runs anyway.
|
|
296
|
+
_emitAudit("idempotency.store_read_failed",
|
|
297
|
+
{ key: _redactKey(key), error: String(_storeErr.message || _storeErr) }, "warning");
|
|
298
|
+
cached = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (cached) {
|
|
302
|
+
if (cached.fingerprint !== fingerprint) {
|
|
303
|
+
// §4.3 — same key, different request body. Client mistake.
|
|
304
|
+
var mismatch = problemDetails().create({
|
|
305
|
+
type: problemDetails().getBase() + "/idempotency/key-reuse-mismatch",
|
|
306
|
+
title: "Idempotency-Key reused with different request",
|
|
307
|
+
status: 422, // allow:raw-byte-literal — HTTP status 422 Unprocessable Content (RFC 9110)
|
|
308
|
+
detail: "The Idempotency-Key matches a prior request but the request body/method/path differs (draft §4.3).",
|
|
309
|
+
});
|
|
310
|
+
_emitAudit("idempotency.key_reuse_mismatch",
|
|
311
|
+
{ method: method, path: req.url, keyHash: _hashKey(key) }, "denied");
|
|
312
|
+
return problemDetails().respond(res, mismatch);
|
|
313
|
+
}
|
|
314
|
+
// Replay. The cached body is a base64 string of the original
|
|
315
|
+
// bytes; restore Buffer + write through the response.
|
|
316
|
+
var rawBody;
|
|
317
|
+
try { rawBody = Buffer.from(cached.body || "", "base64"); }
|
|
318
|
+
catch (_decodeErr) { rawBody = Buffer.alloc(0); }
|
|
319
|
+
res.statusCode = cached.statusCode;
|
|
320
|
+
var headerKeys = Object.keys(cached.headers || {});
|
|
321
|
+
for (var i = 0; i < headerKeys.length; i += 1) {
|
|
322
|
+
try { res.setHeader(headerKeys[i], cached.headers[headerKeys[i]]); }
|
|
323
|
+
catch (_hdrErr) { /* operator-restricted header — skip */ }
|
|
324
|
+
}
|
|
325
|
+
_emitAudit("idempotency.replay",
|
|
326
|
+
{ method: method, path: req.url, statusCode: cached.statusCode, keyHash: _hashKey(key) });
|
|
327
|
+
res.end(rawBody);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Miss — capture the handler's response. The bounded collector
|
|
332
|
+
// refuses bodies > maxBodyBytes at push() time (operator can
|
|
333
|
+
// tune via opts.maxBodyBytes; default 1 MiB). When the cap is
|
|
334
|
+
// hit, we abandon the capture + emit audit + DO NOT cache;
|
|
335
|
+
// operators wanting larger replay windows raise the cap.
|
|
336
|
+
var origEnd = res.end.bind(res);
|
|
337
|
+
var origWrite = res.write.bind(res);
|
|
338
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
339
|
+
maxBytes: maxBodyBytes,
|
|
340
|
+
errorClass: IdempotencyError,
|
|
341
|
+
sizeCode: "idempotency/body-too-large",
|
|
342
|
+
sizeMessage: "idempotency: response body exceeded maxBodyBytes (cap=" + maxBodyBytes + "); not cached.",
|
|
343
|
+
});
|
|
344
|
+
var captured = false;
|
|
345
|
+
var oversized = false;
|
|
346
|
+
function _pushChunk(chunk, encoding) {
|
|
347
|
+
if (oversized || !chunk) return;
|
|
348
|
+
try { collector.push(_toBuffer(chunk, encoding)); }
|
|
349
|
+
catch (_capErr) {
|
|
350
|
+
oversized = true;
|
|
351
|
+
_emitAudit("idempotency.body_too_large",
|
|
352
|
+
{ method: method, path: req.url, cap: maxBodyBytes, keyHash: _hashKey(key) }, "warning");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
res.write = function (chunk, encoding) {
|
|
356
|
+
_pushChunk(chunk, encoding);
|
|
357
|
+
return origWrite(chunk, encoding);
|
|
358
|
+
};
|
|
359
|
+
res.end = function (chunk, encoding) {
|
|
360
|
+
if (!captured) {
|
|
361
|
+
captured = true;
|
|
362
|
+
_pushChunk(chunk, encoding);
|
|
363
|
+
var status = res.statusCode || 200; // allow:raw-byte-literal — default HTTP status 200
|
|
364
|
+
// Only persist 2xx-4xx responses; 5xx is transient infra
|
|
365
|
+
// failure that should be retried fresh, not replayed.
|
|
366
|
+
if (!oversized && status >= 200 && status < 500) { // allow:raw-byte-literal — HTTP status class boundaries
|
|
367
|
+
var headerMap = {};
|
|
368
|
+
try {
|
|
369
|
+
var allHeaders = typeof res.getHeaders === "function" ? res.getHeaders() : {};
|
|
370
|
+
var hk = Object.keys(allHeaders);
|
|
371
|
+
for (var j = 0; j < hk.length; j += 1) {
|
|
372
|
+
if (hk[j] === "set-cookie") continue; // Set-Cookie is per-request and unsafe to replay
|
|
373
|
+
headerMap[hk[j]] = allHeaders[hk[j]];
|
|
374
|
+
}
|
|
375
|
+
} catch (_e) { /* ignore */ }
|
|
376
|
+
var combined = collector.result();
|
|
377
|
+
try {
|
|
378
|
+
opts.store.set(key, {
|
|
379
|
+
fingerprint: fingerprint,
|
|
380
|
+
statusCode: status,
|
|
381
|
+
headers: headerMap,
|
|
382
|
+
body: combined.toString("base64"),
|
|
383
|
+
}, ttlMs);
|
|
384
|
+
_emitAudit("idempotency.cache_store",
|
|
385
|
+
{ method: method, path: req.url, statusCode: status, keyHash: _hashKey(key), bodyBytes: combined.length });
|
|
386
|
+
} catch (storeErr) {
|
|
387
|
+
_emitAudit("idempotency.store_write_failed",
|
|
388
|
+
{ key: _redactKey(key), error: String(storeErr.message || storeErr) }, "warning");
|
|
389
|
+
}
|
|
390
|
+
} else if (!oversized) {
|
|
391
|
+
_emitAudit("idempotency.skip_5xx",
|
|
392
|
+
{ method: method, path: req.url, statusCode: status, keyHash: _hashKey(key) });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return origEnd(chunk, encoding);
|
|
396
|
+
};
|
|
397
|
+
next();
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function _toBuffer(chunk, encoding) {
|
|
402
|
+
if (Buffer.isBuffer(chunk)) return chunk;
|
|
403
|
+
if (typeof chunk === "string") return Buffer.from(chunk, encoding || "utf8");
|
|
404
|
+
return Buffer.from(String(chunk));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function _hashKey(key) {
|
|
408
|
+
// Hash before logging — operator's audit chain shouldn't carry raw
|
|
409
|
+
// idempotency keys (clients sometimes inadvertently put PII / order
|
|
410
|
+
// numbers in them).
|
|
411
|
+
return nodeCrypto.createHash("sha3-256").update(key, "utf8").digest("hex").slice(0, 16); // allow:raw-byte-literal — log-truncation length, not bytes
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function _redactKey(key) {
|
|
415
|
+
if (typeof key !== "string") return "<non-string>";
|
|
416
|
+
if (key.length <= 8) return "<short:" + key.length + ">"; // allow:raw-byte-literal — log-redaction length threshold
|
|
417
|
+
return key.slice(0, 4) + "..." + key.slice(-2) + " (len=" + key.length + ")"; // allow:raw-byte-literal — log-redaction prefix/suffix lengths
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
module.exports = create;
|
|
421
|
+
module.exports.create = create;
|
|
422
|
+
module.exports.memoryStore = memoryStore;
|
|
423
|
+
module.exports.DEFAULT_METHODS = DEFAULT_METHODS;
|
|
424
|
+
module.exports.IdempotencyError = IdempotencyError;
|
package/lib/middleware/index.js
CHANGED
|
@@ -66,6 +66,8 @@ var tusUpload = require("./tus-upload");
|
|
|
66
66
|
var webAppManifest = require("./web-app-manifest");
|
|
67
67
|
var protectedResourceMetadata = require("./protected-resource-metadata");
|
|
68
68
|
var scimServer = require("./scim-server");
|
|
69
|
+
var idempotencyKey = require("./idempotency-key");
|
|
70
|
+
var noCache = require("./no-cache");
|
|
69
71
|
|
|
70
72
|
module.exports = {
|
|
71
73
|
requestId: requestId.create,
|
|
@@ -118,6 +120,12 @@ module.exports = {
|
|
|
118
120
|
speculationRules: speculationRules.create,
|
|
119
121
|
protectedResourceMetadata: protectedResourceMetadata.create,
|
|
120
122
|
scimServer: scimServer.create,
|
|
123
|
+
idempotencyKey: Object.assign(idempotencyKey.create, {
|
|
124
|
+
memoryStore: idempotencyKey.memoryStore,
|
|
125
|
+
DEFAULT_METHODS: idempotencyKey.DEFAULT_METHODS,
|
|
126
|
+
IdempotencyError: idempotencyKey.IdempotencyError,
|
|
127
|
+
}),
|
|
128
|
+
noCache: noCache.create,
|
|
121
129
|
|
|
122
130
|
// Module exports for advanced use (constants, raw factory access)
|
|
123
131
|
_modules: {
|
|
@@ -165,6 +173,8 @@ module.exports = {
|
|
|
165
173
|
clearSiteData: clearSiteData,
|
|
166
174
|
nel: nel,
|
|
167
175
|
speculationRules: speculationRules,
|
|
176
|
+
idempotencyKey: idempotencyKey,
|
|
177
|
+
noCache: noCache,
|
|
168
178
|
},
|
|
169
179
|
};
|
|
170
180
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.middleware.noCache
|
|
4
|
+
* @nav Middleware
|
|
5
|
+
* @title Cache-Control: no-store
|
|
6
|
+
* @order 410
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 9111 §5.2.2.5 `Cache-Control: no-store` middleware for paths
|
|
10
|
+
* that serve operator-individualized content (account pages,
|
|
11
|
+
* transactional pages, API responses with PII, auth-gated routes).
|
|
12
|
+
* Sets `Cache-Control: no-store` + `Pragma: no-cache` (HTTP/1.0
|
|
13
|
+
* compatibility) + `Vary: Cookie, Authorization` so intermediate
|
|
14
|
+
* caches don't store a personalized response keyed by URL alone.
|
|
15
|
+
*
|
|
16
|
+
* Per the 2026-05-11 audit's web-browser hardening gap: many
|
|
17
|
+
* primitives (`b.middleware.requireAuth` etc.) already set
|
|
18
|
+
* no-store on the 401 refuse path, but operator routes serving
|
|
19
|
+
* AUTHENTICATED content lacked a centralized no-store middleware.
|
|
20
|
+
* This is it.
|
|
21
|
+
*
|
|
22
|
+
* Compose with `b.middleware.requireAuth` for the standard
|
|
23
|
+
* auth-gated shape:
|
|
24
|
+
*
|
|
25
|
+
* app.use("/account", b.middleware.requireAuth());
|
|
26
|
+
* app.use("/account", b.middleware.noCache());
|
|
27
|
+
*
|
|
28
|
+
* Or use the predicate form to apply only when the route matches
|
|
29
|
+
* an operator-supplied test:
|
|
30
|
+
*
|
|
31
|
+
* app.use(b.middleware.noCache({
|
|
32
|
+
* when: function (req) {
|
|
33
|
+
* return req.url.indexOf("/api/private/") === 0;
|
|
34
|
+
* },
|
|
35
|
+
* }));
|
|
36
|
+
*
|
|
37
|
+
* @card
|
|
38
|
+
* RFC 9111 §5.2.2.5 Cache-Control: no-store middleware for auth-gated / individualized response paths — sets no-store + Pragma + Vary headers so intermediate caches don't store personalized responses keyed by URL alone.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var validateOpts = require("../validate-opts");
|
|
42
|
+
var { defineClass } = require("../framework-error");
|
|
43
|
+
|
|
44
|
+
var NoCacheError = defineClass("NoCacheError", { alwaysPermanent: true });
|
|
45
|
+
|
|
46
|
+
var DEFAULT_VARY = "Cookie, Authorization";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @primitive b.middleware.noCache
|
|
50
|
+
* @signature b.middleware.noCache(opts?)
|
|
51
|
+
* @since 0.8.86
|
|
52
|
+
* @status stable
|
|
53
|
+
*
|
|
54
|
+
* Build the no-cache middleware. With no opts, applies to every
|
|
55
|
+
* request: sets `Cache-Control: no-store`, `Pragma: no-cache`,
|
|
56
|
+
* `Vary: Cookie, Authorization`. Pass `opts.when(req)` for a
|
|
57
|
+
* conditional path predicate.
|
|
58
|
+
*
|
|
59
|
+
* @opts
|
|
60
|
+
* when: function (req) → boolean, // optional — only set headers when truthy
|
|
61
|
+
* cacheControl: string, // override "no-store" (e.g. "no-store, private")
|
|
62
|
+
* vary: string, // override the Vary header (default "Cookie, Authorization")
|
|
63
|
+
* skipExisting: boolean, // default false — when true, skip when Cache-Control is already set
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* app.use("/account", b.middleware.requireAuth(), b.middleware.noCache());
|
|
67
|
+
*
|
|
68
|
+
* // Conditional — only for the API subtree
|
|
69
|
+
* app.use(b.middleware.noCache({
|
|
70
|
+
* when: function (req) { return req.url.indexOf("/api/private/") === 0; },
|
|
71
|
+
* }));
|
|
72
|
+
*/
|
|
73
|
+
function create(opts) {
|
|
74
|
+
opts = opts || {};
|
|
75
|
+
if (typeof opts !== "object" || Array.isArray(opts)) {
|
|
76
|
+
throw new NoCacheError("no-cache/bad-opts",
|
|
77
|
+
"middleware.noCache: opts must be an object when provided", true);
|
|
78
|
+
}
|
|
79
|
+
validateOpts.optionalFunction(
|
|
80
|
+
opts.when, "noCache.when", NoCacheError, "no-cache/bad-when");
|
|
81
|
+
validateOpts.optionalNonEmptyString(
|
|
82
|
+
opts.cacheControl, "noCache.cacheControl", NoCacheError, "no-cache/bad-cache-control");
|
|
83
|
+
validateOpts.optionalNonEmptyString(
|
|
84
|
+
opts.vary, "noCache.vary", NoCacheError, "no-cache/bad-vary");
|
|
85
|
+
|
|
86
|
+
var cacheControl = opts.cacheControl || "no-store";
|
|
87
|
+
var vary = opts.vary || DEFAULT_VARY;
|
|
88
|
+
var skipExisting = opts.skipExisting === true;
|
|
89
|
+
var when = opts.when;
|
|
90
|
+
|
|
91
|
+
return function noCacheMiddleware(req, res, next) {
|
|
92
|
+
if (when && !when(req)) return next();
|
|
93
|
+
if (skipExisting && typeof res.getHeader === "function" && res.getHeader("Cache-Control")) {
|
|
94
|
+
return next();
|
|
95
|
+
}
|
|
96
|
+
res.setHeader("Cache-Control", cacheControl);
|
|
97
|
+
res.setHeader("Pragma", "no-cache");
|
|
98
|
+
res.setHeader("Vary", vary);
|
|
99
|
+
next();
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = create;
|
|
104
|
+
module.exports.create = create;
|
|
105
|
+
module.exports.NoCacheError = NoCacheError;
|
|
106
|
+
module.exports.DEFAULT_VARY = DEFAULT_VARY;
|