@nifrajs/core 5.1.0 → 5.3.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 (92) hide show
  1. package/CHANGELOG.md +2409 -0
  2. package/dist/edge-kit.d.ts +1 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +1 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/idempotency.d.ts.map +1 -1
  7. package/dist/idempotency.js +2 -0
  8. package/dist/idempotency.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/internal/hook-origins.d.ts +17 -1
  12. package/dist/internal/hook-origins.d.ts.map +1 -1
  13. package/dist/internal/hook-origins.js +20 -0
  14. package/dist/internal/hook-origins.js.map +1 -1
  15. package/dist/internal/route-program.d.ts +2 -0
  16. package/dist/internal/route-program.d.ts.map +1 -1
  17. package/dist/internal/route-program.js +22 -17
  18. package/dist/internal/route-program.js.map +1 -1
  19. package/dist/mount.d.ts +12 -0
  20. package/dist/mount.d.ts.map +1 -1
  21. package/dist/mount.js +20 -0
  22. package/dist/mount.js.map +1 -1
  23. package/dist/reflection.d.ts +23 -2
  24. package/dist/reflection.d.ts.map +1 -1
  25. package/dist/reflection.js +57 -1
  26. package/dist/reflection.js.map +1 -1
  27. package/dist/server/context.d.ts +7 -4
  28. package/dist/server/context.d.ts.map +1 -1
  29. package/dist/server/core-copies.d.ts +7 -1
  30. package/dist/server/core-copies.d.ts.map +1 -1
  31. package/dist/server/core-copies.js +37 -1
  32. package/dist/server/core-copies.js.map +1 -1
  33. package/dist/server/headers.d.ts +9 -0
  34. package/dist/server/headers.d.ts.map +1 -1
  35. package/dist/server/headers.js +26 -0
  36. package/dist/server/headers.js.map +1 -1
  37. package/dist/server/registry.d.ts +8 -10
  38. package/dist/server/registry.d.ts.map +1 -1
  39. package/dist/server/request-context.d.ts.map +1 -1
  40. package/dist/server/request-context.js +4 -3
  41. package/dist/server/request-context.js.map +1 -1
  42. package/dist/server/request-target.d.ts +9 -7
  43. package/dist/server/request-target.d.ts.map +1 -1
  44. package/dist/server/request-target.js +21 -12
  45. package/dist/server/request-target.js.map +1 -1
  46. package/dist/server/respond.d.ts.map +1 -1
  47. package/dist/server/respond.js +10 -7
  48. package/dist/server/respond.js.map +1 -1
  49. package/dist/server/response-contract-lane.d.ts +37 -2
  50. package/dist/server/response-contract-lane.d.ts.map +1 -1
  51. package/dist/server/response-contract-lane.js +278 -32
  52. package/dist/server/response-contract-lane.js.map +1 -1
  53. package/dist/server/server-types.d.ts +7 -4
  54. package/dist/server/server-types.d.ts.map +1 -1
  55. package/dist/server/server.d.ts +17 -4
  56. package/dist/server/server.d.ts.map +1 -1
  57. package/dist/server/server.js +184 -40
  58. package/dist/server/server.js.map +1 -1
  59. package/dist/server/websocket.d.ts.map +1 -1
  60. package/dist/server/websocket.js +4 -0
  61. package/dist/server/websocket.js.map +1 -1
  62. package/dist/single-copy.d.ts.map +1 -1
  63. package/dist/single-copy.js +46 -5
  64. package/dist/single-copy.js.map +1 -1
  65. package/dist/tool-contract.d.ts.map +1 -1
  66. package/dist/tool-contract.js +8 -4
  67. package/dist/tool-contract.js.map +1 -1
  68. package/dist/transport-codec.d.ts.map +1 -1
  69. package/dist/transport-codec.js +4 -1
  70. package/dist/transport-codec.js.map +1 -1
  71. package/package.json +3 -2
  72. package/src/edge-kit.ts +1 -1
  73. package/src/idempotency.ts +2 -0
  74. package/src/index.ts +1 -1
  75. package/src/internal/hook-origins.ts +35 -0
  76. package/src/internal/route-program.ts +39 -20
  77. package/src/mount.ts +25 -0
  78. package/src/reflection.ts +61 -1
  79. package/src/server/context.ts +7 -4
  80. package/src/server/core-copies.ts +40 -0
  81. package/src/server/headers.ts +39 -0
  82. package/src/server/registry.ts +18 -19
  83. package/src/server/request-context.ts +6 -3
  84. package/src/server/request-target.ts +21 -12
  85. package/src/server/respond.ts +10 -7
  86. package/src/server/response-contract-lane.ts +357 -42
  87. package/src/server/server-types.ts +7 -4
  88. package/src/server/server.ts +235 -35
  89. package/src/server/websocket.ts +3 -0
  90. package/src/single-copy.ts +55 -5
  91. package/src/tool-contract.ts +7 -4
  92. package/src/transport-codec.ts +4 -1
