@nifrajs/core 1.10.0 → 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 (63) hide show
  1. package/dist/assurance.d.ts +13 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +20 -0
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +30 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +32 -6
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/classification.d.ts +20 -0
  10. package/dist/classification.d.ts.map +1 -1
  11. package/dist/classification.js +120 -0
  12. package/dist/classification.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/idempotency.d.ts +59 -12
  17. package/dist/idempotency.d.ts.map +1 -1
  18. package/dist/idempotency.js +191 -20
  19. package/dist/idempotency.js.map +1 -1
  20. package/dist/index.d.ts +5 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/invariants.d.ts +66 -0
  25. package/dist/invariants.d.ts.map +1 -0
  26. package/dist/invariants.js +483 -0
  27. package/dist/invariants.js.map +1 -0
  28. package/dist/ledger.d.ts +154 -0
  29. package/dist/ledger.d.ts.map +1 -0
  30. package/dist/ledger.js +271 -0
  31. package/dist/ledger.js.map +1 -0
  32. package/dist/manifest.d.ts +81 -0
  33. package/dist/manifest.d.ts.map +1 -0
  34. package/dist/manifest.js +394 -0
  35. package/dist/manifest.js.map +1 -0
  36. package/dist/mount.d.ts +18 -0
  37. package/dist/mount.d.ts.map +1 -0
  38. package/dist/mount.js +11 -0
  39. package/dist/mount.js.map +1 -0
  40. package/dist/reflection.d.ts +3 -3
  41. package/dist/reflection.d.ts.map +1 -1
  42. package/dist/reflection.js +5 -5
  43. package/dist/reflection.js.map +1 -1
  44. package/dist/server/context.d.ts +17 -1
  45. package/dist/server/context.d.ts.map +1 -1
  46. package/dist/server/server.d.ts +23 -2
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +179 -26
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +26 -1
  51. package/src/assurance.ts +33 -0
  52. package/src/capabilities.ts +60 -4
  53. package/src/classification.ts +145 -0
  54. package/src/errors.ts +1 -0
  55. package/src/idempotency.ts +245 -30
  56. package/src/index.ts +5 -1
  57. package/src/invariants.ts +600 -0
  58. package/src/ledger.ts +430 -0
  59. package/src/manifest.ts +543 -0
  60. package/src/mount.ts +24 -0
  61. package/src/reflection.ts +11 -7
  62. package/src/server/context.ts +20 -1
  63. package/src/server/server.ts +254 -30
