@nifrajs/core 5.0.0 → 5.2.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/CHANGELOG.md +2384 -0
- package/dist/assurance.d.ts +15 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +97 -71
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +17 -1
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +20 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-program.d.ts +2 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +22 -17
- package/dist/internal/route-program.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/reflection.d.ts +23 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +57 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +7 -4
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +7 -1
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +37 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/headers.d.ts +9 -0
- package/dist/server/headers.d.ts.map +1 -1
- package/dist/server/headers.js +26 -0
- package/dist/server/headers.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +57 -7
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +394 -23
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -9
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +40 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +188 -43
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +11 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +26 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +9 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +81 -10
- package/dist/single-copy.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +2 -1
- package/dist/ws.js.map +1 -1
- package/package.json +3 -2
- package/src/assurance.ts +120 -81
- package/src/capabilities.ts +3 -1
- package/src/edge-kit.ts +1 -1
- package/src/index.ts +2 -2
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +39 -20
- package/src/internal/same-origin.ts +14 -1
- package/src/reflection.ts +61 -1
- package/src/server/body-lane.ts +36 -1
- package/src/server/client-ip.ts +5 -5
- package/src/server/context.ts +7 -4
- package/src/server/cookies.ts +12 -1
- package/src/server/core-copies.ts +40 -0
- package/src/server/edge.ts +2 -1
- package/src/server/headers.ts +39 -0
- package/src/server/http.ts +7 -1
- package/src/server/response-contract-lane.ts +491 -24
- package/src/server/server-types.ts +23 -9
- package/src/server/server.ts +249 -35
- package/src/server/websocket.ts +39 -13
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +9 -2
- package/src/server.ts +1 -1
- package/src/single-copy.ts +90 -9
- package/src/wire.ts +14 -0
- package/src/ws.ts +2 -0
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* That is not an inconsistency to paper over: a strict schema has already declared that extra fields
|
|
30
30
|
* are an error, and a stripping one has declared they are ignorable. Enforcement honours what the
|
|
31
|
-
* author wrote rather than overriding it
|
|
31
|
+
* author wrote rather than overriding it - unless the app asks for `extra: "strip"`. A schema that
|
|
32
|
+
* carries its JSON Schema as `jsonSchema` (every `t` schema does) CAN list its declared keys, so for
|
|
33
|
+
* those the payload is projected onto the declared shape before it is validated.
|
|
32
34
|
*
|
|
33
35
|
* ## What enforcement costs
|
|
34
36
|
*
|
|
@@ -62,6 +64,51 @@ import type { AnyServer } from "./server.ts"
|
|
|
62
64
|
*/
|
|
63
65
|
export type ResponseContractMode = "warn" | "enforce"
|
|
64
66
|
|
|
67
|
+
export interface ResponseContractOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Fields the schema does not declare. `"reject"` (the default) leaves them to the validator: a
|
|
70
|
+
* closed `t.object` reports them, so an enforced response becomes a 500 and the log names them.
|
|
71
|
+
* `"strip"` drops them first, for any schema exposing its JSON Schema as `jsonSchema` (every
|
|
72
|
+
* `@nifrajs/schema` `t` schema) - nested objects, arrays and records included. A union is projected
|
|
73
|
+
* when an object value can meet only one of its branches: a single object branch, or object
|
|
74
|
+
* branches that all require one key holding literals no two branches share (`ok: true` /
|
|
75
|
+
* `ok: false`, `kind: "a"` / `kind: "b"`). Schemas without `jsonSchema` (zod, valibot) are
|
|
76
|
+
* unaffected: they already strip or reject by their own rules.
|
|
77
|
+
*/
|
|
78
|
+
readonly extra?: "reject" | "strip"
|
|
79
|
+
/**
|
|
80
|
+
* `enforce` only: log the names of the fields that never reached the wire - dropped by `extra:
|
|
81
|
+
* "strip"` or by a validator that strips on its own - so a field the contract does not declare
|
|
82
|
+
* (a new column) shows up in the log instead of vanishing. Logged as a `warn` named "response
|
|
83
|
+
* contract", once per route for each distinct set of names, at most 32 sets per route. Values are
|
|
84
|
+
* never logged. Each response is compared with what was sent, so it costs a walk of the payload.
|
|
85
|
+
*/
|
|
86
|
+
readonly logStripped?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Called for every response the contract has something to say about, for a metric or an alert, where
|
|
89
|
+
* the log speaks once per route. `violation`: enforce mode, the payload failed and a 500 went out in
|
|
90
|
+
* its place. `warn`: warn mode, the payload failed or carried undeclared top-level keys (`fields`)
|
|
91
|
+
* and was sent anyway. `stripped`: enforce mode, the payload was sent without `fields`. The message is
|
|
92
|
+
* the text the log line carries; values are never passed. Called before the response is sent; a hook
|
|
93
|
+
* that throws, rejects or is not a function is logged once and changes nothing. In enforce mode it costs the payload walk
|
|
94
|
+
* `logStripped` does.
|
|
95
|
+
*/
|
|
96
|
+
readonly onReport?: (report: ResponseContractReport) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One response the contract reported on, passed to {@link ResponseContractOptions.onReport}. */
|
|
100
|
+
export interface ResponseContractReport {
|
|
101
|
+
readonly kind: "violation" | "warn" | "stripped"
|
|
102
|
+
/** The route's method as registered, e.g. `GET`. */
|
|
103
|
+
readonly method: string
|
|
104
|
+
/** The route's path as registered, e.g. `/users/:id`, so a metric keyed by it stays bounded. */
|
|
105
|
+
readonly route: string
|
|
106
|
+
/** `stripped`: each field that never reached the wire (`user.passwordHash`, `items[].cost`), sorted.
|
|
107
|
+
* `warn`: the undeclared top-level keys that were sent, when they are the reason. */
|
|
108
|
+
readonly fields?: readonly string[] | undefined
|
|
109
|
+
readonly message: string
|
|
110
|
+
}
|
|
111
|
+
|
|
65
112
|
/** Success and status-specific error schemas captured by one route at registration time. */
|
|
66
113
|
export interface ResponseContractDefinition {
|
|
67
114
|
readonly response?: StandardSchemaV1
|
|
@@ -72,14 +119,293 @@ export interface ResponseContractDefinition {
|
|
|
72
119
|
export type ResponseContractOutcome =
|
|
73
120
|
/** Nothing to report. `value` is what should be serialized (the original result, or the cleaned one). */
|
|
74
121
|
| { readonly kind: "ok"; readonly value: unknown }
|
|
75
|
-
/** `
|
|
76
|
-
|
|
122
|
+
/** Logged, then `value` is served: in `warn` the ORIGINAL result, in `enforce` (with
|
|
123
|
+
* `logStripped`) the cleaned one. `fields`: in `warn`, the undeclared keys when they are the reason. */
|
|
124
|
+
| {
|
|
125
|
+
readonly kind: "warn"
|
|
126
|
+
readonly value: unknown
|
|
127
|
+
readonly message: string
|
|
128
|
+
readonly fields?: readonly string[]
|
|
129
|
+
}
|
|
77
130
|
/** `enforce` only: the payload cannot be reconciled with the contract, so it must not be sent. */
|
|
78
131
|
| { readonly kind: "violation"; readonly message: string }
|
|
79
132
|
|
|
80
133
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
81
134
|
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
82
135
|
|
|
136
|
+
/** Rebuilds a value with only what its schema declares; absent when the schema can strip nothing. */
|
|
137
|
+
type Projector = (value: unknown) => unknown
|
|
138
|
+
|
|
139
|
+
const projectors = new WeakMap<object, Projector | null>()
|
|
140
|
+
|
|
141
|
+
/** The projector for a schema's JSON Schema, compiled once per schema. */
|
|
142
|
+
function projectorOf(schema: StandardSchemaV1): Projector | undefined {
|
|
143
|
+
const node: unknown = Reflect.get(schema, "jsonSchema")
|
|
144
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
145
|
+
let found = projectors.get(node)
|
|
146
|
+
if (found === undefined) {
|
|
147
|
+
found = compileProjector(node) ?? null
|
|
148
|
+
projectors.set(node, found)
|
|
149
|
+
}
|
|
150
|
+
return found ?? undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const objectField = (node: object, key: string): object | undefined => {
|
|
154
|
+
const value: unknown = Reflect.get(node, key)
|
|
155
|
+
return typeof value === "object" && value !== null ? value : undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function compileProjector(node: unknown): Projector | undefined {
|
|
159
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
160
|
+
const schema: object = node
|
|
161
|
+
const type: unknown = Reflect.get(schema, "type")
|
|
162
|
+
const branches: unknown = Reflect.get(schema, "anyOf") ?? Reflect.get(schema, "oneOf")
|
|
163
|
+
if (Array.isArray(branches)) return compileUnion(branches)
|
|
164
|
+
if (type === "array") {
|
|
165
|
+
const items: unknown = Reflect.get(schema, "items")
|
|
166
|
+
const item = Array.isArray(items) ? undefined : compileProjector(items)
|
|
167
|
+
return item === undefined
|
|
168
|
+
? undefined
|
|
169
|
+
: (value) => (Array.isArray(value) ? value.map(item) : value)
|
|
170
|
+
}
|
|
171
|
+
if (type !== "object") return undefined
|
|
172
|
+
const properties = objectField(schema, "properties") ?? {}
|
|
173
|
+
const keys = Object.keys(properties)
|
|
174
|
+
const nested = new Map<string, Projector>()
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const projector = compileProjector(Reflect.get(properties, key))
|
|
177
|
+
if (projector !== undefined) nested.set(key, projector)
|
|
178
|
+
}
|
|
179
|
+
const patterns: Array<readonly [RegExp, Projector | undefined]> = []
|
|
180
|
+
for (const [source, child] of Object.entries(objectField(schema, "patternProperties") ?? {})) {
|
|
181
|
+
let pattern: RegExp
|
|
182
|
+
try {
|
|
183
|
+
pattern = new RegExp(source, "u")
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined // a pattern this engine can't read: the validator judges the value whole
|
|
186
|
+
}
|
|
187
|
+
patterns.push([pattern, compileProjector(child)])
|
|
188
|
+
}
|
|
189
|
+
const closed = Reflect.get(schema, "additionalProperties") === false
|
|
190
|
+
if (!closed && nested.size === 0 && patterns.every(([, projector]) => projector === undefined))
|
|
191
|
+
return undefined
|
|
192
|
+
const declared = new Set(keys)
|
|
193
|
+
return (value) => {
|
|
194
|
+
if (!isPlainObject(value)) return value
|
|
195
|
+
const out: Record<string, unknown> = closed ? {} : { ...value }
|
|
196
|
+
for (const key of closed ? keys : nested.keys()) {
|
|
197
|
+
if (!hasField(value, key)) continue
|
|
198
|
+
const projector = nested.get(key)
|
|
199
|
+
define(out, key, projector === undefined ? value[key] : projector(value[key]))
|
|
200
|
+
}
|
|
201
|
+
if (patterns.length > 0) projectPatternKeys(value, out, declared, patterns, closed)
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A getter on a row class's prototype is a declared field; `Object.prototype`'s members are not. */
|
|
207
|
+
const hasField = (value: Record<string, unknown>, key: string): boolean =>
|
|
208
|
+
Object.hasOwn(value, key) || (key in value && !(key in Object.prototype))
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A union projects a value onto the one branch able to accept it, and leaves anything less certain to
|
|
212
|
+
* the validator. JSON Schema's `object` never matches an array nor `array` an object, so the two are
|
|
213
|
+
* routed apart; two object branches are told apart only by a discriminator (see
|
|
214
|
+
* {@link compileDiscriminated}). A branch that could hold anything (`{}`, `$ref`, `allOf`) stops it.
|
|
215
|
+
*/
|
|
216
|
+
function compileUnion(branches: readonly unknown[]): Projector | undefined {
|
|
217
|
+
const objects: object[] = []
|
|
218
|
+
const arrays: object[] = []
|
|
219
|
+
if (!sortBranches(branches, objects, arrays)) return undefined
|
|
220
|
+
const forArray = arrays.length === 1 ? compileProjector(arrays[0]) : undefined
|
|
221
|
+
const forObject =
|
|
222
|
+
objects.length === 1
|
|
223
|
+
? compileProjector(objects[0])
|
|
224
|
+
: objects.length > 1
|
|
225
|
+
? compileDiscriminated(objects)
|
|
226
|
+
: undefined
|
|
227
|
+
if (forArray === undefined && forObject === undefined) return undefined
|
|
228
|
+
return (value) => {
|
|
229
|
+
if (Array.isArray(value)) return forArray === undefined ? value : forArray(value)
|
|
230
|
+
return forObject !== undefined && isPlainObject(value) ? forObject(value) : value
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Sort a union's branches, nested unions flattened; `false` when one could hold anything. */
|
|
235
|
+
function sortBranches(branches: readonly unknown[], objects: object[], arrays: object[]): boolean {
|
|
236
|
+
for (const branch of branches) {
|
|
237
|
+
if (branch === false) continue
|
|
238
|
+
if (typeof branch !== "object" || branch === null) return false
|
|
239
|
+
const nested: unknown = Reflect.get(branch, "anyOf") ?? Reflect.get(branch, "oneOf")
|
|
240
|
+
if (Array.isArray(nested)) {
|
|
241
|
+
if (!sortBranches(nested, objects, arrays)) return false
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
const type: unknown = Reflect.get(branch, "type")
|
|
245
|
+
if (type === "object") objects.push(branch)
|
|
246
|
+
else if (type === "array") arrays.push(branch)
|
|
247
|
+
else if (typeof type === "string") continue
|
|
248
|
+
else if (type !== undefined || literalsOf(branch) === undefined) return false
|
|
249
|
+
}
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Object branches that all require one key, each holding literals no other branch holds: at most one
|
|
255
|
+
* branch can accept a value, so projecting onto the branch its tag names cannot strip a field another
|
|
256
|
+
* branch needed. The first such key in the first branch's declared order is used. A value whose tag
|
|
257
|
+
* names no branch is left to the validator, which rejects it.
|
|
258
|
+
*/
|
|
259
|
+
function compileDiscriminated(branches: readonly object[]): Projector | undefined {
|
|
260
|
+
const projectors = branches.map((branch) => compileProjector(branch))
|
|
261
|
+
const first = branches[0]
|
|
262
|
+
if (first === undefined || projectors.every((projector) => projector === undefined))
|
|
263
|
+
return undefined
|
|
264
|
+
for (const key of Object.keys(objectField(first, "properties") ?? {})) {
|
|
265
|
+
const byTag = new Map<unknown, Projector | undefined>()
|
|
266
|
+
const disjoint = branches.every((branch, index) => {
|
|
267
|
+
const tags = requiredLiterals(branch, key)
|
|
268
|
+
if (tags === undefined) return false
|
|
269
|
+
for (const tag of tags) {
|
|
270
|
+
if (byTag.has(tag)) return false
|
|
271
|
+
byTag.set(tag, projectors[index])
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
})
|
|
275
|
+
if (!disjoint) continue
|
|
276
|
+
return (value) => {
|
|
277
|
+
if (!isPlainObject(value) || !hasField(value, key)) return value
|
|
278
|
+
const projector = byTag.get(value[key])
|
|
279
|
+
return projector === undefined ? value : projector(value)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The distinct literals `key` may hold in an object branch that requires it. */
|
|
286
|
+
function requiredLiterals(branch: object, key: string): ReadonlySet<unknown> | undefined {
|
|
287
|
+
const required: unknown = Reflect.get(branch, "required")
|
|
288
|
+
const properties = objectField(branch, "properties")
|
|
289
|
+
if (!Array.isArray(required) || !required.includes(key)) return undefined
|
|
290
|
+
if (properties === undefined || !Object.hasOwn(properties, key)) return undefined
|
|
291
|
+
const literals = literalsOf(Reflect.get(properties, key))
|
|
292
|
+
return literals === undefined ? undefined : new Set(literals)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const isLiteral = (value: unknown): boolean =>
|
|
296
|
+
value === null || ["string", "number", "boolean"].includes(typeof value)
|
|
297
|
+
|
|
298
|
+
/** The finite set of primitives a schema admits (`const`, `enum`, `null`, a union of those), else `undefined`. */
|
|
299
|
+
function literalsOf(node: unknown): readonly unknown[] | undefined {
|
|
300
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
301
|
+
if (Object.hasOwn(node, "const")) {
|
|
302
|
+
const value: unknown = Reflect.get(node, "const")
|
|
303
|
+
return isLiteral(value) ? [value] : undefined
|
|
304
|
+
}
|
|
305
|
+
const listed: unknown = Reflect.get(node, "enum")
|
|
306
|
+
if (Array.isArray(listed))
|
|
307
|
+
return listed.length > 0 && listed.every(isLiteral) ? listed : undefined
|
|
308
|
+
const branches: unknown = Reflect.get(node, "anyOf") ?? Reflect.get(node, "oneOf")
|
|
309
|
+
if (Array.isArray(branches)) {
|
|
310
|
+
const all: unknown[] = []
|
|
311
|
+
for (const branch of branches) {
|
|
312
|
+
const literals = literalsOf(branch)
|
|
313
|
+
if (literals === undefined) return undefined
|
|
314
|
+
all.push(...literals)
|
|
315
|
+
}
|
|
316
|
+
return all.length > 0 ? all : undefined
|
|
317
|
+
}
|
|
318
|
+
return Reflect.get(node, "type") === "null" ? [null] : undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Carry the undeclared keys a `patternProperties` entry admits: a closed record's keys are all
|
|
323
|
+
* pattern keys, so dropping them empties a valid value. A key two patterns match is copied verbatim,
|
|
324
|
+
* since projecting it onto one could strip what the other requires.
|
|
325
|
+
*/
|
|
326
|
+
function projectPatternKeys(
|
|
327
|
+
value: Record<string, unknown>,
|
|
328
|
+
out: Record<string, unknown>,
|
|
329
|
+
declared: ReadonlySet<string>,
|
|
330
|
+
patterns: ReadonlyArray<readonly [RegExp, Projector | undefined]>,
|
|
331
|
+
closed: boolean,
|
|
332
|
+
): void {
|
|
333
|
+
for (const key of Object.keys(value)) {
|
|
334
|
+
if (declared.has(key)) continue
|
|
335
|
+
let projector: Projector | undefined
|
|
336
|
+
let matches = 0
|
|
337
|
+
for (const [pattern, child] of patterns)
|
|
338
|
+
if (pattern.test(key)) {
|
|
339
|
+
projector = child
|
|
340
|
+
matches++
|
|
341
|
+
}
|
|
342
|
+
if (matches === 0) continue
|
|
343
|
+
if (matches === 1 && projector !== undefined) define(out, key, projector(value[key]))
|
|
344
|
+
else if (closed) define(out, key, value[key])
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** `__proto__` is a declared name like any other here; plain assignment would set the prototype. */
|
|
349
|
+
function define(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
350
|
+
if (key === "__proto__")
|
|
351
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
352
|
+
else out[key] = value
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const MAX_STRIPPED_NAMES = 20
|
|
356
|
+
const MAX_NAME_LENGTH = 64
|
|
357
|
+
const MAX_REPORTED_SETS = 32
|
|
358
|
+
|
|
359
|
+
/** Paths in what the handler returned that are absent from what was sent, array items as `name[]`.
|
|
360
|
+
* Stops one past {@link MAX_STRIPPED_NAMES}, so the log can say there were more. */
|
|
361
|
+
function collectStripped(sent: unknown, kept: unknown, path: string, out: Set<string>): void {
|
|
362
|
+
if (Array.isArray(sent)) {
|
|
363
|
+
if (!Array.isArray(kept)) return
|
|
364
|
+
const items = `${path}[]`
|
|
365
|
+
for (let i = 0; i < sent.length && i < kept.length; i++) {
|
|
366
|
+
collectStripped(sent[i], kept[i], items, out)
|
|
367
|
+
if (out.size > MAX_STRIPPED_NAMES) return
|
|
368
|
+
}
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (!isPlainObject(sent) || !isPlainObject(kept)) return
|
|
372
|
+
for (const key of Object.keys(sent)) {
|
|
373
|
+
const short = key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}...` : key
|
|
374
|
+
const name = path === "" ? short : `${path}.${short}`
|
|
375
|
+
if (!Object.hasOwn(kept, key)) out.add(name)
|
|
376
|
+
else collectStripped(sent[key], kept[key], name, out)
|
|
377
|
+
if (out.size > MAX_STRIPPED_NAMES) return
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The log line for a route's stripped names the first time it sends that set, else `undefined`. */
|
|
382
|
+
function firstReport(
|
|
383
|
+
reported: WeakMap<ResponseContractDefinition, Set<string>>,
|
|
384
|
+
definition: ResponseContractDefinition,
|
|
385
|
+
fields: readonly string[],
|
|
386
|
+
): string | undefined {
|
|
387
|
+
if (fields.length === 0) return undefined
|
|
388
|
+
let seen = reported.get(definition)
|
|
389
|
+
if (seen === undefined) {
|
|
390
|
+
seen = new Set()
|
|
391
|
+
reported.set(definition, seen)
|
|
392
|
+
}
|
|
393
|
+
const key = fields.join("\n")
|
|
394
|
+
if (seen.size >= MAX_REPORTED_SETS || seen.has(key)) return undefined
|
|
395
|
+
seen.add(key)
|
|
396
|
+
const last =
|
|
397
|
+
seen.size === MAX_REPORTED_SETS ? " Further sets of fields on this route are not logged." : ""
|
|
398
|
+
return `${strippedMessage(fields)}${last}`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function strippedMessage(fields: readonly string[]): string {
|
|
402
|
+
const listed =
|
|
403
|
+
fields.length > MAX_STRIPPED_NAMES
|
|
404
|
+
? `${fields.slice(0, MAX_STRIPPED_NAMES).join(", ")}, and more`
|
|
405
|
+
: fields.join(", ")
|
|
406
|
+
return `response carried fields its contract does not declare: ${listed}. They were not sent.`
|
|
407
|
+
}
|
|
408
|
+
|
|
83
409
|
/** Top-level keys present in the handler's result but absent from the validated value. */
|
|
84
410
|
function droppedKeys(result: unknown, value: unknown): string[] {
|
|
85
411
|
if (!isPlainObject(result) || !isPlainObject(value)) return []
|
|
@@ -96,8 +422,9 @@ export function checkResponseContract(
|
|
|
96
422
|
schema: StandardSchemaV1,
|
|
97
423
|
result: unknown,
|
|
98
424
|
mode: "warn" | "enforce",
|
|
425
|
+
options?: ResponseContractOptions,
|
|
99
426
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
100
|
-
return checkRouteResponseContract({ response: schema }, result, mode)
|
|
427
|
+
return checkRouteResponseContract({ response: schema }, result, mode, undefined, options)
|
|
101
428
|
}
|
|
102
429
|
|
|
103
430
|
/** The schema a reply with this status is held to: `response` for 2xx, else `errors[status]`. */
|
|
@@ -127,12 +454,39 @@ export function checkRouteResponseContract(
|
|
|
127
454
|
result: unknown,
|
|
128
455
|
mode: "warn" | "enforce",
|
|
129
456
|
jsonReply?: { readonly body: unknown },
|
|
457
|
+
options?: ResponseContractOptions,
|
|
458
|
+
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
459
|
+
return checkContract(
|
|
460
|
+
definition,
|
|
461
|
+
result,
|
|
462
|
+
mode,
|
|
463
|
+
jsonReply,
|
|
464
|
+
options?.extra === "strip" ? STRIP : KEEP,
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** How the plugin holds a payload: projected first or not, and the stripped-field reporter. */
|
|
469
|
+
interface ContractPolicy {
|
|
470
|
+
readonly strip: boolean
|
|
471
|
+
/** The message to log for these stripped names, or `undefined` to stay quiet. */
|
|
472
|
+
readonly report: ((fields: readonly string[]) => string | undefined) | undefined
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const STRIP: ContractPolicy = { strip: true, report: undefined }
|
|
476
|
+
const KEEP: ContractPolicy = { strip: false, report: undefined }
|
|
477
|
+
|
|
478
|
+
function checkContract(
|
|
479
|
+
definition: ResponseContractDefinition,
|
|
480
|
+
result: unknown,
|
|
481
|
+
mode: "warn" | "enforce",
|
|
482
|
+
jsonReply: { readonly body: unknown } | undefined,
|
|
483
|
+
policy: ContractPolicy,
|
|
130
484
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
131
485
|
const plain = isResponseResult(result) ? result.plain : undefined
|
|
132
486
|
if (plain !== undefined) {
|
|
133
487
|
const schema = schemaFor(definition, plain.status)
|
|
134
488
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
135
|
-
return validateContract(schema, plain.body, result, mode, (value) =>
|
|
489
|
+
return validateContract(schema, plain.body, result, mode, policy, (value) =>
|
|
136
490
|
status(
|
|
137
491
|
plain.status,
|
|
138
492
|
value,
|
|
@@ -143,7 +497,7 @@ export function checkRouteResponseContract(
|
|
|
143
497
|
if (jsonReply !== undefined && result instanceof Response) {
|
|
144
498
|
const schema = schemaFor(definition, result.status)
|
|
145
499
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
146
|
-
return validateContract(schema, jsonReply.body, result, mode, (value) =>
|
|
500
|
+
return validateContract(schema, jsonReply.body, result, mode, policy, (value) =>
|
|
147
501
|
reserialize(result, value),
|
|
148
502
|
)
|
|
149
503
|
}
|
|
@@ -155,7 +509,7 @@ export function checkRouteResponseContract(
|
|
|
155
509
|
if (result instanceof Response || result === undefined) return { kind: "ok", value: result }
|
|
156
510
|
const schema = definition.response
|
|
157
511
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
158
|
-
return validateContract(schema, result, result, mode, (value) => value)
|
|
512
|
+
return validateContract(schema, result, result, mode, policy, (value) => value)
|
|
159
513
|
}
|
|
160
514
|
|
|
161
515
|
/** Validate `payload`; `served` is what goes out unchanged when nothing is enforced - the whole
|
|
@@ -165,12 +519,14 @@ function validateContract(
|
|
|
165
519
|
payload: unknown,
|
|
166
520
|
served: unknown,
|
|
167
521
|
mode: "warn" | "enforce",
|
|
522
|
+
policy: ContractPolicy,
|
|
168
523
|
replace: (value: unknown) => unknown,
|
|
169
524
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
170
|
-
const
|
|
525
|
+
const project = policy.strip ? projectorOf(schema) : undefined
|
|
526
|
+
const settled = schema["~standard"].validate(project === undefined ? payload : project(payload))
|
|
171
527
|
return settled instanceof Promise
|
|
172
|
-
? settled.then((r) => interpret(r, payload, served, mode, replace))
|
|
173
|
-
: interpret(settled, payload, served, mode, replace)
|
|
528
|
+
? settled.then((r) => interpret(r, payload, served, mode, replace, policy.report))
|
|
529
|
+
: interpret(settled, payload, served, mode, replace, policy.report)
|
|
174
530
|
}
|
|
175
531
|
|
|
176
532
|
function interpret(
|
|
@@ -179,6 +535,7 @@ function interpret(
|
|
|
179
535
|
served: unknown,
|
|
180
536
|
mode: "warn" | "enforce",
|
|
181
537
|
replace: (value: unknown) => unknown,
|
|
538
|
+
report: ContractPolicy["report"],
|
|
182
539
|
): ResponseContractOutcome {
|
|
183
540
|
if (settled.issues !== undefined) {
|
|
184
541
|
const message = `response does not satisfy its declared contract: ${formatStandardIssues(settled.issues)}`
|
|
@@ -188,14 +545,22 @@ function interpret(
|
|
|
188
545
|
? { kind: "warn", value: served, message }
|
|
189
546
|
: { kind: "violation", message }
|
|
190
547
|
}
|
|
548
|
+
if (mode === "enforce") {
|
|
549
|
+
const value = replace(settled.value)
|
|
550
|
+
if (report === undefined) return { kind: "ok", value }
|
|
551
|
+
const stripped = new Set<string>()
|
|
552
|
+
collectStripped(payload, settled.value, "", stripped)
|
|
553
|
+
const message = report([...stripped].sort())
|
|
554
|
+
return message === undefined ? { kind: "ok", value } : { kind: "warn", value, message }
|
|
555
|
+
}
|
|
191
556
|
const dropped = droppedKeys(payload, settled.value)
|
|
192
|
-
if (mode === "enforce") return { kind: "ok", value: replace(settled.value) }
|
|
193
557
|
return dropped.length === 0
|
|
194
558
|
? { kind: "ok", value: served }
|
|
195
559
|
: {
|
|
196
560
|
kind: "warn",
|
|
197
561
|
value: served,
|
|
198
|
-
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract
|
|
562
|
+
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract("enforce")\` would strip them.`,
|
|
563
|
+
fields: dropped,
|
|
199
564
|
}
|
|
200
565
|
}
|
|
201
566
|
|
|
@@ -208,7 +573,11 @@ export interface ResponseContractRuntime {
|
|
|
208
573
|
check(
|
|
209
574
|
definition: ResponseContractDefinition,
|
|
210
575
|
result: unknown,
|
|
211
|
-
context?: {
|
|
576
|
+
context?: {
|
|
577
|
+
readonly req?: Request
|
|
578
|
+
readonly jsonReply?: Response | undefined
|
|
579
|
+
readonly jsonBody?: unknown
|
|
580
|
+
},
|
|
212
581
|
): ResponseContractOutcome | Promise<ResponseContractOutcome>
|
|
213
582
|
}
|
|
214
583
|
|
|
@@ -216,13 +585,27 @@ interface ResponseContractInstallable {
|
|
|
216
585
|
[INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void
|
|
217
586
|
}
|
|
218
587
|
|
|
588
|
+
/** What the plugin reads off a server it was applied to, to name a route and to log a failing hook. */
|
|
589
|
+
interface ContractHost {
|
|
590
|
+
readonly catalog: {
|
|
591
|
+
entries(): readonly {
|
|
592
|
+
readonly method: string
|
|
593
|
+
readonly path: string
|
|
594
|
+
readonly entry: { readonly responseContract?: { readonly definition: object } }
|
|
595
|
+
}[]
|
|
596
|
+
}
|
|
597
|
+
readonly logger: { error(message: string, fields: { detail: string }): void }
|
|
598
|
+
}
|
|
599
|
+
|
|
219
600
|
/**
|
|
220
601
|
* Hold every route's declared `response` schema to what the handler actually returned.
|
|
221
602
|
*
|
|
222
|
-
* app.use(responseContract("enforce"))
|
|
603
|
+
* app.use(responseContract("enforce")) // a closed t.object -> 500 on extras
|
|
604
|
+
* app.use(responseContract("enforce", { extra: "strip" })) // extras dropped, declared fields sent
|
|
223
605
|
*
|
|
224
606
|
* Install it before the routes it should cover - like `idempotency()`, the decision is made per route
|
|
225
|
-
* at registration, so routes registered earlier are not retroactively covered.
|
|
607
|
+
* at registration, so routes registered earlier are not retroactively covered. It covers routes on
|
|
608
|
+
* the server it is installed on, its `group()`s, and servers `merge()`d into it afterwards.
|
|
226
609
|
*
|
|
227
610
|
* The check itself is cheap - with a compiled validator it measures in the ~100ns-per-response
|
|
228
611
|
* range, within benchmark noise of an uncontracted route on any route that carries middleware or a
|
|
@@ -231,21 +614,105 @@ interface ResponseContractInstallable {
|
|
|
231
614
|
* taking anyway. Opt-in because not installing the plugin keeps the lane out of the bundle, not
|
|
232
615
|
* because enforcement is expensive.
|
|
233
616
|
*/
|
|
234
|
-
export function responseContract(
|
|
617
|
+
export function responseContract(
|
|
618
|
+
mode: ResponseContractMode = "warn",
|
|
619
|
+
options: ResponseContractOptions = {},
|
|
620
|
+
): IdentityPlugin {
|
|
621
|
+
if (options.extra !== undefined && options.extra !== "reject" && options.extra !== "strip") {
|
|
622
|
+
throw new TypeError(
|
|
623
|
+
`responseContract(): extra must be "reject" or "strip", got ${JSON.stringify(options.extra)}`,
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
if (options.logStripped !== undefined && typeof options.logStripped !== "boolean") {
|
|
627
|
+
throw new TypeError(
|
|
628
|
+
`responseContract(): logStripped must be a boolean, got ${JSON.stringify(options.logStripped)}`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
const onReport = options.onReport
|
|
632
|
+
const strip = options.extra === "strip"
|
|
633
|
+
const quiet = strip ? STRIP : KEEP
|
|
634
|
+
const reported =
|
|
635
|
+
options.logStripped === true && mode === "enforce"
|
|
636
|
+
? new WeakMap<ResponseContractDefinition, Set<string>>()
|
|
637
|
+
: undefined
|
|
638
|
+
const hosts: ContractHost[] = []
|
|
639
|
+
// A definition is compiled per route into the catalog of a server this plugin was applied to (a
|
|
640
|
+
// `group()`'s and a `merge()`d server's routes are adopted into it), so the route is found there
|
|
641
|
+
// and naming it costs the kernel nothing.
|
|
642
|
+
const routes = new WeakMap<object, { readonly method: string; readonly path: string }>()
|
|
643
|
+
let failed = false
|
|
644
|
+
// A hook that throws or rejects (or is not a function) is logged once and never reaches a response.
|
|
645
|
+
const fail = (error: unknown): void => {
|
|
646
|
+
if (!failed)
|
|
647
|
+
hosts[0]?.logger.error("response contract onReport threw", { detail: String(error) })
|
|
648
|
+
failed = true
|
|
649
|
+
}
|
|
235
650
|
const runtime: ResponseContractRuntime = {
|
|
236
651
|
mode,
|
|
237
|
-
check: (definition, result, context) =>
|
|
238
|
-
|
|
239
|
-
definition,
|
|
240
|
-
result,
|
|
241
|
-
mode,
|
|
652
|
+
check: (definition, result, context) => {
|
|
653
|
+
const jsonReply =
|
|
242
654
|
result instanceof Response && result === context?.jsonReply
|
|
243
655
|
? { body: context.jsonBody }
|
|
244
|
-
: undefined
|
|
245
|
-
|
|
656
|
+
: undefined
|
|
657
|
+
const logged =
|
|
658
|
+
reported === undefined
|
|
659
|
+
? undefined
|
|
660
|
+
: (fields: readonly string[]) => firstReport(reported, definition, fields)
|
|
661
|
+
if (onReport === undefined)
|
|
662
|
+
return checkContract(
|
|
663
|
+
definition,
|
|
664
|
+
result,
|
|
665
|
+
mode,
|
|
666
|
+
jsonReply,
|
|
667
|
+
logged === undefined ? quiet : { strip, report: logged },
|
|
668
|
+
)
|
|
669
|
+
const send = (
|
|
670
|
+
kind: ResponseContractReport["kind"],
|
|
671
|
+
message: string,
|
|
672
|
+
fields?: readonly string[],
|
|
673
|
+
) => {
|
|
674
|
+
if (!routes.has(definition))
|
|
675
|
+
for (const host of hosts)
|
|
676
|
+
for (const route of host.catalog.entries()) {
|
|
677
|
+
const owned = route.entry.responseContract?.definition
|
|
678
|
+
if (owned !== undefined) routes.set(owned, route)
|
|
679
|
+
}
|
|
680
|
+
const route = routes.get(definition)
|
|
681
|
+
try {
|
|
682
|
+
const returned: unknown = onReport({
|
|
683
|
+
kind,
|
|
684
|
+
method: route?.method ?? "",
|
|
685
|
+
route: route?.path ?? "",
|
|
686
|
+
fields,
|
|
687
|
+
message,
|
|
688
|
+
})
|
|
689
|
+
if (returned instanceof Promise) returned.catch(fail)
|
|
690
|
+
} catch (error) {
|
|
691
|
+
fail(error)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const outcome = checkContract(definition, result, mode, jsonReply, {
|
|
695
|
+
strip,
|
|
696
|
+
report: (fields) => {
|
|
697
|
+
if (fields.length > 0) send("stripped", strippedMessage(fields), fields)
|
|
698
|
+
return logged?.(fields)
|
|
699
|
+
},
|
|
700
|
+
})
|
|
701
|
+
const observe = (settled: ResponseContractOutcome): ResponseContractOutcome => {
|
|
702
|
+
// An enforce-mode warn is the stripped-field log line, already reported as `stripped`.
|
|
703
|
+
if (settled.kind === "violation") send("violation", settled.message)
|
|
704
|
+
else if (settled.kind === "warn" && mode === "warn")
|
|
705
|
+
send("warn", settled.message, settled.fields)
|
|
706
|
+
return settled
|
|
707
|
+
}
|
|
708
|
+
return outcome instanceof Promise ? outcome.then(observe) : observe(outcome)
|
|
709
|
+
},
|
|
246
710
|
}
|
|
247
711
|
const apply = <S extends AnyServer>(app: S): S => {
|
|
248
|
-
|
|
712
|
+
// A server also holds the route catalog and the logger ContractHost reads.
|
|
713
|
+
const host = app as unknown as ResponseContractInstallable & ContractHost
|
|
714
|
+
host[INSTALL_RESPONSE_CONTRACT](runtime)
|
|
715
|
+
if (!hosts.includes(host)) hosts.push(host)
|
|
249
716
|
return app
|
|
250
717
|
}
|
|
251
718
|
return Object.assign(apply, { pluginName: "nifra:response-contract" }) as IdentityPlugin
|