@nifrajs/core 1.13.0 → 2.0.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 +10 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +8 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +44 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +181 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +107 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +214 -831
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/websocket.d.ts +17 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +77 -6
- package/src/assurance.ts +36 -1
- package/src/effect-ledger.ts +9 -0
- package/src/idempotency-plugin.ts +7 -0
- package/src/index.ts +6 -133
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +287 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +93 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +402 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/websocket.ts +20 -0
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in MCP runtime plugin.
|
|
3
|
+
*
|
|
4
|
+
* `.use(mcp())` enables `.tool()`, `.resource()`, and `.prompt()`. Without it those methods throw at
|
|
5
|
+
* registration (fail-closed), so an ordinary HTTP app never evaluates or bundles the MCP wiring. This
|
|
6
|
+
* is the same install seam as `.use(idempotency())` / `.use(effectLedger())` - one consistent `.use()`
|
|
7
|
+
* opt-in, not a side-effect import.
|
|
8
|
+
*/
|
|
9
|
+
import { INSTALL_MCP } from "./server/install.ts"
|
|
10
|
+
import type { McpRuntime } from "./server/mcp-hook.ts"
|
|
11
|
+
import type { IdentityPlugin } from "./server/plugin.ts"
|
|
12
|
+
import type { AnyServer } from "./server/server.ts"
|
|
13
|
+
|
|
14
|
+
const MCP_RUNTIME: McpRuntime = {
|
|
15
|
+
tool(name, config, handler) {
|
|
16
|
+
const path = `/_nifra/tool/${name}`
|
|
17
|
+
return {
|
|
18
|
+
path,
|
|
19
|
+
schema:
|
|
20
|
+
config.output !== undefined
|
|
21
|
+
? { body: config.input, response: config.output }
|
|
22
|
+
: { body: config.input },
|
|
23
|
+
run: (context) => handler(context.body, context),
|
|
24
|
+
descriptor: {
|
|
25
|
+
name,
|
|
26
|
+
description: config.description,
|
|
27
|
+
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
resource(uri, config, read) {
|
|
32
|
+
return {
|
|
33
|
+
uri,
|
|
34
|
+
name: config.name,
|
|
35
|
+
...(config.description !== undefined ? { description: config.description } : {}),
|
|
36
|
+
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
37
|
+
read,
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
prompt(name, config, handler) {
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
description: config.description,
|
|
44
|
+
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
45
|
+
handler,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The install seam a server exposes so the `mcp()` plugin can hand it the runtime. */
|
|
51
|
+
interface McpInstallable {
|
|
52
|
+
[INSTALL_MCP](runtime: McpRuntime): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enable MCP declarations on a server: `.use(mcp())` turns on `.tool()`, `.resource()`, and
|
|
57
|
+
* `.prompt()`. Applying it twice is a no-op (named plugin dedupe).
|
|
58
|
+
*/
|
|
59
|
+
export function mcp(): IdentityPlugin {
|
|
60
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
61
|
+
;(app as unknown as McpInstallable)[INSTALL_MCP](MCP_RUNTIME)
|
|
62
|
+
return app
|
|
63
|
+
}
|
|
64
|
+
return Object.assign(apply, { pluginName: "nifra:mcp" }) as IdentityPlugin
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in Node-direct renderer plugin.
|
|
3
|
+
*
|
|
4
|
+
* `.use(nodeDirect())` enables `app.resolveNode()` for callers that invoke it themselves. Normal
|
|
5
|
+
* `@nifrajs/node` serving supplies the renderer directly, so it never needs this; only direct
|
|
6
|
+
* `resolveNode()` callers do. Same `.use()` install seam as `mcp()` / `idempotency()`.
|
|
7
|
+
*/
|
|
8
|
+
import { INSTALL_NODE_DIRECT } from "./server/install.ts"
|
|
9
|
+
import { nodeOutcomeFromResponse, toNodeOutcome } from "./server/node-outcome.ts"
|
|
10
|
+
import type { NodeOutcomeRuntime } from "./server/node-outcome-hook.ts"
|
|
11
|
+
import type { IdentityPlugin } from "./server/plugin.ts"
|
|
12
|
+
import type { AnyServer } from "./server/server.ts"
|
|
13
|
+
|
|
14
|
+
const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
|
|
15
|
+
toOutcome: toNodeOutcome,
|
|
16
|
+
fromResponse: nodeOutcomeFromResponse,
|
|
17
|
+
timeout: () => ({
|
|
18
|
+
kind: "response",
|
|
19
|
+
response: Response.json({ ok: false, error: "request_timeout" }, { status: 503 }),
|
|
20
|
+
}),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The install seam a server exposes so the `nodeDirect()` plugin can hand it the renderer. */
|
|
24
|
+
interface NodeDirectInstallable {
|
|
25
|
+
[INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Enable `app.resolveNode()` for direct callers. Applying it twice is a no-op (named plugin dedupe). */
|
|
29
|
+
export function nodeDirect(): IdentityPlugin {
|
|
30
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
31
|
+
;(app as unknown as NodeDirectInstallable)[INSTALL_NODE_DIRECT](NODE_OUTCOME_RUNTIME)
|
|
32
|
+
return app
|
|
33
|
+
}
|
|
34
|
+
return Object.assign(apply, { pluginName: "nifra:node-direct" }) as IdentityPlugin
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type { NodeServeOutcome } from "./server/node-outcome.ts"
|
package/src/reflection.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface SchemaReflection {
|
|
|
40
40
|
export interface ReflectedRouteSchema {
|
|
41
41
|
readonly body?: SchemaReflection
|
|
42
42
|
readonly query?: SchemaReflection
|
|
43
|
+
/** Path-params schema — constraints (uuid format, integer min/max) declared via `params: t.object(…)`. */
|
|
44
|
+
readonly params?: SchemaReflection
|
|
43
45
|
readonly response?: SchemaReflection
|
|
44
46
|
readonly errors?: Readonly<Record<string, SchemaReflection>>
|
|
45
47
|
/** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
|
|
@@ -52,6 +54,9 @@ export interface ReflectedRoute {
|
|
|
52
54
|
readonly schema?: ReflectedRouteSchema
|
|
53
55
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
54
56
|
readonly capabilities?: readonly string[]
|
|
57
|
+
/** Set when the route is a declared dynamic route family (a runtime-resolved template like
|
|
58
|
+
* `/api/:slug/:resource`) - so the assurance gate treats it as one classified family, not a single route. */
|
|
59
|
+
readonly family?: boolean
|
|
55
60
|
/** Field-level response classification plus the highest sensitivity present. */
|
|
56
61
|
readonly classification?: ResponseClassification
|
|
57
62
|
readonly tool?: {
|
|
@@ -167,6 +172,7 @@ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined
|
|
|
167
172
|
return {
|
|
168
173
|
...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
|
|
169
174
|
...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
|
|
175
|
+
...(schema.params !== undefined ? { params: reflectSchema(schema.params) } : {}),
|
|
170
176
|
...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
|
|
171
177
|
...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
|
|
172
178
|
...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
|
|
@@ -218,6 +224,7 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
|
|
|
218
224
|
...(schema !== undefined ? { schema } : {}),
|
|
219
225
|
...(assurance !== undefined ? { assurance } : {}),
|
|
220
226
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
227
|
+
...(route.family === true ? { family: true } : {}),
|
|
221
228
|
...(classification !== undefined ? { classification } : {}),
|
|
222
229
|
...(tool !== undefined ? { tool } : {}),
|
|
223
230
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deriving the caller's IP (`c.clientIp`), correctly and vendor-neutrally.
|
|
3
|
+
*
|
|
4
|
+
* The default is the raw socket peer the serving adapter observed - the only value that cannot be
|
|
5
|
+
* forged by a client, since the client never controls who the TCP connection comes from. Behind a
|
|
6
|
+
* reverse proxy or CDN the socket peer is the proxy, not the caller, so a `Forwarded`/`X-Forwarded-For`
|
|
7
|
+
* value must be consulted - but ONLY as far as you actually trust the hops in front of you, because a
|
|
8
|
+
* directly-reachable app lets any client forge those headers. The trust is therefore an explicit,
|
|
9
|
+
* opt-in declaration; with none set, `c.clientIp` is the socket peer and no header is ever believed.
|
|
10
|
+
*
|
|
11
|
+
* No vendor names live here: a Cloudflare/Fastly/nginx deployment declares its own header or hop count.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How much of the forwarding chain to trust when deriving `c.clientIp`. Omit for the safe default
|
|
16
|
+
* (socket peer only, no header believed).
|
|
17
|
+
*
|
|
18
|
+
* - `{ trustedHops: n }` - trust `n` reverse-proxy hops in front of the app. The caller is taken from
|
|
19
|
+
* `X-Forwarded-For` counted from the right past the `n` trusted hops (the socket peer is hop 0). Use
|
|
20
|
+
* this when the app sits behind `n` proxies **you operate**. Fewer entries than trusted hops means
|
|
21
|
+
* the header is too short to trust, so `c.clientIp` is `undefined` (fail closed).
|
|
22
|
+
* - `{ header: name }` - trust a single named header's first value (e.g. a CDN that overwrites it at
|
|
23
|
+
* the edge). Only declare this when the app is reachable **exclusively** through that edge; a
|
|
24
|
+
* directly-reachable app lets any client forge the header.
|
|
25
|
+
*/
|
|
26
|
+
export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
|
|
27
|
+
|
|
28
|
+
function firstHeaderValue(req: Request, header: string): string | undefined {
|
|
29
|
+
const raw = req.headers.get(header)
|
|
30
|
+
if (raw === null) return undefined
|
|
31
|
+
const first = raw.split(",", 1)[0]?.trim()
|
|
32
|
+
return first === undefined || first === "" ? undefined : first
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the caller IP from the raw socket `peer` (adapter-observed), the request's forwarding
|
|
37
|
+
* headers, and the app's `trust` declaration. Pure and synchronous.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveClientIp(
|
|
40
|
+
peer: string | undefined,
|
|
41
|
+
req: Request,
|
|
42
|
+
trust: ClientIpTrust | undefined,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
if (trust === undefined) return peer
|
|
45
|
+
if ("header" in trust) return firstHeaderValue(req, trust.header)
|
|
46
|
+
|
|
47
|
+
// trustedHops: walk `X-Forwarded-For` (client-most first) plus the socket peer as the closest hop.
|
|
48
|
+
const xff = req.headers.get("x-forwarded-for")
|
|
49
|
+
const forwarded =
|
|
50
|
+
xff === null
|
|
51
|
+
? []
|
|
52
|
+
: xff
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((entry) => entry.trim())
|
|
55
|
+
.filter((entry) => entry !== "")
|
|
56
|
+
const chain = peer !== undefined ? [...forwarded, peer] : forwarded
|
|
57
|
+
// The caller sits just left of the `trustedHops` trusted proxies at the tail of the chain.
|
|
58
|
+
const index = chain.length - 1 - trust.trustedHops
|
|
59
|
+
return index >= 0 ? chain[index] : undefined
|
|
60
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -78,6 +78,34 @@ export interface RouteSchema {
|
|
|
78
78
|
* and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
|
|
79
79
|
*/
|
|
80
80
|
readonly classification?: DataClassification
|
|
81
|
+
/**
|
|
82
|
+
* Inline **route-assurance evidence**: the enforcement posture this route carries, declared adjacent to
|
|
83
|
+
* the handler (e.g. `[NIFRA_ASSURANCE.AUTHENTICATED]`). Each id becomes route-scoped `declared` evidence
|
|
84
|
+
* in reflection, so a `nifra.assurance.ts` policy `require:` clause is satisfiable WITHOUT rewriting an
|
|
85
|
+
* in-handler guard into a `withRouteAssurance`-marked middleware. Ids are lowercase dot/dash segments
|
|
86
|
+
* (invalid ids throw at registration). Reflected for the assurance gate; never read on the hot path.
|
|
87
|
+
*
|
|
88
|
+
* This is an author ASSERTION (recorded as `source: "declared"`), not runtime proof - like every
|
|
89
|
+
* assurance marker it is trusted by the static gate, which matches on the evidence id, not its source.
|
|
90
|
+
* Declare an id only on a route whose guard actually enforces it; a false declaration silently satisfies
|
|
91
|
+
* the policy. (`source` is retained in reflection, so a stricter policy or reviewer can still tell
|
|
92
|
+
* declared evidence from enforcement-module evidence.)
|
|
93
|
+
*/
|
|
94
|
+
readonly assurance?: readonly string[]
|
|
95
|
+
/**
|
|
96
|
+
* Mark this route a **dynamic route family**: a template (`/api/:slug/:resource`) whose concrete
|
|
97
|
+
* resources are resolved at runtime (auto-CRUD over tenant-defined tables, a catch-all dispatcher).
|
|
98
|
+
* Purely declarative - it does not change dispatch. It surfaces in reflection so the assurance gate and
|
|
99
|
+
* tooling treat the one templated route as a deliberate family whose evidence covers every runtime-
|
|
100
|
+
* resolved resource, instead of reading it as a single forgotten route.
|
|
101
|
+
*/
|
|
102
|
+
readonly family?: boolean
|
|
103
|
+
/** Optional **path-params schema**. Path params arrive as strings (`/users/:id` -> `c.params.id`);
|
|
104
|
+
* declaring a schema validates them at the boundary (a malformed `:id` is a `422` before the handler,
|
|
105
|
+
* like `body`/`query`) and can coerce them (use `t.query({ id: t.integer() })` for a numeric param).
|
|
106
|
+
* The schema must cover every path param; with the strict default an undeclared path param is rejected.
|
|
107
|
+
* When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
|
|
108
|
+
readonly params?: StandardSchemaV1
|
|
81
109
|
readonly body?: StandardSchemaV1
|
|
82
110
|
readonly query?: StandardSchemaV1
|
|
83
111
|
/** Optional **response contract**. When declared: the handler's return is type-checked against it
|
|
@@ -117,7 +145,7 @@ export interface RouteSchema {
|
|
|
117
145
|
readonly onValidationError?: (
|
|
118
146
|
issues: ReadonlyArray<StandardIssue>,
|
|
119
147
|
ctx: Context,
|
|
120
|
-
kind: "body" | "query",
|
|
148
|
+
kind: "body" | "query" | "params",
|
|
121
149
|
) => Response | unknown | Promise<Response | unknown>
|
|
122
150
|
}
|
|
123
151
|
|
|
@@ -131,6 +159,13 @@ type QueryOf<S extends RouteSchema> = S extends { query: infer Q extends Standar
|
|
|
131
159
|
? InferOutput<Q>
|
|
132
160
|
: URLSearchParams
|
|
133
161
|
|
|
162
|
+
/** The validated params type when a params schema is declared, else the path-inferred `Params<Path>`. */
|
|
163
|
+
type ParamsOf<S extends RouteSchema, Path extends string> = S extends {
|
|
164
|
+
params: infer P extends StandardSchemaV1
|
|
165
|
+
}
|
|
166
|
+
? InferOutput<P>
|
|
167
|
+
: Params<Path>
|
|
168
|
+
|
|
134
169
|
/** Mutable response controls a handler may write to before returning. */
|
|
135
170
|
export interface ResponseControls {
|
|
136
171
|
status?: number
|
|
@@ -159,6 +194,10 @@ export interface Platform<Env = unknown> {
|
|
|
159
194
|
readonly env?: Env
|
|
160
195
|
/** Extend the response's lifetime for background work (Workers `ctx.waitUntil`). */
|
|
161
196
|
readonly waitUntil?: (promise: Promise<unknown>) => void
|
|
197
|
+
/** The raw socket peer address the serving adapter observed, if any (`listen()`, `@nifrajs/node`,
|
|
198
|
+
* `@nifrajs/deno` set it). The server applies the app's `clientIp` trust declaration to this before
|
|
199
|
+
* exposing {@link Context.clientIp}; read `c.clientIp`, not this. */
|
|
200
|
+
readonly clientIp?: string | undefined
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
/**
|
|
@@ -174,7 +213,7 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
174
213
|
* everywhere (no more `c.req` vs `ctx.request` mismatch).
|
|
175
214
|
*/
|
|
176
215
|
readonly request: Request
|
|
177
|
-
readonly params:
|
|
216
|
+
readonly params: ParamsOf<S, Path>
|
|
178
217
|
readonly query: QueryOf<S>
|
|
179
218
|
readonly body: BodyOf<S>
|
|
180
219
|
/** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
|
|
@@ -198,6 +237,15 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
|
|
|
198
237
|
* (`c.env: Env`); otherwise `unknown`. Validate at the trust boundary before use either way.
|
|
199
238
|
*/
|
|
200
239
|
readonly env: unknown
|
|
240
|
+
/**
|
|
241
|
+
* The caller's IP. By default the raw socket peer the serving adapter observed (`listen()`,
|
|
242
|
+
* `@nifrajs/node`, `@nifrajs/deno`) — the one address a client cannot forge — or `undefined` when the
|
|
243
|
+
* runtime exposes no socket (Workers) and no trust is declared. Behind a proxy/CDN, set the
|
|
244
|
+
* `clientIp` server option (`{ trustedHops }` or `{ header }`) to derive the real caller from the
|
|
245
|
+
* forwarding chain as far as you trust it; unset, no forwarded header is ever believed. Safe to key
|
|
246
|
+
* rate limits and audit logs on.
|
|
247
|
+
*/
|
|
248
|
+
readonly clientIp: string | undefined
|
|
201
249
|
/**
|
|
202
250
|
* Schedule background work. On edge runtimes it extends the response lifetime (Workers
|
|
203
251
|
* `ctx.waitUntil`); off-edge it runs the promise fire-and-forget (a rejection is swallowed,
|
package/src/server/contract.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface ResponseDef {
|
|
|
26
26
|
export interface OperationDef {
|
|
27
27
|
readonly method: Method
|
|
28
28
|
readonly path: string
|
|
29
|
+
/** Optional path-params schema (validated + coercible at the boundary; see {@link RouteSchema.params}). */
|
|
30
|
+
readonly params?: StandardSchemaV1
|
|
29
31
|
readonly body?: StandardSchemaV1
|
|
30
32
|
readonly query?: StandardSchemaV1
|
|
31
33
|
readonly response?: StandardSchemaV1
|
|
@@ -35,6 +37,10 @@ export interface OperationDef {
|
|
|
35
37
|
readonly idempotency?: IdempotencyConfig
|
|
36
38
|
/** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
|
|
37
39
|
readonly classification?: DataClassification
|
|
40
|
+
/** Inline route-assurance evidence this operation carries (see {@link RouteSchema.assurance}). */
|
|
41
|
+
readonly assurance?: readonly string[]
|
|
42
|
+
/** Mark this operation a dynamic route family (see {@link RouteSchema.family}). */
|
|
43
|
+
readonly family?: boolean
|
|
38
44
|
/** Short summary (OpenAPI `summary`). */
|
|
39
45
|
readonly summary?: string
|
|
40
46
|
/** Longer description (OpenAPI `description`, CommonMark). */
|
|
@@ -68,21 +74,27 @@ type OpResponse<O extends OperationDef> = O extends { response: infer R extends
|
|
|
68
74
|
|
|
69
75
|
/** The body types of a contract op's declared **non-2xx** `responses` (schema-bearing ones), keyed by status. */
|
|
70
76
|
type OpErrorBodies<Rs extends Record<string, ResponseDef>> = {
|
|
71
|
-
|
|
77
|
+
// Non-2xx statuses, re-keyed from the contract's string keys ("404") to number literals (404) so
|
|
78
|
+
// the client's failure union discriminates on the numeric `status` it actually receives.
|
|
79
|
+
[K in keyof Rs as K extends `2${string}`
|
|
80
|
+
? never
|
|
81
|
+
: K extends `${infer N extends number}`
|
|
82
|
+
? N
|
|
83
|
+
: never]: Rs[K] extends {
|
|
72
84
|
schema: infer S extends StandardSchemaV1
|
|
73
85
|
}
|
|
74
86
|
? InferOutput<S>
|
|
75
87
|
: never
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
/** The client-visible error
|
|
79
|
-
*
|
|
90
|
+
/** The client-visible error bodies for a contract op as a status-keyed record (`{ 404: Body }`), or
|
|
91
|
+
* `unknown` when it declares none (mirrors an inline route with no `errors`). */
|
|
80
92
|
type OpErrors<O extends OperationDef> = O extends {
|
|
81
93
|
responses: infer Rs extends Record<string, ResponseDef>
|
|
82
94
|
}
|
|
83
|
-
? [
|
|
95
|
+
? [keyof OpErrorBodies<Rs>] extends [never]
|
|
84
96
|
? unknown
|
|
85
|
-
: OpErrorBodies<Rs>
|
|
97
|
+
: OpErrorBodies<Rs>
|
|
86
98
|
: unknown
|
|
87
99
|
|
|
88
100
|
/**
|
|
@@ -116,7 +128,8 @@ export type RegistryFor<C extends ContractShape> = {
|
|
|
116
128
|
type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends StandardSchemaV1 }
|
|
117
129
|
? { body: B }
|
|
118
130
|
: Record<never, never>) &
|
|
119
|
-
(O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>)
|
|
131
|
+
(O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>) &
|
|
132
|
+
(O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
|
|
120
133
|
|
|
121
134
|
/**
|
|
122
135
|
* The handler context for an op — identical to the inline `Context<Path, S>`, so a
|
|
@@ -251,19 +264,25 @@ export function implement<
|
|
|
251
264
|
// once here at bind time, not per request — it just carries a reference to the op's existing schema.
|
|
252
265
|
// A response-less op still yields `undefined`, byte-identical to before.
|
|
253
266
|
const schema: RouteSchema | undefined =
|
|
267
|
+
op.params !== undefined ||
|
|
254
268
|
op.body !== undefined ||
|
|
255
269
|
op.query !== undefined ||
|
|
256
270
|
op.response !== undefined ||
|
|
257
271
|
op.capabilities !== undefined ||
|
|
258
272
|
op.idempotency !== undefined ||
|
|
259
|
-
op.classification !== undefined
|
|
273
|
+
op.classification !== undefined ||
|
|
274
|
+
op.assurance !== undefined ||
|
|
275
|
+
op.family !== undefined
|
|
260
276
|
? {
|
|
277
|
+
...(op.params !== undefined ? { params: op.params } : {}),
|
|
261
278
|
...(op.body !== undefined ? { body: op.body } : {}),
|
|
262
279
|
...(op.query !== undefined ? { query: op.query } : {}),
|
|
263
280
|
...(op.response !== undefined ? { response: op.response } : {}),
|
|
264
281
|
...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
|
|
265
282
|
...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
|
|
266
283
|
...(op.classification !== undefined ? { classification: op.classification } : {}),
|
|
284
|
+
...(op.assurance !== undefined ? { assurance: op.assurance } : {}),
|
|
285
|
+
...(op.family !== undefined ? { family: op.family } : {}),
|
|
267
286
|
}
|
|
268
287
|
: undefined
|
|
269
288
|
return {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free HTTP helpers shared by the kernel and the opt-in request lanes (idempotency,
|
|
3
|
+
* effect-ledger). Kept in a leaf module so a lane can reuse them without importing the server, and so
|
|
4
|
+
* the server can reuse them without pulling a lane's feature code into the base bundle.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A uniform JSON error envelope: `{ ok: false, error }` at the given status. */
|
|
8
|
+
export function jsonError(
|
|
9
|
+
status: number,
|
|
10
|
+
error: string,
|
|
11
|
+
headers?: Record<string, string>,
|
|
12
|
+
): Response {
|
|
13
|
+
return Response.json(
|
|
14
|
+
{ ok: false, error },
|
|
15
|
+
headers !== undefined ? { status, headers } : { status },
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UrlParts {
|
|
20
|
+
readonly pathname: string
|
|
21
|
+
readonly search: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract pathname + query WITHOUT a full WHATWG `new URL(req.url)` parse.
|
|
25
|
+
// `req.url` from every supported runtime is an absolute, already-normalized URL, so the pathname is
|
|
26
|
+
// the substring after `scheme://host[:port]` up to `?`/`#`. Query-schema routes also need the search
|
|
27
|
+
// string; parsing both in one scanner avoids the old `pathnameOf()` + `searchOf()` double scan.
|
|
28
|
+
export function urlPartsOf(url: string): UrlParts {
|
|
29
|
+
const schemeEnd = url.indexOf("://")
|
|
30
|
+
const start = schemeEnd === -1 ? url.indexOf("/") : url.indexOf("/", schemeEnd + 3)
|
|
31
|
+
if (start === -1) return { pathname: "/", search: "" }
|
|
32
|
+
|
|
33
|
+
let pathEnd = url.length
|
|
34
|
+
let searchStart = -1
|
|
35
|
+
let searchEnd = url.length
|
|
36
|
+
for (let i = start; i < url.length; i++) {
|
|
37
|
+
const c = url.charCodeAt(i)
|
|
38
|
+
if (c === 63 /* ? */ && searchStart === -1) {
|
|
39
|
+
pathEnd = i
|
|
40
|
+
searchStart = i
|
|
41
|
+
} else if (c === 35 /* # */) {
|
|
42
|
+
if (searchStart === -1) pathEnd = i
|
|
43
|
+
searchEnd = i
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
pathname: url.slice(start, pathEnd),
|
|
50
|
+
search: searchStart === -1 ? "" : url.slice(searchStart, searchEnd),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract the pathname WITHOUT a full WHATWG `new URL(req.url)` parse. Kept as a public-ish helper
|
|
55
|
+
// for tests and callers that only need the path; the request hot path uses `urlPartsOf()` once.
|
|
56
|
+
export function pathnameOf(url: string): string {
|
|
57
|
+
return urlPartsOf(url).pathname
|
|
58
|
+
}
|