@blamejs/core 0.14.18 → 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.
@@ -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
  };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * OpenAPI 3.1 — paths / operations builder.
3
+ * OpenAPI 3.1 / 3.2 — paths / operations + webhooks builder.
4
4
  *
5
5
  * Internal to lib/openapi.js. Holds the per-path operation table
6
6
  * (method to operationObject) and produces the final `paths` map used
@@ -13,6 +13,14 @@
13
13
  *
14
14
  * Operation methods accepted: get / put / post / delete / options /
15
15
  * head / patch / trace (RFC 9110 + OpenAPI 3.1 §4.8.5).
16
+ *
17
+ * `WebhooksBuilder` shares the same Operation Object normalisation but
18
+ * keys by a free-form webhook NAME (not a URL pattern): the top-level
19
+ * `webhooks` field is a map of named Path Item Objects describing
20
+ * out-of-band requests the API initiates (OpenAPI 3.2 §4.8.2, "Fixed
21
+ * Fields" — `webhooks`; carried forward unchanged from 3.1.0 §4.1).
22
+ * Webhook keys are not URL templates, so the `/`-prefix and
23
+ * path-template-placeholder checks are intentionally not applied.
16
24
  */
17
25
 
18
26
  var validateOpts = require("./validate-opts");
@@ -39,25 +47,23 @@ function PathsBuilder() {
39
47
  this._paths = {};
40
48
  }
41
49
 
42
- PathsBuilder.prototype.add = function (method, urlPattern, opts) {
43
- opts = opts || {};
50
+ // _buildOperation normalise a single Operation Object from operator
51
+ // opts. Shared by PathsBuilder.add and WebhooksBuilder.add. `label` is
52
+ // the caller-facing prefix used in error messages. `declaredPathParams`
53
+ // (out-param object) records every in=path parameter so the caller can
54
+ // verify path-template placeholders against it; webhooks have no URL
55
+ // template so the caller simply ignores it.
56
+ function _buildOperation(method, opts, label, declaredPathParams) {
44
57
  if (typeof method !== "string" || VALID_METHODS.indexOf(method.toLowerCase()) === -1) {
45
58
  throw new OpenApiError("openapi/bad-method",
46
- "paths.add: method must be one of " + VALID_METHODS.join(", ") +
59
+ label + ": method must be one of " + VALID_METHODS.join(", ") +
47
60
  " - got " + JSON.stringify(method));
48
61
  }
49
- validateOpts.requireNonEmptyString(urlPattern, "paths.add: urlPattern",
50
- OpenApiError, "openapi/bad-path");
51
- if (urlPattern.charAt(0) !== "/") {
52
- throw new OpenApiError("openapi/bad-path",
53
- "paths.add: urlPattern must start with '/' - got " +
54
- JSON.stringify(urlPattern));
55
- }
56
62
  validateOpts(opts, [
57
63
  "summary", "description", "operationId", "tags",
58
64
  "parameters", "requestBody", "responses",
59
65
  "security", "deprecated", "servers", "externalDocs",
60
- ], "paths.add");
66
+ ], label);
61
67
 
62
68
  var op = {};
63
69
  if (typeof opts.summary === "string") op.summary = opts.summary;
@@ -67,50 +73,65 @@ PathsBuilder.prototype.add = function (method, urlPattern, opts) {
67
73
  op.tags = opts.tags.map(function (t) {
68
74
  if (typeof t !== "string" || t.length === 0) {
69
75
  throw new OpenApiError("openapi/bad-tag",
70
- "paths.add: tags must be non-empty strings");
76
+ label + ": tags must be non-empty strings");
71
77
  }
72
78
  return t;
73
79
  });
74
80
  }
75
81
 
76
82
  // Parameters
77
- var declaredPathParams = Object.create(null);
78
83
  if (Array.isArray(opts.parameters)) {
79
84
  op.parameters = [];
80
85
  for (var i = 0; i < opts.parameters.length; i += 1) {
81
- var p = _normaliseParameter(opts.parameters[i], "paths.add: parameters[" + i + "]");
86
+ var p = _normaliseParameter(opts.parameters[i], label + ": parameters[" + i + "]");
82
87
  op.parameters.push(p);
83
88
  if (p.in === "path") declaredPathParams[p.name] = true;
84
89
  }
85
90
  }
86
- // Verify every {placeholder} in path is declared.
87
- var placeholders = _extractPathParams(urlPattern);
88
- for (var j = 0; j < placeholders.length; j += 1) {
89
- if (!declaredPathParams[placeholders[j]]) {
90
- throw new OpenApiError("openapi/missing-path-param",
91
- "paths.add: path template " + JSON.stringify(urlPattern) +
92
- " references {" + placeholders[j] +
93
- "} but no parameter with in=path name=" + JSON.stringify(placeholders[j]) +
94
- " was declared");
95
- }
96
- }
97
91
 
98
92
  // Request body
99
93
  if (opts.requestBody) {
100
- op.requestBody = _normaliseRequestBody(opts.requestBody, "paths.add: requestBody");
94
+ op.requestBody = _normaliseRequestBody(opts.requestBody, label + ": requestBody");
101
95
  }
102
96
 
103
97
  // Responses (required)
104
98
  if (!opts.responses || typeof opts.responses !== "object") {
105
99
  throw new OpenApiError("openapi/missing-responses",
106
- "paths.add: responses object is required (per OpenAPI 3.1 §4.8.5)");
100
+ label + ": responses object is required (per OpenAPI 3.1 §4.8.5)");
107
101
  }
