@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.
- package/dist/assurance.d.ts +13 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +30 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +32 -6
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +20 -0
- package/dist/classification.d.ts.map +1 -1
- package/dist/classification.js +120 -0
- package/dist/classification.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/idempotency.d.ts +59 -12
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +191 -20
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/invariants.d.ts +66 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +483 -0
- package/dist/invariants.js.map +1 -0
- package/dist/ledger.d.ts +154 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +271 -0
- package/dist/ledger.js.map +1 -0
- package/dist/manifest.d.ts +81 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +394 -0
- package/dist/manifest.js.map +1 -0
- package/dist/mount.d.ts +18 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +11 -0
- package/dist/mount.js.map +1 -0
- package/dist/reflection.d.ts +3 -3
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +5 -5
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +17 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/server.d.ts +23 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +179 -26
- package/dist/server/server.js.map +1 -1
- package/package.json +26 -1
- package/src/assurance.ts +33 -0
- package/src/capabilities.ts +60 -4
- package/src/classification.ts +145 -0
- package/src/errors.ts +1 -0
- package/src/idempotency.ts +245 -30
- package/src/index.ts +5 -1
- package/src/invariants.ts +600 -0
- package/src/ledger.ts +430 -0
- package/src/manifest.ts +543 -0
- package/src/mount.ts +24 -0
- package/src/reflection.ts +11 -7
- package/src/server/context.ts +20 -1
- package/src/server/server.ts +254 -30
package/src/manifest.ts
ADDED
|
@@ -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 {
|
|
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
|
-
/**
|
|
52
|
-
readonly classification?:
|
|
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
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
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,
|
package/src/server/context.ts
CHANGED
|
@@ -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
|
|
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. */
|