@nifrajs/core 1.6.0 → 1.8.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.
@@ -0,0 +1,166 @@
1
+ import { METHODS, type Method } from "../router/router.ts"
2
+
3
+ /** Where enforcement evidence follows Nifra's route-registration semantics. */
4
+ export type AssuranceScope = "global" | "subsequent" | "plugin"
5
+
6
+ /** Reflection-safe proof that a named enforcement module covered a route. */
7
+ export interface AssuranceEvidence {
8
+ readonly id: string
9
+ readonly source: string
10
+ }
11
+
12
+ /** Metadata installed on a middleware/plugin by {@link withRouteAssurance}. */
13
+ export interface AssuranceDeclaration extends AssuranceEvidence {
14
+ readonly scope: AssuranceScope
15
+ /** Restrict evidence to these HTTP methods. Omit for every method. */
16
+ readonly methods?: readonly Method[]
17
+ /** Restrict evidence to these absolute route globs. Omit for every path. */
18
+ readonly paths?: readonly string[]
19
+ }
20
+
21
+ const EVIDENCE_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
22
+ const METHOD_SET: ReadonlySet<string> = new Set(METHODS)
23
+ const declarations = new WeakMap<object, readonly AssuranceDeclaration[]>()
24
+
25
+ export const NIFRA_ASSURANCE_IDS = Object.freeze({
26
+ AUTHENTICATED: "nifra.authenticated",
27
+ BODY_BOUNDED: "nifra.body-bounded",
28
+ CSRF: "nifra.csrf",
29
+ IDEMPOTENCY_KEY: "nifra.idempotency-key",
30
+ IP_RESTRICTED: "nifra.ip-restricted",
31
+ RATE_LIMITED: "nifra.rate-limited",
32
+ SECURITY_HEADERS: "nifra.security-headers",
33
+ } as const)
34
+
35
+ const escapeRegex = (value: string): string => value.replace(/[|\\{}()[\]^$+?.-]/g, "\\$&")
36
+
37
+ /** Compile an absolute route glob. `*` is one segment; `**` is zero or more segments. */
38
+ export function routeGlob(pattern: string): RegExp {
39
+ if (!pattern.startsWith("/") || pattern.includes("?") || pattern.includes("#")) {
40
+ throw new Error(
41
+ `route assurance: path glob must be an absolute path: ${JSON.stringify(pattern)}`,
42
+ )
43
+ }
44
+ if (pattern === "/") return /^\/$/
45
+ const segments = pattern.slice(1).split("/")
46
+ let source = "^"
47
+ for (const [index, segment] of segments.entries()) {
48
+ if (segment === "**") {
49
+ if (index !== segments.length - 1) {
50
+ throw new Error(
51
+ `route assurance: ** must be the final path segment: ${JSON.stringify(pattern)}`,
52
+ )
53
+ }
54
+ source += "(?:/.*)?"
55
+ } else if (segment === "*") {
56
+ source += "/[^/]+"
57
+ } else {
58
+ if (segment.includes("*")) {
59
+ throw new Error(
60
+ `route assurance: * must occupy a whole path segment: ${JSON.stringify(pattern)}`,
61
+ )
62
+ }
63
+ source += `/${escapeRegex(segment)}`
64
+ }
65
+ }
66
+ return new RegExp(`${source}$`)
67
+ }
68
+
69
+ function normalizeDeclaration(value: AssuranceDeclaration): AssuranceDeclaration {
70
+ if (!EVIDENCE_ID.test(value.id)) {
71
+ throw new Error(
72
+ `route assurance: invalid evidence id ${JSON.stringify(value.id)} (use lowercase dot/dash segments)`,
73
+ )
74
+ }
75
+ if (typeof value.source !== "string" || value.source.trim() === "") {
76
+ throw new Error("route assurance: evidence source must be a non-empty string")
77
+ }
78
+ if (value.scope !== "global" && value.scope !== "subsequent" && value.scope !== "plugin") {
79
+ throw new Error(`route assurance: invalid scope ${JSON.stringify(value.scope)}`)
80
+ }
81
+ const methods = value.methods?.map((method) => method.toUpperCase())
82
+ for (const method of methods ?? []) {
83
+ if (!METHOD_SET.has(method)) {
84
+ throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
85
+ }
86
+ }
87
+ const paths = value.paths?.map((path) => {
88
+ routeGlob(path)
89
+ return path
90
+ })
91
+ return Object.freeze({
92
+ id: value.id,
93
+ source: value.source.trim(),
94
+ scope: value.scope,
95
+ ...(methods !== undefined ? { methods: Object.freeze(methods as Method[]) } : {}),
96
+ ...(paths !== undefined ? { paths: Object.freeze(paths) } : {}),
97
+ })
98
+ }
99
+
100
+ /** Attach enforcement evidence to the middleware/plugin that installs it. */
101
+ export function withRouteAssurance<T extends object>(
102
+ target: T,
103
+ declaration: AssuranceDeclaration | readonly AssuranceDeclaration[],
104
+ ): T {
105
+ const values = Array.isArray(declaration) ? declaration : [declaration]
106
+ if (values.length === 0) throw new Error("route assurance: at least one declaration is required")
107
+ const previous = declarations.get(target) ?? []
108
+ declarations.set(
109
+ target,
110
+ Object.freeze([...previous, ...values.map((value) => normalizeDeclaration(value))]),
111
+ )
112
+ return target
113
+ }
114
+
115
+ export function assuranceDeclarationsOf(value: object): readonly AssuranceDeclaration[] {
116
+ return declarations.get(value) ?? []
117
+ }
118
+
119
+ export function declarationApplies(
120
+ declaration: AssuranceDeclaration,
121
+ method: string,
122
+ path: string,
123
+ ): boolean {
124
+ if (declaration.methods !== undefined && !declaration.methods.includes(method as Method))
125
+ return false
126
+ return (
127
+ declaration.paths === undefined ||
128
+ declaration.paths.some((pattern) => routeGlob(pattern).test(path))
129
+ )
130
+ }
131
+
132
+ export function assuranceEvidenceFor(
133
+ declarationsToApply: readonly AssuranceDeclaration[],
134
+ method: string,
135
+ path: string,
136
+ ): readonly AssuranceEvidence[] {
137
+ const seen = new Set<string>()
138
+ const evidence: AssuranceEvidence[] = []
139
+ for (const declaration of declarationsToApply) {
140
+ if (!declarationApplies(declaration, method, path)) continue
141
+ const key = `${declaration.id}\n${declaration.source}`
142
+ if (seen.has(key)) continue
143
+ seen.add(key)
144
+ evidence.push(Object.freeze({ id: declaration.id, source: declaration.source }))
145
+ }
146
+ return Object.freeze(evidence)
147
+ }
148
+
149
+ export function validEvidence(value: unknown): value is AssuranceEvidence {
150
+ if (typeof value !== "object" || value === null) return false
151
+ const candidate = value as Partial<AssuranceEvidence>
152
+ return (
153
+ typeof candidate.id === "string" &&
154
+ EVIDENCE_ID.test(candidate.id) &&
155
+ typeof candidate.source === "string" &&
156
+ candidate.source.trim() !== ""
157
+ )
158
+ }
159
+
160
+ export function validEvidenceId(value: string): boolean {
161
+ return EVIDENCE_ID.test(value)
162
+ }
163
+
164
+ export function validMethod(value: string): value is Method {
165
+ return METHOD_SET.has(value.toUpperCase())
166
+ }
package/src/reflection.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * recognizes Nifra/TypeBox carriers and raw JSON Schema without depending on a validator package.
8
8
  */
