@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
@@ -59,10 +59,18 @@ export interface ServerOptions {
59
59
  * `__proto__` key, or a `constructor` carrying a `prototype`, is the payload shape that turns a
60
60
  * later innocent merge/assign into prototype pollution. `"reject"` (default) answers the same
61
61
  * flat 400 as malformed JSON; `"strip"` removes the offending keys and continues; `"ignore"`
62
- * parses as-is (only for routes that never merge body input into other objects). Clean payloads
63
- * pay a substring pre-scan only - the deep walk runs solely on suspect text.
62
+ * parses as-is (only for routes that never merge body input into other objects). Every guarded
63
+ * payload pays one iterative pass over its parsed object nodes; `"ignore"` skips it.
64
64
  */
65
65
  readonly protoPoisoning?: "reject" | "strip" | "ignore"
66
+ /**
67
+ * Other sites whose HTML forms may post to a body-schema route: exact origins, or a function that
68
+ * receives the `Origin`. A urlencoded, multipart or `text/plain` body is one a form on any site can
69
+ * send with the visitor's cookies and no preflight, so one whose `Origin` names another site is
70
+ * refused with `403` before it is read. Same-origin posts and requests without an `Origin`, such
71
+ * as server-to-server calls, pass. JSON is not affected: a browser preflights it, so CORS decides.
72
+ */
73
+ readonly formOrigins?: ReadonlyArray<string> | ((origin: string) => boolean)
66
74
  /**
67
75
  * Declare that every `Request` reaching `app.fetch` was framed by a runtime HTTP parser, so its
68
76
  * `Content-Length` is an exact transport boundary rather than a hint. Set this ONLY when the
@@ -81,9 +89,12 @@ export interface ServerOptions {
81
89
  * untrusted input.
82
90
  */
83
91
  readonly trustBodyFraming?: boolean
84
- /** Max inbound WebSocket message size (bytes) when `listen()`ing on Bun - frames over this are rejected
85
- * by the runtime before reaching your handler (so a huge frame can't be JSON-parsed into memory).
86
- * Default: `maxBodyBytes` (1 MB). */
92
+ /**
93
+ * Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
94
+ * socket closes with `1009`. Default: `maxBodyBytes` (1 MB). Bun and Node stop buffering at the cap,
95
+ * so a huge frame is never held in memory; on Bun a mounted app's smaller cap, and on Deno and
96
+ * Workers every cap, is checked after the runtime has buffered the whole message.
97
+ */
87
98
  readonly wsMaxPayloadBytes?: number
88
99
  /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
89
100
  readonly requestTimeoutMs?: number
@@ -153,10 +164,13 @@ export interface ServerOptions {
153
164
  */
154
165
  readonly onValidationError?: RouteSchema["onValidationError"]
155
166
  /**
156
- * What to do when an order-scoped hook (`derive`/`beforeHandle`/`around`/`aroundCapability`/
157
- * `afterHandle`/`onError`/`decorate`) covers no route because it was added after the last route was
158
- * registered. `register()` snapshots the current chain into each route as it is declared, so a hook
159
- * appended after every route reaches nothing and is silently dead. `"warn"` (default) logs once at
167
+ * What to do when an order-scoped hook (`derive`/`authenticate`/`beforeHandle`/`around`/
168
+ * `aroundCapability`/`afterHandle`/`onError`/`decorate`) covers no route because it was added after
169
+ * the last route was registered. `register()` snapshots the current chain into each route as it is
170
+ * declared, so a hook appended after every route reaches nothing and is silently dead. A `merge()`d
171
+ * route keeps the chain of the server that declared it, so a hook followed only by merges is dead
172
+ * too. The same report covers `responseContract()` and `effectLedger()` installed after routes they
173
+ * would have covered (a declared response, declared capabilities). `"warn"` (default) logs once at
160
174
  * seal (first `listen()`/`fetch`/`resolveNode`); `"error"` throws a `FrameworkError`
161
175
  * (`UNUSED_SCOPED_HOOKS`); `"off"` skips the bookkeeping entirely, including the one-frame stack
162
176
  * capture. App-global hooks (`onRequest`/`onResponse`/...) are unaffected in every mode.
@@ -23,6 +23,8 @@ import {
23
23
  leavePlugin,
24
24
  tagAdoption,
25
25
  tagHook,
26
+ tagMergeAuthGap,
27
+ tagRouteSite,
26
28
  tagScoped,
27
29
  tagStatic,
28
30
  } from "../internal/hook-origins.ts"
@@ -30,9 +32,10 @@ import {
30
32
  type AssuranceDeclaration,
31
33
  assuranceDeclarationsOf,
32
34
  assuranceEvidenceFor,
35
+ NIFRA_ASSURANCE_IDS,
33
36
  } from "../internal/route-assurance.ts"
34
37
  import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
35
- import { compileRouteOptions } from "../internal/route-compiler.ts"
38
+ import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.ts"
36
39
  import {
37
40
  compileRouteExecutionPlan,
38
41
  type FusedBodyRunner,
@@ -67,19 +70,20 @@ import {
67
70
  type RawBodyReaders,
68
71
  UNLIMITED_BODY_BYTES,
69
72
  } from "./body.ts"
73
+ import type { FormOrigins } from "./body-lane.ts"
70
74
  import {
71
75
  type ClientIpTrust,
72
76
  NIFRA_PLATFORM_CLIENT_IP_DERIVED,
73
77
  resolveClientIp,
74
78
  } from "./client-ip.ts"
75
79
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
76
- import "./core-copies.ts"
80
+ import { mergeRefusal, recordCopyVersion } from "./core-copies.ts"
77
81
  import {
78
82
  hasLowercaseHeaderKeysMark,
79
83
  headerKeysAllLowercase,
80
84
  markLowercaseHeaderKeys,
81
85
  } from "./header-case.ts"
82
- import { headerObjectOf } from "./headers.ts"
86
+ import { declaredHeaders, headerObjectOf } from "./headers.ts"
83
87
  import {
84
88
  isRoutableMethod,
85
89
  jsonError,
@@ -786,6 +790,7 @@ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
786
790
  // node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
787
791
  // throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
788
792
  // off) while a production define still folds `<flag> && false` to `false` on every other runtime.
793
+ // It must live in this module: imported from another, Bun leaves `<flag>,` at every folded guard.
789
794
  const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
790
795
 
791
796
  // This module's own path, captured once from a sentinel stack. Every internal frame of an
@@ -796,9 +801,10 @@ const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
796
801
  return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
797
802
  })()
798
803
 
799
- /** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
800
- * report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
801
- * non-trivial cost) is never constructed when the check is disabled. */
804
+ /** One `file:line:col` frame for the caller of an order-scoped hook method or a route registration,
805
+ * for the unused-hook report and route provenance. Only ever called behind a development guard that
806
+ * also requires the feature to be on, so the `Error` (the sole non-trivial cost) is never constructed
807
+ * otherwise. */
802
808
  const captureCallerSite = (): string | undefined => {
803
809
  const stack = new Error().stack
804
810
  if (stack === undefined) return undefined
@@ -822,9 +828,19 @@ interface HookAuditState {
822
828
  readonly hasCustomLogger: boolean
823
829
  readonly sites: {
824
830
  readonly kind: string
831
+ /** Routes that captured the chain so far: merged routes excluded, they keep their own. */
825
832
  readonly routesAtPush: number
833
+ readonly mergedAtPush: number
826
834
  readonly site: string | undefined
827
835
  }[]
836
+ /** Registration-time plugins installed after routes they would have covered. */
837
+ readonly lateInstalls: {
838
+ readonly plugin: string
839
+ readonly site: string | undefined
840
+ readonly routes: readonly string[]
841
+ }[]
842
+ /** Routes adopted by `merge()`, which keep the chain of the server that declared them. */
843
+ merged: number
828
844
  checked: boolean
829
845
  }
830
846
  const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
@@ -859,7 +875,14 @@ const beginHookAudit = (
859
875
  policy: "warn" | "error" | "off",
860
876
  hasCustomLogger: boolean,
861
877
  ): void => {
862
- hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false })
878
+ hookAudit.set(key, {
879
+ policy,
880
+ hasCustomLogger,
881
+ sites: [],
882
+ lateInstalls: [],
883
+ merged: 0,
884
+ checked: false,
885
+ })
863
886
  }
864
887
 
865
888
  /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
@@ -876,7 +899,62 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
876
899
  }
