@nifrajs/core 5.1.0 → 5.3.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 (92) hide show
  1. package/CHANGELOG.md +2409 -0
  2. package/dist/edge-kit.d.ts +1 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +1 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/idempotency.d.ts.map +1 -1
  7. package/dist/idempotency.js +2 -0
  8. package/dist/idempotency.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/internal/hook-origins.d.ts +17 -1
  12. package/dist/internal/hook-origins.d.ts.map +1 -1
  13. package/dist/internal/hook-origins.js +20 -0
  14. package/dist/internal/hook-origins.js.map +1 -1
  15. package/dist/internal/route-program.d.ts +2 -0
  16. package/dist/internal/route-program.d.ts.map +1 -1
  17. package/dist/internal/route-program.js +22 -17
  18. package/dist/internal/route-program.js.map +1 -1
  19. package/dist/mount.d.ts +12 -0
  20. package/dist/mount.d.ts.map +1 -1
  21. package/dist/mount.js +20 -0
  22. package/dist/mount.js.map +1 -1
  23. package/dist/reflection.d.ts +23 -2
  24. package/dist/reflection.d.ts.map +1 -1
  25. package/dist/reflection.js +57 -1
  26. package/dist/reflection.js.map +1 -1
  27. package/dist/server/context.d.ts +7 -4
  28. package/dist/server/context.d.ts.map +1 -1
  29. package/dist/server/core-copies.d.ts +7 -1
  30. package/dist/server/core-copies.d.ts.map +1 -1
  31. package/dist/server/core-copies.js +37 -1
  32. package/dist/server/core-copies.js.map +1 -1
  33. package/dist/server/headers.d.ts +9 -0
  34. package/dist/server/headers.d.ts.map +1 -1
  35. package/dist/server/headers.js +26 -0
  36. package/dist/server/headers.js.map +1 -1
  37. package/dist/server/registry.d.ts +8 -10
  38. package/dist/server/registry.d.ts.map +1 -1
  39. package/dist/server/request-context.d.ts.map +1 -1
  40. package/dist/server/request-context.js +4 -3
  41. package/dist/server/request-context.js.map +1 -1
  42. package/dist/server/request-target.d.ts +9 -7
  43. package/dist/server/request-target.d.ts.map +1 -1
  44. package/dist/server/request-target.js +21 -12
  45. package/dist/server/request-target.js.map +1 -1
  46. package/dist/server/respond.d.ts.map +1 -1
  47. package/dist/server/respond.js +10 -7
  48. package/dist/server/respond.js.map +1 -1
  49. package/dist/server/response-contract-lane.d.ts +37 -2
  50. package/dist/server/response-contract-lane.d.ts.map +1 -1
  51. package/dist/server/response-contract-lane.js +278 -32
  52. package/dist/server/response-contract-lane.js.map +1 -1
  53. package/dist/server/server-types.d.ts +7 -4
  54. package/dist/server/server-types.d.ts.map +1 -1
  55. package/dist/server/server.d.ts +17 -4
  56. package/dist/server/server.d.ts.map +1 -1
  57. package/dist/server/server.js +184 -40
  58. package/dist/server/server.js.map +1 -1
  59. package/dist/server/websocket.d.ts.map +1 -1
  60. package/dist/server/websocket.js +4 -0
  61. package/dist/server/websocket.js.map +1 -1
  62. package/dist/single-copy.d.ts.map +1 -1
  63. package/dist/single-copy.js +46 -5
  64. package/dist/single-copy.js.map +1 -1
  65. package/dist/tool-contract.d.ts.map +1 -1
  66. package/dist/tool-contract.js +8 -4
  67. package/dist/tool-contract.js.map +1 -1
  68. package/dist/transport-codec.d.ts.map +1 -1
  69. package/dist/transport-codec.js +4 -1
  70. package/dist/transport-codec.js.map +1 -1
  71. package/package.json +3 -2
  72. package/src/edge-kit.ts +1 -1
  73. package/src/idempotency.ts +2 -0
  74. package/src/index.ts +1 -1
  75. package/src/internal/hook-origins.ts +35 -0
  76. package/src/internal/route-program.ts +39 -20
  77. package/src/mount.ts +25 -0
  78. package/src/reflection.ts +61 -1
  79. package/src/server/context.ts +7 -4
  80. package/src/server/core-copies.ts +40 -0
  81. package/src/server/headers.ts +39 -0
  82. package/src/server/registry.ts +18 -19
  83. package/src/server/request-context.ts +6 -3
  84. package/src/server/request-target.ts +21 -12
  85. package/src/server/respond.ts +10 -7
  86. package/src/server/response-contract-lane.ts +357 -42
  87. package/src/server/server-types.ts +7 -4
  88. package/src/server/server.ts +235 -35
  89. package/src/server/websocket.ts +3 -0
  90. package/src/single-copy.ts +55 -5
  91. package/src/tool-contract.ts +7 -4
  92. package/src/transport-codec.ts +4 -1
