@nifrajs/core 2.14.1 → 3.2.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 +25 -0
- package/dist/assurance.d.ts +16 -8
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +33 -0
- package/dist/assurance.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +26 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-approval.d.ts +56 -0
- package/dist/durable-approval.d.ts.map +1 -0
- package/dist/durable-approval.js +233 -0
- package/dist/durable-approval.js.map +1 -0
- package/dist/durable-effect.d.ts +42 -0
- package/dist/durable-effect.d.ts.map +1 -0
- package/dist/durable-effect.js +234 -0
- package/dist/durable-effect.js.map +1 -0
- package/dist/durable-execution.d.ts +8 -346
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +7 -1150
- package/dist/durable-execution.js.map +1 -1
- package/dist/durable-saga.d.ts +94 -0
- package/dist/durable-saga.d.ts.map +1 -0
- package/dist/durable-saga.js +550 -0
- package/dist/durable-saga.js.map +1 -0
- package/dist/edge-kit.d.ts +27 -0
- package/dist/edge-kit.d.ts.map +1 -0
- package/dist/edge-kit.js +24 -0
- package/dist/edge-kit.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +23 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-approval-protocol.d.ts +1 -1
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
- package/dist/internal/durable-effect-protocol.d.ts +1 -1
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.d.ts +1 -1
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-shared.d.ts +26 -0
- package/dist/internal/durable-shared.d.ts.map +1 -0
- package/dist/internal/durable-shared.js +131 -0
- package/dist/internal/durable-shared.js.map +1 -0
- package/dist/internal/durable-types.d.ts +159 -0
- package/dist/internal/durable-types.d.ts.map +1 -0
- package/dist/internal/durable-types.js +2 -0
- package/dist/internal/durable-types.js.map +1 -0
- package/dist/internal/route-compiler.d.ts +61 -0
- package/dist/internal/route-compiler.d.ts.map +1 -0
- package/dist/internal/route-compiler.js +121 -0
- package/dist/internal/route-compiler.js.map +1 -0
- package/dist/internal/route-execution.d.ts +17 -9
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -18
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +35 -0
- package/dist/internal/route-lanes.d.ts.map +1 -0
- package/dist/internal/route-lanes.js +150 -0
- package/dist/internal/route-lanes.js.map +1 -0
- package/dist/internal/route-program.d.ts +67 -0
- package/dist/internal/route-program.d.ts.map +1 -0
- package/dist/internal/route-program.js +199 -0
- package/dist/internal/route-program.js.map +1 -0
- package/dist/response-observer.d.ts +25 -0
- package/dist/response-observer.d.ts.map +1 -0
- package/dist/response-observer.js +40 -0
- package/dist/response-observer.js.map +1 -0
- package/dist/security/baseline.d.ts +68 -0
- package/dist/security/baseline.d.ts.map +1 -0
- package/dist/security/baseline.js +118 -0
- package/dist/security/baseline.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +26 -0
- package/dist/server/bare-error-lane.d.ts.map +1 -0
- package/dist/server/bare-error-lane.js +60 -0
- package/dist/server/bare-error-lane.js.map +1 -0
- package/dist/server/body-lane.d.ts +13 -0
- package/dist/server/body-lane.d.ts.map +1 -0
- package/dist/server/body-lane.js +134 -0
- package/dist/server/body-lane.js.map +1 -0
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +7 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +25 -0
- package/dist/server/bridge-markers.d.ts.map +1 -0
- package/dist/server/bridge-markers.js +25 -0
- package/dist/server/bridge-markers.js.map +1 -0
- package/dist/server/context.d.ts +6 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +40 -14
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +7 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +16 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/header-case.d.ts.map +1 -1
- package/dist/server/header-case.js +2 -1
- package/dist/server/header-case.js.map +1 -1
- package/dist/server/http.d.ts +36 -3
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +49 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/install.d.ts +6 -8
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +7 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +2 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +17 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +37 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +2 -2
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +6 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/query.d.ts +7 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -3
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +40 -12
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +2 -13
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +18 -159
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +58 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +5 -2
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +20 -0
- package/dist/server/response-observer-runtime.d.ts.map +1 -0
- package/dist/server/response-observer-runtime.js +95 -0
- package/dist/server/response-observer-runtime.js.map +1 -0
- package/dist/server/runtime-core.d.ts +76 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +42 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +6 -1
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +158 -116
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +707 -844
- package/dist/server/server.js.map +1 -1
- package/dist/server/validation.d.ts +5 -0
- package/dist/server/validation.d.ts.map +1 -0
- package/dist/server/validation.js +13 -0
- package/dist/server/validation.js.map +1 -0
- package/dist/server/websocket.d.ts +20 -4
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +81 -11
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +12 -4
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +50 -16
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +3 -2
- 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-register.d.ts +2 -0
- package/dist/single-copy-register.d.ts.map +1 -0
- package/dist/single-copy-register.js +24 -0
- package/dist/single-copy-register.js.map +1 -0
- package/dist/single-copy.d.ts +156 -0
- package/dist/single-copy.d.ts.map +1 -0
- package/dist/single-copy.js +472 -0
- package/dist/single-copy.js.map +1 -0
- package/package.json +28 -3
- package/src/assurance.ts +58 -1
- package/src/durable-adapters.ts +20 -0
- package/src/durable-approval.ts +281 -0
- package/src/durable-effect.ts +300 -0
- package/src/durable-execution.ts +8 -1627
- package/src/durable-saga.ts +744 -0
- package/src/edge-kit.ts +27 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +129 -2
- package/src/internal/durable-approval-protocol.ts +1 -1
- package/src/internal/durable-effect-protocol.ts +1 -1
- package/src/internal/durable-saga-protocol.ts +1 -1
- package/src/internal/durable-shared.ts +157 -0
- package/src/internal/durable-types.ts +194 -0
- package/src/internal/route-compiler.ts +234 -0
- package/src/internal/route-execution.ts +53 -96
- package/src/internal/route-lanes.ts +221 -0
- package/src/internal/route-program.ts +404 -0
- package/src/response-observer.ts +64 -0
- package/src/security/baseline.ts +172 -0
- package/src/server/bare-error-lane.ts +77 -0
- package/src/server/body-lane.ts +203 -0
- package/src/server/body.ts +8 -0
- package/src/server/bridge-markers.ts +24 -0
- package/src/server/context.ts +6 -1
- package/src/server/contract.ts +70 -20
- package/src/server/cookies.ts +16 -2
- package/src/server/header-case.ts +3 -1
- package/src/server/http.ts +53 -3
- package/src/server/install.ts +14 -0
- package/src/server/node-outcome-hook.ts +2 -1
- package/src/server/node-outcome.ts +44 -3
- package/src/server/plugin.ts +11 -5
- package/src/server/query.ts +10 -4
- package/src/server/registry.ts +71 -16
- package/src/server/request-context.ts +13 -216
- package/src/server/respond.ts +71 -3
- package/src/server/response-contract-lane.ts +6 -2
- package/src/server/response-observer-runtime.ts +135 -0
- package/src/server/runtime-core.ts +124 -1
- package/src/server/server-types.ts +6 -1
- package/src/server/server.ts +1446 -1485
- package/src/server/validation.ts +19 -0
- package/src/server/websocket.ts +127 -17
- package/src/server/ws-bun.ts +67 -15
- package/src/server/ws-hook.ts +3 -2
- package/src/server.ts +8 -2
- package/src/single-copy-register.ts +24 -0
- package/src/single-copy.ts +526 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Bun-native, contract-first HTTP framework - the router, server, and route descriptor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
"types": "./dist/ws.d.ts",
|
|
38
38
|
"default": "./dist/ws.js"
|
|
39
39
|
},
|
|
40
|
+
"./edge-kit": {
|
|
41
|
+
"bun": "./src/edge-kit.ts",
|
|
42
|
+
"types": "./dist/edge-kit.d.ts",
|
|
43
|
+
"default": "./dist/edge-kit.js"
|
|
44
|
+
},
|
|
40
45
|
"./wire": {
|
|
41
46
|
"bun": "./src/wire.ts",
|
|
42
47
|
"types": "./dist/wire.d.ts",
|
|
@@ -212,6 +217,11 @@
|
|
|
212
217
|
"types": "./dist/router/router.d.ts",
|
|
213
218
|
"default": "./dist/router/router.js"
|
|
214
219
|
},
|
|
220
|
+
"./edge": {
|
|
221
|
+
"bun": "./src/server/edge.ts",
|
|
222
|
+
"types": "./dist/server/edge.d.ts",
|
|
223
|
+
"default": "./dist/server/edge.js"
|
|
224
|
+
},
|
|
215
225
|
"./schema": {
|
|
216
226
|
"bun": "./src/schema/standard.ts",
|
|
217
227
|
"types": "./dist/schema/standard.d.ts",
|
|
@@ -237,6 +247,11 @@
|
|
|
237
247
|
"types": "./dist/server/response-contract-lane.d.ts",
|
|
238
248
|
"default": "./dist/server/response-contract-lane.js"
|
|
239
249
|
},
|
|
250
|
+
"./response-observer": {
|
|
251
|
+
"bun": "./src/response-observer.ts",
|
|
252
|
+
"types": "./dist/response-observer.d.ts",
|
|
253
|
+
"default": "./dist/response-observer.js"
|
|
254
|
+
},
|
|
240
255
|
"./tool-contract": {
|
|
241
256
|
"bun": "./src/tool-contract.ts",
|
|
242
257
|
"types": "./dist/tool-contract.d.ts",
|
|
@@ -251,6 +266,16 @@
|
|
|
251
266
|
"bun": "./src/execution-policy.ts",
|
|
252
267
|
"types": "./dist/execution-policy.d.ts",
|
|
253
268
|
"default": "./dist/execution-policy.js"
|
|
269
|
+
},
|
|
270
|
+
"./single-copy": {
|
|
271
|
+
"bun": "./src/single-copy.ts",
|
|
272
|
+
"types": "./dist/single-copy.d.ts",
|
|
273
|
+
"default": "./dist/single-copy.js"
|
|
274
|
+
},
|
|
275
|
+
"./single-copy/register": {
|
|
276
|
+
"bun": "./src/single-copy-register.ts",
|
|
277
|
+
"types": "./dist/single-copy-register.d.ts",
|
|
278
|
+
"default": "./dist/single-copy-register.js"
|
|
254
279
|
}
|
|
255
280
|
},
|
|
256
281
|
"files": [
|
|
@@ -264,8 +289,8 @@
|
|
|
264
289
|
"./dist/server/sse.js",
|
|
265
290
|
"./src/node-direct.ts",
|
|
266
291
|
"./dist/node-direct.js",
|
|
267
|
-
"./src/
|
|
268
|
-
"./dist/
|
|
292
|
+
"./src/single-copy-register.ts",
|
|
293
|
+
"./dist/single-copy-register.js"
|
|
269
294
|
],
|
|
270
295
|
"scripts": {
|
|
271
296
|
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
package/src/assurance.ts
CHANGED
|
@@ -36,6 +36,11 @@ import type { NifraManifestSigner } from "./manifest.ts"
|
|
|
36
36
|
import { type ReflectedRoute, reflectRoutes } from "./reflection.ts"
|
|
37
37
|
import type { Method } from "./router/router.ts"
|
|
38
38
|
|
|
39
|
+
export {
|
|
40
|
+
type SecurityBaselineLevel,
|
|
41
|
+
type SecurityBaselineOptions,
|
|
42
|
+
securityBaseline,
|
|
43
|
+
} from "./security/baseline.ts"
|
|
39
44
|
export type { AssuranceAttachment, AssuranceDeclaration, AssuranceEvidence, AssuranceScope }
|
|
40
45
|
export { withRouteAssurance }
|
|
41
46
|
|
|
@@ -135,6 +140,20 @@ export interface AssuranceRouteSelector {
|
|
|
135
140
|
readonly zone?: CapabilityZone
|
|
136
141
|
/** Match routes whose response classification is at least this sensitivity. */
|
|
137
142
|
readonly classificationAtLeast?: DataClassification
|
|
143
|
+
/**
|
|
144
|
+
* Match routes by whether they declare a request-body schema. `true` selects routes that parse a
|
|
145
|
+
* body (the buffered-read surface F-001 is about); `false` selects bodyless routes. Reflection
|
|
146
|
+
* already carries `schema.body`, so this reads what the route declared, not where it lives.
|
|
147
|
+
*/
|
|
148
|
+
readonly hasBody?: boolean
|
|
149
|
+
/**
|
|
150
|
+
* Match routes by their effective transport body policy. `"unlimited"` is the explicit
|
|
151
|
+
* streaming/upload exemption; `"bounded"` is any finite cap (an explicit number or the inherited
|
|
152
|
+
* server default); `"unset"` is a route that declared no `bodyLimit` at all. Lets a baseline say
|
|
153
|
+
* "a body-schema route may never be unlimited" as a first-class, movable invariant rather than a
|
|
154
|
+
* path list. A route with no schema reports `"unset"` unless it names a limit.
|
|
155
|
+
*/
|
|
156
|
+
readonly bodyLimit?: "bounded" | "unlimited" | "unset"
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
/** Extra inputs an assurance evaluation needs beyond the routes themselves. */
|
|
@@ -337,6 +356,8 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
337
356
|
"capabilities",
|
|
338
357
|
...CLASS_SELECTOR_KEYS,
|
|
339
358
|
"classificationAtLeast",
|
|
359
|
+
"hasBody",
|
|
360
|
+
"bodyLimit",
|
|
340
361
|
].includes(key),
|
|
341
362
|
)
|
|
342
363
|
if (unknown.length > 0) {
|
|
@@ -351,6 +372,21 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
351
372
|
`route assurance: rule ${JSON.stringify(name)} tools selector must be boolean`,
|
|
352
373
|
)
|
|
353
374
|
}
|
|
375
|
+
if (rule.match.hasBody !== undefined && typeof rule.match.hasBody !== "boolean") {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`route assurance: rule ${JSON.stringify(name)} hasBody selector must be boolean`,
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
if (
|
|
381
|
+
rule.match.bodyLimit !== undefined &&
|
|
382
|
+
rule.match.bodyLimit !== "bounded" &&
|
|
383
|
+
rule.match.bodyLimit !== "unlimited" &&
|
|
384
|
+
rule.match.bodyLimit !== "unset"
|
|
385
|
+
) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`route assurance: rule ${JSON.stringify(name)} bodyLimit selector must be "bounded", "unlimited", or "unset"`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
354
390
|
const methods = rule.match.methods?.map((method) => method.toUpperCase())
|
|
355
391
|
for (const method of methods ?? []) {
|
|
356
392
|
if (!validMethod(method)) {
|
|
@@ -431,6 +467,8 @@ export function defineAssurancePolicy(policy: AssurancePolicy): AssurancePolicy
|
|
|
431
467
|
...(rule.match.classificationAtLeast !== undefined
|
|
432
468
|
? { classificationAtLeast: rule.match.classificationAtLeast }
|
|
433
469
|
: {}),
|
|
470
|
+
...(rule.match.hasBody !== undefined ? { hasBody: rule.match.hasBody } : {}),
|
|
471
|
+
...(rule.match.bodyLimit !== undefined ? { bodyLimit: rule.match.bodyLimit } : {}),
|
|
434
472
|
}),
|
|
435
473
|
require: required,
|
|
436
474
|
forbid: forbidden,
|
|
@@ -558,16 +596,35 @@ export function defineAssuranceConfig(config: AssuranceConfig): AssuranceConfig
|
|
|
558
596
|
* reflection alone. A caller that omits it while the selector needs it gets no match, so callers that
|
|
559
597
|
* accept user policy must reject that combination up front - `defineAssuranceConfig` does.
|
|
560
598
|
*/
|
|
599
|
+
/**
|
|
600
|
+
* The effective transport body policy a `bodyLimit` selector matches on. A route that named no limit
|
|
601
|
+
* reports `"unset"` (it inherits the server default, which a policy that cares about explicit caps
|
|
602
|
+
* should treat distinctly); a finite number is `"bounded"`; the streaming/upload exemption is
|
|
603
|
+
* `"unlimited"`. Kept total so a future `bodyLimit` shape cannot fall through to a silent match.
|
|
604
|
+
*/
|
|
605
|
+
function effectiveBodyLimit(schema: ReflectedRoute["schema"]): "bounded" | "unlimited" | "unset" {
|
|
606
|
+
const limit = schema?.bodyLimit
|
|
607
|
+
if (limit === "unlimited") return "unlimited"
|
|
608
|
+
if (typeof limit === "number") return "bounded"
|
|
609
|
+
return "unset"
|
|
610
|
+
}
|
|
611
|
+
|
|
561
612
|
export function matchesAssuranceSelector(
|
|
562
|
-
route: Pick<
|
|
613
|
+
route: Pick<
|
|
614
|
+
ReflectedRoute,
|
|
615
|
+
"method" | "path" | "tool" | "capabilities" | "classification" | "schema"
|
|
616
|
+
>,
|
|
563
617
|
selector: AssuranceRouteSelector,
|
|
564
618
|
definitions?: ReadonlyMap<string, CapabilityDefinition>,
|
|
565
619
|
): boolean {
|
|
566
620
|
const { methods, paths, tools, capabilities, access, zone, classificationAtLeast } = selector
|
|
621
|
+
const { hasBody, bodyLimit } = selector
|
|
567
622
|
if (methods !== undefined && !methods.includes(route.method as Method)) return false
|
|
568
623
|
if (paths !== undefined && !paths.some((pattern) => routeGlob(pattern).test(route.path)))
|
|
569
624
|
return false
|
|
570
625
|
if (tools !== undefined && (route.tool !== undefined) !== tools) return false
|
|
626
|
+
if (hasBody !== undefined && (route.schema?.body !== undefined) !== hasBody) return false
|
|
627
|
+
if (bodyLimit !== undefined && effectiveBodyLimit(route.schema) !== bodyLimit) return false
|
|
571
628
|
if (
|
|
572
629
|
classificationAtLeast !== undefined &&
|
|
573
630
|
(route.classification === undefined ||
|
package/src/durable-adapters.ts
CHANGED
|
@@ -480,12 +480,15 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
async migrate(): Promise<void> {
|
|
483
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
483
484
|
await this.client.query(
|
|
484
485
|
sqlIdentifiers`CREATE TABLE IF NOT EXISTS ${this.recordsTable} (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at BIGINT NOT NULL, version BIGINT NOT NULL, payload JSONB NOT NULL, PRIMARY KEY (kind, id))`,
|
|
485
486
|
)
|
|
487
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
486
488
|
await this.client.query(
|
|
487
489
|
sqlIdentifiers`CREATE INDEX IF NOT EXISTS ${this.recordsTable}_reconcile ON ${this.recordsTable} (kind, state, updated_at, id)`,
|
|
488
490
|
)
|
|
491
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
489
492
|
await this.client.query(
|
|
490
493
|
sqlIdentifiers`CREATE TABLE IF NOT EXISTS ${this.leasesTable} (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at BIGINT NOT NULL DEFAULT 0, cursor TEXT)`,
|
|
491
494
|
)
|
|
@@ -496,6 +499,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
496
499
|
id: string,
|
|
497
500
|
record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
|
|
498
501
|
): Promise<boolean> {
|
|
502
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
499
503
|
const result = await this.client.query(
|
|
500
504
|
sqlIdentifiers`INSERT INTO ${this.recordsTable} (kind,id,state,updated_at,version,payload) VALUES ($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING RETURNING id`,
|
|
501
505
|
[
|
|
@@ -513,6 +517,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
513
517
|
kind: DurableRecordKind,
|
|
514
518
|
id: string,
|
|
515
519
|
): Promise<T | undefined> {
|
|
520
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
516
521
|
const result = await this.client.query(
|
|
517
522
|
sqlIdentifiers`SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`,
|
|
518
523
|
[kind, id],
|
|
@@ -526,6 +531,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
526
531
|
record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
|
|
527
532
|
): Promise<boolean> {
|
|
528
533
|
if (record.version !== expectedVersion + 1) return false
|
|
534
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
529
535
|
const result = await this.client.query(
|
|
530
536
|
sqlIdentifiers`UPDATE ${this.recordsTable} SET state=$4,updated_at=$5,version=$6,payload=$7::jsonb WHERE kind=$1 AND id=$2 AND version=$3 RETURNING id`,
|
|
531
537
|
[
|
|
@@ -544,6 +550,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
544
550
|
kind: DurableRecordKind,
|
|
545
551
|
input: ReconciliationScanOptions<string>,
|
|
546
552
|
): Promise<ReconciliationScanPage<T>> {
|
|
553
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
547
554
|
const result = await this.client.query(
|
|
548
555
|
sqlIdentifiers`SELECT id,payload FROM ${this.recordsTable} WHERE kind=$1 AND state = ANY($2::text[]) AND ($3::bigint IS NULL OR updated_at <= $3) AND ($4::text IS NULL OR id > $4) ORDER BY id LIMIT $5`,
|
|
549
556
|
[kind, input.states, input.updatedBefore ?? null, input.cursor ?? null, input.limit + 1],
|
|
@@ -560,6 +567,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
560
567
|
): Promise<ReconciliationLease | undefined> {
|
|
561
568
|
const token = crypto.randomUUID()
|
|
562
569
|
const expiresAt = input.now + input.leaseMs
|
|
570
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
563
571
|
const result = await this.client.query(
|
|
564
572
|
sqlIdentifiers`INSERT INTO ${this.leasesTable} (name,owner,token,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (name) DO UPDATE SET owner=EXCLUDED.owner,token=EXCLUDED.token,expires_at=EXCLUDED.expires_at WHERE ${this.leasesTable}.expires_at <= $5 RETURNING name,owner,token,expires_at,cursor`,
|
|
565
573
|
[input.name, input.owner, token, expiresAt, input.now],
|
|
@@ -575,6 +583,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
575
583
|
})
|
|
576
584
|
}
|
|
577
585
|
async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
|
|
586
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
578
587
|
const result = await this.client.query(
|
|
579
588
|
sqlIdentifiers`UPDATE ${this.leasesTable} SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name`,
|
|
580
589
|
[input.name, input.owner, input.token, input.now + input.leaseMs, input.now],
|
|
@@ -584,6 +593,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
584
593
|
async checkpointLease(
|
|
585
594
|
input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
|
|
586
595
|
): Promise<boolean> {
|
|
596
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
587
597
|
const result = await this.client.query(
|
|
588
598
|
sqlIdentifiers`UPDATE ${this.leasesTable} SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name`,
|
|
589
599
|
[input.name, input.owner, input.token, input.cursor ?? null],
|
|
@@ -591,6 +601,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
591
601
|
return result.rows.length === 1
|
|
592
602
|
}
|
|
593
603
|
async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
|
|
604
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
594
605
|
const result = await this.client.query(
|
|
595
606
|
sqlIdentifiers`UPDATE ${this.leasesTable} SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name`,
|
|
596
607
|
[input.name, input.owner, input.token],
|
|
@@ -646,6 +657,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
646
657
|
this.leasesTable = `${prefix}_leases`
|
|
647
658
|
}
|
|
648
659
|
migrate(): void {
|
|
660
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
649
661
|
this.client.exec(
|
|
650
662
|
sqlIdentifiers`CREATE TABLE IF NOT EXISTS ${this.recordsTable} (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (kind,id));CREATE INDEX IF NOT EXISTS ${this.recordsTable}_reconcile ON ${this.recordsTable} (kind,state,updated_at,id);CREATE TABLE IF NOT EXISTS ${this.leasesTable} (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at INTEGER NOT NULL DEFAULT 0, cursor TEXT);`,
|
|
651
663
|
)
|
|
@@ -655,6 +667,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
655
667
|
id: string,
|
|
656
668
|
record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
|
|
657
669
|
): Promise<boolean> {
|
|
670
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
658
671
|
const result = this.client
|
|
659
672
|
.query(
|
|
660
673
|
sqlIdentifiers`INSERT OR IGNORE INTO ${this.recordsTable} (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)`,
|
|
@@ -673,6 +686,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
673
686
|
kind: DurableRecordKind,
|
|
674
687
|
id: string,
|
|
675
688
|
): Promise<T | undefined> {
|
|
689
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
676
690
|
const row = this.client
|
|
677
691
|
.query(sqlIdentifiers`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
|
|
678
692
|
.get(kind, id)
|
|
@@ -685,6 +699,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
685
699
|
record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
|
|
686
700
|
): Promise<boolean> {
|
|
687
701
|
if (record.version !== expectedVersion + 1) return false
|
|
702
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
688
703
|
const result = this.client
|
|
689
704
|
.query(
|
|
690
705
|
sqlIdentifiers`UPDATE ${this.recordsTable} SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?`,
|
|
@@ -706,6 +721,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
706
721
|
): Promise<ReconciliationScanPage<T>> {
|
|
707
722
|
if (input.states.length === 0) return { records: [] }
|
|
708
723
|
const placeholders = sqlPlaceholders(input.states.length)
|
|
724
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name (and static ?-placeholders), which SQL cannot bind as a parameter
|
|
709
725
|
const rows = this.client
|
|
710
726
|
.query(
|
|
711
727
|
sqlIdentifiers`SELECT id,payload FROM ${this.recordsTable} WHERE kind=? AND state IN (${placeholders}) AND (? IS NULL OR updated_at <= ?) AND (? IS NULL OR id > ?) ORDER BY id LIMIT ?`,
|
|
@@ -732,6 +748,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
732
748
|
): Promise<ReconciliationLease | undefined> {
|
|
733
749
|
const token = crypto.randomUUID()
|
|
734
750
|
const expiresAt = input.now + input.leaseMs
|
|
751
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
735
752
|
const row = this.client
|
|
736
753
|
.query(
|
|
737
754
|
sqlIdentifiers`INSERT INTO ${this.leasesTable} (name,owner,token,expires_at) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,token=excluded.token,expires_at=excluded.expires_at WHERE ${this.leasesTable}.expires_at <= ? RETURNING name,owner,token,expires_at,cursor`,
|
|
@@ -748,6 +765,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
748
765
|
}
|
|
749
766
|
async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
|
|
750
767
|
return (
|
|
768
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
751
769
|
this.client
|
|
752
770
|
.query(
|
|
753
771
|
sqlIdentifiers`UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`,
|
|
@@ -760,6 +778,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
760
778
|
input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
|
|
761
779
|
): Promise<boolean> {
|
|
762
780
|
return (
|
|
781
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
763
782
|
this.client
|
|
764
783
|
.query(
|
|
765
784
|
sqlIdentifiers`UPDATE ${this.leasesTable} SET cursor=? WHERE name=? AND owner=? AND token=?`,
|
|
@@ -769,6 +788,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
769
788
|
}
|
|
770
789
|
async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
|
|
771
790
|
return (
|
|
791
|
+
// nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
|
|
772
792
|
this.client
|
|
773
793
|
.query(
|
|
774
794
|
sqlIdentifiers`UPDATE ${this.leasesTable} SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?`,
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { CapabilityApprovalGate } from "./internal/capability-runtime.ts"
|
|
2
|
+
import { durableApprovalProtocol } from "./internal/durable-approval-protocol.ts"
|
|
3
|
+
import {
|
|
4
|
+
addDuration,
|
|
5
|
+
assertPositiveMs,
|
|
6
|
+
assertToken,
|
|
7
|
+
bytesToBase64Url,
|
|
8
|
+
cloneValue,
|
|
9
|
+
encoder,
|
|
10
|
+
readClock,
|
|
11
|
+
sha256,
|
|
12
|
+
} from "./internal/durable-shared.ts"
|
|
13
|
+
import type {
|
|
14
|
+
ApprovalConsumeResult,
|
|
15
|
+
ApprovalRecord,
|
|
16
|
+
ApprovalStore,
|
|
17
|
+
} from "./internal/durable-types.ts"
|
|
18
|
+
|
|
19
|
+
const decoder = new TextDecoder()
|
|
20
|
+
|
|
21
|
+
function base64UrlToBytes(value: string): Uint8Array {
|
|
22
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new ApprovalTokenInvalidError()
|
|
23
|
+
const padded = value
|
|
24
|
+
.replaceAll("-", "+")
|
|
25
|
+
.replaceAll("_", "/")
|
|
26
|
+
.padEnd(Math.ceil(value.length / 4) * 4, "=")
|
|
27
|
+
let binary: string
|
|
28
|
+
try {
|
|
29
|
+
binary = atob(padded)
|
|
30
|
+
} catch {
|
|
31
|
+
throw new ApprovalTokenInvalidError()
|
|
32
|
+
}
|
|
33
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
|
34
|
+
}
|
|
35
|
+
// Durable human approval with signed, single-use resume tokens
|
|
36
|
+
|
|
37
|
+
export class ApprovalRequiredError extends Error {
|
|
38
|
+
readonly approvalId: string
|
|
39
|
+
readonly effectId: string
|
|
40
|
+
readonly resumeToken!: string
|
|
41
|
+
readonly expiresAt: number
|
|
42
|
+
|
|
43
|
+
constructor(approvalId: string, effectId: string, resumeToken: string, expiresAt: number) {
|
|
44
|
+
super("capability approval is required")
|
|
45
|
+
this.approvalId = approvalId
|
|
46
|
+
this.effectId = effectId
|
|
47
|
+
this.expiresAt = expiresAt
|
|
48
|
+
Object.defineProperty(this, "resumeToken", {
|
|
49
|
+
value: resumeToken,
|
|
50
|
+
enumerable: false,
|
|
51
|
+
configurable: false,
|
|
52
|
+
writable: false,
|
|
53
|
+
})
|
|
54
|
+
this.name = "ApprovalRequiredError"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class ApprovalTokenInvalidError extends Error {
|
|
59
|
+
constructor() {
|
|
60
|
+
super("approval resume token is invalid")
|
|
61
|
+
this.name = "ApprovalTokenInvalidError"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export class ApprovalTokenExpiredError extends Error {
|
|
65
|
+
constructor() {
|
|
66
|
+
super("approval resume token has expired")
|
|
67
|
+
this.name = "ApprovalTokenExpiredError"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export class ApprovalTokenReplayError extends Error {
|
|
71
|
+
constructor() {
|
|
72
|
+
super("approval resume token has already been consumed")
|
|
73
|
+
this.name = "ApprovalTokenReplayError"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export class ApprovalBindingError extends Error {
|
|
77
|
+
constructor() {
|
|
78
|
+
super("approval resume token is bound to another tenant or principal")
|
|
79
|
+
this.name = "ApprovalBindingError"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export class ApprovalPendingError extends Error {
|
|
83
|
+
constructor() {
|
|
84
|
+
super("capability approval is still pending")
|
|
85
|
+
this.name = "ApprovalPendingError"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export class ApprovalDeniedError extends Error {
|
|
89
|
+
constructor() {
|
|
90
|
+
super("capability approval was denied")
|
|
91
|
+
this.name = "ApprovalDeniedError"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ResumeClaims {
|
|
96
|
+
readonly v: 1
|
|
97
|
+
readonly id: string
|
|
98
|
+
readonly nonce: string
|
|
99
|
+
readonly exp: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ApprovalCoordinator extends CapabilityApprovalGate {
|
|
103
|
+
decide(input: {
|
|
104
|
+
readonly approvalId: string
|
|
105
|
+
readonly tenantId: string
|
|
106
|
+
readonly decision: "approved" | "denied"
|
|
107
|
+
readonly decidedBy: string
|
|
108
|
+
}): Promise<void>
|
|
109
|
+
get(approvalId: string): Promise<ApprovalRecord | undefined>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ApprovalCoordinatorOptions {
|
|
113
|
+
readonly store: ApprovalStore
|
|
114
|
+
/** At least 32 random bytes, kept separately from the approval store. */
|
|
115
|
+
readonly secret: Uint8Array
|
|
116
|
+
readonly ttlMs?: number
|
|
117
|
+
readonly now?: () => number
|
|
118
|
+
/** Tests/local development only. Production approval records require a durable store. */
|
|
119
|
+
readonly allowMemoryStore?: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createApprovalCoordinator(
|
|
123
|
+
options: ApprovalCoordinatorOptions,
|
|
124
|
+
): ApprovalCoordinator {
|
|
125
|
+
if (options.store.durability !== "durable" && options.allowMemoryStore !== true) {
|
|
126
|
+
throw new TypeError('approval coordinator requires store.durability === "durable"')
|
|
127
|
+
}
|
|
128
|
+
if (!(options.secret instanceof Uint8Array) || options.secret.byteLength < 32) {
|
|
129
|
+
throw new TypeError("approval secret must contain at least 32 random bytes")
|
|
130
|
+
}
|
|
131
|
+
const secret = new Uint8Array(options.secret)
|
|
132
|
+
const ttlMs = options.ttlMs ?? 15 * 60_000
|
|
133
|
+
assertPositiveMs(ttlMs, "approval ttlMs")
|
|
134
|
+
const clock = options.now ?? Date.now
|
|
135
|
+
const now = (): number => readClock(clock, "approval clock")
|
|
136
|
+
const key = crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
|
|
137
|
+
"sign",
|
|
138
|
+
"verify",
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
const sign = async (claims: ResumeClaims): Promise<string> => {
|
|
142
|
+
const payload = bytesToBase64Url(encoder.encode(JSON.stringify(claims)))
|
|
143
|
+
const signature = new Uint8Array(
|
|
144
|
+
await crypto.subtle.sign("HMAC", await key, encoder.encode(payload)),
|
|
145
|
+
)
|
|
146
|
+
return `v1.${payload}.${bytesToBase64Url(signature)}`
|
|
147
|
+
}
|
|
148
|
+
const verify = async (token: string): Promise<ResumeClaims> => {
|
|
149
|
+
if (typeof token !== "string" || token.length > 2048) throw new ApprovalTokenInvalidError()
|
|
150
|
+
const parts = token.split(".")
|
|
151
|
+
if (parts.length !== 3 || parts[0] !== "v1") throw new ApprovalTokenInvalidError()
|
|
152
|
+
const payload = parts[1] as string
|
|
153
|
+
const valid = await crypto.subtle.verify(
|
|
154
|
+
"HMAC",
|
|
155
|
+
await key,
|
|
156
|
+
base64UrlToBytes(parts[2] as string).buffer as ArrayBuffer,
|
|
157
|
+
encoder.encode(payload),
|
|
158
|
+
)
|
|
159
|
+
if (!valid) throw new ApprovalTokenInvalidError()
|
|
160
|
+
let claims: unknown
|
|
161
|
+
try {
|
|
162
|
+
claims = JSON.parse(decoder.decode(base64UrlToBytes(payload)))
|
|
163
|
+
} catch {
|
|
164
|
+
throw new ApprovalTokenInvalidError()
|
|
165
|
+
}
|
|
166
|
+
if (typeof claims !== "object" || claims === null) throw new ApprovalTokenInvalidError()
|
|
167
|
+
const candidate = claims as Partial<ResumeClaims>
|
|
168
|
+
if (
|
|
169
|
+
candidate.v !== 1 ||
|
|
170
|
+
typeof candidate.id !== "string" ||
|
|
171
|
+
typeof candidate.nonce !== "string" ||
|
|
172
|
+
!Number.isSafeInteger(candidate.exp)
|
|
173
|
+
) {
|
|
174
|
+
throw new ApprovalTokenInvalidError()
|
|
175
|
+
}
|
|
176
|
+
assertToken(candidate.id, "approval id", 128)
|
|
177
|
+
assertToken(candidate.nonce, "approval nonce", 128)
|
|
178
|
+
if ((candidate.exp as number) <= now()) throw new ApprovalTokenExpiredError()
|
|
179
|
+
return candidate as ResumeClaims
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const coordinator: ApprovalCoordinator = {
|
|
183
|
+
async authorize(input: Parameters<CapabilityApprovalGate["authorize"]>[0]) {
|
|
184
|
+
if (input.signal.aborted) throw input.signal.reason
|
|
185
|
+
assertToken(input.identity.tenantId, "approval tenantId")
|
|
186
|
+
assertToken(input.identity.principalId, "approval principalId")
|
|
187
|
+
if (input.resumeToken === undefined) {
|
|
188
|
+
const approvalId = crypto.randomUUID()
|
|
189
|
+
const nonce = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(18)))
|
|
190
|
+
const issuedAt = now()
|
|
191
|
+
const expiresAt = addDuration(issuedAt, ttlMs, "approval expiry")
|
|
192
|
+
const resumeToken = await sign({ v: 1, id: approvalId, nonce, exp: expiresAt })
|
|
193
|
+
const accepted = await options.store.create(
|
|
194
|
+
Object.freeze({
|
|
195
|
+
approvalId,
|
|
196
|
+
effectId: input.effectId,
|
|
197
|
+
capability: input.capability,
|
|
198
|
+
...(input.target === undefined ? {} : { target: input.target }),
|
|
199
|
+
...(input.digest === undefined ? {} : { digest: input.digest }),
|
|
200
|
+
tenantId: input.identity.tenantId,
|
|
201
|
+
principalId: input.identity.principalId,
|
|
202
|
+
tokenHash: await sha256(resumeToken),
|
|
203
|
+
state: "pending" as const,
|
|
204
|
+
createdAt: issuedAt,
|
|
205
|
+
expiresAt,
|
|
206
|
+
updatedAt: issuedAt,
|
|
207
|
+
version: 1,
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
if (!accepted) throw new Error("approval store rejected a unique approval id")
|
|
211
|
+
throw new ApprovalRequiredError(approvalId, input.effectId, resumeToken, expiresAt)
|
|
212
|
+
}
|
|
213
|
+
const claims = await verify(input.resumeToken)
|
|
214
|
+
const consumed = await options.store.consume({
|
|
215
|
+
approvalId: claims.id,
|
|
216
|
+
tenantId: input.identity.tenantId,
|
|
217
|
+
principalId: input.identity.principalId,
|
|
218
|
+
capability: input.capability,
|
|
219
|
+
...(input.target === undefined ? {} : { target: input.target }),
|
|
220
|
+
...(input.digest === undefined ? {} : { digest: input.digest }),
|
|
221
|
+
tokenHash: await sha256(input.resumeToken),
|
|
222
|
+
now: now(),
|
|
223
|
+
})
|
|
224
|
+
if (consumed.state === "consumed") return
|
|
225
|
+
if (consumed.state === "replay") throw new ApprovalTokenReplayError()
|
|
226
|
+
if (consumed.state === "binding") throw new ApprovalBindingError()
|
|
227
|
+
if (consumed.state === "expired") throw new ApprovalTokenExpiredError()
|
|
228
|
+
if (consumed.state === "pending") throw new ApprovalPendingError()
|
|
229
|
+
if (consumed.state === "denied") throw new ApprovalDeniedError()
|
|
230
|
+
throw new ApprovalTokenInvalidError()
|
|
231
|
+
},
|
|
232
|
+
async decide(input: Parameters<ApprovalCoordinator["decide"]>[0]) {
|
|
233
|
+
assertToken(input.approvalId, "approval id", 128)
|
|
234
|
+
assertToken(input.tenantId, "approval tenantId")
|
|
235
|
+
assertToken(input.decidedBy, "approval decidedBy")
|
|
236
|
+
const accepted = await options.store.decide({ ...input, now: now() })
|
|
237
|
+
if (!accepted) throw new Error("approval decision rejected")
|
|
238
|
+
},
|
|
239
|
+
async get(approvalId: string) {
|
|
240
|
+
assertToken(approvalId, "approval id", 128)
|
|
241
|
+
return await options.store.get(approvalId)
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
return Object.freeze(coordinator)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class MemoryApprovalStore implements ApprovalStore {
|
|
248
|
+
readonly durability = "memory" as const
|
|
249
|
+
private readonly records = new Map<string, ApprovalRecord>()
|
|
250
|
+
|
|
251
|
+
create(record: ApprovalRecord): boolean {
|
|
252
|
+
if (this.records.has(record.approvalId)) return false
|
|
253
|
+
this.records.set(record.approvalId, Object.freeze(cloneValue(record)))
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
get(approvalId: string): ApprovalRecord | undefined {
|
|
257
|
+
const record = this.records.get(approvalId)
|
|
258
|
+
return record === undefined ? undefined : Object.freeze(cloneValue(record))
|
|
259
|
+
}
|
|
260
|
+
decide(input: Parameters<ApprovalStore["decide"]>[0]): boolean {
|
|
261
|
+
const record = this.records.get(input.approvalId)
|
|
262
|
+
const next = durableApprovalProtocol.decide(record, input)
|
|
263
|
+
if (next === undefined) return false
|
|
264
|
+
this.records.set(input.approvalId, Object.freeze(next))
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
consume(input: Parameters<ApprovalStore["consume"]>[0]): ApprovalConsumeResult {
|
|
268
|
+
const record = this.records.get(input.approvalId)
|
|
269
|
+
const transition = durableApprovalProtocol.consume(record, input)
|
|
270
|
+
if (transition.next !== undefined)
|
|
271
|
+
this.records.set(input.approvalId, Object.freeze(transition.next))
|
|
272
|
+
return transition.result
|
|
273
|
+
}
|
|
274
|
+
list(): readonly ApprovalRecord[] {
|
|
275
|
+
return Object.freeze(
|
|
276
|
+
[...this.records.values()].map((record) => Object.freeze(cloneValue(record))),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// -------------------------------------------------------------------------------------------------
|