@blamejs/core 0.14.17 → 0.14.19

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.
@@ -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") {
@@ -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);
@@ -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;
@@ -279,10 +555,13 @@ function _readJsonBody(req) {
279
555
  return Promise.resolve(safeJson.parse(req.body.toString("utf8"), { maxBytes: MAX }));
280
556
  }
281
557
  if (req.body && typeof req.body === "object") return Promise.resolve(req.body);
282
- return safeBuffer.boundedChunkCollector(req, MAX, ScimServerError, "middleware/scim-server/body-too-large")
283
- .then(function (buf) {
284
- return safeJson.parse(buf.toString("utf8"), { maxBytes: MAX });
285
- });
558
+ return safeBuffer.collectStream(req, {
559
+ maxBytes: MAX,
560
+ errorClass: ScimServerError,
561
+ sizeCode: "middleware/scim-server/body-too-large",
562
+ }).then(function (buf) {
563
+ return safeJson.parse(buf.toString("utf8"), { maxBytes: MAX });
564
+ });
286
565
  }
287
566
 
288
567
  function _assertSchema(body, expectedSchema) {
@@ -318,11 +597,17 @@ function _writeScimError(res, status, scimType, detail) {
318
597
  }
319
598
 
320
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 };
321
606
  return {
322
607
  schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
323
608
  documentationUri: opts.documentationUri || "https://datatracker.ietf.org/doc/html/rfc7643",
324
609
  patch: { supported: true },
325
- bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
610
+ bulk: bulk,
326
611
  filter: { supported: true, maxResults: opts.maxPageSize || 200 },
327
612
  changePassword: { supported: false },
328
613
  sort: { supported: true },
@@ -367,9 +652,11 @@ function _schemas() {
367
652
  }
368
653
 
369
654
  module.exports = {
370
- create: create,
371
- ScimServerError: ScimServerError,
372
- SCIM_CORE_SCHEMA_USER: SCIM_CORE_SCHEMA_USER,
373
- SCIM_CORE_SCHEMA_GROUP: SCIM_CORE_SCHEMA_GROUP,
374
- 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,
375
662
  };