@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.
Files changed (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. 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.14.1",
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/mcp-runtime.ts",
268
- "./dist/mcp-runtime.js"
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<ReflectedRoute, "method" | "path" | "tool" | "capabilities" | "classification">,
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 ||
@@ -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
+ // -------------------------------------------------------------------------------------------------