@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.
Files changed (239) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +86 -6
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +186 -95
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +8 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +53 -1
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +8 -3
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-compiler.d.ts +9 -0
  49. package/dist/internal/route-compiler.d.ts.map +1 -1
  50. package/dist/internal/route-compiler.js +95 -13
  51. package/dist/internal/route-compiler.js.map +1 -1
  52. package/dist/internal/route-program.d.ts +1 -1
  53. package/dist/internal/route-program.d.ts.map +1 -1
  54. package/dist/internal/route-program.js +59 -16
  55. package/dist/internal/route-program.js.map +1 -1
  56. package/dist/internal/same-origin.d.ts +11 -1
  57. package/dist/internal/same-origin.d.ts.map +1 -1
  58. package/dist/internal/same-origin.js +8 -0
  59. package/dist/internal/same-origin.js.map +1 -1
  60. package/dist/reconciliation-worker.d.ts.map +1 -1
  61. package/dist/reconciliation-worker.js +12 -2
  62. package/dist/reconciliation-worker.js.map +1 -1
  63. package/dist/reflection.d.ts +53 -2
  64. package/dist/reflection.d.ts.map +1 -1
  65. package/dist/reflection.js +352 -5
  66. package/dist/reflection.js.map +1 -1
  67. package/dist/router/pattern.d.ts +1 -1
  68. package/dist/router/pattern.d.ts.map +1 -1
  69. package/dist/router/pattern.js +6 -3
  70. package/dist/router/pattern.js.map +1 -1
  71. package/dist/security/baseline.d.ts +3 -0
  72. package/dist/security/baseline.d.ts.map +1 -1
  73. package/dist/security/baseline.js +9 -5
  74. package/dist/security/baseline.js.map +1 -1
  75. package/dist/server/auth.d.ts +32 -3
  76. package/dist/server/auth.d.ts.map +1 -1
  77. package/dist/server/auth.js +23 -0
  78. package/dist/server/auth.js.map +1 -1
  79. package/dist/server/body-lane.d.ts +8 -2
  80. package/dist/server/body-lane.d.ts.map +1 -1
  81. package/dist/server/body-lane.js +27 -2
  82. package/dist/server/body-lane.js.map +1 -1
  83. package/dist/server/body.d.ts +7 -0
  84. package/dist/server/body.d.ts.map +1 -1
  85. package/dist/server/body.js +34 -1
  86. package/dist/server/body.js.map +1 -1
  87. package/dist/server/client-ip.d.ts +3 -0
  88. package/dist/server/client-ip.d.ts.map +1 -1
  89. package/dist/server/client-ip.js +6 -5
  90. package/dist/server/client-ip.js.map +1 -1
  91. package/dist/server/context.d.ts +2 -1
  92. package/dist/server/context.d.ts.map +1 -1
  93. package/dist/server/cookies.d.ts.map +1 -1
  94. package/dist/server/cookies.js +13 -1
  95. package/dist/server/cookies.js.map +1 -1
  96. package/dist/server/edge.d.ts.map +1 -1
  97. package/dist/server/edge.js +2 -1
  98. package/dist/server/edge.js.map +1 -1
  99. package/dist/server/http.d.ts.map +1 -1
  100. package/dist/server/http.js +5 -1
  101. package/dist/server/http.js.map +1 -1
  102. package/dist/server/idempotency-lane.d.ts +6 -3
  103. package/dist/server/idempotency-lane.d.ts.map +1 -1
  104. package/dist/server/idempotency-lane.js +38 -17
  105. package/dist/server/idempotency-lane.js.map +1 -1
  106. package/dist/server/install.d.ts +2 -0
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +2 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/logger.d.ts.map +1 -1
  111. package/dist/server/logger.js +3 -1
  112. package/dist/server/logger.js.map +1 -1
  113. package/dist/server/methods.d.ts +1 -0
  114. package/dist/server/methods.d.ts.map +1 -1
  115. package/dist/server/methods.js +1 -0
  116. package/dist/server/methods.js.map +1 -1
  117. package/dist/server/node-outcome.d.ts.map +1 -1
  118. package/dist/server/node-outcome.js +29 -11
  119. package/dist/server/node-outcome.js.map +1 -1
  120. package/dist/server/platform-adapters.d.ts.map +1 -1
  121. package/dist/server/platform-adapters.js +3 -2
  122. package/dist/server/platform-adapters.js.map +1 -1
  123. package/dist/server/request-context.d.ts +2 -1
  124. package/dist/server/request-context.d.ts.map +1 -1
  125. package/dist/server/request-context.js +4 -1
  126. package/dist/server/request-context.js.map +1 -1
  127. package/dist/server/respond.d.ts +44 -11
  128. package/dist/server/respond.d.ts.map +1 -1
  129. package/dist/server/respond.js +192 -79
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +21 -6
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +139 -14
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  136. package/dist/server/response-observer-runtime.js +10 -41
  137. package/dist/server/response-observer-runtime.js.map +1 -1
  138. package/dist/server/runtime-core.d.ts +2 -0
  139. package/dist/server/runtime-core.d.ts.map +1 -1
  140. package/dist/server/runtime-core.js +2 -0
  141. package/dist/server/runtime-core.js.map +1 -1
  142. package/dist/server/server-types.d.ts +45 -10
  143. package/dist/server/server-types.d.ts.map +1 -1
  144. package/dist/server/server.d.ts +67 -32
  145. package/dist/server/server.d.ts.map +1 -1
  146. package/dist/server/server.js +481 -334
  147. package/dist/server/server.js.map +1 -1
  148. package/dist/server/sse.d.ts +11 -1
  149. package/dist/server/sse.d.ts.map +1 -1
  150. package/dist/server/sse.js +18 -3
  151. package/dist/server/sse.js.map +1 -1
  152. package/dist/server/transport-lane.d.ts +7 -1
  153. package/dist/server/transport-lane.d.ts.map +1 -1
  154. package/dist/server/transport-lane.js.map +1 -1
  155. package/dist/server/webhook.d.ts +5 -2
  156. package/dist/server/webhook.d.ts.map +1 -1
  157. package/dist/server/webhook.js +17 -1
  158. package/dist/server/webhook.js.map +1 -1
  159. package/dist/server/websocket.d.ts +33 -14
  160. package/dist/server/websocket.d.ts.map +1 -1
  161. package/dist/server/websocket.js +47 -12
  162. package/dist/server/websocket.js.map +1 -1
  163. package/dist/server/ws-bun.d.ts +7 -1
  164. package/dist/server/ws-bun.d.ts.map +1 -1
  165. package/dist/server/ws-bun.js +42 -2
  166. package/dist/server/ws-bun.js.map +1 -1
  167. package/dist/server/ws-hook.d.ts +15 -3
  168. package/dist/server/ws-hook.d.ts.map +1 -1
  169. package/dist/server.d.ts +4 -3
  170. package/dist/server.d.ts.map +1 -1
  171. package/dist/server.js +3 -2
  172. package/dist/server.js.map +1 -1
  173. package/dist/single-copy.d.ts +6 -0
  174. package/dist/single-copy.d.ts.map +1 -1
  175. package/dist/single-copy.js +55 -6
  176. package/dist/single-copy.js.map +1 -1
  177. package/dist/tool-contract.d.ts.map +1 -1
  178. package/dist/tool-contract.js +71 -40
  179. package/dist/tool-contract.js.map +1 -1
  180. package/dist/wire.d.ts +5 -0
  181. package/dist/wire.d.ts.map +1 -1
  182. package/dist/wire.js +5 -0
  183. package/dist/wire.js.map +1 -1
  184. package/dist/ws.d.ts.map +1 -1
  185. package/dist/ws.js +4 -1
  186. package/dist/ws.js.map +1 -1
  187. package/package.json +1 -1
  188. package/src/assurance.ts +299 -111
  189. package/src/capabilities.ts +62 -1
  190. package/src/durable-effect.ts +1 -1
  191. package/src/durable-saga.ts +94 -10
  192. package/src/edge-kit.ts +8 -3
  193. package/src/effect-scope.ts +15 -5
  194. package/src/errors.ts +1 -0
  195. package/src/evidence.ts +12 -3
  196. package/src/index.ts +7 -2
  197. package/src/internal/durable-shared.ts +25 -37
  198. package/src/internal/durable-types.ts +3 -0
  199. package/src/internal/hook-origins.ts +153 -0
  200. package/src/internal/route-assurance.ts +32 -18
  201. package/src/internal/route-compiler.ts +107 -16
  202. package/src/internal/route-program.ts +98 -44
  203. package/src/internal/same-origin.ts +14 -1
  204. package/src/reconciliation-worker.ts +10 -2
  205. package/src/reflection.ts +455 -4
  206. package/src/router/pattern.ts +6 -3
  207. package/src/security/baseline.ts +10 -5
  208. package/src/server/auth.ts +45 -3
  209. package/src/server/body-lane.ts +36 -1
  210. package/src/server/body.ts +34 -1
  211. package/src/server/client-ip.ts +8 -5
  212. package/src/server/context.ts +2 -1
  213. package/src/server/cookies.ts +12 -1
  214. package/src/server/edge.ts +2 -1
  215. package/src/server/http.ts +7 -1
  216. package/src/server/idempotency-lane.ts +64 -22
  217. package/src/server/install.ts +3 -0
  218. package/src/server/logger.ts +3 -1
  219. package/src/server/methods.ts +1 -0
  220. package/src/server/node-outcome.ts +34 -12
  221. package/src/server/platform-adapters.ts +3 -2
  222. package/src/server/request-context.ts +5 -0
  223. package/src/server/respond.ts +222 -69
  224. package/src/server/response-contract-lane.ts +162 -10
  225. package/src/server/response-observer-runtime.ts +16 -38
  226. package/src/server/runtime-core.ts +2 -0
  227. package/src/server/server-types.ts +45 -10
  228. package/src/server/server.ts +707 -539
  229. package/src/server/sse.ts +84 -55
  230. package/src/server/transport-lane.ts +7 -1
  231. package/src/server/webhook.ts +22 -3
  232. package/src/server/websocket.ts +83 -18
  233. package/src/server/ws-bun.ts +48 -1
  234. package/src/server/ws-hook.ts +14 -2
  235. package/src/server.ts +6 -1
  236. package/src/single-copy.ts +55 -5
  237. package/src/tool-contract.ts +67 -39
  238. package/src/wire.ts +14 -0
  239. 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.filter((rule) => usesClassSelector(rule.match)).map((rule) => rule.name)
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 rules = policy.rules.map((rule): AssuranceRule => {
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
- if (rule.match.hasBody !== undefined && typeof rule.match.hasBody !== "boolean") {
384
- throw new Error(
385
- `route assurance: rule ${JSON.stringify(name)} hasBody selector must be boolean`,
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?.map((method) => method.toUpperCase())
409
- for (const method of methods ?? []) {
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 && typeof config.invariants.executor !== "function") {
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: Object.freeze({ executor: config.invariants.executor }) }
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
- /** Evaluate reflected route evidence against the first matching policy rule. */
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
- routes.push({
739
- method: route.method,
740
- path: route.path,
741
- evidence,
742
- missing: [],
743
- forbidden: [],
744
- ...(route.schema?.response !== undefined || route.schema?.errors !== undefined
745
- ? { responseContract: route.responseContract ?? "unchecked" }
746
- : {}),
747
- })
748
- continue
749
- }
750
-
751
- const requiredProvenance = rule.requireProvenance ?? "any"
752
- let missing = (rule.require ?? []).filter((id) => {
753
- if (!evidenceIds.has(id)) return true
754
- if (requiredProvenance === "any") return false
755
- return !evidence.some(
756
- (item) => item.id === id && evidenceProvenance(item) === requiredProvenance,
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
- // Opt-in visibility: a pure-classification rule (no require + no forbid) matching a route with zero
794
- // evidence is a "label without proof". Surface it so the gap isn't silently green. A rule that expects
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),