@blamejs/blamejs-shop 0.3.56 → 0.3.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +11 -0
  4. package/lib/asset-manifest.json +3 -3
  5. package/lib/checkout.js +89 -10
  6. package/lib/security-middleware.js +17 -1
  7. package/lib/storefront.js +156 -51
  8. package/lib/vendor/MANIFEST.json +84 -72
  9. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  10. package/lib/vendor/blamejs/README.md +3 -3
  11. package/lib/vendor/blamejs/SECURITY.md +5 -0
  12. package/lib/vendor/blamejs/api-snapshot.json +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
  14. package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
  15. package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
  16. package/lib/vendor/blamejs/lib/archive-read.js +215 -16
  17. package/lib/vendor/blamejs/lib/archive.js +206 -52
  18. package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
  19. package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
  20. package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
  21. package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
  22. package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
  23. package/lib/vendor/blamejs/lib/db-schema.js +120 -3
  24. package/lib/vendor/blamejs/lib/db.js +10 -3
  25. package/lib/vendor/blamejs/lib/error-page.js +93 -9
  26. package/lib/vendor/blamejs/lib/external-db.js +164 -13
  27. package/lib/vendor/blamejs/lib/guard-email.js +36 -3
  28. package/lib/vendor/blamejs/lib/http-client.js +37 -7
  29. package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
  30. package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
  31. package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
  32. package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
  33. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
  34. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
  35. package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
  36. package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
  37. package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
  38. package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
  39. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
  40. package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
  41. package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
  42. package/lib/vendor/blamejs/lib/openapi.js +225 -100
  43. package/lib/vendor/blamejs/lib/problem-details.js +15 -3
  44. package/lib/vendor/blamejs/lib/queue-local.js +148 -38
  45. package/lib/vendor/blamejs/lib/queue.js +41 -11
  46. package/lib/vendor/blamejs/lib/render.js +21 -3
  47. package/lib/vendor/blamejs/lib/router.js +13 -6
  48. package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
  49. package/lib/vendor/blamejs/lib/sse.js +7 -5
  50. package/lib/vendor/blamejs/lib/static.js +46 -17
  51. package/lib/vendor/blamejs/lib/uri-template.js +3 -1
  52. package/lib/vendor/blamejs/package.json +1 -1
  53. package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
  54. package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
  55. package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
  56. package/lib/vendor/blamejs/test/00-primitives.js +151 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
  65. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
  66. package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
  67. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
  68. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
  69. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
  70. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
  71. package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
  73. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
  74. package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
  77. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
  81. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
  82. package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
  83. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
  84. package/package.json +1 -1
@@ -69,6 +69,11 @@
69
69
  * document: { maxBytes: b.constants.BYTES.mib(25) },
70
70
  * },
71
71
  *
72
+ * // RFC 5987 filename* charsets to decode. utf-8 is always
73
+ * // supported (RFC 8187 §3.2); add "iso-8859-1" (RFC 5987 §3.2)
74
+ * // to accept legacy senders that encode filenames in Latin-1.
75
+ * filenameCharsets: ["utf-8"],
76
+ *
72
77
  * // When wired, fileFilter rejections emit body-parser.multipart.file_rejected
73
78
  * // on the audit chain with the field, filename, mime, and reason.
74
79
  * audit: b.audit,
@@ -121,6 +126,7 @@ var safeBuffer = require("../safe-buffer");
121
126
  var safeJson = require("../safe-json");
122
127
  var structuredFields = require("../structured-fields");
123
128
  var validateOpts = require("../validate-opts");
129
+ var codepointClass = require("../codepoint-class");
124
130
  var C = require("../constants");
125
131
  var { defineClass } = require("../framework-error");
126
132
 
@@ -196,6 +202,7 @@ var DEFAULTS = Object.freeze({
196
202
  fileFilter: null, // fn({ field, filename, mimeType, partHeaders }) → bool | { reject, code, message }
197
203
  fields: null, // per-field overrides: { name: { maxBytes?, mimeTypes? } }
198
204
  audit: null, // when wired, file-rejection emits an audit event
205
+ filenameCharsets: ["utf-8"], // RFC 5987 filename* charsets to decode; add "iso-8859-1" to opt that legacy charset in
199
206
  contentTypes: ["multipart/form-data"],
200
207
  },
201
208
  });
