@nifrajs/core 2.1.0 → 2.3.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 (138) hide show
  1. package/README.md +2 -2
  2. package/dist/assurance.d.ts +48 -4
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +87 -9
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/binary.d.ts +54 -0
  7. package/dist/binary.d.ts.map +1 -0
  8. package/dist/binary.js +90 -0
  9. package/dist/binary.js.map +1 -0
  10. package/dist/budget.d.ts +1 -1
  11. package/dist/budget.js +1 -1
  12. package/dist/capabilities.d.ts +19 -2
  13. package/dist/capabilities.d.ts.map +1 -1
  14. package/dist/capabilities.js +60 -8
  15. package/dist/capabilities.js.map +1 -1
  16. package/dist/classification.d.ts +1 -1
  17. package/dist/classification.js +1 -1
  18. package/dist/diff.d.ts +6 -6
  19. package/dist/diff.js +6 -6
  20. package/dist/durable-adapters.d.ts.map +1 -1
  21. package/dist/durable-adapters.js +36 -70
  22. package/dist/durable-adapters.js.map +1 -1
  23. package/dist/durable-execution.d.ts +2 -2
  24. package/dist/durable-execution.js +2 -2
  25. package/dist/idempotency.d.ts +3 -3
  26. package/dist/idempotency.js +4 -4
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +3 -3
  29. package/dist/internal/brand.d.ts +1 -1
  30. package/dist/internal/brand.js +1 -1
  31. package/dist/internal/route-catalog.d.ts +58 -0
  32. package/dist/internal/route-catalog.d.ts.map +1 -0
  33. package/dist/internal/route-catalog.js +58 -0
  34. package/dist/internal/route-catalog.js.map +1 -0
  35. package/dist/internal/route-execution.d.ts +75 -0
  36. package/dist/internal/route-execution.d.ts.map +1 -0
  37. package/dist/internal/route-execution.js +2 -0
  38. package/dist/internal/route-execution.js.map +1 -0
  39. package/dist/internal/same-origin.d.ts +29 -0
  40. package/dist/internal/same-origin.d.ts.map +1 -0
  41. package/dist/internal/same-origin.js +47 -0
  42. package/dist/internal/same-origin.js.map +1 -0
  43. package/dist/internal/secret.d.ts +1 -1
  44. package/dist/internal/secret.js +1 -1
  45. package/dist/ledger.d.ts +9 -9
  46. package/dist/ledger.js +5 -5
  47. package/dist/reflection.d.ts +1 -1
  48. package/dist/router/pattern.d.ts +26 -1
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +164 -8
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.d.ts.map +1 -1
  53. package/dist/router/router.js +52 -4
  54. package/dist/router/router.js.map +1 -1
  55. package/dist/schema/standard.d.ts +2 -2
  56. package/dist/schema/standard.js +2 -2
  57. package/dist/seo.d.ts +3 -3
  58. package/dist/seo.js +4 -4
  59. package/dist/server/body.d.ts +2 -2
  60. package/dist/server/body.js +2 -2
  61. package/dist/server/context.d.ts +18 -18
  62. package/dist/server/contract.d.ts +7 -7
  63. package/dist/server/contract.js +3 -3
  64. package/dist/server/cookies.d.ts +4 -4
  65. package/dist/server/cookies.js +8 -8
  66. package/dist/server/edge.d.ts +83 -0
  67. package/dist/server/edge.d.ts.map +1 -0
  68. package/dist/server/edge.js +71 -0
  69. package/dist/server/edge.js.map +1 -0
  70. package/dist/server/install.d.ts +2 -0
  71. package/dist/server/install.d.ts.map +1 -1
  72. package/dist/server/install.js +2 -0
  73. package/dist/server/install.js.map +1 -1
  74. package/dist/server/logger.d.ts +7 -7
  75. package/dist/server/logger.js +4 -4
  76. package/dist/server/registry.d.ts +5 -5
  77. package/dist/server/response-contract-lane.d.ts +91 -0
  78. package/dist/server/response-contract-lane.d.ts.map +1 -0
  79. package/dist/server/response-contract-lane.js +81 -0
  80. package/dist/server/response-contract-lane.js.map +1 -0
  81. package/dist/server/server.d.ts +52 -91
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +130 -186
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/sse.d.ts +4 -4
  86. package/dist/server/sse.js +7 -7
  87. package/dist/server/webhook.d.ts +1 -1
  88. package/dist/server/webhook.js +5 -5
  89. package/dist/server/websocket.d.ts +19 -19
  90. package/dist/server/websocket.js +9 -9
  91. package/dist/server/ws-bun.d.ts +4 -4
  92. package/dist/server/ws-bun.js +3 -3
  93. package/dist/server.d.ts +3 -1
  94. package/dist/server.d.ts.map +1 -1
  95. package/dist/server.js +3 -1
  96. package/dist/server.js.map +1 -1
  97. package/dist/transport-codec.d.ts +13 -1
  98. package/dist/transport-codec.d.ts.map +1 -1
  99. package/dist/transport-codec.js +48 -9
  100. package/dist/transport-codec.js.map +1 -1
  101. package/package.json +12 -2
  102. package/src/assurance.ts +154 -8
  103. package/src/binary.ts +128 -0
  104. package/src/budget.ts +1 -1
  105. package/src/capabilities.ts +71 -9
  106. package/src/classification.ts +1 -1
  107. package/src/diff.ts +8 -8
  108. package/src/durable-adapters.ts +40 -70
  109. package/src/durable-execution.ts +2 -2
  110. package/src/idempotency.ts +4 -4
  111. package/src/index.ts +3 -3
  112. package/src/internal/brand.ts +1 -1
  113. package/src/internal/route-catalog.ts +94 -0
  114. package/src/internal/route-execution.ts +106 -0
  115. package/src/internal/same-origin.ts +43 -0
  116. package/src/internal/secret.ts +1 -1
  117. package/src/ledger.ts +11 -11
  118. package/src/reflection.ts +1 -1
  119. package/src/router/pattern.ts +179 -8
  120. package/src/router/router.ts +80 -6
  121. package/src/schema/standard.ts +2 -2
  122. package/src/seo.ts +6 -6
  123. package/src/server/body.ts +2 -2
  124. package/src/server/context.ts +18 -18
  125. package/src/server/contract.ts +13 -13
  126. package/src/server/cookies.ts +8 -8
  127. package/src/server/edge.ts +150 -0
  128. package/src/server/install.ts +5 -0
  129. package/src/server/logger.ts +8 -8
  130. package/src/server/registry.ts +5 -5
  131. package/src/server/response-contract-lane.ts +164 -0
  132. package/src/server/server.ts +159 -366
  133. package/src/server/sse.ts +11 -11
  134. package/src/server/webhook.ts +5 -5
  135. package/src/server/websocket.ts +22 -22
  136. package/src/server/ws-bun.ts +4 -4
  137. package/src/server.ts +8 -5
  138. package/src/transport-codec.ts +63 -10
