@blamejs/core 0.14.18 → 0.14.20

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.
@@ -43,6 +43,52 @@ var observability = lazyRequire(function () { return require("../observability")
43
43
 
44
44
  var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
45
45
 
46
+ // Sec-Fetch-Dest request-destination vocabulary (Fetch Standard §3.2.6
47
+ // "destination", https://fetch.spec.whatwg.org/#concept-request-destination;
48
+ // surfaced as the Sec-Fetch-Dest header by the Fetch Metadata Request
49
+ // Headers spec, https://www.w3.org/TR/fetch-metadata/#sec-fetch-dest-header).
50
+ // "webidentity" (FedCM credentialed-request destination,
51
+ // https://w3c.github.io/FedCM/) is included so an operator can recognize
52
+ // and gate FedCM traffic first-class — a webidentity Sec-Fetch-Dest on a
53
+ // route that is not a FedCM identity endpoint is a request worth refusing.
54
+ var KNOWN_DESTINATIONS = Object.freeze([
55
+ "audio", "audioworklet", "document", "embed", "empty", "fencedframe",
56
+ "font", "frame", "iframe", "image", "json", "manifest", "object",
57
+ "paintworklet", "report", "script", "serviceworker", "sharedworker",
58
+ "style", "track", "video", "webidentity", "worker", "xslt",
59
+ ]);
60
+ var KNOWN_DEST_SET = Object.create(null);
61
+ (function () {
62
+ for (var i = 0; i < KNOWN_DESTINATIONS.length; i += 1) {
63
+ KNOWN_DEST_SET[KNOWN_DESTINATIONS[i]] = true;
64
+ }
65
+ })();
66
+
67
+ // Sec-Fetch-Storage-Access status values (Storage Access API,
68
+ // https://privacycg.github.io/storage-access-headers/ — the header is
69
+ // distinct from Sec-Fetch-Dest). The browser sends this only on cross-site
70
+ // credentialed requests. "active" / "inactive" both indicate the embedded
71
+ // context can (active) or could (inactive, permission granted but not yet
72
+ // exercised) reach unpartitioned cross-site cookies; "none" carries no
73
+ // such capability. A route that does not participate in the Storage Access
74
+ // flow may refuse the active/inactive escalation.
75
+ var STORAGE_ACCESS_ESCALATED = Object.freeze({ active: true, inactive: true });
76
+
77
+ function _validateDestList(list, label) {
78
+ // Config-time tier — an unknown Sec-Fetch-Dest value in a strict
79
+ // allow/deny list is almost always an operator typo (e.g. "web-identity"
80
+ // for "webidentity"). Throw at boot per the config/entry-point tier so
81
+ // the typo surfaces before it silently fails to match at request time.
82
+ if (!Array.isArray(list)) return;
83
+ for (var i = 0; i < list.length; i += 1) {
84
+ if (!KNOWN_DEST_SET[list[i]]) {
85
+ throw new Error("middleware.fetchMetadata: " + label + "[" + i +
86
+ "] is not a known Sec-Fetch-Dest value (got '" + String(list[i]) +
87
+ "'). Known destinations: " + KNOWN_DESTINATIONS.join(", ") + ".");
88
+ }
89
+ }
90
+ }
91
+
46
92
  function _writeReject(req, res, message, reason, onDeny, problemMode) {
47
93
  denyResponse(req, res, {
48
94
  onDeny: onDeny,
@@ -75,17 +121,30 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
75
121
  * the value-add; non-browser callers carry their own auth threat
76
122
  * model.
77
123
  *
124
+ * The Sec-Fetch-Dest vocabulary tracks the Fetch Standard request-
125
+ * destination list, including `webidentity` (FedCM credentialed
126
+ * requests). `deniedDest` refuses chosen destinations outright on the
127
+ * gated methods — a FedCM `webidentity` Sec-Fetch-Dest hitting a route
128
+ * that is not an identity endpoint is refused. `allowStorageAccess:
129
+ * false` refuses the Storage Access API escalation (a cross-site request
130
+ * carrying `Sec-Fetch-Storage-Access: active` / `inactive`) on routes
131
+ * that do not participate in the Storage Access flow. Both are opt-in;
132
+ * leaving them unset preserves the prior behavior exactly.
133
+ *
78
134
  * @opts
79
135
  * {
80
- * allowSameSite: boolean, // default true
81
- * allowCrossSite: boolean, // default false
82
- * allowMissing: boolean, // default true
83
- * allowedDest: string[],
84
- * allowedNavigate: boolean, // default true
85
- * methods: string[], // default POST/PUT/DELETE/PATCH
86
- * audit: boolean, // default true
87
- * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
88
- * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
136
+ * allowSameSite: boolean, // default true
137
+ * allowCrossSite: boolean, // default false
138
+ * allowMissing: boolean, // default true
139
+ * allowedDest: string[], // cross-site allowlist of Sec-Fetch-Dest values
140
+ * deniedDest: string[], // Sec-Fetch-Dest values refused on gated methods regardless of site (e.g. ["webidentity"])
141
+ * allowStorageAccess: boolean, // default true — false refuses Sec-Fetch-Storage-Access: active|inactive
142
+ * strictDest: boolean, // default false — true throws at config time on an allowedDest/deniedDest value outside the known Sec-Fetch-Dest vocabulary
143
+ * allowedNavigate: boolean, // default true
144
+ * methods: string[], // default POST/PUT/DELETE/PATCH
145
+ * audit: boolean, // default true
146
+ * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
147
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
89
148
  * }
90
149
  *
91
150
  * @example
@@ -102,15 +161,34 @@ function create(opts) {
102
161
  opts = opts || {};
103
162
  validateOpts(opts, [
104
163
  "allowSameSite", "allowCrossSite", "allowMissing",
105
- "allowedDest", "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
164
+ "allowedDest", "deniedDest", "allowStorageAccess", "strictDest",
165
+ "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
106
166
  ], "middleware.fetchMetadata");
167
+ validateOpts.optionalBoolean(opts.allowStorageAccess, "middleware.fetchMetadata: allowStorageAccess");
168
+ validateOpts.optionalBoolean(opts.strictDest, "middleware.fetchMetadata: strictDest");
169
+ validateOpts.optionalNonEmptyStringArray(opts.deniedDest, "middleware.fetchMetadata: deniedDest");
170
+ if (opts.strictDest === true) {
171
+ _validateDestList(opts.allowedDest, "allowedDest");
172
+ _validateDestList(opts.deniedDest, "deniedDest");
173
+ }
107
174
 
108
175
  var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
109
176
  var problemMode = opts.problemDetails === true;
110
- var allowSameSite = opts.allowSameSite !== false;
111
- var allowCrossSite = opts.allowCrossSite === true;
112
- var allowMissing = opts.allowMissing !== false;
113
- var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
177
+ var allowSameSite = opts.allowSameSite !== false;
178
+ var allowCrossSite = opts.allowCrossSite === true;
179
+ var allowMissing = opts.allowMissing !== false;
180
+ var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
181
+ var allowStorageAccess = opts.allowStorageAccess !== false;
182
+ // deniedDest → a null-prototype membership map; an operator-supplied
183
+ // destination string is never assigned onto a plain object, so no
184
+ // reserved name (__proto__ / constructor / prototype) can pollute it.
185
+ var deniedDest = null;
186
+ if (Array.isArray(opts.deniedDest) && opts.deniedDest.length > 0) {
187
+ deniedDest = Object.create(null);
188
+ for (var di = 0; di < opts.deniedDest.length; di += 1) {
189
+ deniedDest[opts.deniedDest[di]] = true;
190
+ }
191
+ }
114
192
  var allowedNavigate = opts.allowedNavigate !== false;
115
193
  var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
116
194
  var auditOn = opts.audit !== false;
@@ -139,6 +217,16 @@ function create(opts) {
139
217
  var mode = headers["sec-fetch-mode"];
140
218
  var dest = headers["sec-fetch-dest"];
141
219
 
220
+ // Destination refusal — independent of site. A FedCM `webidentity`
221
+ // (or any operator-denied) Sec-Fetch-Dest on a route that is not an
222
+ // identity endpoint is refused outright. The membership test is exact
223
+ // (null-prototype map keyed on the verbatim header value), never a
224
+ // substring scan.
225
+ if (deniedDest && typeof dest === "string" && deniedDest[dest] === true) {
226
+ _emitDenied(req, "dest-denied (dest=" + dest + ")");
227
+ return _writeReject(req, res, "Request destination not allowed for this route.", "dest-not-allowed", onDeny, problemMode);
228
+ }
229
+
142
230
  if (typeof site !== "string" || site.length === 0) {
143
231
  // No Sec-Fetch-Site header — legacy browser or non-browser client.
144
232
  // Defer to other auth/CSRF layers per allowMissing.
@@ -165,6 +253,19 @@ function create(opts) {
165
253
  }
166
254
 
167
255
  // cross-site
256
+ // Storage Access API escalation — the browser sends
257
+ // Sec-Fetch-Storage-Access only on cross-site credentialed requests.
258
+ // active|inactive both mean the embedded context can / could reach
259
+ // unpartitioned cross-site cookies; refuse it on routes that do not
260
+ // participate in the Storage Access flow. Exact membership, never a
261
+ // substring scan. Checked before the allowCrossSite shortcut so the
262
+ // escalation is gated even when cross-site is otherwise permitted.
263
+ var storageAccess = headers["sec-fetch-storage-access"];
264
+ if (!allowStorageAccess && typeof storageAccess === "string" &&
265
+ STORAGE_ACCESS_ESCALATED[storageAccess] === true) {
266
+ _emitDenied(req, "storage-access-refused (status=" + storageAccess + ")");
267
+ return _writeReject(req, res, "Storage Access escalation not allowed for this route.", "storage-access-refused", onDeny, problemMode);
268
+ }
168
269
  if (allowCrossSite) return next();
169
270
  if (allowedDest && typeof dest === "string" && allowedDest.indexOf(dest) !== -1) {
170
271
  return next();
@@ -19,8 +19,17 @@ var ScimServerError = framework_error.defineClass(
19
19
 
20
20
  var SCIM_CORE_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
21
21
  var SCIM_CORE_SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
22
- var SCIM_MESSAGE_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
23
- var SCIM_MESSAGE_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
22
+ var SCIM_MESSAGE_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
23
+ var SCIM_MESSAGE_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
24
+ var SCIM_MESSAGE_BULK_REQUEST = "urn:ietf:params:scim:api:messages:2.0:BulkRequest";
25
+ var SCIM_MESSAGE_BULK_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:BulkResponse";
26
+
27
+ // RFC 7644 §3.7.2 — a bulkId cross-reference is the literal token
28
+ // "bulkId:" followed by the client-assigned identifier. Bounded: the
29
+ // identifier is matched lazily-free (one or more non-quote chars) and
30
+ // only ever applied to operator-defined bulkId strings, never to free
31
+ // request text, so there is no super-linear backtracking exposure.
32
+ var BULK_ID_REF_RE = /^bulkId:(.+)$/;
24
33
 
25
34
  var ALLOWED_FILTER_OPS = ["eq", "ne", "co", "sw", "ew", "pr", "gt", "ge", "lt", "le"];
26
35
 
@@ -37,6 +46,11 @@ var BEARER_RE = /^Bearer\s+(.+)$/i;
37
46
  * Returns a request middleware that handles SCIM 2.0 requests
38
47
  * (RFC 7642-7644). Operator supplies CRUD callbacks per resource.
39
48
  *
49
+ * Bulk operations (RFC 7644 §3.7) are opt-in: pass `opts.bulk` to
50
+ * enable the `/Bulk` POST endpoint and advertise it in
51
+ * ServiceProviderConfig. When omitted, `bulk.supported` stays `false`
52
+ * and `/Bulk` is not routed (back-compatible default).
53
+ *
40
54
  * @opts
41
55
  * {
42
56
  * basePath?: string,
@@ -44,6 +58,10 @@ var BEARER_RE = /^Bearer\s+(.+)$/i;
44
58
  * groups?: ScimResourceImpl,
45
59
  * bearer?: (token) => Promise<actor>,
46
60
  * maxPageSize?: number,
61
+ * bulk?: {
62
+ * maxOperations?: number, // default: 1000 (config-time positive int)
63
+ * maxPayloadSize?: number, // default: 1 MiB (config-time positive int, bytes)
64
+ * },
47
65
  * }
48
66
  *
49
67
  * @example
@@ -51,6 +69,7 @@ var BEARER_RE = /^Bearer\s+(.+)$/i;
51
69
  * basePath: "/scim/v2",
52
70
  * users: myUserAdapter,
53
71
  * groups: myGroupAdapter,
72
+ * bulk: { maxOperations: 100 },
54
73
  * });
55
74
  * app.use(mw);
56
75
  */
@@ -63,11 +82,12 @@ function create(opts) {
63
82
  var basePath = opts.basePath || "/scim/v2";
64
83
  var maxPageSize = opts.maxPageSize || 200; // page-size count, not bytes
65
84
  var bearer = opts.bearer || null;
85
+ var bulkCfg = _resolveBulkConfig(opts.bulk);
66
86
 
67
87
  function middleware(req, res, next) {
68
88
  var url = req.url.split("?")[0];
69
89
  if (url.indexOf(basePath) !== 0) { next(); return; }
70
- _dispatch(req, res, basePath, bearer, opts, maxPageSize)
90
+ _dispatch(req, res, basePath, bearer, opts, maxPageSize, bulkCfg)
71
91
  .catch(function (err) {
72
92
  _writeScimError(res, err.statusCode || 500, err.scimType || "internal",
73
93
  (err.message || String(err)).slice(0, 500));
@@ -92,7 +112,26 @@ function _validateResourceImpl(impl, name) {
92
112
  });
93
113
  }
94
114
 
95
- async function _dispatch(req, res, basePath, bearer, opts, maxPageSize) {
115
+ // Config-time / entry-point tier: bad bulk caps THROW so the operator
116
+ // catches a typo at boot rather than at request time. RFC 7644 §3.7.
117
+ // Returns null when bulk is not opted into (back-compat default off).
118
+ function _resolveBulkConfig(bulk) {
119
+ if (bulk === undefined || bulk === null) return null;
120
+ if (typeof bulk !== "object") {
121
+ throw new ScimServerError("middleware/scim-server/bad-bulk",
122
+ "middleware.scimServer: opts.bulk must be an object { maxOperations?, maxPayloadSize? }");
123
+ }
124
+ validateOpts.optionalPositiveInt(bulk.maxOperations, "middleware.scimServer: opts.bulk.maxOperations",
125
+ ScimServerError, "middleware/scim-server/bad-bulk-max-operations");
126
+ validateOpts.optionalPositiveInt(bulk.maxPayloadSize, "middleware.scimServer: opts.bulk.maxPayloadSize",
127
+ ScimServerError, "middleware/scim-server/bad-bulk-max-payload-size");
128
+ return {
129
+ maxOperations: bulk.maxOperations || 1000,
130
+ maxPayloadSize: bulk.maxPayloadSize || C.BYTES.mib(1),
131
+ };
132
+ }
133
+
134
+ async function _dispatch(req, res, basePath, bearer, opts, maxPageSize, bulkCfg) {
96
135
  var relUrl = req.url.slice(basePath.length) || "/";
97
136
  var qIdx = relUrl.indexOf("?");
98
137
  var path = qIdx === -1 ? relUrl : relUrl.slice(0, qIdx);
@@ -148,6 +187,19 @@ async function _dispatch(req, res, basePath, bearer, opts, maxPageSize) {
148
187
  var users = opts.users;
149
188
  var groups = opts.groups;
150
189
 
190
+ if (path === "/Bulk") {
191
+ if (req.method !== "POST") {
192
+ _writeScimError(res, H.METHOD_NOT_ALLOWED, "noTarget", req.method + " not allowed on /Bulk");
193
+ return;
194
+ }
195
+ if (!bulkCfg) {
196
+ _writeScimError(res, 501, "notSupported", "bulk operations are not enabled");
197
+ return;
198
+ }
199
+ await _handleBulk(req, res, opts, bulkCfg, ctx);
200
+ return;
201
+ }
202
+
151
203
  // RESOURCE_PATH_RE applies to a URL path; node http caps URL at 8 KiB.
152
204
  var match = path.length < C.BYTES.kib(8) && RESOURCE_PATH_RE.test(path) ? path.match(RESOURCE_PATH_RE) : null; // allow:regex-no-length-cap URL bounded above
153
205
  if (!match) {
@@ -244,6 +296,230 @@ async function _dispatch(req, res, basePath, bearer, opts, maxPageSize) {
244
296
  _writeScimError(res, H.METHOD_NOT_ALLOWED, "noTarget", req.method + " not allowed on " + path);
245
297
  }
246
298
 
299
+ // RFC 7644 §3.7 — /Bulk POST. Parses a BulkRequest, enforces the
300
+ // config-time maxOperations / maxPayloadSize caps, optionally
301
+ // short-circuits at failOnErrors, resolves bulkId cross-references
302
+ // (§3.7.2), dispatches each operation through the same per-resource
303
+ // create/update/delete logic the singleton endpoints use, and returns
304
+ // a BulkResponse carrying one result object per operation.
305
+ async function _handleBulk(req, res, opts, bulkCfg, ctx) {
306
+ var body;
307
+ try {
308
+ body = await _readBulkBody(req, bulkCfg.maxPayloadSize);
309
+ } catch (e) {
310
+ // collectStream rejects with the configured errorClass on overflow.
311
+ if (e && e.code === "middleware/scim-server/bulk-too-large") {
312
+ _writeScimError(res, H.PAYLOAD_TOO_LARGE, "tooLarge",
313
+ "bulk payload exceeds maxPayloadSize of " + bulkCfg.maxPayloadSize + " bytes");
314
+ return;
315
+ }
316
+ throw e;
317
+ }
318
+
319
+ if (!body || typeof body !== "object" ||
320
+ !Array.isArray(body.schemas) ||
321
+ body.schemas.indexOf(SCIM_MESSAGE_BULK_REQUEST) === -1) {
322
+ _writeScimError(res, H.BAD_REQUEST, "invalidValue",
323
+ "BulkRequest body.schemas must include '" + SCIM_MESSAGE_BULK_REQUEST + "'");
324
+ return;
325
+ }
326
+ if (!Array.isArray(body.Operations)) {
327
+ _writeScimError(res, H.BAD_REQUEST, "invalidValue", "BulkRequest must include Operations[]");
328
+ return;
329
+ }
330
+ if (body.Operations.length > bulkCfg.maxOperations) {
331
+ _writeScimError(res, H.PAYLOAD_TOO_LARGE, "tooMany",
332
+ "bulk request has " + body.Operations.length +
333
+ " operations, exceeding maxOperations of " + bulkCfg.maxOperations);
334
+ return;
335
+ }
336
+
337
+ var failOnErrors = _parseFailOnErrors(body.failOnErrors);
338
+ var bulkIdMap = Object.create(null); // client bulkId -> assigned resource id
339
+ var results = [];
340
+ var errorCount = 0;
341
+
342
+ for (var i = 0; i < body.Operations.length; i++) {
343
+ var result = await _runBulkOperation(body.Operations[i], i, opts, ctx, bulkIdMap);
344
+ results.push(result.entry);
345
+ if (result.isError) {
346
+ errorCount++;
347
+ // RFC 7644 §3.7 — once the error count reaches failOnErrors the
348
+ // service stops processing and returns the results so far.
349
+ if (failOnErrors !== null && errorCount >= failOnErrors) break;
350
+ }
351
+ }
352
+
353
+ _writeJson(res, H.OK, {
354
+ schemas: [SCIM_MESSAGE_BULK_RESPONSE],
355
+ Operations: results,
356
+ });
357
+ }
358
+
359
+ // RFC 7644 §3.7 — failOnErrors is an OPTIONAL integer >= 1. Absent /
360
+ // non-conforming values mean "process every operation" (null).
361
+ function _parseFailOnErrors(value) {
362
+ if (typeof value !== "number" || !isFinite(value) || Math.floor(value) !== value || value < 1) {
363
+ return null;
364
+ }
365
+ return value;
366
+ }
367
+
368
+ // Dispatch one BulkOperation through the matching per-resource adapter.
369
+ // Returns { entry, isError }; entry is the BulkResponse Operation object
370
+ // (RFC 7644 §3.7). Adapter rejections become per-op error entries — one
371
+ // failing operation never aborts the whole batch (unless failOnErrors).
372
+ async function _runBulkOperation(op, index, opts, ctx, bulkIdMap) {
373
+ if (!op || typeof op !== "object" || typeof op.method !== "string") {
374
+ return _bulkErr(op, "400", "invalidSyntax",
375
+ "Operations[" + index + "] missing string method");
376
+ }
377
+ var method = op.method.toUpperCase();
378
+ if (method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
379
+ return _bulkErr(op, "400", "invalidValue",
380
+ "Operations[" + index + "].method '" + op.method + "' not in POST/PUT/PATCH/DELETE");
381
+ }
382
+
383
+ var parsed = _parseBulkPath(op.path);
384
+ if (!parsed) {
385
+ return _bulkErr(op, "400", "invalidValue",
386
+ "Operations[" + index + "].path '" + String(op.path) + "' is not a valid bulk path");
387
+ }
388
+ var impl = parsed.resourceType === "Users" ? opts.users : opts.groups;
389
+ if (!impl) {
390
+ return _bulkErr(op, "404", "invalidValue", "/" + parsed.resourceType + " not configured");
391
+ }
392
+ // POST defines a bulkId-assigned resource; PUT/PATCH/DELETE target an id.
393
+ if (method === "POST" && !op.bulkId) {
394
+ return _bulkErr(op, "400", "invalidValue",
395
+ "Operations[" + index + "] POST requires a bulkId");
396
+ }
397
+ if (method !== "POST" && !parsed.resourceId) {
398
+ return _bulkErr(op, "400", "invalidValue",
399
+ "Operations[" + index + "] " + method + " requires a resource id in path");
400
+ }
401
+
402
+ var data = op.data;
403
+ if (method === "POST" || method === "PUT" || method === "PATCH") {
404
+ // RFC 7644 §3.7.2 — substitute any bulkId cross-references that
405
+ // earlier operations have resolved before handing data to the adapter.
406
+ data = _resolveBulkIdRefs(op.data, bulkIdMap);
407
+ }
408
+
409
+ try {
410
+ if (method === "POST" || method === "PUT") {
411
+ // The same SCIM schema gate the singleton POST / PUT routes apply,
412
+ // so a bulk op cannot persist a resource with a missing or wrong
413
+ // schema that the singleton endpoints would reject (RFC 7644
414
+ // §3.5.1). A throw here is caught below and returned as the op's
415
+ // own error, not aborting the batch.
416
+ _assertSchema(data, parsed.resourceType === "Users" ? SCIM_CORE_SCHEMA_USER : SCIM_CORE_SCHEMA_GROUP);
417
+ }
418
+ if (method === "POST") {
419
+ var created = await impl.create(data || {}, ctx);
420
+ var newId = created && created.id;
421
+ if (op.bulkId && newId !== undefined && newId !== null) {
422
+ bulkIdMap[String(op.bulkId)] = String(newId);
423
+ }
424
+ return _bulkOk(op, "201", _bulkLocation(parsed.resourceType, newId), created);
425
+ }
426
+ if (method === "PUT") {
427
+ var replaced = await impl.update(parsed.resourceId, data || {}, ctx);
428
+ return _bulkOk(op, "200", _bulkLocation(parsed.resourceType, parsed.resourceId), replaced);
429
+ }
430
+ if (method === "PATCH") {
431
+ var ops = data && Array.isArray(data.Operations) ? data.Operations : [];
432
+ var patched = await impl.patch(parsed.resourceId, ops, ctx);
433
+ return _bulkOk(op, "200", _bulkLocation(parsed.resourceType, parsed.resourceId), patched);
434
+ }
435
+ await impl.remove(parsed.resourceId, ctx); // DELETE
436
+ return _bulkOk(op, "204", _bulkLocation(parsed.resourceType, parsed.resourceId), null);
437
+ } catch (e) {
438
+ var status = e && e.statusCode ? String(e.statusCode) : "500";
439
+ var scimType = e && e.scimType ? e.scimType : null;
440
+ // Operator-adapter error detail is surfaced verbatim (it is the
441
+ // adapter's own message, not request-derived secret material).
442
+ return _bulkErr(op, status, scimType, (e && e.message) ? String(e.message) : "operation failed");
443
+ }
444
+ }
445
+
446
+ // A bulk path is "/Users", "/Users/<id>", "/Groups", or "/Groups/<id>".
447
+ function _parseBulkPath(path) {
448
+ if (typeof path !== "string" || path.length === 0) return null;
449
+ // Reuse the singleton resource-path grammar; node http caps URL at 8 KiB.
450
+ var m = path.length < C.BYTES.kib(8) && RESOURCE_PATH_RE.test(path) // allow:regex-no-length-cap path bounded above
451
+ ? path.match(RESOURCE_PATH_RE) : null;
452
+ if (!m) return null;
453
+ return { resourceType: m[1], resourceId: m[2] || null };
454
+ }
455
+
456
+ // RFC 7644 §3.7.2 — walk operation data and replace any string of the
457
+ // form "bulkId:<clientId>" with the server-assigned id once known.
458
+ // Operates only on operator/client-supplied bulk data of bounded size
459
+ // (the whole payload is capped at maxPayloadSize before parse).
460
+ function _resolveBulkIdRefs(value, bulkIdMap) {
461
+ if (typeof value === "string") {
462
+ var ref = BULK_ID_REF_RE.exec(value);
463
+ if (ref) {
464
+ var resolved = bulkIdMap[ref[1]];
465
+ return resolved !== undefined ? resolved : value;
466
+ }
467
+ return value;
468
+ }
469
+ if (Array.isArray(value)) {
470
+ var arr = [];
471
+ for (var i = 0; i < value.length; i++) arr.push(_resolveBulkIdRefs(value[i], bulkIdMap));
472
+ return arr;
473
+ }
474
+ if (value && typeof value === "object") {
475
+ var out = {};
476
+ var keys = Object.keys(value);
477
+ for (var k = 0; k < keys.length; k++) {
478
+ if (keys[k] === "__proto__" || keys[k] === "constructor" || keys[k] === "prototype") continue;
479
+ out[keys[k]] = _resolveBulkIdRefs(value[keys[k]], bulkIdMap);
480
+ }
481
+ return out;
482
+ }
483
+ return value;
484
+ }
485
+
486
+ function _bulkLocation(resourceType, id) {
487
+ if (id === undefined || id === null) return undefined;
488
+ return "/" + resourceType + "/" + String(id);
489
+ }
490
+
491
+ function _bulkOk(op, status, location, response) {
492
+ var entry = { method: op && op.method, status: status };
493
+ if (op && op.bulkId) entry.bulkId = op.bulkId;
494
+ if (location) entry.location = location;
495
+ // Per §3.7 the response body is OPTIONAL; include it when the adapter
496
+ // returned a representation (omit on 204 No Content).
497
+ if (response !== null && response !== undefined) entry.response = response;
498
+ return { entry: entry, isError: false };
499
+ }
500
+
501
+ function _bulkErr(op, status, scimType, detail) {
502
+ var errBody = { schemas: [SCIM_MESSAGE_ERROR], status: status, detail: detail };
503
+ if (scimType) errBody.scimType = scimType;
504
+ var entry = { method: op && op.method, status: status, response: errBody };
505
+ if (op && op.bulkId) entry.bulkId = op.bulkId;
506
+ return { entry: entry, isError: true };
507
+ }
508
+
509
+ function _readBulkBody(req, maxBytes) {
510
+ if (req.body && Buffer.isBuffer(req.body)) {
511
+ return Promise.resolve(safeJson.parse(req.body.toString("utf8"), { maxBytes: maxBytes }));
512
+ }
513
+ if (req.body && typeof req.body === "object") return Promise.resolve(req.body);
514
+ return safeBuffer.collectStream(req, {
515
+ maxBytes: maxBytes,
516
+ errorClass: ScimServerError,
517
+ sizeCode: "middleware/scim-server/bulk-too-large",
518
+ }).then(function (buf) {
519
+ return safeJson.parse(buf.toString("utf8"), { maxBytes: maxBytes });
520
+ });
521
+ }
522
+
247
523
  function _parseQuery(qs) {
248
524
  var out = {};
249
525
  if (!qs) return out;
@@ -321,11 +597,17 @@ function _writeScimError(res, status, scimType, detail) {
321
597
  }
322
598
 
323
599
  function _serviceProviderConfig(opts) {
600
+ // RFC 7644 §3.7 — advertise bulk only when the operator opted in via
601
+ // opts.bulk; otherwise report it unsupported (back-compat default).
602
+ var bulkCfg = _resolveBulkConfig(opts.bulk);
603
+ var bulk = bulkCfg
604
+ ? { supported: true, maxOperations: bulkCfg.maxOperations, maxPayloadSize: bulkCfg.maxPayloadSize }
605
+ : { supported: false, maxOperations: 0, maxPayloadSize: 0 };
324
606
  return {
325
607
  schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
326
608
  documentationUri: opts.documentationUri || "https://datatracker.ietf.org/doc/html/rfc7643",
327
609
  patch: { supported: true },
328
- bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
610
+ bulk: bulk,
329
611
  filter: { supported: true, maxResults: opts.maxPageSize || 200 },
330
612
  changePassword: { supported: false },
331
613
  sort: { supported: true },
@@ -370,9 +652,11 @@ function _schemas() {
370
652
  }
371
653
 
372
654
  module.exports = {
373
- create: create,
374
- ScimServerError: ScimServerError,
375
- SCIM_CORE_SCHEMA_USER: SCIM_CORE_SCHEMA_USER,
376
- SCIM_CORE_SCHEMA_GROUP: SCIM_CORE_SCHEMA_GROUP,
377
- ALLOWED_FILTER_OPS: ALLOWED_FILTER_OPS,
655
+ create: create,
656
+ ScimServerError: ScimServerError,
657
+ SCIM_CORE_SCHEMA_USER: SCIM_CORE_SCHEMA_USER,
658
+ SCIM_CORE_SCHEMA_GROUP: SCIM_CORE_SCHEMA_GROUP,
659
+ SCIM_MESSAGE_BULK_REQUEST: SCIM_MESSAGE_BULK_REQUEST,
660
+ SCIM_MESSAGE_BULK_RESPONSE: SCIM_MESSAGE_BULK_RESPONSE,
661
+ ALLOWED_FILTER_OPS: ALLOWED_FILTER_OPS,
378
662
  };
@@ -34,6 +34,18 @@
34
34
  * dnsPrefetchControl: 'off' (default) or 'on' or false
35
35
  * csp: '<full CSP string>' or false to disable
36
36
  * }
37
+ *
38
+ * Monitor-mode opt-ins (all default-off; unset emits no new header):
39
+ *
40
+ * coopReportOnly / coepReportOnly / documentPolicyReportOnly — set a
41
+ * policy string to emit the matching `*-Report-Only` header so the
42
+ * operator can roll out the enforcing policy in monitor mode first.
43
+ * The browser reports violations (to a Reporting-Endpoints group named
44
+ * in the value, e.g. `same-origin; report-to="coop"`) without blocking.
45
+ * requireDocumentPolicy — the embedder-required Document-Policy a
46
+ * subframe must advertise before this document will embed it.
47
+ * serviceWorkerAllowed — broadens the max scope a service worker
48
+ * registered from this script may claim (the operator opts in).
37
49
  */
38
50
 
39
51
  var requestHelpers = require("../request-helpers");
@@ -186,6 +198,11 @@ function _validatePermissionsPolicy(value) {
186
198
  * criticalCh: string|false,
187
199
  * reportingEndpoints: object,
188
200
  * trustProxy: boolean|number,
201
+ * coopReportOnly: string, // default: off — monitor-mode COOP
202
+ * coepReportOnly: string, // default: off — monitor-mode COEP
203
+ * documentPolicyReportOnly: string, // default: off — monitor-mode Document-Policy
204
+ * requireDocumentPolicy: string, // default: off — embedder-required subframe policy
205
+ * serviceWorkerAllowed: string, // default: off — broadens SW registration scope
189
206
  * }
190
207
  *
191
208
  * @example
@@ -202,6 +219,8 @@ function create(opts) {
202
219
  "permissionsPolicy", "coop", "coep", "corp",
203
220
  "originAgentCluster", "dnsPrefetchControl", "csp", "trustProxy",
204
221
  "reportingEndpoints", "documentPolicy", "criticalCh", "acceptCh",
222
+ "coopReportOnly", "coepReportOnly", "documentPolicyReportOnly",
223
+ "requireDocumentPolicy", "serviceWorkerAllowed",
205
224
  ], "middleware.securityHeaders");
206
225
  if (opts.permissionsPolicy && typeof opts.permissionsPolicy === "string") {
207
226
  _validatePermissionsPolicy(opts.permissionsPolicy);
@@ -222,6 +241,26 @@ function create(opts) {
222
241
  var docPolicy = opts.documentPolicy === undefined ? DEFAULT_DOCUMENT_POLICY : opts.documentPolicy;
223
242
  var criticalCh = opts.criticalCh && typeof opts.criticalCh === "string" ? opts.criticalCh : false;
224
243
  var acceptCh = opts.acceptCh && typeof opts.acceptCh === "string" ? opts.acceptCh : false;
244
+ // Monitor-mode + scope opt-ins — all default-off. Each only emits its
245
+ // header when the operator passes a non-empty string; unset = silent.
246
+ // coopReportOnly / coepReportOnly — WHATWG HTML cross-origin isolation
247
+ // report-only variants: the UA evaluates the policy and reports
248
+ // violations to the named Reporting-Endpoints group without
249
+ // enforcing, so an operator can verify a same-origin / require-corp
250
+ // rollout won't break embeds before flipping the enforcing header.
251
+ // documentPolicyReportOnly — W3C Document Policy report-only variant
252
+ // (same monitor-mode semantics for the Document-Policy feature set).
253
+ // requireDocumentPolicy — W3C Document Policy: the policy a subframe
254
+ // must itself advertise (via Document-Policy) before this document
255
+ // will embed it; the embedder declares its floor.
256
+ // serviceWorkerAllowed — W3C Service Workers §Service-Worker-Allowed:
257
+ // widens the max scope a worker registered from this script may
258
+ // claim beyond the script's own path. Operator opts in explicitly.
259
+ var coopReportOnly = opts.coopReportOnly && typeof opts.coopReportOnly === "string" ? opts.coopReportOnly : false;
260
+ var coepReportOnly = opts.coepReportOnly && typeof opts.coepReportOnly === "string" ? opts.coepReportOnly : false;
261
+ var docPolicyReportOnly = opts.documentPolicyReportOnly && typeof opts.documentPolicyReportOnly === "string" ? opts.documentPolicyReportOnly : false;
262
+ var requireDocPolicy = opts.requireDocumentPolicy && typeof opts.requireDocumentPolicy === "string" ? opts.requireDocumentPolicy : false;
263
+ var serviceWorkerAllowed = opts.serviceWorkerAllowed && typeof opts.serviceWorkerAllowed === "string" ? opts.serviceWorkerAllowed : false;
225
264
  // Reporting-Endpoints (W3C Reporting API) — when operator passes a
226
265
  // map of endpoint-name → URL, we emit `Reporting-Endpoints: name="url",
227
266
  // name2="url2", ...` and (when default CSP is in force) append
@@ -273,6 +312,14 @@ function create(opts) {
273
312
  if (acceptCh) res.setHeader("Accept-CH", acceptCh);
274
313
  if (criticalCh) res.setHeader("Critical-CH", criticalCh);
275
314
  if (reportingEndpoints) res.setHeader("Reporting-Endpoints", reportingEndpoints);
315
+ // Monitor-mode + scope opt-ins — emitted only when the operator set
316
+ // the corresponding opt; the enforcing COOP/COEP/Document-Policy
317
+ // headers above are unaffected.
318
+ if (coopReportOnly) res.setHeader("Cross-Origin-Opener-Policy-Report-Only", coopReportOnly);
319
+ if (coepReportOnly) res.setHeader("Cross-Origin-Embedder-Policy-Report-Only", coepReportOnly);
320
+ if (docPolicyReportOnly) res.setHeader("Document-Policy-Report-Only", docPolicyReportOnly);
321
+ if (requireDocPolicy) res.setHeader("Require-Document-Policy", requireDocPolicy);
322
+ if (serviceWorkerAllowed) res.setHeader("Service-Worker-Allowed", serviceWorkerAllowed);
276
323
  next();
277
324
  };
278
325
  }