@@ -344,9 +351,16 @@ function _detectSmuggling(req) {
344
351
  function _writeError(res, status, message, code) {
345
352
  if (res.headersSent) return;
346
353
  var body = JSON.stringify({ error: message, code: code });
354
+ // Connection: close on a body-parse rejection (malformed JSON, poisoned
355
+ // key, oversize payload) so an upstream proxy can't reuse a socket whose
356
+ // request stream we abandoned mid-body. Pairing the 4xx with a forced
357
+ // socket teardown closes the desync window a partially-consumed body
358
+ // would otherwise leave open (RFC 9112 §9.6 — a server MAY close after
359
+ // an error response; doing so here prevents request-smuggling reuse).
347
360
  res.writeHead(status, {
348
361
  "Content-Type": "application/json; charset=utf-8",
349
362
  "Content-Length": Buffer.byteLength(body),
363
+ "Connection": "close",
350
364
  });
351
365
  res.end(body);
352
366
  }
@@ -600,28 +614,58 @@ function _parseMultipartHeaders(rawHeaders) {
600
614
  return out;
601
615
  }
602
616
 
617
+ // Percent-decode an RFC 5987 ext-value's value segment under iso-8859-1.
618
+ // RFC 5987 §3.2 / RFC 2231 §7 define iso-8859-1 (Latin-1) as the only
619
+ // non-utf-8 charset a recipient is expected to understand: each decoded
620
+ // byte is itself the Latin-1 code point, so a byte b maps directly to
621
+ // U+00bb. We percent-unescape to raw bytes, then map each byte to its
622
+ // code point. Returns null on a malformed `%`-escape.
623
+ function _percentDecodeLatin1(encoded) {
624
+ var out = "";
625
+ for (var i = 0; i < encoded.length; i += 1) {
626
+ var ch = encoded.charAt(i);
627
+ if (ch === "%") {
628
+ var hex = encoded.substr(i + 1, 2);
629
+ if (hex.length !== 2 || !codepointClass.HEX_PAIR_RE.test(hex)) return null;
630
+ out += String.fromCharCode(parseInt(hex, 16));
631
+ i += 2;
632
+ } else {
633
+ out += ch;
634
+ }
635
+ }
636
+ return out;
637
+ }
638
+
603
639
  // RFC 5987 / 8187 — `filename*=UTF-8''percent%20encoded.txt` extended
604
- // parameter form for non-ASCII filenames. Charset MUST be `UTF-8`
605
- // (case-insensitive); we refuse other charsets to keep the decode
606
- // path single-encoding. Language tag (between the two `'`s) is
607
- // permitted but ignored.
608
- function _decodeRfc5987(raw) {
640
+ // parameter form for non-ASCII filenames. utf-8 is always accepted
641
+ // (RFC 8187 §3.2 mandates support); iso-8859-1 (RFC 5987 §3.2 / RFC 2231
642
+ // §7) decodes only when the operator opts it in via
643
+ // `multipart.filenameCharsets`. Any other charset is refused to keep the
644
+ // decode path bounded to the two RFC-defined encodings. The language tag
645
+ // (between the two `'`s) is permitted but ignored. `allowed` is a
646
+ // lower-cased charset allowlist; it always includes "utf-8".
647
+ function _decodeRfc5987(raw, allowed) {
609
648
  if (typeof raw !== "string") return null;
610
649
  var firstTick = raw.indexOf("'");
611
650
  if (firstTick === -1) return null;
612
651
  var secondTick = raw.indexOf("'", firstTick + 1);
613
652
  if (secondTick === -1) return null;
614
653
  var charset = raw.slice(0, firstTick).toLowerCase();
615
- if (charset !== "utf-8") return null; // RFC 5987 mandated charset; refuse anything else
616
654
  var encoded = raw.slice(secondTick + 1);
617
- try {
618
- return decodeURIComponent(encoded);
619
- } catch (_e) {
620
- return null;
655
+ if (charset === "utf-8") {
656
+ try {
657
+ return decodeURIComponent(encoded);
658
+ } catch (_e) {
659
+ return null;
660
+ }
621
661
  }
662
+ if (charset === "iso-8859-1" && allowed && allowed.indexOf("iso-8859-1") !== -1) {
663
+ return _percentDecodeLatin1(encoded);
664
+ }
665
+ return null; // charset not enabled — refuse
622
666
  }
623
667
 
624
- function _parseHeaderParams(headerValue) {
668
+ function _parseHeaderParams(headerValue, filenameCharsets) {
625
669
  // Content-Disposition: form-data; name="field"; filename="x.txt"
626
670
  // Returns { _value: "form-data", name: "field", filename: "x.txt" }
627
671
  // RFC 5987 / 8187 — when a `filename*=UTF-8''...` extended parameter
@@ -646,7 +690,7 @@ function _parseHeaderParams(headerValue) {
646
690
  var _unq = structuredFields.unquoteSfString(v);
647
691
  if (_unq !== null) v = _unq;
648
692
  if (k.charAt(k.length - 1) === "*") {
649
- var decoded = _decodeRfc5987(v);
693
+ var decoded = _decodeRfc5987(v, filenameCharsets);
650
694
  if (decoded !== null) {
651
695
  var bareKey = k.slice(0, -1);
652
696
  if (bareKey === "filename") extName = decoded;
@@ -682,6 +726,17 @@ async function _parseMultipart(req, opts, ctParams) {
682
726
  true, HTTP_STATUS.BAD_REQUEST
683
727
  );
684
728
  }
729
+ // RFC 5987 filename* charsets the operator opts into decoding. utf-8 is
730
+ // always present (RFC 8187 §3.2 mandates it); operators add "iso-8859-1"
731
+ // for legacy senders. Lower-cased once here so the per-part decode does
732
+ // a plain membership check.
733
+ var filenameCharsets = ["utf-8"];
734
+ if (Array.isArray(opts.filenameCharsets)) {
735
+ filenameCharsets = opts.filenameCharsets.map(function (c) {
736
+ return String(c).toLowerCase();
737
+ });
738
+ if (filenameCharsets.indexOf("utf-8") === -1) filenameCharsets.push("utf-8");
739
+ }
685
740
  // storage: "memory" buffers file parts in RAM (capped by fileSize ×
686
741
  // fileCount, the same DoS bound as disk mode) and exposes each file as
687
742
  // req.files[].buffer with no filesystem touch — the read-only /
@@ -870,7 +925,7 @@ async function _parseMultipart(req, opts, ctParams) {
870
925
  }
871
926
  pending = pending.slice(headEnd + 4);
872
927
  // Decode Content-Disposition.
873
- var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
928
+ var cd = _parseHeaderParams(currentHeaders["content-disposition"], filenameCharsets);
874
929
  if (cd._value !== "form-data" || typeof cd.name !== "string" || cd.name.length === 0) {
875
930
  done(new BodyParserError("body-parser/multipart-bad-disposition",
876
931
  "multipart part missing form-data Content-Disposition", true, HTTP_STATUS.BAD_REQUEST));
@@ -1211,7 +1266,8 @@ async function _parseMultipart(req, opts, ctParams) {
1211
1266
  * raw: false | { limit, contentTypes },
1212
1267
  * multipart: false | {
1213
1268
  * storage, tmpDir, fileSize, totalSize, fileCount, fieldCount,
1214
- * fieldSize, mimeAllowlist, fileFilter, fields, audit, contentTypes,
1269
+ * fieldSize, mimeAllowlist, fileFilter, fields, audit,
1270
+ * filenameCharsets, contentTypes,
1215
1271
  * },
1216
1272
  * keepRawBody: boolean, // expose req.bodyRaw for webhook signing
1217
1273
  * }
@@ -99,9 +99,21 @@ function _normalizeOne(reportLike) {
99
99
  * `csp.violation`, and forwarded to the operator's `onReport`
100
100
  * callback for metrics or alerting.
101
101
  *
102
+ * The rejection paths (405 / 413 / 400) are otherwise empty-bodied —
103
+ * the spec'd Reporting API (W3C Reporting API §3.1) ignores the
104
+ * response body, so there's nothing for the browser to read. `onReject`
105
+ * surfaces these refusals to the operator for the same metrics /
106
+ * alerting use as `onReport`: a flood of 413s signals a misconfigured
107
+ * `Reporting-Endpoints` URL or a report-bomb. It receives
108
+ * `(req, res, { status, reason })` where `reason` is one of
109
+ * `method-not-allowed` / `payload-too-large` / `invalid-json`. Invoked
110
+ * after the rejection response is written; a throwing hook is swallowed
111
+ * so a broken metrics sink can't crash the endpoint.
112
+ *
102
113
  * @opts
103
114
  * {
104
115
  * onReport: function(report): void,
116
+ * onReject: function(req, res, { status, reason }): void,
105
117
  * maxBytes: number, // default 64 KiB
106
118
  * audit: boolean, // default true
107
119
  * }
@@ -118,23 +130,38 @@ function _normalizeOne(reportLike) {
118
130
  */
119
131
  function create(opts) {
120
132
  opts = opts || {};
121
- validateOpts(opts, ["audit", "onReport", "maxBytes"], "middleware.cspReport");
133
+ validateOpts(opts, ["audit", "onReport", "onReject", "maxBytes"], "middleware.cspReport");
134
+ if (opts.onReject !== undefined && opts.onReject !== null &&
135
+ typeof opts.onReject !== "function") {
136
+ throw new TypeError("middleware.cspReport: opts.onReject must be a function");
137
+ }
122
138
  var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
123
139
  ? opts.maxBytes : DEFAULT_MAX_BYTES;
124
140
  var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
141
+ var onReject = (typeof opts.onReject === "function") ? opts.onReject : null;
142
+
143
+ // Drop-silent observability sink — the rejection response is already
144
+ // on the wire; a throwing metrics hook must not crash the endpoint.
145
+ function _emitReject(req, res, status, reason) {
146
+ if (!onReject) return;
147
+ try { onReject(req, res, { status: status, reason: reason }); }
148
+ catch (_e) { /* hook best-effort */ }
149
+ }
125
150
 
126
151
  return async function cspReport(req, res, _next) {
127
152
  if (req.method !== "POST") {
128
153
  res.writeHead(405, { "Allow": "POST" }); // HTTP 405 status
129
154
  res.end();
155
+ _emitReject(req, res, 405, "method-not-allowed");
130
156
  return;
131
157
  }
132
158
  var body;
133
159
  try {
134
- body = await safeBuffer.boundedChunkCollector(req, { maxBytes: maxBytes });
160
+ body = await safeBuffer.collectStream(req, { maxBytes: maxBytes });
135
161
  } catch (_e) {
136
162
  res.writeHead(413); // HTTP 413 status
137
163
  res.end();
164
+ _emitReject(req, res, 413, "payload-too-large");
138
165
  return;
139
166
  }
140
167
  var parsed;
@@ -142,6 +169,7 @@ function create(opts) {
142
169
  catch (_e) {
143
170
  res.writeHead(400); // HTTP 400 status
144
171
  res.end();
172
+ _emitReject(req, res, 400, "invalid-json");
145
173
  return;
146
174
  }
147
175
  var reports = Array.isArray(parsed) ? parsed : [parsed];
@@ -79,25 +79,40 @@ function denyResponse(req, res, ctx) {
79
79
  if (_isFn(ctx.onDeny)) {
80
80
  try {
81
81
  var returned = ctx.onDeny(req, res, info);
82
- if (res.writableEnded) return returned;
83
- // Hook ran but did not write — fall through to the default so
84
- // the response can never hang on a no-op hook.
82
+ if (res.writableEnded || res.headersSent) return returned;
83
+ // Hook ran but did not commit the response — fall through to the
84
+ // default so the response can never hang on a no-op hook. A
85
+ // wrapping consumer that already sent headers (without flipping
86
+ // writableEnded) counts as committed: re-entering writeHead below
87
+ // would throw "headers already sent".
85
88
  } catch (e) {
86
89
  if (_isFn(ctx.onThrow)) {
87
90
  try { ctx.onThrow(e); } catch (_e) { /* drop-silent */ }
88
91
  }
89
- if (res.writableEnded) return undefined;
90
- // Hook threw before writing — fall through to the default.
92
+ if (res.writableEnded || res.headersSent) return undefined;
93
+ // Hook threw before committing the response — fall through to
94
+ // the default.
91
95
  }
92
96
  }
93
97
 
94
- if (res.writableEnded || !_isFn(res.writeHead)) return undefined;
98
+ if (res.writableEnded || res.headersSent || !_isFn(res.writeHead)) return undefined;
95
99
 
96
100
  var extra = (ctx.headers && typeof ctx.headers === "object") ? ctx.headers : null;
97
101
 
98
102
  if (ctx.problem) {
99
103
  var fields = { status: ctx.status };
100
- if (ctx.problemType) fields.type = ctx.problemType;
104
+ if (ctx.problemType) {
105
+ fields.type = ctx.problemType;
106
+ } else if (typeof ctx.problemCode === "string" && ctx.problemCode.length > 0) {
107
+ // No explicit type URI: derive one from problemCode using the
108
+ // same `<base>/<code>` convention as problemDetails.fromError, so
109
+ // a 429 carrying problemCode reads `<base>/rate-limit-exceeded`
110
+ // rather than defaulting to "about:blank". RFC 9457 §3.1.1 lets
111
+ // the type be any URI reference; sanitize the suffix into RFC
112
+ // 3986 unreserved + "/" path chars, matching fromError exactly.
113
+ fields.type = problemDetails.getBase() + "/" +
114
+ ctx.problemCode.replace(/[^A-Za-z0-9\-._/]/g, "-");
115
+ }
101
116
  if (ctx.problemTitle) fields.title = ctx.problemTitle;
102
117
  if (ctx.problemDetail) fields.detail = ctx.problemDetail;
103
118
  if (ctx.problemExt && typeof ctx.problemExt === "object") {
@@ -125,13 +140,18 @@ function denyResponse(req, res, ctx) {
125
140
  res.setHeader(hk[h], extra[hk[h]]);
126
141
  }
127
142
  }
128
- problemDetails.respond(res, problem);
143
+ problemDetails.respond(res, problem, req);
129
144
  return undefined;
130
145
  }
131
146
 
132
147
  var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
148
+ var denyOut = (ctx.body === undefined || ctx.body === null) ? ""
149
+ : (typeof ctx.body === "string" ? ctx.body : JSON.stringify(ctx.body));
150
+ if (ctx.body !== undefined && ctx.body !== null && req && typeof req.apiEncryptEncode === "function") {
151
+ try { denyOut = JSON.stringify(req.apiEncryptEncode(ctx.body)); } catch (_e) { /* plaintext kept */ }
152
+ }
133
153
  res.writeHead(ctx.status, head);
134
- res.end((ctx.body === undefined || ctx.body === null) ? "" : ctx.body);
154
+ res.end(denyOut);
135
155
  return undefined;
136
156
  }
137
157
 
@@ -22,15 +22,50 @@
22
22
  * If `accessControl: "public"` (the default), the middleware emits
23
23
  * `Access-Control-Allow-Origin: *` so external doc tooling can fetch.
24
24
  * For internal-only docs operators set `accessControl: "same-origin"`
25
- * which omits the CORS header.
25
+ * which omits the CORS header. To allow exactly one external origin set
26
+ * `accessControl: { allowOrigin: "https://docs.example.com" }`; the
27
+ * origin is validated and echoed verbatim with `Vary: Origin`.
26
28
  */
27
29
 
28
30
  var nodeCrypto = require("node:crypto");
29
31
  var validateOpts = require("../validate-opts");
30
32
  var lazyRequire = require("../lazy-require");
33
+ var safeUrl = require("../safe-url");
31
34
  var { defineClass } = require("../framework-error");
32
35
  var OpenApiError = defineClass("OpenApiError", { alwaysPermanent: true });
33
36
 
37
+ // Validate an operator-supplied accessControl.allowOrigin and return the
38
+ // canonical `scheme://host[:port]` string for the Access-Control-Allow-
39
+ // Origin response header. CORS (Fetch Standard §3.2.1) requires a single
40
+ // concrete origin with no path / query / fragment; the empty-string and
41
+ // "*" wildcard forms are spelled separately ("same-origin" / "public").
42
+ // Parsing through safeUrl rejects header-injection bytes (CR/LF) and
43
+ // userinfo, and confirms the value is a real http(s) origin. Throws so
44
+ // the operator catches a typo'd allowOrigin at boot.
45
+ function _canonicalAllowOrigin(value, label) {
46
+ if (typeof value !== "string" || value.length === 0) {
47
+ throw new OpenApiError("openapi/bad-access-control",
48
+ label + ": accessControl.allowOrigin must be a non-empty origin string " +
49
+ "(e.g. \"https://docs.example.com\")");
50
+ }
51
+ var parsed;
52
+ try {
53
+ parsed = safeUrl.parse(value, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
54
+ } catch (e) {
55
+ throw new OpenApiError("openapi/bad-access-control",
56
+ label + ": accessControl.allowOrigin '" + value + "' is not a valid " +
57
+ "http(s) origin: " + ((e && e.message) || String(e)));
58
+ }
59
+ var path = parsed.pathname || "";
60
+ if ((path !== "" && path !== "/") || parsed.search || parsed.hash) {
61
+ throw new OpenApiError("openapi/bad-access-control",
62
+ label + ": accessControl.allowOrigin must be a bare origin " +
63
+ "(scheme://host[:port]) with no path / query / fragment; got '" + value + "'");
64
+ }
65
+ var port = parsed.port;
66
+ return parsed.protocol + "//" + parsed.hostname.toLowerCase() + (port ? ":" + port : "");
67
+ }
68
+
34
69
  var openapiYaml = lazyRequire(function () { return require("../openapi-yaml"); });
35
70
  var audit = lazyRequire(function () { return require("../audit"); });
36
71
 
@@ -45,7 +80,9 @@ var audit = lazyRequire(function () { return require("../audit"); });
45
80
  * else falls through. SHA3-512 ETag enables conditional 304. With
46
81
  * `accessControl: "public"` (default) emits
47
82
  * `Access-Control-Allow-Origin: *` so external doc tooling can
48
- * fetch; `same-origin` omits the CORS header for internal-only docs.
83
+ * fetch; `same-origin` omits the CORS header for internal-only docs;
84
+ * `{ allowOrigin: "https://docs.example.com" }` echoes one validated
85
+ * origin with `Vary: Origin`.
49
86
  *
50
87
  * @opts
51
88
  * {
@@ -84,6 +121,17 @@ function create(opts) {
84
121
  var cacheControl = (typeof opts.cacheControl === "string" && opts.cacheControl.length > 0)
85
122
  ? opts.cacheControl : "public, max-age=300";
86
123
  var accessControl = opts.accessControl || "public";
124
+ // Resolve the Access-Control-Allow-Origin value once at config time.
125
+ // "public" → "*"; an { allowOrigin } object → the canonical origin
126
+ // (validated, throws on a bad value); "same-origin" / anything else →
127
+ // null (no CORS header emitted).
128
+ var allowOriginHeader = null;
129
+ if (accessControl === "public") {
130
+ allowOriginHeader = "*";
131
+ } else if (accessControl && typeof accessControl === "object" &&
132
+ typeof accessControl.allowOrigin === "string") {
133
+ allowOriginHeader = _canonicalAllowOrigin(accessControl.allowOrigin, "openapiServe");
134
+ }
87
135
  var auditOn = opts.audit !== false;
88
136
 
89
137
  if (typeof pathJson !== "string" || pathJson.charAt(0) !== "/") {
@@ -123,8 +171,12 @@ function create(opts) {
123
171
  "Cache-Control": cacheControl,
124
172
  "ETag": etag,
125
173
  };
126
- if (accessControl === "public") {
127
- headers["Access-Control-Allow-Origin"] = "*";
174
+ if (allowOriginHeader !== null) {
175
+ headers["Access-Control-Allow-Origin"] = allowOriginHeader;
176
+ // A specific (non-"*") origin makes the response vary by Origin;
177
+ // advertise it so shared caches don't serve one operator's allowed
178
+ // origin to another's request (Fetch Standard §3.2.1).
179
+ if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
128
180
  }
129
181
  res.writeHead(200, headers); // HTTP 200
130
182
  res.end(body);