@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
@@ -154,6 +154,8 @@ export interface RouteCapabilityEvidence {
154
154
  /** True only when the route's reachable module graph was actually scanned. */
155
155
  readonly covered: boolean
156
156
  readonly evidence: readonly CapabilityEvidence[]
157
+ /** The probable cause of an uncovered route, when the collector can tell. */
158
+ readonly uncoveredReason?: string
157
159
  }
158
160
 
159
161
  export interface CapabilityEvidenceSet {
@@ -195,6 +197,9 @@ export interface AssuredCapabilityRoute {
195
197
  readonly covered: boolean
196
198
  /** Highest data-sensitivity the response carries, when the route declares it. */
197
199
  readonly classification?: DataClassification
200
+ /** `"declared"` for a route an opaque mount states it serves: listed on the mount's word, never
201
+ * analyzed. Absent on an analyzed route. */
202
+ readonly analysis?: "declared"
198
203
  }
199
204
 
200
205
  /**
@@ -225,6 +230,7 @@ export interface CapabilitySnapshotRoute {
225
230
  readonly unproven: readonly string[]
226
231
  /** Recorded so a route that starts returning `pii`/`secret` flips the lockfile and forces a review. */
227
232
  readonly classification?: DataClassification
233
+ readonly analysis?: "declared"
228
234
  }
229
235
 
230
236
  export interface CapabilitySnapshot {
@@ -412,7 +418,7 @@ export function evaluateCapabilityAssurance(
412
418
  code: "provenance-uncovered",
413
419
  method: route.method,
414
420
  path: route.path,
415
- message: `${route.method} ${route.path} has no static provenance coverage`,
421
+ message: `${route.method} ${route.path} has no static provenance coverage${supplied?.uncoveredReason === undefined ? "" : ` - ${supplied.uncoveredReason}`}`,
416
422
  })
417
423
  }
418
424
  // A safe-method route that REACHES a domain write without declaring it used to draw two findings
