@nifrajs/core 1.12.0 → 2.0.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 (167) hide show
  1. package/README.md +10 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/effect-ledger.d.ts +9 -0
  7. package/dist/effect-ledger.d.ts.map +1 -0
  8. package/dist/effect-ledger.js +8 -0
  9. package/dist/effect-ledger.js.map +1 -0
  10. package/dist/idempotency-plugin.d.ts +8 -0
  11. package/dist/idempotency-plugin.d.ts.map +1 -0
  12. package/dist/idempotency-plugin.js +8 -0
  13. package/dist/idempotency-plugin.js.map +1 -0
  14. package/dist/index.d.ts +6 -34
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -30
  17. package/dist/index.js.map +1 -1
  18. package/dist/mcp.d.ts +7 -0
  19. package/dist/mcp.d.ts.map +1 -0
  20. package/dist/mcp.js +56 -0
  21. package/dist/mcp.js.map +1 -0
  22. package/dist/node-direct.d.ts +5 -0
  23. package/dist/node-direct.d.ts.map +1 -0
  24. package/dist/node-direct.js +27 -0
  25. package/dist/node-direct.js.map +1 -0
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +2 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/router/pattern.d.ts +42 -0
  31. package/dist/router/pattern.d.ts.map +1 -0
  32. package/dist/router/pattern.js +127 -0
  33. package/dist/router/pattern.js.map +1 -0
  34. package/dist/router/router.d.ts +2 -1
  35. package/dist/router/router.d.ts.map +1 -1
  36. package/dist/router/router.js +11 -48
  37. package/dist/router/router.js.map +1 -1
  38. package/dist/server/client-ip.d.ts +35 -0
  39. package/dist/server/client-ip.d.ts.map +1 -0
  40. package/dist/server/client-ip.js +42 -0
  41. package/dist/server/client-ip.js.map +1 -0
  42. package/dist/server/context.d.ts +47 -2
  43. package/dist/server/context.d.ts.map +1 -1
  44. package/dist/server/contract.d.ts +37 -10
  45. package/dist/server/contract.d.ts.map +1 -1
  46. package/dist/server/contract.js +33 -8
  47. package/dist/server/contract.js.map +1 -1
  48. package/dist/server/http.d.ts +14 -0
  49. package/dist/server/http.d.ts.map +1 -0
  50. package/dist/server/http.js +45 -0
  51. package/dist/server/http.js.map +1 -0
  52. package/dist/server/idempotency-lane.d.ts +44 -0
  53. package/dist/server/idempotency-lane.d.ts.map +1 -0
  54. package/dist/server/idempotency-lane.js +181 -0
  55. package/dist/server/idempotency-lane.js.map +1 -0
  56. package/dist/server/install.d.ts +21 -0
  57. package/dist/server/install.d.ts.map +1 -0
  58. package/dist/server/install.js +21 -0
  59. package/dist/server/install.js.map +1 -0
  60. package/dist/server/ledger-lane.d.ts +53 -0
  61. package/dist/server/ledger-lane.d.ts.map +1 -0
  62. package/dist/server/ledger-lane.js +82 -0
  63. package/dist/server/ledger-lane.js.map +1 -0
  64. package/dist/server/mcp-hook.d.ts +28 -0
  65. package/dist/server/mcp-hook.d.ts.map +1 -0
  66. package/dist/server/mcp-hook.js +2 -0
  67. package/dist/server/mcp-hook.js.map +1 -0
  68. package/dist/server/node-outcome-hook.d.ts +9 -0
  69. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  70. package/dist/server/node-outcome-hook.js +2 -0
  71. package/dist/server/node-outcome-hook.js.map +1 -0
  72. package/dist/server/node-outcome.d.ts +37 -0
  73. package/dist/server/node-outcome.d.ts.map +1 -0
  74. package/dist/server/node-outcome.js +75 -0
  75. package/dist/server/node-outcome.js.map +1 -0
  76. package/dist/server/plugin.d.ts +78 -0
  77. package/dist/server/plugin.d.ts.map +1 -0
  78. package/dist/server/plugin.js +57 -0
  79. package/dist/server/plugin.js.map +1 -0
  80. package/dist/server/query.d.ts +18 -0
  81. package/dist/server/query.d.ts.map +1 -0
  82. package/dist/server/query.js +102 -0
  83. package/dist/server/query.js.map +1 -0
  84. package/dist/server/registry.d.ts +31 -6
  85. package/dist/server/registry.d.ts.map +1 -1
  86. package/dist/server/request-context.d.ts +51 -0
  87. package/dist/server/request-context.d.ts.map +1 -0
  88. package/dist/server/request-context.js +184 -0
  89. package/dist/server/request-context.js.map +1 -0
  90. package/dist/server/respond.d.ts +17 -0
  91. package/dist/server/respond.d.ts.map +1 -0
  92. package/dist/server/respond.js +81 -0
  93. package/dist/server/respond.js.map +1 -0
  94. package/dist/server/runtime-core.d.ts +36 -0
  95. package/dist/server/runtime-core.d.ts.map +1 -0
  96. package/dist/server/runtime-core.js +51 -0
  97. package/dist/server/runtime-core.js.map +1 -0
  98. package/dist/server/server-types.d.ts +203 -0
  99. package/dist/server/server-types.d.ts.map +1 -0
  100. package/dist/server/server-types.js +2 -0
  101. package/dist/server/server-types.js.map +1 -0
  102. package/dist/server/server.d.ts +135 -358
  103. package/dist/server/server.d.ts.map +1 -1
  104. package/dist/server/server.js +476 -1065
  105. package/dist/server/server.js.map +1 -1
  106. package/dist/server/sse-hook.d.ts +10 -0
  107. package/dist/server/sse-hook.d.ts.map +1 -0
  108. package/dist/server/sse-hook.js +2 -0
  109. package/dist/server/sse-hook.js.map +1 -0
  110. package/dist/server/sse.d.ts +7 -23
  111. package/dist/server/sse.d.ts.map +1 -1
  112. package/dist/server/sse.js +18 -0
  113. package/dist/server/sse.js.map +1 -1
  114. package/dist/server/websocket.d.ts +17 -1
  115. package/dist/server/websocket.d.ts.map +1 -1
  116. package/dist/server/websocket.js.map +1 -1
  117. package/dist/server/ws-hook.d.ts +8 -11
  118. package/dist/server/ws-hook.d.ts.map +1 -1
  119. package/dist/server/ws-hook.js +7 -14
  120. package/dist/server/ws-hook.js.map +1 -1
  121. package/dist/server.d.ts +21 -0
  122. package/dist/server.d.ts.map +1 -0
  123. package/dist/server.js +16 -0
  124. package/dist/server.js.map +1 -0
  125. package/dist/ws.d.ts +16 -7
  126. package/dist/ws.d.ts.map +1 -1
  127. package/dist/ws.js +25 -10
  128. package/dist/ws.js.map +1 -1
  129. package/package.json +82 -6
  130. package/src/assurance.ts +36 -1
  131. package/src/effect-ledger.ts +9 -0
  132. package/src/idempotency-plugin.ts +7 -0
  133. package/src/index.ts +6 -132
  134. package/src/mcp.ts +65 -0
  135. package/src/node-direct.ts +37 -0
  136. package/src/reflection.ts +7 -0
  137. package/src/router/pattern.ts +176 -0
  138. package/src/router/router.ts +11 -69
  139. package/src/server/client-ip.ts +60 -0
  140. package/src/server/context.ts +50 -2
  141. package/src/server/contract.ts +68 -25
  142. package/src/server/http.ts +58 -0
  143. package/src/server/idempotency-lane.ts +287 -0
  144. package/src/server/install.ts +28 -0
  145. package/src/server/ledger-lane.ts +125 -0
  146. package/src/server/mcp-hook.ts +41 -0
  147. package/src/server/node-outcome-hook.ts +9 -0
  148. package/src/server/node-outcome.ts +112 -0
  149. package/src/server/plugin.ts +92 -0
  150. package/src/server/query.ts +119 -0
  151. package/src/server/registry.ts +36 -6
  152. package/src/server/request-context.ts +271 -0
  153. package/src/server/respond.ts +93 -0
  154. package/src/server/runtime-core.ts +74 -0
  155. package/src/server/server-types.ts +204 -0
  156. package/src/server/server.ts +863 -1657
  157. package/src/server/sse-hook.ts +14 -0
  158. package/src/server/sse.ts +26 -0
  159. package/src/server/websocket.ts +20 -0
  160. package/src/server/ws-hook.ts +8 -19
  161. package/src/server.ts +92 -0
  162. package/src/ws.ts +33 -10
  163. package/dist/invariants.d.ts +0 -70
  164. package/dist/invariants.d.ts.map +0 -1
  165. package/dist/invariants.js +0 -487
  166. package/dist/invariants.js.map +0 -1
  167. package/src/invariants.ts +0 -604