@@ -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,6 +32,7 @@ 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
38
  import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.ts"
@@ -67,20 +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"
77
- import type { FormOrigins } from "./body-lane.ts"
80
+ import { mergeRefusal, recordCopyVersion } from "./core-copies.ts"
78
81
  import {
79
82
  hasLowercaseHeaderKeysMark,
80
83
  headerKeysAllLowercase,
81
84
  markLowercaseHeaderKeys,
82
85
  } from "./header-case.ts"
83
- import { headerObjectOf } from "./headers.ts"
86
+ import { declaredHeaders, headerObjectOf } from "./headers.ts"
84
87
  import {
85
88
  isRoutableMethod,
86
89
  jsonError,
@@ -110,7 +113,7 @@ import {
110
113
  readBodyFramed,
111
114
  } from "./request-context.ts"
112
115
  import { recordRequestReplacement } from "./request-lineage.ts"
113
- import { hasDotSegment, resolveDotSegments } from "./request-target.ts"
116
+ import { needsResolving, resolveDotSegments } from "./request-target.ts"
114
117
  import {
115
118
  applyStaticResponseHeaders,
116
119
  buildStaticResponseHeaders,
@@ -580,14 +583,21 @@ export { plainValidationError } from "./validation.ts"
580
583
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
581
584
  export { type QueryValue, queryObjectOf, searchOf }
582
585
 
586
+ // Outside the printable ASCII a parsed path keeps as written: a control, space, `"`, `#`, `<`, `>`,
587
+ // `\`, `^`, `` ` ``, `{`, `}`, or past ASCII (U+FFFD, an invalid escape, included). Bun's table reads
588
+ // a raw `#` and what follows as path, where the parsed URL ends the path there.
589
+ const UNROUTED_CHAR = /[^!$%&'()*+,\-./0-9:;=?@A-Z[\]_a-z|~]/
590
+
583
591
  /**
584
592
  * A param value Bun's native route table matched but the portable router must decide: an invalid
585
593
  * escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
586
- * segment (Bun hands `%2e` over decoded), or a backslash, which the parsed URL reads as `/`.
594
+ * segment (Bun hands `%2e` over decoded), a backslash, which the parsed URL reads as `/`, a `#`,
595
+ * where the parsed URL's path ends (`/a/..#/b` is `/`), or a character the parsed URL
596
+ * percent-encodes (`/t/<b>` is `/t/%3Cb%3E`, which a static route for that text matches). The value
597
+ * is decoded, so a raw `<` and a sent `%3C` look alike; both are re-routed.
587
598
  */
588
599
  const unroutedParam = (value: string | undefined): boolean =>
589
- value !== undefined &&
590
- (value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..")
600
+ value !== undefined && (value === "." || value === ".." || UNROUTED_CHAR.test(value))
591
601
 
592
602
  function hasUnroutedParam(params: Record<string, string>): boolean {
593
603
  for (const key in params) {
@@ -602,9 +612,19 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
602
612
  */
603
613
  const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
604
614
 
605
- /** `req` as Bun or workerd would have delivered it: the same request, its URL's dot segments resolved. */
615
+ /** `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it. */
606
616
  const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
607
617
 
618
+ // A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
619
+ // `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
620
+ // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
621
+ const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
622
+
623
+ const invalidHost = (req: Request): boolean => {
624
+ const host = req.headers.get("host")
625
+ return host !== null && !HOST_AUTHORITY.test(host)
626
+ }
627
+
608
628
  function normalizeMountPrefix(path: string): string {
609
629
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
610
630
  throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
@@ -787,6 +807,7 @@ const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
787
807
  // node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
788
808
  // throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
789
809
  // off) while a production define still folds `<flag> && false` to `false` on every other runtime.
810
+ // It must live in this module: imported from another, Bun leaves `<flag>,` at every folded guard.
790
811
  const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
791
812
 
792
813
  // This module's own path, captured once from a sentinel stack. Every internal frame of an
@@ -797,9 +818,10 @@ const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
797
818
  return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
798
819
  })()
799
820
 
800
- /** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
801
- * report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
802
- * non-trivial cost) is never constructed when the check is disabled. */
821
+ /** One `file:line:col` frame for the caller of an order-scoped hook method or a route registration,
822
+ * for the unused-hook report and route provenance. Only ever called behind a development guard that
823
+ * also requires the feature to be on, so the `Error` (the sole non-trivial cost) is never constructed
824
+ * otherwise. */
803
825
  const captureCallerSite = (): string | undefined => {
804
826
  const stack = new Error().stack
805
827
  if (stack === undefined) return undefined
@@ -823,9 +845,19 @@ interface HookAuditState {
823
845
  readonly hasCustomLogger: boolean
824
846
  readonly sites: {
825
847
  readonly kind: string
848
+ /** Routes that captured the chain so far: merged routes excluded, they keep their own. */
826
849
  readonly routesAtPush: number
850
+ readonly mergedAtPush: number
851
+ readonly site: string | undefined
852
+ }[]
853
+ /** Registration-time plugins installed after routes they would have covered. */
854
+ readonly lateInstalls: {
855
+ readonly plugin: string
827
856
  readonly site: string | undefined
857
+ readonly routes: readonly string[]
828
858
  }[]
859
+ /** Routes adopted by `merge()`, which keep the chain of the server that declared them. */
860
+ merged: number
829
861
  checked: boolean
830
862
  }
831
863
  const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
@@ -860,7 +892,14 @@ const beginHookAudit = (
860
892
  policy: "warn" | "error" | "off",
861
893
  hasCustomLogger: boolean,
862
894
  ): void => {
863
- hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false })
895
+ hookAudit.set(key, {
896
+ policy,
897
+ hasCustomLogger,
898
+ sites: [],
899
+ lateInstalls: [],
900
+ merged: 0,
901
+ checked: false,
902
+ })
864
903
  }
865
904
 
866
905
  /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
@@ -877,7 +916,62 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
877
916
  }
878
917
  const state = hookAudit.get(key)
879
918
  if (state === undefined || state.policy === "off") return
880
- state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
919
+ state.sites.push({
920
+ kind,
921
+ routesAtPush: routesAtPush - state.merged,
922
+ mergedAtPush: state.merged,
923
+ site: captureCallerSite(),
924
+ })
925
+ }
926
+
927
+ /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
928
+ * followed only by merges covers nothing. */
929
+ const noteMergedRoutes = (key: object, count: number): void => {
930
+ const state = hookAudit.get(key)
931
+ if (state !== undefined) state.merged += count
932
+ }
933
+
934
+ /** Record a `merge()` whose server authenticates none of its routes while this server's order-scoped
935
+ * auth - `authenticate()` stages, or `authenticated` evidence scoped to subsequent routes - would
936
+ * cover some of them. A server that authenticates any of its own routes chose its own auth, so its
937
+ * unauthenticated routes are taken as public on purpose. `nifra check` reports what this records. */
938
+ const noteMergeAuthGap = (
939
+ target: object,
940
+ authStages: readonly RawAuthStage[],
941
+ active: readonly AssuranceDeclaration[],
942
+ routes: readonly CatalogRoute[],
943
+ sourceGlobal: readonly AssuranceDeclaration[],
944
+ ): void => {
945
+ const parentAuth = active.filter((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
946
+ if (authStages.length === 0 && parentAuth.length === 0) return
947
+ const authenticates = (route: CatalogRoute): boolean =>
948
+ route.assurance.some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED) ||
949
+ assuranceEvidenceFor(sourceGlobal, route.method, route.path).some(
950
+ (item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED,
951
+ )
952
+ if (routes.some(authenticates)) return
953
+ const covered = routes.filter(
954
+ (route) =>
955
+ authStages.length > 0 ||
956
+ assuranceEvidenceFor(parentAuth, route.method, route.path).length > 0,
957
+ )
958
+ if (covered.length === 0) return
959
+ tagMergeAuthGap(target, {
960
+ site: captureCallerSite(),
961
+ auth: [
962
+ ...authStages.map((stage) => `authenticate(${stage.id})`),
963
+ ...new Set(parentAuth.map((item) => item.source)),
964
+ ],
965
+ routes: covered.map((route) => `${route.method} ${route.path}`),
966
+ })
967
+ }
968
+
969
+ /** Record a registration-time plugin (`responseContract()`, `effectLedger()`) installed after routes
970
+ * it would have covered, named as `METHOD /path`. Reported with the dead hooks at seal. */
971
+ const recordLateInstall = (key: object, plugin: string, routes: readonly string[]): void => {
972
+ const state = hookAudit.get(key)
973
+ if (state === undefined || state.policy === "off" || routes.length === 0) return
974
+ state.lateInstalls.push({ plugin, site: captureCallerSite(), routes })
881
975
  }
882
976
 
883
977
  /** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
@@ -914,20 +1008,46 @@ const sealHookAudit = (
914
1008
  if (state === undefined || state.checked) return
915
1009
  state.checked = true
916
1010
  if (state.policy === "off" || total + sockets === 0) return
1011
+ const own = total - state.merged
917
1012
  const dead = state.sites.filter(
918
1013
  (hook) =>
919
1014
  hook.routesAtPush ===
920
1015
  (hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
921
- ? total
922
- : total + sockets),
1016
+ ? own
1017
+ : own + sockets),
923
1018
  )
924
- if (dead.length === 0) return
925
- const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
926
- const message =
927
- `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
928
- `last route was registered, and register() snapshots the chain into each route as it is ` +
929
- `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
930
- `(onRequest/onResponse) are not order-scoped and can be added at any time.`
1019
+ const parts: string[] = []
1020
+ if (dead.length > 0) {
1021
+ const mergedAfter = (hook: (typeof dead)[number]): boolean => state.merged > hook.mergedAtPush
1022
+ const detail = dead
1023
+ .map(
1024
+ (hook) =>
1025
+ ` ${hook.kind}() at ${hook.site ?? "<unknown>"}${mergedAfter(hook) ? " (only merge()d routes follow it)" : ""}`,
1026
+ )
1027
+ .join("\n")
1028
+ parts.push(
1029
+ `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
1030
+ `last route was registered, and register() snapshots the chain into each route as it is ` +
1031
+ `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
1032
+ `(onRequest/onResponse) are not order-scoped and can be added at any time.` +
1033
+ (dead.some(mergedAfter)
1034
+ ? " A merge()d route keeps the hooks of the server that declared it and never runs this " +
1035
+ "server's: wire the hook on the merged server, or declare those routes in a group()."
1036
+ : ""),
1037
+ )
1038
+ }
1039
+ for (const late of state.lateInstalls) {
1040
+ const shown = late.routes.slice(0, 5).join(", ")
1041
+ const more = late.routes.length > 5 ? ` and ${late.routes.length - 5} more` : ""
1042
+ parts.push(
1043
+ `[nifra] ${late.plugin}() at ${late.site ?? "<unknown>"} was installed after ` +
1044
+ `${late.routes.length} route(s) it would have covered: ${shown}${more}. It is decided per ` +
1045
+ `route at registration and merge(), so those routes run without it. Install it before the ` +
1046
+ `routes and merge() calls it should cover.`,
1047
+ )
1048
+ }
1049
+ if (parts.length === 0) return
1050
+ const message = parts.join("\n")
931
1051
  if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
932
1052
  if (state.hasCustomLogger) logger.warn(message)
933
1053
  else console.warn(message)
@@ -1177,6 +1297,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1177
1297
  // for the bundler to eliminate; a non-bundled production run skips it the same way.
1178
1298
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1179
1299
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
1300
+ recordCopyVersion()
1180
1301
  }
1181
1302
  this.derives = []
1182
1303
  this.authStages = []
@@ -1232,6 +1353,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1232
1353
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
1233
1354
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
1234
1355
  * Mounted responses are outside Nifra's typed route and response-contract checks.
1356
+ *
1357
+ * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
1358
+ * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
1359
+ * mounted child app, or keep the legacy server running beside this one for them.
1235
1360
  */
1236
1361
  mountFetch(
1237
1362
  path: string,
@@ -1369,6 +1494,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1369
1494
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
1370
1495
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
1371
1496
  * security gate and lets the route compiler retain a protected fast lane.
1497
+ *
1498
+ * A stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, fails
1499
+ * closed with 503 `{ ok: false, error: "auth_unavailable" }`. The cause goes where a handler's error
1500
+ * goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger. A
1501
+ * thrown `Response` or `status(...)` is sent as thrown, as it is from a handler.
1372
1502
  */
1373
1503
  authenticate<Principal>(
1374
1504
  stage: AuthenticationStage<Principal, EnvOf<Ctx>>,
@@ -1387,8 +1517,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1387
1517
  throw new TypeError('authenticate() stage mode must be "sync" or "async"')
1388
1518
  }
1389
1519
  this.authStages.push(stage as unknown as RawAuthStage)
1390
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1520
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1521
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "authenticate")
1391
1522
  tagHook(stage, "authenticate", this)
1523
+ }
1392
1524
  return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
1393
1525
  }
1394
1526
 
@@ -1505,9 +1637,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1505
1637
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1506
1638
  }
1507
1639
 
1508
- /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
1509
- * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
1510
- * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
1640
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
1641
+ * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
1642
+ * declared *after* this call, none before; one added after the last route reaches nothing (see
1643
+ * {@link ServerOptions.unusedScopedHooks}). */
1511
1644
  onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1512
1645
  fn: H,
1513
1646
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
@@ -2261,6 +2394,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2261
2394
  schema: RouteSchema | undefined,
2262
2395
  handler: (context: never) => unknown,
2263
2396
  ): CatalogRoute {
2397
+ // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
2398
+ // app: the module that registered the route, read where a static scan cannot see the path.
2399
+ if (
2400
+ hookAuditRuntime &&
2401
+ process.env.NODE_ENV !== "production" &&
2402
+ process.env.NIFRA_ROUTE_SITES === "1"
2403
+ ) {
2404
+ const site = captureCallerSite()
2405
+ if (site !== undefined) tagRouteSite(this, `${method.toUpperCase()} ${path}`, site)
2406
+ }
2264
2407
  const compiled = compileRouteOptions(
2265
2408
  {
2266
2409
  maxBodyBytes: this.maxBodyBytes,
@@ -2461,6 +2604,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2461
2604
  [INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
2462
2605
  this.assertConfigurable("responseContract()")
2463
2606
  this.responseContractRuntime = runtime
2607
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2608
+ recordLateInstall(
2609
+ this,
2610
+ "responseContract",
2611
+ this.catalog
2612
+ .entries()
2613
+ .filter(
2614
+ ({ entry }) =>
2615
+ entry.responseContract === undefined &&
2616
+ (entry.schema?.response !== undefined || entry.schema?.errors !== undefined),
2617
+ )
2618
+ .map((route) => `${route.method} ${route.path}`),
2619
+ )
2464
2620
  }
2465
2621
 
2466
2622
  /** @internal Symbol-keyed install seam for the response observer plugin. */
@@ -2610,6 +2766,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2610
2766
  *
2611
2767
  * The response contract and the ledger can only add a check or a record, so a merged route is
2612
2768
  * recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
2769
+ *
2770
+ * In development, {@link ServerOptions.unusedScopedHooks} reports a hook followed only by merges and
2771
+ * a contract or ledger installed after the routes it would cover; `nifra check` reports a merge whose
2772
+ * server authenticates none of its routes under this server's `authenticate()` (NF-C043).
2613
2773
  */
2614
2774
  merge<R2 extends Registry, Ctx2, HookOutput2>(
2615
2775
  other: Server<R2, Ctx2, HookOutput2>,
@@ -2618,7 +2778,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2618
2778
  if (!(other instanceof Server)) {
2619
2779
  // A server from another copy of this package reaches this copy's private request state through
2620
2780
  // symbols it does not share, and would fail per request. The boot warning names the copies.
2621
- throw new TypeError("merge() requires a server() from this copy of @nifrajs/core")
2781
+ throw new TypeError(
2782
+ hookAuditRuntime && process.env.NODE_ENV !== "production"
2783
+ ? mergeRefusal()
2784
+ : "merge() requires a server() from this copy of @nifrajs/core",
2785
+ )
2622
2786
  }
2623
2787
  if (this.routePrefix !== "") {
2624
2788
  // The merged routes were compiled under their own paths; adopting them here would publish them
@@ -2635,7 +2799,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2635
2799
  "merge() does not carry WebSocket routes or mounts - register them on the parent server",
2636
2800
  )
2637
2801
  }
2802
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2803
+ noteMergeAuthGap(
2804
+ this,
2805
+ this.authStages,
2806
+ this.activeAssurance,
2807
+ source.catalog.entries(),
2808
+ source.globalAssurance,
2809
+ )
2638
2810
  this.adopt(source, undefined, 0)
2811
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2812
+ noteMergedRoutes(this, source.catalog.size)
2639
2813
  return this as unknown as Server<R & R2, Ctx, HookOutput>
2640
2814
  }
2641
2815
 
@@ -3002,7 +3176,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3002
3176
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3003
3177
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3004
3178
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
3005
- if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
3179
+ if (rawRequestTargets) {
3180
+ if (invalidHost(req)) return jsonError(400, "invalid_host")
3181
+ if (needsResolving(req.url)) req = withResolvedTarget(req)
3182
+ }
3006
3183
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
3007
3184
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
3008
3185
  if (this.trustBodyFraming) markTrustedBodyFraming(req)
@@ -3497,8 +3674,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3497
3674
  if (req.method !== "GET") return WS_PASS
3498
3675
  // The adapter answers a pass by handing this same object to `fetch`.
3499
3676
  const received = req
3500
- // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
3501
- if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
3677
+ // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
3678
+ if (rawRequestTargets) {
3679
+ if (invalidHost(req)) {
3680
+ return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3681
+ }
3682
+ if (needsResolving(req.url)) req = withResolvedTarget(req)
3683
+ }
3502
3684
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
3503
3685
  platform = this.trustedPlatform(req, platform)
3504
3686
 
@@ -4318,6 +4500,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4318
4500
  [INSTALL_EFFECT_LEDGER](runtime: EffectLedgerRuntime): void {
4319
4501
  this.assertConfigurable("effectLedger()")
4320
4502
  this.effectLedgerRuntime = runtime
4503
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
4504
+ recordLateInstall(
4505
+ this,
4506
+ "effectLedger",
4507
+ this.catalog
4508
+ .entries()
4509
+ .filter(
4510
+ ({ entry, descriptor }) =>
4511
+ entry.ledgered === undefined && (descriptor.capabilities?.length ?? 0) > 0,
4512
+ )
4513
+ .map((route) => `${route.method} ${route.path}`),
4514
+ )
4321
4515
  }
4322
4516
 
4323
4517
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
@@ -5640,7 +5834,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5640
5834
  if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
5641
5835
  const input =
5642
5836
  stage.kind === "headers"
5643
- ? headerObjectOf(source.headers)
5837
+ ? stage.names === undefined
5838
+ ? headerObjectOf(source.headers)
5839
+ : declaredHeaders(source.headers, stage.names)
5644
5840
  : stage.kind === "query"
5645
5841
  ? queryObjectOf(ctx[CONTEXT_SEARCH])
5646
5842
  : ctx[stage.kind]
@@ -5669,8 +5865,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5669
5865
  ctx: RawContext,
5670
5866
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5671
5867
  wrapResponse: (response: Response | ResponseResult) => T,
5868
+ fallback?: () => T,
5672
5869
  ): MaybePromise<T> {
5673
- return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
5870
+ return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse, fallback)
5674
5871
  }
