@daloyjs/core 0.35.2 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +22 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +223 -1
  8. package/dist/app.js +358 -8
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
package/dist/mtls.js ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Mutual-TLS / client-certificate authentication.
3
+ *
4
+ * The Web-standard {@link Request} does not surface the TLS peer certificate,
5
+ * so — exactly like {@link "./conn-info.js"} does for the peer IP — adapters
6
+ * (or a trusted TLS-terminating reverse proxy) attach a normalized
7
+ * {@link ClientCertificate} to the request, and the {@link clientCertAuth}
8
+ * middleware reads it back to enforce a client-certificate identity for
9
+ * zero-trust / service-to-service deployments.
10
+ *
11
+ * Two population paths are supported, both runtime-portable and dependency-free:
12
+ *
13
+ * 1. **Native TLS** — when the runtime terminates TLS itself (the Node adapter
14
+ * reads `tls.TLSSocket#getPeerCertificate()`), the adapter stashes a lazy
15
+ * thunk via {@link setClientCertificate}; the certificate is only normalized
16
+ * if a `clientCertAuth()`-guarded route actually reads it, so plain requests
17
+ * pay nothing.
18
+ * 2. **Forwarded by a trusted proxy** — when TLS is terminated upstream
19
+ * (Envoy, nginx, HAProxy, Traefik, a cloud load balancer), the proxy forwards
20
+ * the verified client identity in request headers. {@link clientCertAuth}
21
+ * can parse Envoy's `X-Forwarded-Client-Cert` (XFCC) or a set of operator-named
22
+ * structured headers (nginx `$ssl_client_*`, etc.). Because those headers are
23
+ * spoofable by anything that can reach the app directly, the header path is
24
+ * opt-in and must be paired with a `behindProxy` posture that guarantees the
25
+ * app is only reachable through the terminating proxy.
26
+ *
27
+ * @module
28
+ * @since 0.37.0
29
+ */
30
+ import { ForbiddenError } from "./errors.js";
31
+ import { timingSafeEqual } from "./security.js";
32
+ const CLIENT_CERT_SYMBOL = Symbol.for("daloyjs.clientCertificate");
33
+ /**
34
+ * @internal Adapter helper — attach a {@link ClientCertificate} (or a lazy
35
+ * thunk producing one) to a `Request`. Mirrors {@link "./conn-info.js".setConnInfo}.
36
+ * Pass a thunk to defer the read until {@link getClientCertificate} is first
37
+ * called; the resolved value is cached back onto the request.
38
+ *
39
+ * @since 0.37.0
40
+ */
41
+ export function setClientCertificate(request, source) {
42
+ request[CLIENT_CERT_SYMBOL] = source;
43
+ }
44
+ /**
45
+ * Read the {@link ClientCertificate} an adapter attached to this request, or
46
+ * `undefined` when the connection presented no client certificate (or the
47
+ * adapter does not expose TLS peer info). If a lazy thunk was stashed, it is
48
+ * resolved once and the result cached.
49
+ *
50
+ * @since 0.37.0
51
+ */
52
+ export function getClientCertificate(request) {
53
+ const store = request;
54
+ const source = store[CLIENT_CERT_SYMBOL];
55
+ if (typeof source === "function") {
56
+ const resolved = source();
57
+ store[CLIENT_CERT_SYMBOL] = resolved;
58
+ return resolved;
59
+ }
60
+ return source;
61
+ }
62
+ /**
63
+ * Normalize a Node `getPeerCertificate(true)` result into a
64
+ * {@link ClientCertificate}. Returns `undefined` for the empty object Node
65
+ * returns when the peer presented no certificate.
66
+ *
67
+ * @param raw - The structured peer-certificate object from the TLS socket.
68
+ * @param verified - Whether the socket reported `authorized === true` (the
69
+ * chain was verified against the configured CA).
70
+ * @since 0.37.0
71
+ */
72
+ export function normalizePeerCertificate(raw, verified) {
73
+ if (!raw || typeof raw !== "object")
74
+ return undefined;
75
+ const subjectDN = dnFromRecord(raw.subject);
76
+ const issuerDN = dnFromRecord(raw.issuer);
77
+ const hasAnyField = subjectDN !== undefined ||
78
+ issuerDN !== undefined ||
79
+ raw.fingerprint256 !== undefined ||
80
+ raw.serialNumber !== undefined;
81
+ if (!hasAnyField)
82
+ return undefined;
83
+ return {
84
+ subjectDN,
85
+ subjectCN: cnFromRecord(raw.subject),
86
+ issuerDN,
87
+ issuerCN: cnFromRecord(raw.issuer),
88
+ serialNumber: raw.serialNumber,
89
+ fingerprint256: normalizeFingerprint(raw.fingerprint256),
90
+ subjectAltNames: parseNodeSubjectAltName(raw.subjectaltname),
91
+ notBefore: parseCertDate(raw.valid_from),
92
+ notAfter: parseCertDate(raw.valid_to),
93
+ verified,
94
+ };
95
+ }
96
+ function dnFromRecord(rec) {
97
+ if (!rec || typeof rec !== "object")
98
+ return undefined;
99
+ const parts = [];
100
+ for (const key of Object.keys(rec)) {
101
+ const value = rec[key];
102
+ if (Array.isArray(value)) {
103
+ for (const v of value)
104
+ parts.push(`${key}=${v}`);
105
+ }
106
+ else if (value !== undefined) {
107
+ parts.push(`${key}=${value}`);
108
+ }
109
+ }
110
+ return parts.length > 0 ? parts.join(",") : undefined;
111
+ }
112
+ function cnFromRecord(rec) {
113
+ if (!rec || typeof rec !== "object")
114
+ return undefined;
115
+ const cn = rec["CN"];
116
+ if (Array.isArray(cn))
117
+ return cn[0];
118
+ return cn;
119
+ }
120
+ function normalizeFingerprint(fp) {
121
+ if (typeof fp !== "string" || fp.length === 0)
122
+ return undefined;
123
+ // Strip colon separators and uppercase so XFCC `Hash=` (no separators) and
124
+ // Node `fingerprint256` (colon-delimited) compare identically.
125
+ let out = "";
126
+ for (let i = 0; i < fp.length; i++) {
127
+ const c = fp.charCodeAt(i);
128
+ if (c === 58 /* ':' */ || c === 32 /* space */)
129
+ continue;
130
+ out += fp[i];
131
+ }
132
+ return out.toUpperCase();
133
+ }
134
+ function parseCertDate(value) {
135
+ if (typeof value !== "string" || value.length === 0)
136
+ return undefined;
137
+ const ms = Date.parse(value);
138
+ return Number.isNaN(ms) ? undefined : new Date(ms);
139
+ }
140
+ function parseNodeSubjectAltName(san) {
141
+ if (typeof san !== "string" || san.length === 0)
142
+ return [];
143
+ // Node renders SANs as `DNS:a, IP Address:1.2.3.4, URI:spiffe://...`.
144
+ const out = [];
145
+ for (const piece of san.split(",")) {
146
+ const trimmed = piece.trim();
147
+ if (trimmed.length === 0)
148
+ continue;
149
+ out.push(trimmed.replace(/^IP Address:/i, "IP:"));
150
+ }
151
+ return out;
152
+ }
153
+ /**
154
+ * Split a string on a single-character separator while ignoring separators that
155
+ * appear inside double-quoted spans. No backtracking — single linear scan.
156
+ *
157
+ * @internal
158
+ */
159
+ function splitRespectingQuotes(value, sep) {
160
+ const out = [];
161
+ let current = "";
162
+ let inQuotes = false;
163
+ for (let i = 0; i < value.length; i++) {
164
+ const ch = value[i];
165
+ if (ch === '"') {
166
+ inQuotes = !inQuotes;
167
+ current += ch;
168
+ }
169
+ else if (ch === sep && !inQuotes) {
170
+ out.push(current);
171
+ current = "";
172
+ }
173
+ else {
174
+ current += ch;
175
+ }
176
+ }
177
+ out.push(current);
178
+ return out;
179
+ }
180
+ function unquote(value) {
181
+ const trimmed = value.trim();
182
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
183
+ return trimmed.slice(1, -1);
184
+ }
185
+ return trimmed;
186
+ }
187
+ function cnFromDN(dn) {
188
+ if (!dn)
189
+ return undefined;
190
+ for (const rdn of splitRespectingQuotes(dn, ",")) {
191
+ const eq = rdn.indexOf("=");
192
+ if (eq === -1)
193
+ continue;
194
+ const key = rdn.slice(0, eq).trim();
195
+ if (key.toUpperCase() === "CN")
196
+ return unquote(rdn.slice(eq + 1));
197
+ }
198
+ return undefined;
199
+ }
200
+ /**
201
+ * Parse an Envoy `X-Forwarded-Client-Cert` (XFCC) header value into a
202
+ * {@link ClientCertificate}. XFCC is a comma-separated list of proxy elements,
203
+ * each a `;`-delimited set of `Key=Value` pairs (`Hash`, `Subject`, `URI`,
204
+ * `DNS`, `Cert`, …). The **first** element is the client closest to the origin
205
+ * and is the one returned. Because Envoy only emits XFCC for connections it
206
+ * mutually authenticated, the result is marked `verified: true`.
207
+ *
208
+ * Returns `undefined` for an empty or unparseable header.
209
+ *
210
+ * @since 0.37.0
211
+ */
212
+ export function parseForwardedClientCert(headerValue) {
213
+ if (typeof headerValue !== "string" || headerValue.trim().length === 0) {
214
+ return undefined;
215
+ }
216
+ const firstElement = splitRespectingQuotes(headerValue, ",")[0];
217
+ if (!firstElement || firstElement.trim().length === 0)
218
+ return undefined;
219
+ let subjectDN;
220
+ let fingerprint256;
221
+ let serialNumber;
222
+ let pem;
223
+ const sans = [];
224
+ for (const pair of splitRespectingQuotes(firstElement, ";")) {
225
+ const eq = pair.indexOf("=");
226
+ if (eq === -1)
227
+ continue;
228
+ const key = pair.slice(0, eq).trim().toLowerCase();
229
+ const value = unquote(pair.slice(eq + 1));
230
+ if (value.length === 0)
231
+ continue;
232
+ switch (key) {
233
+ case "subject":
234
+ subjectDN = value;
235
+ break;
236
+ case "hash":
237
+ fingerprint256 = normalizeFingerprint(value);
238
+ break;
239
+ case "serial":
240
+ serialNumber = value;
241
+ break;
242
+ case "dns":
243
+ sans.push(`DNS:${value}`);
244
+ break;
245
+ case "uri":
246
+ sans.push(`URI:${value}`);
247
+ break;
248
+ case "cert":
249
+ pem = decodeURIComponentSafe(value);
250
+ break;
251
+ default:
252
+ break;
253
+ }
254
+ }
255
+ if (subjectDN === undefined &&
256
+ fingerprint256 === undefined &&
257
+ sans.length === 0 &&
258
+ pem === undefined) {
259
+ return undefined;
260
+ }
261
+ return {
262
+ subjectDN,
263
+ subjectCN: cnFromDN(subjectDN),
264
+ serialNumber,
265
+ fingerprint256,
266
+ subjectAltNames: sans,
267
+ verified: true,
268
+ pem,
269
+ };
270
+ }
271
+ function decodeURIComponentSafe(value) {
272
+ try {
273
+ return decodeURIComponent(value);
274
+ }
275
+ catch {
276
+ return value;
277
+ }
278
+ }
279
+ const MISSING_CERT_BODY = JSON.stringify({
280
+ type: "https://daloyjs.dev/errors/client-certificate-required",
281
+ title: "Client certificate required",
282
+ status: 401,
283
+ });
284
+ /**
285
+ * Middleware enforcing mutual-TLS client-certificate authentication. Reads the
286
+ * normalized {@link ClientCertificate} attached by the adapter (native TLS) or
287
+ * parsed from a trusted-proxy header, enforces verification + optional
288
+ * allow-lists + validity window + a custom hook, and stamps the accepted
289
+ * certificate on `ctx.state` for downstream handlers.
290
+ *
291
+ * Rejection semantics:
292
+ * - **No certificate presented** → `401` `application/problem+json` with
293
+ * `Cache-Control: no-store`.
294
+ * - **Unverified / not allow-listed / expired / custom-rejected** → `403`
295
+ * {@link ForbiddenError} (never echoes certificate details).
296
+ *
297
+ * @example Native TLS (Node adapter terminates mTLS):
298
+ * ```ts
299
+ * app.use(clientCertAuth({
300
+ * allowIssuerCNs: ["acme-internal-ca"],
301
+ * allowSANs: ["URI:spiffe://acme/svc-a"],
302
+ * }));
303
+ * ```
304
+ *
305
+ * @example Behind an Envoy proxy forwarding XFCC:
306
+ * ```ts
307
+ * app.use(clientCertAuth({
308
+ * header: { format: "xfcc" },
309
+ * allowFingerprints: [process.env.PEER_FINGERPRINT!],
310
+ * }));
311
+ * ```
312
+ *
313
+ * @param opts - Verification, allow-list, header-source, and hook options.
314
+ * @returns A {@link Hooks} bundle for `app.use(...)`.
315
+ * @since 0.37.0
316
+ */
317
+ export function clientCertAuth(opts = {}) {
318
+ const requireVerified = opts.requireVerified !== false;
319
+ const checkValidity = opts.checkValidity !== false;
320
+ const message = opts.message ?? "Client certificate not permitted";
321
+ const stateKey = opts.stateKey ?? "clientCertificate";
322
+ const now = opts.now ?? Date.now;
323
+ const allowFingerprints = (opts.allowFingerprints ?? []).map((f) => normalizeFingerprint(f) ?? "");
324
+ const allowSubjectCNs = opts.allowSubjectCNs;
325
+ const allowIssuerCNs = opts.allowIssuerCNs;
326
+ const allowSANs = opts.allowSANs;
327
+ const headerConfig = opts.header;
328
+ if (headerConfig !== undefined) {
329
+ assertHeaderConfig(headerConfig);
330
+ }
331
+ const resolve = opts.resolve ??
332
+ ((ctx) => {
333
+ const native = getClientCertificate(ctx.request);
334
+ if (native)
335
+ return native;
336
+ if (headerConfig)
337
+ return certFromHeaders(ctx.request, headerConfig);
338
+ return undefined;
339
+ });
340
+ return {
341
+ async beforeHandle(ctx) {
342
+ const cert = resolve(ctx);
343
+ if (!cert) {
344
+ return new Response(MISSING_CERT_BODY, {
345
+ status: 401,
346
+ headers: {
347
+ "content-type": "application/problem+json",
348
+ "cache-control": "no-store",
349
+ },
350
+ });
351
+ }
352
+ if (requireVerified && !cert.verified) {
353
+ throw new ForbiddenError(message);
354
+ }
355
+ if (checkValidity && !isWithinValidity(cert, now())) {
356
+ throw new ForbiddenError(message);
357
+ }
358
+ if (allowSubjectCNs && !matchesAllowedCN(cert.subjectCN, allowSubjectCNs)) {
359
+ throw new ForbiddenError(message);
360
+ }
361
+ if (allowIssuerCNs && !matchesAllowedCN(cert.issuerCN, allowIssuerCNs)) {
362
+ throw new ForbiddenError(message);
363
+ }
364
+ if (allowFingerprints.length > 0 && !matchesFingerprint(cert, allowFingerprints)) {
365
+ throw new ForbiddenError(message);
366
+ }
367
+ if (allowSANs && !matchesSAN(cert.subjectAltNames, allowSANs)) {
368
+ throw new ForbiddenError(message);
369
+ }
370
+ if (opts.verify) {
371
+ const ok = await opts.verify(cert, ctx);
372
+ if (ok === false)
373
+ throw new ForbiddenError(message);
374
+ }
375
+ ctx.state[stateKey] = cert;
376
+ return undefined;
377
+ },
378
+ };
379
+ }
380
+ function assertHeaderConfig(cfg) {
381
+ if (cfg.format === "xfcc")
382
+ return;
383
+ if (cfg.format === "structured") {
384
+ if (!cfg.subjectDN &&
385
+ !cfg.fingerprint &&
386
+ !cfg.san &&
387
+ !cfg.serialNumber &&
388
+ !cfg.issuerDN) {
389
+ throw new Error("clientCertAuth(): structured header config must name at least one of subjectDN/issuerDN/fingerprint/serialNumber/san.");
390
+ }
391
+ return;
392
+ }
393
+ throw new Error('clientCertAuth(): header.format must be "xfcc" or "structured".');
394
+ }
395
+ function certFromHeaders(request, cfg) {
396
+ if (cfg.format === "xfcc") {
397
+ const name = cfg.name ?? "x-forwarded-client-cert";
398
+ return parseForwardedClientCert(request.headers.get(name));
399
+ }
400
+ const subjectDN = readHeader(request, cfg.subjectDN);
401
+ const issuerDN = readHeader(request, cfg.issuerDN);
402
+ const fingerprint = normalizeFingerprint(readHeader(request, cfg.fingerprint));
403
+ const serialNumber = readHeader(request, cfg.serialNumber);
404
+ const sanRaw = readHeader(request, cfg.san);
405
+ const verifyRaw = cfg.verify ? readHeader(request, cfg.verify) : undefined;
406
+ const successValue = (cfg.verifySuccessValue ?? "SUCCESS").toLowerCase();
407
+ const verified = cfg.verify === undefined
408
+ ? true
409
+ : (verifyRaw ?? "").toLowerCase() === successValue;
410
+ const sans = [];
411
+ if (sanRaw) {
412
+ for (const piece of sanRaw.split(",")) {
413
+ const trimmed = piece.trim();
414
+ if (trimmed.length > 0)
415
+ sans.push(trimmed);
416
+ }
417
+ }
418
+ if (subjectDN === undefined &&
419
+ issuerDN === undefined &&
420
+ fingerprint === undefined &&
421
+ serialNumber === undefined &&
422
+ sans.length === 0) {
423
+ return undefined;
424
+ }
425
+ return {
426
+ subjectDN,
427
+ subjectCN: cnFromDN(subjectDN),
428
+ issuerDN,
429
+ issuerCN: cnFromDN(issuerDN),
430
+ serialNumber,
431
+ fingerprint256: fingerprint,
432
+ subjectAltNames: sans,
433
+ verified,
434
+ };
435
+ }
436
+ function readHeader(request, name) {
437
+ if (!name)
438
+ return undefined;
439
+ const value = request.headers.get(name);
440
+ if (value === null)
441
+ return undefined;
442
+ const trimmed = value.trim();
443
+ return trimmed.length > 0 ? trimmed : undefined;
444
+ }
445
+ function isWithinValidity(cert, nowMs) {
446
+ if (cert.notBefore && nowMs < cert.notBefore.getTime())
447
+ return false;
448
+ if (cert.notAfter && nowMs > cert.notAfter.getTime())
449
+ return false;
450
+ return true;
451
+ }
452
+ function matchesAllowedCN(cn, allowed) {
453
+ if (!cn)
454
+ return false;
455
+ for (const a of allowed) {
456
+ if (a === cn)
457
+ return true;
458
+ }
459
+ return false;
460
+ }
461
+ function matchesFingerprint(cert, allowed) {
462
+ const fp = cert.fingerprint256;
463
+ if (!fp)
464
+ return false;
465
+ let matched = false;
466
+ // Constant-time per comparison; do not early-return so we don't leak which
467
+ // allow-list entry matched via timing.
468
+ for (const a of allowed) {
469
+ if (timingSafeEqual(fp, a))
470
+ matched = true;
471
+ }
472
+ return matched;
473
+ }
474
+ function matchesSAN(sans, allowed) {
475
+ if (sans.length === 0)
476
+ return false;
477
+ for (const want of allowed) {
478
+ for (const have of sans) {
479
+ if (have === want)
480
+ return true;
481
+ // Allow matching a bare value against the `TYPE:value` form.
482
+ const colon = have.indexOf(":");
483
+ if (colon !== -1 && have.slice(colon + 1) === want)
484
+ return true;
485
+ }
486
+ }
487
+ return false;
488
+ }
package/dist/multipart.js CHANGED
@@ -236,8 +236,8 @@ async function verifyMagicBytes(file, signatures) {
236
236
  /** Shared implementation for the `fileField` overloads above. */
237
237
  export function fileField(options = {}) {
238
238
  const opts = {
239
- format: options.format ?? "binary",
240
239
  ...options,
240
+ format: options.format ?? "binary",
241
241
  };
242
242
  const magicSignatures = normalizeMagicBytesOption(opts.magicBytes, opts.accept);
243
243
  const scriptableImagesEnabled = opts.rejectScriptableImages === undefined
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `@daloyjs/core/openapi-diff` — pure, dependency-free OpenAPI 3.x diffing.
3
+ *
4
+ * Compares two OpenAPI documents (a published *baseline* and a freshly
5
+ * generated *current* spec) and classifies every structural change as either
6
+ * **breaking** (a consumer relying on the baseline could now fail) or
7
+ * **non-breaking** (purely additive / informational). This is the engine
8
+ * behind the `daloy diff` CLI command and the `verify:breaking-changes` CI
9
+ * gate, answering the single question a contract-first framework should make
10
+ * trivial: *"did this change break my published API?"*
11
+ *
12
+ * The implementation walks plain JSON and never imports a schema validator or
13
+ * any runtime dependency, so it can run in any environment that can read two
14
+ * JSON files.
15
+ *
16
+ * @module
17
+ * @since 0.37.0
18
+ */
19
+ /** Severity classification for a single detected change. */
20
+ export type ChangeSeverity = "breaking" | "non-breaking";
21
+ /**
22
+ * A single structural difference between two OpenAPI documents.
23
+ */
24
+ export interface OpenAPIChange {
25
+ /** Whether this change can break an existing consumer. */
26
+ severity: ChangeSeverity;
27
+ /**
28
+ * Stable machine-readable category, e.g. `"operation.removed"`,
29
+ * `"response.removed"`, `"parameter.required.added"`.
30
+ */
31
+ kind: string;
32
+ /** Human-readable pointer, e.g. `"GET /books/{id}"` or `"GET /books → 404"`. */
33
+ location: string;
34
+ /** Short prose describing the change. */
35
+ detail: string;
36
+ }
37
+ /** Structured result of {@link diffOpenAPI}. */
38
+ export interface OpenAPIDiffResult {
39
+ /** Changes that may break an existing consumer. */
40
+ breaking: OpenAPIChange[];
41
+ /** Additive or informational changes that are safe for consumers. */
42
+ nonBreaking: OpenAPIChange[];
43
+ }
44
+ /**
45
+ * Compare a baseline OpenAPI document against a current one and classify the
46
+ * differences. The comparison is intentionally conservative: anything that
47
+ * could cause a request that succeeded against the baseline to fail against
48
+ * the current spec is reported as **breaking**; additive and metadata-only
49
+ * changes are reported as **non-breaking**.
50
+ *
51
+ * Detected breaking changes:
52
+ * - a path or operation (HTTP method) present in the baseline is removed;
53
+ * - a documented response status code is removed from an operation;
54
+ * - a new `required` parameter is added to an existing operation;
55
+ * - an existing optional parameter becomes `required`;
56
+ * - an operation's request body becomes required when it was not.
57
+ *
58
+ * Detected non-breaking changes:
59
+ * - new paths, operations, response codes, or optional parameters;
60
+ * - a parameter is removed (the server no longer reads it);
61
+ * - an operation becomes `deprecated`;
62
+ * - the document `info.version` changes.
63
+ *
64
+ * @param baseline - The previously published OpenAPI document (JSON).
65
+ * @param current - The freshly generated OpenAPI document (JSON).
66
+ * @returns Structured lists of breaking and non-breaking changes.
67
+ * @since 0.37.0
68
+ */
69
+ export declare function diffOpenAPI(baseline: unknown, current: unknown): OpenAPIDiffResult;
70
+ /**
71
+ * Convenience predicate over {@link diffOpenAPI}: `true` when the current
72
+ * document introduces at least one breaking change versus the baseline.
73
+ *
74
+ * @param baseline - The previously published OpenAPI document (JSON).
75
+ * @param current - The freshly generated OpenAPI document (JSON).
76
+ * @returns `true` if any breaking change was detected.
77
+ * @since 0.37.0
78
+ */
79
+ export declare function hasBreakingChanges(baseline: unknown, current: unknown): boolean;