@blamejs/core 0.8.89 → 0.9.0
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 +847 -845
- package/index.js +6 -0
- package/lib/ai-pref.js +8 -2
- package/lib/auth/step-up.js +14 -5
- package/lib/cdn-cache-control.js +473 -0
- package/lib/client-hints.js +318 -0
- package/lib/http-client-cache.js +15 -8
- package/lib/http-client-cookie-jar.js +18 -7
- package/lib/http-message-signature.js +18 -11
- package/lib/log-stream.js +25 -1
- package/lib/mail-auth.js +3 -2
- package/lib/mail-require-tls.js +198 -0
- package/lib/mail.js +3 -1
- package/lib/middleware/body-parser.js +24 -12
- package/lib/middleware/scim-server.js +2 -2
- package/lib/middleware/tus-upload.js +12 -7
- package/lib/network-dns.js +178 -0
- package/lib/network-smtp-policy.js +2 -2
- package/lib/request-helpers.js +15 -0
- package/lib/security-assert.js +23 -1
- package/lib/structured-fields.js +244 -0
- package/lib/websocket.js +15 -9
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/index.js
CHANGED
|
@@ -145,6 +145,9 @@ var compliance = Object.assign({}, require("./lib/compliance"), {
|
|
|
145
145
|
var dataAct = require("./lib/data-act");
|
|
146
146
|
var problemDetails = require("./lib/problem-details");
|
|
147
147
|
var cacheStatus = require("./lib/cache-status");
|
|
148
|
+
var cdnCacheControl = require("./lib/cdn-cache-control");
|
|
149
|
+
var clientHints = require("./lib/client-hints");
|
|
150
|
+
var structuredFields = require("./lib/structured-fields");
|
|
148
151
|
var serverTiming = require("./lib/server-timing");
|
|
149
152
|
var earlyHints = require("./lib/early-hints");
|
|
150
153
|
var gateContract = require("./lib/gate-contract");
|
|
@@ -377,6 +380,9 @@ module.exports = {
|
|
|
377
380
|
dataAct: dataAct,
|
|
378
381
|
problemDetails: problemDetails,
|
|
379
382
|
cacheStatus: cacheStatus,
|
|
383
|
+
cdnCacheControl: cdnCacheControl,
|
|
384
|
+
clientHints: clientHints,
|
|
385
|
+
structuredFields: structuredFields,
|
|
380
386
|
serverTiming: serverTiming,
|
|
381
387
|
earlyHints: earlyHints,
|
|
382
388
|
gateContract: gateContract,
|
package/lib/ai-pref.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
* AIPREF (RFC draft) signal — operators publish a machine-readable preference about AI training / agent crawling / etc.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
var audit
|
|
30
|
-
var requestHelpers
|
|
29
|
+
var audit = require("./audit");
|
|
30
|
+
var requestHelpers = require("./request-helpers");
|
|
31
|
+
var structuredFields = require("./structured-fields");
|
|
31
32
|
var { defineClass } = require("./framework-error");
|
|
32
33
|
var AiPrefError = defineClass("AiPrefError", { alwaysPermanent: true });
|
|
33
34
|
|
|
@@ -145,6 +146,11 @@ function parseHeader(value) {
|
|
|
145
146
|
throw AiPrefError.factory("HEADER_TOO_LARGE",
|
|
146
147
|
"aiPref.parseHeader: value exceeds 1024 chars");
|
|
147
148
|
}
|
|
149
|
+
structuredFields.refuseControlBytes(value, {
|
|
150
|
+
ErrorClass: AiPrefError,
|
|
151
|
+
code: "BAD_HEADER",
|
|
152
|
+
label: "aiPref.parseHeader",
|
|
153
|
+
});
|
|
148
154
|
var out = { train: null, infer: null, snippet: null, price: null };
|
|
149
155
|
var pairs = value.split(",");
|
|
150
156
|
for (var i = 0; i < pairs.length; i += 1) {
|
package/lib/auth/step-up.js
CHANGED
|
@@ -60,11 +60,12 @@
|
|
|
60
60
|
* - auth.stepUp.grant.revoked (elevation grant revoked)
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
|
-
var lazyRequire
|
|
64
|
-
var validateOpts
|
|
65
|
-
var safeJson
|
|
66
|
-
var
|
|
67
|
-
var
|
|
63
|
+
var lazyRequire = require("../lazy-require");
|
|
64
|
+
var validateOpts = require("../validate-opts");
|
|
65
|
+
var safeJson = require("../safe-json");
|
|
66
|
+
var structuredFields = require("../structured-fields");
|
|
67
|
+
var C = require("../constants");
|
|
68
|
+
var { AuthError } = require("../framework-error");
|
|
68
69
|
|
|
69
70
|
var acr = require("./acr-vocabulary");
|
|
70
71
|
var authTime = require("./auth-time-tracker");
|
|
@@ -362,6 +363,14 @@ function _summarizePresented(presented) {
|
|
|
362
363
|
|
|
363
364
|
function parseChallenge(headerValue) {
|
|
364
365
|
if (typeof headerValue !== "string") return null;
|
|
366
|
+
// Refuse C0 / DEL on the RAW value BEFORE the slice + trim
|
|
367
|
+
// normalisation below. WWW-Authenticate is a token-or-quoted-string
|
|
368
|
+
// grammar per RFC 9110 §11.3; a leading `\nBearer ...` would slip
|
|
369
|
+
// past the slice() with a clean `Bearer` token if we trimmed first
|
|
370
|
+
// (same shape as the v0.8.90 mail-require-tls bug class).
|
|
371
|
+
// parseChallenge is a defensive request-shape reader, so the
|
|
372
|
+
// predicate variant returns null rather than throwing.
|
|
373
|
+
if (structuredFields.containsControlBytes(headerValue)) return null;
|
|
365
374
|
// Tolerate "Bearer " prefix in any case; reject anything else.
|
|
366
375
|
var idx = headerValue.toLowerCase().indexOf("bearer");
|
|
367
376
|
if (idx === -1) return null;
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.cdnCacheControl
|
|
4
|
+
* @nav HTTP
|
|
5
|
+
* @title RFC 9213 Targeted Cache-Control
|
|
6
|
+
* @order 315
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 9213 Targeted HTTP Cache-Control directives. Operators address
|
|
10
|
+
* specific layers in the caching chain by setting parallel headers
|
|
11
|
+
* that share the `Cache-Control` directive grammar but apply only
|
|
12
|
+
* to caches matching the target. The well-known shapes:
|
|
13
|
+
*
|
|
14
|
+
* Cache-Control: max-age=60 (user-agent cache)
|
|
15
|
+
* CDN-Cache-Control: max-age=3600 (every CDN class cache)
|
|
16
|
+
* Cloudflare-CDN-Cache-Control: max-age=86400 (CDN-specific override)
|
|
17
|
+
* Vercel-CDN-Cache-Control: max-age=86400 (CDN-specific override)
|
|
18
|
+
* Surrogate-Control: max-age=3600 (W3C Edge Architecture)
|
|
19
|
+
*
|
|
20
|
+
* The same response can carry multiple targeted variants — a CDN
|
|
21
|
+
* that recognizes its operator-specific header uses that one; CDNs
|
|
22
|
+
* without a match fall back to `CDN-Cache-Control`; user agents
|
|
23
|
+
* apply only the plain `Cache-Control`. The framework treats every
|
|
24
|
+
* variant as the same RFC 9111 §5.2.2 directive grammar.
|
|
25
|
+
*
|
|
26
|
+
* `build({...})` emits a directive string for any of the targeted
|
|
27
|
+
* headers; the operator chooses which header name to set. `parse()`
|
|
28
|
+
* round-trips: decode an inbound directive list into a normalized
|
|
29
|
+
* object with numeric maxAge / staleWhileRevalidate, boolean flags
|
|
30
|
+
* (public / private / noStore / noCache / mustRevalidate / immutable),
|
|
31
|
+
* and the raw `directives` map for unknown / extension keys.
|
|
32
|
+
*
|
|
33
|
+
* `TARGETED_HEADERS` lists the well-known header names the operator
|
|
34
|
+
* may set; an explicit allowlist instead of guessing prevents
|
|
35
|
+
* operators from emitting a malformed `CDN-Cache-Control-X-Custom`
|
|
36
|
+
* header that no cache will look at.
|
|
37
|
+
*
|
|
38
|
+
* @card
|
|
39
|
+
* RFC 9213 Targeted Cache-Control — build / parse `CDN-Cache-Control`,
|
|
40
|
+
* `Surrogate-Control`, and operator-specific (`Cloudflare-` / `Vercel-`
|
|
41
|
+
* / `Fastly-`) variants with the same directive grammar as plain
|
|
42
|
+
* `Cache-Control`.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
var numericBounds = require("./numeric-bounds");
|
|
46
|
+
var structuredFields = require("./structured-fields");
|
|
47
|
+
var validateOpts = require("./validate-opts");
|
|
48
|
+
var { defineClass } = require("./framework-error");
|
|
49
|
+
|
|
50
|
+
var CdnCacheControlError = defineClass("CdnCacheControlError",
|
|
51
|
+
{ alwaysPermanent: true });
|
|
52
|
+
|
|
53
|
+
// RFC 9213 §3 — well-known targeted header names. The list is curated
|
|
54
|
+
// (not regex-matched) because operators routinely typo `CDN-Cache-
|
|
55
|
+
// Control` into `Cdn-CacheControl` etc. and a typo silently emits a
|
|
56
|
+
// header no cache will read. Operators with a CDN not on this list
|
|
57
|
+
// pass the header name verbatim to `build()` via `headerName:` for
|
|
58
|
+
// audit visibility — the value still goes through directive
|
|
59
|
+
// validation.
|
|
60
|
+
var TARGETED_HEADERS = Object.freeze([
|
|
61
|
+
"Cache-Control",
|
|
62
|
+
"CDN-Cache-Control",
|
|
63
|
+
"Surrogate-Control",
|
|
64
|
+
"Cloudflare-CDN-Cache-Control",
|
|
65
|
+
"Vercel-CDN-Cache-Control",
|
|
66
|
+
"Fastly-CDN-Cache-Control",
|
|
67
|
+
"Akamai-CDN-Cache-Control",
|
|
68
|
+
"Netlify-CDN-Cache-Control",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
var TARGETED_LC = {};
|
|
72
|
+
for (var _i = 0; _i < TARGETED_HEADERS.length; _i += 1) {
|
|
73
|
+
TARGETED_LC[TARGETED_HEADERS[_i].toLowerCase()] = TARGETED_HEADERS[_i];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// RFC 9111 §5.2 — directive grammar. Numeric directives carry a
|
|
77
|
+
// delta-seconds value; boolean directives appear as bare tokens.
|
|
78
|
+
var BOOLEAN_DIRECTIVES = Object.freeze([
|
|
79
|
+
"public", "private", "no-store", "no-cache",
|
|
80
|
+
"must-revalidate", "proxy-revalidate", "immutable",
|
|
81
|
+
"no-transform", "must-understand",
|
|
82
|
+
]);
|
|
83
|
+
var NUMERIC_DIRECTIVES = Object.freeze([
|
|
84
|
+
"max-age", "s-maxage",
|
|
85
|
+
"stale-while-revalidate", "stale-if-error",
|
|
86
|
+
"min-fresh", "max-stale",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// camelCase → kebab-case so `build({ maxAge: 60 })` emits `max-age=60`.
|
|
90
|
+
var KEBAB = {
|
|
91
|
+
maxAge: "max-age",
|
|
92
|
+
sMaxAge: "s-maxage",
|
|
93
|
+
staleWhileRevalidate: "stale-while-revalidate",
|
|
94
|
+
staleIfError: "stale-if-error",
|
|
95
|
+
minFresh: "min-fresh",
|
|
96
|
+
maxStale: "max-stale",
|
|
97
|
+
noStore: "no-store",
|
|
98
|
+
noCache: "no-cache",
|
|
99
|
+
mustRevalidate: "must-revalidate",
|
|
100
|
+
proxyRevalidate: "proxy-revalidate",
|
|
101
|
+
mustUnderstand: "must-understand",
|
|
102
|
+
noTransform: "no-transform",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// RFC 7234 §5.2 token: `token = 1*tchar` where tchar excludes delimiter
|
|
106
|
+
// chars. Directive keys are tchar-only and conventionally ASCII-lower.
|
|
107
|
+
var DIRECTIVE_KEY_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; // allow:duplicate-regex — RFC 7234 §5.2 tchar (=RFC 8941 token shape)
|
|
108
|
+
|
|
109
|
+
// Internal validation tier: throw at config-time. build() is the
|
|
110
|
+
// operator-facing entry, parse() is the request-shape reader (returns
|
|
111
|
+
// defensive defaults on garbage rather than throwing).
|
|
112
|
+
|
|
113
|
+
function _isNonNegInt(v) {
|
|
114
|
+
return typeof v === "number" && isFinite(v) && v >= 0 && Math.floor(v) === v;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @primitive b.cdnCacheControl.build
|
|
119
|
+
* @signature b.cdnCacheControl.build(opts)
|
|
120
|
+
* @since 0.8.91
|
|
121
|
+
* @status stable
|
|
122
|
+
* @related b.cdnCacheControl.parse, b.cdnCacheControl.isTargetedHeader
|
|
123
|
+
*
|
|
124
|
+
* Build a Cache-Control-style directive list string for any RFC 9213
|
|
125
|
+
* targeted header. `opts` accepts the standard RFC 9111 §5.2.2
|
|
126
|
+
* directives in camelCase (`maxAge`, `sMaxAge`, `staleWhileRevalidate`,
|
|
127
|
+
* `staleIfError`, `mustRevalidate`, etc.) — kebab-case keys
|
|
128
|
+
* (`max-age`, `s-maxage`, ...) also pass through unchanged for
|
|
129
|
+
* operators porting from existing header-building code.
|
|
130
|
+
*
|
|
131
|
+
* Numeric directives accept non-negative finite integers; the
|
|
132
|
+
* primitive refuses negative / non-integer / `Infinity` / `NaN`
|
|
133
|
+
* inputs (the directive grammar requires delta-seconds). Boolean
|
|
134
|
+
* directives only emit when explicitly `true`; `false` / `undefined`
|
|
135
|
+
* omit the token.
|
|
136
|
+
*
|
|
137
|
+
* Returns the directive list string ready to be assigned to any
|
|
138
|
+
* header in `TARGETED_HEADERS`. Caller is responsible for choosing
|
|
139
|
+
* which header name to set.
|
|
140
|
+
*
|
|
141
|
+
* @opts
|
|
142
|
+
* maxAge: number, // max-age=N (user-agent + shared)
|
|
143
|
+
* sMaxAge: number, // s-maxage=N (shared caches only)
|
|
144
|
+
* staleWhileRevalidate: number, // RFC 5861 §3
|
|
145
|
+
* staleIfError: number, // RFC 5861 §4
|
|
146
|
+
* minFresh: number, // request directive
|
|
147
|
+
* maxStale: number, // request directive
|
|
148
|
+
* public: boolean,
|
|
149
|
+
* private: boolean,
|
|
150
|
+
* noStore: boolean,
|
|
151
|
+
* noCache: boolean,
|
|
152
|
+
* mustRevalidate: boolean,
|
|
153
|
+
* proxyRevalidate: boolean,
|
|
154
|
+
* mustUnderstand: boolean, // RFC 8246
|
|
155
|
+
* noTransform: boolean,
|
|
156
|
+
* immutable: boolean, // RFC 8246
|
|
157
|
+
* extensions: object, // raw key→value/true map for non-standard directives
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* res.setHeader("CDN-Cache-Control", b.cdnCacheControl.build({
|
|
161
|
+
* public: true,
|
|
162
|
+
* sMaxAge: 3600,
|
|
163
|
+
* staleWhileRevalidate: 60,
|
|
164
|
+
* staleIfError: 86400,
|
|
165
|
+
* }));
|
|
166
|
+
* // → "public, s-maxage=3600, stale-while-revalidate=60, stale-if-error=86400"
|
|
167
|
+
*
|
|
168
|
+
* res.setHeader("Cache-Control", b.cdnCacheControl.build({
|
|
169
|
+
* private: true, maxAge: 0, noStore: true,
|
|
170
|
+
* }));
|
|
171
|
+
* // → "private, max-age=0, no-store"
|
|
172
|
+
*/
|
|
173
|
+
function build(opts) {
|
|
174
|
+
if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
|
|
175
|
+
throw new CdnCacheControlError("cdn-cache-control/bad-opts",
|
|
176
|
+
"build: opts must be a non-null object", true);
|
|
177
|
+
}
|
|
178
|
+
// Conflict check: public + private is incoherent per RFC 9111 §5.2.2;
|
|
179
|
+
// operators almost always meant one or the other.
|
|
180
|
+
if (opts.public === true && opts.private === true) {
|
|
181
|
+
throw new CdnCacheControlError("cdn-cache-control/conflicting-visibility",
|
|
182
|
+
"build: cannot set both 'public' and 'private' (RFC 9111 §5.2.2.5/§5.2.2.6)");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var seen = {};
|
|
186
|
+
var parts = [];
|
|
187
|
+
|
|
188
|
+
// Visibility booleans first (operator-facing readability)
|
|
189
|
+
if (opts.public === true) { parts.push("public"); seen.public = true; }
|
|
190
|
+
if (opts.private === true) { parts.push("private"); seen.private = true; }
|
|
191
|
+
|
|
192
|
+
// Numeric directives, both camelCase and raw kebab-case keys
|
|
193
|
+
var numericKeys = [
|
|
194
|
+
"maxAge", "max-age",
|
|
195
|
+
"sMaxAge", "s-maxage",
|
|
196
|
+
"staleWhileRevalidate", "stale-while-revalidate",
|
|
197
|
+
"staleIfError", "stale-if-error",
|
|
198
|
+
"minFresh", "min-fresh",
|
|
199
|
+
"maxStale", "max-stale",
|
|
200
|
+
];
|
|
201
|
+
for (var i = 0; i < numericKeys.length; i += 1) {
|
|
202
|
+
var k = numericKeys[i];
|
|
203
|
+
if (opts[k] === undefined || opts[k] === null) continue;
|
|
204
|
+
if (!_isNonNegInt(opts[k])) {
|
|
205
|
+
throw new CdnCacheControlError("cdn-cache-control/bad-numeric",
|
|
206
|
+
"build: " + k + " must be a non-negative finite integer (got " +
|
|
207
|
+
typeof opts[k] + " " + String(opts[k]) + ")");
|
|
208
|
+
}
|
|
209
|
+
var kebab = KEBAB[k] || k;
|
|
210
|
+
if (!seen[kebab]) {
|
|
211
|
+
parts.push(kebab + "=" + opts[k]);
|
|
212
|
+
seen[kebab] = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Remaining boolean directives
|
|
217
|
+
var boolKeys = [
|
|
218
|
+
"noStore", "no-store",
|
|
219
|
+
"noCache", "no-cache",
|
|
220
|
+
"mustRevalidate", "must-revalidate",
|
|
221
|
+
"proxyRevalidate", "proxy-revalidate",
|
|
222
|
+
"mustUnderstand", "must-understand",
|
|
223
|
+
"noTransform", "no-transform",
|
|
224
|
+
"immutable",
|
|
225
|
+
];
|
|
226
|
+
for (var j = 0; j < boolKeys.length; j += 1) {
|
|
227
|
+
var bk = boolKeys[j];
|
|
228
|
+
if (opts[bk] === true) {
|
|
229
|
+
var bkebab = KEBAB[bk] || bk;
|
|
230
|
+
if (!seen[bkebab]) {
|
|
231
|
+
parts.push(bkebab);
|
|
232
|
+
seen[bkebab] = true;
|
|
233
|
+
}
|
|
234
|
+
} else if (opts[bk] !== undefined && opts[bk] !== false && opts[bk] !== null) {
|
|
235
|
+
throw new CdnCacheControlError("cdn-cache-control/bad-boolean",
|
|
236
|
+
"build: " + bk + " must be a boolean (got " + typeof opts[bk] + ")");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Operator-supplied extension directives (must be RFC 7234 tchar
|
|
241
|
+
// shape; values are either `true` for bare token or string/number
|
|
242
|
+
// for token=value). Refuses delimiter / control / whitespace in
|
|
243
|
+
// keys so the assembled list can't carry an injection.
|
|
244
|
+
if (opts.extensions !== undefined && opts.extensions !== null) {
|
|
245
|
+
validateOpts.optionalPlainObject(opts.extensions, "opts.extensions",
|
|
246
|
+
CdnCacheControlError, "cdn-cache-control/bad-extensions",
|
|
247
|
+
"non-null object of <directive-key>: true | token-string | non-negative integer");
|
|
248
|
+
var ekeys = Object.keys(opts.extensions);
|
|
249
|
+
// Bound directive-key + value length BEFORE regex test so a
|
|
250
|
+
// multi-MB attacker-supplied string can't burn CPU on the tchar
|
|
251
|
+
// regex. RFC 7234 §5.2 token directives are tiny in practice
|
|
252
|
+
// (max-age = 7 chars, stale-while-revalidate = 22); 64 is the
|
|
253
|
+
// operator-headroom ceiling.
|
|
254
|
+
var DIRECTIVE_MAX = 64; // allow:raw-byte-literal — directive key/value length cap
|
|
255
|
+
for (var e = 0; e < ekeys.length; e += 1) {
|
|
256
|
+
var ek = ekeys[e];
|
|
257
|
+
if (ek.length === 0 || ek.length > DIRECTIVE_MAX || !DIRECTIVE_KEY_RE.test(ek)) {
|
|
258
|
+
throw new CdnCacheControlError("cdn-cache-control/bad-extension-key",
|
|
259
|
+
"build: extensions['" + ek + "'] — key must match RFC 7234 §5.2 token grammar " +
|
|
260
|
+
"(<= " + DIRECTIVE_MAX + " chars)");
|
|
261
|
+
}
|
|
262
|
+
if (seen[ek]) continue;
|
|
263
|
+
var ev = opts.extensions[ek];
|
|
264
|
+
if (ev === true) {
|
|
265
|
+
parts.push(ek);
|
|
266
|
+
seen[ek] = true;
|
|
267
|
+
} else if (typeof ev === "string") {
|
|
268
|
+
// Value must be either RFC 7234 token OR sf-string quoted.
|
|
269
|
+
// We only emit unquoted-token form; operators with delimiter
|
|
270
|
+
// chars must pre-quote and supply via raw header set.
|
|
271
|
+
if (ev.length === 0 || ev.length > DIRECTIVE_MAX || !DIRECTIVE_KEY_RE.test(ev)) {
|
|
272
|
+
throw new CdnCacheControlError("cdn-cache-control/bad-extension-value",
|
|
273
|
+
"build: extensions['" + ek + "'] string value must match RFC 7234 §5.2 token grammar " +
|
|
274
|
+
"(<= " + DIRECTIVE_MAX + " chars); " +
|
|
275
|
+
"for quoted-string values set the header directly");
|
|
276
|
+
}
|
|
277
|
+
parts.push(ek + "=" + ev);
|
|
278
|
+
seen[ek] = true;
|
|
279
|
+
} else if (_isNonNegInt(ev)) {
|
|
280
|
+
parts.push(ek + "=" + ev);
|
|
281
|
+
seen[ek] = true;
|
|
282
|
+
} else {
|
|
283
|
+
throw new CdnCacheControlError("cdn-cache-control/bad-extension-value",
|
|
284
|
+
"build: extensions['" + ek + "'] must be true | token-string | non-negative integer");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (parts.length === 0) {
|
|
290
|
+
throw new CdnCacheControlError("cdn-cache-control/empty",
|
|
291
|
+
"build: no directives supplied — refuse to emit an empty Cache-Control list");
|
|
292
|
+
}
|
|
293
|
+
return parts.join(", ");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @primitive b.cdnCacheControl.parse
|
|
298
|
+
* @signature b.cdnCacheControl.parse(headerValue)
|
|
299
|
+
* @since 0.8.91
|
|
300
|
+
* @status stable
|
|
301
|
+
* @related b.cdnCacheControl.build
|
|
302
|
+
*
|
|
303
|
+
* Parse a Cache-Control-style directive list (from any RFC 9213
|
|
304
|
+
* targeted header) into a normalized object. Returns `null` for
|
|
305
|
+
* absent / empty / non-string input — operator code branches on
|
|
306
|
+
* `null` vs the populated shape.
|
|
307
|
+
*
|
|
308
|
+
* Numeric directives are surfaced as camelCase number fields
|
|
309
|
+
* (`maxAge`, `sMaxAge`, `staleWhileRevalidate`, `staleIfError`,
|
|
310
|
+
* `minFresh`, `maxStale`); boolean directives as camelCase boolean
|
|
311
|
+
* fields. Unknown directives land in `directives` (a `name → value`
|
|
312
|
+
* map where boolean directives map to `true` and value-bearing
|
|
313
|
+
* directives map to the raw string).
|
|
314
|
+
*
|
|
315
|
+
* Defensive parser: tolerates trailing semicolons, repeated whitespace,
|
|
316
|
+
* and unquoted-quoted-string values; refuses control characters in
|
|
317
|
+
* the header value (CR/LF/NUL/DEL header-injection shape) by throwing
|
|
318
|
+
* `cdn-cache-control/bad-header-value`. ASCII HT remains permitted
|
|
319
|
+
* (structural folding whitespace).
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* b.cdnCacheControl.parse("public, s-maxage=3600, stale-while-revalidate=60");
|
|
323
|
+
* // → { public: true, sMaxAge: 3600, staleWhileRevalidate: 60, directives: {} }
|
|
324
|
+
*
|
|
325
|
+
* b.cdnCacheControl.parse("private, no-store, x-foo=bar");
|
|
326
|
+
* // → { private: true, noStore: true, directives: { "x-foo": "bar" } }
|
|
327
|
+
*/
|
|
328
|
+
function parse(headerValue) {
|
|
329
|
+
if (typeof headerValue !== "string") return null;
|
|
330
|
+
structuredFields.refuseControlBytes(headerValue, {
|
|
331
|
+
ErrorClass: CdnCacheControlError,
|
|
332
|
+
code: "cdn-cache-control/bad-header-value",
|
|
333
|
+
label: "parse",
|
|
334
|
+
});
|
|
335
|
+
var trimmed = headerValue.trim();
|
|
336
|
+
if (trimmed.length === 0) return null;
|
|
337
|
+
|
|
338
|
+
var out = { directives: {} };
|
|
339
|
+
var pieces = structuredFields.splitTopLevel(trimmed, ",");
|
|
340
|
+
for (var p = 0; p < pieces.length; p += 1) {
|
|
341
|
+
var raw = pieces[p].trim();
|
|
342
|
+
if (raw.length === 0) continue;
|
|
343
|
+
var eq = raw.indexOf("=");
|
|
344
|
+
var key, val, bare;
|
|
345
|
+
if (eq === -1) {
|
|
346
|
+
key = raw.toLowerCase();
|
|
347
|
+
val = "";
|
|
348
|
+
bare = true;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
key = raw.slice(0, eq).trim().toLowerCase();
|
|
352
|
+
val = raw.slice(eq + 1).trim();
|
|
353
|
+
bare = false;
|
|
354
|
+
// Unquote sf-string per RFC 8941 §3.3.3 (defensive — operators
|
|
355
|
+
// routinely emit `s-maxage="3600"` even though the directive is
|
|
356
|
+
// numeric, and the spec says quoted-string is also valid). The
|
|
357
|
+
// RFC 9111 `private="Authorization, Cookie"` qualified form
|
|
358
|
+
// arrives here too — the top-level comma splitter already
|
|
359
|
+
// preserved the inner comma; this just strips the surrounding
|
|
360
|
+
// quotes so operators read the field-name list directly.
|
|
361
|
+
var _unq = structuredFields.unquoteSfString(val);
|
|
362
|
+
if (_unq !== null) val = _unq;
|
|
363
|
+
}
|
|
364
|
+
if (key.length === 0) continue;
|
|
365
|
+
|
|
366
|
+
// Numeric directive → coerce to non-negative integer or skip.
|
|
367
|
+
// RFC 9111 §5.2.1.2: bare `max-stale` (no argument) means "accept
|
|
368
|
+
// a stale response of any age" — surface as Infinity rather than
|
|
369
|
+
// coercing Number(true) === 1 (which would materially change
|
|
370
|
+
// request semantics and reject otherwise-acceptable cached
|
|
371
|
+
// responses). Other numeric directives in bare form aren't
|
|
372
|
+
// RFC-defined; treat their bare form as absent.
|
|
373
|
+
if (NUMERIC_DIRECTIVES.indexOf(key) !== -1) {
|
|
374
|
+
if (bare) {
|
|
375
|
+
if (key === "max-stale") {
|
|
376
|
+
out[_camel(key)] = Infinity;
|
|
377
|
+
}
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
var n = Number(val);
|
|
381
|
+
if (isFinite(n) && n >= 0) {
|
|
382
|
+
out[_camel(key)] = Math.floor(n);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// Boolean directive → presence == enabled. RFC 9111 §5.2.2.6
|
|
387
|
+
// (`private`) and §5.2.2.4 (`no-cache`) carry an OPTIONAL field-
|
|
388
|
+
// name list as a qualified-form argument: `private="Authorization"`
|
|
389
|
+
// means "only Authorization is the private bit". The directive is
|
|
390
|
+
// STILL enabled — the argument narrows the scope, not the verdict.
|
|
391
|
+
// A previous version coerced (val === "" || val === "true") which
|
|
392
|
+
// inverted the meaning of a qualified directive.
|
|
393
|
+
if (BOOLEAN_DIRECTIVES.indexOf(key) !== -1) {
|
|
394
|
+
out[_camel(key)] = true;
|
|
395
|
+
// Qualified form: surface the field-name list on `fields` so
|
|
396
|
+
// operators reading the parse output can apply the narrower
|
|
397
|
+
// scope without re-parsing.
|
|
398
|
+
if (!bare && val.length > 0) {
|
|
399
|
+
if (!out.fields) out.fields = {};
|
|
400
|
+
out.fields[_camel(key)] = _splitFieldNameList(val);
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// Unknown / extension directive → land in `directives` map.
|
|
405
|
+
// Bare form → true; valued form → the (possibly unquoted) string.
|
|
406
|
+
out.directives[key] = bare ? true : val;
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Field-name list (RFC 9111 §5.2.2.6 `private="A, B"`). Inner
|
|
412
|
+
// comma-separated header-field-name list; values are tokens per
|
|
413
|
+
// RFC 9110 §5.1. Lowercased + trimmed per piece.
|
|
414
|
+
function _splitFieldNameList(s) {
|
|
415
|
+
var parts = s.split(",");
|
|
416
|
+
var out = [];
|
|
417
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
418
|
+
var t = parts[i].trim();
|
|
419
|
+
if (t.length > 0) out.push(t.toLowerCase());
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Reverse of KEBAB — directive-name → camelCase the parser surfaces.
|
|
425
|
+
// Maintained explicitly because `s-maxage` has no internal hyphen
|
|
426
|
+
// (the spec spells it `s-maxage`, not `s-max-age`) so a generic
|
|
427
|
+
// kebab → camel transform produces `sMaxage`. We want `sMaxAge` so
|
|
428
|
+
// the parse output mirrors the build input.
|
|
429
|
+
var CAMEL_OVERRIDE = {
|
|
430
|
+
"s-maxage": "sMaxAge",
|
|
431
|
+
};
|
|
432
|
+
function _camel(kebab) {
|
|
433
|
+
if (CAMEL_OVERRIDE[kebab]) return CAMEL_OVERRIDE[kebab];
|
|
434
|
+
return kebab.replace(/-([a-z])/g, function (_, ch) { return ch.toUpperCase(); });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @primitive b.cdnCacheControl.isTargetedHeader
|
|
439
|
+
* @signature b.cdnCacheControl.isTargetedHeader(headerName)
|
|
440
|
+
* @since 0.8.91
|
|
441
|
+
* @status stable
|
|
442
|
+
* @related b.cdnCacheControl.parse, b.cdnCacheControl.build
|
|
443
|
+
*
|
|
444
|
+
* Returns `true` when `headerName` matches one of the well-known RFC
|
|
445
|
+
* 9213 targeted header names (case-insensitive). Operators auditing
|
|
446
|
+
* an inbound response's cache headers walk the response headers and
|
|
447
|
+
* call this to identify which directive lists were intended for which
|
|
448
|
+
* cache class.
|
|
449
|
+
*
|
|
450
|
+
* @example
|
|
451
|
+
* b.cdnCacheControl.isTargetedHeader("CDN-Cache-Control"); // → true
|
|
452
|
+
* b.cdnCacheControl.isTargetedHeader("cloudflare-cdn-cache-control"); // → true
|
|
453
|
+
* b.cdnCacheControl.isTargetedHeader("Cache"); // → false
|
|
454
|
+
*/
|
|
455
|
+
function isTargetedHeader(headerName) {
|
|
456
|
+
if (typeof headerName !== "string" || headerName.length === 0) return false;
|
|
457
|
+
return Object.prototype.hasOwnProperty.call(TARGETED_LC, headerName.toLowerCase());
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
module.exports = {
|
|
461
|
+
build: build,
|
|
462
|
+
parse: parse,
|
|
463
|
+
isTargetedHeader: isTargetedHeader,
|
|
464
|
+
TARGETED_HEADERS: TARGETED_HEADERS,
|
|
465
|
+
BOOLEAN_DIRECTIVES: BOOLEAN_DIRECTIVES,
|
|
466
|
+
NUMERIC_DIRECTIVES: NUMERIC_DIRECTIVES,
|
|
467
|
+
CdnCacheControlError: CdnCacheControlError,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Reserved for future field validation paths; kept in canonical
|
|
471
|
+
// require ordering.
|
|
472
|
+
void numericBounds;
|
|
473
|
+
void validateOpts;
|