5675
5872
 
5676
5873
  /** Finish a successful program result through response-contract enforcement and finalization. */
@@ -6140,6 +6337,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6140
6337
  ctx: RawContext,
6141
6338
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6142
6339
  wrapResponse: (response: Response | ResponseResult) => T,
6340
+ // The answer when no hook does; the default 500 unless a stage owns a different fail-closed status.
6341
+ fallback?: () => T,
6143
6342
  ): MaybePromise<T> {
6144
6343
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
6145
6344
  // an action throws an error page. Send it as the same Response returned would be sent (Remix/
@@ -6155,9 +6354,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6155
6354
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
6156
6355
  // (redacting) logger. Body-read failures and around-hook failures land here too.
6157
6356
  this.logRequestError(err, ctx)
6158
- return wrapResponse(plainError(500, "internal_error"))
6357
+ return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6159
6358
  }
6160
- return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
6359
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback)
6161
6360
  }
6162
6361
 
6163
6362
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
@@ -6168,6 +6367,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6168
6367
  ctx: RawContext,
6169
6368
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6170
6369
  wrapResponse: (response: Response | ResponseResult) => T,
6370
+ fallback?: () => T,
6171
6371
  ): Promise<T> {
6172
6372
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
6173
6373
  // the `await` and stay heap-allocated on both engines.
@@ -6188,7 +6388,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6188
6388
  }
