@nifrajs/core 1.7.0 → 1.9.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 +25 -1
- 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/context.d.ts +6 -0
- package/dist/server/context.d.ts.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 +32 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +332 -61
- package/dist/server/server.js.map +1 -1
- package/package.json +9 -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/context.ts +6 -0
- package/src/server/registry.ts +14 -8
- package/src/server/server.ts +521 -65
|
@@ -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/context.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestBudget } from "@nifrajs/budget"
|
|
1
2
|
import type { InferOutput, StandardIssue, StandardSchemaV1 } from "../schema/standard.ts"
|
|
2
3
|
import type { CookieOptions } from "./cookies.ts"
|
|
3
4
|
|
|
@@ -133,6 +134,11 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
133
134
|
* timed-out request stops doing work instead of running on after the 503.
|
|
134
135
|
*/
|
|
135
136
|
readonly signal: AbortSignal
|
|
137
|
+
/**
|
|
138
|
+
* Absolute request deadline plus monotonic remaining time. It shares {@link signal}; the server
|
|
139
|
+
* clamps an inbound deadline to local policy when `acceptInboundDeadlines` is enabled.
|
|
140
|
+
*/
|
|
141
|
+
readonly budget: RequestBudget
|
|
136
142
|
/**
|
|
137
143
|
* Platform bindings from `app.fetch(request, { env })` — Workers `env` (KV/D1/secrets), etc.
|
|
138
144
|
* `undefined` off-edge (Bun/Node/Deno). Declare the shape with `server<Env>()` to read it typed
|
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,
|