@@ -70,10 +70,43 @@ export interface ResponseContractOptions {
70
70
  * closed `t.object` reports them, so an enforced response becomes a 500 and the log names them.
71
71
  * `"strip"` drops them first, for any schema exposing its JSON Schema as `jsonSchema` (every
72
72
  * `@nifrajs/schema` `t` schema) - nested objects, arrays and records included. A union is projected
73
- * only when exactly one branch can hold an object. Schemas without `jsonSchema` (zod, valibot) are
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
74
76
  * unaffected: they already strip or reject by their own rules.
75
77
  */
76
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
77
110
  }
78
111
 
79
112
  /** Success and status-specific error schemas captured by one route at registration time. */
@@ -86,8 +119,14 @@ export interface ResponseContractDefinition {
86
119
  export type ResponseContractOutcome =
87
120
  /** Nothing to report. `value` is what should be serialized (the original result, or the cleaned one). */
88
121
  | { readonly kind: "ok"; readonly value: unknown }
89
- /** `warn` only: the payload differed from the contract. The ORIGINAL result is still served. */
90
- | { 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
+ }
91
130
  /** `enforce` only: the payload cannot be reconciled with the contract, so it must not be sent. */
92
131
  | { readonly kind: "violation"; readonly message: string }
93
132
 
@@ -111,11 +150,6 @@ function projectorOf(schema: StandardSchemaV1): Projector | undefined {
111
150
  return found ?? undefined
112
151
  }
113
152
 
114
- const canHoldObject = (node: object): boolean => {
115
- const type: unknown = Reflect.get(node, "type")
116
- return type === undefined || type === "object" || type === "array"
117
- }
118
-
119
153
  const objectField = (node: object, key: string): object | undefined => {
120
154
  const value: unknown = Reflect.get(node, key)
121
155
  return typeof value === "object" && value !== null ? value : undefined
@@ -126,18 +160,7 @@ function compileProjector(node: unknown): Projector | undefined {
126
160
  const schema: object = node
127
161
  const type: unknown = Reflect.get(schema, "type")
128
162
  const branches: unknown = Reflect.get(schema, "anyOf") ?? Reflect.get(schema, "oneOf")
129
- if (Array.isArray(branches)) {
130
- // Which branch a value meets is the validator's call; projecting onto one of two object branches
131
- // could strip the fields that make the other match.
132
- const holders = branches.filter(
133
- (branch): branch is object =>
134
- typeof branch === "object" && branch !== null && canHoldObject(branch),
135
- )
136
- const only = holders.length === 1 ? compileProjector(holders[0]) : undefined
137
- return only === undefined
138
- ? undefined
139
- : (value) => (typeof value === "object" && value !== null ? only(value) : value)
140
- }
163
+ if (Array.isArray(branches)) return compileUnion(branches)
141
164
  if (type === "array") {
142
165
  const items: unknown = Reflect.get(schema, "items")
143
166
  const item = Array.isArray(items) ? undefined : compileProjector(items)
@@ -171,8 +194,7 @@ function compileProjector(node: unknown): Projector | undefined {
171
194
  if (!isPlainObject(value)) return value
172
195
  const out: Record<string, unknown> = closed ? {} : { ...value }
173
196
  for (const key of closed ? keys : nested.keys()) {
174
- // A getter on a row class's prototype is a declared field; `Object.prototype`'s members are not.
175
- if (!Object.hasOwn(value, key) && (!(key in value) || key in Object.prototype)) continue
197
+ if (!hasField(value, key)) continue
176
198
  const projector = nested.get(key)
177
199
  define(out, key, projector === undefined ? value[key] : projector(value[key]))
178
200
  }
@@ -181,6 +203,121 @@ function compileProjector(node: unknown): Projector | undefined {
181
203
  }
182
204
  }
183
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
+
184
321
  /**
185
322
  * Carry the undeclared keys a `patternProperties` entry admits: a closed record's keys are all
186
323
  * pattern keys, so dropping them empties a valid value. A key two patterns match is copied verbatim,
@@ -215,6 +352,60 @@ function define(out: Record<string, unknown>, key: string, value: unknown): void
215
352
  else out[key] = value
216
353
  }
217
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
+
218
409
  /** Top-level keys present in the handler's result but absent from the validated value. */
219
410
  function droppedKeys(result: unknown, value: unknown): string[] {
220
411
  if (!isPlainObject(result) || !isPlainObject(value)) return []
@@ -265,12 +456,37 @@ export function checkRouteResponseContract(
265
456
  jsonReply?: { readonly body: unknown },
266
457
  options?: ResponseContractOptions,
267
458
  ): ResponseContractOutcome | Promise<ResponseContractOutcome> {
268
- const strip = options?.extra === "strip"
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,
484
+ ): ResponseContractOutcome | Promise<ResponseContractOutcome> {
269
485
  const plain = isResponseResult(result) ? result.plain : undefined
270
486
  if (plain !== undefined) {
271
487
  const schema = schemaFor(definition, plain.status)
272
488
  if (schema === undefined) return { kind: "ok", value: result }
273
- return validateContract(schema, plain.body, result, mode, strip, (value) =>
489
+ return validateContract(schema, plain.body, result, mode, policy, (value) =>
274
490
  status(
275
491
  plain.status,
276
492
  value,
@@ -281,7 +497,7 @@ export function checkRouteResponseContract(
281
497
  if (jsonReply !== undefined && result instanceof Response) {
282
498
  const schema = schemaFor(definition, result.status)
283
499
  if (schema === undefined) return { kind: "ok", value: result }
284
- return validateContract(schema, jsonReply.body, result, mode, strip, (value) =>
500
+ return validateContract(schema, jsonReply.body, result, mode, policy, (value) =>
285
501
  reserialize(result, value),
286
502
  )
287
503
  }
@@ -293,7 +509,7 @@ export function checkRouteResponseContract(
293
509
  if (result instanceof Response || result === undefined) return { kind: "ok", value: result }
294
510
  const schema = definition.response
295
511
  if (schema === undefined) return { kind: "ok", value: result }
296
- return validateContract(schema, result, result, mode, strip, (value) => value)
512
+ return validateContract(schema, result, result, mode, policy, (value) => value)
297
513
  }
298
514
 
299
515
  /** Validate `payload`; `served` is what goes out unchanged when nothing is enforced - the whole
@@ -303,14 +519,14 @@ function validateContract(
303
519
  payload: unknown,
304
520
  served: unknown,
305
521
  mode: "warn" | "enforce",
306
- strip: boolean,
522
+ policy: ContractPolicy,
307
523
  replace: (value: unknown) => unknown,
308
524
  ): ResponseContractOutcome | Promise<ResponseContractOutcome> {
309
- const project = strip ? projectorOf(schema) : undefined
525
+ const project = policy.strip ? projectorOf(schema) : undefined
310
526
  const settled = schema["~standard"].validate(project === undefined ? payload : project(payload))
311
527
  return settled instanceof Promise
312
- ? settled.then((r) => interpret(r, payload, served, mode, replace))
313
- : 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)
314
530
  }
315
531
 
316
532
  function interpret(
@@ -319,6 +535,7 @@ function interpret(
319
535
  served: unknown,
320
536
  mode: "warn" | "enforce",
321
537
  replace: (value: unknown) => unknown,
538
+ report: ContractPolicy["report"],
322
539
  ): ResponseContractOutcome {
323
540
  if (settled.issues !== undefined) {
324
541
  const message = `response does not satisfy its declared contract: ${formatStandardIssues(settled.issues)}`
@@ -328,14 +545,22 @@ function interpret(
328
545
  ? { kind: "warn", value: served, message }
329
546
  : { kind: "violation", message }
330
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
+ }
331
556
  const dropped = droppedKeys(payload, settled.value)
332
- if (mode === "enforce") return { kind: "ok", value: replace(settled.value) }
333
557
  return dropped.length === 0
334
558
  ? { kind: "ok", value: served }
335
559
  : {
336
560
  kind: "warn",
337
561
  value: served,
338
562
  message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract("enforce")\` would strip them.`,
563
+ fields: dropped,
339
564
  }
340
565
  }
341
566
 
@@ -348,7 +573,11 @@ export interface ResponseContractRuntime {
348
573
  check(
349
574
  definition: ResponseContractDefinition,
350
575
  result: unknown,
351
- context?: { readonly jsonReply?: Response | undefined; readonly jsonBody?: unknown },
576
+ context?: {
577
+ readonly req?: Request
578
+ readonly jsonReply?: Response | undefined
579
+ readonly jsonBody?: unknown
580
+ },
352
581
  ): ResponseContractOutcome | Promise<ResponseContractOutcome>
353
582
  }
354
583
 
@@ -356,6 +585,18 @@ interface ResponseContractInstallable {
356
585
  [INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void
357
586
  }
358
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
+
359
600
  /**
360
601
  * Hold every route's declared `response` schema to what the handler actually returned.
361
602
  *
@@ -382,22 +623,96 @@ export function responseContract(
382
623
  `responseContract(): extra must be "reject" or "strip", got ${JSON.stringify(options.extra)}`,
383
624
  )
384
625
  }
385
- const contractOptions = options.extra === "strip" ? options : undefined
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
+ }
386
650
  const runtime: ResponseContractRuntime = {
387
651
  mode,
388
- check: (definition, result, context) =>
389
- checkRouteResponseContract(
390
- definition,
391
- result,
392
- mode,
652
+ check: (definition, result, context) => {
653
+ const jsonReply =
393
654
  result instanceof Response && result === context?.jsonReply
394
655
  ? { body: context.jsonBody }
395
- : undefined,
396
- contractOptions,
397
- ),
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
+ },
398
710
  }
399
711
  const apply = <S extends AnyServer>(app: S): S => {
400
- ;(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)
401
716
  return app
402
717
  }
403
718
  return Object.assign(apply, { pluginName: "nifra:response-contract" }) as IdentityPlugin
@@ -164,10 +164,13 @@ export interface ServerOptions {
164
164
  */
165
165
  readonly onValidationError?: RouteSchema["onValidationError"]
166
166
  /**
167
- * What to do when an order-scoped hook (`derive`/`beforeHandle`/`around`/`aroundCapability`/
168
- * `afterHandle`/`onError`/`decorate`) covers no route because it was added after the last route was
169
- * registered. `register()` snapshots the current chain into each route as it is declared, so a hook
170
- * appended after every route reaches nothing and is silently dead. `"warn"` (default) logs once at
167
+ * What to do when an order-scoped hook (`derive`/`authenticate`/`beforeHandle`/`around`/
168
+ * `aroundCapability`/`afterHandle`/`onError`/`decorate`) covers no route because it was added after
169
+ * the last route was registered. `register()` snapshots the current chain into each route as it is
170
+ * declared, so a hook appended after every route reaches nothing and is silently dead. A `merge()`d
171
+ * route keeps the chain of the server that declared it, so a hook followed only by merges is dead
172
+ * too. The same report covers `responseContract()` and `effectLedger()` installed after routes they
173
+ * would have covered (a declared response, declared capabilities). `"warn"` (default) logs once at
171
174
  * seal (first `listen()`/`fetch`/`resolveNode`); `"error"` throws a `FrameworkError`
172
175
  * (`UNUSED_SCOPED_HOOKS`); `"off"` skips the bookkeeping entirely, including the one-frame stack
173
176
  * capture. App-global hooks (`onRequest`/`onResponse`/...) are unaffected in every mode.