@nifrajs/core 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +2384 -0
  2. package/dist/assurance.d.ts +15 -1
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +97 -71
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +2 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +1 -1
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/edge-kit.d.ts +1 -1
  11. package/dist/edge-kit.d.ts.map +1 -1
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/hook-origins.d.ts +17 -1
  18. package/dist/internal/hook-origins.d.ts.map +1 -1
  19. package/dist/internal/hook-origins.js +20 -0
  20. package/dist/internal/hook-origins.js.map +1 -1
  21. package/dist/internal/route-compiler.d.ts +9 -0
  22. package/dist/internal/route-compiler.d.ts.map +1 -1
  23. package/dist/internal/route-compiler.js +95 -13
  24. package/dist/internal/route-compiler.js.map +1 -1
  25. package/dist/internal/route-program.d.ts +2 -0
  26. package/dist/internal/route-program.d.ts.map +1 -1
  27. package/dist/internal/route-program.js +22 -17
  28. package/dist/internal/route-program.js.map +1 -1
  29. package/dist/internal/same-origin.d.ts +11 -1
  30. package/dist/internal/same-origin.d.ts.map +1 -1
  31. package/dist/internal/same-origin.js +8 -0
  32. package/dist/internal/same-origin.js.map +1 -1
  33. package/dist/reflection.d.ts +23 -2
  34. package/dist/reflection.d.ts.map +1 -1
  35. package/dist/reflection.js +57 -1
  36. package/dist/reflection.js.map +1 -1
  37. package/dist/server/body-lane.d.ts +8 -2
  38. package/dist/server/body-lane.d.ts.map +1 -1
  39. package/dist/server/body-lane.js +27 -2
  40. package/dist/server/body-lane.js.map +1 -1
  41. package/dist/server/client-ip.js +6 -5
  42. package/dist/server/client-ip.js.map +1 -1
  43. package/dist/server/context.d.ts +7 -4
  44. package/dist/server/context.d.ts.map +1 -1
  45. package/dist/server/cookies.d.ts.map +1 -1
  46. package/dist/server/cookies.js +13 -1
  47. package/dist/server/cookies.js.map +1 -1
  48. package/dist/server/core-copies.d.ts +7 -1
  49. package/dist/server/core-copies.d.ts.map +1 -1
  50. package/dist/server/core-copies.js +37 -1
  51. package/dist/server/core-copies.js.map +1 -1
  52. package/dist/server/edge.d.ts.map +1 -1
  53. package/dist/server/edge.js +2 -1
  54. package/dist/server/edge.js.map +1 -1
  55. package/dist/server/headers.d.ts +9 -0
  56. package/dist/server/headers.d.ts.map +1 -1
  57. package/dist/server/headers.js +26 -0
  58. package/dist/server/headers.js.map +1 -1
  59. package/dist/server/http.d.ts.map +1 -1
  60. package/dist/server/http.js +5 -1
  61. package/dist/server/http.js.map +1 -1
  62. package/dist/server/response-contract-lane.d.ts +57 -7
  63. package/dist/server/response-contract-lane.d.ts.map +1 -1
  64. package/dist/server/response-contract-lane.js +394 -23
  65. package/dist/server/response-contract-lane.js.map +1 -1
  66. package/dist/server/server-types.d.ts +23 -9
  67. package/dist/server/server-types.d.ts.map +1 -1
  68. package/dist/server/server.d.ts +40 -5
  69. package/dist/server/server.d.ts.map +1 -1
  70. package/dist/server/server.js +188 -43
  71. package/dist/server/server.js.map +1 -1
  72. package/dist/server/websocket.d.ts +11 -0
  73. package/dist/server/websocket.d.ts.map +1 -1
  74. package/dist/server/websocket.js +26 -12
  75. package/dist/server/websocket.js.map +1 -1
  76. package/dist/server/ws-bun.d.ts +7 -1
  77. package/dist/server/ws-bun.d.ts.map +1 -1
  78. package/dist/server/ws-bun.js +42 -2
  79. package/dist/server/ws-bun.js.map +1 -1
  80. package/dist/server/ws-hook.d.ts +9 -3
  81. package/dist/server/ws-hook.d.ts.map +1 -1
  82. package/dist/server.d.ts +1 -1
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/server.js +1 -1
  85. package/dist/server.js.map +1 -1
  86. package/dist/single-copy.d.ts.map +1 -1
  87. package/dist/single-copy.js +81 -10
  88. package/dist/single-copy.js.map +1 -1
  89. package/dist/wire.d.ts +5 -0
  90. package/dist/wire.d.ts.map +1 -1
  91. package/dist/wire.js +5 -0
  92. package/dist/wire.js.map +1 -1
  93. package/dist/ws.d.ts.map +1 -1
  94. package/dist/ws.js +2 -1
  95. package/dist/ws.js.map +1 -1
  96. package/package.json +3 -2
  97. package/src/assurance.ts +120 -81
  98. package/src/capabilities.ts +3 -1
  99. package/src/edge-kit.ts +1 -1
  100. package/src/index.ts +2 -2
  101. package/src/internal/hook-origins.ts +35 -0
  102. package/src/internal/route-compiler.ts +107 -16
  103. package/src/internal/route-program.ts +39 -20
  104. package/src/internal/same-origin.ts +14 -1
  105. package/src/reflection.ts +61 -1
  106. package/src/server/body-lane.ts +36 -1
  107. package/src/server/client-ip.ts +5 -5
  108. package/src/server/context.ts +7 -4
  109. package/src/server/cookies.ts +12 -1
  110. package/src/server/core-copies.ts +40 -0
  111. package/src/server/edge.ts +2 -1
  112. package/src/server/headers.ts +39 -0
  113. package/src/server/http.ts +7 -1
  114. package/src/server/response-contract-lane.ts +491 -24
  115. package/src/server/server-types.ts +23 -9
  116. package/src/server/server.ts +249 -35
  117. package/src/server/websocket.ts +39 -13
  118. package/src/server/ws-bun.ts +48 -1
  119. package/src/server/ws-hook.ts +9 -2
  120. package/src/server.ts +1 -1
  121. package/src/single-copy.ts +90 -9
  122. package/src/wire.ts +14 -0
  123. package/src/ws.ts +2 -0