877
900
  const state = hookAudit.get(key)
878
901
  if (state === undefined || state.policy === "off") return
879
- state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
902
+ state.sites.push({
903
+ kind,
904
+ routesAtPush: routesAtPush - state.merged,
905
+ mergedAtPush: state.merged,
906
+ site: captureCallerSite(),
907
+ })
908
+ }
909
+
910
+ /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
911
+ * followed only by merges covers nothing. */
912
+ const noteMergedRoutes = (key: object, count: number): void => {
913
+ const state = hookAudit.get(key)
914
+ if (state !== undefined) state.merged += count
915
+ }
916
+
917
+ /** Record a `merge()` whose server authenticates none of its routes while this server's order-scoped
918
+ * auth - `authenticate()` stages, or `authenticated` evidence scoped to subsequent routes - would
919
+ * cover some of them. A server that authenticates any of its own routes chose its own auth, so its
920
+ * unauthenticated routes are taken as public on purpose. `nifra check` reports what this records. */
921
+ const noteMergeAuthGap = (
922
+ target: object,
923
+ authStages: readonly RawAuthStage[],
924
+ active: readonly AssuranceDeclaration[],
925
+ routes: readonly CatalogRoute[],
926
+ sourceGlobal: readonly AssuranceDeclaration[],
927
+ ): void => {
928
+ const parentAuth = active.filter((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
929
+ if (authStages.length === 0 && parentAuth.length === 0) return
930
+ const authenticates = (route: CatalogRoute): boolean =>
931
+ route.assurance.some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED) ||
932
+ assuranceEvidenceFor(sourceGlobal, route.method, route.path).some(
933
+ (item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED,
934
+ )
935
+ if (routes.some(authenticates)) return
936
+ const covered = routes.filter(
937
+ (route) =>
938
+ authStages.length > 0 ||
939
+ assuranceEvidenceFor(parentAuth, route.method, route.path).length > 0,
940
+ )
941
+ if (covered.length === 0) return
942
+ tagMergeAuthGap(target, {
943
+ site: captureCallerSite(),
944
+ auth: [
945
+ ...authStages.map((stage) => `authenticate(${stage.id})`),
946
+ ...new Set(parentAuth.map((item) => item.source)),
947
+ ],
948
+ routes: covered.map((route) => `${route.method} ${route.path}`),
949
+ })
950
+ }
951
+
952
+ /** Record a registration-time plugin (`responseContract()`, `effectLedger()`) installed after routes
953
+ * it would have covered, named as `METHOD /path`. Reported with the dead hooks at seal. */
954
+ const recordLateInstall = (key: object, plugin: string, routes: readonly string[]): void => {
955
+ const state = hookAudit.get(key)
956
+ if (state === undefined || state.policy === "off" || routes.length === 0) return
957
+ state.lateInstalls.push({ plugin, site: captureCallerSite(), routes })
880
958
  }
881
959
 
882
960
  /** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
@@ -913,20 +991,46 @@ const sealHookAudit = (
913
991
  if (state === undefined || state.checked) return
914
992
  state.checked = true
915
993
  if (state.policy === "off" || total + sockets === 0) return
994
+ const own = total - state.merged
916
995
  const dead = state.sites.filter(
917
996
  (hook) =>
918
997
  hook.routesAtPush ===
919
998
  (hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
920
- ? total
921
- : total + sockets),
999
+ ? own
1000
+ : own + sockets),
922
1001
  )
923
- if (dead.length === 0) return
924
- const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
925
- const message =
926
- `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
927
- `last route was registered, and register() snapshots the chain into each route as it is ` +
928
- `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
929
- `(onRequest/onResponse) are not order-scoped and can be added at any time.`
1002
+ const parts: string[] = []
1003
+ if (dead.length > 0) {
1004
+ const mergedAfter = (hook: (typeof dead)[number]): boolean => state.merged > hook.mergedAtPush
1005
+ const detail = dead
1006
+ .map(
1007
+ (hook) =>
1008
+ ` ${hook.kind}() at ${hook.site ?? "<unknown>"}${mergedAfter(hook) ? " (only merge()d routes follow it)" : ""}`,
1009
+ )
1010
+ .join("\n")
1011
+ parts.push(
1012
+ `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
1013
+ `last route was registered, and register() snapshots the chain into each route as it is ` +
1014
+ `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
1015
+ `(onRequest/onResponse) are not order-scoped and can be added at any time.` +
1016
+ (dead.some(mergedAfter)
1017
+ ? " A merge()d route keeps the hooks of the server that declared it and never runs this " +
1018
+ "server's: wire the hook on the merged server, or declare those routes in a group()."
1019
+ : ""),
1020
+ )
1021
+ }
1022
+ for (const late of state.lateInstalls) {
1023
+ const shown = late.routes.slice(0, 5).join(", ")
1024
+ const more = late.routes.length > 5 ? ` and ${late.routes.length - 5} more` : ""
1025
+ parts.push(
1026
+ `[nifra] ${late.plugin}() at ${late.site ?? "<unknown>"} was installed after ` +
1027
+ `${late.routes.length} route(s) it would have covered: ${shown}${more}. It is decided per ` +
1028
+ `route at registration and merge(), so those routes run without it. Install it before the ` +
1029
+ `routes and merge() calls it should cover.`,
1030
+ )
1031
+ }
1032
+ if (parts.length === 0) return
1033
+ const message = parts.join("\n")
930
1034
  if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
931
1035
  if (state.hasCustomLogger) logger.warn(message)
932
1036
  else console.warn(message)
@@ -982,6 +1086,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
982
1086
  | undefined
983
1087
  private readonly maxBodyBytes: number
984
1088
  private readonly protoPoisoning: "reject" | "strip" | "ignore"
1089
+ private readonly formOrigins: FormOrigins | undefined
985
1090
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
986
1091
  private readonly trustBodyFraming: boolean
987
1092
  private readonly wsMaxPayloadBytes: number
@@ -1117,6 +1222,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1117
1222
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1118
1223
  assertByteLimit(maxBodyBytes, "maxBodyBytes")
1119
1224
  this.protoPoisoning = options.protoPoisoning ?? "reject"
1225
+ this.formOrigins = options.formOrigins
1120
1226
  this.trustBodyFraming = options.trustBodyFraming ?? false
1121
1227
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
1122
1228
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
@@ -1174,6 +1280,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1174
1280
  // for the bundler to eliminate; a non-bundled production run skips it the same way.
1175
1281
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1176
1282
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
1283
+ recordCopyVersion()
1177
1284
  }
1178
1285
  this.derives = []
1179
1286
  this.authStages = []
@@ -1229,6 +1336,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1229
1336
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
1230
1337
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
1231
1338
  * Mounted responses are outside Nifra's typed route and response-contract checks.
1339
+ *
1340
+ * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
1341
+ * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
1342
+ * mounted child app, or keep the legacy server running beside this one for them.
1232
1343
  */
1233
1344
  mountFetch(
1234
1345
  path: string,
@@ -1366,6 +1477,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1366
1477
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
1367
1478
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
1368
1479
  * security gate and lets the route compiler retain a protected fast lane.
1480
+ *
1481
+ * A stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, fails
1482
+ * closed with 503 `{ ok: false, error: "auth_unavailable" }`. The cause goes where a handler's error
1483
+ * goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger. A
1484
+ * thrown `Response` or `status(...)` is sent as thrown, as it is from a handler.
1369
1485
  */
1370
1486
  authenticate<Principal>(
1371
1487
  stage: AuthenticationStage<Principal, EnvOf<Ctx>>,
@@ -1384,8 +1500,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1384
1500
  throw new TypeError('authenticate() stage mode must be "sync" or "async"')
1385
1501
  }
1386
1502
  this.authStages.push(stage as unknown as RawAuthStage)
1387
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1503
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1504
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "authenticate")
1388
1505
  tagHook(stage, "authenticate", this)
1506
+ }
1389
1507
  return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
1390
1508
  }
