@nifrajs/core 1.9.1 → 1.11.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 (83) hide show
  1. package/dist/assurance.d.ts +18 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +28 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/budget.d.ts +91 -0
  6. package/dist/budget.d.ts.map +1 -0
  7. package/dist/budget.js +172 -0
  8. package/dist/budget.js.map +1 -0
  9. package/dist/capabilities.d.ts +146 -0
  10. package/dist/capabilities.d.ts.map +1 -0
  11. package/dist/capabilities.js +283 -0
  12. package/dist/capabilities.js.map +1 -0
  13. package/dist/classification.d.ts +39 -0
  14. package/dist/classification.d.ts.map +1 -0
  15. package/dist/classification.js +153 -0
  16. package/dist/classification.js.map +1 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js.map +1 -1
  20. package/dist/idempotency.d.ts +137 -0
  21. package/dist/idempotency.d.ts.map +1 -0
  22. package/dist/idempotency.js +306 -0
  23. package/dist/idempotency.js.map +1 -0
  24. package/dist/index.d.ts +8 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/capability-runtime.d.ts +16 -0
  29. package/dist/internal/capability-runtime.d.ts.map +1 -0
  30. package/dist/internal/capability-runtime.js +27 -0
  31. package/dist/internal/capability-runtime.js.map +1 -0
  32. package/dist/internal/route-assurance.d.ts +1 -0
  33. package/dist/internal/route-assurance.d.ts.map +1 -1
  34. package/dist/internal/route-assurance.js +1 -0
  35. package/dist/internal/route-assurance.js.map +1 -1
  36. package/dist/invariants.d.ts +66 -0
  37. package/dist/invariants.d.ts.map +1 -0
  38. package/dist/invariants.js +483 -0
  39. package/dist/invariants.js.map +1 -0
  40. package/dist/ledger.d.ts +154 -0
  41. package/dist/ledger.d.ts.map +1 -0
  42. package/dist/ledger.js +271 -0
  43. package/dist/ledger.js.map +1 -0
  44. package/dist/manifest.d.ts +81 -0
  45. package/dist/manifest.d.ts.map +1 -0
  46. package/dist/manifest.js +394 -0
  47. package/dist/manifest.js.map +1 -0
  48. package/dist/mount.d.ts +18 -0
  49. package/dist/mount.d.ts.map +1 -0
  50. package/dist/mount.js +11 -0
  51. package/dist/mount.js.map +1 -0
  52. package/dist/reflection.d.ts +4 -0
  53. package/dist/reflection.d.ts.map +1 -1
  54. package/dist/reflection.js +9 -0
  55. package/dist/reflection.js.map +1 -1
  56. package/dist/server/context.d.ts +50 -1
  57. package/dist/server/context.d.ts.map +1 -1
  58. package/dist/server/contract.d.ts +8 -1
  59. package/dist/server/contract.d.ts.map +1 -1
  60. package/dist/server/contract.js +11 -1
  61. package/dist/server/contract.js.map +1 -1
  62. package/dist/server/server.d.ts +56 -2
  63. package/dist/server/server.d.ts.map +1 -1
  64. package/dist/server/server.js +294 -17
  65. package/dist/server/server.js.map +1 -1
  66. package/package.json +36 -4
  67. package/src/assurance.ts +48 -1
  68. package/src/budget.ts +267 -0
  69. package/src/capabilities.ts +486 -0
  70. package/src/classification.ts +189 -0
  71. package/src/errors.ts +2 -0
  72. package/src/idempotency.ts +420 -0
  73. package/src/index.ts +8 -1
  74. package/src/internal/capability-runtime.ts +49 -0
  75. package/src/internal/route-assurance.ts +1 -0
  76. package/src/invariants.ts +600 -0
  77. package/src/ledger.ts +430 -0
  78. package/src/manifest.ts +543 -0
  79. package/src/mount.ts +24 -0
  80. package/src/reflection.ts +18 -0
  81. package/src/server/context.ts +54 -1
  82. package/src/server/contract.ts +19 -2
  83. package/src/server/server.ts +463 -26
@@ -5,8 +5,27 @@ import {
5
5
  type DeadlineAdmissionOptions,
6
6
  NIFRA_DEADLINE_HEADER,
7
7
  type RequestBudget,
8
- } from "@nifrajs/budget"
8
+ } from "../budget.ts"
9
9
  import { FrameworkError, RouteConfigError } from "../errors.ts"
