@nifrajs/core 4.0.2 → 5.1.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 +6 -2
- package/dist/assurance.d.ts +86 -6
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +186 -95
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +53 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +8 -3
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +21 -6
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +139 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +45 -10
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +67 -32
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +481 -334
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +33 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +47 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +15 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +4 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +55 -6
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +4 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +299 -111
- package/src/capabilities.ts +62 -1
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +8 -3
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +7 -2
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +98 -44
- package/src/internal/same-origin.ts +14 -1
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body-lane.ts +36 -1
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +8 -5
- package/src/server/context.ts +2 -1
- package/src/server/cookies.ts +12 -1
- package/src/server/edge.ts +2 -1
- package/src/server/http.ts +7 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-contract-lane.ts +162 -10
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +45 -10
- package/src/server/server.ts +707 -539
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +83 -18
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +14 -2
- package/src/server.ts +6 -1
- package/src/single-copy.ts +55 -5
- package/src/tool-contract.ts +67 -39
- package/src/wire.ts +14 -0
- package/src/ws.ts +6 -0
package/src/assurance.ts
CHANGED
|
@@ -48,6 +48,80 @@ export { withRouteAssurance }
|
|
|
48
48
|
/** Isolated request executor used by adversarial contract verification. */
|
|
49
49
|
export type InvariantExecutor = (request: Request) => Response | Promise<Response>
|
|
50
50
|
|
|
51
|
+
export type ContractTarget = "headers" | "cookies" | "params" | "body" | "query" | "response"
|
|
52
|
+
export type ContractCaseKind = "input-rejection" | "response-conformance"
|
|
53
|
+
export type ContractAuthContext = "authenticated" | "unauthenticated"
|
|
54
|
+
|
|
55
|
+
export interface ContractExpectedResponse {
|
|
56
|
+
readonly status: number
|
|
57
|
+
readonly contentType?: "json" | "text"
|
|
58
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
59
|
+
/** When present, the decoded body is compared byte-structure-wise to this value. */
|
|
60
|
+
readonly body?: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A known-good request. Missing body/query values are synthesized from inspectable JSON Schema. */
|
|
64
|
+
export interface ContractWitness {
|
|
65
|
+
readonly params?: Readonly<Record<string, string>>
|
|
66
|
+
readonly query?: unknown
|
|
67
|
+
readonly body?: unknown
|
|
68
|
+
readonly headers?: ConstructorParameters<typeof Headers>[0]
|
|
69
|
+
/** Explicit auth state for the prepareRequest hook; the harness never invents credentials. */
|
|
70
|
+
readonly auth?: ContractAuthContext
|
|
71
|
+
/** Expected response for a guard/error witness. Omit for the normal success witness. */
|
|
72
|
+
readonly expected?: ContractExpectedResponse
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stable context passed to request/rejection hooks. It contains no request payloads or secrets. */
|
|
76
|
+
export interface ContractCaseContext {
|
|
77
|
+
readonly seed: number
|
|
78
|
+
readonly caseId: string
|
|
79
|
+
readonly route: ReflectedRoute
|
|
80
|
+
readonly runtime: string
|
|
81
|
+
readonly kind: ContractCaseKind
|
|
82
|
+
readonly target: ContractTarget
|
|
83
|
+
readonly auth?: ContractAuthContext
|
|
84
|
+
readonly mutation?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `invariants.setup` provisions for one L4 run. */
|
|
88
|
+
export interface InvariantSetupResult {
|
|
89
|
+
/** The isolated target for this run. Replaces `invariants.executor`. */
|
|
90
|
+
readonly executor?: InvariantExecutor
|
|
91
|
+
/** Called once the run ends, after a failure too. */
|
|
92
|
+
readonly teardown?: () => void | Promise<void>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Provisions a throwaway L4 target. Called once, before the first L4 request - never on import. */
|
|
96
|
+
export type InvariantSetup = () => Promise<InvariantSetupResult | undefined | void>
|
|
97
|
+
|
|
98
|
+
/** Fixture options handed to the L4 contract runner (`runAdversarialContract` in `@nifrajs/testing`). */
|
|
99
|
+
export interface InvariantRunOptions {
|
|
100
|
+
/** Known-good request values keyed by `"METHOD /path"`. Required for opaque Standard Schemas. */
|
|
101
|
+
readonly witnesses?: Readonly<Record<string, ContractWitness>>
|
|
102
|
+
/** Attach a session, real row ids or signed tokens. Return a new Request. */
|
|
103
|
+
readonly prepareRequest?: (
|
|
104
|
+
request: Request,
|
|
105
|
+
context: ContractCaseContext,
|
|
106
|
+
) => Request | Promise<Request>
|
|
107
|
+
/** Override rejection semantics, for an app with a custom validation response. */
|
|
108
|
+
readonly isRejected?: (
|
|
109
|
+
response: Response,
|
|
110
|
+
context: ContractCaseContext,
|
|
111
|
+
) => boolean | Promise<boolean>
|
|
112
|
+
/** Run L4 on a subset of the reflected routes. */
|
|
113
|
+
readonly include?: (route: ReflectedRoute) => boolean
|
|
114
|
+
/** Maximum validator-proven mutations per body/query schema. Default 32. */
|
|
115
|
+
readonly maxMutationsPerInput?: number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** L4 configuration: an isolated `executor`, a `setup` that provisions one, or both. */
|
|
119
|
+
export type InvariantsConfig = InvariantRunOptions &
|
|
120
|
+
(
|
|
121
|
+
| { readonly executor: InvariantExecutor; readonly setup?: InvariantSetup }
|
|
122
|
+
| { readonly executor?: InvariantExecutor; readonly setup: InvariantSetup }
|
|
123
|
+
)
|
|
124
|
+
|
|
51
125
|
/** Canonical evidence ids emitted by Nifra's official middleware modules. */
|
|
52
126
|
export const NIFRA_ASSURANCE = NIFRA_ASSURANCE_IDS
|
|
53
127
|
|
|
@@ -103,8 +177,14 @@ export function assure(
|
|
|
103
177
|
}
|
|
104
178
|
|
|
105
179
|
export interface AssuranceRouteSelector {
|
|
106
|
-
/** Omit for every method. */
|
|
107
|
-
readonly methods?: readonly Method[]
|
|
180
|
+
/** Omit for every method. `"WS"` selects WebSocket routes. */
|
|
181
|
+
readonly methods?: readonly (Method | "WS")[]
|
|
182
|
+
/**
|
|
183
|
+
* Skip routes under these methods; `"WS"` skips WebSocket routes. Every other method matches, a
|
|
184
|
+
* custom one such as `PURGE` included, so `["GET", "HEAD", "OPTIONS", "WS"]` selects every route
|
|
185
|
+
* that can change state.
|
|
186
|
+
*/
|
|
187
|
+
readonly exceptMethods?: readonly (Method | "WS")[]
|
|
108
188
|
/** Absolute route globs. `*` matches one segment; final `**` matches zero or more. */
|
|
109
189
|
readonly paths?: readonly string[]
|
|
110
190
|
/** Restrict the rule to MCP tool routes (`true`) or non-tool routes (`false`). */
|
|
@@ -147,6 +227,12 @@ export interface AssuranceRouteSelector {
|
|
|
147
227
|
* already carries `schema.body`, so this reads what the route declared, not where it lives.
|
|
148
228
|
*/
|
|
149
229
|
readonly hasBody?: boolean
|
|
230
|
+
/**
|
|
231
|
+
* Match routes by whether they declare a success `response` schema - the routes a response contract
|
|
232
|
+
* can hold to anything. Pair it with `require: ["nifra.response-contract"]` to say "every route
|
|
233
|
+
* that declares a response enforces it" without listing the routes that declare none.
|
|
234
|
+
*/
|
|
235
|
+
readonly hasResponse?: boolean
|
|
150
236
|
/**
|
|
151
237
|
* Match routes by their effective transport body policy. `"unlimited"` is the explicit
|
|
152
238
|
* streaming/upload exemption; `"bounded"` is any finite cap (an explicit number or the inherited
|
|
@@ -179,7 +265,9 @@ const usesClassSelector = (selector: AssuranceRouteSelector): boolean =>
|
|
|
179
265
|
|
|
180
266
|
/** The rules whose selector needs capability definitions to mean anything. */
|
|
181
267
|
function rulesNeedingDefinitions(policy: AssurancePolicy): readonly string[] {
|
|
182
|
-
return policy.rules
|
|
268
|
+
return [...policy.rules, ...(policy.baseline ?? [])]
|
|
269
|
+
.filter((rule) => usesClassSelector(rule.match))
|
|
270
|
+
.map((rule) => rule.name)
|
|
183
271
|
}
|
|
184
272
|
|
|
185
273
|
function definitionMap(
|
|
@@ -214,6 +302,13 @@ export interface AssuranceRule {
|
|
|
214
302
|
export interface AssurancePolicy {
|
|
215
303
|
/** First matching rule owns a route. Put exceptions before broad defaults. */
|
|
216
304
|
readonly rules: readonly AssuranceRule[]
|
|
305
|
+
/**
|
|
306
|
+
* Rules checked on EVERY route they match, in addition to the first-match rule that owns it - for a
|
|
307
|
+
* requirement that holds across classes ("every route declaring a response enforces it"), which
|
|
308
|
+
* first-match-wins would otherwise make you repeat in each rule. A baseline rule does not classify a
|
|
309
|
+
* route: one no `rules` entry matches is still unclassified. Names share one namespace with `rules`.
|
|
310
|
+
*/
|
|
311
|
+
readonly baseline?: readonly AssuranceRule[]
|
|
217
312
|
/** Default `error`: an unclassified route fails closed. */
|
|
218
313
|
readonly unmatched?: "error" | "ignore"
|
|
219
314
|
/** Default false: reject an empty reflected source so a wrong import cannot pass CI silently. */
|
|
@@ -292,9 +387,7 @@ export interface AssuranceConfig {
|
|
|
292
387
|
readonly signer?: (keyRef: string) => NifraManifestSigner | Promise<NifraManifestSigner>
|
|
293
388
|
}
|
|
294
389
|
/** Dynamic invariant execution is opt-in and must use a disposable/sandboxed target. */
|
|
295
|
-
readonly invariants?:
|
|
296
|
-
readonly executor: InvariantExecutor
|
|
297
|
-
}
|
|
390
|
+
readonly invariants?: InvariantsConfig
|
|
298
391
|
/** Optional application-supplied rule packs appended after built-in verification rules. */
|
|
299
392
|
readonly rulePacks?: readonly AssuranceRulePack[]
|
|
300
393
|
/** Optional sink for an assurance bundle. The CLI validates the callable shape at runtime. */
|
|
@@ -307,6 +400,23 @@ export interface AssuranceConfig {
|
|
|
307
400
|
|
|
308
401
|
const nonEmpty = (value: string): boolean => value.trim() !== ""
|
|
309
402
|
|
|
403
|
+
/** A selector's method list, upper-cased. Standard methods and `"WS"` only, so a typo throws rather
|
|
404
|
+
* than leaving a rule that never matches. */
|
|
405
|
+
function selectorMethods(
|
|
406
|
+
values: readonly string[] | undefined,
|
|
407
|
+
): readonly (Method | "WS")[] | undefined {
|
|
408
|
+
if (values === undefined) return undefined
|
|
409
|
+
return Object.freeze(
|
|
410
|
+
values.map((value): Method | "WS" => {
|
|
411
|
+
const method = value.toUpperCase()
|
|
412
|
+
if (method !== "WS" && !validMethod(method)) {
|
|
413
|
+
throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
|
|
414
|
+
}
|
|
415
|
+
return method
|
|
416
|
+
}),
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
310
420
|
function normalizeEvidenceIds(
|
|
311
421
|
values: readonly string[] | undefined,
|
|
312
422
|
label: string,
|
|
@@ -343,8 +453,11 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
343
453
|
) {
|
|
344
454
|
throw new Error(`route assurance: flagClassifiedWithoutEvidence must be boolean`)
|
|
345
455
|
}
|
|
456
|
+
if (policy.baseline !== undefined && !Array.isArray(policy.baseline)) {
|
|
457
|
+
throw new Error("route assurance: baseline must be an array of rules")
|
|
458
|
+
}
|
|
346
459
|
const names = new Set<string>()
|
|
347
|
-
const
|
|
460
|
+
const normalize = (rule: AssuranceRule): AssuranceRule => {
|
|
348
461
|
if (typeof rule.name !== "string" || !nonEmpty(rule.name)) {
|
|
349
462
|
throw new Error("route assurance: rule name must be non-empty")
|
|
350
463
|
}
|
|
@@ -359,12 +472,14 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
359
472
|
(key) =>
|
|
360
473
|
![
|
|
361
474
|
"methods",
|
|
475
|
+
"exceptMethods",
|
|
362
476
|
"paths",
|
|
363
477
|
"tools",
|
|
364
478
|
"capabilities",
|
|
365
479
|
...CLASS_SELECTOR_KEYS,
|
|
366
480
|
"classificationAtLeast",
|
|
367
481
|
"hasBody",
|
|
482
|
+
"hasResponse",
|
|
368
483
|
"bodyLimit",
|
|
369
484
|
].includes(key),
|
|
370
485
|
)
|
|
@@ -380,10 +495,12 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
380
495
|
`route assurance: rule ${JSON.stringify(name)} tools selector must be boolean`,
|
|
381
496
|
)
|
|
382
497
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
498
|
+
for (const key of ["hasBody", "hasResponse"] as const) {
|
|
499
|
+
if (rule.match[key] !== undefined && typeof rule.match[key] !== "boolean") {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`route assurance: rule ${JSON.stringify(name)} ${key} selector must be boolean`,
|
|
502
|
+
)
|
|
503
|
+
}
|
|
387
504
|
}
|
|
388
505
|
if (
|
|
389
506
|
rule.match.bodyLimit !== undefined &&
|
|
@@ -397,7 +514,7 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
397
514
|
}
|
|
398
515
|
// An empty list matches nothing, so the rule is inert and its routes fall through to whatever
|
|
399
516
|
// laxer rule comes next - the same hole as a dropped key, the other way round.
|
|
400
|
-
for (const key of ["methods", "paths"] as const) {
|
|
517
|
+
for (const key of ["methods", "exceptMethods", "paths"] as const) {
|
|
401
518
|
const list = rule.match[key]
|
|
402
519
|
if (list !== undefined && (!Array.isArray(list) || list.length === 0)) {
|
|
403
520
|
throw new Error(
|
|
@@ -405,12 +522,8 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
405
522
|
)
|
|
406
523
|
}
|
|
407
524
|
}
|
|
408
|
-
const methods = rule.match.methods
|
|
409
|
-
|
|
410
|
-
if (!validMethod(method)) {
|
|
411
|
-
throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
525
|
+
const methods = selectorMethods(rule.match.methods)
|
|
526
|
+
const exceptMethods = selectorMethods(rule.match.exceptMethods)
|
|
414
527
|
const paths = rule.match.paths?.map((path) => {
|
|
415
528
|
routeGlob(path)
|
|
416
529
|
return path
|
|
@@ -480,21 +593,23 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
480
593
|
`route assurance: rule ${JSON.stringify(name)} both requires and forbids ${overlap}`,
|
|
481
594
|
)
|
|
482
595
|
}
|
|
596
|
+
const match: { -readonly [K in keyof AssuranceRouteSelector]: AssuranceRouteSelector[K] } = {}
|
|
597
|
+
if (methods !== undefined) match.methods = methods
|
|
598
|
+
if (exceptMethods !== undefined) match.exceptMethods = exceptMethods
|
|
599
|
+
if (paths !== undefined) match.paths = Object.freeze(paths)
|
|
600
|
+
if (rule.match.tools !== undefined) match.tools = rule.match.tools
|
|
601
|
+
if (capabilities !== undefined) match.capabilities = Object.freeze([...capabilities])
|
|
602
|
+
if (access !== undefined) match.access = access
|
|
603
|
+
if (zone !== undefined) match.zone = zone
|
|
604
|
+
if (rule.match.classificationAtLeast !== undefined) {
|
|
605
|
+
match.classificationAtLeast = rule.match.classificationAtLeast
|
|
606
|
+
}
|
|
607
|
+
if (rule.match.hasBody !== undefined) match.hasBody = rule.match.hasBody
|
|
608
|
+
if (rule.match.hasResponse !== undefined) match.hasResponse = rule.match.hasResponse
|
|
609
|
+
if (rule.match.bodyLimit !== undefined) match.bodyLimit = rule.match.bodyLimit
|
|
483
610
|
return Object.freeze({
|
|
484
611
|
name,
|
|
485
|
-
match: Object.freeze(
|
|
486
|
-
...(methods !== undefined ? { methods: Object.freeze(methods as Method[]) } : {}),
|
|
487
|
-
...(paths !== undefined ? { paths: Object.freeze(paths) } : {}),
|
|
488
|
-
...(rule.match.tools !== undefined ? { tools: rule.match.tools } : {}),
|
|
489
|
-
...(capabilities !== undefined ? { capabilities: Object.freeze([...capabilities]) } : {}),
|
|
490
|
-
...(access !== undefined ? { access } : {}),
|
|
491
|
-
...(zone !== undefined ? { zone } : {}),
|
|
492
|
-
...(rule.match.classificationAtLeast !== undefined
|
|
493
|
-
? { classificationAtLeast: rule.match.classificationAtLeast }
|
|
494
|
-
: {}),
|
|
495
|
-
...(rule.match.hasBody !== undefined ? { hasBody: rule.match.hasBody } : {}),
|
|
496
|
-
...(rule.match.bodyLimit !== undefined ? { bodyLimit: rule.match.bodyLimit } : {}),
|
|
497
|
-
}),
|
|
612
|
+
match: Object.freeze(match),
|
|
498
613
|
require: required,
|
|
499
614
|
forbid: forbidden,
|
|
500
615
|
...(rule.requireProvenance !== undefined
|
|
@@ -502,15 +617,75 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
502
617
|
: {}),
|
|
503
618
|
...(rule.requireCsrfWithAuthenticated === true ? { requireCsrfWithAuthenticated: true } : {}),
|
|
504
619
|
})
|
|
505
|
-
}
|
|
620
|
+
}
|
|
621
|
+
const rules = policy.rules.map(normalize)
|
|
622
|
+
const baseline = (policy.baseline ?? []).map(normalize)
|
|
506
623
|
return Object.freeze({
|
|
507
624
|
rules: Object.freeze(rules),
|
|
625
|
+
...(baseline.length > 0 ? { baseline: Object.freeze(baseline) } : {}),
|
|
508
626
|
unmatched: policy.unmatched ?? "error",
|
|
509
627
|
allowEmpty: policy.allowEmpty ?? false,
|
|
510
628
|
flagClassifiedWithoutEvidence: policy.flagClassifiedWithoutEvidence ?? false,
|
|
511
629
|
})
|
|
512
630
|
}
|
|
513
631
|
|
|
632
|
+
const INVARIANT_FUNCTIONS = [
|
|
633
|
+
"executor",
|
|
634
|
+
"setup",
|
|
635
|
+
"prepareRequest",
|
|
636
|
+
"isRejected",
|
|
637
|
+
"include",
|
|
638
|
+
] as const
|
|
639
|
+
|
|
640
|
+
function assertInvariantsConfig(invariants: InvariantsConfig): void {
|
|
641
|
+
if (invariants === null || typeof invariants !== "object") {
|
|
642
|
+
throw new TypeError("route assurance: invariants must be an object")
|
|
643
|
+
}
|
|
644
|
+
for (const name of INVARIANT_FUNCTIONS) {
|
|
645
|
+
const value: unknown = invariants[name]
|
|
646
|
+
if (value !== undefined && typeof value !== "function") {
|
|
647
|
+
throw new TypeError(
|
|
648
|
+
name === "executor"
|
|
649
|
+
? "route assurance: invariant executor must be a function"
|
|
650
|
+
: `route assurance: invariants.${name} must be a function`,
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (invariants.executor === undefined && invariants.setup === undefined) {
|
|
655
|
+
throw new TypeError("route assurance: invariants needs an executor or a setup")
|
|
656
|
+
}
|
|
657
|
+
const witnesses: unknown = invariants.witnesses
|
|
658
|
+
if (witnesses !== undefined) {
|
|
659
|
+
if (witnesses === null || typeof witnesses !== "object" || Array.isArray(witnesses)) {
|
|
660
|
+
throw new TypeError(
|
|
661
|
+
'route assurance: invariants.witnesses must map "METHOD /path" to a witness',
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
for (const [key, witness] of Object.entries(witnesses)) {
|
|
665
|
+
if (witness === null || typeof witness !== "object" || Array.isArray(witness)) {
|
|
666
|
+
throw new TypeError(
|
|
667
|
+
`route assurance: invariants.witnesses[${JSON.stringify(key)}] must be an object`,
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const max = invariants.maxMutationsPerInput
|
|
673
|
+
if (max !== undefined && (!Number.isSafeInteger(max) || max < 1)) {
|
|
674
|
+
throw new RangeError(
|
|
675
|
+
"route assurance: invariants.maxMutationsPerInput must be a positive integer",
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function frozenInvariantsConfig(invariants: InvariantsConfig): InvariantsConfig {
|
|
681
|
+
const witnesses = invariants.witnesses
|
|
682
|
+
return Object.freeze(
|
|
683
|
+
witnesses === undefined
|
|
684
|
+
? { ...invariants }
|
|
685
|
+
: { ...invariants, witnesses: Object.freeze({ ...witnesses }) },
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
514
689
|
/** Identity helper for a `nifra.assurance.ts` default export. */
|
|
515
690
|
export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig {
|
|
516
691
|
if (config.manifest?.path !== undefined && config.manifest.path.trim() === "") {
|
|
@@ -519,9 +694,7 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
|
|
|
519
694
|
if (config.manifest?.signer !== undefined && typeof config.manifest.signer !== "function") {
|
|
520
695
|
throw new Error("route assurance: manifest signer must be a function")
|
|
521
696
|
}
|
|
522
|
-
if (config.invariants !== undefined
|
|
523
|
-
throw new Error("route assurance: invariant executor must be a function")
|
|
524
|
-
}
|
|
697
|
+
if (config.invariants !== undefined) assertInvariantsConfig(config.invariants)
|
|
525
698
|
if (config.rulePacks !== undefined) {
|
|
526
699
|
if (!Array.isArray(config.rulePacks))
|
|
527
700
|
throw new TypeError("route assurance: rulePacks must be an array")
|
|
@@ -584,7 +757,7 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
|
|
|
584
757
|
// that passes capability assurance, so the rule would match nothing.
|
|
585
758
|
if (capabilities !== undefined) {
|
|
586
759
|
const defined = new Set(capabilities.definitions.map((definition) => definition.id))
|
|
587
|
-
for (const rule of policy.rules) {
|
|
760
|
+
for (const rule of [...policy.rules, ...(policy.baseline ?? [])]) {
|
|
588
761
|
const undefinedToken = rule.match.capabilities?.find((token) => !defined.has(token))
|
|
589
762
|
if (undefinedToken !== undefined) {
|
|
590
763
|
throw new Error(
|
|
@@ -616,7 +789,7 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
|
|
|
616
789
|
}
|
|
617
790
|
: {}),
|
|
618
791
|
...(config.invariants !== undefined
|
|
619
|
-
? { invariants:
|
|
792
|
+
? { invariants: frozenInvariantsConfig(config.invariants) }
|
|
620
793
|
: {}),
|
|
621
794
|
...(config.rulePacks !== undefined ? { rulePacks: Object.freeze([...config.rulePacks]) } : {}),
|
|
622
795
|
...(config.assureSink !== undefined ? { assureSink: config.assureSink } : {}),
|
|
@@ -656,12 +829,15 @@ export function matchesAssuranceSelector(
|
|
|
656
829
|
definitions?: ReadonlyMap<string, CapabilityDefinition>,
|
|
657
830
|
): boolean {
|
|
658
831
|
const { methods, paths, tools, capabilities, access, zone, classificationAtLeast } = selector
|
|
659
|
-
const { hasBody, bodyLimit } = selector
|
|
660
|
-
if (methods !== undefined && !methods.includes(route.method as Method)) return false
|
|
832
|
+
const { exceptMethods, hasBody, hasResponse, bodyLimit } = selector
|
|
833
|
+
if (methods !== undefined && !methods.includes(route.method as Method | "WS")) return false
|
|
834
|
+
if (exceptMethods?.some((method) => method === route.method) === true) return false
|
|
661
835
|
if (paths !== undefined && !paths.some((pattern) => routeGlob(pattern).test(route.path)))
|
|
662
836
|
return false
|
|
663
837
|
if (tools !== undefined && (route.tool !== undefined) !== tools) return false
|
|
664
838
|
if (hasBody !== undefined && (route.schema?.body !== undefined) !== hasBody) return false
|
|
839
|
+
if (hasResponse !== undefined && (route.schema?.response !== undefined) !== hasResponse)
|
|
840
|
+
return false
|
|
665
841
|
if (bodyLimit !== undefined && effectiveBodyLimit(route.schema) !== bodyLimit) return false
|
|
666
842
|
if (
|
|
667
843
|
classificationAtLeast !== undefined &&
|
|
@@ -687,7 +863,61 @@ export function matchesAssuranceSelector(
|
|
|
687
863
|
return true
|
|
688
864
|
}
|
|
689
865
|
|
|
690
|
-
/**
|
|
866
|
+
/** One rule's missing and forbidden evidence on one route, each also reported as a finding. */
|
|
867
|
+
function checkAssuranceRule(
|
|
868
|
+
rule: AssuranceRule,
|
|
869
|
+
route: Pick<ReflectedRoute, "method" | "path">,
|
|
870
|
+
evidence: readonly AssuranceEvidence[],
|
|
871
|
+
evidenceIds: ReadonlySet<string>,
|
|
872
|
+
findings: AssuranceFinding[],
|
|
873
|
+
): { readonly missing: readonly string[]; readonly forbidden: readonly string[] } {
|
|
874
|
+
const requiredProvenance = rule.requireProvenance ?? "any"
|
|
875
|
+
const missing = (rule.require ?? []).filter((id) => {
|
|
876
|
+
if (!evidenceIds.has(id)) return true
|
|
877
|
+
if (requiredProvenance === "any") return false
|
|
878
|
+
return !evidence.some(
|
|
879
|
+
(item) => item.id === id && evidenceProvenance(item) === requiredProvenance,
|
|
880
|
+
)
|
|
881
|
+
})
|
|
882
|
+
const forbidden = (rule.forbid ?? []).filter((id) => evidenceIds.has(id))
|
|
883
|
+
if (
|
|
884
|
+
rule.requireCsrfWithAuthenticated === true &&
|
|
885
|
+
evidenceIds.has(NIFRA_ASSURANCE_IDS.AUTHENTICATED) &&
|
|
886
|
+
!evidence.some(
|
|
887
|
+
(item) => item.id === NIFRA_ASSURANCE_IDS.CSRF && evidenceProvenance(item) === "runtime",
|
|
888
|
+
) &&
|
|
889
|
+
!missing.includes(NIFRA_ASSURANCE_IDS.CSRF)
|
|
890
|
+
) {
|
|
891
|
+
missing.push(NIFRA_ASSURANCE_IDS.CSRF)
|
|
892
|
+
}
|
|
893
|
+
for (const id of missing) {
|
|
894
|
+
findings.push({
|
|
895
|
+
code: "missing-evidence",
|
|
896
|
+
method: route.method,
|
|
897
|
+
path: route.path,
|
|
898
|
+
rule: rule.name,
|
|
899
|
+
evidence: id,
|
|
900
|
+
message:
|
|
901
|
+
requiredProvenance === "any"
|
|
902
|
+
? `${route.method} ${route.path} (${rule.name}) is missing ${id}`
|
|
903
|
+
: `${route.method} ${route.path} (${rule.name}) is missing ${requiredProvenance} evidence for ${id}`,
|
|
904
|
+
})
|
|
905
|
+
}
|
|
906
|
+
for (const id of forbidden) {
|
|
907
|
+
findings.push({
|
|
908
|
+
code: "forbidden-evidence",
|
|
909
|
+
method: route.method,
|
|
910
|
+
path: route.path,
|
|
911
|
+
rule: rule.name,
|
|
912
|
+
evidence: id,
|
|
913
|
+
message: `${route.method} ${route.path} (${rule.name}) carries forbidden ${id}`,
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
return { missing, forbidden }
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Evaluate reflected route evidence against the first matching policy rule, plus every matching
|
|
920
|
+
* baseline rule. */
|
|
691
921
|
export function evaluateRouteAssurance(
|
|
692
922
|
source: unknown,
|
|
693
923
|
policyInput: AssurancePolicy,
|
|
@@ -726,6 +956,13 @@ export function evaluateRouteAssurance(
|
|
|
726
956
|
)
|
|
727
957
|
const evidence = route.assurance ?? []
|
|
728
958
|
const evidenceIds = new Set(evidence.map((item) => item.id))
|
|
959
|
+
const missing: string[] = []
|
|
960
|
+
const forbidden: string[] = []
|
|
961
|
+
const hold = (candidate: AssuranceRule): void => {
|
|
962
|
+
const outcome = checkAssuranceRule(candidate, route, evidence, evidenceIds, findings)
|
|
963
|
+
for (const id of outcome.missing) if (!missing.includes(id)) missing.push(id)
|
|
964
|
+
for (const id of outcome.forbidden) if (!forbidden.includes(id)) forbidden.push(id)
|
|
965
|
+
}
|
|
729
966
|
if (rule === undefined) {
|
|
730
967
|
if (policy.unmatched !== "ignore") {
|
|
731
968
|
findings.push({
|
|
@@ -735,82 +972,33 @@ export function evaluateRouteAssurance(
|
|
|
735
972
|
message: `${route.method} ${route.path} is not classified by an assurance rule`,
|
|
736
973
|
})
|
|
737
974
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
)
|
|
758
|
-
})
|
|
759
|
-
const forbidden = (rule.forbid ?? []).filter((id) => evidenceIds.has(id))
|
|
760
|
-
if (
|
|
761
|
-
rule.requireCsrfWithAuthenticated === true &&
|
|
762
|
-
evidenceIds.has(NIFRA_ASSURANCE_IDS.AUTHENTICATED) &&
|
|
763
|
-
!evidence.some(
|
|
764
|
-
(item) => item.id === NIFRA_ASSURANCE_IDS.CSRF && evidenceProvenance(item) === "runtime",
|
|
765
|
-
)
|
|
766
|
-
) {
|
|
767
|
-
const id = NIFRA_ASSURANCE_IDS.CSRF
|
|
768
|
-
if (!missing.includes(id)) missing = [...missing, id]
|
|
769
|
-
}
|
|
770
|
-
for (const id of missing) {
|
|
771
|
-
findings.push({
|
|
772
|
-
code: "missing-evidence",
|
|
773
|
-
method: route.method,
|
|
774
|
-
path: route.path,
|
|
775
|
-
rule: rule.name,
|
|
776
|
-
evidence: id,
|
|
777
|
-
message:
|
|
778
|
-
requiredProvenance === "any"
|
|
779
|
-
? `${route.method} ${route.path} (${rule.name}) is missing ${id}`
|
|
780
|
-
: `${route.method} ${route.path} (${rule.name}) is missing ${requiredProvenance} evidence for ${id}`,
|
|
781
|
-
})
|
|
782
|
-
}
|
|
783
|
-
for (const id of forbidden) {
|
|
784
|
-
findings.push({
|
|
785
|
-
code: "forbidden-evidence",
|
|
786
|
-
method: route.method,
|
|
787
|
-
path: route.path,
|
|
788
|
-
rule: rule.name,
|
|
789
|
-
evidence: id,
|
|
790
|
-
message: `${route.method} ${route.path} (${rule.name}) carries forbidden ${id}`,
|
|
791
|
-
})
|
|
975
|
+
} else {
|
|
976
|
+
hold(rule)
|
|
977
|
+
// Opt-in visibility: a pure-classification rule (no require + no forbid) matching a route with
|
|
978
|
+
// zero evidence is a "label without proof". Surface it so the gap isn't silently green. A rule
|
|
979
|
+
// that expects or forbids evidence already speaks for itself, so it's excluded here.
|
|
980
|
+
if (
|
|
981
|
+
policy.flagClassifiedWithoutEvidence === true &&
|
|
982
|
+
evidence.length === 0 &&
|
|
983
|
+
(rule.require ?? []).length === 0 &&
|
|
984
|
+
(rule.forbid ?? []).length === 0
|
|
985
|
+
) {
|
|
986
|
+
findings.push({
|
|
987
|
+
code: "classified-no-evidence",
|
|
988
|
+
method: route.method,
|
|
989
|
+
path: route.path,
|
|
990
|
+
rule: rule.name,
|
|
991
|
+
message: `${route.method} ${route.path} (${rule.name}) is classified but carries no enforcement evidence`,
|
|
992
|
+
})
|
|
993
|
+
}
|
|
792
994
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
// or forbids evidence already speaks for itself via the loops above, so it's excluded here.
|
|
796
|
-
if (
|
|
797
|
-
policy.flagClassifiedWithoutEvidence === true &&
|
|
798
|
-
evidence.length === 0 &&
|
|
799
|
-
(rule.require ?? []).length === 0 &&
|
|
800
|
-
(rule.forbid ?? []).length === 0
|
|
801
|
-
) {
|
|
802
|
-
findings.push({
|
|
803
|
-
code: "classified-no-evidence",
|
|
804
|
-
method: route.method,
|
|
805
|
-
path: route.path,
|
|
806
|
-
rule: rule.name,
|
|
807
|
-
message: `${route.method} ${route.path} (${rule.name}) is classified but carries no enforcement evidence`,
|
|
808
|
-
})
|
|
995
|
+
for (const candidate of policy.baseline ?? []) {
|
|
996
|
+
if (matchesAssuranceSelector(route, candidate.match, definitions)) hold(candidate)
|
|
809
997
|
}
|
|
810
998
|
routes.push({
|
|
811
999
|
method: route.method,
|
|
812
1000
|
path: route.path,
|
|
813
|
-
rule: rule.name,
|
|
1001
|
+
...(rule === undefined ? {} : { rule: rule.name }),
|
|
814
1002
|
evidence,
|
|
815
1003
|
missing: Object.freeze(missing),
|
|
816
1004
|
forbidden: Object.freeze(forbidden),
|