1391
1509
 
@@ -1502,9 +1620,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1502
1620
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1503
1621
  }
1504
1622
 
1505
- /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
1506
- * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
1507
- * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
1623
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
1624
+ * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
1625
+ * declared *after* this call, none before; one added after the last route reaches nothing (see
1626
+ * {@link ServerOptions.unusedScopedHooks}). */
1508
1627
  onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1509
1628
  fn: H,
1510
1629
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
@@ -2258,6 +2377,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2258
2377
  schema: RouteSchema | undefined,
2259
2378
  handler: (context: never) => unknown,
2260
2379
  ): CatalogRoute {
2380
+ // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
2381
+ // app: the module that registered the route, read where a static scan cannot see the path.
2382
+ if (
2383
+ hookAuditRuntime &&
2384
+ process.env.NODE_ENV !== "production" &&
2385
+ process.env.NIFRA_ROUTE_SITES === "1"
2386
+ ) {
2387
+ const site = captureCallerSite()
2388
+ if (site !== undefined) tagRouteSite(this, `${method.toUpperCase()} ${path}`, site)
2389
+ }
2261
2390
  const compiled = compileRouteOptions(
2262
2391
  {
2263
2392
  maxBodyBytes: this.maxBodyBytes,
@@ -2458,6 +2587,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2458
2587
  [INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
2459
2588
  this.assertConfigurable("responseContract()")
2460
2589
  this.responseContractRuntime = runtime
2590
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2591
+ recordLateInstall(
2592
+ this,
2593
+ "responseContract",
2594
+ this.catalog
2595
+ .entries()
2596
+ .filter(
2597
+ ({ entry }) =>
2598
+ entry.responseContract === undefined &&
2599
+ (entry.schema?.response !== undefined || entry.schema?.errors !== undefined),
2600
+ )
2601
+ .map((route) => `${route.method} ${route.path}`),
2602
+ )
2461
2603
  }
2462
2604
 
2463
2605
  /** @internal Symbol-keyed install seam for the response observer plugin. */
@@ -2586,6 +2728,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2586
2728
  * registered before a `derive`). Fail closed: a path+method collision throws
2587
2729
  * `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
2588
2730
  * (register those on the parent).
2731
+ *
2732
+ * What this server's `.use()`, made BEFORE the merge, covers on a merged route versus a
2733
+ * `group()` route:
2734
+ *
2735
+ * | installed on this server | group() route | merge()d route |
2736
+ * | ------------------------------------------ | ------------- | ------------------------------ |
2737
+ * | derive/decorate/beforeHandle/afterHandle, | yes | no - wire it on the merged |
2738
+ * | onError/around, authenticate, route-scoped | | server |
2739
+ * | assurance | | |
2740
+ * | onRequest/onResponse/responseHeaders hooks | yes | yes (app-wide) |
2741
+ * | responseContract() | yes | yes, when the route declares a |
2742
+ * | | | response and its own server |
2743
+ * | | | installed no contract |
2744
+ * | effectLedger() | yes | yes, when the route declares |
2745
+ * | | | capabilities and has no ledger |
2746
+ * | idempotency() | yes | the merged server's own; a |
2747
+ * | | | route declaring it without one |
2748
+ * | | | is refused at registration |
2749
+ *
2750
+ * The response contract and the ledger can only add a check or a record, so a merged route is
2751
+ * recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
2752
+ *
2753
+ * In development, {@link ServerOptions.unusedScopedHooks} reports a hook followed only by merges and
2754
+ * a contract or ledger installed after the routes it would cover; `nifra check` reports a merge whose
2755
+ * server authenticates none of its routes under this server's `authenticate()` (NF-C043).
2589
2756
  */
2590
2757
  merge<R2 extends Registry, Ctx2, HookOutput2>(
2591
2758
  other: Server<R2, Ctx2, HookOutput2>,
@@ -2594,7 +2761,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2594
2761
  if (!(other instanceof Server)) {
2595
2762
  // A server from another copy of this package reaches this copy's private request state through
2596
2763
  // symbols it does not share, and would fail per request. The boot warning names the copies.
2597
- throw new TypeError("merge() requires a server() from this copy of @nifrajs/core")
2764
+ throw new TypeError(
2765
+ hookAuditRuntime && process.env.NODE_ENV !== "production"
2766
+ ? mergeRefusal()
2767
+ : "merge() requires a server() from this copy of @nifrajs/core",
2768
+ )
2598
2769
  }
2599
2770
  if (this.routePrefix !== "") {
2600
2771
  // The merged routes were compiled under their own paths; adopting them here would publish them
@@ -2611,7 +2782,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2611
2782
  "merge() does not carry WebSocket routes or mounts - register them on the parent server",
2612
2783
  )
2613
2784
  }
2785
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2786
+ noteMergeAuthGap(
2787
+ this,
2788
+ this.authStages,
2789
+ this.activeAssurance,
2790
+ source.catalog.entries(),
2791
+ source.globalAssurance,
2792
+ )
2614
2793
  this.adopt(source, undefined, 0)
2794
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2795
+ noteMergedRoutes(this, source.catalog.size)
2615
2796
  return this as unknown as Server<R & R2, Ctx, HookOutput>
2616
2797
  }
2617
2798
 
@@ -2630,7 +2811,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2630
2811
  * The builder receives a scope that INHERITS this server's route chain as it stands at the call
2631
2812
  * (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
2632
2813
  * capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
2633
- * would. A plugin this server already applied is in effect for the group: the group's `use()` of
2814
+ * would. {@link merge} documents how that differs for a merged server. A plugin this server already applied is in effect for the group: the group's `use()` of
2634
2815
  * that same instance does nothing, and its `use()` of another instance under the same name throws a
2635
2816
  * `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
2636
2817
  * A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
@@ -2738,7 +2919,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2738
2919
  })