108
- op.responses = _normaliseResponses(opts.responses, "paths.add: responses");
102
+ op.responses = _normaliseResponses(opts.responses, label + ": responses");
109
103
 
110
104
  if (Array.isArray(opts.security)) op.security = opts.security.slice();
111
105
  if (opts.deprecated === true) op.deprecated = true;
112
106
  if (Array.isArray(opts.servers)) op.servers = opts.servers.slice();
113
107
  if (opts.externalDocs) op.externalDocs = opts.externalDocs;
108
+ return op;
109
+ }
110
+
111
+ PathsBuilder.prototype.add = function (method, urlPattern, opts) {
112
+ opts = opts || {};
113
+ validateOpts.requireNonEmptyString(urlPattern, "paths.add: urlPattern",
114
+ OpenApiError, "openapi/bad-path");
115
+ if (urlPattern.charAt(0) !== "/") {
116
+ throw new OpenApiError("openapi/bad-path",
117
+ "paths.add: urlPattern must start with '/' - got " +
118
+ JSON.stringify(urlPattern));
119
+ }
120
+
121
+ var declaredPathParams = Object.create(null);
122
+ var op = _buildOperation(method, opts, "paths.add", declaredPathParams);
123
+
124
+ // Verify every {placeholder} in path is declared.
125
+ var placeholders = _extractPathParams(urlPattern);
126
+ for (var j = 0; j < placeholders.length; j += 1) {
127
+ if (!declaredPathParams[placeholders[j]]) {
128
+ throw new OpenApiError("openapi/missing-path-param",
129
+ "paths.add: path template " + JSON.stringify(urlPattern) +
130
+ " references {" + placeholders[j] +
131
+ "} but no parameter with in=path name=" + JSON.stringify(placeholders[j]) +
132
+ " was declared");
133
+ }
134
+ }
114
135
 
115
136
  if (!this._paths[urlPattern]) this._paths[urlPattern] = {};
116
137
  if (this._paths[urlPattern][method.toLowerCase()]) {
@@ -240,8 +261,63 @@ PathsBuilder.prototype.toMap = function () {
240
261
  return out;
241
262
  };
242
263
 
264
+ // WebhooksBuilder — the top-level `webhooks` field is a map of named
265
+ // Path Item Objects describing requests the API initiates out-of-band
266
+ // (OpenAPI 3.2 §4.8.2 Fixed Fields → `webhooks`; unchanged from
267
+ // 3.1.0 §4.1). Keys are free-form webhook names (e.g. "newPet"), NOT
268
+ // URL templates — no `/`-prefix and no path-template-placeholder
269
+ // validation. Each named entry holds the same Operation Objects as a
270
+ // regular path item.
271
+ function WebhooksBuilder() {
272
+ // Null-prototype map so a free-form webhook name that collides with an
273
+ // Object.prototype member (__proto__ / constructor / prototype) becomes
274
+ // an own property instead of mutating the prototype (CWE-1321).
275
+ this._webhooks = Object.create(null);
276
+ }
277
+
278
+ WebhooksBuilder.prototype.add = function (name, method, opts) {
279
+ opts = opts || {};
280
+ validateOpts.requireNonEmptyString(name, "webhook.add: name",
281
+ OpenApiError, "openapi/bad-webhook");
282
+ if (name === "__proto__" || name === "constructor" || name === "prototype") {
283
+ throw new OpenApiError("openapi/bad-webhook",
284
+ "webhook.add: name must not be a reserved object key (" + JSON.stringify(name) + ")");
285
+ }
286
+ var declaredPathParams = Object.create(null);
287
+ var op = _buildOperation(method, opts, "webhook.add", declaredPathParams);
288
+ if (!this._webhooks[name]) this._webhooks[name] = {};
289
+ if (this._webhooks[name][method.toLowerCase()]) {
290
+ throw new OpenApiError("openapi/duplicate-operation",
291
+ "webhook.add: duplicate operation " + method.toUpperCase() +
292
+ " on webhook " + JSON.stringify(name));
293
+ }
294
+ this._webhooks[name][method.toLowerCase()] = op;
295
+ return op;
296
+ };
297
+
298
+ WebhooksBuilder.prototype.count = function () {
299
+ return Object.keys(this._webhooks).length;
300
+ };
301
+
302
+ WebhooksBuilder.prototype.toMap = function () {
303
+ var sorted = Object.keys(this._webhooks).sort();
304
+ var out = {};
305
+ for (var i = 0; i < sorted.length; i += 1) {
306
+ var name = sorted[i];
307
+ var item = this._webhooks[name];
308
+ var ordered = {};
309
+ for (var j = 0; j < VALID_METHODS.length; j += 1) {
310
+ var method = VALID_METHODS[j];
311
+ if (item[method]) ordered[method] = item[method];
312
+ }
313
+ out[name] = ordered;
314
+ }
315
+ return out;
316
+ };
317
+
243
318
  module.exports = {
244
319
  PathsBuilder: PathsBuilder,
320
+ WebhooksBuilder: WebhooksBuilder,
245
321
  VALID_METHODS: VALID_METHODS,
246
322
  _extractPathParams: _extractPathParams,
247
323
  OpenApiError: OpenApiError,