@nifrajs/core 1.7.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.
- package/README.md +20 -0
- package/dist/assurance.d.ts +80 -0
- package/dist/assurance.d.ts.map +1 -0
- package/dist/assurance.js +162 -0
- package/dist/assurance.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-assurance.d.ts +36 -0
- package/dist/internal/route-assurance.d.ts.map +1 -0
- package/dist/internal/route-assurance.js +118 -0
- package/dist/internal/route-assurance.js.map +1 -0
- package/dist/reflection.d.ts +2 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +12 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/registry.d.ts +14 -8
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +57 -5
- package/dist/server/server.js.map +1 -1
- package/package.json +6 -1
- package/src/assurance.ts +263 -0
- package/src/index.ts +2 -1
- package/src/internal/route-assurance.ts +166 -0
- package/src/reflection.ts +13 -1
- package/src/server/registry.ts +14 -8
- package/src/server/server.ts +65 -5
package/src/assurance.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route assurance: reflection-time proof that every route is classified and carries the enforcement
|
|
3
|
+
* evidence its policy requires. Evaluation is pure and never runs on the request hot path.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
AssuranceDeclaration,
|
|
8
|
+
AssuranceEvidence,
|
|
9
|
+
AssuranceScope,
|
|
10
|
+
} from "./internal/route-assurance.ts"
|
|
11
|
+
import {
|
|
12
|
+
NIFRA_ASSURANCE_IDS,
|
|
13
|
+
routeGlob,
|
|
14
|
+
validEvidenceId,
|
|
15
|
+
validMethod,
|
|
16
|
+
withRouteAssurance,
|
|
17
|
+
} from "./internal/route-assurance.ts"
|
|
18
|
+
import { type ReflectedRoute, reflectRoutes } from "./reflection.ts"
|
|
19
|
+
import type { Method } from "./router/router.ts"
|
|
20
|
+
|
|
21
|
+
export type { AssuranceDeclaration, AssuranceEvidence, AssuranceScope }
|
|
22
|
+
export { withRouteAssurance }
|
|
23
|
+
|
|
24
|
+
/** Canonical evidence ids emitted by Nifra's official middleware modules. */
|
|
25
|
+
export const NIFRA_ASSURANCE = NIFRA_ASSURANCE_IDS
|
|
26
|
+
|
|
27
|
+
export interface AssuranceRouteSelector {
|
|
28
|
+
/** Omit for every method. */
|
|
29
|
+
readonly methods?: readonly Method[]
|
|
30
|
+
/** Absolute route globs. `*` matches one segment; final `**` matches zero or more. */
|
|
31
|
+
readonly paths?: readonly string[]
|
|
32
|
+
/** Restrict the rule to MCP tool routes (`true`) or non-tool routes (`false`). */
|
|
33
|
+
readonly tools?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AssuranceRule {
|
|
37
|
+
/** Stable human-readable classification included in diagnostics. */
|
|
38
|
+
readonly name: string
|
|
39
|
+
readonly match: AssuranceRouteSelector
|
|
40
|
+
/** Evidence ids the route must carry. */
|
|
41
|
+
readonly require?: readonly string[]
|
|
42
|
+
/** Evidence ids the route must not carry (useful for public webhooks and health routes). */
|
|
43
|
+
readonly forbid?: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AssurancePolicy {
|
|
47
|
+
/** First matching rule owns a route. Put exceptions before broad defaults. */
|
|
48
|
+
readonly rules: readonly AssuranceRule[]
|
|
49
|
+
/** Default `error`: an unclassified route fails closed. */
|
|
50
|
+
readonly unmatched?: "error" | "ignore"
|
|
51
|
+
/** Default false: reject an empty reflected source so a wrong import cannot pass CI silently. */
|
|
52
|
+
readonly allowEmpty?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type AssuranceFindingCode =
|
|
56
|
+
| "no-routes"
|
|
57
|
+
| "unclassified-route"
|
|
58
|
+
| "missing-evidence"
|
|
59
|
+
| "forbidden-evidence"
|
|
60
|
+
|
|
61
|
+
export interface AssuranceFinding {
|
|
62
|
+
readonly code: AssuranceFindingCode
|
|
63
|
+
readonly method: string
|
|
64
|
+
readonly path: string
|
|
65
|
+
readonly rule?: string
|
|
66
|
+
readonly evidence?: string
|
|
67
|
+
readonly message: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AssuredRoute {
|
|
71
|
+
readonly method: string
|
|
72
|
+
readonly path: string
|
|
73
|
+
readonly rule?: string
|
|
74
|
+
readonly evidence: readonly AssuranceEvidence[]
|
|
75
|
+
readonly missing: readonly string[]
|
|
76
|
+
readonly forbidden: readonly string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AssuranceReport {
|
|
80
|
+
readonly ok: boolean
|
|
81
|
+
readonly routes: readonly AssuredRoute[]
|
|
82
|
+
readonly findings: readonly AssuranceFinding[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AssuranceConfig {
|
|
86
|
+
readonly source: unknown
|
|
87
|
+
readonly policy: AssurancePolicy
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const nonEmpty = (value: string): boolean => value.trim() !== ""
|
|
91
|
+
|
|
92
|
+
function normalizeEvidenceIds(
|
|
93
|
+
values: readonly string[] | undefined,
|
|
94
|
+
label: string,
|
|
95
|
+
): readonly string[] {
|
|
96
|
+
const out: string[] = []
|
|
97
|
+
const seen = new Set<string>()
|
|
98
|
+
for (const value of values ?? []) {
|
|
99
|
+
if (!validEvidenceId(value)) {
|
|
100
|
+
throw new Error(`route assurance: invalid ${label} evidence id ${JSON.stringify(value)}`)
|
|
101
|
+
}
|
|
102
|
+
if (!seen.has(value)) {
|
|
103
|
+
seen.add(value)
|
|
104
|
+
out.push(value)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return Object.freeze(out)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validate and freeze an ordered assurance policy. */
|
|
111
|
+
export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy {
|
|
112
|
+
if (
|
|
113
|
+
policy.unmatched !== undefined &&
|
|
114
|
+
policy.unmatched !== "error" &&
|
|
115
|
+
policy.unmatched !== "ignore"
|
|
116
|
+
) {
|
|
117
|
+
throw new Error(`route assurance: invalid unmatched mode ${JSON.stringify(policy.unmatched)}`)
|
|
118
|
+
}
|
|
119
|
+
if (policy.allowEmpty !== undefined && typeof policy.allowEmpty !== "boolean") {
|
|
120
|
+
throw new Error(`route assurance: allowEmpty must be boolean`)
|
|
121
|
+
}
|
|
122
|
+
const names = new Set<string>()
|
|
123
|
+
const rules = policy.rules.map((rule): AssuranceRule => {
|
|
124
|
+
if (typeof rule.name !== "string" || !nonEmpty(rule.name)) {
|
|
125
|
+
throw new Error("route assurance: rule name must be non-empty")
|
|
126
|
+
}
|
|
127
|
+
const name = rule.name.trim()
|
|
128
|
+
if (names.has(name))
|
|
129
|
+
throw new Error(`route assurance: duplicate rule name ${JSON.stringify(name)}`)
|
|
130
|
+
names.add(name)
|
|
131
|
+
if (rule.match.tools !== undefined && typeof rule.match.tools !== "boolean") {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`route assurance: rule ${JSON.stringify(name)} tools selector must be boolean`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
const methods = rule.match.methods?.map((method) => method.toUpperCase())
|
|
137
|
+
for (const method of methods ?? []) {
|
|
138
|
+
if (!validMethod(method)) {
|
|
139
|
+
throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const paths = rule.match.paths?.map((path) => {
|
|
143
|
+
routeGlob(path)
|
|
144
|
+
return path
|
|
145
|
+
})
|
|
146
|
+
const required = normalizeEvidenceIds(rule.require, "required")
|
|
147
|
+
const forbidden = normalizeEvidenceIds(rule.forbid, "forbidden")
|
|
148
|
+
const overlap = required.find((id) => forbidden.includes(id))
|
|
149
|
+
if (overlap !== undefined) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`route assurance: rule ${JSON.stringify(name)} both requires and forbids ${overlap}`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
name,
|
|
156
|
+
match: Object.freeze({
|
|
157
|
+
...(methods !== undefined ? { methods: Object.freeze(methods as Method[]) } : {}),
|
|
158
|
+
...(paths !== undefined ? { paths: Object.freeze(paths) } : {}),
|
|
159
|
+
...(rule.match.tools !== undefined ? { tools: rule.match.tools } : {}),
|
|
160
|
+
}),
|
|
161
|
+
require: required,
|
|
162
|
+
forbid: forbidden,
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
rules: Object.freeze(rules),
|
|
167
|
+
unmatched: policy.unmatched ?? "error",
|
|
168
|
+
allowEmpty: policy.allowEmpty ?? false,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Identity helper for a `nifra.assurance.ts` default export. */
|
|
173
|
+
export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig {
|
|
174
|
+
return Object.freeze({ source: config.source, policy: defineAssurancePolicy(config.policy) })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Shared selector semantics for policy rules and framework adapters. */
|
|
178
|
+
export function matchesAssuranceSelector(
|
|
179
|
+
route: Pick<ReflectedRoute, "method" | "path" | "tool">,
|
|
180
|
+
selector: AssuranceRouteSelector,
|
|
181
|
+
): boolean {
|
|
182
|
+
const { methods, paths, tools } = selector
|
|
183
|
+
if (methods !== undefined && !methods.includes(route.method as Method)) return false
|
|
184
|
+
if (paths !== undefined && !paths.some((pattern) => routeGlob(pattern).test(route.path)))
|
|
185
|
+
return false
|
|
186
|
+
if (tools !== undefined && (route.tool !== undefined) !== tools) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Evaluate reflected route evidence against the first matching policy rule. */
|
|
191
|
+
export function evaluateRouteAssurance(
|
|
192
|
+
source: unknown,
|
|
193
|
+
policyInput: AssurancePolicy,
|
|
194
|
+
): AssuranceReport {
|
|
195
|
+
const policy = defineAssurancePolicy(policyInput)
|
|
196
|
+
const findings: AssuranceFinding[] = []
|
|
197
|
+
const routes: AssuredRoute[] = []
|
|
198
|
+
const reflected = reflectRoutes(source)
|
|
199
|
+
|
|
200
|
+
if (reflected.length === 0 && policy.allowEmpty !== true) {
|
|
201
|
+
findings.push({
|
|
202
|
+
code: "no-routes",
|
|
203
|
+
method: "*",
|
|
204
|
+
path: "*",
|
|
205
|
+
message: "route assurance source reflected zero routes (set allowEmpty: true if intentional)",
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const route of reflected) {
|
|
210
|
+
const rule = policy.rules.find((candidate) => matchesAssuranceSelector(route, candidate.match))
|
|
211
|
+
const evidence = route.assurance ?? []
|
|
212
|
+
const evidenceIds = new Set(evidence.map((item) => item.id))
|
|
213
|
+
if (rule === undefined) {
|
|
214
|
+
if (policy.unmatched !== "ignore") {
|
|
215
|
+
findings.push({
|
|
216
|
+
code: "unclassified-route",
|
|
217
|
+
method: route.method,
|
|
218
|
+
path: route.path,
|
|
219
|
+
message: `${route.method} ${route.path} is not classified by an assurance rule`,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
routes.push({ method: route.method, path: route.path, evidence, missing: [], forbidden: [] })
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const missing = (rule.require ?? []).filter((id) => !evidenceIds.has(id))
|
|
227
|
+
const forbidden = (rule.forbid ?? []).filter((id) => evidenceIds.has(id))
|
|
228
|
+
for (const id of missing) {
|
|
229
|
+
findings.push({
|
|
230
|
+
code: "missing-evidence",
|
|
231
|
+
method: route.method,
|
|
232
|
+
path: route.path,
|
|
233
|
+
rule: rule.name,
|
|
234
|
+
evidence: id,
|
|
235
|
+
message: `${route.method} ${route.path} (${rule.name}) is missing ${id}`,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
for (const id of forbidden) {
|
|
239
|
+
findings.push({
|
|
240
|
+
code: "forbidden-evidence",
|
|
241
|
+
method: route.method,
|
|
242
|
+
path: route.path,
|
|
243
|
+
rule: rule.name,
|
|
244
|
+
evidence: id,
|
|
245
|
+
message: `${route.method} ${route.path} (${rule.name}) carries forbidden ${id}`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
routes.push({
|
|
249
|
+
method: route.method,
|
|
250
|
+
path: route.path,
|
|
251
|
+
rule: rule.name,
|
|
252
|
+
evidence,
|
|
253
|
+
missing: Object.freeze(missing),
|
|
254
|
+
forbidden: Object.freeze(forbidden),
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
ok: findings.length === 0,
|
|
260
|
+
routes: Object.freeze(routes),
|
|
261
|
+
findings: Object.freeze(findings),
|
|
262
|
+
})
|
|
263
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
|
|
13
13
|
* consumers can pin it at the type level — see `test/version.test-d.ts`.
|
|
14
14
|
*/
|
|
15
|
-
export const VERSION = "1.
|
|
15
|
+
export const VERSION = "1.8.0" as const
|
|
16
16
|
|
|
17
17
|
export type Version = typeof VERSION
|
|
18
18
|
|
|
19
|
+
export * from "./assurance.ts"
|
|
19
20
|
export * from "./diff.ts"
|
|
20
21
|
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
|
21
22
|
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
@@ -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 {
|
|
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
|
}
|
package/src/server/registry.ts
CHANGED
|
@@ -78,14 +78,20 @@ export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
|
|
|
78
78
|
/**
|
|
79
79
|
* Merge a new route into the registry, combining methods that share a path.
|
|
80
80
|
*
|
|
81
|
-
* SCALING CEILING (measured
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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).
|
|
89
95
|
*/
|
|
90
96
|
export type AddRoute<
|
|
91
97
|
R extends Registry,
|
package/src/server/server.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
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,6 +1135,10 @@ 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[]
|
|
@@ -1140,6 +1153,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1140
1153
|
readonly path: string
|
|
1141
1154
|
readonly entry: RouteEntry
|
|
1142
1155
|
readonly descriptor: RouteDescriptor
|
|
1156
|
+
readonly assurance: readonly AssuranceDeclaration[]
|
|
1143
1157
|
}>
|
|
1144
1158
|
|
|
1145
1159
|
constructor(options: ServerOptions = {}) {
|
|
@@ -1166,6 +1180,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1166
1180
|
this.onResponseFinalizedHooks = []
|
|
1167
1181
|
this.responseRequests = new WeakMap()
|
|
1168
1182
|
this.appliedPlugins = new Set()
|
|
1183
|
+
this.activeAssurance = []
|
|
1184
|
+
this.globalAssurance = []
|
|
1169
1185
|
this.mcpResourceList = []
|
|
1170
1186
|
this.mcpPromptList = []
|
|
1171
1187
|
this.rawEntries = []
|
|
@@ -1263,12 +1279,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1263
1279
|
if (this.appliedPlugins.has(name)) return this // idempotent: already applied
|
|
1264
1280
|
this.appliedPlugins.add(name)
|
|
1265
1281
|
}
|
|
1266
|
-
|
|
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
|
+
}
|
|
1267
1297
|
}
|
|
1268
1298
|
if (arg.name !== undefined) {
|
|
1269
1299
|
if (this.appliedPlugins.has(arg.name)) return this
|
|
1270
1300
|
this.appliedPlugins.add(arg.name)
|
|
1271
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"))
|
|
1272
1308
|
if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
|
|
1273
1309
|
if (arg.around !== undefined) this.around(arg.around)
|
|
1274
1310
|
if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
|
|
@@ -1633,7 +1669,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1633
1669
|
this.router.add(method, path, entry)
|
|
1634
1670
|
const descriptor: RouteDescriptor = { method, path, schema }
|
|
1635
1671
|
this.routeList.push(descriptor)
|
|
1636
|
-
|
|
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),
|
|
1688
|
+
})
|
|
1637
1689
|
}
|
|
1638
1690
|
|
|
1639
1691
|
/**
|
|
@@ -1663,14 +1715,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1663
1715
|
"merge() does not carry WebSocket routes — register .ws() routes on the parent server",
|
|
1664
1716
|
)
|
|
1665
1717
|
}
|
|
1666
|
-
for (const { method, path, entry, descriptor } of source.rawEntries) {
|
|
1718
|
+
for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
|
|
1667
1719
|
this.router.add(method, path, entry) // throws RouteConfigError on a duplicate — fail closed
|
|
1668
1720
|
this.routeList.push(descriptor) // shared ref on purpose: carries later .tool() tagging
|
|
1669
|
-
this.rawEntries.push({ method, path, entry, descriptor })
|
|
1721
|
+
this.rawEntries.push({ method, path, entry, descriptor, assurance })
|
|
1670
1722
|
}
|
|
1671
1723
|
this.onRequestHooks.push(...source.onRequestHooks)
|
|
1672
1724
|
this.onResponseHooks.push(...source.onResponseHooks)
|
|
1673
1725
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
|
|
1726
|
+
this.globalAssurance.push(...source.globalAssurance)
|
|
1674
1727
|
this.mcpResourceList.push(...source.mcpResourceList)
|
|
1675
1728
|
this.mcpPromptList.push(...source.mcpPromptList)
|
|
1676
1729
|
return this as unknown as Server<R & R2, Ctx>
|
|
@@ -1682,7 +1735,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1682
1735
|
* longer holds the original patterns.
|
|
1683
1736
|
*/
|
|
1684
1737
|
routes(): ReadonlyArray<RouteDescriptor> {
|
|
1685
|
-
|
|
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
|
+
})
|
|
1686
1746
|
}
|
|
1687
1747
|
|
|
1688
1748
|
/**
|