@nifrajs/core 1.9.1 → 1.10.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 (63) hide show
  1. package/dist/assurance.d.ts +5 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +8 -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 +117 -0
  10. package/dist/capabilities.d.ts.map +1 -0
  11. package/dist/capabilities.js +257 -0
  12. package/dist/capabilities.js.map +1 -0
  13. package/dist/classification.d.ts +19 -0
  14. package/dist/classification.d.ts.map +1 -0
  15. package/dist/classification.js +33 -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 +90 -0
  21. package/dist/idempotency.d.ts.map +1 -0
  22. package/dist/idempotency.js +135 -0
  23. package/dist/idempotency.js.map +1 -0
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -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/reflection.d.ts +4 -0
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +9 -0
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/server/context.d.ts +34 -1
  41. package/dist/server/context.d.ts.map +1 -1
  42. package/dist/server/contract.d.ts +8 -1
  43. package/dist/server/contract.d.ts.map +1 -1
  44. package/dist/server/contract.js +11 -1
  45. package/dist/server/contract.js.map +1 -1
  46. package/dist/server/server.d.ts +33 -0
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +139 -15
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +11 -4
  51. package/src/assurance.ts +15 -1
  52. package/src/budget.ts +267 -0
  53. package/src/capabilities.ts +430 -0
  54. package/src/classification.ts +44 -0
  55. package/src/errors.ts +1 -0
  56. package/src/idempotency.ts +205 -0
  57. package/src/index.ts +4 -1
  58. package/src/internal/capability-runtime.ts +49 -0
  59. package/src/internal/route-assurance.ts +1 -0
  60. package/src/reflection.ts +14 -0
  61. package/src/server/context.ts +35 -1
  62. package/src/server/contract.ts +19 -2
  63. package/src/server/server.ts +233 -20