10
+ import {
11
+ canonicalizeIdempotencyBody,
12
+ computeIdempotencyFingerprint,
13
+ DEFAULT_IDEMPOTENCY_HEADER,
14
+ DEFAULT_IDEMPOTENCY_TTL_MS,
15
+ IdempotencyResponseTooLargeError,
16
+ type IdempotencyStore,
17
+ MemoryIdempotencyStore,
18
+ responseFromStored,
19
+ serializeResponse,
20
+ validIdempotencyKey,
21
+ validIdempotencyNamespace,
22
+ } from "../idempotency.ts"
23
+ import {
24
+ CAPABILITY_GUARD,
25
+ type CapabilityUseEvent,
26
+ createCapabilityGuard,
27
+ normalizeRouteCapabilities,
28
+ } from "../internal/capability-runtime.ts"
10
29
  import {
11
30
  type AssuranceDeclaration,
12
31
  type AssuranceEvidence,
@@ -14,6 +33,13 @@ import {
14
33
  assuranceEvidenceFor,
15
34
  NIFRA_ASSURANCE_IDS,
16
35
  } from "../internal/route-assurance.ts"
36
+ import {
37
+ attachEffectLedger,
38
+ createRequestLedger,
39
+ DEFAULT_MAX_ENTRIES,
40
+ type EffectLedgerOptions,
41
+ type RequestLedger,
42
+ } from "../ledger.ts"
17
43
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
18
44
  import type {
19
45
  InferOutput,
@@ -148,17 +174,42 @@ type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown
148
174
  type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
149
175
  type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
150
176
  export type OnRequestResult = Response | Request | undefined
151
- type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
177
+ type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
152
178
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
153
179
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
154
180
 
181
+ /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
182
+ interface ResolvedIdempotency {
183
+ readonly store: IdempotencyStore
184
+ readonly ttlMs: number
185
+ readonly headerName: string
186
+ readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
187
+ readonly maxResponseBytes: number
188
+ }
189
+
190
+ /** Registration-resolved effect-ledger wiring for one capability-declaring route. */
191
+ interface ResolvedEffectLedger {
192
+ readonly sink: EffectLedgerOptions["sink"]
193
+ readonly maxEntries: number
194
+ readonly chain: boolean
195
+ /** The registered route pattern — what the sealed ledger names (never the concrete URL). */
196
+ readonly method: string
197
+ readonly path: string
198
+ /** The route's declared capability tokens, surfaced on the sealed ledger. */
199
+ readonly declared: readonly string[]
200
+ }
201
+
155
202
  interface RouteEntry {
156
203
  readonly handler: InternalHandler
157
204
  readonly schema: RouteSchema | undefined
205
+ /** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
206
+ readonly idempotent: ResolvedIdempotency | undefined
207
+ /** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
208
+ readonly ledgered: ResolvedEffectLedger | undefined
158
209
  /** Per-request context extensions captured at registration (order-scoped). */
159
210
  readonly derives: ReadonlyArray<RawDerive>
160
211
  /** Static context extensions captured at registration. */
161
- readonly decorations: Record<string, unknown>
212
+ readonly decorations: Record<PropertyKey, unknown>
162
213
  /** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
163
214
  * `Object.assign` on the (common) no-decoration route. */
164
215
  readonly hasDecorations: boolean
@@ -313,6 +364,27 @@ export interface ServerOptions {
313
364
  * shared matched lane where the gate runs); when unset, the request path pays nothing.
314
365
  */
315
366
  readonly admission?: AdmissionController
367
+ /**
368
+ * Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
369
+ * parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
370
+ */
371
+ readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
372
+ /**
373
+ * Enable the per-request effect ledger. Each route that declares `schema.capabilities` gets a
374
+ * bounded, token-only ledger; `useCapability(c, id, …)` appends one entry per effect, and the sink
375
+ * receives the sealed ledger when the response settles (only when it recorded entries). Token-only
376
+ * by construction — capability ids, phases, counters, digests; never payloads, values, or the
377
+ * concrete URL (the route *pattern* is recorded). Enabling it moves capability-declaring routes off
378
+ * the fused fast path (they need a per-request context + a settle step); all other routes are
379
+ * unaffected, and when unset the request path pays nothing.
380
+ */
381
+ readonly effectLedger?: EffectLedgerOptions
382
+ /**
383
+ * Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
384
+ * to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
385
+ * Inject a durable store here to make request-scoped dedupe survive restarts app-wide.
386
+ */
387
+ readonly idempotencyStore?: IdempotencyStore
316
388
  /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
317
389
  readonly logger?: Logger
318
390
  /**
@@ -354,6 +426,8 @@ export interface RouteDescriptor {
354
426
  readonly schema: RouteSchema | undefined
355
427
  /** Effective enforcement evidence, populated only during reflection/introspection. */
356
428
  readonly assurance?: readonly AssuranceEvidence[]
429
+ /** Normalized declared effect tokens, populated only when the route declares any. */
430
+ readonly capabilities?: readonly string[]
357
431
  readonly tool?: {
358
432
  readonly name: string
359
433
  readonly description: string
@@ -412,7 +486,7 @@ export interface RunningServer {
412
486
  */
413
487
  export interface Middleware {
414
488
  readonly name?: string
415
- readonly onRequest?: (req: Request) => MaybePromise<OnRequestResult>
489
+ readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
416
490
  readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
417
491
  readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
418
492
  readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
@@ -1233,6 +1307,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1233
1307
  private readonly gracefulSignals: boolean
1234
1308
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
1235
1309
  private readonly capacityGate: AdmissionController | undefined
1310
+ private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
1311
+ /** Effect-ledger defaults pinned at construction; per-route `ledgered` copies add route identity. */
1312
+ private readonly effectLedger:
1313
+ | Omit<ResolvedEffectLedger, "method" | "path" | "declared">
1314
+ | undefined
1315
+ /** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
1316
+ private readonly configuredIdempotencyStore: IdempotencyStore | undefined
1317
+ private lazyIdempotencyStore: IdempotencyStore | undefined
1236
1318
  private readonly logger: Logger
1237
1319
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1238
1320
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
@@ -1293,6 +1375,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1293
1375
  }
1294
1376
  this.gracefulSignals = options.gracefulSignals ?? false
1295
1377
  this.capacityGate = options.admission
1378
+ this.onCapabilityUse = options.onCapabilityUse
1379
+ if (options.effectLedger !== undefined) {
1380
+ const ledger = options.effectLedger
1381
+ if (typeof ledger.sink !== "function") {
1382
+ throw new TypeError("effectLedger.sink must be a function")
1383
+ }
1384
+ const maxEntries = ledger.maxEntries ?? DEFAULT_MAX_ENTRIES
1385
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
1386
+ throw new RangeError("effectLedger.maxEntries must be a positive safe integer")
1387
+ }
1388
+ this.effectLedger = Object.freeze({
1389
+ sink: ledger.sink,
1390
+ maxEntries,
1391
+ chain: ledger.chain ?? false,
1392
+ })
1393
+ } else {
1394
+ this.effectLedger = undefined
1395
+ }
1396
+ this.configuredIdempotencyStore = options.idempotencyStore
1397
+ this.lazyIdempotencyStore = undefined
1296
1398
  this.logger = options.logger ?? jsonLogger()
1297
1399
  this.defaultOnValidationError = options.onValidationError
1298
1400
  this.bunServer = undefined
@@ -1330,8 +1432,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1330
1432
  * Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
1331
1433
  * `Request` to continue routing with a rewritten method/URL/headers. Global.
1332
1434
  */
1333
- onRequest(fn: (req: Request) => MaybePromise<OnRequestResult>): this {
1334
- this.onRequestHooks.push(fn)
1435
+ onRequest(
1436
+ fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
1437
+ ): this {
1438
+ this.onRequestHooks.push(fn as RawOnRequest)
1335
1439
  return this
1336
1440
  }
1337
1441
 
@@ -1744,9 +1848,48 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1744
1848
  schema: RouteSchema | undefined,
1745
1849
  handler: (context: never) => unknown,
1746
1850
  ): void {
1851
+ const capabilities = normalizeRouteCapabilities(schema?.capabilities)
1852
+ const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
1853
+ const invalidHandlerScope = handlerAssurance.find(
1854
+ (declaration) => declaration.scope !== "plugin",
1855
+ )
1856
+ if (invalidHandlerScope !== undefined) {
1857
+ throw new RouteConfigError(
1858
+ "INVALID_ASSURANCE",
1859
+ `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
1860
+ )
1861
+ }
1862
+ const authenticated = assuranceEvidenceFor(
1863
+ [...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance],
1864
+ method,
1865
+ path,
1866
+ ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
1867
+ const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
1868
+ if (capabilities.length > 0) {
1869
+ routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
1870
+ capabilities,
1871
+ method,
1872
+ path,
1873
+ this.onCapabilityUse,
1874
+ )
1875
+ }
1876
+ const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
1877
+ // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1878
+ // never takes the fused/native fast path — force it onto the portable matched lane (which routes
1879
+ // through `fetchMatched`, where the dedupe wrapper lives).
1880
+ const idempotent = this.resolveIdempotency(schema, authenticated)
1881
+ // A ledgered route (capabilities declared + `server({ effectLedger })`) needs a per-request
1882
+ // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1883
+ // fused/contextless fast path. Resolved per route, at registration — like the capability guard.
1884
+ const ledgered: ResolvedEffectLedger | undefined =
1885
+ capabilities.length > 0 && this.effectLedger !== undefined
1886
+ ? Object.freeze({ ...this.effectLedger, method, path, declared: capabilities })
1887
+ : undefined
1747
1888
  const bare =
1748
1889
  schema?.body === undefined &&
1749
1890
  schema?.query === undefined &&
1891
+ idempotent === undefined &&
1892
+ ledgered === undefined &&
1750
1893
  this.derives.length === 0 &&
1751
1894
  this.beforeHandleHooks.length === 0 &&
1752
1895
  this.afterHandleHooks.length === 0 &&
@@ -1755,7 +1898,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1755
1898
  bare && this.aroundHooks.length === 0
1756
1899
  ? this.buildFusedWeb(
1757
1900
  handler as unknown as InternalHandler,
1758
- Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined,
1901
+ hasDecorations ? routeDecorations : undefined,
1759
1902
  isContextlessNoArgArrow(handler),
1760
1903
  )
1761
1904
  : undefined
@@ -1764,10 +1907,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1764
1907
  // with the concrete RawContext the typed handler expects, so this is sound.
1765
1908
  handler: handler as unknown as InternalHandler,
1766
1909
  schema,
1910
+ idempotent,
1911
+ ledgered,
1767
1912
  fusedWeb,
1768
1913
  derives: [...this.derives],
1769
- decorations: { ...this.decorations },
1770
- hasDecorations: Object.keys(this.decorations).length > 0,
1914
+ decorations: routeDecorations,
1915
+ hasDecorations,
1771
1916
  beforeHandle: [...this.beforeHandleHooks],
1772
1917
  afterHandle: [...this.afterHandleHooks],
1773
1918
  onError: [...this.onErrorHooks],
@@ -1794,9 +1939,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1794
1939
  this.onErrorHooks.length === 0,
1795
1940
  }
1796
1941
  this.router.add(method, path, entry)
1797
- const descriptor: RouteDescriptor = { method, path, schema }
1942
+ const descriptor: RouteDescriptor = {
1943
+ method,
1944
+ path,
1945
+ schema,
1946
+ ...(capabilities.length > 0 ? { capabilities } : {}),
1947
+ }
1798
1948
  this.routeList.push(descriptor)
1799
- const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
1949
+ const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1800
1950
  if (schema?.body !== undefined) {
1801
1951
  routeAssurance.push(
1802
1952
  Object.freeze({
@@ -1806,6 +1956,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1806
1956
  }),
1807
1957
  )
1808
1958
  }
1959
+ // Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
1960
+ // durable command execution; that stronger evidence belongs to a command/outbox adapter.
1961
+ if (schema?.idempotency !== undefined) {
1962
+ routeAssurance.push(
1963
+ Object.freeze({
1964
+ id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
1965
+ source: "route-schema",
1966
+ scope: "plugin",
1967
+ }),
1968
+ )
1969
+ }
1809
1970
  this.rawEntries.push({
1810
1971
  method,
1811
1972
  path,
@@ -1815,6 +1976,84 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1815
1976
  })
1816
1977
  }
1817
1978
 
1979
+ /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
1980
+ private resolveIdempotency(
1981
+ schema: RouteSchema | undefined,
1982
+ authenticated: boolean,
1983
+ ): ResolvedIdempotency | undefined {
1984
+ const config = schema?.idempotency
1985
+ if (config === undefined) return undefined
1986
+ if (schema?.sse !== undefined) {
1987
+ throw new RouteConfigError(
1988
+ "INVALID_IDEMPOTENCY",
1989
+ "streaming/SSE responses cannot be used on an idempotency route",
1990
+ )
1991
+ }
1992
+ const store = config.store ?? this.defaultIdempotencyStore()
1993
+ if (config.scope === "durable" && store.durability !== "durable") {
1994
+ throw new RouteConfigError(
1995
+ "INVALID_IDEMPOTENCY",
1996
+ 'durable idempotency requires a durable store (store.durability must be "durable")',
1997
+ )
1998
+ }
1999
+ const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
2000
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
2001
+ throw new RouteConfigError(
2002
+ "INVALID_IDEMPOTENCY",
2003
+ "idempotency ttlMs must be a finite positive number",
2004
+ )
2005
+ }
2006
+ const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
2007
+ try {
2008
+ new Headers().set(headerName, "probe")
2009
+ } catch {
2010
+ throw new RouteConfigError(
2011
+ "INVALID_IDEMPOTENCY",
2012
+ `invalid idempotency header name ${JSON.stringify(headerName)}`,
2013
+ )
2014
+ }
2015
+ const namespace = config.namespace
2016
+ if (namespace === undefined) {
2017
+ throw new RouteConfigError(
2018
+ "INVALID_IDEMPOTENCY",
2019
+ "idempotency namespace is required; use a principal resolver or an explicit shared scope",
2020
+ )
2021
+ }
2022
+ if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
2023
+ throw new RouteConfigError(
2024
+ "INVALID_IDEMPOTENCY",
2025
+ `invalid idempotency namespace ${JSON.stringify(namespace)}`,
2026
+ )
2027
+ }
2028
+ if (authenticated && typeof namespace !== "function") {
2029
+ throw new RouteConfigError(
2030
+ "INVALID_IDEMPOTENCY",
2031
+ "authenticated idempotency routes require a principal namespace resolver",
2032
+ )
2033
+ }
2034
+ const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes
2035
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
2036
+ throw new RouteConfigError(
2037
+ "INVALID_IDEMPOTENCY",
2038
+ "idempotency maxResponseBytes must be a positive integer",
2039
+ )
2040
+ }
2041
+ return Object.freeze({
2042
+ store,
2043
+ ttlMs,
2044
+ headerName,
2045
+ namespace,
2046
+ maxResponseBytes,
2047
+ })
2048
+ }
2049
+
2050
+ /** The app-wide idempotency store: the configured one, else a lazily-created shared in-process store. */
2051
+ private defaultIdempotencyStore(): IdempotencyStore {
2052
+ if (this.configuredIdempotencyStore !== undefined) return this.configuredIdempotencyStore
2053
+ this.lazyIdempotencyStore ??= new MemoryIdempotencyStore()
2054
+ return this.lazyIdempotencyStore
2055
+ }
2056
+
1818
2057
  /**
1819
2058
  * Merge another server's routes into this one — the composition escape hatch for large apps.
1820
2059
  *
@@ -1986,18 +2225,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1986
2225
  release()
1987
2226
  throw error
1988
2227
  }
1989
- if (outcome instanceof Promise) {
1990
- return outcome.then(
1991
- (response) => {
1992
- release()
1993
- return response
1994
- },
1995
- (error) => {
1996
- release()
1997
- throw error
1998
- },
1999
- )
2000
- }
2228
+ // Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
2229
+ // the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
2230
+ // request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
2231
+ if (outcome instanceof Promise) return outcome.finally(release)
2001
2232
  release()
2002
2233
  return outcome
2003
2234
  }
@@ -2177,7 +2408,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2177
2408
  const originalRequest = requestOf(source)
2178
2409
  let current: RequestSource = source
2179
2410
  for (let i = 0; i < hooks.length; i++) {
2180
- const outcome = (hooks[i] as RawOnRequest)(requestOf(current))
2411
+ const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
2181
2412
  if (outcome instanceof Promise) {
2182
2413
  return outcome.then((early) =>
2183
2414
  this.continueOnRequest(
@@ -2227,7 +2458,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2227
2458
  return wrapResponse(early)
2228
2459
  }
2229
2460
  if (index >= this.onRequestHooks.length) break
2230
- const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current))
2461
+ const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current), platform)
2231
2462
  early = outcome instanceof Promise ? await outcome : outcome
2232
2463
  index++
2233
2464
  }
@@ -2297,6 +2528,160 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2297
2528
  wrapResponse: (response: Response) => T,
2298
2529
  onTimeout: () => T,
2299
2530
  webFast: boolean,
2531
+ ): MaybePromise<T> {
2532
+ // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2533
+ // (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
2534
+ if (entry.idempotent !== undefined) {
2535
+ return this.runIdempotent(source, platform, entry, params, search, wrapResponse)
2536
+ }
2537
+ return this.runMatchedLanes(
2538
+ source,
2539
+ platform,
2540
+ entry,
2541
+ params,
2542
+ search,
2543
+ finalize,
2544
+ wrapResponse,
2545
+ onTimeout,
2546
+ webFast,
2547
+ )
2548
+ }
2549
+
2550
+ /**
2551
+ * Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
2552
+ * store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
2553
+ * the response; a repeat key replays it without re-running; a key reused with a different body or an
2554
+ * in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
2555
+ * caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
2556
+ */
2557
+ private async runIdempotent<T>(
2558
+ source: RequestSource,
2559
+ platform: Platform | undefined,
2560
+ entry: RouteEntry,
2561
+ params: Record<string, string>,
2562
+ search: string | undefined,
2563
+ wrapResponse: (response: Response) => T,
2564
+ ): Promise<T> {
2565
+ const config = entry.idempotent as ResolvedIdempotency
2566
+ const req = requestOf(source)
2567
+ const key = req.headers.get(config.headerName)
2568
+ if (key === null || !validIdempotencyKey(key)) {
2569
+ return wrapResponse(jsonError(400, "idempotency_key_required"))
2570
+ }
2571
+ const namespace =
2572
+ typeof config.namespace === "function"
2573
+ ? await config.namespace(req.clone(), platform)
2574
+ : config.namespace
2575
+ if (!validIdempotencyNamespace(namespace)) {
2576
+ return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
2577
+ }
2578
+ const read = await readBoundedBytes(req, this.maxBodyBytes)
2579
+ if (!read.ok) {
2580
+ return wrapResponse(
2581
+ jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
2582
+ )
2583
+ }
2584
+ const url = urlPartsOf(req.url)
2585
+ const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
2586
+ const fingerprint = await computeIdempotencyFingerprint(
2587
+ req.method,
2588
+ `${url.pathname}${url.search}`,
2589
+ canonicalBody,
2590
+ req.headers.get("content-type") ?? "",
2591
+ )
2592
+
2593
+ const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
2594
+ if (begin.state === "replay") {
2595
+ return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }))
2596
+ }
2597
+ if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
2598
+ if (begin.state === "in-flight") {
2599
+ return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
2600
+ }
2601
+ if (begin.state === "capacity") {
2602
+ return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
2603
+ }
2604
+ const reservation = begin.reservation
2605
+
2606
+ // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
2607
+ // then run those lanes to a concrete Response.
2608
+ const bufferedInit: RequestInit = {
2609
+ method: req.method,
2610
+ headers: req.headers,
2611
+ signal: req.signal,
2612
+ }
2613
+ if (read.bytes.byteLength > 0)
2614
+ bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
2615
+ const buffered: RequestSource = new Request(req.url, bufferedInit)
2616
+ let response: Response
2617
+ try {
2618
+ response = await this.runMatchedLanes(
2619
+ buffered,
2620
+ platform,
2621
+ entry,
2622
+ params,
2623
+ search,
2624
+ toResponse,
2625
+ IDENTITY_RESPONSE,
2626
+ RESPONSE_TIMEOUT,
2627
+ false,
2628
+ )
2629
+ } catch (err) {
2630
+ // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
2631
+ // reservation so a retry isn't wedged, then re-throw.
2632
+ await config.store.abandon({ namespace, key, reservation })
2633
+ throw err
2634
+ }
2635
+ // Cache only successful responses; release the key on any error so the client can retry.
2636
+ if (response.status >= 200 && response.status < 300) {
2637
+ let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
2638
+ try {
2639
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
2640
+ } catch (error) {
2641
+ if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
2642
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
2643
+ // Commit a small terminal response under the winning key and return that same response now.
2644
+ response = jsonError(507, "idempotency_response_too_large")
2645
+ try {
2646
+ storedResponse = await serializeResponse(response, {
2647
+ maxBytes: config.maxResponseBytes,
2648
+ })
2649
+ } catch (terminalError) {
2650
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
2651
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
2652
+ // status + replay safety without silently storing more bytes than the route permits.
2653
+ response = new Response(null, { status: 507 })
2654
+ storedResponse = await serializeResponse(response, {
2655
+ maxBytes: config.maxResponseBytes,
2656
+ })
2657
+ }
2658
+ }
2659
+ const completed = await config.store.complete({
2660
+ namespace,
2661
+ key,
2662
+ reservation,
2663
+ response: storedResponse,
2664
+ })
2665
+ if (!completed) {
2666
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
2667
+ }
2668
+ } else {
2669
+ await config.store.abandon({ namespace, key, reservation })
2670
+ }
2671
+ return wrapResponse(response)
2672
+ }
2673
+
2674
+ /** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
2675
+ private runMatchedLanes<T>(
2676
+ source: RequestSource,
2677
+ platform: Platform | undefined,
2678
+ entry: RouteEntry,
2679
+ params: Record<string, string>,
2680
+ search: string | undefined,
2681
+ finalize: (result: unknown, set: CtxSet) => T,
2682
+ wrapResponse: (response: Response) => T,
2683
+ onTimeout: () => T,
2684
+ webFast: boolean,
2300
2685
  ): MaybePromise<T> {
2301
2686
  // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
2302
2687
  // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
@@ -2371,6 +2756,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2371
2756
  platform,
2372
2757
  this.maxBodyBytes,
2373
2758
  )
2759
+ // Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
2760
+ // `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
2761
+ // was forced off the fused/contextless lanes at registration, so this branch always runs.
2762
+ let ledger: RequestLedger | undefined
2763
+ if (entry.ledgered !== undefined) {
2764
+ ledger = createRequestLedger({
2765
+ method: entry.ledgered.method,
2766
+ path: entry.ledgered.path,
2767
+ declared: entry.ledgered.declared,
2768
+ maxEntries: entry.ledgered.maxEntries,
2769
+ chain: entry.ledgered.chain,
2770
+ })
2771
+ attachEffectLedger(ctx, ledger)
2772
+ }
2374
2773
 
2375
2774
  if (entry.around.length === 0) {
2376
2775
  outcome = entry.bare
@@ -2396,6 +2795,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2396
2795
  wrapResponse,
2397
2796
  )
2398
2797
  }
2798
+ // Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
2799
+ // success and error responses alike (partial work is exactly what an audit must capture). The
2800
+ // lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
2801
+ if (ledger !== undefined) {
2802
+ const resolved = entry.ledgered as ResolvedEffectLedger
2803
+ const active = ledger
2804
+ outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
2805
+ this.settleEffectLedger(active, resolved, value),
2806
+ )
2807
+ }
2399
2808
  }
2400
2809
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
2401
2810
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
@@ -2414,6 +2823,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2414
2823
  return outcome
2415
2824
  }
2416
2825
 
2826
+ /**
2827
+ * Seal a ledgered request and deliver the sealed ledger to the sink. An empty ledger (declared
2828
+ * capabilities, none exercised) is not delivered — there is nothing to audit. This post-effect
2829
+ * ledger is observational: transactional/durable audit belongs in
2830
+ * the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
2831
+ * 500 that can duplicate it.
2832
+ */
2833
+ private async settleEffectLedger<T>(
2834
+ ledger: RequestLedger,
2835
+ resolved: ResolvedEffectLedger,
2836
+ value: T,
2837
+ ): Promise<T> {
2838
+ const sealed = await ledger.seal()
2839
+ if (sealed.entries.length === 0) return value
2840
+ try {
2841
+ await resolved.sink(sealed)
2842
+ } catch (err) {
2843
+ // Token-only logging: the route pattern and the failure name — never entry payloads (there are
2844
+ // none) and never the request.
2845
+ this.logger.error("effect ledger sink failed", {
2846
+ method: resolved.method,
2847
+ path: resolved.path,
2848
+ name: err instanceof Error ? err.name : "Error",
2849
+ })
2850
+ }
2851
+ return value
2852
+ }
2853
+
2417
2854
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
2418
2855
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2419
2856
  private runContextlessBare<T>(
@@ -2527,7 +2964,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2527
2964
  */
2528
2965
  private buildFusedWeb(
2529
2966
  handler: InternalHandler,
2530
- decorations: Record<string, unknown> | undefined,
2967
+ decorations: Record<PropertyKey, unknown> | undefined,
2531
2968
  contextless: boolean,
2532
2969
  ): FusedWebRunner {
2533
2970
  const maxBodyBytes = this.maxBodyBytes