@@ -0,0 +1,543 @@
1
+ /** Deterministic, signed route manifest: contract × assurance × capabilities × classification. */
2
+
3
+ import type { AssuranceReport } from "./assurance.ts"
4
+ import type { CapabilityAssuranceReport } from "./capabilities.ts"
5
+ import {
6
+ type DataClassification,
7
+ isDataClassification,
8
+ type ResponseClassification,
9
+ } from "./classification.ts"
10
+ import {
11
+ type DiffSeverity,
12
+ diffRouteSnapshots,
13
+ type RouteChange,
14
+ type RouteSnapshotSchema,
15
+ snapshotRoutes,
16
+ } from "./diff.ts"
17
+ import { reflectRoutes } from "./reflection.ts"
18
+
19
+ export interface NifraManifestAssurance {
20
+ readonly rule?: string
21
+ readonly evidence: readonly { readonly id: string; readonly source: string }[]
22
+ }
23
+
24
+ export interface NifraManifestCapabilities {
25
+ readonly declared: readonly string[]
26
+ readonly evidenced: readonly string[]
27
+ readonly unproven: readonly string[]
28
+ readonly covered: boolean
29
+ }
30
+
31
+ export interface NifraManifestRoute {
32
+ readonly method: string
33
+ readonly path: string
34
+ readonly schema?: RouteSnapshotSchema
35
+ readonly assurance?: NifraManifestAssurance
36
+ readonly capabilities?: NifraManifestCapabilities
37
+ readonly classification?: ResponseClassification
38
+ }
39
+
40
+ export interface NifraManifest {
41
+ readonly manifestVersion: 1
42
+ readonly routes: readonly NifraManifestRoute[]
43
+ /** SHA-256 hex of the canonical manifest body (`manifestVersion` + `routes`). */
44
+ readonly contentHash: string
45
+ }
46
+
47
+ export interface NifraManifestSigner {
48
+ readonly algorithm: "Ed25519"
49
+ readonly keyId: string
50
+ sign(payload: Uint8Array): ArrayBuffer | Uint8Array | Promise<ArrayBuffer | Uint8Array>
51
+ }
52
+
53
+ export interface NifraManifestSignature {
54
+ readonly nifraManifestSignature: 1
55
+ readonly algorithm: "Ed25519"
56
+ readonly keyId: string
57
+ readonly contentHash: string
58
+ /** Base64url Ed25519 signature over the canonical manifest body. */
59
+ readonly signature: string
60
+ }
61
+
62
+ export interface BuildNifraManifestInput {
63
+ readonly source: unknown
64
+ readonly assurance?: AssuranceReport
65
+ readonly capabilities?: CapabilityAssuranceReport
66
+ }
67
+
68
+ const encoder = new TextEncoder()
69
+
70
+ function hasControlCharacter(value: string): boolean {
71
+ for (let index = 0; index < value.length; index++) {
72
+ const code = value.charCodeAt(index)
73
+ if (code <= 31 || code === 127) return true
74
+ }
75
+ return false
76
+ }
77
+
78
+ function canonicalValue(value: unknown): string {
79
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
80
+ return JSON.stringify(value)
81
+ }
82
+ if (typeof value === "number") {
83
+ if (!Number.isFinite(value))
84
+ throw new TypeError("nifra manifest cannot encode non-finite numbers")
85
+ return JSON.stringify(value)
86
+ }
87
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalValue(item)).join(",")}]`
88
+ if (typeof value === "object") {
89
+ const record = value as Readonly<Record<string, unknown>>
90
+ return `{${Object.keys(record)
91
+ .filter((key) => record[key] !== undefined)
92
+ .sort()
93
+ .map((key) => `${JSON.stringify(key)}:${canonicalValue(record[key])}`)
94
+ .join(",")}}`
95
+ }
96
+ throw new TypeError(`nifra manifest cannot encode ${typeof value}`)
97
+ }
98
+
99
+ function manifestBody(manifest: Pick<NifraManifest, "manifestVersion" | "routes">): {
100
+ readonly manifestVersion: 1
101
+ readonly routes: readonly NifraManifestRoute[]
102
+ } {
103
+ return { manifestVersion: 1, routes: manifest.routes }
104
+ }
105
+
106
+ /** Canonical bytes are stable across runtime, object-key order, and route registration order. */
107
+ export function canonicalManifest(
108
+ manifest: Pick<NifraManifest, "manifestVersion" | "routes">,
109
+ ): string {
110
+ return canonicalValue(manifestBody(manifest))
111
+ }
112
+
113
+ /** Byte-stable artifact serialization (including `contentHash`). */
114
+ export function serializeNifraManifest(manifest: NifraManifest): string {
115
+ return canonicalValue(manifest)
116
+ }
117
+
118
+ /** Byte-stable serialization for the detached signature sidecar. */
119
+ export function serializeNifraManifestSignature(signature: NifraManifestSignature): string {
120
+ return canonicalValue(signature)
121
+ }
122
+
123
+ /** Parse the detached sidecar before selecting its operator-controlled public key. */
124
+ export function parseNifraManifestSignature(
125
+ content: string,
126
+ source = "manifest signature",
127
+ ): NifraManifestSignature {
128
+ let parsed: unknown
129
+ try {
130
+ parsed = JSON.parse(content)
131
+ } catch {
132
+ throw new Error(`nifra manifest: ${source} is not valid JSON`)
133
+ }
134
+ const candidate = parsed as Partial<NifraManifestSignature> | null
135
+ let signatureLength = -1
136
+ if (typeof candidate?.signature === "string") {
137
+ try {
138
+ signatureLength = fromBase64Url(candidate.signature).byteLength
139
+ } catch {
140
+ signatureLength = -1
141
+ }
142
+ }
143
+ if (
144
+ candidate === null ||
145
+ typeof candidate !== "object" ||
146
+ candidate.nifraManifestSignature !== 1 ||
147
+ candidate.algorithm !== "Ed25519" ||
148
+ typeof candidate.keyId !== "string" ||
149
+ candidate.keyId.trim() !== candidate.keyId ||
150
+ candidate.keyId.length === 0 ||
151
+ candidate.keyId.length > 255 ||
152
+ hasControlCharacter(candidate.keyId) ||
153
+ typeof candidate.contentHash !== "string" ||
154
+ !/^[0-9a-f]{64}$/.test(candidate.contentHash) ||
155
+ typeof candidate.signature !== "string" ||
156
+ signatureLength !== 64
157
+ ) {
158
+ throw new Error(`nifra manifest: ${source} is not a valid Ed25519 manifest signature`)
159
+ }
160
+ return Object.freeze(candidate as NifraManifestSignature)
161
+ }
162
+
163
+ async function sha256Hex(value: string): Promise<string> {
164
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)))
165
+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")
166
+ }
167
+
168
+ const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
169
+ const sortedStrings = (values: Iterable<string>): readonly string[] =>
170
+ Object.freeze([...new Set(values)].sort())
171
+
172
+ /** Build one fail-closed, deterministic manifest from already-evaluated assurance reports. */
173
+ export async function buildNifraManifest(input: BuildNifraManifestInput): Promise<NifraManifest> {
174
+ if (input.assurance !== undefined && !input.assurance.ok) {
175
+ throw new Error("nifra manifest: refusing to emit failing route assurance")
176
+ }
177
+ if (input.capabilities !== undefined && !input.capabilities.ok) {
178
+ throw new Error("nifra manifest: refusing to emit failing capability assurance")
179
+ }
180
+ const reflected = new Map(
181
+ reflectRoutes(input.source).map((route) => [routeKey(route.method, route.path), route]),
182
+ )
183
+ const assured = new Map(
184
+ (input.assurance?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
185
+ )
186
+ const capable = new Map(
187
+ (input.capabilities?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
188
+ )
189
+ const routes = snapshotRoutes(input.source)
190
+ .map((route): NifraManifestRoute => {
191
+ const key = routeKey(route.method, route.path)
192
+ const reflection = reflected.get(key)
193
+ const assurance = assured.get(key)
194
+ const capabilities = capable.get(key)
195
+ return {
196
+ method: route.method.toUpperCase(),
197
+ path: route.path,
198
+ ...(route.schema !== undefined ? { schema: route.schema } : {}),
199
+ ...(assurance !== undefined
200
+ ? {
201
+ assurance: {
202
+ ...(assurance.rule !== undefined ? { rule: assurance.rule } : {}),
203
+ evidence: Object.freeze(
204
+ [...assurance.evidence]
205
+ .map((item) => ({ id: item.id, source: item.source }))
206
+ .sort((a, b) => a.id.localeCompare(b.id) || a.source.localeCompare(b.source)),
207
+ ),
208
+ },
209
+ }
210
+ : {}),
211
+ ...(capabilities !== undefined
212
+ ? {
213
+ capabilities: {
214
+ declared: sortedStrings(capabilities.declared),
215
+ evidenced: sortedStrings(capabilities.evidence.map((item) => item.id)),
216
+ unproven: sortedStrings(capabilities.unproven),
217
+ covered: capabilities.covered,
218
+ },
219
+ }
220
+ : {}),
221
+ ...(reflection?.classification !== undefined
222
+ ? { classification: reflection.classification }
223
+ : {}),
224
+ }
225
+ })
226
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
227
+ const body = Object.freeze({ manifestVersion: 1 as const, routes: Object.freeze(routes) })
228
+ return Object.freeze({ ...body, contentHash: await sha256Hex(canonicalManifest(body)) })
229
+ }
230
+
231
+ function base64Url(bytes: Uint8Array): string {
232
+ let binary = ""
233
+ for (const byte of bytes) binary += String.fromCharCode(byte)
234
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")
235
+ }
236
+
237
+ function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {
238
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new TypeError("invalid base64url signature")
239
+ const padded = value
240
+ .replaceAll("-", "+")
241
+ .replaceAll("_", "/")
242
+ .padEnd(Math.ceil(value.length / 4) * 4, "=")
243
+ const binary = atob(padded)
244
+ const bytes = new Uint8Array(binary.length)
245
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
246
+ return bytes
247
+ }
248
+
249
+ /** Sign without handling private keys: the operator-supplied signer may call KMS/HSM/local WebCrypto. */
250
+ export async function signNifraManifest(
251
+ manifest: NifraManifest,
252
+ signer: NifraManifestSigner,
253
+ ): Promise<NifraManifestSignature> {
254
+ const canonical = canonicalManifest(manifest)
255
+ if ((await sha256Hex(canonical)) !== manifest.contentHash) {
256
+ throw new Error("nifra manifest: contentHash does not match canonical content")
257
+ }
258
+ if (
259
+ signer.algorithm !== "Ed25519" ||
260
+ signer.keyId.trim() !== signer.keyId ||
261
+ signer.keyId.length === 0 ||
262
+ signer.keyId.length > 255 ||
263
+ hasControlCharacter(signer.keyId)
264
+ ) {
265
+ throw new TypeError("nifra manifest: signer requires Ed25519 and a non-empty keyId")
266
+ }
267
+ const signed = await signer.sign(encoder.encode(canonical))
268
+ if (signed.byteLength !== 64) {
269
+ throw new TypeError("nifra manifest: Ed25519 signer must return a 64-byte signature")
270
+ }
271
+ return Object.freeze({
272
+ nifraManifestSignature: 1,
273
+ algorithm: "Ed25519",
274
+ keyId: signer.keyId,
275
+ contentHash: manifest.contentHash,
276
+ signature: base64Url(new Uint8Array(signed)),
277
+ })
278
+ }
279
+
280
+ /** Verify the hash first, then the detached Ed25519 signature. Malformed/tampered input returns false. */
281
+ export async function verifyNifraManifestSignature(
282
+ manifest: NifraManifest,
283
+ signature: NifraManifestSignature,
284
+ publicKey: CryptoKey,
285
+ ): Promise<boolean> {
286
+ try {
287
+ const canonical = canonicalManifest(manifest)
288
+ const contentHash = await sha256Hex(canonical)
289
+ if (
290
+ contentHash !== manifest.contentHash ||
291
+ signature.nifraManifestSignature !== 1 ||
292
+ signature.algorithm !== "Ed25519" ||
293
+ signature.contentHash !== contentHash
294
+ ) {
295
+ return false
296
+ }
297
+ return await crypto.subtle.verify(
298
+ "Ed25519",
299
+ publicKey,
300
+ fromBase64Url(signature.signature),
301
+ encoder.encode(canonical),
302
+ )
303
+ } catch {
304
+ return false
305
+ }
306
+ }
307
+
308
+ /** Parse and hash-verify an emitted manifest before it is trusted by diff/codegen tooling. */
309
+ export async function parseNifraManifest(
310
+ content: string,
311
+ source = "manifest",
312
+ ): Promise<NifraManifest> {
313
+ let parsed: unknown
314
+ try {
315
+ parsed = JSON.parse(content)
316
+ } catch {
317
+ throw new Error(`nifra manifest: ${source} is not valid JSON`)
318
+ }
319
+ const candidate = parsed as Partial<NifraManifest> | null
320
+ if (
321
+ candidate === null ||
322
+ typeof candidate !== "object" ||
323
+ candidate.manifestVersion !== 1 ||
324
+ !Array.isArray(candidate.routes) ||
325
+ typeof candidate.contentHash !== "string" ||
326
+ !/^[0-9a-f]{64}$/.test(candidate.contentHash)
327
+ ) {
328
+ throw new Error(`nifra manifest: ${source} is not a version 1 Nifra manifest`)
329
+ }
330
+ const seen = new Set<string>()
331
+ for (const route of candidate.routes) {
332
+ if (
333
+ route === null ||
334
+ typeof route !== "object" ||
335
+ typeof route.method !== "string" ||
336
+ typeof route.path !== "string" ||
337
+ !route.path.startsWith("/")
338
+ ) {
339
+ throw new Error(`nifra manifest: ${source} contains an invalid route`)
340
+ }
341
+ const typed = route as Partial<NifraManifestRoute>
342
+ if (!/^[A-Z]+$/.test(typed.method as string)) {
343
+ throw new Error(`nifra manifest: ${source} contains an invalid route method`)
344
+ }
345
+ const key = routeKey(typed.method as string, typed.path as string)
346
+ if (seen.has(key)) throw new Error(`nifra manifest: ${source} contains a duplicate route`)
347
+ seen.add(key)
348
+ if (typed.assurance !== undefined) {
349
+ if (
350
+ typed.assurance === null ||
351
+ typeof typed.assurance !== "object" ||
352
+ !Array.isArray(typed.assurance.evidence) ||
353
+ (typed.assurance.rule !== undefined && typeof typed.assurance.rule !== "string") ||
354
+ typed.assurance.evidence.some(
355
+ (item) =>
356
+ item === null ||
357
+ typeof item !== "object" ||
358
+ typeof item.id !== "string" ||
359
+ typeof item.source !== "string",
360
+ )
361
+ ) {
362
+ throw new Error(`nifra manifest: ${source} contains invalid assurance material`)
363
+ }
364
+ }
365
+ if (typed.capabilities !== undefined) {
366
+ const capabilities = typed.capabilities
367
+ if (
368
+ capabilities === null ||
369
+ typeof capabilities !== "object" ||
370
+ !Array.isArray(capabilities.declared) ||
371
+ !Array.isArray(capabilities.evidenced) ||
372
+ !Array.isArray(capabilities.unproven) ||
373
+ typeof capabilities.covered !== "boolean" ||
374
+ [...capabilities.declared, ...capabilities.evidenced, ...capabilities.unproven].some(
375
+ (value) => typeof value !== "string" || !/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(value),
376
+ )
377
+ ) {
378
+ throw new Error(`nifra manifest: ${source} contains invalid capability material`)
379
+ }
380
+ }
381
+ if (typed.classification !== undefined) {
382
+ const classification = typed.classification
383
+ if (
384
+ classification === null ||
385
+ typeof classification !== "object" ||
386
+ !isDataClassification(classification.max) ||
387
+ classification.fields === null ||
388
+ typeof classification.fields !== "object" ||
389
+ Array.isArray(classification.fields) ||
390
+ Object.values(classification.fields).some((value) => !isDataClassification(value))
391
+ ) {
392
+ throw new Error(`nifra manifest: ${source} contains invalid response classification`)
393
+ }
394
+ }
395
+ }
396
+ const manifest = candidate as NifraManifest
397
+ if ((await sha256Hex(canonicalManifest(manifest))) !== manifest.contentHash) {
398
+ throw new Error(`nifra manifest: ${source} contentHash mismatch`)
399
+ }
400
+ return manifest
401
+ }
402
+
403
+ export interface NifraManifestChange {
404
+ readonly severity: DiffSeverity
405
+ readonly method: string
406
+ readonly path: string
407
+ readonly section: RouteChange["section"] | "assurance" | "capabilities" | "classification"
408
+ readonly field?: string
409
+ readonly message: string
410
+ }
411
+
412
+ export interface NifraManifestDiff {
413
+ readonly changes: readonly NifraManifestChange[]
414
+ readonly hasBreaking: boolean
415
+ }
416
+
417
+ function listChanges(
418
+ before: readonly string[],
419
+ after: readonly string[],
420
+ ): {
421
+ added: string[]
422
+ removed: string[]
423
+ } {
424
+ return {
425
+ added: after.filter((value) => !before.includes(value)),
426
+ removed: before.filter((value) => !after.includes(value)),
427
+ }
428
+ }
429
+
430
+ const classificationRank = (value: DataClassification | undefined): number =>
431
+ value === undefined ? -1 : value === "public" ? 0 : value === "pii" ? 1 : 2
432
+
433
+ /** Contract changes reuse the route-diff engine; governance changes fail closed on expanded risk. */
434
+ export function diffNifraManifests(before: NifraManifest, after: NifraManifest): NifraManifestDiff {
435
+ const changes: NifraManifestChange[] = [
436
+ ...diffRouteSnapshots(before.routes, after.routes).changes,
437
+ ]
438
+ const previous = new Map(
439
+ before.routes.map((route) => [routeKey(route.method, route.path), route]),
440
+ )
441
+ for (const route of after.routes) {
442
+ const old = previous.get(routeKey(route.method, route.path))
443
+ if (old === undefined) continue
444
+ const base = { method: route.method, path: route.path } as const
445
+
446
+ const oldRule = old.assurance?.rule
447
+ const newRule = route.assurance?.rule
448
+ if (oldRule !== newRule) {
449
+ changes.push({
450
+ ...base,
451
+ section: "assurance",
452
+ severity: "breaking",
453
+ message: `assurance rule changed from ${oldRule ?? "unclassified"} to ${newRule ?? "unclassified"}`,
454
+ })
455
+ }
456
+ const oldEvidence = (old.assurance?.evidence ?? [])
457
+ .map((item) => `${item.id}@${item.source}`)
458
+ .sort()
459
+ const newEvidence = (route.assurance?.evidence ?? [])
460
+ .map((item) => `${item.id}@${item.source}`)
461
+ .sort()
462
+ const assuranceDelta = listChanges(oldEvidence, newEvidence)
463
+ for (const value of assuranceDelta.added) {
464
+ changes.push({
465
+ ...base,
466
+ section: "assurance",
467
+ severity: "compatible",
468
+ message: `assurance evidence added ${value}`,
469
+ })
470
+ }
471
+ for (const value of assuranceDelta.removed) {
472
+ changes.push({
473
+ ...base,
474
+ section: "assurance",
475
+ severity: "breaking",
476
+ message: `assurance evidence removed ${value}`,
477
+ })
478
+ }
479
+
480
+ for (const field of ["declared", "evidenced", "unproven"] as const) {
481
+ const delta = listChanges(old.capabilities?.[field] ?? [], route.capabilities?.[field] ?? [])
482
+ for (const value of delta.added) {
483
+ changes.push({
484
+ ...base,
485
+ section: "capabilities",
486
+ field,
487
+ severity: "breaking",
488
+ message: `${field} capability added ${value}`,
489
+ })
490
+ }
491
+ for (const value of delta.removed) {
492
+ changes.push({
493
+ ...base,
494
+ section: "capabilities",
495
+ field,
496
+ severity: "compatible",
497
+ message: `${field} capability removed ${value}`,
498
+ })
499
+ }
500
+ }
501
+ if (old.capabilities?.covered === true && route.capabilities?.covered !== true) {
502
+ changes.push({
503
+ ...base,
504
+ section: "capabilities",
505
+ severity: "breaking",
506
+ message: "capability provenance coverage lost",
507
+ })
508
+ }
509
+
510
+ const beforeClassification = old.classification?.max
511
+ const afterClassification = route.classification?.max
512
+ if (beforeClassification !== afterClassification) {
513
+ const increased =
514
+ classificationRank(afterClassification) > classificationRank(beforeClassification)
515
+ changes.push({
516
+ ...base,
517
+ section: "classification",
518
+ severity:
519
+ increased && classificationRank(afterClassification) > 0 ? "breaking" : "compatible",
520
+ message: `response classification changed from ${beforeClassification ?? "unclassified"} to ${afterClassification ?? "unclassified"}`,
521
+ })
522
+ }
523
+ const oldFields = old.classification?.fields ?? {}
524
+ const newFields = route.classification?.fields ?? {}
525
+ for (const field of new Set([...Object.keys(oldFields), ...Object.keys(newFields)])) {
526
+ const previousTag = oldFields[field]
527
+ const currentTag = newFields[field]
528
+ if (previousTag === currentTag) continue
529
+ const increased = classificationRank(currentTag) > classificationRank(previousTag)
530
+ changes.push({
531
+ ...base,
532
+ section: "classification",
533
+ field,
534
+ severity: increased && classificationRank(currentTag) > 0 ? "breaking" : "compatible",
535
+ message: `response field classification changed from ${previousTag ?? "unclassified"} to ${currentTag ?? "unclassified"}`,
536
+ })
537
+ }
538
+ }
539
+ return Object.freeze({
540
+ changes: Object.freeze(changes),
541
+ hasBreaking: changes.some((change) => change.severity === "breaking"),
542
+ })
543
+ }
package/src/mount.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Explicit in-process backend mount seam shared by `@nifrajs/client` and `@nifrajs/web`.
3
+ *
4
+ * A symbol key keeps the mount control path separate from the typed route proxy: an application may
5
+ * legitimately expose a `/fetch` or `/mount` route without shadowing this interface. The handler gets
6
+ * the same platform object as the outer app, so Workers bindings and execution lifetime survive an
7
+ * in-process mount.
8
+ */
9
+
10
+ import type { Platform } from "./server/context.ts"
11
+
12
+ /** Global symbol so independently bundled copies of core/client/web still agree on the mount seam. */
13
+ export const NIFRA_BACKEND_MOUNT = Symbol.for("@nifrajs/backend-mount")
14
+
15
+ /** Dispatch one already-materialized request into a backend with its outer runtime platform context. */
16
+ export type BackendMountHandler<Env = unknown> = (
17
+ request: Request,
18
+ platform?: Platform<Env>,
19
+ ) => Response | Promise<Response>
20
+
21
+ /** Structural mount capability exposed by an in-process typed client. */
22
+ export interface BackendMount<Env = unknown> {
23
+ readonly [NIFRA_BACKEND_MOUNT]: BackendMountHandler<Env>
24
+ }
package/src/reflection.ts CHANGED
@@ -7,7 +7,11 @@
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"
10
+ import {
11
+ isDataClassification,
12
+ type ResponseClassification,
13
+ routeClassification,
14
+ } from "./classification.ts"
11
15
  import { normalizeRouteCapabilities } from "./internal/capability-runtime.ts"
12
16
  import { type AssuranceEvidence, validEvidence } from "./internal/route-assurance.ts"
13
17
  import type { StandardSchemaV1 } from "./schema/standard.ts"
@@ -48,8 +52,8 @@ export interface ReflectedRoute {
48
52
  readonly schema?: ReflectedRouteSchema
49
53
  readonly assurance?: readonly AssuranceEvidence[]
50
54
  readonly capabilities?: readonly string[]
51
- /** Highest data-sensitivity the response carries, when declared. */
52
- readonly classification?: DataClassification
55
+ /** Field-level response classification plus the highest sensitivity present. */
56
+ readonly classification?: ResponseClassification
53
57
  readonly tool?: {
54
58
  readonly name: string
55
59
  readonly description: string
@@ -204,10 +208,10 @@ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
204
208
  const capabilities = normalizeRouteCapabilities(
205
209
  Array.isArray(route.capabilities) ? (route.capabilities as readonly string[]) : undefined,
206
210
  )
207
- const rawClassification = recordOf(route.schema)?.classification
208
- const classification: DataClassification | undefined = isDataClassification(rawClassification)
209
- ? rawClassification
210
- : undefined
211
+ const routeSchema = recordOf(route.schema)
212
+ const rawClassification = routeSchema?.classification
213
+ const fallback = isDataClassification(rawClassification) ? rawClassification : undefined
214
+ const classification = routeClassification(routeSchema?.response, fallback)
211
215
  reflected.push({
212
216
  method: route.method.toUpperCase(),
213
217
  path: route.path,
@@ -7,7 +7,8 @@ import type { CookieOptions } from "./cookies.ts"
7
7
  /**
8
8
  * Declares a mutating route as idempotent: the server dedupes retries on an `Idempotency-Key` header
9
9
  * (see {@link RouteSchema.idempotency}). Satisfies the capability-assurance idempotency requirement
10
- * for a `write` capability — `scope: "durable"` additionally clears the durable-command requirement.
10
+ * for a request-idempotent `write` capability. Durable response replay is deliberately NOT durable-
11
+ * command evidence: a command/provider key must still prove the side effect itself is deduplicated.
11
12
  */
12
13
  export interface IdempotencyConfig {
13
14
  /** `request` = in-process dedupe; `durable` = cross-restart, backed by a durable store. */
@@ -18,6 +19,24 @@ export interface IdempotencyConfig {
18
19
  readonly store?: IdempotencyStore
19
20
  /** Header carrying the key. Default `idempotency-key`. */
20
21
  readonly headerName?: string
22
+ /** Maximum response bytes retained for replay. Defaults to the server body limit. */
23
+ readonly maxResponseBytes?: number
24
+ /**
25
+ * Required server-owned key namespace. Use a tenant/subject-scoped opaque token for authenticated
26
+ * routes so two principals choosing the same header key never collide. A static namespace is an
27
+ * explicit shared/public scope and is safe only when the response is principal-independent. The
28
+ * resolver receives a clone and must not derive identity from unauthenticated client input.
29
+ */
30
+ readonly namespace:
31
+ | string
32
+ | ((
33
+ request: {
34
+ readonly method: string
35
+ readonly url: string
36
+ readonly headers: { get(name: string): string | null }
37
+ },
38
+ platform: Platform | undefined,
39
+ ) => string | Promise<string>)
21
40
  }
22
41
 
23
42
  /** Flattens an intersection into a single object type for readable hovers. */