@nifrajs/core 2.11.0 → 2.12.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 (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +424 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +667 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. package/src/tool-contract.ts +896 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/core",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Bun-native, contract-first HTTP framework - the router, server, and route descriptor.",
5
5
  "keywords": [
6
6
  "bun",
@@ -67,11 +67,21 @@
67
67
  "types": "./dist/diff.d.ts",
68
68
  "default": "./dist/diff.js"
69
69
  },
70
+ "./evidence": {
71
+ "bun": "./src/evidence.ts",
72
+ "types": "./dist/evidence.d.ts",
73
+ "default": "./dist/evidence.js"
74
+ },
70
75
  "./assurance": {
71
76
  "bun": "./src/assurance.ts",
72
77
  "types": "./dist/assurance.d.ts",
73
78
  "default": "./dist/assurance.js"
74
79
  },
80
+ "./replay": {
81
+ "bun": "./src/replay.ts",
82
+ "types": "./dist/replay.d.ts",
83
+ "default": "./dist/replay.js"
84
+ },
75
85
  "./binary": {
76
86
  "bun": "./src/binary.ts",
77
87
  "types": "./dist/binary.d.ts",
@@ -226,6 +236,21 @@
226
236
  "bun": "./src/server/response-contract-lane.ts",
227
237
  "types": "./dist/server/response-contract-lane.d.ts",
228
238
  "default": "./dist/server/response-contract-lane.js"
239
+ },
240
+ "./tool-contract": {
241
+ "bun": "./src/tool-contract.ts",
242
+ "types": "./dist/tool-contract.d.ts",
243
+ "default": "./dist/tool-contract.js"
244
+ },
245
+ "./tool-catalog": {
246
+ "bun": "./src/tool-catalog.ts",
247
+ "types": "./dist/tool-catalog.d.ts",
248
+ "default": "./dist/tool-catalog.js"
249
+ },
250
+ "./execution-policy": {
251
+ "bun": "./src/execution-policy.ts",
252
+ "types": "./dist/execution-policy.d.ts",
253
+ "default": "./dist/execution-policy.js"
229
254
  }
230
255
  },
