@nifrajs/core 4.0.2 → 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.
- package/README.md +6 -2
- package/dist/assurance.d.ts +71 -5
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +90 -25
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +52 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +7 -2
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +29 -5
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +44 -31
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +444 -321
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +22 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +21 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +7 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +20 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +3 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +180 -31
- package/src/capabilities.ts +59 -0
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +7 -2
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +6 -1
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-program.ts +98 -44
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +3 -0
- package/src/server/context.ts +2 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +29 -5
- package/src/server/server.ts +659 -530
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +44 -5
- package/src/server/ws-hook.ts +5 -0
- package/src/server.ts +5 -0
- package/src/single-copy.ts +20 -1
- package/src/tool-contract.ts +67 -39
- 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
|
|
409
|
-
|
|
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
|
|
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:
|
|
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
|
package/src/capabilities.ts
CHANGED
|
@@ -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))
|
package/src/durable-effect.ts
CHANGED
|
@@ -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(
|
package/src/durable-saga.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
package/src/effect-scope.ts
CHANGED
|
@@ -234,10 +234,20 @@ export function createEffectScope(
|
|
|
234
234
|
})
|
|
235
235
|
return result
|
|
236
236
|
} catch (error) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(),
|