2739
2920
  : (route: CatalogRoute): CatalogRoute => route
2740
2921
  this.catalog.addBatch(
2741
- sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
2922
+ sourceRoutes.map((route) =>
2923
+ this.bindFusedRuntime(
2924
+ foldedAssurance(
2925
+ adoptRouteRuntimes(route, this.responseContractRuntime, this.effectLedgerRuntime),
2926
+ ),
2927
+ ),
2928
+ ),
2742
2929
  )
2743
2930
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
2744
2931
  // runtime object supplies the generic execution machinery. If the parent already has a runtime,
@@ -3547,6 +3734,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3547
3734
  pubsub,
3548
3735
  attach: runtime.attach,
3549
3736
  maxPayloadBytes: this.wsMaxPayloadBytes,
3737
+ sender: runtime.sender,
3550
3738
  }
3551
3739
  }
3552
3740
  const wrap = (response: Response | ResponseResult): WebSocketUpgradeOutcome =>
@@ -3659,9 +3847,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3659
3847
  if (o.kind === "pass")
3660
3848
  return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
3661
3849
  if (o.kind === "reject") return o.response
3662
- return server.upgrade(req, {
3663
- data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
3664
- })
3850
+ // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
3851
+ return server.upgrade(req, { data: { ...o } })
3665
3852
  ? undefined