231
256
  "files": [
package/src/assurance.ts CHANGED
@@ -11,23 +11,32 @@ import {
11
11
  type CapabilityZone,
12
12
  defineCapabilityPolicy,
13
13
  } from "./capabilities.ts"
14
+ import {
15
+ type DataClassification,
16
+ classificationAtLeast as isClassificationAtLeast,
17
+ isDataClassification,
18
+ } from "./classification.ts"
14
19
  import type {
20
+ AssuranceAttachment,
15
21
  AssuranceDeclaration,
16
22
  AssuranceEvidence,
17
23
  AssuranceScope,
18
24
  } from "./internal/route-assurance.ts"
19
25
  import {
26
+ evidenceProvenance,
20
27
  NIFRA_ASSURANCE_IDS,
28
+ normalizeAssuranceDeclarations,
21
29
  routeGlob,
22
30
  validEvidenceId,
23
31
  validMethod,
24
32
  withRouteAssurance,
25
33
  } from "./internal/route-assurance.ts"
34
+ import type { SealedEffectLedger } from "./ledger.ts"
26
35
  import type { NifraManifestSigner } from "./manifest.ts"
27
36
  import { type ReflectedRoute, reflectRoutes } from "./reflection.ts"
28
37
  import type { Method } from "./router/router.ts"
29
38
 
30
- export type { AssuranceDeclaration, AssuranceEvidence, AssuranceScope }
39
+ export type { AssuranceAttachment, AssuranceDeclaration, AssuranceEvidence, AssuranceScope }
31
40
  export { withRouteAssurance }
32
41
 
33
42
  /** Isolated request executor used by adversarial contract verification. */
@@ -36,6 +45,57 @@ export type InvariantExecutor = (request: Request) => Response | Promise<Respons
36
45
  /** Canonical evidence ids emitted by Nifra's official middleware modules. */
37
46
  export const NIFRA_ASSURANCE = NIFRA_ASSURANCE_IDS
38
47
 
48
+ /** The seam `assure()` writes through: an evidence-only middleware bundle, applied like any other. */
49
+ interface AssurableServer {
50
+ use(middleware: object): unknown
51
+ }
52
+
53
+ /**
54
+ * Publish enforcement evidence from OUTSIDE the plugin chain - the deployment shell that wraps the
55
+ * app, the mount site, or the call that hands it to `serve`. When the thing doing the enforcing is
56
+ * not a nifra plugin (a gateway, a service mesh, an outer framework), the alternative is switching
57
+ * the affected rules off; this records what covers the routes instead, so the policy keeps running.
58
+ *
59
+ * const app = buildApp() // no assurance-bearing plugin in its .use() chain
60
+ * assure(app, { id: NIFRA_ASSURANCE.AUTHENTICATED, source: "edge-gateway" })
61
+ * serve(app)
62
+ *
63
+ * `scope` defaults to `global`: the shell runs after every route is registered, so the evidence is
64
+ * retroactive and app-wide. Narrow it with `methods`/`paths` (absolute globs), or pass
65
+ * `scope: "subsequent"` to cover only routes registered after the call.
66
+ *
67
+ * Provenance is always `declared` - nifra did not install this enforcement and cannot observe it, so
68
+ * a rule with `requireProvenance: "runtime"` still rejects the route, by design.
69
+ */
70
+ export function assure(
71
+ app: unknown,
72
+ evidence: AssuranceAttachment | readonly AssuranceAttachment[],
73
+ ): void {
74
+ const values: readonly AssuranceAttachment[] = Array.isArray(evidence)
75
+ ? (evidence as readonly AssuranceAttachment[])
76
+ : [evidence as AssuranceAttachment]
77
+ const declarations = normalizeAssuranceDeclarations(
78
+ values.map((item) => ({
79
+ ...item,
80
+ scope: item.scope ?? "global",
81
+ provenance: "declared" as const,
82
+ })),
83
+ )
84
+ for (const declaration of declarations) {
85
+ if (declaration.scope === "plugin") {
86
+ throw new Error('assure(): scope "plugin" may only annotate a plugin function')
87
+ }
88
+ }
89
+ const target = app as Partial<AssurableServer>
90
+ if (typeof target.use !== "function") {
91
+ throw new TypeError("assure(): expected a nifra server")
92
+ }
93
+ // An evidence-only bundle: `use()` already reads declarations off what it applies, and a bundle
94
+ // with no hooks registers nothing on any lifecycle array - so the kernel needs no seam of its own,
95
+ // the request path is untouched, and an app that never calls `assure()` carries none of this.
96
+ target.use(withRouteAssurance({}, declarations))
97
+ }
98
+
39
99
  export interface AssuranceRouteSelector {
40
100
  /** Omit for every method. */
41
101
  readonly methods?: readonly Method[]
@@ -73,6 +133,8 @@ export interface AssuranceRouteSelector {
73
133
  * match a route that only writes an audit log.
74
134
  */
75
135
  readonly zone?: CapabilityZone
136
+ /** Match routes whose response classification is at least this sensitivity. */
137
+ readonly classificationAtLeast?: DataClassification
76
138
  }
77
139
 
78
140
  /** Extra inputs an assurance evaluation needs beyond the routes themselves. */
@@ -109,6 +171,19 @@ export interface AssuranceRule {
109
171
  readonly require?: readonly string[]
110
172
  /** Evidence ids the route must not carry (useful for public webhooks and health routes). */
111
173
  readonly forbid?: readonly string[]
174
+ /**
175
+ * Provenance required for every id in require. any (default) preserves compatibility with
176
+ * existing policies; runtime rejects schema.assurance author assertions and accepts only
177
+ * evidence installed by middleware/plugins or framework runtime policy. declared is useful for
178
+ * explicitly reviewing in-handler assertions and should not be used as an enforcement gate.
179
+ */
180
+ readonly requireProvenance?: "any" | "runtime" | "declared"
181
+ /**
182
+ * When true, an authenticated route selected by this rule must also carry runtime CSRF evidence.
183
+ * Enable this on rules covering cookie/session-authenticated browser routes; bearer-only APIs
184
+ * should use a separate rule because they do not have browser ambient-authority exposure.
185
+ */
186
+ readonly requireCsrfWithAuthenticated?: boolean
112
187
  }
113
188
 
114
189
  export interface AssurancePolicy {
@@ -161,6 +236,23 @@ export interface AssuranceReport {
161
236
  readonly capabilities?: CapabilityAssuranceReport
162
237
  }
163
238
 
239
+ /** Application-supplied verification rules. The CLI validates the executable rule shape at runtime. */
240
+ export interface AssuranceRulePack {
241
+ readonly name: string
242
+ readonly rules: readonly unknown[]
243
+ }
244
+
245
+ export interface IdempotencyWorkload {
246
+ readonly name: string
247
+ readonly run: () => Promise<SealedEffectLedger> | SealedEffectLedger
248
+ }
249
+
250
+ export interface AssuranceSizeBudget {
251
+ readonly outDir?: string
252
+ readonly maxBytes?: number
253
+ readonly maxGzipBytes?: number
254
+ }
255
+
164
256
  export interface AssuranceConfig {
165
257
  readonly source: unknown
166
258
  readonly policy: AssurancePolicy
@@ -176,6 +268,14 @@ export interface AssuranceConfig {
176
268
  readonly invariants?: {
177
269
  readonly executor: InvariantExecutor
178
270
  }
271
+ /** Optional application-supplied rule packs appended after built-in verification rules. */
272
+ readonly rulePacks?: readonly AssuranceRulePack[]
273
+ /** Optional sink for an assurance bundle. The CLI validates the callable shape at runtime. */
274
+ readonly assureSink?: unknown
275
+ /** Optional deterministic effect workloads for the tests gate. */
276
+ readonly idempotency?: readonly IdempotencyWorkload[]
277
+ /** Optional output-size budget for the assurance bundle's size gate. */
278
+ readonly size?: AssuranceSizeBudget
179
279
  }
180
280
 
181
281
  const nonEmpty = (value: string): boolean => value.trim() !== ""
@@ -229,7 +329,15 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
229
329
  // silence - and a selector that loses its only constraint matches EVERY route, making the rule
230
330
  // swallow everything after it. A misspelled key is a policy hole, so it is refused here.
231
331
  const unknown = Object.keys(rule.match).filter(
232
- (key) => !["methods", "paths", "tools", "capabilities", ...CLASS_SELECTOR_KEYS].includes(key),
332
+ (key) =>
333
+ ![
334
+ "methods",
335
+ "paths",
336
+ "tools",
337
+ "capabilities",
338
+ ...CLASS_SELECTOR_KEYS,
339
+ "classificationAtLeast",
340
+ ].includes(key),
233
341
  )
234
342
  if (unknown.length > 0) {
235
343
  throw new Error(
@@ -271,8 +379,40 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
271
379
  `route assurance: rule ${JSON.stringify(name)} zone selector must be "domain" or "operational"`,
272
380
  )
273
381
  }
382
+ if (
383
+ rule.match.classificationAtLeast !== undefined &&
384
+ !isDataClassification(rule.match.classificationAtLeast)
385
+ ) {
386
+ throw new Error(
387
+ "route assurance: rule " +
388
+ JSON.stringify(name) +
389
+ ' classificationAtLeast must be "public", "pii", or "secret"',
390
+ )
391
+ }
274
392
  const required = normalizeEvidenceIds(rule.require, "required")
275
393
  const forbidden = normalizeEvidenceIds(rule.forbid, "forbidden")
394
+ if (
395
+ rule.requireProvenance !== undefined &&
396
+ rule.requireProvenance !== "any" &&
397
+ rule.requireProvenance !== "runtime" &&
398
+ rule.requireProvenance !== "declared"
399
+ ) {
400
+ throw new Error(
401
+ "route assurance: rule " +
402
+ JSON.stringify(name) +
403
+ ' requireProvenance must be "any", "runtime", or "declared"',
404
+ )
405
+ }
406
+ if (
407
+ rule.requireCsrfWithAuthenticated !== undefined &&
408
+ typeof rule.requireCsrfWithAuthenticated !== "boolean"
409
+ ) {
410
+ throw new Error(
411
+ "route assurance: rule " +
412
+ JSON.stringify(name) +
413
+ " requireCsrfWithAuthenticated must be boolean",
414
+ )
415
+ }
276
416
  const overlap = required.find((id) => forbidden.includes(id))
277
417
  if (overlap !== undefined) {
278
418
  throw new Error(
@@ -288,9 +428,16 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
288
428
  ...(capabilities !== undefined ? { capabilities: Object.freeze([...capabilities]) } : {}),
289
429
  ...(access !== undefined ? { access } : {}),
290
430
  ...(zone !== undefined ? { zone } : {}),
431
+ ...(rule.match.classificationAtLeast !== undefined
432
+ ? { classificationAtLeast: rule.match.classificationAtLeast }
433
+ : {}),
291
434
  }),
292
435
  require: required,
293
436
  forbid: forbidden,
437
+ ...(rule.requireProvenance !== undefined
438
+ ? { requireProvenance: rule.requireProvenance }
439
+ : {}),
440
+ ...(rule.requireCsrfWithAuthenticated === true ? { requireCsrfWithAuthenticated: true } : {}),
294
441
  })
295
442
  })