6189
6389
  }
6190
6390
  this.logRequestError(err, ctx)
6191
- return wrapResponse(plainError(500, "internal_error"))
6391
+ return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6192
6392
  }
6193
6393
 
6194
6394
  /** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
@@ -60,6 +60,9 @@ export class TopicRegistry {
60
60
  private readonly memberships = new WeakMap<NifraWebSocket, Set<string>>()
61
61
 
62
62
  subscribe(topic: string, ws: NifraWebSocket): void {
63
+ // CLOSING or CLOSED: close has run, or is about to run, `unsubscribeAll`, so an async handler's
64
+ // late subscribe would hold the dead socket for good.
65
+ if (ws.readyState >= 2) return
63
66
  let subs = this.topics.get(topic)
64
67
  if (subs === undefined) {
65
68
  subs = new Set()
@@ -666,6 +666,36 @@ const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string =
666
666
  return lines.join("\n")
667
667
  }
668
668
 
669
+ /** The `node_modules` folder a copy was installed into: `/kit/node_modules/@scope/pkg` -> `/kit/node_modules`. */
670
+ const modulesDirOf = (copy: string): string => {
671
+ const marker = `${sep}node_modules${sep}`
672
+ const at = copy.lastIndexOf(marker)
673
+ return at === -1 ? dirname(copy) : copy.slice(0, at + marker.length - 1)
674
+ }
675
+
676
+ /**
677
+ * Version skews that share both trees and both versions, as one report: a linked repository on
678
+ * another release skews every declared package at once, and a block per package said the same thing
679
+ * eighteen times. One package keeps the per-package report.
680
+ */
681
+ const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
682
+ const [first] = skips
683
+ if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
684
+ const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
685
+ return [
686
+ `[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each loads, so their module state is not shared.`,
687
+ copyLine(
688
+ "app copies",
689
+ first.to === undefined ? undefined : modulesDirOf(first.to),
690
+ first.toVersion,
691
+ ),
692
+ copyLine("linked copies", modulesDirOf(first.from), first.fromVersion),
693
+ ` packages: ${names.join(", ")}`,
694
+ ` ${first.fromVersion ?? "another version"} there, ${first.toVersion ?? "another version"} here - redirecting would serve a version those copies did not ask for`,
695
+ " Fix: align the dependency ranges so both trees install one version, then reinstall. nifra never redirects across versions.",
696
+ ].join("\n")
697
+ }
698
+
669
699
  const strictFromEnv = (): boolean => {
670
700
  const value = typeof process === "undefined" ? undefined : process.env[SINGLE_COPY_STRICT_ENV]
671
701
  return value === "1" || value === "true"
@@ -685,15 +715,15 @@ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyP
685
715
  const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
686
716
  const strict = options.strict ?? (readSingleCopyStrict(root) || strictFromEnv())
687
717
  const warned = processSet(WARNED)
688
- const report = (name: string, skips: readonly SingleCopySkip[]): void => {
689
- const message = describeSkips(name, skips)
718
+ const emit = (key: string, message: string): void => {
690
719
  if (strict)
691
720
  throw new Error(`${message}\n Strict mode is on, so this process refuses to start.`)
692
- const key = `${skips[0]?.reason ?? ""}\0${name}`
693
721
  if (warned.has(key)) return
694
722
  warned.add(key)
695
723
  console.warn(message)
696
724
  }
725
+ const report = (name: string, skips: readonly SingleCopySkip[]): void =>
726
+ emit(`${skips[0]?.reason ?? ""}\0${name}`, describeSkips(name, skips))
697
727
  const plugin = singleCopyPlugin({
698
728
  ...options,
699
729
  cwd: root,
@@ -703,13 +733,33 @@ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyP
703
733
  },
704
734
  })
705
735
  const byPackage = new Map<string, SingleCopySkip[]>()
736
+ const bySkew = new Map<string, SingleCopySkip[]>()
706
737
  for (const skip of plugin.plan.skipped) {
707
738
  options.onSkip?.(skip)
708
- const group = byPackage.get(skip.package) ?? []
739
+ const skew =
740
+ skip.reason === "version-skew"
741
+ ? [
742
+ modulesDirOf(skip.from),
743
+ skip.to === undefined ? "" : modulesDirOf(skip.to),
744
+ skip.fromVersion ?? "",
745
+ skip.toVersion ?? "",
746
+ ].join("\0")
747
+ : undefined
748
+ const groups = skew === undefined ? byPackage : bySkew
749
+ const key = skew ?? skip.package
750
+ const group = groups.get(key) ?? []
709
751
  group.push(skip)
710
- byPackage.set(skip.package, group)
752
+ groups.set(key, group)
711
753
  }
712
754
  for (const [name, skips] of byPackage) report(name, skips)
755
+ for (const skips of bySkew.values()) {
756
+ if (skips.length === 1) report(skips[0]?.package ?? "", skips)
757
+ else
758
+ emit(
759
+ `version-skew\0${skips.map((skip) => skip.package).join("\0")}`,
760
+ describeSkewGroup(skips),
761
+ )
762
+ }
713
763
  // Installing twice (a preload listed twice, or an explicit call on top of the preload) would register
714
764
  // two `onLoad` hooks for the same foreign files, and two hooks both answering one file stall the
715
765
  // runtime loader. One install per root and declaration is the whole effect anyway.
@@ -469,8 +469,9 @@ export async function executeTool<Input, Output>(
469
469
  await options.idempotency?.abandon(reservationInput(reservation))
470
470
  } catch {}
471
471
  }
