@nifrajs/core 1.13.0 → 2.1.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 (233) hide show
  1. package/README.md +37 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +54 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +240 -5
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-adapters.d.ts +241 -0
  11. package/dist/durable-adapters.d.ts.map +1 -0
  12. package/dist/durable-adapters.js +697 -0
  13. package/dist/durable-adapters.js.map +1 -0
  14. package/dist/durable-execution.d.ts +349 -0
  15. package/dist/durable-execution.d.ts.map +1 -0
  16. package/dist/durable-execution.js +1173 -0
  17. package/dist/durable-execution.js.map +1 -0
  18. package/dist/effect-ledger.d.ts +9 -0
  19. package/dist/effect-ledger.d.ts.map +1 -0
  20. package/dist/effect-ledger.js +8 -0
  21. package/dist/effect-ledger.js.map +1 -0
  22. package/dist/effect-lifecycle.d.ts +42 -0
  23. package/dist/effect-lifecycle.d.ts.map +1 -0
  24. package/dist/effect-lifecycle.js +66 -0
  25. package/dist/effect-lifecycle.js.map +1 -0
  26. package/dist/effect-scope.d.ts +75 -0
  27. package/dist/effect-scope.d.ts.map +1 -0
  28. package/dist/effect-scope.js +177 -0
  29. package/dist/effect-scope.js.map +1 -0
  30. package/dist/idempotency-plugin.d.ts +13 -0
  31. package/dist/idempotency-plugin.d.ts.map +1 -0
  32. package/dist/idempotency-plugin.js +16 -0
  33. package/dist/idempotency-plugin.js.map +1 -0
  34. package/dist/index.d.ts +6 -35
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -31
  37. package/dist/index.js.map +1 -1
  38. package/dist/internal/capability-runtime.d.ts +62 -1
  39. package/dist/internal/capability-runtime.d.ts.map +1 -1
  40. package/dist/internal/capability-runtime.js +11 -2
  41. package/dist/internal/capability-runtime.js.map +1 -1
  42. package/dist/internal/effect-execution.d.ts +16 -0
  43. package/dist/internal/effect-execution.d.ts.map +1 -0
  44. package/dist/internal/effect-execution.js +50 -0
  45. package/dist/internal/effect-execution.js.map +1 -0
  46. package/dist/internal/safe-equal.d.ts +20 -0
  47. package/dist/internal/safe-equal.d.ts.map +1 -0
  48. package/dist/internal/safe-equal.js +31 -0
  49. package/dist/internal/safe-equal.js.map +1 -0
  50. package/dist/ledger.d.ts +14 -6
  51. package/dist/ledger.d.ts.map +1 -1
  52. package/dist/ledger.js +31 -5
  53. package/dist/ledger.js.map +1 -1
  54. package/dist/mcp.d.ts +7 -0
  55. package/dist/mcp.d.ts.map +1 -0
  56. package/dist/mcp.js +56 -0
  57. package/dist/mcp.js.map +1 -0
  58. package/dist/node-direct.d.ts +5 -0
  59. package/dist/node-direct.d.ts.map +1 -0
  60. package/dist/node-direct.js +27 -0
  61. package/dist/node-direct.js.map +1 -0
  62. package/dist/reconciliation-worker.d.ts +108 -0
  63. package/dist/reconciliation-worker.d.ts.map +1 -0
  64. package/dist/reconciliation-worker.js +239 -0
  65. package/dist/reconciliation-worker.js.map +1 -0
  66. package/dist/reflection.d.ts +5 -0
  67. package/dist/reflection.d.ts.map +1 -1
  68. package/dist/reflection.js +2 -0
  69. package/dist/reflection.js.map +1 -1
  70. package/dist/server/client-ip.d.ts +35 -0
  71. package/dist/server/client-ip.d.ts.map +1 -0
  72. package/dist/server/client-ip.js +42 -0
  73. package/dist/server/client-ip.js.map +1 -0
  74. package/dist/server/context.d.ts +47 -2
  75. package/dist/server/context.d.ts.map +1 -1
  76. package/dist/server/contract.d.ts +14 -4
  77. package/dist/server/contract.d.ts.map +1 -1
  78. package/dist/server/contract.js +8 -2
  79. package/dist/server/contract.js.map +1 -1
  80. package/dist/server/http.d.ts +14 -0
  81. package/dist/server/http.d.ts.map +1 -0
  82. package/dist/server/http.js +45 -0
  83. package/dist/server/http.js.map +1 -0
  84. package/dist/server/idempotency-lane.d.ts +45 -0
  85. package/dist/server/idempotency-lane.d.ts.map +1 -0
  86. package/dist/server/idempotency-lane.js +205 -0
  87. package/dist/server/idempotency-lane.js.map +1 -0
  88. package/dist/server/install.d.ts +21 -0
  89. package/dist/server/install.d.ts.map +1 -0
  90. package/dist/server/install.js +21 -0
  91. package/dist/server/install.js.map +1 -0
  92. package/dist/server/ledger-lane.d.ts +53 -0
  93. package/dist/server/ledger-lane.d.ts.map +1 -0
  94. package/dist/server/ledger-lane.js +82 -0
  95. package/dist/server/ledger-lane.js.map +1 -0
  96. package/dist/server/mcp-hook.d.ts +28 -0
  97. package/dist/server/mcp-hook.d.ts.map +1 -0
  98. package/dist/server/mcp-hook.js +2 -0
  99. package/dist/server/mcp-hook.js.map +1 -0
  100. package/dist/server/node-outcome-hook.d.ts +9 -0
  101. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  102. package/dist/server/node-outcome-hook.js +2 -0
  103. package/dist/server/node-outcome-hook.js.map +1 -0
  104. package/dist/server/node-outcome.d.ts +37 -0
  105. package/dist/server/node-outcome.d.ts.map +1 -0
  106. package/dist/server/node-outcome.js +75 -0
  107. package/dist/server/node-outcome.js.map +1 -0
  108. package/dist/server/plugin.d.ts +78 -0
  109. package/dist/server/plugin.d.ts.map +1 -0
  110. package/dist/server/plugin.js +57 -0
  111. package/dist/server/plugin.js.map +1 -0
  112. package/dist/server/query.d.ts +18 -0
  113. package/dist/server/query.d.ts.map +1 -0
  114. package/dist/server/query.js +102 -0
  115. package/dist/server/query.js.map +1 -0
  116. package/dist/server/registry.d.ts +31 -6
  117. package/dist/server/registry.d.ts.map +1 -1
  118. package/dist/server/request-context.d.ts +51 -0
  119. package/dist/server/request-context.d.ts.map +1 -0
  120. package/dist/server/request-context.js +184 -0
  121. package/dist/server/request-context.js.map +1 -0
  122. package/dist/server/respond.d.ts +17 -0
  123. package/dist/server/respond.d.ts.map +1 -0
  124. package/dist/server/respond.js +81 -0
  125. package/dist/server/respond.js.map +1 -0
  126. package/dist/server/runtime-core.d.ts +36 -0
  127. package/dist/server/runtime-core.d.ts.map +1 -0
  128. package/dist/server/runtime-core.js +51 -0
  129. package/dist/server/runtime-core.js.map +1 -0
  130. package/dist/server/server-types.d.ts +203 -0
  131. package/dist/server/server-types.d.ts.map +1 -0
  132. package/dist/server/server-types.js +2 -0
  133. package/dist/server/server-types.js.map +1 -0
  134. package/dist/server/server.d.ts +119 -345
  135. package/dist/server/server.d.ts.map +1 -1
  136. package/dist/server/server.js +247 -833
  137. package/dist/server/server.js.map +1 -1
  138. package/dist/server/sse-hook.d.ts +10 -0
  139. package/dist/server/sse-hook.d.ts.map +1 -0
  140. package/dist/server/sse-hook.js +2 -0
  141. package/dist/server/sse-hook.js.map +1 -0
  142. package/dist/server/sse.d.ts +7 -23
  143. package/dist/server/sse.d.ts.map +1 -1
  144. package/dist/server/sse.js +18 -0
  145. package/dist/server/sse.js.map +1 -1
  146. package/dist/server/transport-lane.d.ts +28 -0
  147. package/dist/server/transport-lane.d.ts.map +1 -0
  148. package/dist/server/transport-lane.js +106 -0
  149. package/dist/server/transport-lane.js.map +1 -0
  150. package/dist/server/websocket.d.ts +23 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +10 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-hook.d.ts +8 -11
  155. package/dist/server/ws-hook.d.ts.map +1 -1
  156. package/dist/server/ws-hook.js +7 -14
  157. package/dist/server/ws-hook.js.map +1 -1
  158. package/dist/server.d.ts +21 -0
  159. package/dist/server.d.ts.map +1 -0
  160. package/dist/server.js +16 -0
  161. package/dist/server.js.map +1 -0
  162. package/dist/transport-codec-rich.d.ts +8 -0
  163. package/dist/transport-codec-rich.d.ts.map +1 -0
  164. package/dist/transport-codec-rich.js +14 -0
  165. package/dist/transport-codec-rich.js.map +1 -0
  166. package/dist/transport-codec.d.ts +31 -0
  167. package/dist/transport-codec.d.ts.map +1 -0
  168. package/dist/transport-codec.js +190 -0
  169. package/dist/transport-codec.js.map +1 -0
  170. package/dist/transport-plugin.d.ts +3 -0
  171. package/dist/transport-plugin.d.ts.map +1 -0
  172. package/dist/transport-plugin.js +3 -0
  173. package/dist/transport-plugin.js.map +1 -0
  174. package/dist/wire.d.ts +58 -0
  175. package/dist/wire.d.ts.map +1 -0
  176. package/dist/wire.js +445 -0
  177. package/dist/wire.js.map +1 -0
  178. package/dist/ws.d.ts +16 -7
  179. package/dist/ws.d.ts.map +1 -1
  180. package/dist/ws.js +25 -10
  181. package/dist/ws.js.map +1 -1
  182. package/package.json +122 -6
  183. package/src/assurance.ts +36 -1
  184. package/src/capabilities.ts +306 -5
  185. package/src/durable-adapters.ts +1039 -0
  186. package/src/durable-execution.ts +1676 -0
  187. package/src/effect-ledger.ts +9 -0
  188. package/src/effect-lifecycle.ts +120 -0
  189. package/src/effect-scope.ts +267 -0
  190. package/src/idempotency-plugin.ts +17 -0
  191. package/src/index.ts +6 -133
  192. package/src/internal/capability-runtime.ts +87 -1
  193. package/src/internal/effect-execution.ts +70 -0
  194. package/src/internal/safe-equal.ts +27 -0
  195. package/src/ledger.ts +45 -11
  196. package/src/mcp.ts +65 -0
  197. package/src/node-direct.ts +37 -0
  198. package/src/reconciliation-worker.ts +372 -0
  199. package/src/reflection.ts +7 -0
  200. package/src/server/client-ip.ts +60 -0
  201. package/src/server/context.ts +50 -2
  202. package/src/server/contract.ts +26 -7
  203. package/src/server/http.ts +58 -0
  204. package/src/server/idempotency-lane.ts +321 -0
  205. package/src/server/install.ts +28 -0
  206. package/src/server/ledger-lane.ts +125 -0
  207. package/src/server/mcp-hook.ts +41 -0
  208. package/src/server/node-outcome-hook.ts +9 -0
  209. package/src/server/node-outcome.ts +112 -0
  210. package/src/server/plugin.ts +92 -0
  211. package/src/server/query.ts +119 -0
  212. package/src/server/registry.ts +36 -6
  213. package/src/server/request-context.ts +271 -0
  214. package/src/server/respond.ts +94 -0
  215. package/src/server/runtime-core.ts +74 -0
  216. package/src/server/server-types.ts +204 -0
  217. package/src/server/server.ts +448 -1314
  218. package/src/server/sse-hook.ts +14 -0
  219. package/src/server/sse.ts +26 -0
  220. package/src/server/transport-lane.ts +141 -0
  221. package/src/server/websocket.ts +35 -1
  222. package/src/server/ws-hook.ts +8 -19
  223. package/src/server.ts +92 -0
  224. package/src/transport-codec-rich.ts +20 -0
  225. package/src/transport-codec.ts +250 -0
  226. package/src/transport-plugin.ts +2 -0
  227. package/src/wire.ts +474 -0
  228. package/src/ws.ts +33 -10
  229. package/dist/invariants.d.ts +0 -70
  230. package/dist/invariants.d.ts.map +0 -1
  231. package/dist/invariants.js +0 -487
  232. package/dist/invariants.js.map +0 -1
  233. package/src/invariants.ts +0 -604
