@nifrajs/core 2.10.0 → 2.12.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 (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +424 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +667 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. package/src/tool-contract.ts +896 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The all-lowercase header-name proof: one answer, computed once per request, read by everyone who
3
+ * would otherwise re-derive it.
4
+ *
5
+ * "Are these header names already the lowercase wire spelling?" is asked on every Node-direct
6
+ * response by the implicit-Content-Type lookup in the native response walk, by the portable header
7
+ * view's alias index, and by the `@nifrajs/node` writer's normalization gate. Each used to answer it
8
+ * by walking the same keys. Instead, whichever stage has ALREADY looked at the names - the static
9
+ * merge, which lowercases every own key anyway, or the native walk - records the answer on the
10
+ * record, and the readers take it.
11
+ *
12
+ * The mark is a plain enumerable symbol assignment on purpose. `Object.keys`, `Object.entries` and
13
+ * `for...in` never see symbol keys, so it cannot reach the wire, while a non-enumerable
14
+ * `defineProperty` would demote the record to V8's dictionary mode - the exact cost the record's `{}`
15
+ * literal shape exists to avoid. Spread and `Object.assign` DO copy it, which is what the clone paths
16
+ * want: a clone of an all-lowercase record is still all-lowercase.
17
+ *
18
+ * It is only ever SET where its truth is provable, and only where every writer that can still touch
19
+ * the record afterwards is case-normalizing. An app registering a raw `onNodeResponse` twin - handed
20
+ * the record itself rather than the case-normalizing view - is left unmarked, and every reader
21
+ * re-derives the answer exactly as it did before the mark existed.
22
+ *
23
+ * Declared with `Symbol.for` so the `@nifrajs/node` adapter can read it by key rather than importing
24
+ * across the package boundary - the same cross-package convention as the body and result marks.
25
+ */
26
+ const LOWERCASE_HEADER_KEYS = Symbol.for("nifra.headers.lowercase")
27
+
28
+ /** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
29
+ export function hasUpperAscii(name: string): boolean {
30
+ for (let i = 0; i < name.length; i++) {
31
+ const code = name.charCodeAt(i)
32
+ if (code >= 65 && code <= 90) return true
33
+ }
34
+ return false
35
+ }
36
+
37
+ /** True when every name in the record is already the lowercase wire spelling. */
38
+ export function headerKeysAllLowercase(record: Readonly<Record<string, unknown>>): boolean {
39
+ const keys = Object.keys(record)
40
+ for (let i = 0; i < keys.length; i++) {
41
+ if (hasUpperAscii(keys[i] as string)) return false
42
+ }
43
+ return true
44
+ }
45
+
46
+ /** Record the {@link LOWERCASE_HEADER_KEYS} proof on a record the caller has just verified. */
47
+ export function markLowercaseHeaderKeys(record: Record<string, string | readonly string[]>): void {
48
+ ;(record as Record<symbol, unknown>)[LOWERCASE_HEADER_KEYS] = true
49
+ }
50
+
51
+ /** True when this record carries the proof - never a claim the reader has to re-check. */
52
+ export function hasLowercaseHeaderKeysMark(record: Readonly<Record<string, unknown>>): boolean {
53
+ return (record as Record<symbol, unknown>)[LOWERCASE_HEADER_KEYS] === true
54
+ }
@@ -0,0 +1,11 @@
1
+ /** Materialize request headers for a Standard Schema boundary.
2
+ *
3
+ * HTTP field names are case-insensitive and `Headers` already combines repeated fields using the
4
+ * platform's defined comma-join behavior. A null-prototype record prevents a hostile field name
5
+ * such as `__proto__` or `constructor` from interacting with Object.prototype before validation.
6
+ */
7
+ export function headerObjectOf(headers: Headers): Record<string, string> {
8
+ const out: Record<string, string> = Object.create(null) as Record<string, string>
9
+ for (const [name, value] of headers) out[name.toLowerCase()] = value
10
+ return out
11
+ }
@@ -1,6 +1,7 @@
1
1
  /** Lazy Node-direct renderer seam. The base server retains the typed method but not its renderer. */
2
2
 
3
3
  import type { Platform } from "./context.ts"
4
+ import { hasLowercaseHeaderKeysMark, hasUpperAscii } from "./header-case.ts"
4
5
  import type { NodeServeOutcome } from "./node-outcome.ts"
5
6
  import type { CtxSet, MaybePromise } from "./server.ts"
6
7
 
@@ -126,6 +127,9 @@ class RecordHeadersView implements ResponseHeadersView {
126
127
 
127
128
  #prepare(record: Record<string, string | readonly string[]>): void {
128
129
  this.#prepared = true
130
+ // The native response walk proved this record's names before any twin ran (and every write this
131
+ // view makes stores the wire spelling), so the index is known-empty without touching a key.
132
+ if (hasLowercaseHeaderKeysMark(record)) return
129
133
  // `Object.keys`, not `for...in`: the record is a `{}`-prototype literal on the render paths, and
130
134
  // a for-in would let an enumerable `Object.prototype` property become a header on the wire.
131
135
  const keys = Object.keys(record)
@@ -136,24 +140,15 @@ class RecordHeadersView implements ResponseHeadersView {
136
140
  // framework, `c.set`, and middleware twins all write the wire spelling. An inline ASCII scan
137
141
  // answers the common case without leaving the loop, and only a genuinely mixed-case key (a
138
142
  // hand-written `X-Foo`) pays for the lowercase copy and the alias entry.
139
- if (!RecordHeadersView.#hasUpperAscii(key)) continue
143
+ if (!hasUpperAscii(key)) continue
140
144
  if (this.#alias === undefined) this.#alias = new Map()
141
145
  this.#alias.set(key.toLowerCase(), key)
142
146
  }
143
147
  }
144
148
 
145
- /** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
146
- static #hasUpperAscii(name: string): boolean {
147
- for (let i = 0; i < name.length; i++) {
148
- const code = name.charCodeAt(i)
149
- if (code >= 65 && code <= 90) return true
150
- }
151
- return false
152
- }
153
-
154
149
  /** The wire spelling of a header name, without paying `toLowerCase` when it already is one. */
155
150
  static #lower(name: string): string {
156
- return RecordHeadersView.#hasUpperAscii(name) ? name.toLowerCase() : name
151
+ return hasUpperAscii(name) ? name.toLowerCase() : name
157
152
  }
158
153
 
159
154
  /** The stored key for an already-lowercased name. Resolution is the prepared index ONLY - a
@@ -138,10 +138,18 @@ export function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
138
138
  * The record handed over is always a fresh copy: the Node writers mutate the outcome's record in
139
139
  * place (content-type, content-length, cookies), and the static record is shared by every request.
140
140
  * Its names are already lowercase, so the writer's all-lowercase fast path still holds.
141
+ *
142
+ * `markLowercase` publishes that fast path as a proof on the record instead of leaving each reader to
143
+ * re-derive it. This stage can answer it for free - the static names were lowercased at registration
144
+ * and the merge lowercases every own name anyway - and it runs BEFORE the response hooks, so it
145
+ * covers the hookless lane too, which never reaches the native walk that would otherwise mark. The
146
+ * caller passes `false` for an app carrying a raw `onNodeResponse` twin: that twin writes the record
147
+ * directly, past the case-normalizing view, after this point.
141
148
  */
142
149
  export function withStaticNodeHeaders(
143
150
  outcome: NodeServeOutcome,
144
151
  statics: StaticResponseHeaders,
152
+ markLowercase = false,
145
153
  ): NodeServeOutcome {
146
154
  if (outcome.kind === "response") {
147
155
  return { kind: "response", response: applyStaticResponseHeaders(outcome.response, statics) }
@@ -149,8 +157,8 @@ export function withStaticNodeHeaders(
149
157
  const own = outcome.headers
150
158
  const headers =
151
159
  own === undefined
152
- ? staticHeaderRecordCopy(statics)
153
- : mergeStaticHeaderRecord(statics.record, own)
160
+ ? staticHeaderRecordCopy(statics, markLowercase)
161
+ : mergeStaticHeaderRecord(statics.record, own, markLowercase)
154
162
  return { ...outcome, headers }
155
163
  }
156
164
 
@@ -3,7 +3,8 @@
3
3
  * `use`/`derive`/`decorate` and/or registering routes) and returns it; these helpers attach a name
4
4
  * for idempotent dedupe and pin the type-threading so `.use()` preserves the caller's typed server.
5
5
  */
6
- import type { AnyServer } from "./server.ts"
6
+ import type { Registry } from "./registry.ts"
7
+ import type { AnyServer, Server } from "./server.ts"
7
8
 
8
9
  /**
9
10
  * A nifra **plugin**: a function that augments an app - calling `use`/`derive`/`decorate` and/or
@@ -26,26 +27,104 @@ export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
26
27
  readonly pluginName?: string
27
28
  }
28
29
 
30
+ /**
31
+ * A named plugin built with {@link defineContextPlugin}: it adds the context `D` to every handler
32
+ * downstream while threading the caller's route registry and existing context UNCHANGED. This is the
33
+ * type {@link definePlugin} cannot express - `definePlugin` infers its parameter from an untyped
34
+ * `(app) => ...` arrow, which lands on `AnyServer`, so `use` returns `Server<any, any>`.
35
+ */
36
+ export type ContextPlugin<D extends object> = (<R extends Registry, Ctx>(
37
+ app: Server<R, Ctx>,
38
+ ) => Server<R, Ctx & D>) & { readonly pluginName?: string }
39
+
40
+ /**
41
+ * Define a named plugin that **adds typed context and nothing else** - the `derive` case - without
42
+ * collapsing the caller's types. `use` instantiates the generic signature against the concrete
43
+ * receiver, so `R` and `Ctx` come back unchanged with `D` intersected onto the context.
44
+ *
45
+ * Use this instead of {@link definePlugin} for every `(app) => app.derive(...)` plugin. `definePlugin`
46
+ * is only safe when the caller supplies `In`/`Out` explicitly; with the usual untyped arrow both
47
+ * default to `AnyServer` and the typed client silently collapses to `any`.
48
+ *
49
+ * ```ts
50
+ * export const requestId = defineContextPlugin<{ requestId: string }>("requestId", (app) =>
51
+ * app.derive(() => ({ requestId: crypto.randomUUID() })),
52
+ * )
53
+ * const api = server().get("/a", h).use(requestId).get("/b", h) // /a AND /b stay typed; c.requestId typed
54
+ * ```
55
+ */
56
+ export function defineContextPlugin<D extends object>(
57
+ name: string,
58
+ apply: <R extends Registry, Ctx>(app: Server<R, Ctx>) => Server<R, Ctx & D>,
59
+ ): ContextPlugin<D> {
60
+ return Object.assign(apply, { pluginName: name }) as ContextPlugin<D>
61
+ }
62
+
63
+ declare const COLLAPSED: unique symbol
64
+
65
+ /**
66
+ * What {@link definePlugin} returns when its `apply` argument never pinned the input server type -
67
+ * the `definePlugin("x", (app) => ...)` arrow, where `app` falls back to `AnyServer`.
68
+ *
69
+ * It is deliberately **not callable**, so `app.use(thatPlugin)` is a type error at the call site
70
+ * instead of a silent widening to `Server<any, any>` that surfaces hundreds of lines away as
71
+ * `Property 'x' does not exist on type 'Promise<Result<unknown, unknown>>'` in the typed client.
72
+ *
73
+ * The plugin still works at runtime. To fix the type, pick the definer that matches what it does:
74
+ *
75
+ * - adds context via `derive`/`decorate` -> {@link defineContextPlugin}
76
+ * - mounts routes/hooks and adds NO context -> {@link defineRouterPlugin} ({@link defineIdentityPlugin})
77
+ * - genuinely needs `definePlugin`: annotate the parameter (`(app: typeof api) => ...`) or pass
78
+ * explicit type arguments (`definePlugin<typeof api, typeof api>(...)`).
79
+ */
80
+ export interface PluginTypeCollapsed {
81
+ readonly [COLLAPSED]: "definePlugin erased the caller's server type: use defineContextPlugin (adds context) or defineRouterPlugin (routes/hooks only), or pin the input type"
82
+ }
83
+
84
+ /**
85
+ * True when `S`'s route registry is `any` - i.e. nothing pinned the plugin's input server type, so
86
+ * every route type flowing through it is about to be widened away.
87
+ */
88
+ // biome-ignore lint/suspicious/noExplicitAny: matches any Server shape to inspect its Registry alone
89
+ type ServerTypeUnpinned<S> = [S] extends [Server<infer R, any>]
90
+ ? 0 extends 1 & R
91
+ ? true
92
+ : false
93
+ : false
94
+
95
+ export type DefinePluginResult<In extends AnyServer, Out extends AnyServer> =
96
+ ServerTypeUnpinned<In> extends true ? PluginTypeCollapsed : NifraPlugin<In, Out>
97
+
29
98
  /**
30
99
  * Name + ergonomics for a plugin that **adds typed context** (`derive`/`decorate`). `app.use(myPlugin)`
31
100
  * applies it once; a second `use` of the same name is skipped (idempotent), so plugins can depend on each
32
101
  * other without double-registering hooks.
33
102
  *
34
103
  * ```ts
35
- * export const requestId = definePlugin("requestId", (app) => app.derive(() => ({ requestId: uuid() })))
104
+ * export const requestId = definePlugin("requestId", (app: typeof api) =>
105
+ * app.derive(() => ({ requestId: uuid() })),
106
+ * )
36
107
  * app.use(requestId) // downstream handlers see c.requestId
37
108
  * ```
38
109
  *
39
- * FOOTGUN: only use this for a plugin that adds context. For a plugin that **mounts routes/hooks but adds
40
- * NO context** (an auth router, an audit logger), use {@link defineRouterPlugin} ({@link defineIdentityPlugin}).
41
- * `definePlugin((app) => app.get(...))` infers `app: Server<any, any>`, so `.use()` returns `Server<any, any>`
42
- * and your whole typed client silently collapses to `any` - no type error, no runtime error.
110
+ * This helper only threads types when the caller pins the input server type - by annotating the
111
+ * parameter as above, or with explicit type arguments. With a bare `(app) => ...` arrow `app` infers as
112
+ * `AnyServer`, which would widen the caller's whole route registry to `any`; that case now returns
113
+ * {@link PluginTypeCollapsed}, so `.use()` fails to compile with one error at the call site rather than
114
+ * producing a typed client full of `any`. Prefer a definer that cannot collapse at all:
115
+ *
116
+ * - adds context via `derive`/`decorate` -> {@link defineContextPlugin}
117
+ * - mounts routes/hooks and adds NO context -> {@link defineRouterPlugin} ({@link defineIdentityPlugin})
43
118
  */
44
119
  export function definePlugin<In extends AnyServer, Out extends AnyServer>(
45
120
  name: string,
46
121
  apply: (app: In) => Out,
47
- ): NifraPlugin<In, Out> {
48
- return Object.assign(apply, { pluginName: name }) as NifraPlugin<In, Out>
122
+ ): DefinePluginResult<In, Out>
123
+ export function definePlugin(
124
+ name: string,
125
+ apply: (app: AnyServer) => AnyServer,
126
+ ): NifraPlugin<AnyServer, AnyServer> {
127
+ return Object.assign(apply, { pluginName: name })
49
128
  }
50
129
 
51
130
  /**
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Prototype-poisoning guard for the JSON body lane - the check behind `c.boundedJson` and the
3
+ * schema path. A single walk of the parsed value, never a reviver (a reviver taxes every key of
4
+ * every parse, including the parses that carry no object at all).
5
+ *
6
+ * No raw-text pre-scan: the Fastify-style `text.includes('"__proto__"')` filter is a pessimization
7
+ * on 2026 engines. A quoted-name search runs a general substring algorithm over the whole body,
8
+ * while the walk only touches the nodes the parse already built. Measured on Node 26 and Bun 1.3,
9
+ * three `includes()` calls cost 2-4x the walk on every body shape an API actually receives
10
+ * (records, strings, nested objects); the scan only wins on a body that is mostly a flat array of
11
+ * numbers, where it saves ~1.5us on a 9KB payload that spends 20us in `JSON.parse` regardless.
12
+ * Dropping the tier also removes an escape-analysis obligation - the scan had to route every `\u`
13
+ * to the walk, because `_` spells `_`.
14
+ */
15
+
16
+ export type ProtoPoisoning = "reject" | "strip" | "ignore"
17
+
18
+ /** Thrown (as a reused singleton - never surfaced, always mapped to a flat 400) on `"reject"`. */
19
+ const POISONED = new Error("json_proto_poisoning")
20
+
21
+ /**
22
+ * `JSON.parse` + the poisoning policy. Returns the parsed value (stripped in place under
23
+ * `"strip"`); throws on invalid JSON or - via the same catch path - on a rejected poisoning, so
24
+ * a poisoned payload is indistinguishable from malformed JSON to the caller.
25
+ */
26
+ export function parseJsonGuarded(text: string, policy: ProtoPoisoning): unknown {
27
+ return guardParsedValue(JSON.parse(text), policy)
28
+ }
29
+
30
+ /**
31
+ * The policy applied to an already-parsed value - the native-`json()` lane, which never holds the
32
+ * raw text, and the transport codecs, which parse with their own decoder. Escapes are resolved by
33
+ * the time a key is an own property, so a `\u`-spelled `__proto__` and a literal one look identical
34
+ * here. Cost is one iterative pass over the value's object nodes; `"ignore"` skips even that.
35
+ * Throws the reject singleton; callers map it to their lane's flat error.
36
+ */
37
+ export function guardParsedValue(value: unknown, policy: ProtoPoisoning): unknown {
38
+ if (policy === "ignore") return value
39
+ return sweep(value, policy)
40
+ }
41
+
42
+ /**
43
+ * Iterative deep-walk (adversarial nesting must not blow the call stack). An own key
44
+ * `__proto__`, or an own `constructor` whose value carries an own `prototype`, is the poisoning
45
+ * shape - `JSON.parse` creates these as plain data properties, and the blast radius is whatever
46
+ * downstream merge/assign later copies them onto a real prototype. A string *value* of
47
+ * `"__proto__"` is legal data and never triggers.
48
+ */
49
+ /** Reused walk stack - `sweep` is synchronous and single-threaded, and a thrown rejection can
50
+ * leave residue, so each entry clears it. Saves one array allocation per swept request. */
51
+ const STACK: unknown[] = []
52
+
53
+ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
54
+ if (root === null || typeof root !== "object") return root
55
+ const stack = STACK
56
+ stack.length = 0
57
+ stack.push(root)
58
+ while (stack.length > 0) {
59
+ const node = stack.pop() as object
60
+ if (Array.isArray(node)) {
61
+ // Only objects are stacked, here and below: a scalar carries no keys, so pushing it just to
62
+ // pop and type-test it is pure stack traffic. Filtering at the push site measured 1.5-3x
63
+ // faster on every body shape, both engines - the win grows with how scalar-heavy the body is.
64
+ //
65
+ // Indexed, not `for...of`: JSC allocates an array iterator per loop and calls `next()` per
66
+ // element, and it does not escape either. Measured on Bun 1.3 the iterator form costs 4x on
67
+ // an array of records, 8x on strings, and 12x on numbers - a 9KB numeric body walked in 27us
68
+ // instead of 2.2us, which at the 1MB default cap is milliseconds of CPU an attacker picks.
69
+ // V8 optimizes the iterator away, so on Node the two forms measure identical; the indexed
70
+ // loop is simply the form that is fast on both.
71
+ for (let i = 0; i < node.length; i++) {
72
+ const item = node[i]
73
+ if (item !== null && typeof item === "object") stack.push(item)
74
+ }
75
+ continue
76
+ }
77
+ const record = node as Record<string, unknown>
78
+ // One pass, not two `Object.hasOwn` probes plus a walk: the suspect names are checked against
79
+ // the keys the walk already enumerates, so a clean node pays two pointer-comparisons per key
80
+ // instead of two hash lookups per node (measured ~30ns/request cheaper on a typical API body).
81
+ // `for-in`, not `Object.keys()`: the keys array would be an allocation per node (~2x the whole
82
+ // walk on measured bodies, both engines, at realistic widths; V8 only prefers `Object.keys` on
83
+ // 200+-property dictionary-mode objects). On JSON.parse output the two enumerate identically -
84
+ // own enumerable string keys, nothing enumerable on the prototype chain. A custom transport
85
+ // codec could hand `guardParsedValue` an object with enumerable INHERITED properties; `for-in`
86
+ // sweeps those too, which only over-sweeps - a poisoned inherited subtree rejects rather than
87
+ // slips through. A *non-enumerable* own `__proto__` is the one shape this misses, and it is
88
+ // not the poisoning shape: `JSON.parse` never produces one, and the merges that carry the
89
+ // payload onward (spread, `Object.assign`, key loops) copy enumerable own keys only.
90
+ for (const key in record) {
91
+ const value = record[key]
92
+ const nested = value !== null && typeof value === "object"
93
+ // Keep the hot clean-key check branch-local and cheap. JSON keys are strings, and the length
94
+ // + first-code-unit guards avoid a full string comparison for the overwhelming majority of
95
+ // fields while preserving exact equality for the two security-sensitive names.
96
+ if (key.length === 9 && key.charCodeAt(0) === 95 /* _ */ && key === "__proto__") {
97
+ if (policy === "reject") throw POISONED
98
+ // biome-ignore lint/complexity/useLiteralKeys: bracket access keeps the guarded key an explicit string, never a prototype walk
99
+ delete record["__proto__"]
100
+ continue
101
+ }
102
+ if (
103
+ key.length === 11 &&
104
+ key.charCodeAt(0) === 99 /* c */ &&
105
+ key === "constructor" &&
106
+ nested &&
107
+ Object.hasOwn(value, "prototype")
108
+ ) {
109
+ if (policy === "reject") throw POISONED
110
+ // biome-ignore lint/complexity/useLiteralKeys: bracket access keeps the guarded key an explicit string, never a prototype walk
111
+ delete record["constructor"]
112
+ continue
113
+ }
114
+ if (nested) stack.push(value)
115
+ }
116
+ }
117
+ return root
118
+ }
@@ -35,12 +35,16 @@ export type Registry = Record<string, Record<string, RouteInfo>>
35
35
  /** The empty registry (no routes). `NonNullable<unknown>` is `{}` without tripping noBannedTypes. */
36
36
  export type EmptyRegistry = NonNullable<unknown>
37
37
 
38
+ /** The client-visible request inputs use the schema's INPUT side: the registry describes what a
39
+ * caller must SEND over the wire (pre-validation), not what the handler receives after parsing. With
40
+ * a Zod `.default()`/transform the two differ - `InferOutput` would force the client to supply
41
+ * fields the schema fills in. (Same rule as the WS `in` channel below.) */
38
42
  type RegistryBody<S extends RouteSchema> = S extends { body: infer B extends StandardSchemaV1 }
39
- ? InferOutput<B>
43
+ ? InferInput<B>
40
44
  : never
41
45
 
42
46
  type RegistryQuery<S extends RouteSchema> = S extends { query: infer Q extends StandardSchemaV1 }
43
- ? InferOutput<Q>
47
+ ? InferInput<Q>
44
48
  : never
45
49
 
46
50
  /** The client-visible output: the declared `response` contract when present (so the client sees the