296
443
  return Object.freeze({
@@ -312,6 +459,57 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
312
459
  if (config.invariants !== undefined && typeof config.invariants.executor !== "function") {
313
460
  throw new Error("route assurance: invariant executor must be a function")
314
461
  }
462
+ if (config.rulePacks !== undefined) {
463
+ if (!Array.isArray(config.rulePacks))
464
+ throw new TypeError("route assurance: rulePacks must be an array")
465
+ const names = new Set<string>()
466
+ for (const pack of config.rulePacks) {
467
+ if (typeof pack.name !== "string" || pack.name.trim() === "")
468
+ throw new TypeError("route assurance: rule pack name must be non-empty")
469
+ if (names.has(pack.name)) throw new Error(`route assurance: duplicate rule pack ${pack.name}`)
470
+ names.add(pack.name)
471
+ if (!Array.isArray(pack.rules))
472
+ throw new TypeError(`route assurance: invalid rules in ${pack.name}`)
473
+ }
474
+ }
475
+ if (config.assureSink !== undefined) {
476
+ if (
477
+ typeof config.assureSink !== "object" ||
478
+ config.assureSink === null ||
479
+ Array.isArray(config.assureSink)
480
+ )
481
+ throw new TypeError("route assurance: assureSink must be an object")
482
+ const record = Object.fromEntries(Object.entries(config.assureSink))
483
+ if (typeof record.record !== "function")
484
+ throw new TypeError("route assurance: assureSink.record must be a function")
485
+ }
486
+ if (config.idempotency !== undefined) {
487
+ if (!Array.isArray(config.idempotency))
488
+ throw new TypeError("route assurance: idempotency must be an array")
489
+ for (const workload of config.idempotency) {
490
+ if (
491
+ typeof workload.name !== "string" ||
492
+ workload.name.trim() === "" ||
493
+ typeof workload.run !== "function"
494
+ )
495
+ throw new TypeError("route assurance: idempotency workloads require name and run")
496
+ }
497
+ }
498
+ if (config.size !== undefined) {
499
+ if (typeof config.size !== "object" || config.size === null || Array.isArray(config.size))
500
+ throw new TypeError("route assurance: size must be an object")
501
+ for (const [name, value] of Object.entries(config.size)) {
502
+ if (name !== "outDir" && name !== "maxBytes" && name !== "maxGzipBytes")
503
+ throw new TypeError(`route assurance: unknown size key ${name}`)
504
+ if (name === "outDir" && typeof value !== "string")
505
+ throw new TypeError("route assurance: size.outDir must be a string")
506
+ if (
507
+ (name === "maxBytes" || name === "maxGzipBytes") &&
508
+ (typeof value !== "number" || !Number.isFinite(value) || value < 0)
509
+ )
510
+ throw new TypeError(`route assurance: size.${name} must be a non-negative number`)
511
+ }
512
+ }
315
513
  const policy = defineAssurancePolicy(config.policy)
316
514
  const capabilities =
317
515
  config.capabilities !== undefined ? defineCapabilityPolicy(config.capabilities) : undefined
@@ -344,6 +542,12 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
344
542
  ...(config.invariants !== undefined
345
543
  ? { invariants: Object.freeze({ executor: config.invariants.executor }) }
346
544
  : {}),
545
+ ...(config.rulePacks !== undefined ? { rulePacks: Object.freeze([...config.rulePacks]) } : {}),
546
+ ...(config.assureSink !== undefined ? { assureSink: config.assureSink } : {}),
547
+ ...(config.idempotency !== undefined
548
+ ? { idempotency: Object.freeze([...config.idempotency]) }
549
+ : {}),
550
+ ...(config.size !== undefined ? { size: Object.freeze({ ...config.size }) } : {}),
347
551
  })