3666
3853
  : jsonError(426, "upgrade_required")
3667
3854
  }
@@ -4288,6 +4475,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4288
4475
  [INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
4289
4476
  this.assertConfigurable("effectLedger()")
4290
4477
  this.effectLedgerRuntime = runtime
4478
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
4479
+ recordLateInstall(
4480
+ this,
4481
+ "effectLedger",
4482
+ this.catalog
4483
+ .entries()
4484
+ .filter(
4485
+ ({ entry, descriptor }) =>
4486
+ entry.ledgered === undefined && (descriptor.capabilities?.length ?? 0) > 0,
4487
+ )
4488
+ .map((route) => `${route.method} ${route.path}`),
4489
+ )
4291
4490
  }
4292
4491
 
4293
4492
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
@@ -4689,6 +4888,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4689
4888
  wrapResponse,
4690
4889
  (err) => logError(err, ctx, finalize, wrapResponse),
4691
4890
  bodySchema,
4891
+ this.formOrigins,
4692
4892
  )
4693
4893
  }
4694
4894
  }
@@ -5103,6 +5303,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5103
5303
  (response) => this.wrapWebResponse(response),
5104
5304
  (err) => Promise.resolve(logError(err, ctx)),
5105
5305
  bodySchema,
5306
+ this.formOrigins,
5106
5307
  ) as MaybePromise<Response>