package/src/reflection.ts CHANGED
@@ -40,6 +40,8 @@ export interface SchemaReflection {
40
40
  export interface ReflectedRouteSchema {
41
41
  readonly body?: SchemaReflection
42
42
  readonly query?: SchemaReflection
43
+ /** Path-params schema — constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
44
+ readonly params?: SchemaReflection
43
45
  readonly response?: SchemaReflection
44
46
  readonly errors?: Readonly<Record<string, SchemaReflection>>
45
47
  /** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
@@ -52,6 +54,9 @@ export interface ReflectedRoute {
52
54
  readonly schema?: ReflectedRouteSchema
53
55
  readonly assurance?: readonly AssuranceEvidence[]
54
56
  readonly capabilities?: readonly string[]
57
+ /** Set when the route is a declared dynamic route family (a runtime-resolved template like
58
+ * `/api/:slug/:resource`) - so the assurance gate treats it as one classified family, not a single route. */
59
+ readonly family?: boolean
55
60
  /** Field-level response classification plus the highest sensitivity present. */
56
61
  readonly classification?: ResponseClassification
57
62
  readonly tool?: {
@@ -167,6 +172,7 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
167
172
  return {
168
173
  ...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
169
174
  ...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
175
+ ...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
170
176
  ...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
171
177
  ...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
172
178
  ...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
@@ -218,6 +224,7 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
218
224
  ...(schema !== undefined ? { schema } : {}),
219
225
  ...(assurance !== undefined ? { assurance } : {}),
220
226
  ...(capabilities.length > 0 ? { capabilities } : {}),
227
+ ...(route.family === true ? { family: true } : {}),
221
228
  ...(classification !== undefined ? { classification } : {}),
222
229
  ...(tool !== undefined ? { tool } : {}),
223
230
  })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Deriving the caller's IP (`c.clientIp`), correctly and vendor-neutrally.
3
+ *
4
+ * The default is the raw socket peer the serving adapter observed - the only value that cannot be
5
+ * forged by a client, since the client never controls who the TCP connection comes from. Behind a
6
+ * reverse proxy or CDN the socket peer is the proxy, not the caller, so a `Forwarded`/`X-Forwarded-For`
7
+ * value must be consulted - but ONLY as far as you actually trust the hops in front of you, because a
8
+ * directly-reachable app lets any client forge those headers. The trust is therefore an explicit,
9
+ * opt-in declaration; with none set, `c.clientIp` is the socket peer and no header is ever believed.
10
+ *
11
+ * No vendor names live here: a Cloudflare/Fastly/nginx deployment declares its own header or hop count.
12
+ */
13
+
14
+ /**
15
+ * How much of the forwarding chain to trust when deriving `c.clientIp`. Omit for the safe default
16
+ * (socket peer only, no header believed).
17
+ *
18
+ * - `{ trustedHops: n }` - trust `n` reverse-proxy hops in front of the app. The caller is taken from
19
+ * `X-Forwarded-For` counted from the right past the `n` trusted hops (the socket peer is hop 0). Use
20
+ * this when the app sits behind `n` proxies **you operate**. Fewer entries than trusted hops means
21
+ * the header is too short to trust, so `c.clientIp` is `undefined` (fail closed).
22
+ * - `{ header: name }` - trust a single named header's first value (e.g. a CDN that overwrites it at
23
+ * the edge). Only declare this when the app is reachable **exclusively** through that edge; a
24
+ * directly-reachable app lets any client forge the header.
25
+ */
26
+ export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
27
+
28
+ function firstHeaderValue(req: Request, header: string): string | undefined {
29
+ const raw = req.headers.get(header)
30
+ if (raw === null) return undefined
31
+ const first = raw.split(",", 1)[0]?.trim()
32
+ return first === undefined || first === "" ? undefined : first
33
+ }
34
+
35
+ /**
36
+ * Resolve the caller IP from the raw socket `peer` (adapter-observed), the request's forwarding
37
+ * headers, and the app's `trust` declaration. Pure and synchronous.
38
+ */
39
+ export function resolveClientIp(
40
+ peer: string | undefined,
41
+ req: Request,
42
+ trust: ClientIpTrust | undefined,
43
+ ): string | undefined {
44
+ if (trust === undefined) return peer
45
+ if ("header" in trust) return firstHeaderValue(req, trust.header)
46
+
47
+ // trustedHops: walk `X-Forwarded-For` (client-most first) plus the socket peer as the closest hop.
48
+ const xff = req.headers.get("x-forwarded-for")
49
+ const forwarded =
50
+ xff === null
51
+ ? []
52
+ : xff
53
+ .split(",")
54
+ .map((entry) => entry.trim())
55
+ .filter((entry) => entry !== "")
56
+ const chain = peer !== undefined ? [...forwarded, peer] : forwarded
57
+ // The caller sits just left of the `trustedHops` trusted proxies at the tail of the chain.
58
+ const index = chain.length - 1 - trust.trustedHops
59
+ return index >= 0 ? chain[index] : undefined
60
+ }
@@ -78,6 +78,34 @@ export interface RouteSchema {
78
78
  * and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
79
79
  */
80
80
  readonly classification?: DataClassification
81
+ /**
82
+ * Inline **route-assurance evidence**: the enforcement posture this route carries, declared adjacent to
83
+ * the handler (e.g. `[NIFRA_ASSURANCE.AUTHENTICATED]`). Each id becomes route-scoped `declared` evidence
84
+ * in reflection, so a `nifra.assurance.ts` policy `require:` clause is satisfiable WITHOUT rewriting an
85
+ * in-handler guard into a `withRouteAssurance`-marked middleware. Ids are lowercase dot/dash segments
86
+ * (invalid ids throw at registration). Reflected for the assurance gate; never read on the hot path.
87
+ *
88
+ * This is an author ASSERTION (recorded as `source: "declared"`), not runtime proof - like every
89
+ * assurance marker it is trusted by the static gate, which matches on the evidence id, not its source.
90
+ * Declare an id only on a route whose guard actually enforces it; a false declaration silently satisfies
91
+ * the policy. (`source` is retained in reflection, so a stricter policy or reviewer can still tell
92
+ * declared evidence from enforcement-module evidence.)
93
+ */
94
+ readonly assurance?: readonly string[]
95
+ /**
96
+ * Mark this route a **dynamic route family**: a template (`/api/:slug/:resource`) whose concrete
97
+ * resources are resolved at runtime (auto-CRUD over tenant-defined tables, a catch-all dispatcher).
98
+ * Purely declarative - it does not change dispatch. It surfaces in reflection so the assurance gate and
99
+ * tooling treat the one templated route as a deliberate family whose evidence covers every runtime-
100
+ * resolved resource, instead of reading it as a single forgotten route.
101
+ */
102
+ readonly family?: boolean
103
+ /** Optional **path-params schema**. Path params arrive as strings (`/users/:id` -> `c.params.id`);
104
+ * declaring a schema validates them at the boundary (a malformed `:id` is a `422` before the handler,
105
+ * like `body`/`query`) and can coerce them (use `t.query({ id: t.integer() })` for a numeric param).
106
+ * The schema must cover every path param; with the strict default an undeclared path param is rejected.
107
+ * When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
108
+ readonly params?: StandardSchemaV1
81
109
  readonly body?: StandardSchemaV1
82
110
  readonly query?: StandardSchemaV1
83
111
  /** Optional **response contract**. When declared: the handler's return is type-checked against it
@@ -117,7 +145,7 @@ export interface RouteSchema {
117
145
  readonly onValidationError?: (
118
146
  issues: ReadonlyArray<StandardIssue>,
119
147
  ctx: Context,
120
- kind: "body" | "query",
148
+ kind: "body" | "query" | "params",
121
149
  ) => Response | unknown | Promise<Response | unknown>
122
150
  }
123
151
 
@@ -131,6 +159,13 @@ type QueryOf<S extends RouteSchema> = S extends { query: infer Q extends Standar
131
159
  ? InferOutput<Q>
132
160
  : URLSearchParams
133
161
 
162
+ /** The validated params type when a params schema is declared, else the path-inferred `Params<Path>`. */
163
+ type ParamsOf<S extends RouteSchema, Path extends string> = S extends {
164
+ params: infer P extends StandardSchemaV1
165
+ }
166
+ ? InferOutput<P>
167
+ : Params<Path>
168
+
134
169
  /** Mutable response controls a handler may write to before returning. */
135
170
  export interface ResponseControls {
136
171
  status?: number
@@ -159,6 +194,10 @@ export interface Platform<Env = unknown> {
159
194
  readonly env?: Env
160
195
  /** Extend the response's lifetime for background work (Workers `ctx.waitUntil`). */
161
196
  readonly waitUntil?: (promise: Promise<unknown>) => void
197
+ /** The raw socket peer address the serving adapter observed, if any (`listen()`, `@nifrajs/node`,
198
+ * `@nifrajs/deno` set it). The server applies the app's `clientIp` trust declaration to this before
199
+ * exposing {@link Context.clientIp}; read `c.clientIp`, not this. */
200
+ readonly clientIp?: string | undefined
162
201
  }
163
202
 
164
203
  /**
@@ -174,7 +213,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
174
213
  * everywhere (no more `c.req` vs `ctx.request` mismatch).
175
214
  */
176
215
  readonly request: Request
177
- readonly params: Params<Path>
216
+ readonly params: ParamsOf<S, Path>
178
217
  readonly query: QueryOf<S>
179
218
  readonly body: BodyOf<S>
180
219
  /** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
@@ -198,6 +237,15 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
198
237
  * (`c.env: Env`); otherwise `unknown`. Validate at the trust boundary before use either way.
199
238
  */
200
239
  readonly env: unknown
240
+ /**
241
+ * The caller's IP. By default the raw socket peer the serving adapter observed (`listen()`,
242
+ * `@nifrajs/node`, `@nifrajs/deno`) — the one address a client cannot forge — or `undefined` when the
243
+ * runtime exposes no socket (Workers) and no trust is declared. Behind a proxy/CDN, set the
244
+ * `clientIp` server option (`{ trustedHops }` or `{ header }`) to derive the real caller from the
245
+ * forwarding chain as far as you trust it; unset, no forwarded header is ever believed. Safe to key
246
+ * rate limits and audit logs on.
247
+ */
248
+ readonly clientIp: string | undefined
201
249
  /**
202
250
  * Schedule background work. On edge runtimes it extends the response lifetime (Workers
203
251
  * `ctx.waitUntil`); off-edge it runs the promise fire-and-forget (a rejection is swallowed,
@@ -26,6 +26,8 @@ export interface ResponseDef {
26
26
  export interface OperationDef {
27
27
  readonly method: Method
28
28
  readonly path: string
29
+ /** Optional path-params schema (validated + coercible at the boundary; see {@link RouteSchema.params}). */
30
+ readonly params?: StandardSchemaV1
29
31
  readonly body?: StandardSchemaV1
30
32
  readonly query?: StandardSchemaV1
31
33
  readonly response?: StandardSchemaV1
@@ -35,6 +37,10 @@ export interface OperationDef {
35
37
  readonly idempotency?: IdempotencyConfig
36
38
  /** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
37
39
  readonly classification?: DataClassification
40
+ /** Inline route-assurance evidence this operation carries (see {@link RouteSchema.assurance}). */
41
+ readonly assurance?: readonly string[]
42
+ /** Mark this operation a dynamic route family (see {@link RouteSchema.family}). */
43
+ readonly family?: boolean
38
44
  /** Short summary (OpenAPI `summary`). */
39
45
  readonly summary?: string
40
46
  /** Longer description (OpenAPI `description`, CommonMark). */
@@ -68,21 +74,27 @@ type OpResponse<O extends OperationDef> = O extends { response: infer R extends
68
74
 
69
75
  /** The body types of a contract op's declared **non-2xx** `responses` (schema-bearing ones), keyed by status. */
70
76
  type OpErrorBodies<Rs extends Record<string, ResponseDef>> = {
71
- [K in keyof Rs as K extends `2${string}` ? never : K]: Rs[K] extends {
77
+ // Non-2xx statuses, re-keyed from the contract's string keys ("404") to number literals (404) so
78
+ // the client's failure union discriminates on the numeric `status` it actually receives.
79
+ [K in keyof Rs as K extends `2${string}`
80
+ ? never
81
+ : K extends `${infer N extends number}`
82
+ ? N
83
+ : never]: Rs[K] extends {
72
84
  schema: infer S extends StandardSchemaV1
73
85
  }
74
86
  ? InferOutput<S>
75
87
  : never
76
88
  }
77
89
 
78
- /** The client-visible error union for a contract op — the union of its non-2xx `responses` body schemas,
79
- * or `unknown` when it declares none (mirrors an inline route with no `errors`). */
90
+ /** The client-visible error bodies for a contract op as a status-keyed record (`{ 404: Body }`), or
91
+ * `unknown` when it declares none (mirrors an inline route with no `errors`). */
80
92
  type OpErrors<O extends OperationDef> = O extends {
81
93
  responses: infer Rs extends Record<string, ResponseDef>
82
94
  }
83
- ? [OpErrorBodies<Rs>[keyof OpErrorBodies<Rs>]] extends [never]
95
+ ? [keyof OpErrorBodies<Rs>] extends [never]
84
96
  ? unknown
85
- : OpErrorBodies<Rs>[keyof OpErrorBodies<Rs>]
97
+ : OpErrorBodies<Rs>
86
98
  : unknown
87
99
 
88
100
  /**
@@ -116,7 +128,8 @@ export type RegistryFor<C extends ContractShape> = {
116
128
  type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends StandardSchemaV1 }
117
129
  ? { body: B }
118
130
  : Record<never, never>) &
119
- (O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>)
131
+ (O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>) &
132
+ (O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
120
133
 
121
134
  /**
122
135
  * The handler context for an op — identical to the inline `Context<Path, S>`, so a
@@ -251,19 +264,25 @@ export function implement<
251
264
  // once here at bind time, not per request — it just carries a reference to the op's existing schema.
252
265
  // A response-less op still yields `undefined`, byte-identical to before.
253
266
  const schema: RouteSchema | undefined =
267
+ op.params !== undefined ||
254
268
  op.body !== undefined ||
255
269
  op.query !== undefined ||
256
270
  op.response !== undefined ||
257
271
  op.capabilities !== undefined ||
258
272
  op.idempotency !== undefined ||
259
- op.classification !== undefined
273
+ op.classification !== undefined ||
274
+ op.assurance !== undefined ||
275
+ op.family !== undefined
260
276
  ? {
277
+ ...(op.params !== undefined ? { params: op.params } : {}),
261
278
  ...(op.body !== undefined ? { body: op.body } : {}),
262
279
  ...(op.query !== undefined ? { query: op.query } : {}),
263
280
  ...(op.response !== undefined ? { response: op.response } : {}),
264
281
  ...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
265
282
  ...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
266
283
  ...(op.classification !== undefined ? { classification: op.classification } : {}),
284
+ ...(op.assurance !== undefined ? { assurance: op.assurance } : {}),
285
+ ...(op.family !== undefined ? { family: op.family } : {}),
267
286
  }
268
287
  : undefined
269
288
  return {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Dependency-free HTTP helpers shared by the kernel and the opt-in request lanes (idempotency,
3
+ * effect-ledger). Kept in a leaf module so a lane can reuse them without importing the server, and so
4
+ * the server can reuse them without pulling a lane's feature code into the base bundle.
5
+ */
6
+
7
+ /** A uniform JSON error envelope: `{ ok: false, error }` at the given status. */
8
+ export function jsonError(
9
+ status: number,
10
+ error: string,
11
+ headers?: Record<string, string>,
12
+ ): Response {
13
+ return Response.json(
14
+ { ok: false, error },
15
+ headers !== undefined ? { status, headers } : { status },
16
+ )
17
+ }
18
+
19
+ export interface UrlParts {
20
+ readonly pathname: string
21
+ readonly search: string
22
+ }
23
+
24
+ // Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
25
+ // `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
26
+ // the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
27
+ // string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
28
+ export function urlPartsOf(url: string): UrlParts {
29
+ const schemeEnd = url.indexOf("://")
30
+ const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
31
+ if (start === -1) return { pathname: "/", search: "" }
32
+
33
+ let pathEnd = url.length
34
+ let searchStart = -1
35
+ let searchEnd = url.length
36
+ for (let i = start; i < url.length; i++) {
37
+ const c = url.charCodeAt(i)
38
+ if (c === 63 /* ? */ && searchStart === -1) {
39
+ pathEnd = i
40
+ searchStart = i
41
+ } else if (c === 35 /* # */) {
42
+ if (searchStart === -1) pathEnd = i
43
+ searchEnd = i
44
+ break
45
+ }
46
+ }
47
+
48
+ return {
49
+ pathname: url.slice(start, pathEnd),
50
+ search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
51
+ }
52
+ }
53
+
54
+ // Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
55
+ // for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
56
+ export function pathnameOf(url: string): string {
57
+ return urlPartsOf(url).pathname
58
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The opt-in idempotency request lane. Installed by `.use(idempotency())` and reached only by routes
3
+ * that declare `schema.idempotency`, so the kernel never statically imports this code: a bare
4
+ * `server()` tree-shakes the whole dedupe machinery out.
5
+ *
6
+ * The kernel resolves a route's declared idempotency at registration (via {@link IdempotencyRuntime.resolve})
7
+ * and, on the request path, hands a matched idempotent route to {@link IdempotencyRuntime.run}. Everything
8
+ * server-specific is supplied through {@link IdempotencyHost} so this module depends on no server internals.
9
+ */
10
+ import { RouteConfigError } from "../errors.ts"
11
+ import {
12
+ canonicalizeIdempotencyBody,
13
+ computeIdempotencyFingerprint,
14
+ DEFAULT_IDEMPOTENCY_HEADER,
15
+ DEFAULT_IDEMPOTENCY_TTL_MS,
16
+ IdempotencyResponseTooLargeError,
17
+ type IdempotencyStore,
18
+ MemoryIdempotencyStore,
19
+ responseFromStored,
20
+ serializeResponse,
21
+ validIdempotencyKey,
22
+ validIdempotencyNamespace,
23
+ } from "../idempotency.ts"
24
+ import {
25
+ beginRequestEffectTracking,
26
+ markEffect,
27
+ requestEffectEvidence,
28
+ requestIsSafeToRetry,
29
+ } from "../internal/effect-execution.ts"
30
+ import { readBoundedBytes } from "./body.ts"
31
+ import type { Platform, RouteSchema } from "./context.ts"
32
+ import { jsonError, urlPartsOf } from "./http.ts"
33
+ import { INSTALL_IDEMPOTENCY } from "./install.ts"
34
+ import type { AnyServer, IdentityPlugin } from "./server.ts"
35
+
36
+ /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
37
+ export interface ResolvedIdempotency {
38
+ readonly store: IdempotencyStore
39
+ readonly ttlMs: number
40
+ readonly headerName: string
41
+ readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
42
+ readonly maxResponseBytes: number
43
+ }
44
+
45
+ /**
46
+ * What the dedupe lane needs from the server to run a fresh key: the app-wide body cap and a way to
47
+ * run the buffered request through the route's normal lanes to a concrete `Response`.
48
+ */
49
+ export interface IdempotencyHost {
50
+ readonly maxBodyBytes: number
51
+ runLanes(
52
+ buffered: Request,
53
+ platform: Platform | undefined,
54
+ entry: unknown,
55
+ params: Record<string, string>,
56
+ search: string | undefined,
57
+ ): Promise<Response>
58
+ }
59
+
60
+ /** The injected idempotency implementation the kernel calls through when the plugin is installed. */
61
+ export interface IdempotencyRuntime {
62
+ readonly trackEffect: (context: object, committed: boolean) => void
63
+ /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
64
+ resolve(
65
+ schema: RouteSchema | undefined,
66
+ authenticated: boolean,
67
+ maxBodyBytes: number,
68
+ ): ResolvedIdempotency | undefined
69
+ /** The dedupe lane: read the key, fingerprint the request, consult the store, run/replay/reject. */
70
+ run<T>(
71
+ config: ResolvedIdempotency,
72
+ req: Request,
73
+ platform: Platform | undefined,
74
+ entry: unknown,
75
+ params: Record<string, string>,
76
+ search: string | undefined,
77
+ wrapResponse: (response: Response) => T,
78
+ host: IdempotencyHost,
79
+ ): Promise<T>
80
+ }
81
+
82
+ /** The install seam a server exposes so the `idempotency()` plugin can hand it a runtime. */
83
+ interface IdempotencyInstallable {
84
+ [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void
85
+ }
86
+
87
+ /**
88
+ * Enable request idempotency. Routes that declare `schema.idempotency` get the dedupe lane: a repeat
89
+ * `Idempotency-Key` replays the stored response instead of re-running the handler. Without this plugin,
90
+ * declaring `schema.idempotency` is a registration error (the safety gate can never be silently
91
+ * dropped). `store` is the app-wide default backing routes that do not pin their own; omit it for a
92
+ * shared in-process store (development/tests only - inject a durable store in production).
93
+ */
94
+ export interface IdempotencyPluginOptions {
95
+ readonly store?: IdempotencyStore | undefined
96
+ }
97
+
98
+ export function idempotency(options?: IdempotencyPluginOptions): IdentityPlugin {
99
+ const runtime = createIdempotencyRuntime(options)
100
+ const apply = <S extends AnyServer>(app: S): S => {
101
+ ;(app as unknown as IdempotencyInstallable)[INSTALL_IDEMPOTENCY](runtime)
102
+ return app
103
+ }
104
+ return Object.assign(apply, { pluginName: "nifra:idempotency" }) as IdentityPlugin
105
+ }
106
+
107
+ /**
108
+ * Build an idempotency runtime. `store` is the app-wide default backing routes that declare
109
+ * `schema.idempotency` without their own `store`; when omitted, a shared in-process
110
+ * {@link MemoryIdempotencyStore} is created lazily on first idempotent route.
111
+ */
112
+ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): IdempotencyRuntime {
113
+ const configuredStore = options?.store
114
+ let lazyStore: IdempotencyStore | undefined
115
+ const defaultStore = (): IdempotencyStore => {
116
+ if (configuredStore !== undefined) return configuredStore
117
+ lazyStore ??= new MemoryIdempotencyStore()
118
+ return lazyStore
119
+ }
120
+
121
+ return {
122
+ trackEffect(context, committed) {
123
+ markEffect(context, committed)
124
+ },
125
+ resolve(schema, authenticated, maxBodyBytes) {
126
+ const config = schema?.idempotency
127
+ if (config === undefined) return undefined
128
+ if (schema?.sse !== undefined) {
129
+ throw new RouteConfigError(
130
+ "INVALID_IDEMPOTENCY",
131
+ "streaming/SSE responses cannot be used on an idempotency route",
132
+ )
133
+ }
134
+ const store = config.store ?? defaultStore()
135
+ if (config.scope === "durable" && store.durability !== "durable") {
136
+ throw new RouteConfigError(
137
+ "INVALID_IDEMPOTENCY",
138
+ 'durable idempotency requires a durable store (store.durability must be "durable")',
139
+ )
140
+ }
141
+ const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
142
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
143
+ throw new RouteConfigError(
144
+ "INVALID_IDEMPOTENCY",
145
+ "idempotency ttlMs must be a finite positive number",
146
+ )
147
+ }
148
+ const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
149
+ try {
150
+ new Headers().set(headerName, "probe")
151
+ } catch {
152
+ throw new RouteConfigError(
153
+ "INVALID_IDEMPOTENCY",
154
+ `invalid idempotency header name ${JSON.stringify(headerName)}`,
155
+ )
156
+ }
157
+ const namespace = config.namespace
158
+ if (namespace === undefined) {
159
+ throw new RouteConfigError(
160
+ "INVALID_IDEMPOTENCY",
161
+ "idempotency namespace is required; use a principal resolver or an explicit shared scope",
162
+ )
163
+ }
164
+ if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
165
+ throw new RouteConfigError(
166
+ "INVALID_IDEMPOTENCY",
167
+ `invalid idempotency namespace ${JSON.stringify(namespace)}`,
168
+ )
169
+ }
170
+ if (authenticated && typeof namespace !== "function") {
171
+ throw new RouteConfigError(
172
+ "INVALID_IDEMPOTENCY",
173
+ "authenticated idempotency routes require a principal namespace resolver",
174
+ )
175
+ }
176
+ const maxResponseBytes = config.maxResponseBytes ?? maxBodyBytes
177
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
178
+ throw new RouteConfigError(
179
+ "INVALID_IDEMPOTENCY",
180
+ "idempotency maxResponseBytes must be a positive integer",
181
+ )
182
+ }
183
+ return Object.freeze({
184
+ store,
185
+ ttlMs,
186
+ headerName,
187
+ namespace,
188
+ maxResponseBytes,
189
+ })
190
+ },
191
+
192
+ async run(config, req, platform, entry, params, search, wrapResponse, host) {
193
+ const key = req.headers.get(config.headerName)
194
+ if (key === null || !validIdempotencyKey(key)) {
195
+ return wrapResponse(jsonError(400, "idempotency_key_required"))
196
+ }
197
+ const namespace =
198
+ typeof config.namespace === "function"
199
+ ? await config.namespace(req.clone(), platform)
200
+ : config.namespace
201
+ if (!validIdempotencyNamespace(namespace)) {
202
+ return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
203
+ }
204
+ const read = await readBoundedBytes(req, host.maxBodyBytes)
205
+ if (!read.ok) {
206
+ return wrapResponse(
207
+ jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
208
+ )
209
+ }
210
+ const url = urlPartsOf(req.url)
211
+ const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
212
+ const fingerprint = await computeIdempotencyFingerprint(
213
+ req.method,
214
+ `${url.pathname}${url.search}`,
215
+ canonicalBody,
216
+ req.headers.get("content-type") ?? "",
217
+ )
218
+
219
+ const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
220
+ if (begin.state === "replay") {
221
+ return wrapResponse(
222
+ responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
223
+ )
224
+ }
225
+ if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
226
+ if (begin.state === "in-flight") {
227
+ return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
228
+ }
229
+ if (begin.state === "capacity") {
230
+ return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
231
+ }
232
+ const reservation = begin.reservation
233
+
234
+ // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
235
+ // then run those lanes to a concrete Response.
236
+ const bufferedInit: RequestInit = {
237
+ method: req.method,
238
+ headers: req.headers,
239
+ signal: req.signal,
240
+ }
241
+ if (read.bytes.byteLength > 0)
242
+ bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
243
+ const buffered = new Request(req.url, bufferedInit)
244
+ beginRequestEffectTracking(buffered)
245
+ let response: Response
246
+ try {
247
+ response = await host.runLanes(buffered, platform, entry, params, search)
248
+ } catch (err) {
249
+ const evidence = requestEffectEvidence(buffered)
250
+ if (!evidence.began) {
251
+ // The request-local boundary proves no owned effect started, so this is the one safe case
252
+ // where releasing the key cannot duplicate an effect.
253
+ await config.store.abandon({ namespace, key, reservation })
254
+ throw err
255
+ }
256
+ // A committed or ambiguous effect must never be repeated. Persist a payload-free terminal
257
+ // response even though the normal handler lifecycle escaped without producing one.
258
+ response = new Response(null, { status: 500 })
259
+ const completed = await config.store.complete({
260
+ namespace,
261
+ key,
262
+ reservation,
263
+ response: await serializeResponse(response, { maxBytes: config.maxResponseBytes }),
264
+ })
265
+ if (!completed) {
266
+ return wrapResponse(
267
+ jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
268
+ )
269
+ }
270
+ return wrapResponse(response)
271
+ }
272
+ if (
273
+ response.status >= 500 &&
274
+ requestIsSafeToRetry(buffered) &&
275
+ !requestEffectEvidence(buffered).began
276
+ ) {
277
+ const abandoned = await config.store.abandon({ namespace, key, reservation })
278
+ if (!abandoned) {
279
+ return wrapResponse(
280
+ jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }),
281
+ )
282
+ }
283
+ return wrapResponse(response)
284
+ }
285
+ // Once the handler ran, every concrete response is terminal for this key. A non-2xx may follow
286
+ // an already-committed external effect; abandoning it would let a retry duplicate that effect.
287
+ let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
288
+ try {
289
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
290
+ } catch (error) {
291
+ if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
292
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
293
+ // Commit a small terminal response under the winning key and return that same response now.
294
+ response = jsonError(507, "idempotency_response_too_large")
295
+ try {
296
+ storedResponse = await serializeResponse(response, {
297
+ maxBytes: config.maxResponseBytes,
298
+ })
299
+ } catch (terminalError) {
300
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
301
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
302
+ // status + replay safety without silently storing more bytes than the route permits.
303
+ response = new Response(null, { status: 507 })
304
+ storedResponse = await serializeResponse(response, {
305
+ maxBytes: config.maxResponseBytes,
306
+ })
307
+ }
308
+ }
309
+ const completed = await config.store.complete({
310
+ namespace,
311
+ key,
312
+ reservation,
313
+ response: storedResponse,
314
+ })
315
+ if (!completed) {
316
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
317
+ }
318
+ return wrapResponse(response)
319
+ },
320
+ }
321
+ }