@nifrajs/core 2.0.0 → 2.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 (103) hide show
  1. package/README.md +27 -0
  2. package/dist/capabilities.d.ts +54 -2
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +240 -5
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/durable-adapters.d.ts +241 -0
  7. package/dist/durable-adapters.d.ts.map +1 -0
  8. package/dist/durable-adapters.js +697 -0
  9. package/dist/durable-adapters.js.map +1 -0
  10. package/dist/durable-execution.d.ts +349 -0
  11. package/dist/durable-execution.d.ts.map +1 -0
  12. package/dist/durable-execution.js +1173 -0
  13. package/dist/durable-execution.js.map +1 -0
  14. package/dist/effect-lifecycle.d.ts +42 -0
  15. package/dist/effect-lifecycle.d.ts.map +1 -0
  16. package/dist/effect-lifecycle.js +66 -0
  17. package/dist/effect-lifecycle.js.map +1 -0
  18. package/dist/effect-scope.d.ts +75 -0
  19. package/dist/effect-scope.d.ts.map +1 -0
  20. package/dist/effect-scope.js +177 -0
  21. package/dist/effect-scope.js.map +1 -0
  22. package/dist/idempotency-plugin.d.ts +5 -0
  23. package/dist/idempotency-plugin.d.ts.map +1 -1
  24. package/dist/idempotency-plugin.js +8 -0
  25. package/dist/idempotency-plugin.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/internal/capability-runtime.d.ts +62 -1
  29. package/dist/internal/capability-runtime.d.ts.map +1 -1
  30. package/dist/internal/capability-runtime.js +11 -2
  31. package/dist/internal/capability-runtime.js.map +1 -1
  32. package/dist/internal/effect-execution.d.ts +16 -0
  33. package/dist/internal/effect-execution.d.ts.map +1 -0
  34. package/dist/internal/effect-execution.js +50 -0
  35. package/dist/internal/effect-execution.js.map +1 -0
  36. package/dist/internal/safe-equal.d.ts +20 -0
  37. package/dist/internal/safe-equal.d.ts.map +1 -0
  38. package/dist/internal/safe-equal.js +31 -0
  39. package/dist/internal/safe-equal.js.map +1 -0
  40. package/dist/ledger.d.ts +14 -6
  41. package/dist/ledger.d.ts.map +1 -1
  42. package/dist/ledger.js +31 -5
  43. package/dist/ledger.js.map +1 -1
  44. package/dist/reconciliation-worker.d.ts +108 -0
  45. package/dist/reconciliation-worker.d.ts.map +1 -0
  46. package/dist/reconciliation-worker.js +239 -0
  47. package/dist/reconciliation-worker.js.map +1 -0
  48. package/dist/server/idempotency-lane.d.ts +1 -0
  49. package/dist/server/idempotency-lane.d.ts.map +1 -1
  50. package/dist/server/idempotency-lane.js +61 -37
  51. package/dist/server/idempotency-lane.js.map +1 -1
  52. package/dist/server/respond.d.ts.map +1 -1
  53. package/dist/server/respond.js.map +1 -1
  54. package/dist/server/server.d.ts +12 -0
  55. package/dist/server/server.d.ts.map +1 -1
  56. package/dist/server/server.js +33 -2
  57. package/dist/server/server.js.map +1 -1
  58. package/dist/server/transport-lane.d.ts +28 -0
  59. package/dist/server/transport-lane.d.ts.map +1 -0
  60. package/dist/server/transport-lane.js +106 -0
  61. package/dist/server/transport-lane.js.map +1 -0
  62. package/dist/server/websocket.d.ts +6 -0
  63. package/dist/server/websocket.d.ts.map +1 -1
  64. package/dist/server/websocket.js +10 -1
  65. package/dist/server/websocket.js.map +1 -1
  66. package/dist/transport-codec-rich.d.ts +8 -0
  67. package/dist/transport-codec-rich.d.ts.map +1 -0
  68. package/dist/transport-codec-rich.js +14 -0
  69. package/dist/transport-codec-rich.js.map +1 -0
  70. package/dist/transport-codec.d.ts +31 -0
  71. package/dist/transport-codec.d.ts.map +1 -0
  72. package/dist/transport-codec.js +190 -0
  73. package/dist/transport-codec.js.map +1 -0
  74. package/dist/transport-plugin.d.ts +3 -0
  75. package/dist/transport-plugin.d.ts.map +1 -0
  76. package/dist/transport-plugin.js +3 -0
  77. package/dist/transport-plugin.js.map +1 -0
  78. package/dist/wire.d.ts +58 -0
  79. package/dist/wire.d.ts.map +1 -0
  80. package/dist/wire.js +445 -0
  81. package/dist/wire.js.map +1 -0
  82. package/package.json +46 -1
  83. package/src/capabilities.ts +306 -5
  84. package/src/durable-adapters.ts +1039 -0
  85. package/src/durable-execution.ts +1676 -0
  86. package/src/effect-lifecycle.ts +120 -0
  87. package/src/effect-scope.ts +267 -0
  88. package/src/idempotency-plugin.ts +10 -0
  89. package/src/index.ts +1 -1
  90. package/src/internal/capability-runtime.ts +87 -1
  91. package/src/internal/effect-execution.ts +70 -0
  92. package/src/internal/safe-equal.ts +27 -0
  93. package/src/ledger.ts +45 -11
  94. package/src/reconciliation-worker.ts +372 -0
  95. package/src/server/idempotency-lane.ts +68 -34
  96. package/src/server/respond.ts +1 -0
  97. package/src/server/server.ts +46 -0
  98. package/src/server/transport-lane.ts +141 -0
  99. package/src/server/websocket.ts +15 -1
  100. package/src/transport-codec-rich.ts +20 -0
  101. package/src/transport-codec.ts +250 -0
  102. package/src/transport-plugin.ts +2 -0
  103. package/src/wire.ts +474 -0