package/src/invariants.ts DELETED
@@ -1,604 +0,0 @@
1
- /**
2
- * Contract-generated invariant tests: the declared contract *is* the test suite.
3
- *
4
- * `runContractInvariants(app)` reads each route's reflected contract (schemas, classification) and
5
- * verifies, with deterministic seeded fuzzing, the properties the contract already promises:
6
- *
7
- * 1. **Classification consistency** (static) — a route whose response fields are tagged more
8
- * sensitive than its declared route-level classification is understating what it emits.
9
- * 2. **Valid input never crashes** (dynamic) — schema-generated valid requests must not produce 5xx.
10
- * 3. **Response conformance** (dynamic) — a 2xx response body must validate against the declared
11
- * response schema; an undeclared response shape is exactly where sensitive data leaks.
12
- * 4. **Validation fails closed** (dynamic) — schema-violating bodies must be rejected (4xx), never
13
- * accepted (2xx) and never crash (5xx).
14
- *
15
- * Everything is deterministic: a seeded PRNG (no ambient randomness, no wall clock), so a failing
16
- * case reproduces from its seed. Routes whose inputs cannot be generated from their JSON Schema are
17
- * reported in `skipped` — never silently dropped.
18
- */
19
-
20
- import {
21
- classificationAtLeast,
22
- type DataClassification,
23
- isDataClassification,
24
- reflectClassification,
25
- } from "./classification.ts"
26
- import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "./reflection.ts"
27
- import type { StandardSchemaV1 } from "./schema/standard.ts"
28
-
29
- export type InvariantFindingCode =
30
- | "classification-understated"
31
- | "server-error-on-valid-input"
32
- | "response-schema-violation"
33
- | "validation-bypass"
34
- | "server-error-on-invalid-input"
35
-
36
- export interface InvariantFinding {
37
- readonly code: InvariantFindingCode
38
- readonly method: string
39
- readonly path: string
40
- /** The case's deterministic seed — rerun with it to reproduce the exact request. */
41
- readonly seed?: number
42
- readonly message: string
43
- }
44
-
45
- export interface SkippedRoute {
46
- readonly method: string
47
- readonly path: string
48
- readonly reason: string
49
- }
50
-
51
- export interface InvariantReport {
52
- readonly ok: boolean
53
- /** Routes exercised dynamically. */
54
- readonly tested: readonly { readonly method: string; readonly path: string }[]
55
- /** Routes that could not be exercised, with the reason — reported, never silently dropped. */
56
- readonly skipped: readonly SkippedRoute[]
57
- readonly findings: readonly InvariantFinding[]
58
- }
59
-
60
- export interface RunInvariantsOptions {
61
- /** Deterministic seed for the fuzzer. Default 1. */
62
- readonly seed?: number
63
- /** Valid-input cases per route. Default 8. */
64
- readonly casesPerRoute?: number
65
- /** Invalid-input (mutated) cases per route with a body schema. Default 4. */
66
- readonly invalidCasesPerRoute?: number
67
- /**
68
- * Explicit isolated executor for dynamic cases. It must point at a disposable test app, sandbox,
69
- * or transaction that is rolled back. The runner never calls `source.fetch()` implicitly: doing so
70
- * could charge cards, send mail, or mutate production state merely by running a verification tool.
71
- */
72
- readonly executor?: InvariantExecutor
73
- }
74
-
75
- export type InvariantExecutor = (request: Request) => Response | Promise<Response>
76
-
77
- /** Opaque route-reflection source. The runner never invokes an app's live fetch method implicitly. */
78
- type ReflectableApp = object
79
-
80
- /**
81
- * The route-level classification exactly as DECLARED (`schema.classification`). Reflection merges the
82
- * declared value with field tags into one maximum, which erases the understatement signal — so the
83
- * consistency check reads the raw descriptor instead.
84
- */
85
- function declaredClassifications(app: ReflectableApp): Map<string, DataClassification> {
86
- const declared = new Map<string, DataClassification>()
87
- const routes = (app as { routes?: () => unknown }).routes?.()
88
- if (!Array.isArray(routes)) return declared
89
- for (const candidate of routes) {
90
- const route = recordOf(candidate)
91
- const schema = recordOf(route?.schema)
92
- const value = schema?.classification
93
- if (
94
- typeof route?.method === "string" &&
95
- typeof route.path === "string" &&
96
- isDataClassification(value)
97
- ) {
98
- declared.set(`${route.method.toUpperCase()}\n${route.path}`, value)
99
- }
100
- }
101
- return declared
102
- }
103
-
104
- /** Deterministic PRNG (mulberry32). Ambient randomness would make failures unreproducible. */
105
- export function createSeededRandom(seed: number): () => number {
106
- let state = seed >>> 0
107
- return () => {
108
- state = (state + 0x6d2b79f5) >>> 0
109
- let t = state
110
- t = Math.imul(t ^ (t >>> 15), t | 1)
111
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
112
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296
113
- }
114
- }
115
-
116
- type JsonRecord = Readonly<Record<string, unknown>>
117
-
118
- const recordOf = (value: unknown): JsonRecord | undefined =>
119
- value !== null && typeof value === "object" && !Array.isArray(value)
120
- ? (value as JsonRecord)
121
- : undefined
122
-
123
- class UngeneratableSchemaError extends Error {}
124
-
125
- const SAFE_TOKEN = "abcdefghijklmnopqrstuvwxyz0123456789"
126
-
127
- function randomToken(rng: () => number, length: number): string {
128
- let out = ""
129
- for (let index = 0; index < length; index++) {
130
- out += SAFE_TOKEN[Math.floor(rng() * SAFE_TOKEN.length)]
131
- }
132
- return out
133
- }
134
-
135
- /**
136
- * Generate one value satisfying a JSON Schema (the introspectable subset nifra schemas emit).
137
- * Throws {@link UngeneratableSchemaError} for shapes it cannot honor — the route is then skipped
138
- * (and reported), never fuzzed with garbage that would produce false findings.
139
- */
140
- function generateFromJsonSchema(schema: unknown, rng: () => number, depth = 0): unknown {
141
- if (depth > 8) throw new UngeneratableSchemaError("schema nesting exceeds the generator depth")
142
- const node = recordOf(schema)
143
- if (node === undefined) throw new UngeneratableSchemaError("schema node is not an object")
144
- if (node.const !== undefined) return node.const
145
- if (Array.isArray(node.enum) && node.enum.length > 0) {
146
- return node.enum[Math.floor(rng() * node.enum.length)]
147
- }
148
- for (const key of ["anyOf", "oneOf"] as const) {
149
- const variants = node[key]
150
- if (Array.isArray(variants) && variants.length > 0) {
151
- return generateFromJsonSchema(variants[Math.floor(rng() * variants.length)], rng, depth + 1)
152
- }
153
- }
154
- const type = Array.isArray(node.type) ? node.type[0] : node.type
155
- switch (type) {
156
- case "string": {
157
- const minLength = node.minLength === undefined ? 0 : node.minLength
158
- const maxLength =
159
- node.maxLength === undefined ? Math.max(12, minLength as number) : node.maxLength
160
- if (node.pattern !== undefined || node.format !== undefined) {
161
- throw new UngeneratableSchemaError("string pattern/format generation is not supported")
162
- }
163
- if (
164
- !Number.isSafeInteger(minLength) ||
165
- !Number.isSafeInteger(maxLength) ||
166
- (minLength as number) < 0 ||
167
- (maxLength as number) < (minLength as number) ||
168
- (minLength as number) > 4_096
169
- ) {
170
- throw new UngeneratableSchemaError("string length bounds cannot be generated safely")
171
- }
172
- const upper = Math.min(maxLength as number, (minLength as number) + 12)
173
- const length = (minLength as number) + Math.floor(rng() * (upper - (minLength as number) + 1))
174
- return randomToken(rng, length)
175
- }
176
- case "number":
177
- case "integer": {
178
- if (
179
- node.exclusiveMinimum !== undefined ||
180
- node.exclusiveMaximum !== undefined ||
181
- node.multipleOf !== undefined
182
- ) {
183
- throw new UngeneratableSchemaError(
184
- "exclusive/multiple numeric constraints are not supported",
185
- )
186
- }
187
- const minimum = typeof node.minimum === "number" ? node.minimum : 0
188
- const maximum = typeof node.maximum === "number" ? node.maximum : minimum + 1000
189
- if (!Number.isFinite(minimum) || !Number.isFinite(maximum) || maximum < minimum) {
190
- throw new UngeneratableSchemaError("numeric bounds cannot be generated")
191
- }
192
- if (type === "integer") {
193
- const lower = Math.ceil(minimum)
194
- const upper = Math.floor(maximum)
195
- if (!Number.isSafeInteger(lower) || !Number.isSafeInteger(upper) || upper < lower) {
196
- throw new UngeneratableSchemaError("integer bounds cannot be generated safely")
197
- }
198
- return lower + Math.floor(rng() * (upper - lower + 1))
199
- }
200
- return minimum + rng() * (maximum - minimum)
201
- }
202
- case "boolean":
203
- return rng() < 0.5
204
- case "null":
205
- return null
206
- case "array": {
207
- if (node.uniqueItems === true || node.contains !== undefined) {
208
- throw new UngeneratableSchemaError("array uniqueItems/contains generation is not supported")
209
- }
210
- const minItems = node.minItems === undefined ? 0 : node.minItems
211
- const maxItems = node.maxItems === undefined ? (minItems as number) + 2 : node.maxItems
212
- if (
213
- !Number.isSafeInteger(minItems) ||
214
- !Number.isSafeInteger(maxItems) ||
215
- (minItems as number) < 0 ||
216
- (maxItems as number) < (minItems as number) ||
217
- (minItems as number) > 256
218
- ) {
219
- throw new UngeneratableSchemaError("array bounds cannot be generated safely")
220
- }
221
- const count =
222
- (minItems as number) +
223
- Math.floor(
224
- rng() *
225
- (Math.min(maxItems as number, (minItems as number) + 2) - (minItems as number) + 1),
226
- )
227
- if (node.items === undefined) {
228
- return Array.from({ length: count }, () => null)
229
- }
230
- const out: unknown[] = []
231
- for (let index = 0; index < count; index++) {
232
- out.push(generateFromJsonSchema(node.items, rng, depth + 1))
233
- }
234
- return out
235
- }
236
- case "object": {
237
- const properties = recordOf(node.properties) ?? {}
238
- const requiredValues = Array.isArray(node.required) ? node.required : []
239
- if (requiredValues.some((value) => typeof value !== "string" || !(value in properties))) {
240
- throw new UngeneratableSchemaError("required object property has no schema")
241
- }
242
- if (
243
- node.minProperties !== undefined ||
244
- node.maxProperties !== undefined ||
245
- node.dependentRequired !== undefined ||
246
- node.dependentSchemas !== undefined
247
- ) {
248
- throw new UngeneratableSchemaError(
249
- "advanced object cardinality/dependency generation is not supported",
250
- )
251
- }
252
- const required = new Set(requiredValues as string[])
253
- const out: Record<string, unknown> = {}
254
- for (const [name, child] of Object.entries(properties)) {
255
- // Required properties always; optional ones half the time — both branches get exercised.
256
- if (required.has(name) || rng() < 0.5) {
257
- out[name] = generateFromJsonSchema(child, rng, depth + 1)
258
- }
259
- }
260
- return out
261
- }
262
- default:
263
- throw new UngeneratableSchemaError(`unsupported schema type ${JSON.stringify(type)}`)
264
- }
265
- }
266
-
267
- /** Mutate a valid object body so it unambiguously violates its schema (remove a required property). */
268
- function mutateInvalid(schema: unknown, valid: unknown): unknown | undefined {
269
- const node = recordOf(schema)
270
- const properties = recordOf(node?.properties)
271
- const required = Array.isArray(node?.required) ? (node.required as string[]) : []
272
- const target = required[0]
273
- if (
274
- node?.type !== "object" ||
275
- properties === undefined ||
276
- target === undefined ||
277
- recordOf(valid) === undefined
278
- ) {
279
- return undefined
280
- }
281
- const invalid = { ...(valid as Record<string, unknown>) }
282
- delete invalid[target]
283
- return invalid
284
- }
285
-
286
- function fillPathParams(pattern: string, rng: () => number): string {
287
- return pattern
288
- .split("/")
289
- .map((segment) => {
290
- if (segment.startsWith(":")) return randomToken(rng, 8)
291
- if (segment.startsWith("*") || segment === "*") return randomToken(rng, 8)
292
- return segment
293
- })
294
- .join("/")
295
- }
296
-
297
- function querySearchOf(query: unknown): string {
298
- const record = recordOf(query)
299
- if (record === undefined) return ""
300
- const params = new URLSearchParams()
301
- for (const [key, value] of Object.entries(record)) {
302
- if (Array.isArray(value)) {
303
- for (const item of value) {
304
- if (item !== null && typeof item === "object") {
305
- throw new UngeneratableSchemaError("nested query values are not serializable")
306
- }
307
- params.append(key, String(item))
308
- }
309
- } else if (value !== undefined && value !== null && typeof value !== "object") {
310
- params.set(key, String(value))
311
- } else if (value !== undefined && value !== null) {
312
- throw new UngeneratableSchemaError("nested query values are not serializable")
313
- }
314
- }
315
- const text = params.toString()
316
- return text === "" ? "" : `?${text}`
317
- }
318
-
319
- async function validateAgainst(
320
- standard: StandardSchemaV1,
321
- value: unknown,
322
- ): Promise<readonly { readonly message: string }[] | undefined> {
323
- const result = standard["~standard"].validate(value)
324
- const settled = result instanceof Promise ? await result : result
325
- return settled.issues
326
- }
327
-
328
- interface RoutePlan {
329
- readonly route: ReflectedRoute
330
- readonly body?: SchemaReflection
331
- readonly query?: SchemaReflection
332
- }
333
-
334
- const FUZZABLE_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
335
-
336
- /** Run the contract-derived invariant suite against a reflectable app. Pure; no ambient state. */
337
- /**
338
- * @deprecated Use `runAdversarialContract` from `@nifrajs/testing`. The testing package is the
339
- * authoritative deep contract laboratory and powers CLI L4; this export remains for compatibility.
340
- */
341
- export async function runContractInvariants(
342
- app: ReflectableApp,
343
- options: RunInvariantsOptions = {},
344
- ): Promise<InvariantReport> {
345
- const seed = options.seed ?? 1
346
- if (!Number.isSafeInteger(seed)) throw new TypeError("invariants: seed must be a safe integer")
347
- const casesPerRoute = options.casesPerRoute ?? 8
348
- const invalidCasesPerRoute = options.invalidCasesPerRoute ?? 4
349
- if (
350
- !Number.isSafeInteger(casesPerRoute) ||
351
- !Number.isSafeInteger(invalidCasesPerRoute) ||
352
- casesPerRoute < 1 ||
353
- invalidCasesPerRoute < 0
354
- ) {
355
- throw new RangeError("invariants: case counts must be safe integers and valid")
356
- }
357
- if (options.executor !== undefined && typeof options.executor !== "function") {
358
- throw new TypeError("invariants: executor must be a function")
359
- }
360
-
361
- const findings: InvariantFinding[] = []
362
- const tested: { method: string; path: string }[] = []
363
- const skipped: SkippedRoute[] = []
364
- const declared = declaredClassifications(app)
365
-
366
- for (const route of reflectRoutes(app)) {
367
- // 1. Static classification consistency — needs no requests at all.
368
- const declaredMax: DataClassification | undefined = declared.get(
369
- `${route.method}\n${route.path}`,
370
- )
371
- const fieldReflected = reflectClassification(route.schema?.response?.standard)
372
- if (
373
- declaredMax !== undefined &&
374
- fieldReflected !== undefined &&
375
- !classificationAtLeast(declaredMax, fieldReflected.max)
376
- ) {
377
- findings.push({
378
- code: "classification-understated",
379
- method: route.method,
380
- path: route.path,
381
- message: `${route.method} ${route.path} declares ${declaredMax} but its response fields are tagged ${fieldReflected.max}`,
382
- })
383
- }
384
-
385
- if (!FUZZABLE_METHODS.has(route.method)) {
386
- skipped.push({ method: route.method, path: route.path, reason: "method not fuzzable" })
387
- continue
388
- }
389
- if (options.executor === undefined) {
390
- skipped.push({
391
- method: route.method,
392
- path: route.path,
393
- reason: "no isolated executor configured",
394
- })
395
- continue
396
- }
397
- const body = route.schema?.body
398
- const query = route.schema?.query
399
- if (body !== undefined && body.jsonSchema === undefined) {
400
- skipped.push({
401
- method: route.method,
402
- path: route.path,
403
- reason: "body schema exposes no JSON Schema metadata",
404
- })
405
- continue
406
- }
407
- if (query !== undefined && query.jsonSchema === undefined) {
408
- skipped.push({
409
- method: route.method,
410
- path: route.path,
411
- reason: "query schema exposes no JSON Schema metadata",
412
- })
413
- continue
414
- }
415
- if (route.schema?.sse !== undefined) {
416
- skipped.push({ method: route.method, path: route.path, reason: "streaming route" })
417
- continue
418
- }
419
-
420
- const plan: RoutePlan = {
421
- route,
422
- ...(body !== undefined ? { body } : {}),
423
- ...(query !== undefined ? { query } : {}),
424
- }
425
- const outcome = await fuzzRoute(
426
- options.executor,
427
- plan,
428
- seed,
429
- casesPerRoute,
430
- invalidCasesPerRoute,
431
- findings,
432
- )
433
- if (outcome === "tested") tested.push({ method: route.method, path: route.path })
434
- else skipped.push({ method: route.method, path: route.path, reason: outcome })
435
- }
436
-
437
- return Object.freeze({
438
- ok: findings.length === 0,
439
- tested: Object.freeze(tested),
440
- skipped: Object.freeze(skipped),
441
- findings: Object.freeze(findings),
442
- })
443
- }
444
-
445
- async function fuzzRoute(
446
- execute: InvariantExecutor,
447
- plan: RoutePlan,
448
- baseSeed: number,
449
- casesPerRoute: number,
450
- invalidCasesPerRoute: number,
451
- findings: InvariantFinding[],
452
- ): Promise<"tested" | string> {
453
- const { route } = plan
454
- for (let caseIndex = 0; caseIndex < casesPerRoute; caseIndex++) {
455
- const caseSeed = baseSeed * 1000 + caseIndex
456
- const rng = createSeededRandom(caseSeed)
457
- let bodyValue: unknown
458
- let queryValue: unknown
459
- try {
460
- bodyValue =
461
- plan.body === undefined ? undefined : generateFromJsonSchema(plan.body.jsonSchema, rng)
462
- queryValue =
463
- plan.query?.jsonSchema === undefined
464
- ? undefined
465
- : generateFromJsonSchema(plan.query.jsonSchema, rng)
466
- // Query values must survive URL serialization; fail honestly instead of silently dropping
467
- // nested values and calling the resulting request schema-valid.
468
- querySearchOf(queryValue)
469
- } catch (err) {
470
- return err instanceof UngeneratableSchemaError ? err.message : String(err)
471
- }
472
-
473
- const path = `${fillPathParams(route.path, rng)}${querySearchOf(queryValue)}`
474
- const request = new Request(`http://invariants.local${path}`, {
475
- method: route.method,
476
- ...(bodyValue !== undefined
477
- ? {
478
- headers: { "content-type": "application/json" },
479
- body: JSON.stringify(bodyValue),
480
- }
481
- : {}),
482
- })
483
- let response: Response
484
- try {
485
- response = await execute(request)
486
- if (!(response instanceof Response)) throw new TypeError("executor returned a non-Response")
487
- } catch {
488
- findings.push({
489
- code: "server-error-on-valid-input",
490
- method: route.method,
491
- path: route.path,
492
- seed: caseSeed,
493
- message: `${route.method} ${route.path} executor threw for a schema-valid input (seed ${caseSeed})`,
494
- })
495
- continue
496
- }
497
-
498
- // 2. Valid input must never crash.
499
- if (response.status >= 500) {
500
- findings.push({
501
- code: "server-error-on-valid-input",
502
- method: route.method,
503
- path: route.path,
504
- seed: caseSeed,
505
- message: `${route.method} ${route.path} returned ${response.status} for a schema-valid input (seed ${caseSeed})`,
506
- })
507
- continue
508
- }
509
- // 3. A 2xx JSON response must conform to the declared response contract.
510
- const responseStandard = plan.route.schema?.response?.standard
511
- if (responseStandard !== undefined && response.status < 300) {
512
- const contentType = response.headers.get("content-type") ?? ""
513
- if (!contentType.includes("application/json")) {
514
- findings.push({
515
- code: "response-schema-violation",
516
- method: route.method,
517
- path: route.path,
518
- seed: caseSeed,
519
- message: `${route.method} ${route.path} returned non-JSON content for its declared response schema (seed ${caseSeed})`,
520
- })
521
- continue
522
- }
523
- let responseValue: unknown
524
- try {
525
- responseValue = await response.clone().json()
526
- } catch {
527
- findings.push({
528
- code: "response-schema-violation",
529
- method: route.method,
530
- path: route.path,
531
- seed: caseSeed,
532
- message: `${route.method} ${route.path} returned malformed JSON for its declared response schema (seed ${caseSeed})`,
533
- })
534
- continue
535
- }
536
- const issues = await validateAgainst(responseStandard, responseValue)
537
- if (issues !== undefined) {
538
- findings.push({
539
- code: "response-schema-violation",
540
- method: route.method,
541
- path: route.path,
542
- seed: caseSeed,
543
- message: `${route.method} ${route.path} response violates its declared schema (seed ${caseSeed}): ${issues[0]?.message ?? "invalid"}`,
544
- })
545
- }
546
- }
547
- }
548
-
549
- // 4. Schema-violating bodies must be rejected — 4xx, never accepted, never a crash.
550
- if (plan.body?.jsonSchema !== undefined) {
551
- for (let caseIndex = 0; caseIndex < invalidCasesPerRoute; caseIndex++) {
552
- const caseSeed = baseSeed * 1000 + 500 + caseIndex
553
- const rng = createSeededRandom(caseSeed)
554
- let invalid: unknown
555
- try {
556
- invalid = mutateInvalid(
557
- plan.body.jsonSchema,
558
- generateFromJsonSchema(plan.body.jsonSchema, rng),
559
- )
560
- } catch {
561
- invalid = undefined
562
- }
563
- if (invalid === undefined) break
564
- let response: Response
565
- try {
566
- response = await execute(
567
- new Request(`http://invariants.local${fillPathParams(route.path, rng)}`, {
568
- method: route.method,
569
- headers: { "content-type": "application/json" },
570
- body: JSON.stringify(invalid),
571
- }),
572
- )
573
- if (!(response instanceof Response)) throw new TypeError("executor returned a non-Response")
574
- } catch {
575
- findings.push({
576
- code: "server-error-on-invalid-input",
577
- method: route.method,
578
- path: route.path,
579
- seed: caseSeed,
580
- message: `${route.method} ${route.path} executor threw on a schema-violating body (seed ${caseSeed})`,
581
- })
582
- continue
583
- }
584
- if (response.status < 300) {
585
- findings.push({
586
- code: "validation-bypass",
587
- method: route.method,
588
- path: route.path,
589
- seed: caseSeed,
590
- message: `${route.method} ${route.path} accepted a schema-violating body (seed ${caseSeed})`,
591
- })
592
- } else if (response.status >= 500) {
593
- findings.push({
594
- code: "server-error-on-invalid-input",
595
- method: route.method,
596
- path: route.path,
597
- seed: caseSeed,
598
- message: `${route.method} ${route.path} crashed (${response.status}) on a schema-violating body (seed ${caseSeed})`,
599
- })
600
- }
601
- }
602
- }
603
- return "tested"
604
- }