@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.
- package/README.md +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -30
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +42 -0
- package/dist/router/pattern.d.ts.map +1 -0
- package/dist/router/pattern.js +127 -0
- package/dist/router/pattern.js.map +1 -0
- package/dist/router/router.d.ts +2 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +11 -48
- package/dist/router/router.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +37 -10
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +33 -8
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +135 -358
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +476 -1065
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +82 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -132
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/router/pattern.ts +176 -0
- package/src/router/router.ts +11 -69
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +68 -25
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +863 -1657
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- 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
|
-
}
|