@nifrajs/core 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +71 -5
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +90 -25
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +6 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +52 -0
  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 +7 -2
  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 +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -2
  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-program.d.ts +1 -1
  49. package/dist/internal/route-program.d.ts.map +1 -1
  50. package/dist/internal/route-program.js +59 -16
  51. package/dist/internal/route-program.js.map +1 -1
  52. package/dist/reconciliation-worker.d.ts.map +1 -1
  53. package/dist/reconciliation-worker.js +12 -2
  54. package/dist/reconciliation-worker.js.map +1 -1
  55. package/dist/reflection.d.ts +53 -2
  56. package/dist/reflection.d.ts.map +1 -1
  57. package/dist/reflection.js +352 -5
  58. package/dist/reflection.js.map +1 -1
  59. package/dist/router/pattern.d.ts +1 -1
  60. package/dist/router/pattern.d.ts.map +1 -1
  61. package/dist/router/pattern.js +6 -3
  62. package/dist/router/pattern.js.map +1 -1
  63. package/dist/security/baseline.d.ts +3 -0
  64. package/dist/security/baseline.d.ts.map +1 -1
  65. package/dist/security/baseline.js +9 -5
  66. package/dist/security/baseline.js.map +1 -1
  67. package/dist/server/auth.d.ts +32 -3
  68. package/dist/server/auth.d.ts.map +1 -1
  69. package/dist/server/auth.js +23 -0
  70. package/dist/server/auth.js.map +1 -1
  71. package/dist/server/body.d.ts +7 -0
  72. package/dist/server/body.d.ts.map +1 -1
  73. package/dist/server/body.js +34 -1
  74. package/dist/server/body.js.map +1 -1
  75. package/dist/server/client-ip.d.ts +3 -0
  76. package/dist/server/client-ip.d.ts.map +1 -1
  77. package/dist/server/client-ip.js.map +1 -1
  78. package/dist/server/context.d.ts +2 -1
  79. package/dist/server/context.d.ts.map +1 -1
  80. package/dist/server/idempotency-lane.d.ts +6 -3
  81. package/dist/server/idempotency-lane.d.ts.map +1 -1
  82. package/dist/server/idempotency-lane.js +38 -17
  83. package/dist/server/idempotency-lane.js.map +1 -1
  84. package/dist/server/install.d.ts +2 -0
  85. package/dist/server/install.d.ts.map +1 -1
  86. package/dist/server/install.js +2 -0
  87. package/dist/server/install.js.map +1 -1
  88. package/dist/server/logger.d.ts.map +1 -1
  89. package/dist/server/logger.js +3 -1
  90. package/dist/server/logger.js.map +1 -1
  91. package/dist/server/methods.d.ts +1 -0
  92. package/dist/server/methods.d.ts.map +1 -1
  93. package/dist/server/methods.js +1 -0
  94. package/dist/server/methods.js.map +1 -1
  95. package/dist/server/node-outcome.d.ts.map +1 -1
  96. package/dist/server/node-outcome.js +29 -11
  97. package/dist/server/node-outcome.js.map +1 -1
  98. package/dist/server/platform-adapters.d.ts.map +1 -1
  99. package/dist/server/platform-adapters.js +3 -2
  100. package/dist/server/platform-adapters.js.map +1 -1
  101. package/dist/server/request-context.d.ts +2 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/request-context.js +4 -1
  104. package/dist/server/request-context.js.map +1 -1
  105. package/dist/server/respond.d.ts +44 -11
  106. package/dist/server/respond.d.ts.map +1 -1
  107. package/dist/server/respond.js +192 -79
  108. package/dist/server/respond.js.map +1 -1
  109. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  110. package/dist/server/response-observer-runtime.js +10 -41
  111. package/dist/server/response-observer-runtime.js.map +1 -1
  112. package/dist/server/runtime-core.d.ts +2 -0
  113. package/dist/server/runtime-core.d.ts.map +1 -1
  114. package/dist/server/runtime-core.js +2 -0
  115. package/dist/server/runtime-core.js.map +1 -1
  116. package/dist/server/server-types.d.ts +29 -5
  117. package/dist/server/server-types.d.ts.map +1 -1
  118. package/dist/server/server.d.ts +44 -31
  119. package/dist/server/server.d.ts.map +1 -1
  120. package/dist/server/server.js +444 -321
  121. package/dist/server/server.js.map +1 -1
  122. package/dist/server/sse.d.ts +11 -1
  123. package/dist/server/sse.d.ts.map +1 -1
  124. package/dist/server/sse.js +18 -3
  125. package/dist/server/sse.js.map +1 -1
  126. package/dist/server/transport-lane.d.ts +7 -1
  127. package/dist/server/transport-lane.d.ts.map +1 -1
  128. package/dist/server/transport-lane.js.map +1 -1
  129. package/dist/server/webhook.d.ts +5 -2
  130. package/dist/server/webhook.d.ts.map +1 -1
  131. package/dist/server/webhook.js +17 -1
  132. package/dist/server/webhook.js.map +1 -1
  133. package/dist/server/websocket.d.ts +22 -14
  134. package/dist/server/websocket.d.ts.map +1 -1
  135. package/dist/server/websocket.js +21 -0
  136. package/dist/server/websocket.js.map +1 -1
  137. package/dist/server/ws-hook.d.ts +7 -1
  138. package/dist/server/ws-hook.d.ts.map +1 -1
  139. package/dist/server.d.ts +3 -2
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +2 -1
  142. package/dist/server.js.map +1 -1
  143. package/dist/single-copy.d.ts +6 -0
  144. package/dist/single-copy.d.ts.map +1 -1
  145. package/dist/single-copy.js +20 -1
  146. package/dist/single-copy.js.map +1 -1
  147. package/dist/tool-contract.d.ts.map +1 -1
  148. package/dist/tool-contract.js +71 -40
  149. package/dist/tool-contract.js.map +1 -1
  150. package/dist/ws.d.ts.map +1 -1
  151. package/dist/ws.js +3 -1
  152. package/dist/ws.js.map +1 -1
  153. package/package.json +1 -1
  154. package/src/assurance.ts +180 -31
  155. package/src/capabilities.ts +59 -0
  156. package/src/durable-effect.ts +1 -1
  157. package/src/durable-saga.ts +94 -10
  158. package/src/edge-kit.ts +7 -2
  159. package/src/effect-scope.ts +15 -5
  160. package/src/errors.ts +1 -0
  161. package/src/evidence.ts +12 -3
  162. package/src/index.ts +6 -1
  163. package/src/internal/durable-shared.ts +25 -37
  164. package/src/internal/durable-types.ts +3 -0
  165. package/src/internal/hook-origins.ts +153 -0
  166. package/src/internal/route-assurance.ts +32 -18
  167. package/src/internal/route-program.ts +98 -44
  168. package/src/reconciliation-worker.ts +10 -2
  169. package/src/reflection.ts +455 -4
  170. package/src/router/pattern.ts +6 -3
  171. package/src/security/baseline.ts +10 -5
  172. package/src/server/auth.ts +45 -3
  173. package/src/server/body.ts +34 -1
  174. package/src/server/client-ip.ts +3 -0
  175. package/src/server/context.ts +2 -1
  176. package/src/server/idempotency-lane.ts +64 -22
  177. package/src/server/install.ts +3 -0
  178. package/src/server/logger.ts +3 -1
  179. package/src/server/methods.ts +1 -0
  180. package/src/server/node-outcome.ts +34 -12
  181. package/src/server/platform-adapters.ts +3 -2
  182. package/src/server/request-context.ts +5 -0
  183. package/src/server/respond.ts +222 -69
  184. package/src/server/response-observer-runtime.ts +16 -38
  185. package/src/server/runtime-core.ts +2 -0
  186. package/src/server/server-types.ts +29 -5
  187. package/src/server/server.ts +659 -530
  188. package/src/server/sse.ts +84 -55
  189. package/src/server/transport-lane.ts +7 -1
  190. package/src/server/webhook.ts +22 -3
  191. package/src/server/websocket.ts +44 -5
  192. package/src/server/ws-hook.ts +5 -0
  193. package/src/server.ts +5 -0
  194. package/src/single-copy.ts +20 -1
  195. package/src/tool-contract.ts +67 -39
  196. package/src/ws.ts +4 -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`). */
@@ -292,9 +372,7 @@ export interface AssuranceConfig {
292
372
  readonly signer?: (keyRef: string) => NifraManifestSigner | Promise<NifraManifestSigner>
293
373
  }
294
374
  /** Dynamic invariant execution is opt-in and must use a disposable/sandboxed target. */
295
- readonly invariants?: {
296
- readonly executor: InvariantExecutor
297
- }
375
+ readonly invariants?: InvariantsConfig
298
376
  /** Optional application-supplied rule packs appended after built-in verification rules. */
299
377
  readonly rulePacks?: readonly AssuranceRulePack[]
300
378
  /** Optional sink for an assurance bundle. The CLI validates the callable shape at runtime. */
@@ -307,6 +385,23 @@ export interface AssuranceConfig {
307
385
 
308
386
  const nonEmpty = (value: string): boolean => value.trim() !== ""
309
387
 
388
+ /** A selector's method list, upper-cased. Standard methods and `"WS"` only, so a typo throws rather
389
+ * than leaving a rule that never matches. */
390
+ function selectorMethods(
391
+ values: readonly string[] | undefined,
392
+ ): readonly (Method | "WS")[] | undefined {
393
+ if (values === undefined) return undefined
394
+ return Object.freeze(
395
+ values.map((value): Method | "WS" => {
396
+ const method = value.toUpperCase()
397
+ if (method !== "WS" && !validMethod(method)) {
398
+ throw new Error(`route assurance: unsupported HTTP method ${JSON.stringify(method)}`)
399
+ }
400
+ return method
401
+ }),
402
+ )
403
+ }
404
+
310
405
  function normalizeEvidenceIds(
311
406
  values: readonly string[] | undefined,
312
407
  label: string,
@@ -359,6 +454,7 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
359
454
  (key) =>
360
455
  ![
361
456
  "methods",
457
+ "exceptMethods",
362
458
  "paths",
363
459
  "tools",
364
460
  "capabilities",
@@ -397,7 +493,7 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
397
493
  }
398
494
  // An empty list matches nothing, so the rule is inert and its routes fall through to whatever
399
495
  // laxer rule comes next - the same hole as a dropped key, the other way round.
400
- for (const key of ["methods", "paths"] as const) {
496
+ for (const key of ["methods", "exceptMethods", "paths"] as const) {
401
497
  const list = rule.match[key]
402
498
  if (list !== undefined && (!Array.isArray(list) || list.length === 0)) {
403
499
  throw new Error(
@@ -405,12 +501,8 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
405
501
  )
406
502
  }
407
503
  }
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
- }
504
+ const methods = selectorMethods(rule.match.methods)
505
+ const exceptMethods = selectorMethods(rule.match.exceptMethods)
414
506
  const paths = rule.match.paths?.map((path) => {
415
507
  routeGlob(path)
416
508
  return path
@@ -480,21 +572,22 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
480
572
  `route assurance: rule ${JSON.stringify(name)} both requires and forbids ${overlap}`,
481
573
  )
482
574
  }
575
+ const match: { -readonly [K in keyof AssuranceRouteSelector]: AssuranceRouteSelector[K] } = {}
576
+ if (methods !== undefined) match.methods = methods
577
+ if (exceptMethods !== undefined) match.exceptMethods = exceptMethods
578
+ if (paths !== undefined) match.paths = Object.freeze(paths)
579
+ if (rule.match.tools !== undefined) match.tools = rule.match.tools
580
+ if (capabilities !== undefined) match.capabilities = Object.freeze([...capabilities])
581
+ if (access !== undefined) match.access = access
582
+ if (zone !== undefined) match.zone = zone
583
+ if (rule.match.classificationAtLeast !== undefined) {
584
+ match.classificationAtLeast = rule.match.classificationAtLeast
585
+ }
586
+ if (rule.match.hasBody !== undefined) match.hasBody = rule.match.hasBody
587
+ if (rule.match.bodyLimit !== undefined) match.bodyLimit = rule.match.bodyLimit
483
588
  return Object.freeze({
484
589
  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
- }),
590
+ match: Object.freeze(match),
498
591
  require: required,
499
592
  forbid: forbidden,
500
593
  ...(rule.requireProvenance !== undefined
@@ -511,6 +604,63 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
511
604
  })
512
605
  }
513
606
 
607
+ const INVARIANT_FUNCTIONS = [
608
+ "executor",
609
+ "setup",
610
+ "prepareRequest",
611
+ "isRejected",
612
+ "include",
613
+ ] as const
614
+
615
+ function assertInvariantsConfig(invariants: InvariantsConfig): void {
616
+ if (invariants === null || typeof invariants !== "object") {
617
+ throw new TypeError("route assurance: invariants must be an object")
618
+ }
619
+ for (const name of INVARIANT_FUNCTIONS) {
620
+ const value: unknown = invariants[name]
621
+ if (value !== undefined && typeof value !== "function") {
622
+ throw new TypeError(
623
+ name === "executor"
624
+ ? "route assurance: invariant executor must be a function"
625
+ : `route assurance: invariants.${name} must be a function`,
626
+ )
627
+ }
628
+ }
629
+ if (invariants.executor === undefined && invariants.setup === undefined) {
630
+ throw new TypeError("route assurance: invariants needs an executor or a setup")
631
+ }
632
+ const witnesses: unknown = invariants.witnesses
633
+ if (witnesses !== undefined) {
634
+ if (witnesses === null || typeof witnesses !== "object" || Array.isArray(witnesses)) {
635
+ throw new TypeError(
636
+ 'route assurance: invariants.witnesses must map "METHOD /path" to a witness',
637
+ )
638
+ }
639
+ for (const [key, witness] of Object.entries(witnesses)) {
640
+ if (witness === null || typeof witness !== "object" || Array.isArray(witness)) {
641
+ throw new TypeError(
642
+ `route assurance: invariants.witnesses[${JSON.stringify(key)}] must be an object`,
643
+ )
644
+ }
645
+ }
646
+ }
647
+ const max = invariants.maxMutationsPerInput
648
+ if (max !== undefined && (!Number.isSafeInteger(max) || max < 1)) {
649
+ throw new RangeError(
650
+ "route assurance: invariants.maxMutationsPerInput must be a positive integer",
651
+ )
652
+ }
653
+ }
654
+
655
+ function frozenInvariantsConfig(invariants: InvariantsConfig): InvariantsConfig {
656
+ const witnesses = invariants.witnesses
657
+ return Object.freeze(
658
+ witnesses === undefined
659
+ ? { ...invariants }
660
+ : { ...invariants, witnesses: Object.freeze({ ...witnesses }) },
661
+ )
662
+ }
663
+
514
664
  /** Identity helper for a `nifra.assurance.ts` default export. */
515
665
  export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig {
516
666
  if (config.manifest?.path !== undefined && config.manifest.path.trim() === "") {
@@ -519,9 +669,7 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
519
669
  if (config.manifest?.signer !== undefined && typeof config.manifest.signer !== "function") {
520
670
  throw new Error("route assurance: manifest signer must be a function")
521
671
  }
522
- if (config.invariants !== undefined && typeof config.invariants.executor !== "function") {
523
- throw new Error("route assurance: invariant executor must be a function")
524
- }
672
+ if (config.invariants !== undefined) assertInvariantsConfig(config.invariants)
525
673
  if (config.rulePacks !== undefined) {
526
674
  if (!Array.isArray(config.rulePacks))
527
675
  throw new TypeError("route assurance: rulePacks must be an array")
@@ -616,7 +764,7 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
616
764
  }
617
765
  : {}),
618
766
  ...(config.invariants !== undefined
619
- ? { invariants: Object.freeze({ executor: config.invariants.executor }) }
767
+ ? { invariants: frozenInvariantsConfig(config.invariants) }
620
768
  : {}),
621
769
  ...(config.rulePacks !== undefined ? { rulePacks: Object.freeze([...config.rulePacks]) } : {}),
622
770
  ...(config.assureSink !== undefined ? { assureSink: config.assureSink } : {}),
@@ -656,8 +804,9 @@ export function matchesAssuranceSelector(
656
804
  definitions?: ReadonlyMap<string, CapabilityDefinition>,
657
805
  ): boolean {
658
806
  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
807
+ const { exceptMethods, hasBody, bodyLimit } = selector
808
+ if (methods !== undefined && !methods.includes(route.method as Method | "WS")) return false
809
+ if (exceptMethods?.some((method) => method === route.method) === true) return false
661
810
  if (paths !== undefined && !paths.some((pattern) => routeGlob(pattern).test(route.path)))
662
811
  return false
663
812
  if (tools !== undefined && (route.tool !== undefined) !== tools) return false
@@ -195,6 +195,9 @@ export interface AssuredCapabilityRoute {
195
195
  readonly covered: boolean
196
196
  /** Highest data-sensitivity the response carries, when the route declares it. */
197
197
  readonly classification?: DataClassification
198
+ /** `"declared"` for a route an opaque mount states it serves: listed on the mount's word, never
199
+ * analyzed. Absent on an analyzed route. */
200
+ readonly analysis?: "declared"
198
201
  }
199
202
 
200
203
  /**
@@ -225,6 +228,7 @@ export interface CapabilitySnapshotRoute {
225
228
  readonly unproven: readonly string[]
226
229
  /** Recorded so a route that starts returning `pii`/`secret` flips the lockfile and forces a review. */
227
230
  readonly classification?: DataClassification
231
+ readonly analysis?: "declared"
228
232
  }
229
233
 
230
234
  export interface CapabilitySnapshot {
@@ -513,6 +517,45 @@ export function evaluateCapabilityAssurance(
513
517
  for (const mount of evidenceSet.mounts ?? reflectMounts(source)) {
514
518
  if (mount.opaque !== undefined) {
515
519
  gaps.push(Object.freeze({ kind: "opaque-mount", path: mount.path, reason: mount.opaque }))
520
+ for (const route of mount.routes ?? []) {
521
+ // Only what a declaration alone can contradict: an id the policy lacks, or a write a safe
522
+ // method can never carry. Evidence-backed checks have nothing to read here.
523
+ for (const id of route.capabilities) {
524
+ const definition = definitions.get(id)
525
+ if (definition === undefined) {
526
+ findings.push({
527
+ code: "unknown-capability",
528
+ method: route.method,
529
+ path: route.path,
530
+ capability: id,
531
+ message: `${route.method} ${route.path} (declared by mount ${mount.path}) declares unknown capability ${id}`,
532
+ })
533
+ } else if (
534
+ SAFE_METHODS.has(route.method) &&
535
+ definition.zone === "domain" &&
536
+ definition.access === "write"
537
+ ) {
538
+ findings.push({
539
+ code: "safe-method-domain-write",
540
+ method: route.method,
541
+ path: route.path,
542
+ capability: id,
543
+ message: `${route.method} ${route.path} (declared by mount ${mount.path}) cannot carry domain write capability ${id}`,
544
+ })
545
+ }
546
+ }
547
+ routes.push(
548
+ Object.freeze({
549
+ method: route.method,
550
+ path: route.path,
551
+ declared: route.capabilities,
552
+ evidence: Object.freeze([]),
553
+ unproven: route.capabilities,
554
+ covered: false,
555
+ analysis: "declared",
556
+ }),
557
+ )
558
+ }
516
559
  continue
517
560
  }
518
561
  findings.push({
@@ -722,6 +765,21 @@ async function runCapabilityInterceptors(
722
765
  await nextPromise
723
766
  }
724
767
 
768
+ /** Preserve the request's effect/admission scope while restricting a nested execution's privileges. */
769
+ export function narrowCapabilityContext(context: object, allowed: readonly string[]): object {
770
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The private symbol stores only the capability guard installed by owner admission.
771
+ const guard = (context as { readonly [CAPABILITY_GUARD]?: CapabilityGuard })[CAPABILITY_GUARD]
772
+ const capabilities = Object.freeze([...new Set(allowed)])
773
+ for (const capability of capabilities) guardFor(context, capability)
774
+ const child: object = Object.create(context)
775
+ if (guard !== undefined) {
776
+ Object.defineProperty(child, CAPABILITY_GUARD, {
777
+ value: Object.freeze({ ...guard, allowed: capabilities }),
778
+ })
779
+ }
780
+ return child
781
+ }
782
+
725
783
  function guardFor(context: object, capability: string): CapabilityGuard {
726
784
  if (!validCapabilityId(capability)) {
727
785
  throw new Error(
@@ -914,6 +972,7 @@ export function snapshotCapabilities(report: CapabilityAssuranceReport): Capabil
914
972
  evidenced: Object.freeze([...new Set(route.evidence.map((item) => item.id))].sort()),
915
973
  unproven: Object.freeze([...route.unproven].sort()),
916
974
  ...(route.classification !== undefined ? { classification: route.classification } : {}),
975
+ ...(route.analysis !== undefined ? { analysis: route.analysis } : {}),
917
976
  }),
918
977
  )
919
978
  .sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
@@ -235,7 +235,7 @@ async function scanEffectRecords(
235
235
  ): Promise<ReconciliationScanPage<DurableEffectRecord>> {
236
236
  if (store.scan !== undefined) return await store.scan(input)
237
237
  if (store.list === undefined) throw new TypeError("effect store does not support reconciliation")
238
- return memoryScan(await store.list(), input)
238
+ return memoryScan(await store.list(), (record) => record.effectId, input)
239
239
  }
240
240
 
241
241
  export async function reconcileEffectsPage(
@@ -118,6 +118,12 @@ export interface SagaEngineOptions {
118
118
  readonly observer?: EffectLifecycleObserver
119
119
  /** Tests/local development only. Production sagas require a durable store. */
120
120
  readonly allowMemoryStore?: boolean
121
+ /**
122
+ * How long one `resume` or `compensate` call owns a saga. A second call while the lease is live
123
+ * throws `SagaConcurrencyError` and changes nothing; every transition renews the lease, and a call
124
+ * that died is taken over once it lapses. Default 5 minutes: longer than any one step should run.
125
+ */
126
+ readonly leaseMs?: number
121
127
  }
122
128
 
123
129
  export type SagaAmbiguityResolution =
@@ -191,6 +197,8 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
191
197
  const clock = options.now ?? Date.now
192
198
  const now = (): number => readClock(clock, "saga clock")
193
199
  const observer = options.observer
200
+ const leaseMs = options.leaseMs ?? 300_000
201
+ assertPositiveMs(leaseMs, "saga leaseMs")
194
202
 
195
203
  const load = async (sagaId: string): Promise<SagaRecord> => {
196
204
  const record = await options.store.get(sagaId)
@@ -207,6 +215,12 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
207
215
  throw new TypeError(`saga ${sagaId}: store returned an invalid record`)
208
216
  }
209
217
  assertToken(record.definition, "stored saga definition", 128)
218
+ if (record.owner !== undefined) {
219
+ assertToken(record.owner, "stored saga owner", 64)
220
+ if (record.leaseUntil === undefined)
221
+ throw new TypeError(`saga ${sagaId}: store returned an owner without a lease`)
222
+ assertTimestamp(record.leaseUntil, "stored saga leaseUntil")
223
+ }
210
224
  assertTimestamp(record.createdAt, "stored saga createdAt")
211
225
  assertTimestamp(record.updatedAt, "stored saga updatedAt")
212
226
  if (record.createdAt > record.updatedAt)
@@ -252,7 +266,14 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
252
266
  const next = durableSagaProtocol.transition(current, {
253
267
  sagaId: current.sagaId,
254
268
  version: current.version,
255
- record: Object.freeze({ ...update, version: current.version + 1 }),
269
+ record: Object.freeze({
270
+ ...update,
271
+ // Every transition the owner writes renews its lease.
272
+ ...(update.owner === undefined
273
+ ? {}
274
+ : { leaseUntil: addDuration(now(), leaseMs, "saga leaseUntil") }),
275
+ version: current.version + 1,
276
+ }),
256
277
  })
257
278
  if (next === undefined) throw new SagaConcurrencyError(current.sagaId)
258
279
  if (
@@ -275,10 +296,51 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
275
296
  ...(observer === undefined ? {} : { observers: [observer] }),
276
297
  })
277
298
 
299
+ /** Take the saga for `owner`, or throw `SagaConcurrencyError` while another call's lease is live. */
300
+ const claim = async (record: SagaRecord, owner: string): Promise<SagaRecord> => {
301
+ if (record.owner === owner) return record
302
+ if (record.owner !== undefined && record.leaseUntil !== undefined && record.leaseUntil > now())
303
+ throw new SagaConcurrencyError(record.sagaId)
304
+ return await save(record, { ...record, owner, updatedAt: now() })
305
+ }
306
+
307
+ /** Give the saga up so the next call need not wait out the lease. */
308
+ const release = async (sagaId: string, owner: string): Promise<void> => {
309
+ try {
310
+ const record = await load(sagaId)
311
+ if (record.owner !== owner) return
312
+ const { owner: _owner, leaseUntil: _leaseUntil, ...released } = record
313
+ await save(record, { ...released, updatedAt: now() })
314
+ } catch {
315
+ // A release that fails leaves the lease to lapse; the next call then takes the saga over.
316
+ }
317
+ }
318
+
319
+ /** The saga as `owner` holds it; once another call took it over, `owner` must stop writing. */
320
+ const loadOwned = async (sagaId: string, owner: string): Promise<SagaRecord> => {
321
+ const record = await load(sagaId)
322
+ if (record.owner !== owner) throw new SagaConcurrencyError(sagaId)
323
+ return record
324
+ }
325
+
278
326
  const compensate = async <I, C extends Record<string, unknown>>(
279
327
  definition: SagaDefinition<I, C>,
280
328
  sagaId: string,
281
329
  runOptions: { readonly signal?: AbortSignal } = {},
330
+ ): Promise<SagaRecord> => {
331
+ const owner = crypto.randomUUID()
332
+ try {
333
+ return await compensateOwned(definition, sagaId, runOptions, owner)
334
+ } finally {
335
+ await release(sagaId, owner)
336
+ }
337
+ }
338
+
339
+ const compensateOwned = async <I, C extends Record<string, unknown>>(
340
+ definition: SagaDefinition<I, C>,
341
+ sagaId: string,
342
+ runOptions: { readonly signal?: AbortSignal },
343
+ owner: string,
282
344
  ): Promise<SagaRecord> => {
283
345
  assertSagaDefinition(definition)
284
346
  assertToken(sagaId, "saga id", 128)
@@ -287,6 +349,7 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
287
349
  let record = await load(sagaId)
288
350
  if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
289
351
  if (record.state === "completed" || record.state === "compensated") return record
352
+ record = await claim(record, owner)
290
353
  if (
291
354
  record.steps.some(
292
355
  (step) =>
@@ -402,6 +465,20 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
402
465
  definition: SagaDefinition<I, C>,
403
466
  sagaId: string,
404
467
  runOptions: { readonly signal?: AbortSignal } = {},
468
+ ): Promise<SagaRecord> => {
469
+ const owner = crypto.randomUUID()
470
+ try {
471
+ return await resumeOwned(definition, sagaId, runOptions, owner)
472
+ } finally {
473
+ await release(sagaId, owner)
474
+ }
475
+ }
476
+
477
+ const resumeOwned = async <I, C extends Record<string, unknown>>(
478
+ definition: SagaDefinition<I, C>,
479
+ sagaId: string,
480
+ runOptions: { readonly signal?: AbortSignal },
481
+ owner: string,
405
482
  ): Promise<SagaRecord> => {
406
483
  assertSagaDefinition(definition)
407
484
  assertToken(sagaId, "saga id", 128)
@@ -410,6 +487,7 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
410
487
  let record = await load(sagaId)
411
488
  if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
412
489
  if (record.state !== "running") return record
490
+ record = await claim(record, owner)
413
491
  const context: SagaRunContext<C> = {
414
492
  async step(name, compensationArgs, execute) {
415
493
  throwIfAborted(signal)
@@ -418,15 +496,15 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
418
496
  throw new TypeError("saga step execute must be a function")
419
497
  if (typeof definition.compensators[name] !== "function")
420
498
  throw new TypeError(`saga step ${name} has no compensator`)
421
- record = await load(sagaId)
499
+ record = await loadOwned(sagaId, owner)
500
+ if (record.state !== "running") throw new SagaConcurrencyError(sagaId)
422
501
  const existingIndex = record.steps.findIndex((step) => step.name === name)
423
502
  if (existingIndex >= 0) {
424
503
  const existing = record.steps[existingIndex] as SagaStepRecord
425
504
  if (existing.state === "committed")
426
505
  return cloneValue(existing.result) as Awaited<ReturnType<typeof execute>>
427
506
  if (existing.state === "executing" || existing.state === "ambiguous") {
428
- if (record.state !== "manual-review")
429
- record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
507
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
430
508
  throw new SagaAmbiguousStepError(sagaId, name, existing.effectId)
431
509
  }
432
510
  throw new Error(`saga ${sagaId}: step ${name} cannot resume from ${existing.state}`)
@@ -463,8 +541,9 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
463
541
  })
464
542
  },
465
543
  async committed(_owned, result) {
466
- record = await load(sagaId)
544
+ record = await loadOwned(sagaId, owner)
467
545
  const index = record.steps.findIndex((candidate) => candidate.effectId === effectId)
546
+ if (index < 0) throw new SagaConcurrencyError(sagaId)
468
547
  const committed = {
469
548
  ...(record.steps[index] as SagaStepRecord),
470
549
  state: "committed" as const,
@@ -473,8 +552,10 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
473
552
  record = await save(record, withStep(record, index, committed))
474
553
  },
475
554
  async failed(_owned, failure) {
476
- record = await load(sagaId)
555
+ record = await loadOwned(sagaId, owner)
477
556
  const index = record.steps.findIndex((candidate) => candidate.effectId === effectId)
557
+ // The step was never recorded: its intent write lost a race, so it never ran.
558
+ if (index < 0) throw new SagaConcurrencyError(sagaId)
478
559
  const known = failure.error instanceof SagaStepNotCommittedError || !failure.began
479
560
  const failed = {
480
561
  ...(record.steps[index] as SagaStepRecord),
@@ -500,14 +581,17 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
500
581
  try {
501
582
  throwIfAborted(signal)
502
583
  await definition.run(context, cloneValue(record.input) as I)
503
- record = await load(sagaId)
584
+ record = await loadOwned(sagaId, owner)
504
585
  if (record.state === "running")
505
586
  record = await save(record, { ...record, state: "completed", updatedAt: now() })
506
587
  return record
507
588
  } catch (error) {
589
+ // Another call holds the saga, or won a write this one lost: what ran is that call's to
590
+ // finish or roll back, and compensating here would undo it.
591
+ if (error instanceof SagaConcurrencyError) throw error
508
592
  record = await load(sagaId)
509
- if (record.state === "manual-review") throw error
510
- await compensate(definition, sagaId, runOptions)
593
+ if (record.state === "manual-review" || record.owner !== owner) throw error
594
+ await compensateOwned(definition, sagaId, runOptions, owner)
511
595
  throw error
512
596
  }
513
597
  }
@@ -676,7 +760,7 @@ async function scanSagaRecords(
676
760
  ): Promise<ReconciliationScanPage<SagaRecord>> {
677
761
  if (store.scan !== undefined) return await store.scan(input)
678
762
  if (store.list === undefined) throw new TypeError("saga store does not support reconciliation")
679
- return memoryScan(await store.list(), input)
763
+ return memoryScan(await store.list(), (record) => record.sagaId, input)
680
764
  }
681
765
 
682
766
  export async function reconcileSagasPage(
package/src/edge-kit.ts CHANGED
@@ -8,15 +8,20 @@
8
8
  * exposes the one lane the fused body runner already calls, so a compact server reuses it verbatim:
9
9
  *
10
10
  * - {@link readBodyFramed} read + cap + proto-guard + content-type framing (JSON / urlencoded / 415)
11
- * - {@link toResponse} render a {@link ResponseResult} (e.g. a 413 / 415 rejection) to a `Response`
11
+ * - {@link capBodyBytes} the same byte cap on a handler's own reads of the raw request
12
+ * - {@link renderResult} render a {@link ResponseResult} (e.g. a 413 / 415 rejection) to a `Response`
13
+ * - {@link toResponse} render any handler result, a handler's own `Response` included
12
14
  * - {@link plainError} the structured rejection envelope the lane returns
13
15
  * - {@link queryObjectOf} / {@link searchOf} query parse without a full WHATWG URL construction
14
16
  * - {@link answerNotFound} run a not-found handler under the same rules (404 only, no 2xx, fail closed)
17
+ * - {@link emitRequestErrorLog} log an unhandled request error under the app's `errorLogDetail`
15
18
  *
16
19
  * Everything here carries only structure - bytes, guards, and envelopes - never routing, lifecycle,
17
20
  * or app state. It is the interface, not the framework.
18
21
  */
19
22
 
23
+ export { type ErrorLogDetail, emitRequestErrorLog } from "./server/bare-error-lane.ts"
24
+ export { capBodyBytes } from "./server/body.ts"
20
25
  export { readBodyFramed } from "./server/body-lane.ts"
21
26
  export { plainError } from "./server/http.ts"
22
27
  export {
@@ -27,7 +32,7 @@ export {
27
32
  } from "./server/not-found-answer.ts"
28
33
  export type { ProtoPoisoning } from "./server/proto-guard.ts"
29
34
  export { type QueryValue, queryObjectOf, searchOf } from "./server/query.ts"
30
- export { toResponse } from "./server/respond.ts"
35
+ export { renderResult, toResponse } from "./server/respond.ts"
31
36
  export type { ResponseResult } from "./server/runtime-core.ts"
32
37
  export { EMPTY_RESPONSE_CONTROLS } from "./server/runtime-core.ts"
33
38
  export type { CtxSet, MaybePromise, RequestSource } from "./server/server.ts"
@@ -234,10 +234,20 @@ export function createEffectScope(
234
234
  })
235
235
  return result
236
236
  } catch (error) {
237
- const errorCode = codeFor(input.errorCode, error, began)
238
- const failurePhase = input.failurePhase?.(error, began) ?? (began ? "ambiguous" : "failed")
239
- if (failurePhase !== "failed" && failurePhase !== "ambiguous") {
240
- throw new TypeError("owned effect failurePhase returned an invalid phase")
237
+ // A misconfigured `errorCode` or `failurePhase` is thrown only once the effect is settled
238
+ // with the defaults: thrown here, it left the durable record unfinished.
239
+ let misconfigured: { readonly error: unknown } | undefined
240
+ let errorCode = began ? "execution_failed" : "admission_failed"
241
+ let failurePhase: "failed" | "ambiguous" = began ? "ambiguous" : "failed"
242
+ try {
243
+ errorCode = codeFor(input.errorCode, error, began)
244
+ const phase = input.failurePhase?.(error, began) ?? failurePhase
245
+ if (phase !== "failed" && phase !== "ambiguous") {
246
+ throw new TypeError("owned effect failurePhase returned an invalid phase")
247
+ }
248
+ failurePhase = phase
249
+ } catch (invalid) {
250
+ misconfigured = { error: invalid }
241
251
  }
242
252
  try {
243
253
  await input.transitions?.failed?.(context, { began, errorCode, error })
@@ -253,7 +263,7 @@ export function createEffectScope(
253
263
  errorCode,
254
264
  ...(input.trace === undefined ? {} : { trace: input.trace }),
255
265
  })
256
- throw error
266
+ throw misconfigured === undefined ? error : misconfigured.error
257
267
  }
258
268
  },
259
269
  evidence: () => evidenceScope.evidence(),