5107
5308
  }
5108
5309
  }
@@ -5534,6 +5735,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5534
5735
  wrapResponse,
5535
5736
  (err) => logError(err, ctx, finalize, wrapResponse),
5536
5737
  bodySchema,
5738
+ this.formOrigins,
5537
5739
  )
5538
5740
  }
5539
5741
  }
@@ -5607,7 +5809,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5607
5809
  if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
5608
5810
  const input =
5609
5811
  stage.kind === "headers"
5610
- ? headerObjectOf(source.headers)
5812
+ ? stage.names === undefined
5813
+ ? headerObjectOf(source.headers)
5814
+ : declaredHeaders(source.headers, stage.names)
5611
5815
  : stage.kind === "query"
5612
5816
  ? queryObjectOf(ctx[CONTEXT_SEARCH])
5613
5817
  : ctx[stage.kind]
@@ -5636,8 +5840,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5636
5840
  ctx: RawContext,
5637
5841
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5638
5842
  wrapResponse: (response: Response | ResponseResult) => T,
5843
+ fallback?: () => T,
5639
5844
  ): MaybePromise<T> {
5640
- return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
5845
+ return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse, fallback)
5641
5846
  }
5642
5847
 
5643
5848
  /** Finish a successful program result through response-contract enforcement and finalization. */
