@nifrajs/core 1.9.1 → 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 +18 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +28 -1
- package/dist/assurance.js.map +1 -1
- package/dist/budget.d.ts +91 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +172 -0
- package/dist/budget.js.map +1 -0
- package/dist/capabilities.d.ts +146 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +283 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/classification.d.ts +39 -0
- package/dist/classification.d.ts.map +1 -0
- package/dist/classification.js +153 -0
- package/dist/classification.js.map +1 -0
- 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 +137 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +306 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts +16 -0
- package/dist/internal/capability-runtime.d.ts.map +1 -0
- package/dist/internal/capability-runtime.js +27 -0
- package/dist/internal/capability-runtime.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +1 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +1 -0
- package/dist/internal/route-assurance.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 +4 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +9 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +50 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +8 -1
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +11 -1
- package/dist/server/contract.js.map +1 -1
- package/dist/server/server.d.ts +56 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +294 -17
- package/dist/server/server.js.map +1 -1
- package/package.json +36 -4
- package/src/assurance.ts +48 -1
- package/src/budget.ts +267 -0
- package/src/capabilities.ts +486 -0
- package/src/classification.ts +189 -0
- package/src/errors.ts +2 -0
- package/src/idempotency.ts +420 -0
- package/src/index.ts +8 -1
- package/src/internal/capability-runtime.ts +49 -0
- package/src/internal/route-assurance.ts +1 -0
- 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 +18 -0
- package/src/server/context.ts +54 -1
- package/src/server/contract.ts +19 -2
- package/src/server/server.ts +463 -26
|
@@ -0,0 +1,486 @@
|
|
|
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 { type EffectCost, type EffectPhase, effectLedgerOf } from "./ledger.ts"
|
|
15
|
+
import { reflectRoutes } from "./reflection.ts"
|
|
16
|
+
|
|
17
|
+
export type { CapabilityUseEvent }
|
|
18
|
+
|
|
19
|
+
export type CapabilityZone = "domain" | "operational"
|
|
20
|
+
export type CapabilityAccess = "read" | "write"
|
|
21
|
+
export type CapabilityIdempotency = "none" | "request" | "durable"
|
|
22
|
+
|
|
23
|
+
export interface CapabilityDefinition {
|
|
24
|
+
readonly id: string
|
|
25
|
+
/** Domain state is subject to HTTP method semantics; operational writes (logs/metrics) are not. */
|
|
26
|
+
readonly zone: CapabilityZone
|
|
27
|
+
readonly access: CapabilityAccess
|
|
28
|
+
/** Evidence required when this capability may execute. Default `none`. */
|
|
29
|
+
readonly idempotency?: CapabilityIdempotency
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CapabilityImportRule {
|
|
33
|
+
/** Exact module specifier, or a trailing `/*` prefix rule. */
|
|
34
|
+
readonly specifier: string
|
|
35
|
+
readonly capabilities: readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ForbiddenCapabilityImport {
|
|
39
|
+
/** Exact module specifier, or a trailing `/*` prefix rule. */
|
|
40
|
+
readonly specifier: string
|
|
41
|
+
readonly reason: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CapabilityRouteSelector {
|
|
45
|
+
readonly methods?: readonly string[]
|
|
46
|
+
readonly paths?: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CapabilityRouteModule {
|
|
50
|
+
readonly match: CapabilityRouteSelector
|
|
51
|
+
/** Project-relative modules that implement the selected routes. */
|
|
52
|
+
readonly modules: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CapabilityProvenancePolicy {
|
|
56
|
+
readonly imports: readonly CapabilityImportRule[]
|
|
57
|
+
readonly forbiddenImports: readonly ForbiddenCapabilityImport[]
|
|
58
|
+
/** Explicit associations for contract/generated routes that source scanning cannot locate. */
|
|
59
|
+
readonly routeModules?: readonly CapabilityRouteModule[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CapabilityPolicy {
|
|
63
|
+
readonly definitions: readonly CapabilityDefinition[]
|
|
64
|
+
/** Required: capability assurance without a static provenance firewall is incomplete. */
|
|
65
|
+
readonly provenance: CapabilityProvenancePolicy
|
|
66
|
+
/** Default `capabilities.lock.json`. */
|
|
67
|
+
readonly lockfile?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CapabilityEvidenceKind = "static" | "runtime"
|
|
71
|
+
|
|
72
|
+
/** Token-only effect evidence. `source` is an adapter/module id, never request or business data. */
|
|
73
|
+
export interface CapabilityEvidence {
|
|
74
|
+
readonly id: string
|
|
75
|
+
readonly kind: CapabilityEvidenceKind
|
|
76
|
+
readonly source: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RouteCapabilityEvidence {
|
|
80
|
+
readonly method: string
|
|
81
|
+
readonly path: string
|
|
82
|
+
/** True only when the route's reachable module graph was actually scanned. */
|
|
83
|
+
readonly covered: boolean
|
|
84
|
+
readonly evidence: readonly CapabilityEvidence[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CapabilityEvidenceSet {
|
|
88
|
+
readonly routes: readonly RouteCapabilityEvidence[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type CapabilityFindingCode =
|
|
92
|
+
| "unknown-capability"
|
|
93
|
+
| "provenance-uncovered"
|
|
94
|
+
| "undeclared-capability-evidence"
|
|
95
|
+
| "safe-method-domain-write"
|
|
96
|
+
| "missing-request-idempotency"
|
|
97
|
+
| "missing-durable-idempotency"
|
|
98
|
+
| "forbidden-effect-import"
|
|
99
|
+
|
|
100
|
+
export interface CapabilityFinding {
|
|
101
|
+
readonly code: CapabilityFindingCode
|
|
102
|
+
readonly method: string
|
|
103
|
+
readonly path: string
|
|
104
|
+
readonly capability?: string
|
|
105
|
+
readonly message: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AssuredCapabilityRoute {
|
|
109
|
+
readonly method: string
|
|
110
|
+
readonly path: string
|
|
111
|
+
readonly declared: readonly string[]
|
|
112
|
+
readonly evidence: readonly CapabilityEvidence[]
|
|
113
|
+
/** Declared capabilities without static/runtime evidence. Informational, never treated as proof. */
|
|
114
|
+
readonly unproven: readonly string[]
|
|
115
|
+
readonly covered: boolean
|
|
116
|
+
/** Highest data-sensitivity the response carries, when the route declares it. */
|
|
117
|
+
readonly classification?: DataClassification
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface CapabilityAssuranceReport {
|
|
121
|
+
readonly ok: boolean
|
|
122
|
+
readonly routes: readonly AssuredCapabilityRoute[]
|
|
123
|
+
readonly findings: readonly CapabilityFinding[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface CapabilitySnapshotRoute {
|
|
127
|
+
readonly method: string
|
|
128
|
+
readonly path: string
|
|
129
|
+
readonly declared: readonly string[]
|
|
130
|
+
readonly evidenced: readonly string[]
|
|
131
|
+
readonly unproven: readonly string[]
|
|
132
|
+
/** Recorded so a route that starts returning `pii`/`secret` flips the lockfile and forces a review. */
|
|
133
|
+
readonly classification?: DataClassification
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface CapabilitySnapshot {
|
|
137
|
+
readonly nifraCapabilities: 1
|
|
138
|
+
readonly routes: readonly CapabilitySnapshotRoute[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const SAFE_METHODS = new Set(["GET", "HEAD"])
|
|
142
|
+
|
|
143
|
+
export { validCapabilityId }
|
|
144
|
+
|
|
145
|
+
function normalizedIds(values: readonly string[], label: string): readonly string[] {
|
|
146
|
+
if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`)
|
|
147
|
+
const out: string[] = []
|
|
148
|
+
const seen = new Set<string>()
|
|
149
|
+
for (const value of values) {
|
|
150
|
+
if (typeof value !== "string" || !validCapabilityId(value)) {
|
|
151
|
+
throw new Error(`capability assurance: invalid ${label} id ${JSON.stringify(value)}`)
|
|
152
|
+
}
|
|
153
|
+
if (!seen.has(value)) {
|
|
154
|
+
seen.add(value)
|
|
155
|
+
out.push(value)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return Object.freeze(out)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validSpecifier(value: string): boolean {
|
|
162
|
+
return value.trim() === value && value !== "" && !hasControlCharacter(value)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasControlCharacter(value: string): boolean {
|
|
166
|
+
for (let index = 0; index < value.length; index++) {
|
|
167
|
+
const code = value.charCodeAt(index)
|
|
168
|
+
if (code <= 31 || code === 127) return true
|
|
169
|
+
}
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Validate and freeze a capability/provenance policy. */
|
|
174
|
+
export function defineCapabilityPolicy(policy: CapabilityPolicy): CapabilityPolicy {
|
|
175
|
+
if (policy.provenance === undefined || policy.provenance === null) {
|
|
176
|
+
throw new Error("capability assurance: provenance policy is required")
|
|
177
|
+
}
|
|
178
|
+
const ids = new Set<string>()
|
|
179
|
+
const definitions = policy.definitions.map((definition): CapabilityDefinition => {
|
|
180
|
+
if (!validCapabilityId(definition.id)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`capability assurance: invalid capability id ${JSON.stringify(definition.id)}`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
if (ids.has(definition.id)) {
|
|
186
|
+
throw new Error(`capability assurance: duplicate capability ${JSON.stringify(definition.id)}`)
|
|
187
|
+
}
|
|
188
|
+
ids.add(definition.id)
|
|
189
|
+
if (definition.zone !== "domain" && definition.zone !== "operational") {
|
|
190
|
+
throw new Error(`capability assurance: invalid zone for ${definition.id}`)
|
|
191
|
+
}
|
|
192
|
+
if (definition.access !== "read" && definition.access !== "write") {
|
|
193
|
+
throw new Error(`capability assurance: invalid access for ${definition.id}`)
|
|
194
|
+
}
|
|
195
|
+
const idempotency = definition.idempotency ?? "none"
|
|
196
|
+
if (idempotency !== "none" && idempotency !== "request" && idempotency !== "durable") {
|
|
197
|
+
throw new Error(`capability assurance: invalid idempotency for ${definition.id}`)
|
|
198
|
+
}
|
|
199
|
+
if (definition.access === "read" && idempotency !== "none") {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`capability assurance: read capability ${definition.id} cannot require idempotency`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return Object.freeze({ ...definition, idempotency })
|
|
205
|
+
})
|
|
206
|
+
const imports = policy.provenance.imports.map((rule): CapabilityImportRule => {
|
|
207
|
+
if (!validSpecifier(rule.specifier))
|
|
208
|
+
throw new Error("capability assurance: invalid import specifier")
|
|
209
|
+
const capabilities = normalizedIds(rule.capabilities, "import capability")
|
|
210
|
+
for (const id of capabilities) {
|
|
211
|
+
if (!ids.has(id)) throw new Error(`capability assurance: import references unknown ${id}`)
|
|
212
|
+
}
|
|
213
|
+
return Object.freeze({ specifier: rule.specifier, capabilities })
|
|
214
|
+
})
|
|
215
|
+
const forbiddenImports = policy.provenance.forbiddenImports.map(
|
|
216
|
+
(rule): ForbiddenCapabilityImport => {
|
|
217
|
+
if (!validSpecifier(rule.specifier) || rule.reason.trim() === "") {
|
|
218
|
+
throw new Error("capability assurance: forbidden import needs a specifier and reason")
|
|
219
|
+
}
|
|
220
|
+
return Object.freeze({ specifier: rule.specifier, reason: rule.reason.trim() })
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
const routeModules = policy.provenance.routeModules?.map((rule): CapabilityRouteModule => {
|
|
224
|
+
if (!Array.isArray(rule.modules) || rule.modules.length === 0) {
|
|
225
|
+
throw new Error("capability assurance: routeModules entry needs at least one module")
|
|
226
|
+
}
|
|
227
|
+
const modules = rule.modules.map((module) => {
|
|
228
|
+
if (!validSpecifier(module)) throw new Error("capability assurance: invalid route module")
|
|
229
|
+
return module.replace(/^\.\//, "")
|
|
230
|
+
})
|
|
231
|
+
return Object.freeze({
|
|
232
|
+
match: Object.freeze({
|
|
233
|
+
...(rule.match.methods !== undefined
|
|
234
|
+
? { methods: Object.freeze(rule.match.methods.map((method) => method.toUpperCase())) }
|
|
235
|
+
: {}),
|
|
236
|
+
...(rule.match.paths !== undefined ? { paths: Object.freeze([...rule.match.paths]) } : {}),
|
|
237
|
+
}),
|
|
238
|
+
modules: Object.freeze(modules),
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
return Object.freeze({
|
|
242
|
+
definitions: Object.freeze(definitions),
|
|
243
|
+
provenance: Object.freeze({
|
|
244
|
+
imports: Object.freeze(imports),
|
|
245
|
+
forbiddenImports: Object.freeze(forbiddenImports),
|
|
246
|
+
...(routeModules !== undefined ? { routeModules: Object.freeze(routeModules) } : {}),
|
|
247
|
+
}),
|
|
248
|
+
lockfile: policy.lockfile ?? "capabilities.lock.json",
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function evidenceSourceValid(source: string): boolean {
|
|
253
|
+
return source.length > 0 && source.length <= 256 && !hasControlCharacter(source)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function routeKey(method: string, path: string): string {
|
|
257
|
+
return `${method.toUpperCase()}\n${path}`
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Compare declared route capabilities against coverage-qualified static/runtime evidence. */
|
|
261
|
+
export function evaluateCapabilityAssurance(
|
|
262
|
+
source: unknown,
|
|
263
|
+
policyInput: CapabilityPolicy,
|
|
264
|
+
evidenceSet: CapabilityEvidenceSet,
|
|
265
|
+
): CapabilityAssuranceReport {
|
|
266
|
+
const policy = defineCapabilityPolicy(policyInput)
|
|
267
|
+
const definitions = new Map(policy.definitions.map((definition) => [definition.id, definition]))
|
|
268
|
+
const evidenceByRoute = new Map(
|
|
269
|
+
evidenceSet.routes.map((route) => [routeKey(route.method, route.path), route]),
|
|
270
|
+
)
|
|
271
|
+
const findings: CapabilityFinding[] = []
|
|
272
|
+
const routes: AssuredCapabilityRoute[] = []
|
|
273
|
+
|
|
274
|
+
for (const route of reflectRoutes(source)) {
|
|
275
|
+
const declared = route.capabilities ?? []
|
|
276
|
+
const supplied = evidenceByRoute.get(routeKey(route.method, route.path))
|
|
277
|
+
const covered = supplied?.covered === true
|
|
278
|
+
const evidence: CapabilityEvidence[] = []
|
|
279
|
+
const evidenceIds = new Set<string>()
|
|
280
|
+
for (const item of supplied?.evidence ?? []) {
|
|
281
|
+
if (
|
|
282
|
+
!validCapabilityId(item.id) ||
|
|
283
|
+
(item.kind !== "static" && item.kind !== "runtime") ||
|
|
284
|
+
!evidenceSourceValid(item.source)
|
|
285
|
+
) {
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
const key = `${item.id}\n${item.kind}\n${item.source}`
|
|
289
|
+
if (
|
|
290
|
+
evidence.some(
|
|
291
|
+
(candidate) => `${candidate.id}\n${candidate.kind}\n${candidate.source}` === key,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
continue
|
|
295
|
+
evidence.push(Object.freeze({ id: item.id, kind: item.kind, source: item.source }))
|
|
296
|
+
evidenceIds.add(item.id)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const id of declared) {
|
|
300
|
+
if (!definitions.has(id)) {
|
|
301
|
+
findings.push({
|
|
302
|
+
code: "unknown-capability",
|
|
303
|
+
method: route.method,
|
|
304
|
+
path: route.path,
|
|
305
|
+
capability: id,
|
|
306
|
+
message: `${route.method} ${route.path} declares unknown capability ${id}`,
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (!covered) {
|
|
311
|
+
findings.push({
|
|
312
|
+
code: "provenance-uncovered",
|
|
313
|
+
method: route.method,
|
|
314
|
+
path: route.path,
|
|
315
|
+
message: `${route.method} ${route.path} has no static provenance coverage`,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
for (const id of evidenceIds) {
|
|
319
|
+
if (!declared.includes(id)) {
|
|
320
|
+
findings.push({
|
|
321
|
+
code: "undeclared-capability-evidence",
|
|
322
|
+
method: route.method,
|
|
323
|
+
path: route.path,
|
|
324
|
+
capability: id,
|
|
325
|
+
message: `${route.method} ${route.path} evidence exceeds its declaration: ${id}`,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const effective = new Set([...declared, ...evidenceIds])
|
|
331
|
+
const assuranceIds = new Set((route.assurance ?? []).map((item) => item.id))
|
|
332
|
+
for (const id of effective) {
|
|
333
|
+
const definition = definitions.get(id)
|
|
334
|
+
if (definition === undefined) continue
|
|
335
|
+
if (
|
|
336
|
+
SAFE_METHODS.has(route.method) &&
|
|
337
|
+
definition.zone === "domain" &&
|
|
338
|
+
definition.access === "write"
|
|
339
|
+
) {
|
|
340
|
+
findings.push({
|
|
341
|
+
code: "safe-method-domain-write",
|
|
342
|
+
method: route.method,
|
|
343
|
+
path: route.path,
|
|
344
|
+
capability: id,
|
|
345
|
+
message: `${route.method} ${route.path} cannot carry domain write capability ${id}`,
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
definition.idempotency === "request" &&
|
|
350
|
+
!assuranceIds.has(NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY)
|
|
351
|
+
) {
|
|
352
|
+
findings.push({
|
|
353
|
+
code: "missing-request-idempotency",
|
|
354
|
+
method: route.method,
|
|
355
|
+
path: route.path,
|
|
356
|
+
capability: id,
|
|
357
|
+
message: `${route.method} ${route.path} capability ${id} requires request idempotency evidence`,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
definition.idempotency === "durable" &&
|
|
362
|
+
!assuranceIds.has(NIFRA_ASSURANCE_IDS.DURABLE_COMMAND)
|
|
363
|
+
) {
|
|
364
|
+
findings.push({
|
|
365
|
+
code: "missing-durable-idempotency",
|
|
366
|
+
method: route.method,
|
|
367
|
+
path: route.path,
|
|
368
|
+
capability: id,
|
|
369
|
+
message: `${route.method} ${route.path} capability ${id} requires durable command/provider-key evidence`,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
routes.push(
|
|
374
|
+
Object.freeze({
|
|
375
|
+
method: route.method,
|
|
376
|
+
path: route.path,
|
|
377
|
+
declared: Object.freeze([...declared]),
|
|
378
|
+
evidence: Object.freeze(evidence),
|
|
379
|
+
unproven: Object.freeze(declared.filter((id) => !evidenceIds.has(id))),
|
|
380
|
+
covered,
|
|
381
|
+
...(route.classification !== undefined ? { classification: route.classification.max } : {}),
|
|
382
|
+
}),
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
return Object.freeze({
|
|
386
|
+
ok: findings.length === 0,
|
|
387
|
+
routes: Object.freeze(routes),
|
|
388
|
+
findings: Object.freeze(findings),
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Optional effect-ledger fields for one `useCapability` beacon. Token-only by design: an adapter
|
|
394
|
+
* names *what* it touched and *how much resource* it used — never the value it read or wrote.
|
|
395
|
+
*/
|
|
396
|
+
export interface UseCapabilityOptions {
|
|
397
|
+
/** Adapter/resource token recorded on the ledger entry (`repo:orders`). */
|
|
398
|
+
readonly target?: string
|
|
399
|
+
/** Dimensionless resource counters recorded on the ledger entry. */
|
|
400
|
+
readonly cost?: EffectCost
|
|
401
|
+
/** Keyed payload digest (see `computeEffectDigest`). */
|
|
402
|
+
readonly digest?: string
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface CapabilityOutcomeOptions extends UseCapabilityOptions {
|
|
406
|
+
/** An outcome can only be recorded after the intent beacon succeeded. */
|
|
407
|
+
readonly phase: Exclude<EffectPhase, "intent">
|
|
408
|
+
/** Outcome error as a bounded token code. */
|
|
409
|
+
readonly error?: { readonly code: string }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function guardFor(context: object, capability: string): CapabilityGuard {
|
|
413
|
+
if (!validCapabilityId(capability)) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`capability assurance: invalid runtime capability ${JSON.stringify(capability)}`,
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
const guard = (context as { readonly [CAPABILITY_GUARD]?: CapabilityGuard })[CAPABILITY_GUARD]
|
|
419
|
+
if (guard === undefined) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`capability assurance: ${capability} used on a route with no capability declaration`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
if (!guard.allowed.includes(capability)) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`capability assurance: ${capability} is not declared for ${guard.method} ${guard.path}`,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
return guard
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Runtime effect beacon for owned adapters. It fails closed when the route omitted the capability or
|
|
434
|
+
* when no route guard is present. Static provenance is still required: code can bypass a beacon.
|
|
435
|
+
* When the server enabled the effect ledger, each beacon call also appends one token-only entry.
|
|
436
|
+
*/
|
|
437
|
+
export function useCapability(
|
|
438
|
+
context: object,
|
|
439
|
+
capability: string,
|
|
440
|
+
options?: UseCapabilityOptions,
|
|
441
|
+
): void {
|
|
442
|
+
const guard = guardFor(context, capability)
|
|
443
|
+
const ledger = effectLedgerOf(context)
|
|
444
|
+
// Validate/append the intent before private admission hooks debit budgets. Overflow or malformed
|
|
445
|
+
// evidence therefore cannot consume quota and then fail the request.
|
|
446
|
+
ledger?.append({ capability, ...options, phase: "intent" })
|
|
447
|
+
guard.onUse?.({ capability, method: guard.method, path: guard.path })
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Record the terminal outcome of an already-admitted capability without debiting admission twice. */
|
|
451
|
+
export function recordCapabilityOutcome(
|
|
452
|
+
context: object,
|
|
453
|
+
capability: string,
|
|
454
|
+
options: CapabilityOutcomeOptions,
|
|
455
|
+
): void {
|
|
456
|
+
guardFor(context, capability)
|
|
457
|
+
const ledger = effectLedgerOf(context)
|
|
458
|
+
if (ledger === undefined) return
|
|
459
|
+
ledger.append({ capability, ...options })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Read the route's token-only declaration for admission plugins. This intentionally exposes neither
|
|
464
|
+
* the request nor runtime evidence; it is the stable public seam for private entitlement policy.
|
|
465
|
+
*/
|
|
466
|
+
export function declaredCapabilities(context: object): readonly string[] {
|
|
467
|
+
const guard = (context as { readonly [CAPABILITY_GUARD]?: CapabilityGuard })[CAPABILITY_GUARD]
|
|
468
|
+
return guard?.allowed ?? Object.freeze([])
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Deterministic, PII-free lockfile material. */
|
|
472
|
+
export function snapshotCapabilities(report: CapabilityAssuranceReport): CapabilitySnapshot {
|
|
473
|
+
const routes = report.routes
|
|
474
|
+
.map(
|
|
475
|
+
(route): CapabilitySnapshotRoute => ({
|
|
476
|
+
method: route.method,
|
|
477
|
+
path: route.path,
|
|
478
|
+
declared: Object.freeze([...route.declared].sort()),
|
|
479
|
+
evidenced: Object.freeze([...new Set(route.evidence.map((item) => item.id))].sort()),
|
|
480
|
+
unproven: Object.freeze([...route.unproven].sort()),
|
|
481
|
+
...(route.classification !== undefined ? { classification: route.classification } : {}),
|
|
482
|
+
}),
|
|
483
|
+
)
|
|
484
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
|
|
485
|
+
return Object.freeze({ nifraCapabilities: 1, routes: Object.freeze(routes) })
|
|
486
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
/** Field paths use JSON Pointer segments; array items use a `*` segment. */
|
|
14
|
+
export interface ResponseClassification {
|
|
15
|
+
readonly fields: Readonly<Record<string, DataClassification>>
|
|
16
|
+
readonly max: DataClassification
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CLASSIFICATION = Symbol.for("nifra.data-classification")
|
|
20
|
+
const JSON_SCHEMA_CLASSIFICATION = "x-nifra-classification"
|
|
21
|
+
|
|
22
|
+
type ClassificationCarrier = {
|
|
23
|
+
readonly [CLASSIFICATION]?: DataClassification
|
|
24
|
+
readonly jsonSchema?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ClassifiedSchema<S extends object> = S & {
|
|
28
|
+
readonly [CLASSIFICATION]: DataClassification
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Total order over classifications; higher = more sensitive. */
|
|
32
|
+
export const DATA_CLASSIFICATION_RANK: Readonly<Record<DataClassification, number>> = Object.freeze(
|
|
33
|
+
{
|
|
34
|
+
public: 0,
|
|
35
|
+
pii: 1,
|
|
36
|
+
secret: 2,
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const CLASSIFICATIONS = Object.keys(DATA_CLASSIFICATION_RANK) as readonly DataClassification[]
|
|
41
|
+
|
|
42
|
+
/** Whether `value` is a known classification token. */
|
|
43
|
+
export function isDataClassification(value: unknown): value is DataClassification {
|
|
44
|
+
return typeof value === "string" && (CLASSIFICATIONS as readonly string[]).includes(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The most sensitive classification among the inputs; `"public"` when none are given. */
|
|
48
|
+
export function maxClassification(values: Iterable<DataClassification>): DataClassification {
|
|
49
|
+
let max: DataClassification = "public"
|
|
50
|
+
for (const value of values) {
|
|
51
|
+
if (DATA_CLASSIFICATION_RANK[value] > DATA_CLASSIFICATION_RANK[max]) max = value
|
|
52
|
+
}
|
|
53
|
+
return max
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when `value` is at least as sensitive as `floor` (e.g. `classificationAtLeast(x, "pii")`). */
|
|
57
|
+
export function classificationAtLeast(
|
|
58
|
+
value: DataClassification,
|
|
59
|
+
floor: DataClassification,
|
|
60
|
+
): boolean {
|
|
61
|
+
return DATA_CLASSIFICATION_RANK[value] >= DATA_CLASSIFICATION_RANK[floor]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cloneObject<T extends object>(value: T): T {
|
|
65
|
+
return Object.create(Object.getPrototypeOf(value), Object.getOwnPropertyDescriptors(value)) as T
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tagObject<T extends object>(value: T, classification: DataClassification): T {
|
|
69
|
+
const clone = cloneObject(value)
|
|
70
|
+
Object.defineProperty(clone, CLASSIFICATION, {
|
|
71
|
+
configurable: false,
|
|
72
|
+
enumerable: true,
|
|
73
|
+
value: classification,
|
|
74
|
+
writable: false,
|
|
75
|
+
})
|
|
76
|
+
Object.defineProperty(clone, JSON_SCHEMA_CLASSIFICATION, {
|
|
77
|
+
configurable: true,
|
|
78
|
+
enumerable: true,
|
|
79
|
+
value: classification,
|
|
80
|
+
writable: true,
|
|
81
|
+
})
|
|
82
|
+
return clone
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Attach data-classification metadata without changing validation or inferred input/output types.
|
|
87
|
+
* For Nifra/TypeBox carriers the raw JSON Schema node is tagged too, so metadata survives composition
|
|
88
|
+
* through `t.object`, `t.array`, `t.optional`, and unions.
|
|
89
|
+
*/
|
|
90
|
+
export function classified<S extends object>(
|
|
91
|
+
schema: S,
|
|
92
|
+
classification: DataClassification,
|
|
93
|
+
): ClassifiedSchema<S> {
|
|
94
|
+
if (!isDataClassification(classification)) {
|
|
95
|
+
throw new TypeError(`classification: invalid tag ${JSON.stringify(classification)}`)
|
|
96
|
+
}
|
|
97
|
+
const clone = tagObject(schema, classification) as S & ClassificationCarrier
|
|
98
|
+
const raw = (schema as ClassificationCarrier).jsonSchema
|
|
99
|
+
if (raw !== null && typeof raw === "object") {
|
|
100
|
+
Object.defineProperty(clone, "jsonSchema", {
|
|
101
|
+
configurable: true,
|
|
102
|
+
enumerable: true,
|
|
103
|
+
value: tagObject(raw, classification),
|
|
104
|
+
writable: false,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return clone as ClassifiedSchema<S>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
111
|
+
value !== null && typeof value === "object"
|
|
112
|
+
? (value as Readonly<Record<string, unknown>>)
|
|
113
|
+
: undefined
|
|
114
|
+
|
|
115
|
+
const classificationOf = (value: unknown): DataClassification | undefined => {
|
|
116
|
+
const record = recordOf(value)
|
|
117
|
+
const symbolValue = (value as ClassificationCarrier | null)?.[CLASSIFICATION]
|
|
118
|
+
if (isDataClassification(symbolValue)) return symbolValue
|
|
119
|
+
const extension = record?.[JSON_SCHEMA_CLASSIFICATION]
|
|
120
|
+
return isDataClassification(extension) ? extension : undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const pointerSegment = (value: string): string => value.replaceAll("~", "~0").replaceAll("/", "~1")
|
|
124
|
+
|
|
125
|
+
/** Read field-level metadata from an introspectable response schema. Never invokes its validator. */
|
|
126
|
+
export function reflectClassification(schema: unknown): ResponseClassification | undefined {
|
|
127
|
+
const carrier = recordOf(schema)
|
|
128
|
+
const raw = carrier !== undefined && "jsonSchema" in carrier ? carrier.jsonSchema : schema
|
|
129
|
+
const fields: Record<string, DataClassification> = {}
|
|
130
|
+
const seen = new Set<object>()
|
|
131
|
+
const all: DataClassification[] = []
|
|
132
|
+
|
|
133
|
+
const record = (path: string, value: DataClassification): void => {
|
|
134
|
+
const key = path === "" ? "$" : path
|
|
135
|
+
const previous = fields[key]
|
|
136
|
+
fields[key] = previous === undefined ? value : maxClassification([previous, value])
|
|
137
|
+
all.push(value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const visit = (value: unknown, path: string): void => {
|
|
141
|
+
const object = recordOf(value)
|
|
142
|
+
if (object === undefined) return
|
|
143
|
+
if (seen.has(object as object)) return
|
|
144
|
+
seen.add(object as object)
|
|
145
|
+
const own = classificationOf(value)
|
|
146
|
+
if (own !== undefined) record(path, own)
|
|
147
|
+
|
|
148
|
+
const properties = recordOf(object.properties)
|
|
149
|
+
if (properties !== undefined) {
|
|
150
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
151
|
+
visit(child, `${path}/${pointerSegment(name)}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (object.items !== undefined) visit(object.items, `${path}/*`)
|
|
155
|
+
if (Array.isArray(object.prefixItems)) {
|
|
156
|
+
for (const [index, child] of object.prefixItems.entries()) visit(child, `${path}/${index}`)
|
|
157
|
+
}
|
|
158
|
+
if (object.additionalProperties !== undefined && object.additionalProperties !== false) {
|
|
159
|
+
visit(object.additionalProperties, `${path}/*`)
|
|
160
|
+
}
|
|
161
|
+
for (const key of ["allOf", "anyOf", "oneOf"] as const) {
|
|
162
|
+
const variants = object[key]
|
|
163
|
+
if (Array.isArray(variants)) for (const child of variants) visit(child, path)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
visit(raw, "")
|
|
168
|
+
// A validation-only carrier can still carry a root tag even when it has no JSON Schema metadata.
|
|
169
|
+
const carrierTag = classificationOf(schema)
|
|
170
|
+
if (carrierTag !== undefined && all.length === 0) record("", carrierTag)
|
|
171
|
+
if (all.length === 0) return undefined
|
|
172
|
+
return Object.freeze({ fields: Object.freeze(fields), max: maxClassification(all) })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Merge field metadata with an optional route-level sensitivity fallback. */
|
|
176
|
+
export function routeClassification(
|
|
177
|
+
responseSchema: unknown,
|
|
178
|
+
fallback: DataClassification | undefined,
|
|
179
|
+
): ResponseClassification | undefined {
|
|
180
|
+
const reflected = reflectClassification(responseSchema)
|
|
181
|
+
if (reflected === undefined && fallback === undefined) return undefined
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
fields: reflected?.fields ?? Object.freeze({}),
|
|
184
|
+
max:
|
|
185
|
+
fallback === undefined
|
|
186
|
+
? (reflected as ResponseClassification).max
|
|
187
|
+
: maxClassification([fallback, ...(reflected === undefined ? [] : [reflected.max])]),
|
|
188
|
+
})
|
|
189
|
+
}
|
package/src/errors.ts
CHANGED