@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.
Files changed (83) hide show
  1. package/dist/assurance.d.ts +18 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +28 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/budget.d.ts +91 -0
  6. package/dist/budget.d.ts.map +1 -0
  7. package/dist/budget.js +172 -0
  8. package/dist/budget.js.map +1 -0
  9. package/dist/capabilities.d.ts +146 -0
  10. package/dist/capabilities.d.ts.map +1 -0
  11. package/dist/capabilities.js +283 -0
  12. package/dist/capabilities.js.map +1 -0
  13. package/dist/classification.d.ts +39 -0
  14. package/dist/classification.d.ts.map +1 -0
  15. package/dist/classification.js +153 -0
  16. package/dist/classification.js.map +1 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js.map +1 -1
  20. package/dist/idempotency.d.ts +137 -0
  21. package/dist/idempotency.d.ts.map +1 -0
  22. package/dist/idempotency.js +306 -0
  23. package/dist/idempotency.js.map +1 -0
  24. package/dist/index.d.ts +8 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/capability-runtime.d.ts +16 -0
  29. package/dist/internal/capability-runtime.d.ts.map +1 -0
  30. package/dist/internal/capability-runtime.js +27 -0
  31. package/dist/internal/capability-runtime.js.map +1 -0
  32. package/dist/internal/route-assurance.d.ts +1 -0
  33. package/dist/internal/route-assurance.d.ts.map +1 -1
  34. package/dist/internal/route-assurance.js +1 -0
  35. package/dist/internal/route-assurance.js.map +1 -1
  36. package/dist/invariants.d.ts +66 -0
  37. package/dist/invariants.d.ts.map +1 -0
  38. package/dist/invariants.js +483 -0
  39. package/dist/invariants.js.map +1 -0
  40. package/dist/ledger.d.ts +154 -0
  41. package/dist/ledger.d.ts.map +1 -0
  42. package/dist/ledger.js +271 -0
  43. package/dist/ledger.js.map +1 -0
  44. package/dist/manifest.d.ts +81 -0
  45. package/dist/manifest.d.ts.map +1 -0
  46. package/dist/manifest.js +394 -0
  47. package/dist/manifest.js.map +1 -0
  48. package/dist/mount.d.ts +18 -0
  49. package/dist/mount.d.ts.map +1 -0
  50. package/dist/mount.js +11 -0
  51. package/dist/mount.js.map +1 -0
  52. package/dist/reflection.d.ts +4 -0
  53. package/dist/reflection.d.ts.map +1 -1
  54. package/dist/reflection.js +9 -0
  55. package/dist/reflection.js.map +1 -1
  56. package/dist/server/context.d.ts +50 -1
  57. package/dist/server/context.d.ts.map +1 -1
  58. package/dist/server/contract.d.ts +8 -1
  59. package/dist/server/contract.d.ts.map +1 -1
  60. package/dist/server/contract.js +11 -1
  61. package/dist/server/contract.js.map +1 -1
  62. package/dist/server/server.d.ts +56 -2
  63. package/dist/server/server.d.ts.map +1 -1
  64. package/dist/server/server.js +294 -17
  65. package/dist/server/server.js.map +1 -1
  66. package/package.json +36 -4
  67. package/src/assurance.ts +48 -1
  68. package/src/budget.ts +267 -0
  69. package/src/capabilities.ts +486 -0
  70. package/src/classification.ts +189 -0
  71. package/src/errors.ts +2 -0
  72. package/src/idempotency.ts +420 -0
  73. package/src/index.ts +8 -1
  74. package/src/internal/capability-runtime.ts +49 -0
  75. package/src/internal/route-assurance.ts +1 -0
  76. package/src/invariants.ts +600 -0
  77. package/src/ledger.ts +430 -0
  78. package/src/manifest.ts +543 -0
  79. package/src/mount.ts +24 -0
  80. package/src/reflection.ts +18 -0
  81. package/src/server/context.ts +54 -1
  82. package/src/server/contract.ts +19 -2
  83. 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
@@ -24,6 +24,8 @@ export type RouteConfigErrorCode =
24
24
  | "INVALID_PARAM_NAME"
25
25
  | "WILDCARD_NOT_LAST"
26
26
  | "INVALID_METHOD"
27
+ | "INVALID_ASSURANCE"
28
+ | "INVALID_IDEMPOTENCY"
27
29
 
28
30
  /**
29
31
  * Thrown at route registration when a route is misconfigured. This is the