@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.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/lib/agent-orchestrator.js +10 -4
- package/lib/ai-prompt.js +1 -1
- package/lib/app-shutdown.js +28 -0
- package/lib/archive-read.js +215 -16
- package/lib/archive.js +206 -52
- package/lib/auth/oauth.js +58 -0
- package/lib/auth/oid4vci.js +84 -27
- package/lib/breach-deadline.js +166 -1
- package/lib/cloud-events.js +3 -1
- package/lib/codepoint-class.js +21 -0
- package/lib/db-schema.js +120 -3
- package/lib/db.js +10 -3
- package/lib/error-page.js +88 -8
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/mail-auth.js +554 -55
- package/lib/mail-send-deliver.js +15 -5
- package/lib/mail-sieve.js +2 -1
- package/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/middleware/attach-user.js +45 -10
- package/lib/middleware/body-parser.js +70 -14
- package/lib/middleware/csp-report.js +30 -2
- package/lib/middleware/deny-response.js +22 -7
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +301 -14
- package/lib/openapi-paths-builder.js +105 -29
- package/lib/openapi.js +225 -100
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/safe-buffer.js +55 -0
- package/lib/static.js +46 -17
- package/lib/uri-template.js +3 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
|
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
|
|
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)
|
|
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 (
|
|
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
|
|
23
|
-
var SCIM_MESSAGE_LIST
|
|
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
|
-
|
|
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.
|
|
283
|
-
|
|
284
|
-
|
|
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:
|
|
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:
|
|
371
|
-
ScimServerError:
|
|
372
|
-
SCIM_CORE_SCHEMA_USER:
|
|
373
|
-
SCIM_CORE_SCHEMA_GROUP:
|
|
374
|
-
|
|
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
|
};
|