package/src/index.ts CHANGED
@@ -12,13 +12,16 @@
12
12
  * (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
13
13
  * consumers can pin it at the type level — see `test/version.test-d.ts`.
14
14
  */
15
- export const VERSION = "1.9.1" as const
15
+ export const VERSION = "1.10.0" as const
16
16
 
17
17
  export type Version = typeof VERSION
18
18
 
19
19
  export * from "./assurance.ts"
20
+ export * from "./capabilities.ts"
21
+ export * from "./classification.ts"
20
22
  export * from "./diff.ts"
21
23
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
24
+ export * from "./idempotency.ts"
22
25
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
23
26
  export * from "./reflection.ts"
24
27
  export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
@@ -0,0 +1,49 @@
1
+ export const CAPABILITY_GUARD = Symbol("nifra.capability.guard")
2
+ const CAPABILITY_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
3
+
4
+ export function validCapabilityId(value: string): boolean {
5
+ return CAPABILITY_ID.test(value)
6
+ }
7
+
8
+ export interface CapabilityUseEvent {
9
+ readonly capability: string
10
+ readonly method: string
11
+ readonly path: string
12
+ }
13
+
14
+ export interface CapabilityGuard {
15
+ readonly allowed: readonly string[]
16
+ readonly method: string
17
+ readonly path: string
18
+ readonly onUse?: (event: CapabilityUseEvent) => void
19
+ }
20
+
21
+ export function normalizeRouteCapabilities(
22
+ values: readonly string[] | undefined,
23
+ ): readonly string[] {
24
+ if (values === undefined) return Object.freeze([])
25
+ if (!Array.isArray(values)) throw new TypeError("route capabilities must be an array")
26
+ const out: string[] = []
27
+ const seen = new Set<string>()
28
+ for (const value of values) {
29
+ if (typeof value !== "string" || !validCapabilityId(value)) {
30
+ throw new Error(
31
+ `route capabilities: invalid capability id ${JSON.stringify(value)} (use lowercase dot/dash segments)`,
32
+ )
33
+ }
34
+ if (!seen.has(value)) {
35
+ seen.add(value)
36
+ out.push(value)
37
+ }
38
+ }
39
+ return Object.freeze(out)
40
+ }
41
+
42
+ export function createCapabilityGuard(
43
+ allowed: readonly string[],
44
+ method: string,
45
+ path: string,
46
+ onUse: ((event: CapabilityUseEvent) => void) | undefined,
47
+ ): CapabilityGuard {
48
+ return Object.freeze({ allowed, method, path, ...(onUse !== undefined ? { onUse } : {}) })
49
+ }
@@ -26,6 +26,7 @@ export const NIFRA_ASSURANCE_IDS = Object.freeze({
26
26
  AUTHENTICATED: "nifra.authenticated",
27
27
  BODY_BOUNDED: "nifra.body-bounded",
28
28
  CSRF: "nifra.csrf",
29
+ DURABLE_COMMAND: "nifra.durable-command",
29
30
  IDEMPOTENCY_KEY: "nifra.idempotency-key",
30
31
  IP_RESTRICTED: "nifra.ip-restricted",
31
32
  RATE_LIMITED: "nifra.rate-limited",
package/src/reflection.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * recognizes Nifra/TypeBox carriers and raw JSON Schema without depending on a validator package.
8
8
  */
9
9
 
10
+ import { type DataClassification, isDataClassification } from "./classification.ts"
11
+ import { normalizeRouteCapabilities } from "./internal/capability-runtime.ts"
10
12
  import { type AssuranceEvidence, validEvidence } from "./internal/route-assurance.ts"
11
13
  import type { StandardSchemaV1 } from "./schema/standard.ts"
12
14
  import type { ToolAnnotations } from "./server/server.ts"
@@ -45,6 +47,9 @@ export interface ReflectedRoute {
45
47
  readonly path: string
46
48
  readonly schema?: ReflectedRouteSchema
47
49
  readonly assurance?: readonly AssuranceEvidence[]
50
+ readonly capabilities?: readonly string[]
51
+ /** Highest data-sensitivity the response carries, when declared. */
52
+ readonly classification?: DataClassification
48
53
  readonly tool?: {
49
54
  readonly name: string
50
55
  readonly description: string
@@ -196,11 +201,20 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
196
201
  const schema = reflectedRouteSchema(route.schema)
197
202
  const tool = reflectedTool(route.tool)
198
203
  const assurance = reflectedAssurance(route.assurance)
204
+ const capabilities = normalizeRouteCapabilities(
205
+ Array.isArray(route.capabilities) ? (route.capabilities as readonly string[]) : undefined,
206
+ )
207
+ const rawClassification = recordOf(route.schema)?.classification
208
+ const classification: DataClassification | undefined = isDataClassification(rawClassification)
209
+ ? rawClassification
210
+ : undefined
199
211
  reflected.push({
200
212
  method: route.method.toUpperCase(),
201
213
  path: route.path,
202
214
  ...(schema !== undefined ? { schema } : {}),
203
215
  ...(assurance !== undefined ? { assurance } : {}),
216
+ ...(capabilities.length > 0 ? { capabilities } : {}),
217
+ ...(classification !== undefined ? { classification } : {}),
204
218
  ...(tool !== undefined ? { tool } : {}),
205
219
  })
206
220
  }
@@ -1,7 +1,25 @@
1
- import type { RequestBudget } from "@nifrajs/budget"
1
+ import type { RequestBudget } from "../budget.ts"
2
+ import type { DataClassification } from "../classification.ts"
3
+ import type { IdempotencyScope, IdempotencyStore } from "../idempotency.ts"
2
4
  import type { InferOutput, StandardIssue, StandardSchemaV1 } from "../schema/standard.ts"
3
5
  import type { CookieOptions } from "./cookies.ts"
4
6
 
7
+ /**
8
+ * Declares a mutating route as idempotent: the server dedupes retries on an `Idempotency-Key` header
9
+ * (see {@link RouteSchema.idempotency}). Satisfies the capability-assurance idempotency requirement
10
+ * for a `write` capability — `scope: "durable"` additionally clears the durable-command requirement.
11
+ */
12
+ export interface IdempotencyConfig {
13
+ /** `request` = in-process dedupe; `durable` = cross-restart, backed by a durable store. */
14
+ readonly scope: IdempotencyScope
15
+ /** Retention for the stored response. Default 24h. */
16
+ readonly ttlMs?: number
17
+ /** Store override. Defaults to the server's shared in-memory store; inject a durable store here. */
18
+ readonly store?: IdempotencyStore
19
+ /** Header carrying the key. Default `idempotency-key`. */
20
+ readonly headerName?: string
21
+ }
22
+
5
23
  /** Flattens an intersection into a single object type for readable hovers. */
6
24
  export type Prettify<T> = { [K in keyof T]: T[K] } & {}
7
25
 
@@ -25,6 +43,22 @@ export type Params<Path extends string> = Prettify<RawParams<Path>>
25
43
 
26
44
  /** Per-route input schemas. Each is any Standard Schema (zod/valibot/arktype/…). */
27
45
  export interface RouteSchema {
46
+ /** Declared effect tokens. Reflected for assurance; never read by validation or serialization. */
47
+ readonly capabilities?: readonly string[]
48
+ /**
49
+ * Dedupe retries of this (mutating) route on an `Idempotency-Key` header. The first request runs and
50
+ * its response is stored; a retry with the same key replays that response without re-running the
51
+ * handler, and a key reused with a different body is rejected (409). A missing key on an
52
+ * idempotency-required route fails closed (400). Off the hot path — routes without it are unchanged.
53
+ */
54
+ readonly idempotency?: IdempotencyConfig
55
+ /**
56
+ * Highest data-sensitivity this route's response body carries (`public` | `pii` | `secret`). A
57
+ * declarative, compile-time + introspection fact — never validated at runtime. Reflected for tooling,
58
+ * recorded in the capability lockfile (so a route that starts returning PII is a reviewable change),
59
+ * and read by downstream policy (e.g. a partner-API surface refusing to expose `pii`/`secret`).
60
+ */
61
+ readonly classification?: DataClassification
28
62
  readonly body?: StandardSchemaV1
29
63
  readonly query?: StandardSchemaV1
30
64
  /** Optional **response contract**. When declared: the handler's return is type-checked against it
@@ -1,7 +1,9 @@
1
+ import type { DataClassification } from "../classification.ts"
1
2
  import { RouteConfigError } from "../errors.ts"
3
+ import { normalizeRouteCapabilities } from "../internal/capability-runtime.ts"
2
4
  import { METHODS, type Method } from "../router/router.ts"
3
5
  import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
4
- import type { Context, Params, RouteSchema } from "./context.ts"
6
+ import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
5
7
  import type { OutputOf } from "./registry.ts"
6
8
  import { Server } from "./server.ts"
7
9
 
@@ -27,6 +29,12 @@ export interface OperationDef {
27
29
  readonly body?: StandardSchemaV1
28
30
  readonly query?: StandardSchemaV1
29
31
  readonly response?: StandardSchemaV1
32
+ /** Declared effect tokens, carried into route reflection and capability assurance. */
33
+ readonly capabilities?: readonly string[]
34
+ /** Dedupe retries of this operation on an `Idempotency-Key` header (see {@link RouteSchema.idempotency}). */
35
+ readonly idempotency?: IdempotencyConfig
36
+ /** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
37
+ readonly classification?: DataClassification
30
38
  /** Short summary (OpenAPI `summary`). */
31
39
  readonly summary?: string
32
40
  /** Longer description (OpenAPI `description`, CommonMark). */
@@ -167,6 +175,7 @@ export function defineContract<const C extends ContractShape>(contract: C): C {
167
175
  if (seen.has(key)) {
168
176
  throw new RouteConfigError("DUPLICATE_ROUTE", `duplicate operation route: ${key}`)
169
177
  }
178
+ normalizeRouteCapabilities(op.capabilities)
170
179
  seen.add(key)
171
180
  }
172
181
  return contract
@@ -219,11 +228,19 @@ export function implement<const C extends ContractShape, H extends HandlersFor<C
219
228
  // once here at bind time, not per request — it just carries a reference to the op's existing schema.
220
229
  // A response-less op still yields `undefined`, byte-identical to before.
221
230
  const schema: RouteSchema | undefined =
222
- op.body !== undefined || op.query !== undefined || op.response !== undefined
231
+ op.body !== undefined ||
232
+ op.query !== undefined ||
233
+ op.response !== undefined ||
234
+ op.capabilities !== undefined ||
235
+ op.idempotency !== undefined ||
236
+ op.classification !== undefined
223
237
  ? {
224
238
  ...(op.body !== undefined ? { body: op.body } : {}),
225
239
  ...(op.query !== undefined ? { query: op.query } : {}),
226
240
  ...(op.response !== undefined ? { response: op.response } : {}),
241
+ ...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
242
+ ...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
243
+ ...(op.classification !== undefined ? { classification: op.classification } : {}),
227
244
  }
228
245
  : undefined
229
246
  app.register(
@@ -5,8 +5,24 @@ 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
+ computeIdempotencyFingerprint,
12
+ DEFAULT_IDEMPOTENCY_HEADER,
13
+ DEFAULT_IDEMPOTENCY_TTL_MS,
14
+ type IdempotencyStore,
15
+ MemoryIdempotencyStore,
16
+ responseFromStored,
17
+ serializeResponse,
18
+ validIdempotencyKey,
19
+ } from "../idempotency.ts"
20
+ import {
21
+ CAPABILITY_GUARD,
22
+ type CapabilityUseEvent,
23
+ createCapabilityGuard,
24
+ normalizeRouteCapabilities,
25
+ } from "../internal/capability-runtime.ts"
10
26
  import {
11
27
  type AssuranceDeclaration,
12
28
  type AssuranceEvidence,
@@ -152,13 +168,22 @@ type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
152
168
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
153
169
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
154
170
 
171
+ /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
172
+ interface ResolvedIdempotency {
173
+ readonly store: IdempotencyStore
174
+ readonly ttlMs: number
175
+ readonly headerName: string
176
+ }
177
+
155
178
  interface RouteEntry {
156
179
  readonly handler: InternalHandler
157
180
  readonly schema: RouteSchema | undefined
181
+ /** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
182
+ readonly idempotent: ResolvedIdempotency | undefined
158
183
  /** Per-request context extensions captured at registration (order-scoped). */
159
184
  readonly derives: ReadonlyArray<RawDerive>
160
185
  /** Static context extensions captured at registration. */
161
- readonly decorations: Record<string, unknown>
186
+ readonly decorations: Record<PropertyKey, unknown>
162
187
  /** Whether {@link decorations} has any keys — precomputed so the hot path skips a no-op
163
188
  * `Object.assign` on the (common) no-decoration route. */
164
189
  readonly hasDecorations: boolean
@@ -313,6 +338,17 @@ export interface ServerOptions {
313
338
  * shared matched lane where the gate runs); when unset, the request path pays nothing.
314
339
  */
315
340
  readonly admission?: AdmissionController
341
+ /**
342
+ * Optional token-only observation hook called by `useCapability(c, id)`. It receives no request,
343
+ * parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
344
+ */
345
+ readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
346
+ /**
347
+ * Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
348
+ * to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
349
+ * Inject a durable store here to make request-scoped dedupe survive restarts app-wide.
350
+ */
351
+ readonly idempotencyStore?: IdempotencyStore
316
352
  /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
317
353
  readonly logger?: Logger
318
354
  /**
@@ -354,6 +390,8 @@ export interface RouteDescriptor {
354
390
  readonly schema: RouteSchema | undefined
355
391
  /** Effective enforcement evidence, populated only during reflection/introspection. */
356
392
  readonly assurance?: readonly AssuranceEvidence[]
393
+ /** Normalized declared effect tokens, populated only when the route declares any. */
394
+ readonly capabilities?: readonly string[]
357
395
  readonly tool?: {
358
396
  readonly name: string
359
397
  readonly description: string
@@ -1233,6 +1271,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1233
1271
  private readonly gracefulSignals: boolean
1234
1272
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
1235
1273
  private readonly capacityGate: AdmissionController | undefined
1274
+ private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
1275
+ /** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
1276
+ private readonly configuredIdempotencyStore: IdempotencyStore | undefined
1277
+ private lazyIdempotencyStore: IdempotencyStore | undefined
1236
1278
  private readonly logger: Logger
1237
1279
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1238
1280
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
@@ -1293,6 +1335,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1293
1335
  }
1294
1336
  this.gracefulSignals = options.gracefulSignals ?? false
1295
1337
  this.capacityGate = options.admission
1338
+ this.onCapabilityUse = options.onCapabilityUse
1339
+ this.configuredIdempotencyStore = options.idempotencyStore
1340
+ this.lazyIdempotencyStore = undefined
1296
1341
  this.logger = options.logger ?? jsonLogger()
1297
1342
  this.defaultOnValidationError = options.onValidationError
1298
1343
  this.bunServer = undefined
@@ -1744,9 +1789,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1744
1789
  schema: RouteSchema | undefined,
1745
1790
  handler: (context: never) => unknown,
1746
1791
  ): void {
1792
+ const capabilities = normalizeRouteCapabilities(schema?.capabilities)
1793
+ const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
1794
+ const invalidHandlerScope = handlerAssurance.find(
1795
+ (declaration) => declaration.scope !== "plugin",
1796
+ )
1797
+ if (invalidHandlerScope !== undefined) {
1798
+ throw new RouteConfigError(
1799
+ "INVALID_ASSURANCE",
1800
+ `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
1801
+ )
1802
+ }
1803
+ const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
1804
+ if (capabilities.length > 0) {
1805
+ routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
1806
+ capabilities,
1807
+ method,
1808
+ path,
1809
+ this.onCapabilityUse,
1810
+ )
1811
+ }
1812
+ const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
1813
+ // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1814
+ // never takes the fused/native fast path — force it onto the portable matched lane (which routes
1815
+ // through `fetchMatched`, where the dedupe wrapper lives).
1816
+ const idempotent = this.resolveIdempotency(schema)
1747
1817
  const bare =
1748
1818
  schema?.body === undefined &&
1749
1819
  schema?.query === undefined &&
1820
+ idempotent === undefined &&
1750
1821
  this.derives.length === 0 &&
1751
1822
  this.beforeHandleHooks.length === 0 &&
1752
1823
  this.afterHandleHooks.length === 0 &&
@@ -1755,7 +1826,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1755
1826
  bare && this.aroundHooks.length === 0
1756
1827
  ? this.buildFusedWeb(
1757
1828
  handler as unknown as InternalHandler,
1758
- Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined,
1829
+ hasDecorations ? routeDecorations : undefined,
1759
1830
  isContextlessNoArgArrow(handler),
1760
1831
  )
1761
1832
  : undefined
@@ -1764,10 +1835,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1764
1835
  // with the concrete RawContext the typed handler expects, so this is sound.
1765
1836
  handler: handler as unknown as InternalHandler,
1766
1837
  schema,
1838
+ idempotent,
1767
1839
  fusedWeb,
1768
1840
  derives: [...this.derives],
1769
- decorations: { ...this.decorations },
1770
- hasDecorations: Object.keys(this.decorations).length > 0,
1841
+ decorations: routeDecorations,
1842
+ hasDecorations,
1771
1843
  beforeHandle: [...this.beforeHandleHooks],
1772
1844
  afterHandle: [...this.afterHandleHooks],
1773
1845
  onError: [...this.onErrorHooks],
@@ -1794,9 +1866,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1794
1866
  this.onErrorHooks.length === 0,
1795
1867
  }
1796
1868
  this.router.add(method, path, entry)
1797
- const descriptor: RouteDescriptor = { method, path, schema }
1869
+ const descriptor: RouteDescriptor = {
1870
+ method,
1871
+ path,
1872
+ schema,
1873
+ ...(capabilities.length > 0 ? { capabilities } : {}),
1874
+ }
1798
1875
  this.routeList.push(descriptor)
1799
- const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
1876
+ const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1800
1877
  if (schema?.body !== undefined) {
1801
1878
  routeAssurance.push(
1802
1879
  Object.freeze({
@@ -1806,6 +1883,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1806
1883
  }),
1807
1884
  )
1808
1885
  }
1886
+ // Declaring `schema.idempotency` is the evidence that satisfies a write capability's idempotency
1887
+ // requirement (capability assurance reads these ids). `durable` scope additionally proves the
1888
+ // durable-command requirement.
1889
+ if (schema?.idempotency !== undefined) {
1890
+ routeAssurance.push(
1891
+ Object.freeze({
1892
+ id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
1893
+ source: "route-schema",
1894
+ scope: "plugin",
1895
+ }),
1896
+ )
1897
+ if (schema.idempotency.scope === "durable") {
1898
+ routeAssurance.push(
1899
+ Object.freeze({
1900
+ id: NIFRA_ASSURANCE_IDS.DURABLE_COMMAND,
1901
+ source: "route-schema",
1902
+ scope: "plugin",
1903
+ }),
1904
+ )
1905
+ }
1906
+ }
1809
1907
  this.rawEntries.push({
1810
1908
  method,
1811
1909
  path,
@@ -1815,6 +1913,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1815
1913
  })
1816
1914
  }
1817
1915
 
1916
+ /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
1917
+ private resolveIdempotency(schema: RouteSchema | undefined): ResolvedIdempotency | undefined {
1918
+ const config = schema?.idempotency
1919
+ if (config === undefined) return undefined
1920
+ return Object.freeze({
1921
+ store: config.store ?? this.defaultIdempotencyStore(),
1922
+ ttlMs: config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS,
1923
+ headerName: (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase(),
1924
+ })
1925
+ }
1926
+
1927
+ /** The app-wide idempotency store: the configured one, else a lazily-created shared in-process store. */
1928
+ private defaultIdempotencyStore(): IdempotencyStore {
1929
+ if (this.configuredIdempotencyStore !== undefined) return this.configuredIdempotencyStore
1930
+ this.lazyIdempotencyStore ??= new MemoryIdempotencyStore()
1931
+ return this.lazyIdempotencyStore
1932
+ }
1933
+
1818
1934
  /**
1819
1935
  * Merge another server's routes into this one — the composition escape hatch for large apps.
1820
1936
  *
@@ -1986,18 +2102,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1986
2102
  release()
1987
2103
  throw error
1988
2104
  }
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
- }
2105
+ // Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
2106
+ // the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
2107
+ // request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
2108
+ if (outcome instanceof Promise) return outcome.finally(release)
2001
2109
  release()
2002
2110
  return outcome
2003
2111
  }
@@ -2297,6 +2405,111 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2297
2405
  wrapResponse: (response: Response) => T,
2298
2406
  onTimeout: () => T,
2299
2407
  webFast: boolean,
2408
+ ): MaybePromise<T> {
2409
+ // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2410
+ // (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
2411
+ if (entry.idempotent !== undefined) {
2412
+ return this.runIdempotent(source, platform, entry, params, search, wrapResponse)
2413
+ }
2414
+ return this.runMatchedLanes(
2415
+ source,
2416
+ platform,
2417
+ entry,
2418
+ params,
2419
+ search,
2420
+ finalize,
2421
+ wrapResponse,
2422
+ onTimeout,
2423
+ webFast,
2424
+ )
2425
+ }
2426
+
2427
+ /**
2428
+ * Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
2429
+ * store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
2430
+ * the response; a repeat key replays it without re-running; a key reused with a different body or an
2431
+ * in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
2432
+ * caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
2433
+ */
2434
+ private async runIdempotent<T>(
2435
+ source: RequestSource,
2436
+ platform: Platform | undefined,
2437
+ entry: RouteEntry,
2438
+ params: Record<string, string>,
2439
+ search: string | undefined,
2440
+ wrapResponse: (response: Response) => T,
2441
+ ): Promise<T> {
2442
+ const config = entry.idempotent as ResolvedIdempotency
2443
+ const req = requestOf(source)
2444
+ const key = req.headers.get(config.headerName)
2445
+ if (key === null || !validIdempotencyKey(key)) {
2446
+ return wrapResponse(jsonError(400, "idempotency_key_required"))
2447
+ }
2448
+ const read = await readBoundedBytes(req, this.maxBodyBytes)
2449
+ if (!read.ok) {
2450
+ return wrapResponse(
2451
+ jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"),
2452
+ )
2453
+ }
2454
+ const url = urlPartsOf(req.url)
2455
+ const fingerprint = await computeIdempotencyFingerprint(
2456
+ req.method,
2457
+ `${url.pathname}${url.search}`,
2458
+ read.bytes,
2459
+ )
2460
+
2461
+ const begin = await config.store.begin(key, fingerprint, config.ttlMs)
2462
+ if (begin.state === "replay") return wrapResponse(responseFromStored(begin.response))
2463
+ if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
2464
+ if (begin.state === "in-flight") {
2465
+ return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
2466
+ }
2467
+
2468
+ // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
2469
+ // then run those lanes to a concrete Response.
2470
+ const bufferedInit: RequestInit = { method: req.method, headers: req.headers }
2471
+ if (read.bytes.byteLength > 0)
2472
+ bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
2473
+ const buffered: RequestSource = new Request(req.url, bufferedInit)
2474
+ let response: Response
2475
+ try {
2476
+ response = await this.runMatchedLanes(
2477
+ buffered,
2478
+ platform,
2479
+ entry,
2480
+ params,
2481
+ search,
2482
+ toResponse,
2483
+ IDENTITY_RESPONSE,
2484
+ RESPONSE_TIMEOUT,
2485
+ false,
2486
+ )
2487
+ } catch (err) {
2488
+ // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
2489
+ // reservation so a retry isn't wedged, then re-throw.
2490
+ await config.store.abandon(key)
2491
+ throw err
2492
+ }
2493
+ // Cache only successful responses; release the key on any error so the client can retry.
2494
+ if (response.status >= 200 && response.status < 300) {
2495
+ await config.store.complete(key, await serializeResponse(response))
2496
+ } else {
2497
+ await config.store.abandon(key)
2498
+ }
2499
+ return wrapResponse(response)
2500
+ }
2501
+
2502
+ /** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
2503
+ private runMatchedLanes<T>(
2504
+ source: RequestSource,
2505
+ platform: Platform | undefined,
2506
+ entry: RouteEntry,
2507
+ params: Record<string, string>,
2508
+ search: string | undefined,
2509
+ finalize: (result: unknown, set: CtxSet) => T,
2510
+ wrapResponse: (response: Response) => T,
2511
+ onTimeout: () => T,
2512
+ webFast: boolean,
2300
2513
  ): MaybePromise<T> {
2301
2514
  // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
2302
2515
  // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
@@ -2527,7 +2740,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2527
2740
  */
2528
2741
  private buildFusedWeb(
2529
2742
  handler: InternalHandler,
2530
- decorations: Record<string, unknown> | undefined,
2743
+ decorations: Record<PropertyKey, unknown> | undefined,
2531
2744
  contextless: boolean,
2532
2745
  ): FusedWebRunner {
2533
2746
  const maxBodyBytes = this.maxBodyBytes