@@ -513,6 +519,45 @@ export function evaluateCapabilityAssurance(
513
519
  for (const mount of evidenceSet.mounts ?? reflectMounts(source)) {
514
520
  if (mount.opaque !== undefined) {
515
521
  gaps.push(Object.freeze({ kind: "opaque-mount", path: mount.path, reason: mount.opaque }))
522
+ for (const route of mount.routes ?? []) {
523
+ // Only what a declaration alone can contradict: an id the policy lacks, or a write a safe
524
+ // method can never carry. Evidence-backed checks have nothing to read here.
525
+ for (const id of route.capabilities) {
526
+ const definition = definitions.get(id)
527
+ if (definition === undefined) {
528
+ findings.push({
529
+ code: "unknown-capability",
530
+ method: route.method,
531
+ path: route.path,
532
+ capability: id,
533
+ message: `${route.method} ${route.path} (declared by mount ${mount.path}) declares unknown capability ${id}`,
534
+ })
535
+ } else if (
536
+ SAFE_METHODS.has(route.method) &&
537
+ definition.zone === "domain" &&
538
+ definition.access === "write"
539
+ ) {
540
+ findings.push({
541
+ code: "safe-method-domain-write",
542
+ method: route.method,
543
+ path: route.path,
544
+ capability: id,
545
+ message: `${route.method} ${route.path} (declared by mount ${mount.path}) cannot carry domain write capability ${id}`,
546
+ })
547
+ }
548
+ }
549
+ routes.push(
550
+ Object.freeze({
551
+ method: route.method,
552
+ path: route.path,
553
+ declared: route.capabilities,
554
+ evidence: Object.freeze([]),
555
+ unproven: route.capabilities,
556
+ covered: false,
557
+ analysis: "declared",
558
+ }),
559
+ )
560
+ }
516
561
  continue
517
562
  }
518
563
  findings.push({
@@ -722,6 +767,21 @@ async function runCapabilityInterceptors(
722
767
  await nextPromise
723
768
  }
724
769
 
770
+ /** Preserve the request's effect/admission scope while restricting a nested execution's privileges. */
771
+ export function narrowCapabilityContext(context: object, allowed: readonly string[]): object {
772
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The private symbol stores only the capability guard installed by owner admission.
773
+ const guard = (context as { readonly [CAPABILITY_GUARD]?: CapabilityGuard })[CAPABILITY_GUARD]
774
+ const capabilities = Object.freeze([...new Set(allowed)])
775
+ for (const capability of capabilities) guardFor(context, capability)
776
+ const child: object = Object.create(context)
777
+ if (guard !== undefined) {
778
+ Object.defineProperty(child, CAPABILITY_GUARD, {
779
+ value: Object.freeze({ ...guard, allowed: capabilities }),
780
+ })
781
+ }
782
+ return child
783
+ }
784
+
725
785
  function guardFor(context: object, capability: string): CapabilityGuard {
726
786
  if (!validCapabilityId(capability)) {
727
787
  throw new Error(
@@ -914,6 +974,7 @@ export function snapshotCapabilities(report: CapabilityAssuranceReport): Capabil
914
974
  evidenced: Object.freeze([...new Set(route.evidence.map((item) => item.id))].sort()),
915
975
  unproven: Object.freeze([...route.unproven].sort()),
916
976
  ...(route.classification !== undefined ? { classification: route.classification } : {}),
977
+ ...(route.analysis !== undefined ? { analysis: route.analysis } : {}),
917
978
  }),
918
979
  )
919
980
  .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,16 +8,21 @@
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
 
20
- export { readBodyFramed } from "./server/body-lane.ts"
23
+ export { type ErrorLogDetail, emitRequestErrorLog } from "./server/bare-error-lane.ts"
24
+ export { capBodyBytes } from "./server/body.ts"
25
+ export { type FormOrigins, readBodyFramed } from "./server/body-lane.ts"
21
26
  export { plainError } from "./server/http.ts"
22
27
  export {
23
28
  answerNotFound,
@@ -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(),
package/src/errors.ts CHANGED
@@ -31,6 +31,7 @@ export type RouteConfigErrorCode =
31
31
  | "INVALID_VALIDATION_ORDER"
32
32
  | "INVALID_BODY_LIMIT"
33
33
  | "PLUGIN_RECONFIGURED"
34
+ | "MOUNT_CYCLE"
34
35
 
35
36
  /**
36
37
  * Thrown at route registration when a route is misconfigured. This is the
package/src/evidence.ts CHANGED
@@ -34,6 +34,8 @@ export interface ProjectEvidenceSchemaPart {
34
34
  }
35
35
 
36
36
  export interface ProjectEvidenceSchema {
37
+ /** Set only when the route chose it; absent means the default for its auth stages. */
38
+ readonly validationOrder?: "validate-before-auth" | "auth-before-validation"
37
39
  readonly bodyLimit?: number | "unlimited"
38
40
  readonly bodyLimitReason?: string
39
41
  readonly headers?: ProjectEvidenceSchemaPart
@@ -160,6 +162,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
160
162
  if (part !== undefined) errors[status] = part
161
163
  }
162
164
  const schema: ProjectEvidenceSchema = {
165
+ ...(source.validationOrder !== undefined ? { validationOrder: source.validationOrder } : {}),
163
166
  ...(source.bodyLimit !== undefined ? { bodyLimit: source.bodyLimit } : {}),
164
167
  ...(source.bodyLimitReason !== undefined ? { bodyLimitReason: source.bodyLimitReason } : {}),
165
168
  ...(headers !== undefined ? { headers } : {}),
@@ -328,9 +331,14 @@ function remapEvidenceSnapshot(
328
331
  const key = route(item.method, item.path)
329
332
  return Object.freeze({ ...item, ...key })
330
333
  })
331
- const mounts = evidence.mounts?.map((item) =>
332
- Object.freeze({ ...item, path: composedPath(pathPrefix, item.path) }),
333
- )
334
+ const mounts = evidence.mounts?.map((item) => {
335
+ const path = composedPath(pathPrefix, item.path)
336
+ if (item.routes === undefined) return Object.freeze({ ...item, path })
337
+ const routes = item.routes.map((declared) =>
338
+ Object.freeze({ ...declared, path: composedPath(pathPrefix, declared.path) }),
339
+ )
340
+ return Object.freeze({ ...item, path, routes: Object.freeze(routes) })
341
+ })
334
342
  const assurance =
335
343
  evidence.assurance === undefined
336
344
  ? undefined
@@ -499,6 +507,7 @@ function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): Reflecte
499
507
  const response = schemaPartToReflection(schema.response)
500
508
  const sse = schemaPartToReflection(schema.sse)
501
509
  return {
510
+ ...(schema.validationOrder !== undefined ? { validationOrder: schema.validationOrder } : {}),
502
511
  ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
503
512
  ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
504
513
  ...(headers !== undefined ? { headers } : {}),
package/src/index.ts CHANGED
@@ -16,13 +16,13 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "4.0.2" as const
19
+ export const VERSION = "5.1.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
23
23
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
24
24
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
25
- export { isSameOriginRequest } from "./internal/same-origin.ts"
25
+ export { isSameOriginForm, isSameOriginRequest } from "./internal/same-origin.ts"
26
26
  export {
27
27
  ROUTE_PATTERN_OVERLAP_MAX_STATES,
28
28
  RoutePatternOverlapLimitError,
@@ -54,6 +54,8 @@ export {
54
54
  type AuthHeaders,
55
55
  type AuthMaybePromise,
56
56
  authenticated,
57
+ isAuthenticatedRequest,
58
+ markAuthenticatedRequest,
57
59
  rejected,
58
60
  } from "./server/auth.ts"
59
61
  export type {
@@ -116,6 +118,7 @@ export type {
116
118
  RouteInfoFor,
117
119
  } from "./server/registry.ts"
118
120
  export { replacedRequestOf } from "./server/request-lineage.ts"
121
+ export { writableResponse } from "./server/respond.ts"
119
122
  export {
120
123
  type PlainRender,
121
124
  type ResponseResult,
@@ -127,6 +130,7 @@ export {
127
130
  type AdmissionDecision,
128
131
  type AnyServer,
129
132
  type ContextPlugin,
133
+ type DeclaredMountRoute,
130
134
  type DefinePluginResult,
131
135
  defineContextPlugin,
132
136
  defineIdentityPlugin,
@@ -150,6 +154,7 @@ export {
150
154
  type NodeResponseHook,
151
155
  type NodeServeOutcome,
152
156
  type OnRequestResult,
157
+ type OpaqueMountDeclaration,
153
158
  type PluginTypeCollapsed,
154
159
  type PromptArgument,
155
160
  type PromptMessage,
@@ -63,12 +63,22 @@ export function reconciliationLimit(value = DEFAULT_RECONCILIATION_LIMIT): numbe
63
63
  return value
64
64
  }
65
65
 
66
- export function cursorOffset(cursor?: string): number {
67
- if (cursor === undefined) return 0
68
- if (!/^(?:0|[1-9][0-9]*)$/u.test(cursor)) throw new TypeError("invalid reconciliation cursor")
69
- const offset = Number(cursor)
70
- if (!Number.isSafeInteger(offset)) throw new TypeError("invalid reconciliation cursor")
71
- return offset
66
+ /**
67
+ * One page of `matches` in id order, the cursor being the last id returned. Keyed rather than an
68
+ * offset into the matching set: a caller reconciles each page before asking for the next, so the
69
+ * set shrinks under it, and an offset then lands past records that were never returned.
70
+ */
71
+ function keysetPage<Record>(
72
+ matches: [id: string, record: Record][],
73
+ limit: number,
74
+ ): ReconciliationScanPage<Record> {
75
+ // Code-unit order, the order the `id > cursor` comparisons use.
76
+ matches.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
77
+ const page = matches.slice(0, limit)
78
+ const records = Object.freeze(page.map(([, record]) => cloneValue(record)))
79
+ const last = page.at(-1)
80
+ if (matches.length <= limit || last === undefined) return Object.freeze({ records })
81
+ return Object.freeze({ records, cursor: last[0] })
72
82
  }
73
83
 
74
84
  export function memoryScan<
@@ -76,28 +86,18 @@ export function memoryScan<
76
86
  State extends string,
77
87
  >(
78
88
  source: Iterable<Record>,
89
+ idOf: (record: Record) => string,
79
90
  input: ReconciliationScanOptions<State>,
80
91
  ): ReconciliationScanPage<Record> {
81
92
  const states = new Set(input.states)
82
- const offset = cursorOffset(input.cursor)
83
- const end = offset + input.limit
84
- const records: Record[] = []
85
- let seen = 0
86
- let more = false
93
+ const matches: [string, Record][] = []
87
94
  for (const record of source) {
88
95
  if (!states.has(record.state)) continue
89
96
  if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore) continue
90
- if (seen >= end) {
91
- more = true
92
- break
93
- }
94
- if (seen >= offset) records.push(cloneValue(record))
95
- seen++
97
+ const id = idOf(record)
98
+ if (input.cursor === undefined || id > input.cursor) matches.push([id, record])
96
99
  }
97
- return Object.freeze({
98
- records: Object.freeze(records),
99
- ...(more ? { cursor: String(offset + records.length) } : {}),
100
- })
100
+ return keysetPage(matches, input.limit)
101
101
  }
102
102
 
103
103
  export function bucketAdd<State extends string>(
@@ -129,29 +129,17 @@ export function indexedScan<Record extends { readonly updatedAt: number }, State
129
129
  resolve: (id: string) => Record | undefined,
130
130
  input: ReconciliationScanOptions<State>,
131
131
  ): ReconciliationScanPage<Record> {
132
- const offset = cursorOffset(input.cursor)
133
- const end = offset + input.limit
134
- const records: Record[] = []
135
- let seen = 0
136
- let more = false
132
+ const matches: [string, Record][] = []
137
133
  for (const state of new Set(input.states)) {
138
- if (more) break
139
134
  const bucket = byState.get(state)
140
135
  if (bucket === undefined) continue
141
136
  for (const id of bucket) {
137
+ if (input.cursor !== undefined && id <= input.cursor) continue
142
138
  const record = resolve(id)
143
139
  if (record === undefined) continue
144
140
  if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore) continue
145
- if (seen >= end) {
146
- more = true
147
- break
148
- }
149
- if (seen >= offset) records.push(cloneValue(record))
150
- seen++
141
+ matches.push([id, record])
151
142
  }
152
143
  }
153
- return Object.freeze({
154
- records: Object.freeze(records),
155
- ...(more ? { cursor: String(offset + records.length) } : {}),
156
- })
144
+ return keysetPage(matches, input.limit)
157
145
  }
@@ -138,6 +138,9 @@ export interface SagaRecord {
138
138
  readonly createdAt: number
139
139
  readonly updatedAt: number
140
140
  readonly version: number
141
+ /** The `resume`/`compensate` call that owns the saga while `leaseUntil` is in the future. */
142
+ readonly owner?: string
143
+ readonly leaseUntil?: number
141
144
  }
142
145
 
143
146
  export interface SagaStore {