@nifrajs/core 1.9.0 → 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 +5 -2
  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 +75 -0
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +195 -9
  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 +6 -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 +328 -11
@@ -0,0 +1,44 @@
1
+ /**
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
4
+ * read on the request hot path and never enforced at runtime here. Downstream consumers use it: a
5
+ * partner-API surface refuses to expose a route whose response is `pii`/`secret`, privacy tooling
6
+ * learns which routes emit regulated data, and the capability lockfile records it so a route that
7
+ * *starts* returning PII flips the lockfile and forces a review.
8
+ */
9
+
10
+ /** Sensitivity of the data a response carries. Ordered `public` < `pii` < `secret`. */
11
+ export type DataClassification = "public" | "pii" | "secret"
12
+
13
+ /** Total order over classifications; higher = more sensitive. */
14
+ export const DATA_CLASSIFICATION_RANK: Readonly<Record<DataClassification, number>> = Object.freeze(
15
+ {
16
+ public: 0,
17
+ pii: 1,
18
+ secret: 2,
19
+ },
20
+ )
21
+
22
+ const CLASSIFICATIONS = Object.keys(DATA_CLASSIFICATION_RANK) as readonly DataClassification[]
23
+
24
+ /** Whether `value` is a known classification token. */
25
+ export function isDataClassification(value: unknown): value is DataClassification {
26
+ return typeof value === "string" && (CLASSIFICATIONS as readonly string[]).includes(value)
27
+ }
28
+
29
+ /** The most sensitive classification among the inputs; `"public"` when none are given. */
30
+ export function maxClassification(values: Iterable<DataClassification>): DataClassification {
31
+ let max: DataClassification = "public"
32
+ for (const value of values) {
33
+ if (DATA_CLASSIFICATION_RANK[value] > DATA_CLASSIFICATION_RANK[max]) max = value
34
+ }
35
+ return max
36
+ }
37
+
38
+ /** True when `value` is at least as sensitive as `floor` (e.g. `classificationAtLeast(x, "pii")`). */
39
+ export function classificationAtLeast(
40
+ value: DataClassification,
41
+ floor: DataClassification,
42
+ ): boolean {
43
+ return DATA_CLASSIFICATION_RANK[value] >= DATA_CLASSIFICATION_RANK[floor]
44
+ }
package/src/errors.ts CHANGED
@@ -24,6 +24,7 @@ export type RouteConfigErrorCode =
24
24
  | "INVALID_PARAM_NAME"
25
25
  | "WILDCARD_NOT_LAST"
26
26
  | "INVALID_METHOD"
27
+ | "INVALID_ASSURANCE"
27
28
 