9
9
 
10
+ import { type AssuranceEvidence, validEvidence } from "./internal/route-assurance.ts"
10
11
  import type { StandardSchemaV1 } from "./schema/standard.ts"
11
12
  import type { ToolAnnotations } from "./server/server.ts"
12
13
 
@@ -43,6 +44,7 @@ export interface ReflectedRoute {
43
44
  readonly method: string
44
45
  readonly path: string
45
46
  readonly schema?: ReflectedRouteSchema
47
+ readonly assurance?: readonly AssuranceEvidence[]
46
48
  readonly tool?: {
47
49
  readonly name: string
48
50
  readonly description: string
@@ -97,7 +99,7 @@ const normalizeJsonSchema = (value: unknown): JsonSchema | undefined => {
97
99
  // carrier (a JSON Schema object that also carries `~standard`, with no separate `.jsonSchema`) would
98
100
  // otherwise leak the marker into emitted OpenAPI / mock / form output.
99
101
  if (!("~standard" in record)) return record
100
- const { ["~standard"]: _omitted, ...rest } = record
102
+ const { "~standard": _omitted, ...rest } = record
101
103
  return rest
102
104
  } catch {
103
105
  return undefined
@@ -174,6 +176,14 @@ const reflectedTool = (value: unknown): ReflectedRoute["tool"] => {
174
176
  }
175
177
  }
176
178
 
179
+ const reflectedAssurance = (value: unknown): readonly AssuranceEvidence[] | undefined => {
180
+ if (!Array.isArray(value)) return undefined
181
+ const evidence = value
182
+ .filter(validEvidence)
183
+ .map((item) => Object.freeze({ id: item.id, source: item.source.trim() }))
184
+ return evidence.length > 0 ? Object.freeze(evidence) : undefined
185
+ }
186
+
177
187
  /**
178
188
  * Safely enumerate and normalize route descriptors from an app or descriptor array. Invalid entries
179
189
  * are ignored; a missing/throwing `routes()` method yields an empty array.
@@ -185,10 +195,12 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
185
195
  if (typeof route?.method !== "string" || typeof route.path !== "string") continue
186
196
  const schema = reflectedRouteSchema(route.schema)
187
197
  const tool = reflectedTool(route.tool)
198
+ const assurance = reflectedAssurance(route.assurance)
188
199
  reflected.push({
189
200
  method: route.method.toUpperCase(),
190
201
  path: route.path,
191
202
  ...(schema !== undefined ? { schema } : {}),
203
+ ...(assurance !== undefined ? { assurance } : {}),
192
204
  ...(tool !== undefined ? { tool } : {}),
193
205
  })
194
206
  }
@@ -75,7 +75,24 @@ export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
75
75
  Response
76
76
  >
77
77
 
78
- /** Merge a new route into the registry, combining methods that share a path. */
78
+ /**
79
+ * Merge a new route into the registry, combining methods that share a path.
80
+ *
81
+ * SCALING CEILING (measured — see many-routes.test-d.ts and the isolation study below): a single
82
+ * fluent chain hits TS2589 at ~95-100 routes. This intersection is NOT the cause — in isolation it
83
+ * accumulates 1000+ routes cleanly, and heavy per-route `Params<Path>`/schema inference alone
84
+ * reaches 600+. The wall is an INTERACTION unique to the fluent builder: each `.get(path, handler)`
85
+ * both (a) computes the handler's context type from the path (`c.params.id` inferred from `:id`)
86
+ * AND (b) returns `Server<AddRoute<growing-R, …>, Ctx>`. Neither alone strains the compiler; the
87
+ * PRODUCT — recomputing the handler context while re-threading the ever-larger registry at each of
88
+ * N steps — exhausts TypeScript's per-expression instantiation budget around N≈95. It is therefore
89
+ * O(N) and inherent to any builder that infers handler context AND accumulates a typed route
90
+ * registry (Elysia/tRPC/hono's typed clients cap the same way); it is not fixable by reshaping
91
+ * AddRoute. Past ~90 routes, use a path that DOESN'T form the product: split into domain groups and
92
+ * `.merge()` them (each group is a short chain; a merge is one `R & R2` intersection with no
93
+ * per-call context work), or contract-first `implement()` (the registry is one object type declared
94
+ * upfront — no grow-R-per-call, so no ceiling at all).
95
+ */
79
96
  export type AddRoute<
80
97
  R extends Registry,
81
98
  Method extends string,
@@ -1,4 +1,11 @@
1
- import { FrameworkError } from "../errors.ts"
1
+ import { FrameworkError, RouteConfigError } from "../errors.ts"
2
+ import {
3
+ type AssuranceDeclaration,
4
+ type AssuranceEvidence,
5
+ assuranceDeclarationsOf,
6
+ assuranceEvidenceFor,
7
+ NIFRA_ASSURANCE_IDS,
8
+ } from "../internal/route-assurance.ts"
2
9
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
3
10
  import type {
4
11
  InferOutput,
@@ -289,6 +296,8 @@ export interface RouteDescriptor {
289
296
  readonly method: Method
290
297
  readonly path: string
291
298
  readonly schema: RouteSchema | undefined
299
+ /** Effective enforcement evidence, populated only during reflection/introspection. */
300
+ readonly assurance?: readonly AssuranceEvidence[]
292
301
  readonly tool?: {
293
302
  readonly name: string
294
303
  readonly description: string
@@ -1126,9 +1135,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1126
1135
  private readonly responseRequests: WeakMap<Request, Request>
1127
1136
  /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
1128
1137
  private readonly appliedPlugins: Set<string>
1138
+ /** Order-scoped evidence captured by routes registered after an assured plugin. */
1139
+ private readonly activeAssurance: AssuranceDeclaration[]
1140
+ /** App-wide evidence from global hooks; applies retroactively to every route. */
1141
+ private readonly globalAssurance: AssuranceDeclaration[]
1129
1142
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
1130
1143
  private readonly mcpResourceList: McpResourceDescriptor[]
1131
1144
  private readonly mcpPromptList: McpPromptDescriptor[]
1145
+ /**
1146
+ * Every registered route's raw router entry + descriptor, in order — the replay source for
1147
+ * {@link merge}. The entry already carries the chains captured at registration (derives,
1148
+ * decorations, before/after/around/onError), so a merged route behaves exactly as it did on
1149
+ * the server that defined it.
1150
+ */
1151
+ private readonly rawEntries: Array<{
1152
+ readonly method: Method
1153
+ readonly path: string
1154
+ readonly entry: RouteEntry
1155
+ readonly descriptor: RouteDescriptor
1156
+ readonly assurance: readonly AssuranceDeclaration[]
1157
+ }>
1132
1158
 
1133
1159
  constructor(options: ServerOptions = {}) {
1134
1160
  this.router = new Router<RouteEntry>()
@@ -1154,8 +1180,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1154
1180
  this.onResponseFinalizedHooks = []
1155
1181
  this.responseRequests = new WeakMap()
1156
1182
  this.appliedPlugins = new Set()
1183
+ this.activeAssurance = []
1184
+ this.globalAssurance = []
1157
1185
  this.mcpResourceList = []
1158
1186
  this.mcpPromptList = []
1187
+ this.rawEntries = []
1159
1188
  }
1160
1189
 
1161
1190
  /** Add a per-request, computed context extension for subsequent routes. */
@@ -1250,12 +1279,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1250
1279
  if (this.appliedPlugins.has(name)) return this // idempotent: already applied
1251
1280
  this.appliedPlugins.add(name)
1252
1281
  }
1253
- return arg(this)
1282
+ const evidence = assuranceDeclarationsOf(arg)
1283
+ const pluginOnly = evidence.filter((item) => item.scope === "plugin")
1284
+ this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1285
+ this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1286
+ this.activeAssurance.push(...pluginOnly)
1287
+ try {
1288
+ return arg(this)
1289
+ } finally {
1290
+ // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
1291
+ // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
1292
+ for (const item of pluginOnly) {
1293
+ const index = this.activeAssurance.indexOf(item)
1294
+ if (index !== -1) this.activeAssurance.splice(index, 1)
1295
+ }
1296
+ }
1254
1297
  }
1255
1298
  if (arg.name !== undefined) {
1256
1299
  if (this.appliedPlugins.has(arg.name)) return this
1257
1300
  this.appliedPlugins.add(arg.name)
1258
1301
  }
1302
+ const evidence = assuranceDeclarationsOf(arg)
1303
+ if (evidence.some((item) => item.scope === "plugin")) {
1304
+ throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
1305
+ }
1306
+ this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1307
+ this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1259
1308
  if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
1260
1309
  if (arg.around !== undefined) this.around(arg.around)
1261
1310
  if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
@@ -1583,7 +1632,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1583
1632
  isContextlessNoArgArrow(handler),
1584
1633
  )
1585
1634
  : undefined
1586
- this.router.add(method, path, {
1635
+ const entry: RouteEntry = {
1587
1636
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1588
1637
  // with the concrete RawContext the typed handler expects, so this is sound.
1589
1638
  handler: handler as unknown as InternalHandler,
@@ -1616,8 +1665,68 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1616
1665
  this.beforeHandleHooks.length === 0 &&
1617
1666
  this.afterHandleHooks.length === 0 &&
1618
1667
  this.onErrorHooks.length === 0,
1668
+ }
1669
+ this.router.add(method, path, entry)
1670
+ const descriptor: RouteDescriptor = { method, path, schema }
1671
+ this.routeList.push(descriptor)
1672
+ const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
1673
+ if (schema?.body !== undefined) {
1674
+ routeAssurance.push(
1675
+ Object.freeze({
1676
+ id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1677
+ source: "route-schema",
1678
+ scope: "plugin",
1679
+ }),
1680
+ )
1681
+ }
1682
+ this.rawEntries.push({
1683
+ method,
1684
+ path,
1685
+ entry,
1686
+ descriptor,
1687
+ assurance: Object.freeze(routeAssurance),
1619
1688
  })
1620
- this.routeList.push({ method, path, schema })
1689
+ }
1690
+
1691
+ /**
1692
+ * Merge another server's routes into this one — the composition escape hatch for large apps.
1693
+ *
1694
+ * WHY: the fluent chain accumulates one type-alias level per route, and TypeScript resolves
1695
+ * that stack in one recursion — a single chain hits TS2589 at ~95 routes. Groups keep every
1696
+ * chain short: build each domain (`listings`, `agents`, …) as its own `server()` (its registry
1697
+ * resolves independently), then `app.merge(listings).merge(agents)` — each merge adds ONE level
1698
+ * regardless of group size. 300+ routes stay fully typed (see many-routes.test-d.ts). The
1699
+ * other escape hatch is contract-first `implement()`, whose registry is a single object type.
1700
+ *
1701
+ * Semantics: merged routes keep the chains captured where they were DEFINED — the group's
1702
+ * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
1703
+ * exactly as they did standalone, so a group wires its own plugins. The group's request-level
1704
+ * hooks (`onRequest`/`onResponse`/`onResponseFinalized`) are appended to this server's. This
1705
+ * server's route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like
1706
+ * routes registered before a `derive`). Fail closed: a path+method collision throws
1707
+ * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
1708
+ * those on the parent).
1709
+ */
1710
+ merge<R2 extends Registry, Ctx2>(other: Server<R2, Ctx2>): Server<R & R2, Ctx> {
1711
+ const source = other as unknown as Server<Registry, EmptyContext>
1712
+ if (source.wsRouteCount > 0) {
1713
+ throw new RouteConfigError(
1714
+ "INVALID_PATH",
1715
+ "merge() does not carry WebSocket routes — register .ws() routes on the parent server",
1716
+ )
1717
+ }
1718
+ for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
1719
+ this.router.add(method, path, entry) // throws RouteConfigError on a duplicate — fail closed
1720
+ this.routeList.push(descriptor) // shared ref on purpose: carries later .tool() tagging
1721
+ this.rawEntries.push({ method, path, entry, descriptor, assurance })
1722
+ }
1723
+ this.onRequestHooks.push(...source.onRequestHooks)
1724
+ this.onResponseHooks.push(...source.onResponseHooks)
1725
+ this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
1726
+ this.globalAssurance.push(...source.globalAssurance)
1727
+ this.mcpResourceList.push(...source.mcpResourceList)
1728
+ this.mcpPromptList.push(...source.mcpPromptList)
1729
+ return this as unknown as Server<R & R2, Ctx>
1621
1730
  }
1622
1731
 
1623
1732
  /**
@@ -1626,7 +1735,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1626
1735
  * longer holds the original patterns.
1627
1736
  */
1628
1737
  routes(): ReadonlyArray<RouteDescriptor> {
1629
- return this.routeList
1738
+ if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
1739
+ const anyRouteEvidence = this.rawEntries.some((entry) => entry.assurance.length > 0)
1740
+ if (!anyRouteEvidence) return this.routeList
1741
+ }
1742
+ return this.rawEntries.map(({ method, path, descriptor, assurance }) => {
1743
+ const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path)
1744
+ return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor
1745
+ })
1630
1746
  }
1631
1747
 
1632
1748
  /**