@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +2384 -0
  2. package/dist/assurance.d.ts +15 -1
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +97 -71
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +2 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +1 -1
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/edge-kit.d.ts +1 -1
  11. package/dist/edge-kit.d.ts.map +1 -1
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/hook-origins.d.ts +17 -1
  18. package/dist/internal/hook-origins.d.ts.map +1 -1
  19. package/dist/internal/hook-origins.js +20 -0
  20. package/dist/internal/hook-origins.js.map +1 -1
  21. package/dist/internal/route-compiler.d.ts +9 -0
  22. package/dist/internal/route-compiler.d.ts.map +1 -1
  23. package/dist/internal/route-compiler.js +95 -13
  24. package/dist/internal/route-compiler.js.map +1 -1
  25. package/dist/internal/route-program.d.ts +2 -0
  26. package/dist/internal/route-program.d.ts.map +1 -1
  27. package/dist/internal/route-program.js +22 -17
  28. package/dist/internal/route-program.js.map +1 -1
  29. package/dist/internal/same-origin.d.ts +11 -1
  30. package/dist/internal/same-origin.d.ts.map +1 -1
  31. package/dist/internal/same-origin.js +8 -0
  32. package/dist/internal/same-origin.js.map +1 -1
  33. package/dist/reflection.d.ts +23 -2
  34. package/dist/reflection.d.ts.map +1 -1
  35. package/dist/reflection.js +57 -1
  36. package/dist/reflection.js.map +1 -1
  37. package/dist/server/body-lane.d.ts +8 -2
  38. package/dist/server/body-lane.d.ts.map +1 -1
  39. package/dist/server/body-lane.js +27 -2
  40. package/dist/server/body-lane.js.map +1 -1
  41. package/dist/server/client-ip.js +6 -5
  42. package/dist/server/client-ip.js.map +1 -1
  43. package/dist/server/context.d.ts +7 -4
  44. package/dist/server/context.d.ts.map +1 -1
  45. package/dist/server/cookies.d.ts.map +1 -1
  46. package/dist/server/cookies.js +13 -1
  47. package/dist/server/cookies.js.map +1 -1
  48. package/dist/server/core-copies.d.ts +7 -1
  49. package/dist/server/core-copies.d.ts.map +1 -1
  50. package/dist/server/core-copies.js +37 -1
  51. package/dist/server/core-copies.js.map +1 -1
  52. package/dist/server/edge.d.ts.map +1 -1
  53. package/dist/server/edge.js +2 -1
  54. package/dist/server/edge.js.map +1 -1
  55. package/dist/server/headers.d.ts +9 -0
  56. package/dist/server/headers.d.ts.map +1 -1
  57. package/dist/server/headers.js +26 -0
  58. package/dist/server/headers.js.map +1 -1
  59. package/dist/server/http.d.ts.map +1 -1
  60. package/dist/server/http.js +5 -1
  61. package/dist/server/http.js.map +1 -1
  62. package/dist/server/response-contract-lane.d.ts +57 -7
  63. package/dist/server/response-contract-lane.d.ts.map +1 -1
  64. package/dist/server/response-contract-lane.js +394 -23
  65. package/dist/server/response-contract-lane.js.map +1 -1
  66. package/dist/server/server-types.d.ts +23 -9
  67. package/dist/server/server-types.d.ts.map +1 -1
  68. package/dist/server/server.d.ts +40 -5
  69. package/dist/server/server.d.ts.map +1 -1
  70. package/dist/server/server.js +188 -43
  71. package/dist/server/server.js.map +1 -1
  72. package/dist/server/websocket.d.ts +11 -0
  73. package/dist/server/websocket.d.ts.map +1 -1
  74. package/dist/server/websocket.js +26 -12
  75. package/dist/server/websocket.js.map +1 -1
  76. package/dist/server/ws-bun.d.ts +7 -1
  77. package/dist/server/ws-bun.d.ts.map +1 -1
  78. package/dist/server/ws-bun.js +42 -2
  79. package/dist/server/ws-bun.js.map +1 -1
  80. package/dist/server/ws-hook.d.ts +9 -3
  81. package/dist/server/ws-hook.d.ts.map +1 -1
  82. package/dist/server.d.ts +1 -1
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/server.js +1 -1
  85. package/dist/server.js.map +1 -1
  86. package/dist/single-copy.d.ts.map +1 -1
  87. package/dist/single-copy.js +81 -10
  88. package/dist/single-copy.js.map +1 -1
  89. package/dist/wire.d.ts +5 -0
  90. package/dist/wire.d.ts.map +1 -1
  91. package/dist/wire.js +5 -0
  92. package/dist/wire.js.map +1 -1
  93. package/dist/ws.d.ts.map +1 -1
  94. package/dist/ws.js +2 -1
  95. package/dist/ws.js.map +1 -1
  96. package/package.json +3 -2
  97. package/src/assurance.ts +120 -81
  98. package/src/capabilities.ts +3 -1
  99. package/src/edge-kit.ts +1 -1
  100. package/src/index.ts +2 -2
  101. package/src/internal/hook-origins.ts +35 -0
  102. package/src/internal/route-compiler.ts +107 -16
  103. package/src/internal/route-program.ts +39 -20
  104. package/src/internal/same-origin.ts +14 -1
  105. package/src/reflection.ts +61 -1
  106. package/src/server/body-lane.ts +36 -1
  107. package/src/server/client-ip.ts +5 -5
  108. package/src/server/context.ts +7 -4
  109. package/src/server/cookies.ts +12 -1
  110. package/src/server/core-copies.ts +40 -0
  111. package/src/server/edge.ts +2 -1
  112. package/src/server/headers.ts +39 -0
  113. package/src/server/http.ts +7 -1
  114. package/src/server/response-contract-lane.ts +491 -24
  115. package/src/server/server-types.ts +23 -9
  116. package/src/server/server.ts +249 -35
  117. package/src/server/websocket.ts +39 -13
  118. package/src/server/ws-bun.ts +48 -1
  119. package/src/server/ws-hook.ts +9 -2
  120. package/src/server.ts +1 -1
  121. package/src/single-copy.ts +90 -9
  122. package/src/wire.ts +14 -0
  123. 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
- /** `warn` only: the payload differed from the contract. The ORIGINAL result is still served. */
76
- | { readonly kind: "warn"; readonly value: unknown; readonly message: string }
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 settled = schema["~standard"].validate(payload)
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: "enforce"\` would strip them.`,
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?: { readonly jsonReply?: Response | undefined; readonly jsonBody?: unknown },
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(mode: ResponseContractMode = "warn"): IdentityPlugin {
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
- checkRouteResponseContract(
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
- ;(app as unknown as ResponseContractInstallable)[INSTALL_RESPONSE_CONTRACT](runtime)
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