package/src/binary.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `@nifrajs/core/binary` - a route that returns bytes, and says so in its type.
3
+ *
4
+ * A download route had no way to be part of the contract. Returning a raw `Response` is the only way
5
+ * to send bytes, and a raw `Response` is exactly what the typed client cannot describe - so the route
6
+ * needed a `// nifra-expect raw-response` pragma to quiet the advisory, and its caller got no type at
7
+ * all. One whole category of endpoint sat outside the thing the framework is otherwise strict about.
8
+ *
9
+ * import { bytes } from "@nifrajs/core/binary"
10
+ *
11
+ * app.get("/invoice.pdf", async (c) => bytes(await render(c.params.id), {
12
+ * type: "application/pdf",
13
+ * filename: "invoice.pdf",
14
+ * }))
15
+ *
16
+ * The client types `data` as `Blob` for that route, and gets the bytes intact.
17
+ *
18
+ * The brand is a phantom: `bytes()` returns a real `Response` and nothing is added to it at runtime.
19
+ * It exists so the type carries a fact the value cannot - that these bytes are the payload rather than
20
+ * a serialization accident - which is what lets `Jsonify` answer `Blob` instead of trying to describe
21
+ * a `Response`'s properties.
22
+ */
23
+
24
+ /**
25
+ * Marker on a binary response's TYPE. A unique symbol rather than a string key, so no ordinary object
26
+ * can satisfy it by accident, and declared required rather than optional - an optional brand is
27
+ * satisfied by every type that lacks it, which would make the check match `Response` itself.
28
+ */
29
+ declare const NIFRA_BYTES: unique symbol
30
+
31
+ /** Runtime marker paired with the type-only brand so clients do not have to guess from media type. */
32
+ export const NIFRA_BINARY_HEADER = "x-nifra-binary"
33
+
34
+ /** A `Response` a route declared as binary. `Jsonify` maps this to `Blob`. */
35
+ export type BinaryResponse = Response & { readonly [NIFRA_BYTES]: true }
36
+
37
+ /** What can be sent as bytes without being re-encoded on the way out. */
38
+ export type BinaryBody = ArrayBuffer | ArrayBufferView | Blob | ReadableStream<Uint8Array>
39
+
40
+ export interface BytesOptions {
41
+ /** Media type. Defaults to `application/octet-stream`, the honest answer for unlabelled bytes. */
42
+ readonly type?: string
43
+ /**
44
+ * Offer the body as a download under this name.
45
+ *
46
+ * A filename derived from user data is the ordinary case - an upload's original name, a document
47
+ * title - so this has to survive anything a person can type. Two things follow, and both are tested:
48
+ * a `"` or a newline cannot end the value and write further parameters, and a non-Latin-1 name is
49
+ * encoded rather than thrown on.
50
+ */
51
+ readonly filename?: string
52
+ readonly status?: number
53
+ readonly headers?: Readonly<Record<string, string>>
54
+ }
55
+
56
+ /**
57
+ * Everything a header value may not contain: quotes, backslashes, and the CR/LF that would split the
58
+ * header. Replaced rather than rejected - a filename is cosmetic, and failing a download because a
59
+ * document was titled `Q1 "final"` would be worse than sending it with the quotes dropped.
60
+ */
61
+ const UNSAFE_FILENAME = /["\\\r\n]/g
62
+
63
+ /**
64
+ * Printable ASCII only.
65
+ *
66
+ * A header value is nominally Latin-1, but runtimes do not agree: Bun rejects `é` in a header outright,
67
+ * so "Latin-1 is fine" would have been a portability bug dressed as a standards reading. Printable
68
+ * ASCII is what every runtime and client accepts, and `filename*` carries everything else.
69
+ */
70
+ const NON_ASCII = /[^\x20-\x7e]/g
71
+
72
+ /**
73
+ * `Content-Disposition` for a name that may be anything a user typed.
74
+ *
75
+ * Setting a header containing `报告.pdf` or an emoji THROWS - which on a download route is a 500 for
76
+ * the ordinary act of naming a file in a language ASCII does not cover, and the name usually came from
77
+ * the user in the first place. RFC 6266 exists for exactly this: an ASCII `filename` every client
78
+ * understands, plus `filename*` carrying the real name UTF-8 percent-encoded, which browsers prefer.
79
+ *
80
+ * The ASCII fallback keeps the characters it can rather than substituting a placeholder, so
81
+ * `report 报告.pdf` still arrives as `report .pdf` on a client that ignores `filename*`.
82
+ *
83
+ * `filename*` is built from the SANITIZED name, not the raw one. Percent-encoding would make a hostile
84
+ * name inert either way, but round-tripping `"; attachment; filename="evil.exe` into the header - even
85
+ * encoded - puts an attacker's string somewhere a future reader has to reason about, and the second
86
+ * parameter is only there to carry characters ASCII cannot.
87
+ */
88
+ function contentDisposition(filename: string): string {
89
+ const safe = filename.toWellFormed().replace(UNSAFE_FILENAME, "")
90
+ const ascii = safe.replace(NON_ASCII, "")
91
+ const disposition = `attachment; filename="${ascii}"`
92
+ // Only when it adds something: a name ASCII already carries needs no second parameter.
93
+ if (ascii === safe) return disposition
94
+ // encodeURIComponent leaves `'()*` untouched, but RFC 5987's attr-char grammar does not.
95
+ const encoded = encodeURIComponent(safe).replace(
96
+ /['()*]/g,
97
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
98
+ )
99
+ return `${disposition}; filename*=UTF-8''${encoded}`
100
+ }
101
+
102
+ export function bytes(body: BinaryBody, options: BytesOptions = {}): BinaryResponse {
103
+ const headers = new Headers(options.headers as Record<string, string> | undefined)
104
+ headers.set("content-type", options.type ?? "application/octet-stream")
105
+ headers.set(NIFRA_BINARY_HEADER, "1")
106
+ // Response headers outside the CORS safelist are hidden from cross-origin clients unless exposed.
107
+ // Preserve an existing list and add the marker so the typed contract works cross-origin too.
108
+ const exposed = headers.get("access-control-expose-headers")
109
+ if (exposed !== "*") {
110
+ const names = exposed
111
+ ?.split(",")
112
+ .map((name) => name.trim())
113
+ .filter(Boolean)
114
+ if (!names?.some((name) => name.toLowerCase() === NIFRA_BINARY_HEADER)) {
115
+ headers.set(
116
+ "access-control-expose-headers",
117
+ [...(names ?? []), NIFRA_BINARY_HEADER].join(", "),
118
+ )
119
+ }
120
+ }
121
+ if (options.filename !== undefined) {
122
+ headers.set("content-disposition", contentDisposition(options.filename))
123
+ }
124
+ return new Response(body as ConstructorParameters<typeof Response>[0], {
125
+ status: options.status ?? 200,
126
+ headers,
127
+ }) as BinaryResponse
128
+ }
package/src/budget.ts CHANGED
@@ -154,7 +154,7 @@ class UnboundedRequestBudget implements RequestBudget {
154
154
 
155
155
  /**
156
156
  * Create a budget from an admitted absolute deadline. Wall time is sampled once; every subsequent
157
- * `remaining()` call is monotonic. This function does not arm a timer—the owner of `signal` does.
157
+ * `remaining()` call is monotonic. This function does not arm a timer-the owner of `signal` does.
158
158
  */
159
159
  export function createRequestBudget(options: CreateRequestBudgetOptions): RequestBudget {
160
160
  assertDeadline(options.deadline)
@@ -44,6 +44,36 @@ export type {
44
44
  EffectLifecycleObserver,
45
45
  }
46
46
 
47
+ const EXECUTION_JOURNAL = Symbol("nifra.capability.journal")
48
+
49
+ /**
50
+ * Framework wiring: put a journal on a request context so every `executeCapability` on that request is
51
+ * journaled without each call site threading it. Not for application code - install the
52
+ * `durableCommand()` adapter, which calls this and declares the assurance evidence that goes with it.
53
+ *
54
+ * Symbol-keyed, mirroring `attachEffectLedger`: it cannot collide with an application field and does
55
+ * not show up in a context spread or a log of it.
56
+ *
57
+ * Deliberately here rather than beside the journal TYPE in `internal/capability-runtime.ts`. That
58
+ * module is reachable from a bare server - it holds the route-capability guard - so a seam living
59
+ * there ships to every app, while here it arrives only with `executeCapability`, the one thing that
60
+ * reads it. Measured rather than assumed: in the internal module this cost every bundle row in the
61
+ * size matrix 10-18 bytes gzip, enough to put `nifra-mcp` over its ceiling.
62
+ */
63
+ export function attachCapabilityJournal(
64
+ context: object,
65
+ journal: CapabilityExecutionJournal,
66
+ ): void {
67
+ ;(context as Record<PropertyKey, unknown>)[EXECUTION_JOURNAL as unknown as string] = journal
68
+ }
69
+
70
+ /** The journal installed for this request, if any. */
71
+ export function capabilityJournalOf(context: object): CapabilityExecutionJournal | undefined {
72
+ return (context as { readonly [EXECUTION_JOURNAL]?: CapabilityExecutionJournal })[
73
+ EXECUTION_JOURNAL
74
+ ]
75
+ }
76
+
47
77
  export type CapabilityZone = "domain" | "operational"
48
78
  export type CapabilityAccess = "read" | "write"
49
79
  export type CapabilityIdempotency = "none" | "request" | "durable"
@@ -121,9 +151,11 @@ export type CapabilityFindingCode =
121
151
  | "provenance-uncovered"
122
152
  | "undeclared-capability-evidence"
123
153
  | "safe-method-domain-write"
154
+ | "unconfined-write-reach"
124
155
  | "missing-request-idempotency"
125
156
  | "missing-durable-idempotency"
126
157
  | "forbidden-effect-import"
158
+ | "provenance-truncated"
127
159
 
128
160
  export interface CapabilityFinding {
129
161
  readonly code: CapabilityFindingCode
@@ -343,16 +375,38 @@ export function evaluateCapabilityAssurance(
343
375
  message: `${route.method} ${route.path} has no static provenance coverage`,
344
376
  })
345
377
  }
378
+ // A safe-method route that REACHES a domain write without declaring it used to draw two findings
379
+ // giving opposite advice: declare it (evidence exceeds its declaration) and do not declare it (a
380
+ // safe method cannot carry a domain write). Both are right and together they are a dead end - the
381
+ // route cannot declare its way out, because the problem is not the declaration. Reach is computed
382
+ // from the module that registers the route, so a read endpoint sitting beside a write seam has
383
+ // write powers in scope, and the only fix is to confine them. Report that once, and say so.
384
+ const unconfined = new Set<string>()
346
385
  for (const id of evidenceIds) {
347
- if (!declared.includes(id)) {
386
+ if (declared.includes(id)) continue
387
+ const definition = definitions.get(id)
388
+ if (
389
+ SAFE_METHODS.has(route.method) &&
390
+ definition?.zone === "domain" &&
391
+ definition.access === "write"
392
+ ) {
393
+ unconfined.add(id)
348
394
  findings.push({
349
- code: "undeclared-capability-evidence",
395
+ code: "unconfined-write-reach",
350
396
  method: route.method,
351
397
  path: route.path,
352
398
  capability: id,
353
- message: `${route.method} ${route.path} evidence exceeds its declaration: ${id}`,
399
+ message: `${route.method} ${route.path} can reach domain write capability ${id} without declaring it, and a safe method may not declare one - move the route or the effect so the write is not in its module's reach`,
354
400
  })
401
+ continue
355
402
  }
403
+ findings.push({
404
+ code: "undeclared-capability-evidence",
405
+ method: route.method,
406
+ path: route.path,
407
+ capability: id,
408
+ message: `${route.method} ${route.path} evidence exceeds its declaration: ${id}`,
409
+ })
356
410
  }
357
411
 
358
412
  const effective = new Set([...declared, ...evidenceIds])
@@ -363,7 +417,10 @@ export function evaluateCapabilityAssurance(
363
417
  if (
364
418
  SAFE_METHODS.has(route.method) &&
365
419
  definition.zone === "domain" &&
366
- definition.access === "write"
420
+ definition.access === "write" &&
421
+ // Already reported as unconfined reach, with the fix that case actually has. Repeating "cannot
422
+ // carry" here is the half of the dead end that sends the reader back the way they came.
423
+ !unconfined.has(id)
367
424
  ) {
368
425
  findings.push({
369
426
  code: "safe-method-domain-write",
@@ -419,7 +476,7 @@ export function evaluateCapabilityAssurance(
419
476
 
420
477
  /**
421
478
  * Optional effect-ledger fields for one `useCapability` beacon. Token-only by design: an adapter
422
- * names *what* it touched and *how much resource* it used — never the value it read or wrote.
479
+ * names *what* it touched and *how much resource* it used - never the value it read or wrote.
423
480
  */
424
481
  export interface UseCapabilityOptions {
425
482
  /** Adapter/resource token recorded on the ledger entry (`repo:orders`). */
@@ -664,6 +721,11 @@ export async function executeCapability<T>(
664
721
  }
665
722
  const guard = guardFor(context, capability)
666
723
  const ledger = effectLedgerOf(context)
724
+ // An explicitly passed journal wins, so every existing call site behaves exactly as it did. The
725
+ // context journal is what the `durableCommand()` adapter installs, so that declaring the adapter
726
+ // once journals every effect on the request rather than each call site remembering to thread it -
727
+ // and so the `nifra.durable-command` evidence it declares is backed by something.
728
+ const journal = options.journal ?? capabilityJournalOf(context)
667
729
  const effectId = crypto.randomUUID()
668
730
  const metadata = normalizeEffectMetadata(options)
669
731
  const trace = effectTraceParentOf(context)
@@ -709,7 +771,7 @@ export async function executeCapability<T>(
709
771
  transitions: {
710
772
  async intent(execution) {
711
773
  ledger?.append({ capability, effectId: execution.effectId, ...metadata, phase: "intent" })
712
- await options.journal?.intent({
774
+ await journal?.intent({
713
775
  effectId: execution.effectId,
714
776
  capability,
715
777
  ...(metadata.target === undefined ? {} : { target: metadata.target }),
@@ -720,18 +782,18 @@ export async function executeCapability<T>(
720
782
  })
721
783
  },
722
784
  async executing(execution) {
723
- await options.journal?.executing(execution.effectId)
785
+ await journal?.executing(execution.effectId)
724
786
  },
725
787
  async committed(execution) {
726
788
  try {
727
- await options.journal?.committed(execution.effectId)
789
+ await journal?.committed(execution.effectId)
728
790
  } catch {
729
791
  throw new CapabilityJournalTransitionError(capability, execution.effectId, "committed")
730
792
  }
731
793
  },
732
794
  async failed(execution, failure) {
733
795
  try {
734
- await options.journal?.failed(execution.effectId, {
796
+ await journal?.failed(execution.effectId, {
735
797
  began: failure.began,
736
798
  errorCode: failure.errorCode,
737
799
  })
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Response data-classification tags. A route can declare the highest sensitivity its response body
3
- * carries (`schema.classification`). This is a declarative, compile-time + introspection fact — never
3
+ * carries (`schema.classification`). This is a declarative, compile-time + introspection fact - never
4
4
  * read on the request hot path and never enforced at runtime here. Downstream consumers use it: a
5
5
  * partner-API surface refuses to expose a route whose response is `pii`/`secret`, privacy tooling
6
6
  * learns which routes emit regulated data, and the capability lockfile records it so a route that
package/src/diff.ts CHANGED
@@ -1,21 +1,21 @@
1
1
  /**
2
2
  * API breaking-change detection over route reflection.
3
3
  *
4
- * `snapshotRoutes` turns an app (or reflected descriptors) into a plain-JSON snapshot — the
4
+ * `snapshotRoutes` turns an app (or reflected descriptors) into a plain-JSON snapshot - the
5
5
  * `standard` validators are dropped, so a snapshot survives `JSON.stringify` and can be committed
6
6
  * as a CI baseline. `diffRouteSnapshots` compares two snapshots and classifies every change by
7
7
  * wire compatibility, direction-aware:
8
8
  *
9
- * request direction (body/query — what clients SEND): a new required field, a removed field, or
9
+ * request direction (body/query - what clients SEND): a new required field, a removed field, or
10
10
  * a narrowed type breaks existing callers; widening (new optional field, enum superset,
11
11
  * required→optional) is compatible.
12
- * response direction (response/sse/errors — what clients RECEIVE): a removed field, a field made
12
+ * response direction (response/sse/errors - what clients RECEIVE): a removed field, a field made
13
13
  * optional, or a widened type breaks existing readers; narrowing (new field, optional→required,
14
14
  * enum subset) is compatible.
15
15
  *
16
16
  * The classifier FAILS CLOSED: a schema change it cannot prove compatible is reported as breaking.
17
17
  * Schemas without JSON Schema metadata (validation-only Standard Schemas) cannot be compared and
18
- * yield `info` — never a silent pass presented as proof.
18
+ * yield `info` - never a silent pass presented as proof.
19
19
  */
20
20
 
21
21
  import {
@@ -39,7 +39,7 @@ export interface RouteSnapshotSchema {
39
39
  readonly errors?: Readonly<Record<string, SchemaSnapshot>>
40
40
  }
41
41
 
42
- /** One route in a snapshot — plain JSON, safe to persist as a CI baseline. */
42
+ /** One route in a snapshot - plain JSON, safe to persist as a CI baseline. */
43
43
  export interface RouteSnapshot {
44
44
  readonly method: string
45
45
  readonly path: string
@@ -61,7 +61,7 @@ export interface RouteChange {
61
61
 
62
62
  export interface RoutesDiff {
63
63
  readonly changes: readonly RouteChange[]
64
- /** True when any change is `breaking` — the CI-gate signal. */
64
+ /** True when any change is `breaking` - the CI-gate signal. */
65
65
  readonly hasBreaking: boolean
66
66
  }
67
67
 
@@ -269,9 +269,9 @@ const diffSchemaSection = (
269
269
  return
270
270
  }
271
271
  if (before.jsonSchema === undefined || after.jsonSchema === undefined) {
272
- // Validation-only schemas expose no JSON Schema metadata — nothing provable either way.
272
+ // Validation-only schemas expose no JSON Schema metadata - nothing provable either way.
273
273
  if (before.jsonSchema !== after.jsonSchema || before.fields !== after.fields) {
274
- push(ctx, "info", `${ctx.section} schema is not introspectable — cannot verify compatibility`)
274
+ push(ctx, "info", `${ctx.section} schema is not introspectable - cannot verify compatibility`)
275
275
  }
276
276
  return
277
277
  }
@@ -461,10 +461,26 @@ export interface PostgresClient {
461
461
  }
462
462
 
463
463
  function sqlIdentifier(value: string): string {
464
- if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("invalid SQL table prefix")
464
+ // PostgreSQL truncates identifiers to 63 bytes. Reserve `_records_reconcile` (18 bytes), the
465
+ // longest suffix this adapter appends, so two accepted prefixes can never collapse to one name.
466
+ if (!/^[a-z_][a-z0-9_]{0,44}$/u.test(value)) throw new TypeError("invalid SQL table prefix")
465
467
  return value
466
468
  }
467
469
 
470
+ /** Explicit static-analysis boundary: every substitution is a validated, internal SQL fragment. */
471
+ function sqlIdentifiers(strings: TemplateStringsArray, ...values: readonly string[]): string {
472
+ for (const value of values) {
473
+ if (!/^(?:[a-z_][a-z0-9_]{0,62}|\?(?:,\?)*)$/u.test(value)) {
474
+ throw new TypeError("invalid SQL identifier fragment")
475
+ }
476
+ }
477
+ return strings.reduce((sql, part, index) => sql + part + (values[index] ?? ""), "")
478
+ }
479
+
480
+ function sqlPlaceholders(count: number): string {
481
+ return Array.from({ length: count }, () => "?").join(",")
482
+ }
483
+
468
484
  function recordFromRow<T>(row: Record<string, unknown> | undefined): T | undefined {
469
485
  if (row === undefined) return undefined
470
486
  const payload = row.payload
@@ -490,21 +506,13 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
490
506
 
491
507
  async migrate(): Promise<void> {
492
508
  await this.client.query(
493
- "CREATE TABLE IF NOT EXISTS " +
494
- this.recordsTable +
495
- " (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))",
509
+ 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))`,
496
510
  )
497
511
  await this.client.query(
498
- "CREATE INDEX IF NOT EXISTS " +
499
- this.recordsTable +
500
- "_reconcile ON " +
501
- this.recordsTable +
502
- " (kind, state, updated_at, id)",
512
+ sqlIdentifiers`CREATE INDEX IF NOT EXISTS ${this.recordsTable}_reconcile ON ${this.recordsTable} (kind, state, updated_at, id)`,
503
513
  )
504
514
  await this.client.query(
505
- "CREATE TABLE IF NOT EXISTS " +
506
- this.leasesTable +
507
- " (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at BIGINT NOT NULL DEFAULT 0, cursor TEXT)",
515
+ 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)`,
508
516
  )
509
517
  }
510
518
 
@@ -514,9 +522,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
514
522
  record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
515
523
  ): Promise<boolean> {
516
524
  const result = await this.client.query(
517
- "INSERT INTO " +
518
- this.recordsTable +
519
- " (kind,id,state,updated_at,version,payload) VALUES ($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING RETURNING id",
525
+ 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`,
520
526
  [
521
527
  kind,
522
528
  id,
@@ -533,7 +539,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
533
539
  id: string,
534
540
  ): Promise<T | undefined> {
535
541
  const result = await this.client.query(
536
- `SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`,
542
+ sqlIdentifiers`SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`,
537
543
  [kind, id],
538
544
  )
539
545
  return recordFromRow<T>(result.rows[0])
@@ -546,9 +552,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
546
552
  ): Promise<boolean> {
547
553
  if (record.version !== expectedVersion + 1) return false
548
554
  const result = await this.client.query(
549
- "UPDATE " +
550
- this.recordsTable +
551
- " SET state=$4,updated_at=$5,version=$6,payload=$7::jsonb WHERE kind=$1 AND id=$2 AND version=$3 RETURNING id",
555
+ 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`,
552
556
  [
553
557
  kind,
554
558
  id,
@@ -566,9 +570,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
566
570
  input: ReconciliationScanOptions<string>,
567
571
  ): Promise<ReconciliationScanPage<T>> {
568
572
  const result = await this.client.query(
569
- "SELECT id,payload FROM " +
570
- this.recordsTable +
571
- " 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",
573
+ 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`,
572
574
  [kind, input.states, input.updatedBefore ?? null, input.cursor ?? null, input.limit + 1],
573
575
  )
574
576
  const hasMore = result.rows.length > input.limit
@@ -584,11 +586,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
584
586
  const token = crypto.randomUUID()
585
587
  const expiresAt = input.now + input.leaseMs
586
588
  const result = await this.client.query(
587
- "INSERT INTO " +
588
- this.leasesTable +
589
- " (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 " +
590
- this.leasesTable +
591
- ".expires_at <= $5 RETURNING name,owner,token,expires_at,cursor",
589
+ 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`,
592
590
  [input.name, input.owner, token, expiresAt, input.now],
593
591
  )
594
592
  const row = result.rows[0]
@@ -603,9 +601,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
603
601
  }
604
602
  async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
605
603
  const result = await this.client.query(
606
- "UPDATE " +
607
- this.leasesTable +
608
- " SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name",
604
+ sqlIdentifiers`UPDATE ${this.leasesTable} SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name`,
609
605
  [input.name, input.owner, input.token, input.now + input.leaseMs, input.now],
610
606
  )
611
607
  return result.rows.length === 1
@@ -614,18 +610,14 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
614
610
  input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
615
611
  ): Promise<boolean> {
616
612
  const result = await this.client.query(
617
- "UPDATE " +
618
- this.leasesTable +
619
- " SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name",
613
+ sqlIdentifiers`UPDATE ${this.leasesTable} SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name`,
620
614
  [input.name, input.owner, input.token, input.cursor ?? null],
621
615
  )
622
616
  return result.rows.length === 1
623
617
  }
624
618
  async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
625
619
  const result = await this.client.query(
626
- "UPDATE " +
627
- this.leasesTable +
628
- " SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name",
620
+ sqlIdentifiers`UPDATE ${this.leasesTable} SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name`,
629
621
  [input.name, input.owner, input.token],
630
622
  )
631
623
  return result.rows.length === 1
@@ -680,17 +672,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
680
672
  }
681
673
  migrate(): void {
682
674
  this.client.exec(
683
- "CREATE TABLE IF NOT EXISTS " +
684
- this.recordsTable +
685
- " (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));" +
686
- "CREATE INDEX IF NOT EXISTS " +
687
- this.recordsTable +
688
- "_reconcile ON " +
689
- this.recordsTable +
690
- " (kind,state,updated_at,id);" +
691
- "CREATE TABLE IF NOT EXISTS " +
692
- this.leasesTable +
693
- " (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at INTEGER NOT NULL DEFAULT 0, cursor TEXT);",
675
+ 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);`,
694
676
  )
695
677
  }
696
678
  async create(
@@ -700,9 +682,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
700
682
  ): Promise<boolean> {
701
683
  const result = this.client
702
684
  .query(
703
- "INSERT OR IGNORE INTO " +
704
- this.recordsTable +
705
- " (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)",
685
+ sqlIdentifiers`INSERT OR IGNORE INTO ${this.recordsTable} (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)`,
706
686
  )
707
687
  .run(
708
688
  kind,
@@ -719,7 +699,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
719
699
  id: string,
720
700
  ): Promise<T | undefined> {
721
701
  const row = this.client
722
- .query(`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
702
+ .query(sqlIdentifiers`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
723
703
  .get(kind, id)
724
704
  return recordFromRow<T>(row ?? undefined)
725
705
  }
@@ -732,9 +712,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
732
712
  if (record.version !== expectedVersion + 1) return false
733
713
  const result = this.client
734
714
  .query(
735
- "UPDATE " +
736
- this.recordsTable +
737
- " SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?",
715
+ sqlIdentifiers`UPDATE ${this.recordsTable} SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?`,
738
716
  )
739
717
  .run(
740
718
  record.state ?? "",
@@ -752,14 +730,10 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
752
730
  input: ReconciliationScanOptions<string>,
753
731
  ): Promise<ReconciliationScanPage<T>> {
754
732
  if (input.states.length === 0) return { records: [] }
755
- const placeholders = input.states.map(() => "?").join(",")
733
+ const placeholders = sqlPlaceholders(input.states.length)
756
734
  const rows = this.client
757
735
  .query(
758
- "SELECT id,payload FROM " +
759
- this.recordsTable +
760
- " WHERE kind=? AND state IN (" +
761
- placeholders +
762
- ") AND (? IS NULL OR updated_at <= ?) AND (? IS NULL OR id > ?) ORDER BY id LIMIT ?",
736
+ 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 ?`,
763
737
  )
764
738
  .all(
765
739
  kind,
@@ -785,11 +759,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
785
759
  const expiresAt = input.now + input.leaseMs
786
760
  const row = this.client
787
761
  .query(
788
- "INSERT INTO " +
789
- this.leasesTable +
790
- " (name,owner,token,expires_at) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,token=excluded.token,expires_at=excluded.expires_at WHERE " +
791
- this.leasesTable +
792
- ".expires_at <= ? RETURNING name,owner,token,expires_at,cursor",
762
+ 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`,
793
763
  )
794
764
  .get(input.name, input.owner, token, expiresAt, input.now)
795
765
  if (row === undefined || row === null) return undefined
@@ -805,7 +775,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
805
775
  return (
806
776
  this.client
807
777
  .query(
808
- `UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`,
778
+ sqlIdentifiers`UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`,
809
779
  )
810
780
  .run(input.now + input.leaseMs, input.name, input.owner, input.token, input.now).changes ===
811
781
  1
@@ -816,7 +786,9 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
816
786
  ): Promise<boolean> {
817
787
  return (
818
788
  this.client
819
- .query(`UPDATE ${this.leasesTable} SET cursor=? WHERE name=? AND owner=? AND token=?`)
789
+ .query(
790
+ sqlIdentifiers`UPDATE ${this.leasesTable} SET cursor=? WHERE name=? AND owner=? AND token=?`,
791
+ )
820
792
  .run(input.cursor ?? null, input.name, input.owner, input.token).changes === 1
821
793
  )
822
794
  }
@@ -824,9 +796,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
824
796
  return (
825
797
  this.client
826
798
  .query(
827
- "UPDATE " +
828
- this.leasesTable +
829
- " SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?",
799
+ sqlIdentifiers`UPDATE ${this.leasesTable} SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?`,
830
800
  )
831
801
  .run(input.name, input.owner, input.token).changes === 1
832
802
  )
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Durable capability execution primitives. This subpath is opt-in so the bare HTTP runtime stays
3
3
  * lean. Approval and journal records are token-only. Saga input, results, and compensation arguments
4
- * intentionally live only in the caller-supplied durable saga store — never in the effect ledger or
5
- * telemetry — and production stores should encrypt them according to the application's data policy.
4
+ * intentionally live only in the caller-supplied durable saga store - never in the effect ledger or
5
+ * telemetry - and production stores should encrypt them according to the application's data policy.
6
6
  */
7
7
 
8
8
  import { type EffectLifecycleObserver, emitEffectLifecycle } from "./effect-lifecycle.ts"
@@ -2,7 +2,7 @@
2
2
  * Idempotency primitive. A mutating route can declare `schema.idempotency`; the server then dedupes
3
3
  * on an `Idempotency-Key` header: the first request runs and its response is stored, and a retry with
4
4
  * the same key replays the stored response byte-for-byte without re-running the handler. A retry that
5
- * reuses a key with a *different* request body is rejected (409) — a key binds to one request.
5
+ * reuses a key with a *different* request body is rejected (409) - a key binds to one request.
6
6
  *
7
7
  * This module is the runtime-neutral core: the store interface, an in-memory store, the request
8
8
  * fingerprint, and response (de)serialization. The server owns the request-path lane that reads the
@@ -136,7 +136,7 @@ const encoder = new TextEncoder()
136
136
 
137
137
  /**
138
138
  * SHA-256 fingerprint binding a key to one request: method, path (+ query), and the raw body bytes.
139
- * A collision-resistant hash matters — a weak hash would let a crafted body replay another's response.
139
+ * A collision-resistant hash matters - a weak hash would let a crafted body replay another's response.
140
140
  */
141
141
  export async function computeIdempotencyFingerprint(
142
142
  method: string,
@@ -297,7 +297,7 @@ export function responseFromStored(
297
297
  const headers = new Headers(replaySafeHeaders(stored.headers) as [string, string][])
298
298
  headers.set(IDEMPOTENT_REPLAY_HEADER, "1")
299
299
  // Reference the Response body type structurally: `BodyInit` isn't a named type under every lib config,
300
- // but a fresh `Uint8Array` is a valid body — the cast bridges the `ArrayBufferLike` generic variance.
300
+ // but a fresh `Uint8Array` is a valid body - the cast bridges the `ArrayBufferLike` generic variance.
301
301
  const body = (stored.body === "" ? null : base64ToBytes(stored.body)) as ConstructorParameters<
302
302
  typeof Response
303
303
  >[0]
@@ -320,7 +320,7 @@ export interface MemoryIdempotencyStoreOptions {
320
320
  }
321
321
 
322
322
  /**
323
- * In-process idempotency store. Reservation is atomic by construction — `begin` never awaits, so the
323
+ * In-process idempotency store. Reservation is atomic by construction - `begin` never awaits, so the
324
324
  * single-threaded event loop serializes concurrent callers for one key. Expired entries are treated
325
325
  * as absent (lazy eviction on access); a periodic {@link MemoryIdempotencyStore.sweep} bounds memory.
326
326
  */