@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.
@@ -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;
@@ -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;