348
552
  }
349
553
 
@@ -355,15 +559,21 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
355
559
  * accept user policy must reject that combination up front - `defineAssuranceConfig` does.
356
560
  */
357
561
  export function matchesAssuranceSelector(
358
- route: Pick<ReflectedRoute, "method" | "path" | "tool" | "capabilities">,
562
+ route: Pick<ReflectedRoute, "method" | "path" | "tool" | "capabilities" | "classification">,
359
563
  selector: AssuranceRouteSelector,
360
564
  definitions?: ReadonlyMap<string, CapabilityDefinition>,
361
565
  ): boolean {
362
- const { methods, paths, tools, capabilities, access, zone } = selector
566
+ const { methods, paths, tools, capabilities, access, zone, classificationAtLeast } = selector
363
567
  if (methods !== undefined && !methods.includes(route.method as Method)) return false
364
568
  if (paths !== undefined && !paths.some((pattern) => routeGlob(pattern).test(route.path)))
365
569
  return false
366
570
  if (tools !== undefined && (route.tool !== undefined) !== tools) return false
571
+ if (
572
+ classificationAtLeast !== undefined &&
573
+ (route.classification === undefined ||
574
+ !isClassificationAtLeast(route.classification.max, classificationAtLeast))
575
+ )
576
+ return false
367
577
  const declared = route.capabilities ?? []
368
578
  if (capabilities !== undefined && !capabilities.some((token) => declared.includes(token)))
369
579
  return false
@@ -434,8 +644,25 @@ export function evaluateRouteAssurance(
434
644
  continue
435
645
  }
436
646
 
437
- const missing = (rule.require ?? []).filter((id) => !evidenceIds.has(id))
647
+ const requiredProvenance = rule.requireProvenance ?? "any"
648
+ let missing = (rule.require ?? []).filter((id) => {
649
+ if (!evidenceIds.has(id)) return true
650
+ if (requiredProvenance === "any") return false
651
+ return !evidence.some(
652
+ (item) => item.id === id && evidenceProvenance(item) === requiredProvenance,
653
+ )
654
+ })
438
655
  const forbidden = (rule.forbid ?? []).filter((id) => evidenceIds.has(id))
656
+ if (
657
+ rule.requireCsrfWithAuthenticated === true &&
658
+ evidenceIds.has(NIFRA_ASSURANCE_IDS.AUTHENTICATED) &&
659
+ !evidence.some(
660
+ (item) => item.id === NIFRA_ASSURANCE_IDS.CSRF && evidenceProvenance(item) === "runtime",
661
+ )
662
+ ) {
663
+ const id = NIFRA_ASSURANCE_IDS.CSRF
664
+ if (!missing.includes(id)) missing = [...missing, id]
665
+ }
439
666
  for (const id of missing) {
440
667
  findings.push({
441
668
  code: "missing-evidence",
@@ -443,7 +670,10 @@ export function evaluateRouteAssurance(
443
670
  path: route.path,
444
671
  rule: rule.name,
445
672
  evidence: id,
446
- message: `${route.method} ${route.path} (${rule.name}) is missing ${id}`,
673
+ message:
674
+ requiredProvenance === "any"
675
+ ? `${route.method} ${route.path} (${rule.name}) is missing ${id}`
676
+ : `${route.method} ${route.path} (${rule.name}) is missing ${requiredProvenance} evidence for ${id}`,
447
677
  })
448
678
  }
449
679
  for (const id of forbidden) {
@@ -91,6 +91,14 @@ export interface CapabilityImportRule {
91
91
  /** Exact module specifier, or a trailing `/*` prefix rule. */
92
92
  readonly specifier: string
93
93
  readonly capabilities: readonly string[]
94
+ /**
95
+ * Allow this seam to be absent. A rule that matches no import anywhere in the project is reported
96
+ * as `unmatched-provenance-seam` by default, because a specifier written differently from the
97
+ * import it means to govern (`"./db/client.ts"` vs `import "./db/client"`) governs nothing and
98
+ * every route touching that effect still passes. Set this only for a policy shared across projects
99
+ * where some of them genuinely do not use the seam.
100
+ */
101
+ readonly optional?: boolean
94
102
  }
95
103
 
96
104
  export interface ForbiddenCapabilityImport {
@@ -156,6 +164,7 @@ export type CapabilityFindingCode =
156
164
  | "missing-durable-idempotency"
157
165
  | "forbidden-effect-import"
158
166
  | "provenance-truncated"
167
+ | "unmatched-provenance-seam"
159
168
 
160
169
  export interface CapabilityFinding {
161
170
  readonly code: CapabilityFindingCode
@@ -270,7 +279,11 @@ export function defineCapabilityPolicy(policy: CapabilityPolicy): CapabilityPoli
270
279
  for (const id of capabilities) {
271
280
  if (!ids.has(id)) throw new Error(`capability assurance: import references unknown ${id}`)
272
281
  }
273
- return Object.freeze({ specifier: rule.specifier, capabilities })
282
+ return Object.freeze({
283
+ specifier: rule.specifier,
284
+ capabilities,
285
+ ...(rule.optional === true ? { optional: true as const } : {}),
286
+ })
274
287
  })
275
288
  const forbiddenImports = policy.provenance.forbiddenImports.map(
276
289
  (rule): ForbiddenCapabilityImport => {
package/src/diff.ts CHANGED
@@ -32,6 +32,7 @@ export interface SchemaSnapshot {
32
32
  }
33
33
 
34
34
  export interface RouteSnapshotSchema {
35
+ readonly headers?: SchemaSnapshot
35
36
  readonly body?: SchemaSnapshot
36
37
  readonly query?: SchemaSnapshot
37
38
  readonly response?: SchemaSnapshot
@@ -53,7 +54,7 @@ export interface RouteChange {
53
54
  readonly method: string
54
55
  readonly path: string
55
56
  /** Which part of the contract changed; "route" for add/remove of the whole route. */
56
- readonly section: "route" | "body" | "query" | "response" | "sse" | "errors"
57
+ readonly section: "route" | "headers" | "body" | "query" | "response" | "sse" | "errors"
57
58
  /** The top-level field (or error status) the change is about, when field-granular. */
58
59
  readonly field?: string
59
60
  readonly message: string
@@ -98,10 +99,12 @@ const snapshotRoute = (route: ReflectedRoute): RouteSnapshot => {
98
99
  if (snapped !== undefined) errors[status] = snapped
99
100
  }
100
101
  const body = schemaSnapshot(schema.body)
102
+ const headers = schemaSnapshot(schema.headers)
101
103
  const query = schemaSnapshot(schema.query)
102
104
  const response = schemaSnapshot(schema.response)
103
105
  const sse = schemaSnapshot(schema.sse)
104
106
  const snapped: RouteSnapshotSchema = {
107
+ ...(headers !== undefined ? { headers } : {}),
105
108
  ...(body !== undefined ? { body } : {}),
106
109
  ...(query !== undefined ? { query } : {}),
107
110
  ...(response !== undefined ? { response } : {}),
@@ -353,7 +356,7 @@ export function diffRouteSnapshots(
353
356
  const path = beforeRoute.path
354
357
  const beforeSchema = beforeRoute.schema ?? {}
355
358
  const afterSchema = afterRoute.schema ?? {}
356
- for (const section of ["body", "query"] as const) {
359
+ for (const section of ["headers", "body", "query"] as const) {
357
360
  diffSchemaSection(
358
361
  { method, path, section, direction: "request", changes },
359
362
  beforeSchema[section],
package/src/errors.ts CHANGED
@@ -26,6 +26,7 @@ export type RouteConfigErrorCode =
26
26
  | "INVALID_METHOD"
27
27
  | "INVALID_ASSURANCE"
28
28
  | "INVALID_IDEMPOTENCY"
29
+ | "INVALID_BODY_LIMIT"
29
30
 
30
31
  /**
31
32
  * Thrown at route registration when a route is misconfigured. This is the