@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
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Route effect/capability assurance. Declarations are reflected at build/CI time; runtime beacons
3
+ * are a fail-closed defence at owned effect seams. Static provenance remains the security anchor.
4
+ */
5
+
6
+ import type { DataClassification } from "./classification.ts"
7
+ import {
8
+ CAPABILITY_GUARD,
9
+ type CapabilityGuard,
10
+ type CapabilityUseEvent,
11
+ validCapabilityId,
12
+ } from "./internal/capability-runtime.ts"
13
+ import { NIFRA_ASSURANCE_IDS } from "./internal/route-assurance.ts"
14
+ import { reflectRoutes } from "./reflection.ts"
15
+
16
+ export type { CapabilityUseEvent }
17
+
18
+ export type CapabilityZone = "domain" | "operational"
19
+ export type CapabilityAccess = "read" | "write"
20
+ export type CapabilityIdempotency = "none" | "request" | "durable"
21
+
22
+ export interface CapabilityDefinition {
23
+ readonly id: string
24
+ /** Domain state is subject to HTTP method semantics; operational writes (logs/metrics) are not. */
25
+ readonly zone: CapabilityZone
26
+ readonly access: CapabilityAccess
27
+ /** Evidence required when this capability may execute. Default `none`. */
28
+ readonly idempotency?: CapabilityIdempotency
29
+ }
30
+
31
+ export interface CapabilityImportRule {
32
+ /** Exact module specifier, or a trailing `/*` prefix rule. */
33
+ readonly specifier: string
34
+ readonly capabilities: readonly string[]
35
+ }
36
+
37
+ export interface ForbiddenCapabilityImport {
38
+ /** Exact module specifier, or a trailing `/*` prefix rule. */
39
+ readonly specifier: string
40
+ readonly reason: string
41
+ }
42
+
43
+ export interface CapabilityRouteSelector {
44
+ readonly methods?: readonly string[]
45
+ readonly paths?: readonly string[]
46
+ }
47
+
48
+ export interface CapabilityRouteModule {
49
+ readonly match: CapabilityRouteSelector
50
+ /** Project-relative modules that implement the selected routes. */
51
+ readonly modules: readonly string[]
52
+ }
53
+
54
+ export interface CapabilityProvenancePolicy {
55
+ readonly imports: readonly CapabilityImportRule[]
56
+ readonly forbiddenImports: readonly ForbiddenCapabilityImport[]
57
+ /** Explicit associations for contract/generated routes that source scanning cannot locate. */
58
+ readonly routeModules?: readonly CapabilityRouteModule[]
59
+ }
60
+
61
+ export interface CapabilityPolicy {
62
+ readonly definitions: readonly CapabilityDefinition[]
63
+ /** Required: capability assurance without a static provenance firewall is incomplete. */
64
+ readonly provenance: CapabilityProvenancePolicy
65
+ /** Default `capabilities.lock.json`. */
66
+ readonly lockfile?: string
67
+ }
68
+
69
+ export type CapabilityEvidenceKind = "static" | "runtime"
70
+
71
+ /** Token-only effect evidence. `source` is an adapter/module id, never request or business data. */
72
+ export interface CapabilityEvidence {
73
+ readonly id: string
74
+ readonly kind: CapabilityEvidenceKind
75
+ readonly source: string
76
+ }
77
+
78
+ export interface RouteCapabilityEvidence {
79
+ readonly method: string
80
+ readonly path: string
81
+ /** True only when the route's reachable module graph was actually scanned. */
82
+ readonly covered: boolean
83
+ readonly evidence: readonly CapabilityEvidence[]
84
+ }
85
+
86
+ export interface CapabilityEvidenceSet {
87
+ readonly routes: readonly RouteCapabilityEvidence[]
88
+ }
89
+
90
+ export type CapabilityFindingCode =
91
+ | "unknown-capability"
92
+ | "provenance-uncovered"
93
+ | "undeclared-capability-evidence"
94
+ | "safe-method-domain-write"
95
+ | "missing-request-idempotency"
96
+ | "missing-durable-idempotency"
97
+ | "forbidden-effect-import"
98
+
99
+ export interface CapabilityFinding {
100
+ readonly code: CapabilityFindingCode
101
+ readonly method: string
102
+ readonly path: string
103
+ readonly capability?: string
104
+ readonly message: string
105
+ }
106
+
107
+ export interface AssuredCapabilityRoute {
108
+ readonly method: string
109
+ readonly path: string
110
+ readonly declared: readonly string[]
111
+ readonly evidence: readonly CapabilityEvidence[]
112
+ /** Declared capabilities without static/runtime evidence. Informational, never treated as proof. */
113
+ readonly unproven: readonly string[]
114
+ readonly covered: boolean
115
+ /** Highest data-sensitivity the response carries, when the route declares it. */
116
+ readonly classification?: DataClassification
117
+ }
118
+
119
+ export interface CapabilityAssuranceReport {
120
+ readonly ok: boolean
121
+ readonly routes: readonly AssuredCapabilityRoute[]
122
+ readonly findings: readonly CapabilityFinding[]
123
+ }
124
+
125
+ export interface CapabilitySnapshotRoute {
126
+ readonly method: string
127
+ readonly path: string
128
+ readonly declared: readonly string[]
129
+ readonly evidenced: readonly string[]
130
+ readonly unproven: readonly string[]
131
+ /** Recorded so a route that starts returning `pii`/`secret` flips the lockfile and forces a review. */
132
+ readonly classification?: DataClassification
133
+ }
134
+
135
+ export interface CapabilitySnapshot {
136
+ readonly nifraCapabilities: 1
137
+ readonly routes: readonly CapabilitySnapshotRoute[]
138
+ }
139
+
140
+ const SAFE_METHODS = new Set(["GET", "HEAD"])
141
+
142
+ export { validCapabilityId }
143
+
144
+ function normalizedIds(values: readonly string[], label: string): readonly string[] {
145
+ if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`)
146
+ const out: string[] = []
147
+ const seen = new Set<string>()
148
+ for (const value of values) {
149
+ if (typeof value !== "string" || !validCapabilityId(value)) {
150
+ throw new Error(`capability assurance: invalid ${label} id ${JSON.stringify(value)}`)
151
+ }
152
+ if (!seen.has(value)) {
153
+ seen.add(value)
154
+ out.push(value)
155
+ }
156
+ }
157
+ return Object.freeze(out)
158
+ }
159
+
160
+ function validSpecifier(value: string): boolean {
161
+ return value.trim() === value && value !== "" && !hasControlCharacter(value)
162
+ }
163
+
164
+ function hasControlCharacter(value: string): boolean {
165
+ for (let index = 0; index < value.length; index++) {
166
+ const code = value.charCodeAt(index)
167
+ if (code <= 31 || code === 127) return true
168
+ }
169
+ return false
170
+ }
171
+
172
+ /** Validate and freeze a capability/provenance policy. */
173
+ export function defineCapabilityPolicy(policy: CapabilityPolicy): CapabilityPolicy {
174
+ if (policy.provenance === undefined || policy.provenance === null) {
175
+ throw new Error("capability assurance: provenance policy is required")
176
+ }
177
+ const ids = new Set<string>()
178
+ const definitions = policy.definitions.map((definition): CapabilityDefinition => {
179
+ if (!validCapabilityId(definition.id)) {
180
+ throw new Error(
181
+ `capability assurance: invalid capability id ${JSON.stringify(definition.id)}`,
182
+ )
183
+ }
184
+ if (ids.has(definition.id)) {
185
+ throw new Error(`capability assurance: duplicate capability ${JSON.stringify(definition.id)}`)
186
+ }
187
+ ids.add(definition.id)
188
+ if (definition.zone !== "domain" && definition.zone !== "operational") {
189
+ throw new Error(`capability assurance: invalid zone for ${definition.id}`)
190
+ }
191
+ if (definition.access !== "read" && definition.access !== "write") {
192
+ throw new Error(`capability assurance: invalid access for ${definition.id}`)
193
+ }
194
+ const idempotency = definition.idempotency ?? "none"
195
+ if (idempotency !== "none" && idempotency !== "request" && idempotency !== "durable") {
196
+ throw new Error(`capability assurance: invalid idempotency for ${definition.id}`)
197
+ }
198
+ if (definition.access === "read" && idempotency !== "none") {
199
+ throw new Error(
200
+ `capability assurance: read capability ${definition.id} cannot require idempotency`,
201
+ )
202
+ }
203
+ return Object.freeze({ ...definition, idempotency })
204
+ })
205
+ const imports = policy.provenance.imports.map((rule): CapabilityImportRule => {
206
+ if (!validSpecifier(rule.specifier))
207
+ throw new Error("capability assurance: invalid import specifier")
208
+ const capabilities = normalizedIds(rule.capabilities, "import capability")
209
+ for (const id of capabilities) {
210
+ if (!ids.has(id)) throw new Error(`capability assurance: import references unknown ${id}`)
211
+ }
212
+ return Object.freeze({ specifier: rule.specifier, capabilities })
213
+ })
214
+ const forbiddenImports = policy.provenance.forbiddenImports.map(
215
+ (rule): ForbiddenCapabilityImport => {
216
+ if (!validSpecifier(rule.specifier) || rule.reason.trim() === "") {
217
+ throw new Error("capability assurance: forbidden import needs a specifier and reason")
218
+ }
219
+ return Object.freeze({ specifier: rule.specifier, reason: rule.reason.trim() })
220
+ },
221
+ )
222
+ const routeModules = policy.provenance.routeModules?.map((rule): CapabilityRouteModule => {
223
+ if (!Array.isArray(rule.modules) || rule.modules.length === 0) {
224
+ throw new Error("capability assurance: routeModules entry needs at least one module")
225
+ }
226
+ const modules = rule.modules.map((module) => {
227
+ if (!validSpecifier(module)) throw new Error("capability assurance: invalid route module")
228
+ return module.replace(/^\.\//, "")
229
+ })
230
+ return Object.freeze({
231
+ match: Object.freeze({
232
+ ...(rule.match.methods !== undefined
233
+ ? { methods: Object.freeze(rule.match.methods.map((method) => method.toUpperCase())) }
234
+ : {}),
235
+ ...(rule.match.paths !== undefined ? { paths: Object.freeze([...rule.match.paths]) } : {}),
236
+ }),
237
+ modules: Object.freeze(modules),
238
+ })
239
+ })
240
+ return Object.freeze({
241
+ definitions: Object.freeze(definitions),
242
+ provenance: Object.freeze({
243
+ imports: Object.freeze(imports),
244
+ forbiddenImports: Object.freeze(forbiddenImports),
245
+ ...(routeModules !== undefined ? { routeModules: Object.freeze(routeModules) } : {}),
246
+ }),
247
+ lockfile: policy.lockfile ?? "capabilities.lock.json",
248
+ })
249
+ }
250
+
251
+ function evidenceSourceValid(source: string): boolean {
252
+ return source.length > 0 && source.length <= 256 && !hasControlCharacter(source)
253
+ }
254
+
255
+ function routeKey(method: string, path: string): string {
256
+ return `${method.toUpperCase()}\n${path}`
257
+ }
258
+
259
+ /** Compare declared route capabilities against coverage-qualified static/runtime evidence. */
260
+ export function evaluateCapabilityAssurance(
261
+ source: unknown,
262
+ policyInput: CapabilityPolicy,
263
+ evidenceSet: CapabilityEvidenceSet,
264
+ ): CapabilityAssuranceReport {
265
+ const policy = defineCapabilityPolicy(policyInput)
266
+ const definitions = new Map(policy.definitions.map((definition) => [definition.id, definition]))
267
+ const evidenceByRoute = new Map(
268
+ evidenceSet.routes.map((route) => [routeKey(route.method, route.path), route]),
269
+ )
270
+ const findings: CapabilityFinding[] = []
271
+ const routes: AssuredCapabilityRoute[] = []
272
+
273
+ for (const route of reflectRoutes(source)) {
274
+ const declared = route.capabilities ?? []
275
+ const supplied = evidenceByRoute.get(routeKey(route.method, route.path))
276
+ const covered = supplied?.covered === true
277
+ const evidence: CapabilityEvidence[] = []
278
+ const evidenceIds = new Set<string>()
279
+ for (const item of supplied?.evidence ?? []) {
280
+ if (
281
+ !validCapabilityId(item.id) ||
282
+ (item.kind !== "static" && item.kind !== "runtime") ||
283
+ !evidenceSourceValid(item.source)
284
+ ) {
285
+ continue
286
+ }
287
+ const key = `${item.id}\n${item.kind}\n${item.source}`
288
+ if (
289
+ evidence.some(
290
+ (candidate) => `${candidate.id}\n${candidate.kind}\n${candidate.source}` === key,
291
+ )
292
+ )
293
+ continue
294
+ evidence.push(Object.freeze({ id: item.id, kind: item.kind, source: item.source }))
295
+ evidenceIds.add(item.id)
296
+ }
297
+
298
+ for (const id of declared) {
299
+ if (!definitions.has(id)) {
300
+ findings.push({
301
+ code: "unknown-capability",
302
+ method: route.method,
303
+ path: route.path,
304
+ capability: id,
305
+ message: `${route.method} ${route.path} declares unknown capability ${id}`,
306
+ })
307
+ }
308
+ }
309
+ if (!covered) {
310
+ findings.push({
311
+ code: "provenance-uncovered",
312
+ method: route.method,
313
+ path: route.path,
314
+ message: `${route.method} ${route.path} has no static provenance coverage`,
315
+ })
316
+ }
317
+ for (const id of evidenceIds) {
318
+ if (!declared.includes(id)) {
319
+ findings.push({
320
+ code: "undeclared-capability-evidence",
321
+ method: route.method,
322
+ path: route.path,
323
+ capability: id,
324
+ message: `${route.method} ${route.path} evidence exceeds its declaration: ${id}`,
325
+ })
326
+ }
327
+ }
328
+
329
+ const effective = new Set([...declared, ...evidenceIds])
330
+ const assuranceIds = new Set((route.assurance ?? []).map((item) => item.id))
331
+ for (const id of effective) {
332
+ const definition = definitions.get(id)
333
+ if (definition === undefined) continue
334
+ if (
335
+ SAFE_METHODS.has(route.method) &&
336
+ definition.zone === "domain" &&
337
+ definition.access === "write"
338
+ ) {
339
+ findings.push({
340
+ code: "safe-method-domain-write",
341
+ method: route.method,
342
+ path: route.path,
343
+ capability: id,
344
+ message: `${route.method} ${route.path} cannot carry domain write capability ${id}`,
345
+ })
346
+ }
347
+ if (
348
+ definition.idempotency === "request" &&
349
+ !assuranceIds.has(NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY)
350
+ ) {
351
+ findings.push({
352
+ code: "missing-request-idempotency",
353
+ method: route.method,
354
+ path: route.path,
355
+ capability: id,
356
+ message: `${route.method} ${route.path} capability ${id} requires request idempotency evidence`,
357
+ })
358
+ }
359
+ if (
360
+ definition.idempotency === "durable" &&
361
+ !assuranceIds.has(NIFRA_ASSURANCE_IDS.DURABLE_COMMAND)
362
+ ) {
363
+ findings.push({
364
+ code: "missing-durable-idempotency",
365
+ method: route.method,
366
+ path: route.path,
367
+ capability: id,
368
+ message: `${route.method} ${route.path} capability ${id} requires durable command/provider-key evidence`,
369
+ })
370
+ }
371
+ }
372
+ routes.push(
373
+ Object.freeze({
374
+ method: route.method,
375
+ path: route.path,
376
+ declared: Object.freeze([...declared]),
377
+ evidence: Object.freeze(evidence),
378
+ unproven: Object.freeze(declared.filter((id) => !evidenceIds.has(id))),
379
+ covered,
380
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
381
+ }),
382
+ )
383
+ }
384
+ return Object.freeze({
385
+ ok: findings.length === 0,
386
+ routes: Object.freeze(routes),
387
+ findings: Object.freeze(findings),
388
+ })
389
+ }
390
+
391
+ /**
392
+ * Runtime effect beacon for owned adapters. It fails closed when the route omitted the capability or
393
+ * when no route guard is present. Static provenance is still required: code can bypass a beacon.
394
+ */
395
+ export function useCapability(context: object, capability: string): void {
396
+ if (!validCapabilityId(capability)) {
397
+ throw new Error(
398
+ `capability assurance: invalid runtime capability ${JSON.stringify(capability)}`,
399
+ )
400
+ }
401
+ const guard = (context as { readonly [CAPABILITY_GUARD]?: CapabilityGuard })[CAPABILITY_GUARD]
402
+ if (guard === undefined) {
403
+ throw new Error(
404
+ `capability assurance: ${capability} used on a route with no capability declaration`,
405
+ )
406
+ }
407
+ if (!guard.allowed.includes(capability)) {
408
+ throw new Error(
409
+ `capability assurance: ${capability} is not declared for ${guard.method} ${guard.path}`,
410
+ )
411
+ }
412
+ guard.onUse?.({ capability, method: guard.method, path: guard.path })
413
+ }
414
+
415
+ /** Deterministic, PII-free lockfile material. */
416
+ export function snapshotCapabilities(report: CapabilityAssuranceReport): CapabilitySnapshot {
417
+ const routes = report.routes
418
+ .map(
419
+ (route): CapabilitySnapshotRoute => ({
420
+ method: route.method,
421
+ path: route.path,
422
+ declared: Object.freeze([...route.declared].sort()),
423
+ evidenced: Object.freeze([...new Set(route.evidence.map((item) => item.id))].sort()),
424
+ unproven: Object.freeze([...route.unproven].sort()),
425
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
426
+ }),
427
+ )
428
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
429
+ return Object.freeze({ nifraCapabilities: 1, routes: Object.freeze(routes) })
430
+ }
@@ -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
+ }