@nifrajs/core 2.14.1 → 3.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 (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. package/src/single-copy.ts +526 -0
@@ -0,0 +1,172 @@
1
+ import type { AssurancePolicy, AssuranceRule } from "../assurance.ts"
2
+ import { NIFRA_ASSURANCE_IDS } from "../internal/route-assurance.ts"
3
+
4
+ /**
5
+ * How much a {@link securityBaseline} demands. Each level is a superset of the one before it, so
6
+ * raising the level only ever adds findings.
7
+ *
8
+ * - `"essential"` - the invariants that hold for ANY app and never false-positive on a reasonable
9
+ * one: a body read must be bounded, an unlimited body may never claim to be bounded, an agent tool
10
+ * ingress must be bounded, and an authenticated state change must prove CSRF. Every required piece
11
+ * of evidence here is either published by the core from the route schema or only demanded when the
12
+ * route already opted into the risk (CSRF is required only where authentication is present). Safe to
13
+ * adopt on day one.
14
+ * - `"standard"` (default) - essential, plus: a route the app itself classified as `pii` or higher
15
+ * must be authenticated, whether it reads or writes. High signal, low noise - it fires only on
16
+ * routes the application already labelled sensitive.
17
+ * - `"strict"` - standard, plus the opinionated, higher-friction requirements: every route must carry
18
+ * a response contract, every read must carry security headers, and every mutation must be rate
19
+ * limited. These need middleware the app has to install, so they are opt-in rather than default.
20
+ */
21
+ export type SecurityBaselineLevel = "essential" | "standard" | "strict"
22
+
23
+ /** Tuning for {@link securityBaseline}. Every knob only ever tightens the policy. */
24
+ export interface SecurityBaselineOptions {
25
+ /** How much the baseline demands. Default `"standard"`. See {@link SecurityBaselineLevel}. */
26
+ readonly level?: SecurityBaselineLevel
27
+ /**
28
+ * How to treat a route no rule matched. Default `"ignore"` so the baseline is purely additive - it
29
+ * asserts the invariants below and stays silent on everything else, which lets a project adopt it
30
+ * without first classifying its whole route table. Set `"error"` to make an unclassified route a
31
+ * finding, turning the baseline into a closed allow-list (recommended once the table is covered).
32
+ */
33
+ readonly unmatched?: "error" | "ignore"
34
+ /**
35
+ * Require that a matched route's evidence was installed by runtime enforcement (middleware/plugin
36
+ * or framework policy) rather than asserted inline on `schema.assurance`. Default `true`: an author
37
+ * label is not proof, and a security baseline that accepts labels is theater. Set `false` only to
38
+ * stage adoption before guards emit runtime evidence.
39
+ */
40
+ readonly requireRuntimeProvenance?: boolean
41
+ }
42
+
43
+ const MUTATING = ["POST", "PUT", "PATCH", "DELETE"] as const
44
+ const READING = ["GET", "HEAD"] as const
45
+ /** The classification threshold above which the baseline demands authentication. */
46
+ const SENSITIVE = "pii" as const
47
+
48
+ /**
49
+ * A first-match-wins security policy that turns the recurring audit finding classes into machine
50
+ * checked route invariants. Composed only from the public assurance engine, so it inherits its
51
+ * fail-closed evaluation, provenance checks, and selector validation - no bespoke evaluator.
52
+ *
53
+ * The rules are ordered most-specific-first because assurance evaluation is first-match-wins: each
54
+ * route is owned by exactly one rule, so that rule carries the FULL requirement bundle for its class
55
+ * (a body-carrying mutation proves bounded-body AND CSRF in one rule, not two). Raising the `level`
56
+ * widens those bundles; it never reorders them.
57
+ *
58
+ * Coverage by level (see {@link SecurityBaselineLevel} for the rationale):
59
+ *
60
+ * | invariant | essential | standard | strict |
61
+ * | --- | :-: | :-: | :-: |
62
+ * | unlimited body can never be "bounded" (F-001) | ✓ | ✓ | ✓ |
63
+ * | every body read is bounded | ✓ | ✓ | ✓ |
64
+ * | agent tool ingress is bounded | ✓ | ✓ | ✓ |
65
+ * | authenticated mutation proves CSRF | ✓ | ✓ | ✓ |
66
+ * | pii+ route is authenticated | | ✓ | ✓ |
67
+ * | every route carries a response contract | | | ✓ |
68
+ * | every read carries security headers | | | ✓ |
69
+ * | every mutation is rate limited | | | ✓ |
70
+ *
71
+ * The baseline deliberately does not select on `access`/`zone` capabilities: those selectors require
72
+ * capability definitions and would make the preset refuse an app that declares none. Extend the
73
+ * returned policy's `rules` for capability- or path-specific requirements; put more specific rules
74
+ * before these so they own their routes first.
75
+ */
76
+ export function securityBaseline(options: SecurityBaselineOptions = {}): AssurancePolicy {
77
+ const level = options.level ?? "standard"
78
+ const strict = level === "strict"
79
+ const authSensitive = level !== "essential"
80
+ const provenance = options.requireRuntimeProvenance === false ? "any" : "runtime"
81
+
82
+ const A = NIFRA_ASSURANCE_IDS
83
+ // Evidence the app must install middleware for, gated to the levels that assume that cost.
84
+ const contract = strict ? [A.RESPONSE_CONTRACT] : []
85
+ const headers = strict ? [A.SECURITY_HEADERS] : []
86
+ const rateLimit = strict ? [A.RATE_LIMITED] : []
87
+ const authed = authSensitive ? [A.AUTHENTICATED] : []
88
+
89
+ const rules: AssuranceRule[] = [
90
+ // The static twin of the config-time rejection: a schema route that opted into `bodyLimit:
91
+ // "unlimited"` can never carry `nifra.body-bounded` (the core withholds it), so requiring it is a
92
+ // standing ban. Owns the unlimited case before any body rule below can soften it. (F-001.)
93
+ {
94
+ name: "banned-unlimited-body",
95
+ match: { hasBody: true, bodyLimit: "unlimited" },
96
+ require: [A.BODY_BOUNDED],
97
+ },
98
+ // Agent tool ingress buffers attacker-influenced arguments; it must be bounded. Not CSRF-guarded:
99
+ // tool calls are token-authenticated machine traffic, not ambient-cookie browser posts.
100
+ {
101
+ name: "tool-ingress-bounded",
102
+ match: { tools: true, hasBody: true },
103
+ require: [A.BODY_BOUNDED, ...contract],
104
+ requireProvenance: provenance,
105
+ },
106
+ ]
107
+
108
+ // Sensitive mutations, split by body shape so bounded-body is required only where a body exists.
109
+ // Emitted only when a level adds a requirement beyond the generic mutation rules, otherwise they
110
+ // would be redundant duplicates that only slow evaluation.
111
+ if (authSensitive || strict) {
112
+ rules.push(
113
+ {
114
+ name: "sensitive-mutation-body",
115
+ match: { methods: [...MUTATING], hasBody: true, classificationAtLeast: SENSITIVE },
116
+ require: [A.BODY_BOUNDED, ...authed, ...contract, ...rateLimit],
117
+ requireCsrfWithAuthenticated: true,
118
+ requireProvenance: provenance,
119
+ },
120
+ {
121
+ name: "sensitive-mutation",
122
+ match: { methods: [...MUTATING], classificationAtLeast: SENSITIVE },
123
+ require: [...authed, ...contract, ...rateLimit],
124
+ requireCsrfWithAuthenticated: true,
125
+ requireProvenance: provenance,
126
+ },
127
+ )
128
+ }
129
+
130
+ rules.push(
131
+ {
132
+ name: "mutation-body",
133
+ match: { methods: [...MUTATING], hasBody: true },
134
+ require: [A.BODY_BOUNDED, ...contract, ...rateLimit],
135
+ requireCsrfWithAuthenticated: true,
136
+ requireProvenance: provenance,
137
+ },
138
+ {
139
+ name: "mutation",
140
+ match: { methods: [...MUTATING] },
141
+ require: [...contract, ...rateLimit],
142
+ requireCsrfWithAuthenticated: true,
143
+ requireProvenance: provenance,
144
+ },
145
+ )
146
+
147
+ if (authSensitive || strict) {
148
+ rules.push({
149
+ name: "sensitive-read",
150
+ match: { methods: [...READING], classificationAtLeast: SENSITIVE },
151
+ require: [...authed, ...contract, ...headers],
152
+ requireProvenance: provenance,
153
+ })
154
+ }
155
+
156
+ // The catch-all read rule. At essential/standard its requirement bundle is empty, so it is a pure
157
+ // classification that simply marks reads as covered - which matters only under `unmatched: "error"`,
158
+ // where an unowned route would otherwise fail. At strict it demands the response + header contract.
159
+ rules.push({
160
+ name: "read",
161
+ match: { methods: [...READING] },
162
+ require: [...contract, ...headers],
163
+ ...(strict ? { requireProvenance: provenance } : {}),
164
+ })
165
+
166
+ return {
167
+ rules,
168
+ unmatched: options.unmatched ?? "ignore",
169
+ // A wrong import reflecting zero routes must fail, not silently pass a security gate.
170
+ allowEmpty: false,
171
+ }
172
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The bare lane's ERROR rendering, as free functions the kernel delegates to.
3
+ *
4
+ * ## Why this is a lane module and not methods on `Server`
5
+ *
6
+ * These are the cold half of the bare request lane: what happens when a handler (or a `derive`)
7
+ * throws, rather than returns. The success half stays on the class - it reads request-lifecycle
8
+ * state and is the async-frame-skip hot path - but the error half needs none of that class state:
9
+ * `renderBareError` is pure control-flow routing, and `emitRequestErrorLog` needs only the logger
10
+ * and the detail policy, both of which are config fixed at construction. Passing those two in keeps
11
+ * this file free of any runtime dependency back on `server.ts`, so there is no import cycle: the
12
+ * `RawContext`/`CtxSet` imports are type-only and erased (the same shape `edge.ts` already relies on).
13
+ *
14
+ * A single source of truth for the thrown-value contract matters because it is a security surface:
15
+ * an unhandled throw must render as a 500 with no caller-controlled body, and the amount of the
16
+ * error's own text that reaches the log sink is the app's `errorLogDetail` call, not an accident of
17
+ * which lane caught it. Both the generic bare lane and the fused body runner route here, so that
18
+ * decision lives in exactly one place.
19
+ */
20
+ import { pathnameOf } from "./http.ts"
21
+ import type { Logger } from "./logger.ts"
22
+ import { isResponseResult, type ResponseResult } from "./runtime-core.ts"
23
+ import type { CtxSet, RawContext } from "./server.ts"
24
+
25
+ /** How much of a thrown error's own text reaches the log sink. Mirrors `Server`'s option. */
26
+ export type ErrorLogDetail = "full" | "message" | "none"
27
+
28
+ /**
29
+ * Render a value thrown on the bare lane. Three shapes, in the order the lane means them:
30
+ * - a bare `Response` is the app answering directly - passed through untouched;
31
+ * - a thrown `status(...)` is control flow, still plain data: rendered through the same `finalize`
32
+ * a RETURNED one takes, with the request's `c.set`, so it costs what the return costs;
33
+ * - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
34
+ *
35
+ * `responseSet` and `logError` are injected rather than imported so this file keeps no runtime edge
36
+ * back to `server.ts` (`responseSet` reaches into the context's set symbol; `logError` closes over the
37
+ * server's logger). Both run only on the throw path, so the closures cost nothing on the hot lane.
38
+ */
39
+ export function renderBareError<T>(
40
+ err: unknown,
41
+ ctx: RawContext,
42
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
43
+ wrapResponse: (response: Response | ResponseResult) => T,
44
+ responseSet: (ctx: RawContext) => CtxSet,
45
+ logError: (err: unknown, ctx: RawContext) => void,
46
+ internalError: () => T,
47
+ ): T {
48
+ if (err instanceof Response) return wrapResponse(err)
49
+ if (isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
50
+ logError(err, ctx)
51
+ return internalError()
52
+ }
53
+
54
+ /**
55
+ * Write an unhandled request error to the log. An error's own text can quote the input that produced
56
+ * it, so how much of it reaches the sink is the app's call (`detail`). The default keeps it: a 500
57
+ * with no message and no stack is an incident nobody can diagnose, and the framework already ships
58
+ * the narrower instrument for the leak - a redacting logger with `valuePatterns`. `"none"` is there
59
+ * for an untrusted sink.
60
+ */
61
+ export function emitRequestErrorLog(
62
+ logger: Logger,
63
+ detail: ErrorLogDetail,
64
+ err: unknown,
65
+ ctx: RawContext,
66
+ ): void {
67
+ logger.error("unhandled request error", {
68
+ method: ctx.req.method,
69
+ path: pathnameOf(ctx.req.url),
70
+ name: err instanceof Error ? err.name : "Error",
71
+ // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
72
+ // that name is silently overwritten and the thrown error's own text never reaches the sink. It
73
+ // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
74
+ ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
75
+ ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
76
+ })
77
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The shared framed body parser. Kept separate from RequestContext so compact edge consumers can
3
+ * reuse the exact trust-boundary lane without bundling the full context implementation (cookies,
4
+ * deferred responses, and request helpers).
5
+ */
6
+ import { drainCapped, hasTrustedBodyFraming, parseContentLength, RAW_BODY_READERS } from "./body.ts"
7
+ import { plainError } from "./http.ts"
8
+ import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
9
+ import { isUrlEncodedForm, readBoundedForm } from "./query.ts"
10
+ import {
11
+ headerOf,
12
+ isResponseResult,
13
+ PRE_DECODED_BODY,
14
+ type PreDecodedBody,
15
+ type ResponseResult,
16
+ TEXT_DECODER,
17
+ } from "./runtime-core.ts"
18
+ import type { MaybePromise, RequestSource } from "./server.ts"
19
+
20
+ type JsonResultContinuation<T> = (result: unknown | ResponseResult) => T | Promise<T>
21
+ type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
22
+
23
+ type FramedJsonSource = RequestSource & {
24
+ readonly bytes?: () => Promise<Uint8Array>
25
+ readonly jsonWithByteLength?: () => Promise<{
26
+ readonly value: unknown
27
+ readonly byteLength: number
28
+ }>
29
+ }
30
+
31
+ const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
32
+
33
+ /** Read a JSON source with the declared-length precheck, delivered-byte recheck, streaming cap, and
34
+ * prototype-poisoning guard in one lane. */
35
+ export function readBoundedJsonSource(
36
+ req: RequestSource,
37
+ maxBytes: number,
38
+ protoPoisoning?: ProtoPoisoning,
39
+ ): Promise<unknown | ResponseResult>
40
+ export function readBoundedJsonSource<T>(
41
+ req: RequestSource,
42
+ maxBytes: number,
43
+ protoPoisoning: ProtoPoisoning,
44
+ onResult: JsonResultContinuation<T>,
45
+ onError: JsonErrorContinuation<T>,
46
+ ): Promise<T>
47
+ export function readBoundedJsonSource<T>(
48
+ req: RequestSource,
49
+ maxBytes: number,
50
+ protoPoisoning: ProtoPoisoning = "reject",
51
+ onResult?: JsonResultContinuation<T>,
52
+ onError?: JsonErrorContinuation<T>,
53
+ ): Promise<unknown | ResponseResult | T> {
54
+ const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
55
+ if (preDecoded !== undefined) {
56
+ return onResult === undefined
57
+ ? Promise.resolve(preDecoded.value)
58
+ : Promise.resolve(preDecoded.value).then(onResult)
59
+ }
60
+
61
+ const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
62
+ req) as FramedJsonSource
63
+ const declared = headerOf(req, "content-length")
64
+ if (declared !== null) {
65
+ const length = parseContentLength(declared)
66
+ if (length === undefined)
67
+ return onResult === undefined
68
+ ? Promise.resolve(plainError(400, "invalid_content_length"))
69
+ : Promise.resolve(plainError(400, "invalid_content_length")).then(onResult)
70
+ if (length > maxBytes)
71
+ return onResult === undefined
72
+ ? Promise.resolve(plainError(413, "payload_too_large"))
73
+ : Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
74
+
75
+ if (hasTrustedBodyFraming(req)) {
76
+ return raw.json().then(
77
+ (parsed) => {
78
+ try {
79
+ const guarded = guardParsedValue(parsed, protoPoisoning)
80
+ return onResult === undefined ? guarded : onResult(guarded)
81
+ } catch {
82
+ return onResult === undefined
83
+ ? plainError(400, "invalid_json")
84
+ : onResult(plainError(400, "invalid_json"))
85
+ }
86
+ },
87
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
88
+ )
89
+ }
90
+
91
+ if (headerOf(req, "transfer-encoding") === null) {
92
+ const jsonWithByteLength = raw.jsonWithByteLength
93
+ if (jsonWithByteLength !== undefined) {
94
+ return jsonWithByteLength.call(raw).then(
95
+ ({ value, byteLength }) => {
96
+ if (byteLength > length || byteLength > maxBytes) {
97
+ return onResult === undefined
98
+ ? plainError(413, "payload_too_large")
99
+ : onResult(plainError(413, "payload_too_large"))
100
+ }
101
+ try {
102
+ const guarded = guardParsedValue(value, protoPoisoning)
103
+ return onResult === undefined ? guarded : onResult(guarded)
104
+ } catch {
105
+ return onResult === undefined
106
+ ? plainError(400, "invalid_json")
107
+ : onResult(plainError(400, "invalid_json"))
108
+ }
109
+ },
110
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
111
+ )
112
+ }
113
+
114
+ const bytesReader = raw.bytes
115
+ if (bytesReader !== undefined) {
116
+ return bytesReader.call(raw).then(
117
+ (bytes) => {
118
+ if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
119
+ return onResult === undefined
120
+ ? plainError(413, "payload_too_large")
121
+ : onResult(plainError(413, "payload_too_large"))
122
+ }
123
+ try {
124
+ const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
125
+ return onResult === undefined ? parsed : onResult(parsed)
126
+ } catch {
127
+ return onResult === undefined
128
+ ? plainError(400, "invalid_json")
129
+ : onResult(plainError(400, "invalid_json"))
130
+ }
131
+ },
132
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
133
+ )
134
+ }
135
+
136
+ return raw.arrayBuffer().then(
137
+ (buffer) => {
138
+ const bytes = new Uint8Array(buffer)
139
+ if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
140
+ return onResult === undefined
141
+ ? plainError(413, "payload_too_large")
142
+ : onResult(plainError(413, "payload_too_large"))
143
+ }
144
+ try {
145
+ const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
146
+ return onResult === undefined ? parsed : onResult(parsed)
147
+ } catch {
148
+ return onResult === undefined
149
+ ? plainError(400, "invalid_json")
150
+ : onResult(plainError(400, "invalid_json"))
151
+ }
152
+ },
153
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
154
+ )
155
+ }
156
+ }
157
+
158
+ const body = raw.body
159
+ const streamed =
160
+ body === null
161
+ ? Promise.resolve<unknown | ResponseResult>(plainError(400, "invalid_json"))
162
+ : drainCapped(body, maxBytes).then((drained) => {
163
+ if (!drained.ok) return plainError(413, "payload_too_large")
164
+ try {
165
+ return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
166
+ } catch {
167
+ return plainError(400, "invalid_json")
168
+ }
169
+ })
170
+ return onResult === undefined ? streamed : streamed.then(onResult, onError)
171
+ }
172
+
173
+ /** The shared content-type dispatcher around the JSON and urlencoded lanes. */
174
+ export function readBodyFramed<T>(
175
+ source: RequestSource,
176
+ maxBodyBytes: number,
177
+ protoPoisoning: ProtoPoisoning,
178
+ onParsed: (parsed: unknown) => MaybePromise<T>,
179
+ wrapResponse: (response: Response | ResponseResult) => T,
180
+ onError: (err: unknown) => MaybePromise<T>,
181
+ ): Promise<T> {
182
+ const contentType = headerOf(source, "content-type") ?? ""
183
+ if (contentType !== "application/json" && !contentType.includes("application/json")) {
184
+ if (isUrlEncodedForm(contentType)) {
185
+ return readBoundedForm(source, maxBodyBytes).then(
186
+ (form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
187
+ onError,
188
+ ) as Promise<T>
189
+ }
190
+ return Promise.resolve(wrapResponse(plainError(415, "unsupported_media_type")))
191
+ }
192
+ try {
193
+ return readBoundedJsonSource(
194
+ source,
195
+ maxBodyBytes,
196
+ protoPoisoning,
197
+ (parsed) => (isResponseResult(parsed) ? wrapResponse(parsed) : onParsed(parsed)),
198
+ onError,
199
+ )
200
+ } catch (err) {
201
+ return Promise.resolve(onError(err))
202
+ }
203
+ }
@@ -37,6 +37,14 @@ export function rawBodySourceOf<T extends BodySource>(req: T): T {
37
37
  return ((req as { [RAW_BODY_READERS]?: T })[RAW_BODY_READERS] as T | undefined) ?? req
38
38
  }
39
39
 
40
+ /**
41
+ * The byte cap a route that declared `bodyLimit: "unlimited"` reads under. It is a real number, not
42
+ * `Infinity` or a skipped check: every bounded reader keeps its single `> maxBytes` enforcement
43
+ * point (and its `assertByteLimit` guard) rather than growing an uncapped branch that a future
44
+ * caller could reach by accident. No HTTP body reaches 2^53-1 bytes, so this is unlimited in fact.
45
+ */
46
+ export const UNLIMITED_BODY_BYTES = Number.MAX_SAFE_INTEGER
47
+
40
48
  /** Security/resource limits must be finite byte counts. Invalid values otherwise make `> maxBytes`
41
49
  * comparisons fail open (notably for `NaN`) and can re-enable unbounded buffering. */
42
50
  export function assertByteLimit(value: number, name = "maxBytes"): void {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Wire marker keys shared between @nifrajs/core and the @nifrajs/node adapter.
3
+ *
4
+ * Each entry names a symbol looked up with `Symbol.for(...)`, so the two packages agree ONLY by the
5
+ * string. A rename on one side and not the other is a silent protocol break - no type error, just a
6
+ * symbol that never matches and a body that stops taking the direct-write lane. This module is the
7
+ * single source: core defines its own symbols from these literals, and `scripts/gen-node-outcome.ts`
8
+ * copies the map verbatim into the adapter's generated contract, so @nifrajs/node reads the identical
9
+ * strings without a runtime dependency on core (the same deployment shape the generated outcome type
10
+ * preserves).
11
+ *
12
+ * Keep this module import-free: every core file that owns one of these symbols imports it, so a cycle
13
+ * here would reach most of the response path.
14
+ */
15
+ export const NODE_BRIDGE_MARKER_KEYS = {
16
+ /** A `Response` carrying its already-serialized bytes, so the Node writer emits them without a drain. */
17
+ responseBody: "nifra.response.body",
18
+ /** A plain-data response-result carrier the node lane renders without building a `Response`. */
19
+ responseResult: "nifra.response.result",
20
+ /** Proof that a header record's names are already the lowercase wire spelling. */
21
+ lowercaseHeaderKeys: "nifra.headers.lowercase",
22
+ /** A mounted handler's node-direct resolver entry point. */
23
+ resolveNodeMount: "@nifrajs/core/resolve-node-mount",
24
+ } as const
@@ -64,7 +64,12 @@ export type Params<Path extends string> = Prettify<RawParams<Path>>
64
64
  export interface RouteSchema {
65
65
  /** Transport body cap for this route. Defaults to the server's maxBodyBytes. Use a finite
66
66
  * smaller/larger limit for a specific endpoint. The 'unlimited' value is an explicit
67
- * streaming/upload exemption and requires a non-empty bodyLimitReason for auditability. */
67
+ * streaming/upload exemption and requires a non-empty bodyLimitReason for auditability.
68
+ *
69
+ * 'unlimited' is legal only for schema-less streaming/upload routes. It is rejected with `body` or
70
+ * `idempotency`, because those lanes buffer the body before user code can apply a protocol-specific
71
+ * bound. Read an unlimited route from `c.req.body` as a stream and bound it by the protocol it speaks;
72
+ * `nifra.body-bounded` is deliberately NOT published for an unlimited route. */
68
73
  readonly bodyLimit?: number | "unlimited"
69
74
  /** Required explanation when bodyLimit is 'unlimited' (for example, a streaming upload is
70
75
  * bounded by an upstream object-store protocol). */
@@ -5,9 +5,11 @@ import { METHODS, type Method } from "../router/router.ts"
5
5
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
6
6
  import { assertByteLimit } from "./body.ts"
7
7
  import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
8
- import type { EmptyRegistry, OutputOf, Registry } from "./registry.ts"
8
+ import type { EmptyRegistry, OutputOf, Registry, RouteInfoFor } from "./registry.ts"
9
9
  import { Server } from "./server.ts"
10
10
 
11
+ type EmptyResponseMap = NonNullable<unknown>
12
+
11
13
  /** An additional (non-success) response a contract operation can document, e.g. a `404`. */
12
14
  export interface ResponseDef {
13
15
  readonly description?: string
@@ -88,7 +90,9 @@ type OpErrorBodies<Rs extends Record<string, ResponseDef>> = {
88
90
  [K in keyof Rs as K extends `2${string}`
89
91
  ? never
90
92
  : K extends `${infer N extends number}`
91
- ? N
93
+ ? Rs[K] extends { schema: StandardSchemaV1 }
94
+ ? N
95
+ : never
92
96
  : never]: Rs[K] extends {
93
97
  schema: infer S extends StandardSchemaV1
94
98
  }
@@ -106,6 +110,26 @@ type OpErrors<O extends OperationDef> = O extends {
106
110
  : OpErrorBodies<Rs>
107
111
  : unknown
108
112
 
113
+ /** The schema-bearing status map a contract consumer can use for success and failure narrowing. */
114
+ type OpAdditionalResponseMap<O extends OperationDef> = O extends {
115
+ responses: infer Rs extends Record<string, ResponseDef>
116
+ }
117
+ ? {
118
+ [K in keyof Rs as K extends `${infer N extends number}`
119
+ ? Rs[K] extends { schema: StandardSchemaV1 }
120
+ ? N
121
+ : never
122
+ : never]: Rs[K] extends { schema: infer S extends StandardSchemaV1 }
123
+ ? InferOutput<S>
124
+ : never
125
+ }
126
+ : EmptyResponseMap
127
+
128
+ type OpResponseMap<O extends OperationDef> = Omit<OpAdditionalResponseMap<O>, 200> &
129
+ (O extends { response: infer R extends StandardSchemaV1 }
130
+ ? { 200: InferOutput<R> }
131
+ : EmptyResponseMap)
132
+
109
133
  /**
110
134
  * RouteInfo as a *decoupled consumer* sees it from the contract alone: the
111
135
  * `output` is the declared `response` schema's type, or `unknown` when none is
@@ -118,6 +142,7 @@ type RouteInfoForOp<O extends OperationDef> = {
118
142
  readonly query: OpQuery<O>
119
143
  readonly body: OpBody<O>
120
144
  readonly output: OpResponse<O>
145
+ readonly responses: OpResponseMap<O>
121
146
  // The error union from the op's non-2xx `responses` - so a decoupled contract client sees typed error
122
147
  // bodies, just like an inline route's `errors`. `unknown` when the op declares no error responses.
123
148
  readonly errors: OpErrors<O>
@@ -143,6 +168,25 @@ type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends St
143
168
  : Record<never, never>) &
144
169
  (O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
145
170
 
171
+ type OpResponseSchemas<O extends OperationDef> = O extends {
172
+ responses: infer Rs extends Record<string, ResponseDef>
173
+ }
174
+ ? {
175
+ [K in keyof Rs as K extends `2${string}`
176
+ ? never
177
+ : Rs[K] extends { schema: StandardSchemaV1 }
178
+ ? K extends `${infer N extends number}`
179
+ ? N
180
+ : never
181
+ : never]: Rs[K] extends { schema: infer S extends StandardSchemaV1 } ? S : never
182
+ }
183
+ : EmptyResponseMap
184
+
185
+ type RouteSchemaForOp<O extends OperationDef> = SchemaForOp<O> &
186
+ RouteSchema &
187
+ (O extends { response: infer R extends StandardSchemaV1 } ? { response: R } : EmptyResponseMap) &
188
+ (keyof OpResponseSchemas<O> extends never ? EmptyResponseMap : { errors: OpResponseSchemas<O> })
189
+
146
190
  /**
147
191
  * The handler context for an op - identical to the inline `Context<Path, S>`, so a
148
192
  * handler written for an inline route type-checks unchanged under `implement`
@@ -245,23 +289,12 @@ export type RegistryFromImpl<
245
289
  C extends ContractShape,
246
290
  H extends HandlersFor<C, Ctx>,
247
291
  Ctx = NonNullable<unknown>,
292
+ HookOutput = never,
248
293
  > = {
249
294
  [P in C[keyof C]["path"]]: {
250
- [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: {
251
- readonly params: Params<C[K]["path"]>
252
- readonly query: OpQuery<C[K]>
253
- readonly body: OpBody<C[K]>
254
- readonly output: C[K] extends { response: infer R extends StandardSchemaV1 }
255
- ? InferOutput<R>
256
- : H[K] extends AnyFn
257
- ? OutputOf<H[K]>
258
- : unknown
259
- // The error union from the op's non-2xx `responses` (see OpErrors) - a graduated contract client sees
260
- // the same typed error bodies as the inline `errors` path.
261
- readonly errors: OpErrors<C[K]>
262
- // Mirrors RouteInfoForOp: no SSE contract on contract ops (yet) - mode conformance holds.
263
- readonly sse: never
264
- }
295
+ [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: H[K] extends AnyFn
296
+ ? RouteInfoFor<C[K]["path"], RouteSchemaForOp<C[K]>, OutputOf<H[K]>, HookOutput>
297
+ : RouteInfoFor<C[K]["path"], RouteSchemaForOp<C[K]>, unknown, HookOutput>
265
298
  }
266
299
  }
267
300
 
@@ -290,9 +323,24 @@ export function implement<
290
323
  H extends HandlersFor<C, Ctx>,
291
324
  R extends Registry = EmptyRegistry,
292
325
  Ctx = NonNullable<unknown>,
293
- >(contract: C, handlers: H, app?: Server<R, Ctx>): Server<R & RegistryFromImpl<C, H, Ctx>, Ctx> {
294
- const target = (app ?? new Server()) as Server<R, Ctx>
326
+ HookOutput = never,
327
+ >(
328
+ contract: C,
329
+ handlers: H,
330
+ app?: Server<R, Ctx, HookOutput>,
331
+ ): Server<R & RegistryFromImpl<C, H, Ctx, HookOutput>, Ctx, HookOutput> {
332
+ const target = (app ?? new Server()) as Server<R, Ctx, HookOutput>
295
333
  const routes = Object.entries(contract).map(([name, op]) => {
334
+ const errors =
335
+ op.responses === undefined
336
+ ? undefined
337
+ : Object.fromEntries(
338
+ Object.entries(op.responses)
339
+ .filter(
340
+ ([status, response]) => !status.startsWith("2") && response.schema !== undefined,
341
+ )
342
+ .map(([status, response]) => [Number(status), response.schema!]),
343
+ )
296
344
  // body/query are validated at the request boundary; `response` is a type + introspection contract
297
345
  // ONLY - never validated at runtime and never read on the request hot path (the lifecycle reads
298
346
  // schema.body/query by name; the `bare`/bodyOnly/queryOnly fast-path gates ignore `response`). Built
@@ -304,6 +352,7 @@ export function implement<
304
352
  op.body !== undefined ||
305
353
  op.query !== undefined ||
306
354
  op.response !== undefined ||
355
+ (errors !== undefined && Object.keys(errors).length > 0) ||
307
356
  op.capabilities !== undefined ||
308
357
  op.idempotency !== undefined ||
309
358
  op.bodyLimit !== undefined ||
@@ -316,6 +365,7 @@ export function implement<
316
365
  ...(op.body !== undefined ? { body: op.body } : {}),
317
366
  ...(op.query !== undefined ? { query: op.query } : {}),
318
367
  ...(op.response !== undefined ? { response: op.response } : {}),
368
+ ...(errors !== undefined && Object.keys(errors).length > 0 ? { errors } : {}),
319
369
  ...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
320
370
  ...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
321
371
  ...(op.bodyLimit !== undefined ? { bodyLimit: op.bodyLimit } : {}),
@@ -335,5 +385,5 @@ export function implement<
335
385
  target.registerBatch(routes)
336
386
  // Runtime registered exactly the contract's routes through the inline path; the
337
387
  // registry type is computed from the contract inputs + handler return types.
338
- return target as unknown as Server<R & RegistryFromImpl<C, H, Ctx>, Ctx>
388
+ return target as unknown as Server<R & RegistryFromImpl<C, H, Ctx, HookOutput>, Ctx, HookOutput>
339
389
  }