28
29
  /**
29
30
  * Thrown at route registration when a route is misconfigured. This is the
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Idempotency primitive. A mutating route can declare `schema.idempotency`; the server then dedupes
3
+ * on an `Idempotency-Key` header: the first request runs and its response is stored, and a retry with
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.
6
+ *
7
+ * This module is the runtime-neutral core: the store interface, an in-memory store, the request
8
+ * fingerprint, and response (de)serialization. The server owns the request-path lane that reads the
9
+ * body, consults the store, and captures the response. All logic here is pure/injectable so it is
10
+ * unit-tested without a server. A durable store (cross-restart) implements the same interface.
11
+ */
12
+
13
+ /** Whether a route's idempotency is satisfied by an in-process store or a durable (cross-restart) one. */
14
+ export type IdempotencyScope = "request" | "durable"
15
+
16
+ /** Default retention for a stored idempotent response: 24 hours. */
17
+ export const DEFAULT_IDEMPOTENCY_TTL_MS = 86_400_000
18
+
19
+ /** Canonical request header carrying the client-chosen idempotency key. */
20
+ export const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"
21
+
22
+ /** Header stamped on a replayed response so clients/proxies can tell a replay from a fresh run. */
23
+ export const IDEMPOTENT_REPLAY_HEADER = "x-nifra-idempotent-replay"
24
+
25
+ const KEY_MAX_LENGTH = 255
26
+
27
+ /** A serialized response held by a store. `body` is base64 so binary payloads round-trip intact. */
28
+ export interface StoredResponse {
29
+ readonly status: number
30
+ readonly headers: readonly (readonly [string, string])[]
31
+ /** Base64-encoded response body; `""` when the response had no body. */
32
+ readonly body: string
33
+ }
34
+
35
+ /**
36
+ * Outcome of reserving a key. `new` → the caller runs the handler and later calls {@link
37
+ * IdempotencyStore.complete}. `replay` → return the stored response, handler never runs. `mismatch`
38
+ * → same key, different request fingerprint (client bug) → 409. `in-flight` → the key is reserved but
39
+ * not yet completed (a concurrent duplicate) → 409 + Retry-After.
40
+ */
41
+ export type IdempotencyBeginResult =
42
+ | { readonly state: "new" }
43
+ | { readonly state: "replay"; readonly response: StoredResponse }
44
+ | { readonly state: "mismatch" }
45
+ | { readonly state: "in-flight" }
46
+
47
+ /**
48
+ * Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent
49
+ * caller sees `new`; the rest see `in-flight` (or `replay` once completed). The in-memory store gets
50
+ * this free from the single-threaded event loop; a durable store uses an atomic insert.
51
+ */
52
+ export interface IdempotencyStore {
53
+ /** Reserve `key` for `fingerprint`, retaining any stored response for `ttlMs`. */
54
+ begin(
55
+ key: string,
56
+ fingerprint: string,
57
+ ttlMs: number,
58
+ ): IdempotencyBeginResult | Promise<IdempotencyBeginResult>
59
+ /** Persist the final response for a reserved key so later retries replay it. */
60
+ complete(key: string, response: StoredResponse): void | Promise<void>
61
+ /** Release a reservation that produced no cacheable response, so the client may retry. */
62
+ abandon(key: string): void | Promise<void>
63
+ }
64
+
65
+ /** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */
66
+ export function validIdempotencyKey(key: string): boolean {
67
+ if (typeof key !== "string" || key.length === 0 || key.length > KEY_MAX_LENGTH) return false
68
+ for (let i = 0; i < key.length; i++) {
69
+ const code = key.charCodeAt(i)
70
+ if (code <= 31 || code === 127) return false
71
+ }
72
+ return true
73
+ }
74
+
75
+ const encoder = new TextEncoder()
76
+
77
+ /**
78
+ * SHA-256 fingerprint binding a key to one request: method, path (+ query), and the raw body bytes.
79
+ * A collision-resistant hash matters — a weak hash would let a crafted body replay another's response.
80
+ */
81
+ export async function computeIdempotencyFingerprint(
82
+ method: string,
83
+ path: string,
84
+ body: Uint8Array,
85
+ ): Promise<string> {
86
+ const head = encoder.encode(`${method.toUpperCase()}\n${path}\n`)
87
+ const material = new Uint8Array(head.length + body.length)
88
+ material.set(head, 0)
89
+ material.set(body, head.length)
90
+ const digest = await crypto.subtle.digest("SHA-256", material)
91
+ const bytes = new Uint8Array(digest)
92
+ let hex = ""
93
+ for (let i = 0; i < bytes.length; i++) hex += (bytes[i] as number).toString(16).padStart(2, "0")
94
+ return hex
95
+ }
96
+
97
+ const BASE64_CHUNK = 0x8000
98
+
99
+ function bytesToBase64(bytes: Uint8Array): string {
100
+ let binary = ""
101
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
102
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK))
103
+ }
104
+ return btoa(binary)
105
+ }
106
+
107
+ function base64ToBytes(value: string): Uint8Array {
108
+ const binary = atob(value)
109
+ const bytes = new Uint8Array(binary.length)
110
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
111
+ return bytes
112
+ }
113
+
114
+ /** Buffer a response into a storable form. Clones first so the live response body stays intact. */
115
+ export async function serializeResponse(response: Response): Promise<StoredResponse> {
116
+ const bytes = new Uint8Array(await response.clone().arrayBuffer())
117
+ return {
118
+ status: response.status,
119
+ headers: [...response.headers] as readonly (readonly [string, string])[],
120
+ body: bytes.length > 0 ? bytesToBase64(bytes) : "",
121
+ }
122
+ }
123
+
124
+ /** Rebuild a live response from storage, stamping the replay marker header. */
125
+ export function responseFromStored(stored: StoredResponse): Response {
126
+ const headers = new Headers(stored.headers as [string, string][])
127
+ headers.set(IDEMPOTENT_REPLAY_HEADER, "1")
128
+ // Reference the Response body type structurally: `BodyInit` isn't a named type under every lib config,
129
+ // but a fresh `Uint8Array` is a valid body — the cast bridges the `ArrayBufferLike` generic variance.
130
+ const body = (stored.body === "" ? null : base64ToBytes(stored.body)) as ConstructorParameters<
131
+ typeof Response
132
+ >[0]
133
+ return new Response(body, { status: stored.status, headers })
134
+ }
135
+
136
+ interface MemoryEntry {
137
+ readonly fingerprint: string
138
+ readonly expiresAt: number
139
+ response: StoredResponse | undefined
140
+ }
141
+
142
+ export interface MemoryIdempotencyStoreOptions {
143
+ /** Injectable clock (epoch ms) for deterministic TTL tests. Default `Date.now`. */
144
+ readonly now?: () => number
145
+ }
146
+
147
+ /**
148
+ * In-process idempotency store. Reservation is atomic by construction — `begin` never awaits, so the
149
+ * single-threaded event loop serializes concurrent callers for one key. Expired entries are treated
150
+ * as absent (lazy eviction on access); a periodic {@link MemoryIdempotencyStore.sweep} bounds memory.
151
+ */
152
+ export class MemoryIdempotencyStore implements IdempotencyStore {
153
+ private readonly entries = new Map<string, MemoryEntry>()
154
+ private readonly now: () => number
155
+
156
+ constructor(options: MemoryIdempotencyStoreOptions = {}) {
157
+ this.now = options.now ?? Date.now
158
+ }
159
+
160
+ begin(key: string, fingerprint: string, ttlMs: number): IdempotencyBeginResult {
161
+ const existing = this.entries.get(key)
162
+ if (existing !== undefined && existing.expiresAt > this.now()) {
163
+ if (existing.fingerprint !== fingerprint) return { state: "mismatch" }
164
+ if (existing.response !== undefined) return { state: "replay", response: existing.response }
165
+ return { state: "in-flight" }
166
+ }
167
+ this.entries.set(key, {
168
+ fingerprint,
169
+ expiresAt: this.now() + ttlMs,
170
+ response: undefined,
171
+ })
172
+ return { state: "new" }
173
+ }
174
+
175
+ complete(key: string, response: StoredResponse): void {
176
+ const entry = this.entries.get(key)
177
+ if (entry !== undefined) entry.response = response
178
+ }
179
+
180
+ abandon(key: string): void {
181
+ const entry = this.entries.get(key)
182
+ // Only drop a still-pending reservation; never evict a completed (replayable) response.
183
+ if (entry !== undefined && entry.response === undefined) this.entries.delete(key)
184
+ }
185
+
186
+ /** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */
187
+ sweep(): void {
188
+ const now = this.now()
189
+ for (const [key, entry] of this.entries) {
190
+ if (entry.expiresAt <= now) this.entries.delete(key)
191
+ }
192
+ }
193
+
194
+ /** Live entry count (post-sweep semantics are the caller's; this is a raw size for tests/metrics). */
195
+ get size(): number {
196
+ return this.entries.size
197
+ }
198
+ }
199
+
200
+ /** Convenience factory mirroring the other core primitives' `create*` style. */
201
+ export function createMemoryIdempotencyStore(
202
+ options?: MemoryIdempotencyStoreOptions,
203
+ ): MemoryIdempotencyStore {
204
+ return new MemoryIdempotencyStore(options)
205
+ }
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.0" 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"
@@ -78,6 +81,8 @@ export {
78
81
  } from "./server/logger.ts"
79
82
  export type { Registry, RouteInfo } from "./server/registry.ts"
80
83
  export {
84
+ type AdmissionController,
85
+ type AdmissionDecision,
81
86
  type AnyServer,
82
87
  type DurableObjectNamespaceLike,
83
88
  defineIdentityPlugin,
@@ -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(