@@ -6107,6 +6312,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6107
6312
  ctx: RawContext,
6108
6313
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6109
6314
  wrapResponse: (response: Response | ResponseResult) => T,
6315
+ // The answer when no hook does; the default 500 unless a stage owns a different fail-closed status.
6316
+ fallback?: () => T,
6110
6317
  ): MaybePromise<T> {
6111
6318
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
6112
6319
  // an action throws an error page. Send it as the same Response returned would be sent (Remix/
@@ -6122,9 +6329,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6122
6329
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
6123
6330
  // (redacting) logger. Body-read failures and around-hook failures land here too.
6124
6331
  this.logRequestError(err, ctx)
6125
- return wrapResponse(plainError(500, "internal_error"))
6332
+ return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6126
6333
  }
6127
- return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
6334
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback)
6128
6335
  }
6129
6336
 
6130
6337
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
@@ -6135,6 +6342,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6135
6342
  ctx: RawContext,
6136
6343
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6137
6344
  wrapResponse: (response: Response | ResponseResult) => T,
6345
+ fallback?: () => T,
6138
6346
  ): Promise<T> {
6139
6347
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
6140
6348
  // the `await` and stay heap-allocated on both engines.
@@ -6155,7 +6363,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6155
6363
  }
6156
6364
  }
6157
6365
  this.logRequestError(err, ctx)
6158
- return wrapResponse(plainError(500, "internal_error"))
6366
+ return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6159
6367
  }
6160
6368
 
6161
6369
  /** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
@@ -6189,6 +6397,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6189
6397
  (response) => response,
6190
6398
  (error) => Promise.reject(error),
6191
6399
  bodySchema,
6400
+ this.formOrigins,
6192
6401
  )
6193
6402
  }
6194
6403
 
@@ -6554,7 +6763,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6554
6763
  // the parent.
6555
6764
  const nativePubsub =
6556
6765
  this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
6557
- const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub)
6766
+ const wsHandlers = wsRuntime?.bunHandlers(
6767
+ this.topics ?? wsRuntime.createTopics(),
6768
+ nativePubsub,
6769
+ this,
6770
+ )
6558
6771
  // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
6559
6772
  // an explicit undefined as a value on some option paths, and omitting is what selects its own
6560
6773
  // defaults, 0.0.0.0 among them.
@@ -6602,8 +6815,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6602
6815
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
6603
6816
  websocket: {
6604
6817
  // Cap inbound frames so a huge message can't be buffered/JSON-parsed into memory; the runtime
6605
- // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes).
6606
- maxPayloadLength: this.wsMaxPayloadBytes,
6818
+ // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
6819
+ // limit per listener: the largest cap among this app and the apps mounted on it.
6820
+ maxPayloadLength: wsHandlers.maxPayloadLength,
6607
6821
  open: (ws) => wsHandlers.open(ws),
6608
6822
  message: (ws, message) => wsHandlers.message(ws, message),
6609
6823
  close: (ws, code, reason) => wsHandlers.close(ws, code, reason),