472
- // Once the executor has run, the reservation is never released: a retry would repeat a side effect
473
- // that may have committed. A store that throws on completion counts as one that refused it.
472
+ // Once the executor has started, the reservation is never released, even when it throws: a retry
473
+ // would repeat a side effect that may have committed. A store that throws on completion counts as
474
+ // one that refused it.
474
475
  let executed = false
475
476
  const complete = async (): Promise<boolean> => {
476
477
  if (reservation === undefined) return true
@@ -660,6 +661,7 @@ export async function executeTool<Input, Output>(
660
661
  record("execution", "dry-run")
661
662
  return finish({ ok: true, dryRun })
662
663
  }
664
+ executed = true
663
665
  const output = await tool.execute(validatedInput.value, {
664
666
  effectId,
665
667
  signal,
@@ -667,7 +669,6 @@ export async function executeTool<Input, Output>(
667
669
  dryRun,
668
670
  ...(tool.policy === undefined ? {} : { policy: tool.policy }),
669
671
  })
670
- executed = true
671
672
  record("execution", "committed")
672
673
  const validatedOutput = await validateStandard(tool.output, output)
673
674
  if (!validatedOutput.ok) {
@@ -699,7 +700,9 @@ export async function executeTool<Input, Output>(
699
700
  }
700
701
  return finish({ ok: true, dryRun, output: validatedOutput.value })
701
702
  } catch (error) {
702
- if (!executed) await abandon()
703
+ // A completion the store refuses still leaves the key pending until its lease lapses.
704
+ if (executed) await complete()
705
+ else await abandon()
703
706
  const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
704
707
  record("execution", "failed", code)
705
708
  return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
@@ -228,8 +228,11 @@ export async function readBoundedBytes(
228
228
  ): Promise<Uint8Array> {
229
229
  const maxBytes = maxBytesOf(options)
230
230
  const declared = response.headers.get("content-length")
231
- if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
231
+ if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes) {
232
+ // Refused unread, the body would otherwise hold its connection open until the sender gives up.
233
+ response.body?.cancel().catch(() => {})
232
234
  throw new TransportCodecError("transport payload exceeds maxBytes")
235
+ }
233
236
  if (response.body === null) return new Uint8Array(0)
234
237
  const reader = response.body.getReader()
235
238
  const chunks: Uint8Array[] = []