@@ -16,6 +16,10 @@ export type HookKind =
16
16
  | "onResponse"
17
17
  | "responseHeaders"
18
18
  | "onResponseFinalized"
19
+ // Runtimes a route resolved at registration: reflected beside its hooks, never tagged as one.
20
+ | "effectLedger"
21
+ | "idempotency"
22
+ | "responseContract"
19
23
 
20
24
  export interface HookOrigin {
21
25
  readonly kind: HookKind
@@ -48,11 +52,24 @@ export interface AdoptionTag {
48
52
  readonly prefix: string | undefined
49
53
  }
50
54
 
55
+ /** A `merge()` whose routes carry no authentication of their own while the parent authenticates the
56
+ * routes it declares: the parent's order-scoped auth never runs for merged routes. */
57
+ export interface MergeAuthGapTag {
58
+ /** The `merge()` call's `file:line:col`, when a frame outside core was found. */
59
+ readonly site: string | undefined
60
+ /** The parent's auth in effect at the merge: `authenticate(<id>)` stages and evidence sources. */
61
+ readonly auth: readonly string[]
62
+ /** `METHOD /path` of each merged route the parent's auth would have covered. */
63
+ readonly routes: readonly string[]
64
+ }
65
+
51
66
  const origins = new WeakMap<object, HookOrigin>()
52
67
  const scopes = new WeakMap<object, HookScopeTag>()
53
68
  const sources = new WeakMap<object, unknown>()
54
69
  const statics = new WeakMap<object, StaticHeadersTag[]>()
55
70
  const adoptions = new WeakMap<object, AdoptionTag[]>()
71
+ const mergeAuthGaps = new WeakMap<object, MergeAuthGapTag[]>()
72
+ const routeSites = new WeakMap<object, Map<string, string>>()
56
73
  let applying: string | undefined
57
74
  // A merged server whose static headers are about to become one of the target's response hooks.
58
75
  let staticsOwner: object | undefined
@@ -130,6 +147,20 @@ export function tagAdoption(target: object, source: object, prefix: string | und
130
147
  else list.push(tag)
131
148
  }
132
149
 
150
+ export function tagMergeAuthGap(target: object, gap: MergeAuthGapTag): void {
151
+ const list = mergeAuthGaps.get(target)
152
+ if (list === undefined) mergeAuthGaps.set(target, [gap])
153
+ else list.push(gap)
154
+ }
155
+
156
+ /** Where `server` registered the route `key` (`METHOD /path`): a `file:line:col` frame. The first
157
+ * registration of a key wins, as it does for the router. */
158
+ export function tagRouteSite(server: object, key: string, site: string): void {
159
+ const sites = routeSites.get(server)
160
+ if (sites === undefined) routeSites.set(server, new Map([[key, site]]))
161
+ else if (!sites.has(key)) sites.set(key, site)
162
+ }
163
+
133
164
  const outer: (string | undefined)[] = []
134
165
 
135
166
  /** Mark the plugin a `use()` is applying, until the matching {@link leavePlugin}. */
@@ -151,3 +182,7 @@ export const hookSourceOf = (hook: unknown): unknown =>
151
182
  export const staticHeadersOf = (server: object): readonly StaticHeadersTag[] =>
152
183
  statics.get(server) ?? []
153
184
  export const adoptionsOf = (server: object): readonly AdoptionTag[] => adoptions.get(server) ?? []
185
+ export const mergeAuthGapsOf = (server: object): readonly MergeAuthGapTag[] =>
186
+ mergeAuthGaps.get(server) ?? []
187
+ export const routeSitesOf = (server: object): ReadonlyMap<string, string> =>
188
+ routeSites.get(server) ?? new Map()
@@ -35,6 +35,7 @@ import {
35
35
  NIFRA_ASSURANCE_IDS,
36
36
  validEvidenceId,
37
37
  } from "./route-assurance.ts"
38
+ import type { CatalogRoute } from "./route-catalog.ts"
38
39
  import type {
39
40
  RawAfterHandle,
40
41
  RawAround,
@@ -43,7 +44,9 @@ import type {
43
44
  RawDerive,
44
45
  RawErrorHandler,
45
46
  } from "./route-execution.ts"
47
+ import { compileRouteExecutionPlan } from "./route-execution.ts"
46
48
  import { type RouteLaneSelection, selectRouteLanes } from "./route-lanes.ts"
49
+ import { compileRouteProgram } from "./route-program.ts"
47
50
 
48
51
  export interface RouteCompilerContext {
49
52
  readonly maxBodyBytes: number
@@ -83,6 +86,108 @@ export interface CompiledRouteOptions {
83
86
  readonly authenticated: boolean
84
87
  }
85
88
 
89
+ const RESPONSE_CONTRACT_EVIDENCE: AssuranceDeclaration = Object.freeze({
90
+ id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
91
+ source: "response-contract",
92
+ scope: "plugin",
93
+ })
94
+
95
+ function responseDefinitionOf(
96
+ schema: RouteSchema | undefined,
97
+ ): ResponseContractDefinition | undefined {
98
+ return schema?.response !== undefined || schema?.errors !== undefined
99
+ ? {
100
+ ...(schema.response === undefined ? {} : { response: schema.response }),
101
+ ...(schema.errors === undefined ? {} : { errors: schema.errors }),
102
+ }
103
+ : undefined
104
+ }
105
+
106
+ /**
107
+ * Recompile a route another server compiled, under the response contract and effect ledger the
108
+ * adopting server installed and the route's own server did not. Both only add a check or a record,
109
+ * so a merged route can never serve more than it did standalone - and without this, the same
110
+ * `.use()` covered a `group()` route but not a `merge()`d one. A route that already resolved its
111
+ * own contract or ledger keeps it.
112
+ */
113
+ export function adoptRouteRuntimes(
114
+ route: CatalogRoute,
115
+ responseContractRuntime: ResponseContractRuntime | undefined,
116
+ effectLedgerRuntime: EffectLedgerRuntime | undefined,
117
+ ): CatalogRoute {
118
+ const { entry, descriptor } = route
119
+ const definition =
120
+ entry.responseContract === undefined && responseContractRuntime !== undefined
121
+ ? responseDefinitionOf(entry.schema)
122
+ : undefined
123
+ const ledgered =
124
+ entry.ledgered ??
125
+ effectLedgerRuntime?.resolve(descriptor.capabilities ?? [], route.method, route.path)
126
+ if (definition === undefined && ledgered === entry.ledgered) return route
127
+ const responseContract =
128
+ definition === undefined || responseContractRuntime === undefined
129
+ ? entry.responseContract
130
+ : { runtime: responseContractRuntime, definition }
131
+ // Either runtime rules out every fused lane, so the route needs only the generic plan here - and
132
+ // nothing the source server's validation-error default decided about fusion can matter any more.
133
+ const lanes = selectRouteLanes({
134
+ schema: entry.schema,
135
+ hasAuth: entry.authStages.length > 0,
136
+ hasIdempotency: entry.idempotent !== undefined,
137
+ hasLedger: ledgered !== undefined,
138
+ hasResponseContract: responseContract !== undefined,
139
+ hasDecorations: entry.hasDecorations,
140
+ derives: entry.derives.length,
141
+ beforeHandle: entry.beforeHandle.length,
142
+ afterHandle: entry.afterHandle.length,
143
+ onError: entry.onError.length,
144
+ around: entry.around.length,
145
+ defaultOnValidationError: false,
146
+ })
147
+ const program = compileRouteProgram({
148
+ schema: entry.schema,
149
+ handler: entry.handler,
150
+ derives: entry.derives,
151
+ authStages: entry.authStages,
152
+ beforeHandle: entry.beforeHandle,
153
+ afterHandle: entry.afterHandle,
154
+ onError: entry.onError,
155
+ decorations: entry.decorations,
156
+ hasDecorations: entry.hasDecorations,
157
+ bodySchema: entry.schema?.body,
158
+ bodyLimit: entry.bodyLimit,
159
+ responseContract,
160
+ })
161
+ const execution = compileRouteExecutionPlan({
162
+ lane: lanes.lane,
163
+ contextless: false,
164
+ hasAround: entry.around.length > 0,
165
+ hasLedger: ledgered !== undefined,
166
+ fusedWeb: undefined,
167
+ fusedBody: undefined,
168
+ fusedLane: undefined,
169
+ })
170
+ const enforced = definition !== undefined && responseContractRuntime?.mode === "enforce"
171
+ return {
172
+ ...route,
173
+ entry: {
174
+ ...entry,
175
+ ledgered,
176
+ responseContract,
177
+ lifecycleHookLane: undefined,
178
+ execution,
179
+ program,
180
+ },
181
+ descriptor:
182
+ definition === undefined || responseContractRuntime === undefined
183
+ ? descriptor
184
+ : { ...descriptor, responseContract: responseContractRuntime.mode },
185
+ assurance: enforced
186
+ ? Object.freeze([...route.assurance, RESPONSE_CONTRACT_EVIDENCE])
187
+ : route.assurance,
188
+ }
189
+ }
190
+
86
191
  /** Resolve all registration-time policy and lane facts for one route. */
87
192
  export function compileRouteOptions(
88
193
  context: RouteCompilerContext,
@@ -181,13 +286,7 @@ export function compileRouteOptions(
181
286
  context.maxBodyBytes,
182
287
  )
183
288
  const ledgered = context.effectLedgerRuntime?.resolve(capabilities, method, path)
184
- const responseDefinition =
185
- schema?.response !== undefined || schema?.errors !== undefined
186
- ? {
187
- ...(schema.response === undefined ? {} : { response: schema.response }),
188
- ...(schema.errors === undefined ? {} : { errors: schema.errors }),
189
- }
190
- : undefined
289
+ const responseDefinition = responseDefinitionOf(schema)
191
290
  const contracted =
192
291
  context.responseContractRuntime !== undefined && responseDefinition !== undefined
193
292
  ? { runtime: context.responseContractRuntime, definition: responseDefinition }
@@ -208,15 +307,7 @@ export function compileRouteOptions(
208
307
  })
209
308
 
210
309
  const routeAssurance: AssuranceDeclaration[] = [...context.activeAssurance, ...handlerAssurance]
211
- if (contracted?.runtime.mode === "enforce") {
212
- routeAssurance.push(
213
- Object.freeze({
214
- id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
215
- source: "response-contract",
216
- scope: "plugin",
217
- }),
218
- )
219
- }
310
+ if (contracted?.runtime.mode === "enforce") routeAssurance.push(RESPONSE_CONTRACT_EVIDENCE)
220
311
  for (const id of schema?.assurance ?? []) {
221
312
  if (!validEvidenceId(id)) {
222
313
  throw new Error(
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { StandardSchemaV1 } from "../schema/standard.ts"
11
11
  import { markAuthenticatedRequest } from "../server/auth.ts"
12
+ import { declaredHeaderNames } from "../server/headers.ts"
12
13
  import { pathnameOf } from "../server/http.ts"
13
14
  import { queryObjectOf } from "../server/query.ts"
14
15
  import type { Registry } from "../server/registry.ts"
@@ -35,7 +36,12 @@ import type {
35
36
  export type ProgramValidationKind = "headers" | "cookies" | "params" | "body" | "query"
36
37
 
37
38
  export type RouteProgramStage =
38
- | { readonly kind: ProgramValidationKind; readonly schema: StandardSchemaV1 }
39
+ | {
40
+ readonly kind: ProgramValidationKind
41
+ readonly schema: StandardSchemaV1
42
+ /** Headers only: the names a closed schema validates, read case-insensitively. */
43
+ readonly names?: readonly string[] | undefined
44
+ }
39
45
  | { readonly kind: "derive"; readonly run: RawDerive }
40
46
  | { readonly kind: "before"; readonly run: RawBeforeHandle }
41
47
  | { readonly kind: "handler"; readonly run: InternalHandler }
@@ -90,7 +96,8 @@ export function compileRouteProgram(input: RouteProgramInput): RouteProgram {
90
96
  for (const kind of ["headers", "cookies", "params", "body", "query"] as const) {
91
97
  const validator = schema?.[kind]
92
98
  if (validator !== undefined) {
93
- const stage = { kind, schema: validator }
99
+ const names = kind === "headers" ? declaredHeaderNames(validator) : undefined
100
+ const stage = { kind, schema: validator, names }
94
101
  stages.push(stage)
95
102
  validationStages.push(stage)
96
103
  }
@@ -142,6 +149,7 @@ interface RouteProgramRuntime {
142
149
  ctx: RawContext,
143
150
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
144
151
  wrapResponse: (response: Response | ResponseResult) => T,
152
+ fallback?: () => T,
145
153
  ): MaybePromise<T>
146
154
  finishProgramResult<T>(
147
155
  entry: RouteEntry,
@@ -243,6 +251,21 @@ function validAuthenticationFailureReason(
243
251
  )
244
252
  }
245
253
 
254
+ /** A failed stage still answers 503, but its cause takes the handler-error path (onError, else the
255
+ * logger) instead of vanishing. */
256
+ function failAuthentication<T>(
257
+ host: RouteProgramRuntime,
258
+ entry: RouteEntry,
259
+ ctx: RawContext,
260
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
261
+ wrapResponse: (response: Response | ResponseResult) => T,
262
+ error: unknown,
263
+ ): ProgramResult<T> {
264
+ return host.handleProgramError(entry, error, ctx, finalize, wrapResponse, () =>
265
+ finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx),
266
+ )
267
+ }
268
+
246
269
  function runAuthenticationStages<T>(
247
270
  host: RouteProgramRuntime,
248
271
  entry: RouteEntry,
@@ -287,8 +310,8 @@ function runAuthenticationStages<T>(
287
310
  let outcome: MaybePromise<import("../server/auth.ts").AuthenticationResult<unknown>>
288
311
  try {
289
312
  outcome = stage.run(input)
290
- } catch {
291
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
313
+ } catch (error) {
314
+ return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
292
315
  }
293
316
  if (outcome instanceof Promise) {
294
317
  return outcome.then(
@@ -312,7 +335,7 @@ function runAuthenticationStages<T>(
312
335
  return host.handleProgramError(entry, error, ctx, finalize, wrapResponse)
313
336
  }
314
337
  },
315
- () => finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx),
338
+ (error) => failAuthentication(host, entry, ctx, finalize, wrapResponse, error),
316
339
  )
317
340
  }
318
341
  return finishAuthentication(
@@ -344,27 +367,23 @@ function finishAuthentication<T>(
344
367
  if (
345
368
  result === null ||
346
369
  typeof result !== "object" ||
347
- (result.kind !== "authenticated" && result.kind !== "rejected")
370
+ (result.kind === "rejected"
371
+ ? !validAuthenticationFailureReason(result.reason) ||
372
+ (result.response !== undefined &&
373
+ !(result.response instanceof Response) &&
374
+ !isResponseResult(result.response))
375
+ : result.kind !== "authenticated" ||
376
+ result.principal === null ||
377
+ result.principal === undefined)
348
378
  ) {
349
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
379
+ const id = program.authStages[next - 1]!.id
380
+ const error = new TypeError(`authenticate() stage "${id}" returned an invalid decision`)
381
+ return failAuthentication(host, entry, ctx, finalize, wrapResponse, error)
350
382
  }
351
383
  if (result.kind === "rejected") {
352
- if (!validAuthenticationFailureReason(result.reason)) {
353
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
354
- }
355
- if (
356
- result.response !== undefined &&
357
- !(result.response instanceof Response) &&
358
- !isResponseResult(result.response)
359
- ) {
360
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
361
- }
362
384
  const response = result.response ?? defaultAuthenticationResponse(result.reason)
363
385
  return finalize(response, responseSet(ctx), ctx)
364
386
  }
365
- if (result.principal === null || result.principal === undefined) {
366
- return finalize(defaultAuthenticationResponse("unavailable"), responseSet(ctx), ctx)
367
- }
368
387
  Object.defineProperty(ctx, "principal", {
369
388
  value: result.principal,
370
389
  enumerable: true,
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * An unparseable Origin is not same-origin.
28
28
  */
29
- export function isSameOriginRequest(origin: string, request: Request): boolean {
29
+ export function isSameOriginRequest(origin: string, request: { readonly url: string }): boolean {
30
30
  try {
31
31
  const from = new URL(origin)
32
32
  const own = new URL(request.url)
@@ -41,3 +41,16 @@ export function isSameOriginRequest(origin: string, request: Request): boolean {
41
41
  return false
42
42
  }
43
43
  }
44
+
45
+ /**
46
+ * {@link isSameOriginRequest} for a browser form submission. Under `Referrer-Policy: no-referrer` a
47
+ * browser sends a same-origin form POST with `Origin: null`; that one passes on its
48
+ * `Sec-Fetch-Site: same-origin`, a header page script cannot set.
49
+ */
50
+ export function isSameOriginForm(
51
+ origin: string,
52
+ secFetchSite: string | null,
53
+ request: { readonly url: string },
54
+ ): boolean {
55
+ return origin === "null" ? secFetchSite === "same-origin" : isSameOriginRequest(origin, request)
56
+ }
package/src/reflection.ts CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  hookOriginOf,
21
21
  hookScopeOf,
22
22
  hookSourceOf,
23
+ type MergeAuthGapTag,
24
+ mergeAuthGapsOf,
25
+ routeSitesOf,
23
26
  staticHeadersOf,
24
27
  } from "./internal/hook-origins.ts"
25
28
  import {
@@ -33,6 +36,8 @@ import { NIFRA_BACKEND_EVIDENCE } from "./mount.ts"
33
36
  import type { StandardSchemaV1 } from "./schema/standard.ts"
34
37
  import type { ToolAnnotations } from "./server/server.ts"
35
38
 
39
+ export { declaredHeaderNames } from "./server/headers.ts"
40
+
36
41
  /** JSON Schema permits either a schema object or the boolean schemas `true` and `false`. */
37
42
  export type JsonSchema = boolean | Readonly<Record<string, unknown>>
38
43
 
@@ -460,7 +465,9 @@ export interface ReflectedHook {
460
465
  readonly kind: HookKind
461
466
  /** The plugin that registered it, else the function's own name, else `(anonymous)`. */
462
467
  readonly name: string
463
- /** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`. */
468
+ /** Where it was registered: `app`, `group /api`, `merge #1`, `mount /auth`, or `unknown`; `route`
469
+ * for a runtime the route resolved at registration (`responseContract`, `effectLedger`,
470
+ * `idempotency`), whose `name` is the contract mode or the runtime. */
464
471
  readonly site: string
465
472
  /** The header names a static `responseHeaders()` declaration sets. */
466
473
  readonly headers?: readonly string[]
@@ -559,6 +566,51 @@ const catalogOf = (
559
566
  const joinPath = (base: string, path: string): string =>
560
567
  base === "" ? path : path === "/" ? base : `${base}${path}`
561
568
 
569
+ /** A `merge()` that authenticates none of its routes under a parent that authenticates its own. */
570
+ export type ReflectedMergeAuthGap = MergeAuthGapTag
571
+
572
+ /**
573
+ * Every `merge()` in the app, its merged servers included, whose server authenticates none of its
574
+ * routes while the parent's order-scoped auth - an `authenticate()` stage, or `authenticated` evidence
575
+ * scoped to subsequent routes - covers the parent's own: that auth never runs for a merged route.
576
+ * Recorded by a development load only (the same provenance {@link reflectHooks} reads), so reflect the
577
+ * app outside production. `nifra check` reports each one (NF-C043).
578
+ */
579
+ export function reflectMergeAuthGaps(source: unknown): readonly ReflectedMergeAuthGap[] {
580
+ if (!isServerRecord(source)) return []
581
+ const gaps: ReflectedMergeAuthGap[] = []
582
+ const seen = new Set<object>()
583
+ const visit = (server: object): void => {
584
+ if (seen.has(server)) return
585
+ seen.add(server)
586
+ gaps.push(...mergeAuthGapsOf(server))
587
+ for (const adoption of adoptionsOf(server)) visit(adoption.source)
588
+ }
589
+ visit(source)
590
+ return gaps
591
+ }
592
+
593
+ /**
594
+ * Where each route was registered: `METHOD /path` (`WS` for a socket) mapped to the `file:line:col` of
595
+ * the call outside nifra's core that registered it, through every `group()` and `merge()`. Recorded only
596
+ * by a development load with `NIFRA_ROUTE_SITES=1` (the CLI sets it while it loads `nifra.assurance.ts`),
597
+ * so the map is empty otherwise. The paths are absolute, which is why they stay out of
598
+ * {@link reflectRoutes}, whose output is written into artifacts.
599
+ */
600
+ export function reflectRouteSites(source: unknown): ReadonlyMap<string, string> {
601
+ const sites = new Map<string, string>()
602
+ if (!isServerRecord(source)) return sites
603
+ const seen = new Set<object>()
604
+ const visit = (server: object): void => {
605
+ if (seen.has(server)) return
606
+ seen.add(server)
607
+ for (const [key, site] of routeSitesOf(server)) if (!sites.has(key)) sites.set(key, site)
608
+ for (const adoption of adoptionsOf(server)) visit(adoption.source)
609
+ }
610
+ visit(source)
611
+ return sites
612
+ }
613
+
562
614
  /**
563
615
  * Every hook that actually runs for each route, and for each prefix's unmatched paths, with the plugin
564
616
  * that registered it and where: the app, a `group()`, a `merge()`d server, or a `mount()`ed one. It
@@ -698,16 +750,24 @@ export function reflectHooks(source: unknown): ReflectedHookReport {
698
750
  const entry = recordOf(route.entry)
699
751
  const chain = (field: string, kind: HookKind) =>
700
752
  entry === undefined ? [] : listOf(entry, field).flatMap((hook) => entriesOf(hook, kind))
753
+ // A runtime is pinned into the route when it registers (or is merged), so it is listed with the
754
+ // route's site rather than a hook's; the mode is the name so a warn -> enforce change shows.
755
+ const mode = recordOf(recordOf(entry?.responseContract)?.runtime)?.mode
756
+ const pinned = (present: boolean, kind: HookKind, name: string): ReflectedHook[] =>
757
+ present ? [{ kind, name, site: "route" }] : []
701
758
  routes.push({
702
759
  method: route.method,
703
760
  path: reportPath(route.path),
704
761
  hooks: [
705
762
  ...requestPhase(probes),
706
763
  ...chain("around", "around"),
764
+ ...pinned(entry?.ledgered !== undefined, "effectLedger", "effectLedger"),
707
765
  ...chain("authStages", "authenticate"),
708
766
  ...chain("derives", "derive"),
709
767
  ...chain("beforeHandle", "beforeHandle"),
768
+ ...pinned(entry?.idempotent !== undefined, "idempotency", "idempotency"),
710
769
  ...chain("afterHandle", "afterHandle"),
770
+ ...pinned(typeof mode === "string", "responseContract", String(mode)),
711
771
  ...chain("onError", "onError"),
712
772
  ...responsePhase(probes),
713
773
  ],
@@ -3,6 +3,7 @@
3
3
  * reuse the exact trust-boundary lane without bundling the full context implementation (cookies,
4
4
  * deferred responses, and request helpers).
5
5
  */
6
+ import { isSameOriginForm } from "../internal/same-origin.ts"
6
7
  import { drainCapped, hasTrustedBodyFraming, parseContentLength, RAW_BODY_READERS } from "./body.ts"
7
8
  import { plainError } from "./http.ts"
8
9
  import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
@@ -191,10 +192,39 @@ export type SchemaBodyReader = ((
191
192
  readonly mediaTypes?: readonly string[]
192
193
  }
193
194
 
195
+ /**
196
+ * Origins besides the request's own whose HTML forms may post a body: exact origins, or a function
197
+ * that receives the request's `Origin`.
198
+ */
199
+ export type FormOrigins = ReadonlyArray<string> | ((origin: string) => boolean)
200
+
201
+ /** The media types a cross-site HTML form can send with the visitor's cookies and no preflight. */
202
+ function isFormMediaType(contentType: string): boolean {
203
+ const semi = contentType.indexOf(";")
204
+ const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
205
+ return (
206
+ essence === "application/x-www-form-urlencoded" ||
207
+ essence === "multipart/form-data" ||
208
+ essence === "text/plain"
209
+ )
210
+ }
211
+
212
+ function formOriginAllowed(source: RequestSource, formOrigins: FormOrigins | undefined): boolean {
213
+ const origin = headerOf(source, "origin")
214
+ if (origin === null || isSameOriginForm(origin, headerOf(source, "sec-fetch-site"), source)) {
215
+ return true
216
+ }
217
+ if (formOrigins === undefined) return false
218
+ return typeof formOrigins === "function"
219
+ ? formOrigins(origin) === true
220
+ : formOrigins.includes(origin)
221
+ }
222
+
194
223
  /**
195
224
  * The shared content-type dispatcher around the JSON and urlencoded lanes. A `bodySchema` branded
196
225
  * with {@link SCHEMA_BODY_READER} takes every other media type; JSON and urlencoded bodies keep
197
- * their lanes whatever the schema carries.
226
+ * their lanes whatever the schema carries. A form media type whose `Origin` is another site's, and
227
+ * not one of `formOrigins`, is refused with `403` before the body is read.
198
228
  */
199
229
  export function readBodyFramed<T>(
200
230
  source: RequestSource,
@@ -204,9 +234,14 @@ export function readBodyFramed<T>(
204
234
  wrapResponse: (response: Response | ResponseResult) => T,
205
235
  onError: (err: unknown) => MaybePromise<T>,
206
236
  bodySchema?: object,
237
+ formOrigins?: FormOrigins,
207
238
  ): Promise<T> {
208
239
  const contentType = headerOf(source, "content-type") ?? ""
209
240
  if (!isJsonMediaType(contentType)) {
241
+ // JSON needs a preflight, so CORS judges it; a form body reaches here from any site with cookies.
242
+ if (isFormMediaType(contentType) && !formOriginAllowed(source, formOrigins)) {
243
+ return Promise.resolve(wrapResponse(plainError(403, "forbidden_origin")))
244
+ }
210
245
  if (isUrlEncodedForm(contentType)) {
211
246
  return readBoundedForm(source, maxBodyBytes).then(
212
247
  (form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
@@ -56,7 +56,9 @@ export function resolveClientIp(
56
56
  if (trust === undefined) return peer
57
57
  if ("header" in trust) return firstHeaderValue(req, trust.header)
58
58
 
59
- // trustedHops: walk `X-Forwarded-For` (client-most first) plus the socket peer as the closest hop.
59
+ if (trust.trustedHops === 0) return peer
60
+ // Hop 0 is the socket peer and hop k the k-th XFF entry from the right, so the caller is read from the
61
+ // header alone: a peer-less request (Workers, a unix socket) must not shift an attacker's entry in.
60
62
  const xff = req.headers.get("x-forwarded-for")
61
63
  const forwarded =
62
64
  xff === null
@@ -65,8 +67,6 @@ export function resolveClientIp(
65
67
  .split(",")
66
68
  .map((entry) => entry.trim())
67
69
  .filter((entry) => entry !== "")
68
- const chain = peer !== undefined ? [...forwarded, peer] : forwarded
69
- // The caller sits just left of the `trustedHops` trusted proxies at the tail of the chain.
70
- const index = chain.length - 1 - trust.trustedHops
71
- return index >= 0 ? chain[index] : undefined
70
+ const index = forwarded.length - trust.trustedHops
71
+ return index >= 0 ? forwarded[index] : undefined
72
72
  }
@@ -312,9 +312,12 @@ export interface RouteSchema {
312
312
  * The schema must cover every path param; with the strict default an undeclared path param is rejected.
313
313
  * When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
314
314
  readonly params?: StandardSchemaV1
315
- /** Optional request-header schema. Header names are normalized to lower-case and the validated
316
- * output is exposed as `c.headers`; use lower-case keys because HTTP header names are
317
- * case-insensitive. */
315
+ /** Optional request-header schema; the validated output is `c.headers` and a failure is a `422`.
316
+ * Every client sends headers a route never declares (`host`, `user-agent`, `accept`), so a closed
317
+ * object schema (`t.object`) validates only the names it declares, each read case-insensitively and
318
+ * keyed as declared. `t.headers` is the open form: it receives every header, names lower-cased,
319
+ * and coerces values like `t.query`. A schema that does not expose its JSON Schema (a zod
320
+ * `.strict()` or valibot `strictObject`) receives every header, so declare it open. */
318
321
  readonly headers?: StandardSchemaV1
319
322
  /** Optional request-cookie schema. It validates the cookies parsed from the `Cookie` header -
320
323
  * names as sent, values URL-decoded strings, the first of a repeated name - before the handler
@@ -446,7 +449,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
446
449
  /** Read one request header without materializing a Web `Request` on the Node adapter hot path. */
447
450
  readonly header: (name: string) => string | null
448
451
  readonly params: ParamsOf<S, Path>
449
- /** Lower-case request headers, validated/coerced when `schema.headers` is declared. */
452
+ /** Request headers, names lower-cased; when `schema.headers` is declared, its validated output. */
450
453
  readonly headers: HeadersOf<S>
451
454
  readonly query: QueryOf<S>
452
455
  readonly body: BodyOf<S>
@@ -212,17 +212,28 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
212
212
 
213
213
  const TEXT = new TextEncoder()
214
214
 
215
+ // Imported once per secret: every signed-cookie read and write, and every auth session check, signs or
216
+ // verifies. Bounded, since a secret can come from per-tenant configuration.
217
+ const HMAC_KEYS = new Map<string, Promise<CryptoKey>>()
218
+ const MAX_HMAC_KEYS = 64
219
+
215
220
  const importHmacKey = (secret: string): Promise<CryptoKey> => {
221
+ const cached = HMAC_KEYS.get(secret)
222
+ if (cached !== undefined) return cached
216
223
  // Reject a weak secret at the choke point both signing and verification flow through (so signed
217
224
  // cookies AND @nifrajs/auth sessions, which delegate here via signValue/unsignValue, are covered).
218
225
  requireSecretBytes(secret, "signed-cookie")
219
- return crypto.subtle.importKey(
226
+ const key = crypto.subtle.importKey(
220
227
  "raw",
221
228
  TEXT.encode(secret),
222
229
  { name: "HMAC", hash: "SHA-256" },
223
230
  false,
224
231
  ["sign", "verify"],
225
232
  )
233
+ if (HMAC_KEYS.size >= MAX_HMAC_KEYS) HMAC_KEYS.clear()
234
+ HMAC_KEYS.set(secret, key)
235
+ key.catch(() => HMAC_KEYS.delete(secret))
236
+ return key
226
237
  }
227
238
 
228
239
  const toBase64Url = (buf: ArrayBuffer): string => {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  const registry = globalThis as unknown as Record<symbol, string[] | undefined>
12
12
  const COPIES = Symbol.for("nifra.core.copies")
13
+ const VERSIONS = Symbol.for("nifra.core.copy-versions")
13
14
  registry[COPIES] ??= []
14
15
  const copies = registry[COPIES]
15
16
  const here = String(import.meta.url).split("?")[0] as string
@@ -19,3 +20,42 @@ if (!copies.includes(here) && copies.push(here) > 1) {
19
20
  `[nifra] @nifrajs/core is loaded ${copies.length} times; \`nifra check\` names each importer:\n ${copies.join("\n ")}`,
20
21
  )
21
22
  }
23
+
24
+ /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
+ * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
+ const COPY_VERSION = "5.2.0"
27
+
28
+ /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
+ * so a production bundle drops it with the version; a copy that built a server has recorded one. */
30
+ export function recordCopyVersion(): void {
31
+ versionsByUrl().set(here, COPY_VERSION)
32
+ }
33
+
34
+ /** The process-wide URL -> version map every copy shares. */
35
+ const versionsByUrl = (): Map<unknown, unknown> => {
36
+ const found: unknown = Reflect.get(globalThis, VERSIONS)
37
+ if (found instanceof Map) return found
38
+ const created = new Map<unknown, unknown>()
39
+ Reflect.set(globalThis, VERSIONS, created)
40
+ return created
41
+ }
42
+
43
+ const labelled = (url: string): string => {
44
+ const version = versionsByUrl().get(url)
45
+ return typeof version === "string" ? `${url} (${version})` : url
46
+ }
47
+
48
+ /** Why `merge()` refused a value that is not a server of this copy: when another copy is loaded, the
49
+ * value is most likely its server, so both copies are named, with their versions where recorded
50
+ * (copies before 5.2 record none), and the way to collapse them. Called only outside production. */
51
+ export function mergeRefusal(): string {
52
+ const base = "merge() requires a server() from this copy of @nifrajs/core"
53
+ const others = copies.filter((url) => url !== here)
54
+ if (others.length === 0) return base
55
+ return [
56
+ `${base}, and ${copies.length} copies are loaded:`,
57
+ ` this copy: ${labelled(here)}`,
58
+ ...others.map((url) => ` other copy: ${labelled(url)}`),
59
+ "A server() from another copy cannot be merged. Make every package that builds a server() resolve one copy: in a linked repository, install the same nifra version (`nifra upgrade <version> --write`, then `bun install`); `nifra doctor` names each importer.",
60
+ ].join("\n")
61
+ }