@@ -0,0 +1,120 @@
1
+ import { validCapabilityId } from "./internal/capability-runtime.ts"
2
+ import { type EffectCost, type EffectMetadata, normalizeEffectMetadata } from "./ledger.ts"
3
+
4
+ /** A bounded, payload-free stage in an effect's lifecycle. */
5
+ export type EffectLifecycleStage = "admission" | "execution" | "compensation" | "reconciliation"
6
+
7
+ export type EffectLifecyclePhase = "started" | "succeeded" | "failed" | "ambiguous"
8
+
9
+ /** Trace-parent tokens copied structurally from an installed tracing plugin. */
10
+ export interface EffectTraceParent {
11
+ readonly traceId: string
12
+ readonly spanId: string
13
+ readonly sampled: boolean
14
+ }
15
+
16
+ /**
17
+ * Token-only lifecycle evidence. There is deliberately no payload, argument, result, error message,
18
+ * request, or context field, so observation adapters cannot accidentally export business data.
19
+ */
20
+ export interface EffectLifecycleEvent extends EffectMetadata {
21
+ readonly effectId: string
22
+ readonly capability: string
23
+ readonly stage: EffectLifecycleStage
24
+ readonly phase: EffectLifecyclePhase
25
+ readonly at: number
26
+ readonly durationMs?: number
27
+ readonly attempt?: number
28
+ readonly errorCode?: string
29
+ readonly trace?: EffectTraceParent
30
+ }
31
+
32
+ /** Observation is fail-open: a broken sink must never change effect behavior. */
33
+ export type EffectLifecycleObserver = (event: EffectLifecycleEvent) => void
34
+
35
+ const ERROR_CODE = /^[a-z][a-z0-9_.-]{0,63}$/
36
+
37
+ export function effectTraceParentOf(context: object): EffectTraceParent | undefined {
38
+ const trace = (
39
+ context as {
40
+ readonly trace?: {
41
+ readonly traceId?: unknown
42
+ readonly spanId?: unknown
43
+ readonly sampled?: unknown
44
+ }
45
+ }
46
+ ).trace
47
+ if (
48
+ trace === undefined ||
49
+ typeof trace.traceId !== "string" ||
50
+ !/^[0-9a-f]{32}$/.test(trace.traceId) ||
51
+ typeof trace.spanId !== "string" ||
52
+ !/^[0-9a-f]{16}$/.test(trace.spanId) ||
53
+ typeof trace.sampled !== "boolean"
54
+ ) {
55
+ return undefined
56
+ }
57
+ return Object.freeze({ traceId: trace.traceId, spanId: trace.spanId, sampled: trace.sampled })
58
+ }
59
+
60
+ export interface EmitEffectLifecycleInput extends EffectMetadata {
61
+ readonly effectId: string
62
+ readonly capability: string
63
+ readonly stage: EffectLifecycleStage
64
+ readonly phase: EffectLifecyclePhase
65
+ readonly at?: number
66
+ readonly durationMs?: number
67
+ readonly attempt?: number
68
+ readonly errorCode?: string
69
+ readonly trace?: EffectTraceParent
70
+ }
71
+
72
+ /** @internal Validate, freeze, and fan out one token-only event. */
73
+ export function emitEffectLifecycle(
74
+ observers: readonly EffectLifecycleObserver[],
75
+ input: EmitEffectLifecycleInput,
76
+ ): void {
77
+ if (observers.length === 0) return
78
+ if (!validCapabilityId(input.capability) || !/^[!-~]{1,64}$/u.test(input.effectId)) return
79
+ let metadata: EffectMetadata
80
+ try {
81
+ metadata = normalizeEffectMetadata(input)
82
+ } catch {
83
+ return
84
+ }
85
+ const at = input.at ?? Date.now()
86
+ if (!Number.isFinite(at) || at < 0) return
87
+ if (
88
+ input.durationMs !== undefined &&
89
+ (!Number.isFinite(input.durationMs) || input.durationMs < 0)
90
+ ) {
91
+ return
92
+ }
93
+ if (input.attempt !== undefined && (!Number.isSafeInteger(input.attempt) || input.attempt < 1)) {
94
+ return
95
+ }
96
+ if (input.errorCode !== undefined && !ERROR_CODE.test(input.errorCode)) return
97
+ const event: EffectLifecycleEvent = Object.freeze({
98
+ effectId: input.effectId,
99
+ capability: input.capability,
100
+ stage: input.stage,
101
+ phase: input.phase,
102
+ at,
103
+ ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }),
104
+ ...(input.attempt === undefined ? {} : { attempt: input.attempt }),
105
+ ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
106
+ ...(metadata.target === undefined ? {} : { target: metadata.target }),
107
+ ...(metadata.cost === undefined
108
+ ? {}
109
+ : { cost: Object.freeze({ ...metadata.cost }) as EffectCost }),
110
+ ...(metadata.digest === undefined ? {} : { digest: metadata.digest }),
111
+ ...(input.trace === undefined ? {} : { trace: input.trace }),
112
+ })
113
+ for (const observer of observers) {
114
+ try {
115
+ observer(event)
116
+ } catch {
117
+ // Observation is deliberately fail-open.
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * One cross-runtime boundary for owned external effects. It centralizes correlation, lifecycle
3
+ * evidence, cancellation, durable transition ordering, and token-only observation without relying
4
+ * on Node-only async-local state.
5
+ */
6
+
7
+ import {
8
+ type EffectLifecycleObserver,
9
+ type EffectLifecycleStage,
10
+ type EffectTraceParent,
11
+ emitEffectLifecycle,
12
+ } from "./effect-lifecycle.ts"
13
+ import { validCapabilityId } from "./internal/capability-runtime.ts"
14
+ import { type EffectMetadata, normalizeEffectMetadata } from "./ledger.ts"
15
+
16
+ const ERROR_CODE = /^[a-z][a-z0-9_.-]{0,63}$/
17
+
18
+ export interface EffectScopeEvidence {
19
+ readonly began: boolean
20
+ readonly committed: boolean
21
+ readonly ambiguous: boolean
22
+ }
23
+
24
+ export interface OwnedEffectContext extends EffectMetadata {
25
+ readonly effectId: string
26
+ readonly capability: string
27
+ readonly stage: Exclude<EffectLifecycleStage, "admission" | "reconciliation">
28
+ readonly signal: AbortSignal
29
+ readonly attempt?: number
30
+ }
31
+
32
+ export interface OwnedEffectTransitions<T> {
33
+ intent?(context: OwnedEffectContext): void | PromiseLike<void>
34
+ executing?(context: OwnedEffectContext): void | PromiseLike<void>
35
+ committed?(context: OwnedEffectContext, result: T): void | PromiseLike<void>
36
+ failed?(
37
+ context: OwnedEffectContext,
38
+ input: { readonly began: boolean; readonly errorCode: string; readonly error: unknown },
39
+ ): void | PromiseLike<void>
40
+ }
41
+
42
+ export interface OwnedEffectRunOptions<T> extends EffectMetadata {
43
+ readonly effectId?: string
44
+ readonly capability: string
45
+ readonly stage?: Exclude<EffectLifecycleStage, "admission" | "reconciliation">
46
+ readonly signal?: AbortSignal
47
+ readonly attempt?: number
48
+ readonly trace?: EffectTraceParent
49
+ /** Per-run observers, combined with the scope's observers. */
50
+ readonly observers?: readonly EffectLifecycleObserver[]
51
+ /** Optional admission boundary executed before the effect is marked as begun. */
52
+ readonly admit?: (context: OwnedEffectContext) => void | PromiseLike<void>
53
+ readonly transitions?: OwnedEffectTransitions<T>
54
+ readonly errorCode?: string | ((error: unknown, began: boolean) => string)
55
+ /** Classify a caught execution failure when the caller can prove it is terminal. */
56
+ readonly failurePhase?: (error: unknown, began: boolean) => "failed" | "ambiguous"
57
+ }
58
+
59
+ export interface EffectScopeOptions {
60
+ readonly observers?: readonly EffectLifecycleObserver[]
61
+ readonly signal?: AbortSignal
62
+ readonly effectId?: () => string
63
+ }
64
+
65
+ export interface EffectEvidenceScope {
66
+ evidence(): EffectScopeEvidence
67
+ /** Legacy/manual effects cannot prove a terminal outcome and therefore become ambiguous. */
68
+ markBegan(): void
69
+ /** Pair a prior manual `markBegan()` with a proven commit. */
70
+ markCommitted(): void
71
+ /** Preserve uncertainty when a caller loses the terminal outcome. */
72
+ markAmbiguous(): void
73
+ /** Settle one begun effect as a proven terminal failure without claiming it committed. */
74
+ markFailed(): void
75
+ /**
76
+ * Declare that the enclosing request may release its idempotency reservation if it returns a
77
+ * server error before any owned effect begins.
78
+ */
79
+ markSafeToRetry(): void
80
+ /** True only while the retry declaration remains backed by evidence that no effect began. */
81
+ safeToRetry(): boolean
82
+ }
83
+
84
+ export interface EffectScope extends EffectEvidenceScope {
85
+ run<T>(
86
+ options: OwnedEffectRunOptions<T>,
87
+ execute: (context: OwnedEffectContext) => T | PromiseLike<T>,
88
+ ): Promise<T>
89
+ }
90
+
91
+ let neverSignal: AbortSignal | undefined
92
+ function signalOrNever(signal?: AbortSignal): AbortSignal {
93
+ neverSignal ??= new AbortController().signal
94
+ return signal ?? neverSignal
95
+ }
96
+
97
+ function assertEffectId(value: string): void {
98
+ if (!/^[!-~]{1,64}$/u.test(value))
99
+ throw new TypeError("effect id must be a bounded printable token")
100
+ }
101
+
102
+ function codeFor(
103
+ configured: OwnedEffectRunOptions<unknown>["errorCode"],
104
+ error: unknown,
105
+ began: boolean,
106
+ ): string {
107
+ const code =
108
+ typeof configured === "function"
109
+ ? configured(error, began)
110
+ : (configured ?? (began ? "execution_failed" : "admission_failed"))
111
+ if (!ERROR_CODE.test(code)) throw new TypeError("effect errorCode is invalid")
112
+ return code
113
+ }
114
+
115
+ /** Lightweight aggregate evidence shared by request idempotency and full owned-effect runners. */
116
+ export function createEffectEvidenceScope(): EffectEvidenceScope {
117
+ const aggregate = { began: false, committed: false }
118
+ let uncertain = 0
119
+ let retryDeclared = false
120
+ return Object.freeze({
121
+ evidence() {
122
+ return Object.freeze({ ...aggregate, ambiguous: uncertain > 0 })
123
+ },
124
+ markBegan() {
125
+ aggregate.began = true
126
+ retryDeclared = false
127
+ uncertain++
128
+ },
129
+ markCommitted() {
130
+ aggregate.began = true
131
+ aggregate.committed = true
132
+ if (uncertain > 0) uncertain--
133
+ },
134
+ markAmbiguous() {
135
+ aggregate.began = true
136
+ retryDeclared = false
137
+ if (uncertain === 0) uncertain = 1
138
+ },
139
+ markFailed() {
140
+ aggregate.began = true
141
+ retryDeclared = false
142
+ if (uncertain > 0) uncertain--
143
+ },
144
+ markSafeToRetry() {
145
+ if (aggregate.began) {
146
+ throw new Error("cannot mark an idempotent request safe to retry after an effect began")
147
+ }
148
+ retryDeclared = true
149
+ },
150
+ safeToRetry() {
151
+ return retryDeclared && !aggregate.began
152
+ },
153
+ })
154
+ }
155
+
156
+ export function createEffectScope(
157
+ options: EffectScopeOptions = {},
158
+ evidenceScope: EffectEvidenceScope = createEffectEvidenceScope(),
159
+ ): EffectScope {
160
+ const observers = Object.freeze([...(options.observers ?? [])])
161
+ const idFor = options.effectId ?? crypto.randomUUID.bind(crypto)
162
+
163
+ const scope: EffectScope = {
164
+ async run<T>(
165
+ input: OwnedEffectRunOptions<T>,
166
+ execute: (context: OwnedEffectContext) => T | PromiseLike<T>,
167
+ ) {
168
+ if (typeof execute !== "function")
169
+ throw new TypeError("owned effect executor must be a function")
170
+ if (!validCapabilityId(input.capability))
171
+ throw new TypeError("owned effect capability is invalid")
172
+ const effectId = input.effectId ?? idFor()
173
+ assertEffectId(effectId)
174
+ const metadata = normalizeEffectMetadata(input)
175
+ const signal = input.signal ?? options.signal ?? signalOrNever()
176
+ const stage = input.stage ?? "execution"
177
+ if (
178
+ input.attempt !== undefined &&
179
+ (!Number.isSafeInteger(input.attempt) || input.attempt < 1)
180
+ )
181
+ throw new RangeError("owned effect attempt must be a positive safe integer")
182
+ const context: OwnedEffectContext = Object.freeze({
183
+ effectId,
184
+ capability: input.capability,
185
+ stage,
186
+ signal,
187
+ ...metadata,
188
+ ...(input.attempt === undefined ? {} : { attempt: input.attempt }),
189
+ })
190
+ const runObservers =
191
+ input.observers === undefined
192
+ ? observers
193
+ : Object.freeze([...observers, ...input.observers])
194
+ let began = false
195
+ const startedAt = performance.now()
196
+ try {
197
+ if (signal.aborted) throw signal.reason
198
+ await input.transitions?.intent?.(context)
199
+ if (input.admit !== undefined) {
200
+ emitEffectLifecycle(runObservers, {
201
+ ...context,
202
+ stage: "admission",
203
+ phase: "started",
204
+ ...(input.trace === undefined ? {} : { trace: input.trace }),
205
+ })
206
+ await input.admit(context)
207
+ emitEffectLifecycle(runObservers, {
208
+ ...context,
209
+ stage: "admission",
210
+ phase: "succeeded",
211
+ durationMs: Math.max(0, performance.now() - startedAt),
212
+ ...(input.trace === undefined ? {} : { trace: input.trace }),
213
+ })
214
+ }
215
+ if (signal.aborted) throw signal.reason
216
+ await input.transitions?.executing?.(context)
217
+ began = true
218
+ evidenceScope.markBegan()
219
+ const executionStartedAt = performance.now()
220
+ emitEffectLifecycle(runObservers, {
221
+ ...context,
222
+ phase: "started",
223
+ ...(input.trace === undefined ? {} : { trace: input.trace }),
224
+ })
225
+ const result = await execute(context)
226
+ await input.transitions?.committed?.(context, result)
227
+ evidenceScope.markCommitted()
228
+ emitEffectLifecycle(runObservers, {
229
+ ...context,
230
+ phase: "succeeded",
231
+ durationMs: Math.max(0, performance.now() - executionStartedAt),
232
+ ...(input.trace === undefined ? {} : { trace: input.trace }),
233
+ })
234
+ return result
235
+ } catch (error) {
236
+ const errorCode = codeFor(input.errorCode, error, began)
237
+ const failurePhase = input.failurePhase?.(error, began) ?? (began ? "ambiguous" : "failed")
238
+ if (failurePhase !== "failed" && failurePhase !== "ambiguous") {
239
+ throw new TypeError("owned effect failurePhase returned an invalid phase")
240
+ }
241
+ try {
242
+ await input.transitions?.failed?.(context, { began, errorCode, error })
243
+ } catch {
244
+ // Preserve the owned effect's cause. Reconciliation sees the unfinished durable state.
245
+ }
246
+ if (began && failurePhase === "failed") evidenceScope.markFailed()
247
+ emitEffectLifecycle(runObservers, {
248
+ ...context,
249
+ stage: began ? stage : input.admit === undefined ? stage : "admission",
250
+ phase: began ? failurePhase : "failed",
251
+ durationMs: Math.max(0, performance.now() - startedAt),
252
+ errorCode,
253
+ ...(input.trace === undefined ? {} : { trace: input.trace }),
254
+ })
255
+ throw error
256
+ }
257
+ },
258
+ evidence: () => evidenceScope.evidence(),
259
+ markBegan: () => evidenceScope.markBegan(),
260
+ markCommitted: () => evidenceScope.markCommitted(),
261
+ markAmbiguous: () => evidenceScope.markAmbiguous(),
262
+ markFailed: () => evidenceScope.markFailed(),
263
+ markSafeToRetry: () => evidenceScope.markSafeToRetry(),
264
+ safeToRetry: () => evidenceScope.safeToRetry(),
265
+ }
266
+ return Object.freeze(scope)
267
+ }
@@ -5,3 +5,13 @@
5
5
  * dedupe lane. Import primitives and stores from `@nifrajs/core/idempotency`.
6
6
  */
7
7
  export { type IdempotencyPluginOptions, idempotency } from "./server/idempotency-lane.ts"
8
+
9
+ import { markRequestSafeToRetry } from "./internal/effect-execution.ts"
10
+
11
+ /**
12
+ * Opt a concrete 5xx response into releasing its idempotency reservation, but only while the
13
+ * request-local effect scope still proves that no owned effect began.
14
+ */
15
+ export function markIdempotencySafeToRetry(context: object): void {
16
+ markRequestSafeToRetry(context)
17
+ }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
11
11
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
12
12
  */
13
- export const VERSION = "2.0.0" as const
13
+ export const VERSION = "2.1.0" as const
14
14
 
15
15
  export type Version = typeof VERSION
16
16
 
@@ -1,3 +1,6 @@
1
+ import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
2
+ import type { EffectCost } from "../ledger.ts"
3
+
1
4
  export const CAPABILITY_GUARD = Symbol("nifra.capability.guard")
2
5
  const CAPABILITY_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
3
6
 
@@ -11,11 +14,83 @@ export interface CapabilityUseEvent {
11
14
  readonly path: string
12
15
  }
13
16
 
17
+ /** Token-only metadata supplied to an asynchronous capability admission interceptor. */
18
+ export interface CapabilityInterceptorEvent extends CapabilityUseEvent {
19
+ readonly effectId: string
20
+ readonly signal: AbortSignal
21
+ readonly target?: string
22
+ readonly cost?: EffectCost
23
+ readonly digest?: string
24
+ }
25
+
26
+ /** Server-owned identity binding for durable admission. Values are opaque tokens, never payloads. */
27
+ export interface CapabilityExecutionIdentity {
28
+ readonly tenantId: string
29
+ readonly principalId: string
30
+ }
31
+
32
+ export interface CapabilityApprovalInput extends CapabilityExecutionIdentity {
33
+ /** Signed token returned by the approval coordinator after the first suspended attempt. */
34
+ readonly resumeToken?: string
35
+ }
36
+
37
+ export interface CapabilityApprovalGate {
38
+ authorize(input: {
39
+ readonly effectId: string
40
+ readonly capability: string
41
+ readonly target?: string
42
+ readonly digest?: string
43
+ readonly identity: CapabilityExecutionIdentity
44
+ readonly resumeToken?: string
45
+ readonly signal: AbortSignal
46
+ }): void | PromiseLike<void>
47
+ }
48
+
49
+ /** Durable, token-only journal seam. Implementations must fail closed on transition failure. */
50
+ export interface CapabilityExecutionJournal {
51
+ intent(input: {
52
+ readonly effectId: string
53
+ readonly capability: string
54
+ readonly target?: string
55
+ readonly digest?: string
56
+ readonly identity?: CapabilityExecutionIdentity
57
+ }): void | PromiseLike<void>
58
+ executing(effectId: string): void | PromiseLike<void>
59
+ committed(effectId: string): void | PromiseLike<void>
60
+ failed(
61
+ effectId: string,
62
+ input: { readonly began: boolean; readonly errorCode: string },
63
+ ): void | PromiseLike<void>
64
+ }
65
+
66
+ /** Continue to the next admission policy. The owned effect runs only after the full chain admits. */
67
+ export type CapabilityInterceptorNext = () => Promise<void>
68
+
69
+ export type CapabilityInterceptor = (
70
+ event: CapabilityInterceptorEvent,
71
+ next: CapabilityInterceptorNext,
72
+ ) => void | PromiseLike<void>
73
+
74
+ export interface AroundCapabilityOptions {
75
+ /** Maximum admission time in milliseconds. Default 30_000. */
76
+ readonly timeoutMs?: number
77
+ }
78
+
79
+ export interface RegisteredCapabilityInterceptor {
80
+ readonly interceptor: CapabilityInterceptor
81
+ readonly timeoutMs: number
82
+ }
83
+
84
+ export const DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS = 30_000
85
+
14
86
  export interface CapabilityGuard {
15
87
  readonly allowed: readonly string[]
16
88
  readonly method: string
17
89
  readonly path: string
18
90
  readonly onUse?: (event: CapabilityUseEvent) => void
91
+ readonly trackEffect?: (context: object, committed: boolean) => void
92
+ readonly interceptors: readonly RegisteredCapabilityInterceptor[]
93
+ readonly observers: readonly EffectLifecycleObserver[]
19
94
  }
20
95
 
21
96
  export function normalizeRouteCapabilities(
@@ -44,6 +119,17 @@ export function createCapabilityGuard(
44
119
  method: string,
45
120
  path: string,
46
121
  onUse: ((event: CapabilityUseEvent) => void) | undefined,
122
+ trackEffect: ((context: object, committed: boolean) => void) | undefined,
123
+ interceptors: readonly RegisteredCapabilityInterceptor[],
124
+ observers: readonly EffectLifecycleObserver[],
47
125
  ): CapabilityGuard {
48
- return Object.freeze({ allowed, method, path, ...(onUse !== undefined ? { onUse } : {}) })
126
+ return Object.freeze({
127
+ allowed,
128
+ method,
129
+ path,
130
+ ...(onUse !== undefined ? { onUse } : {}),
131
+ ...(trackEffect !== undefined ? { trackEffect } : {}),
132
+ interceptors,
133
+ observers,
134
+ })
49
135
  }
@@ -0,0 +1,70 @@
1
+ /** Request-local owned-effect scope used by idempotency and every capability boundary. */
2
+
3
+ import {
4
+ createEffectEvidenceScope,
5
+ type EffectEvidenceScope,
6
+ type EffectScopeEvidence,
7
+ } from "../effect-scope.ts"
8
+
9
+ export type RequestEffectEvidence = EffectScopeEvidence
10
+
11
+ const scopeByRequest = new WeakMap<Request, EffectEvidenceScope>()
12
+
13
+ export function beginRequestEffectTracking(request: Request): EffectEvidenceScope {
14
+ const scope = createEffectEvidenceScope()
15
+ scopeByRequest.set(request, scope)
16
+ return scope
17
+ }
18
+
19
+ export function requestEffectScope(request: Request): EffectEvidenceScope | undefined {
20
+ return scopeByRequest.get(request)
21
+ }
22
+
23
+ export function requestEffectEvidence(request: Request): RequestEffectEvidence {
24
+ return (
25
+ scopeByRequest.get(request)?.evidence() ??
26
+ Object.freeze({ began: false, committed: false, ambiguous: false })
27
+ )
28
+ }
29
+
30
+ export function effectScopeForContext(context: object): EffectEvidenceScope | undefined {
31
+ const request = (context as { readonly req?: unknown }).req
32
+ return request instanceof Request ? scopeByRequest.get(request) : undefined
33
+ }
34
+
35
+ export function markEffect(context: object, committed: boolean): void {
36
+ const scope = effectScopeForContext(context)
37
+ if (committed) scope?.markCommitted()
38
+ else scope?.markBegan()
39
+ }
40
+
41
+ /** A legacy beacon cannot prove what happens after the call, so it is immediately ambiguous. */
42
+ export function markBeaconEffectBegan(context: object): void {
43
+ effectScopeForContext(context)?.markBegan()
44
+ }
45
+
46
+ export function markEffectExecuting(context: object): void {
47
+ effectScopeForContext(context)?.markBegan()
48
+ }
49
+
50
+ export function markEffectCommitted(context: object): void {
51
+ effectScopeForContext(context)?.markCommitted()
52
+ }
53
+
54
+ export function markEffectAmbiguous(context: object): void {
55
+ effectScopeForContext(context)?.markAmbiguous()
56
+ }
57
+
58
+ export function markRequestSafeToRetry(context: object): void {
59
+ const scope = effectScopeForContext(context)
60
+ if (scope === undefined) {
61
+ throw new Error(
62
+ "markIdempotencySafeToRetry() requires an active route with idempotency enabled",
63
+ )
64
+ }
65
+ scope.markSafeToRetry()
66
+ }
67
+
68
+ export function requestIsSafeToRetry(request: Request): boolean {
69
+ return scopeByRequest.get(request)?.safeToRetry() ?? false
70
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Constant-time string comparison for credential-shaped values - lease tokens, token hashes, and
3
+ * binding digests.
4
+ *
5
+ * `a !== b` returns as soon as it finds a differing character, so how long it takes reveals how much
6
+ * of a guess was correct. That is only exploitable when an attacker can submit guesses and measure,
7
+ * which is not the case on every path here - but these values are credentials, the comparison is on
8
+ * the verification path, and doing it correctly costs nothing. Doing it inconsistently is what makes
9
+ * the one place that matters easy to miss.
10
+ *
11
+ * Length is compared first, so a length difference is still observable. Every value compared through
12
+ * this helper is fixed-width by construction (a UUID, a SHA-256 hex/base64url digest), so length
13
+ * carries no secret. Content comparison always visits every character.
14
+ *
15
+ * Synchronous on purpose: the callers are store transitions and lease checks that are themselves
16
+ * synchronous. A hash-then-compare variant (which would also hide length) is available as
17
+ * `timingSafeEqualString` in `@nifrajs/middleware`, but it is async and would force those paths async.
18
+ */
19
+ export function safeEqual(a: string | undefined, b: string | undefined): boolean {
20
+ // Accepts `undefined` so optional binding fields keep their exact `!==` semantics at call sites:
21
+ // absent equals absent, and absent never equals present. Neither branch is secret-bearing.
22
+ if (a === undefined || b === undefined) return a === b
23
+ if (a.length !== b.length) return false
24
+ let diff = 0
25
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
26
+ return diff === 0
27
+ }