@nifrajs/core 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/assurance.d.ts +5 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +8 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/budget.d.ts +91 -0
  6. package/dist/budget.d.ts.map +1 -0
  7. package/dist/budget.js +172 -0
  8. package/dist/budget.js.map +1 -0
  9. package/dist/capabilities.d.ts +117 -0
  10. package/dist/capabilities.d.ts.map +1 -0
  11. package/dist/capabilities.js +257 -0
  12. package/dist/capabilities.js.map +1 -0
  13. package/dist/classification.d.ts +19 -0
  14. package/dist/classification.d.ts.map +1 -0
  15. package/dist/classification.js +33 -0
  16. package/dist/classification.js.map +1 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js.map +1 -1
  20. package/dist/idempotency.d.ts +90 -0
  21. package/dist/idempotency.d.ts.map +1 -0
  22. package/dist/idempotency.js +135 -0
  23. package/dist/idempotency.js.map +1 -0
  24. package/dist/index.d.ts +5 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/capability-runtime.d.ts +16 -0
  29. package/dist/internal/capability-runtime.d.ts.map +1 -0
  30. package/dist/internal/capability-runtime.js +27 -0
  31. package/dist/internal/capability-runtime.js.map +1 -0
  32. package/dist/internal/route-assurance.d.ts +1 -0
  33. package/dist/internal/route-assurance.d.ts.map +1 -1
  34. package/dist/internal/route-assurance.js +1 -0
  35. package/dist/internal/route-assurance.js.map +1 -1
  36. package/dist/reflection.d.ts +4 -0
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +9 -0
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/server/context.d.ts +34 -1
  41. package/dist/server/context.d.ts.map +1 -1
  42. package/dist/server/contract.d.ts +8 -1
  43. package/dist/server/contract.d.ts.map +1 -1
  44. package/dist/server/contract.js +11 -1
  45. package/dist/server/contract.js.map +1 -1
  46. package/dist/server/server.d.ts +75 -0
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +195 -9
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +11 -4
  51. package/src/assurance.ts +15 -1
  52. package/src/budget.ts +267 -0
  53. package/src/capabilities.ts +430 -0
  54. package/src/classification.ts +44 -0
  55. package/src/errors.ts +1 -0
  56. package/src/idempotency.ts +205 -0
  57. package/src/index.ts +6 -1
  58. package/src/internal/capability-runtime.ts +49 -0
  59. package/src/internal/route-assurance.ts +1 -0
  60. package/src/reflection.ts +14 -0
  61. package/src/server/context.ts +35 -1
  62. package/src/server/contract.ts +19 -2
  63. package/src/server/server.ts +328 -11
package/src/budget.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Portable request-deadline mechanics owned by core.
3
+ *
4
+ * A wire deadline is an absolute wall-clock instant so every hop shares one end time. Once admitted,
5
+ * remaining time is measured against a monotonic clock so NTP or manual wall-clock changes cannot
6
+ * add time back to an in-flight request. Policy stays out of this primitive.
7
+ */
8
+
9
+ /** Canonical wire header carrying an absolute Unix epoch deadline in milliseconds. */
10
+ export const NIFRA_DEADLINE_HEADER = "x-nifra-deadline"
11
+
12
+ /** The only clocks deadline mechanics need. Inject both for deterministic tests. */
13
+ export interface BudgetClock {
14
+ /** Monotonic milliseconds; must never move backwards during the process lifetime. */
15
+ monotonic(): number
16
+ /** Unix epoch milliseconds; used only to translate an absolute wire deadline on admission. */
17
+ wall(): number
18
+ }
19
+
20
+ /** A time budget shared by one request and every downstream hop it initiates. */
21
+ export interface RequestBudget {
22
+ /** Absolute Unix epoch deadline for wire propagation. */
23
+ readonly deadline: number
24
+ /** The request cancellation signal. Nifra drives this when its effective deadline elapses. */
25
+ readonly signal: AbortSignal
26
+ /** Non-negative milliseconds remaining, measured with the monotonic clock. */
27
+ remaining(): number
28
+ /** A view that finishes `reserveMs` before this budget, preserving time for response cleanup. */
29
+ child(reserveMs: number): RequestBudget
30
+ }
31
+
32
+ export type DeadlineHeaderResult =
33
+ | { readonly ok: true; readonly deadline: number }
34
+ | { readonly ok: false; readonly reason: "missing" | "malformed" }
35
+
36
+ const MISSING_DEADLINE: DeadlineHeaderResult = Object.freeze({ ok: false, reason: "missing" })
37
+ const MALFORMED_DEADLINE: DeadlineHeaderResult = Object.freeze({ ok: false, reason: "malformed" })
38
+ const UNBOUNDED_ADMISSION: DeadlineAdmission = Object.freeze({
39
+ ok: true,
40
+ inherited: false,
41
+ timeoutMs: 0,
42
+ })
43
+
44
+ /** DOM-lib-independent subset accepted by the Web `Headers` constructor. */
45
+ export type DeadlineHeadersInit =
46
+ | Headers
47
+ | Readonly<Record<string, string>>
48
+ | [string, string][]
49
+ | undefined
50
+
51
+ export interface CreateRequestBudgetOptions {
52
+ /** Absolute Unix epoch deadline. */
53
+ readonly deadline: number
54
+ readonly signal: AbortSignal
55
+ readonly clock?: BudgetClock
56
+ }
57
+
58
+ export interface DeadlineAdmissionOptions {
59
+ /** Local request timeout. `0` keeps requests without a wire deadline unbounded. */
60
+ readonly localTimeoutMs?: number
61
+ /** Hard cap for a request carrying the wire header. Default 30 seconds. */
62
+ readonly maxInboundDeadlineMs?: number
63
+ /** Injected wall clock for deterministic admission tests. */
64
+ readonly wallNow?: () => number
65
+ }
66
+
67
+ export type DeadlineAdmission =
68
+ | {
69
+ readonly ok: true
70
+ readonly inherited: boolean
71
+ readonly timeoutMs: number
72
+ /** Absent only for the unbounded no-header/no-local-timeout case. */
73
+ readonly deadline?: number
74
+ }
75
+ | {
76
+ readonly ok: false
77
+ readonly status: 400 | 408
78
+ readonly reason: "malformed_deadline" | "deadline_exceeded"
79
+ }
80
+
81
+ /** Sentinel used only for an unbounded local budget. It is never written to the wire. */
82
+ export const UNBOUNDED_DEADLINE = Number.MAX_SAFE_INTEGER
83
+
84
+ export class DeadlineExceededError extends Error {
85
+ readonly code = "NIFRA_DEADLINE_EXCEEDED" as const
86
+ readonly status = 504 as const
87
+
88
+ constructor(
89
+ public readonly remainingMs: number,
90
+ public readonly requiredMs: number,
91
+ ) {
92
+ super(`request deadline exceeded: ${remainingMs}ms remaining, ${requiredMs}ms required`)
93
+ this.name = "DeadlineExceededError"
94
+ }
95
+ }
96
+
97
+ const systemClock: BudgetClock = {
98
+ monotonic: () => globalThis.performance?.now?.() ?? Date.now(),
99
+ wall: () => Date.now(),
100
+ }
101
+
102
+ const assertFiniteNonNegative = (name: string, value: number): void => {
103
+ if (!Number.isFinite(value) || value < 0) {
104
+ throw new RangeError(`@nifrajs/core/budget: ${name} must be a finite non-negative number`)
105
+ }
106
+ }
107
+
108
+ const assertDeadline = (deadline: number): void => {
109
+ if (!Number.isSafeInteger(deadline) || deadline <= 0) {
110
+ throw new RangeError(
111
+ "@nifrajs/core/budget: deadline must be a positive safe-integer epoch-ms value",
112
+ )
113
+ }
114
+ }
115
+
116
+ class MonotonicRequestBudget implements RequestBudget {
117
+ constructor(
118
+ readonly deadline: number,
119
+ readonly signal: AbortSignal,
120
+ private readonly monotonicDeadline: number,
121
+ private readonly clock: BudgetClock,
122
+ ) {}
123
+
124
+ remaining(): number {
125
+ if (this.signal.aborted) return 0
126
+ return Math.max(0, this.monotonicDeadline - this.clock.monotonic())
127
+ }
128
+
129
+ child(reserveMs: number): RequestBudget {
130
+ assertFiniteNonNegative("reserveMs", reserveMs)
131
+ return new MonotonicRequestBudget(
132
+ Math.max(1, Math.floor(this.deadline - reserveMs)),
133
+ this.signal,
134
+ this.monotonicDeadline - reserveMs,
135
+ this.clock,
136
+ )
137
+ }
138
+ }
139
+
140
+ class UnboundedRequestBudget implements RequestBudget {
141
+ readonly deadline = UNBOUNDED_DEADLINE
142
+
143
+ constructor(readonly signal: AbortSignal) {}
144
+
145
+ remaining(): number {
146
+ return this.signal.aborted ? 0 : Number.POSITIVE_INFINITY
147
+ }
148
+
149
+ child(reserveMs: number): RequestBudget {
150
+ assertFiniteNonNegative("reserveMs", reserveMs)
151
+ return this
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Create a budget from an admitted absolute deadline. Wall time is sampled once; every subsequent
157
+ * `remaining()` call is monotonic. This function does not arm a timer—the owner of `signal` does.
158
+ */
159
+ export function createRequestBudget(options: CreateRequestBudgetOptions): RequestBudget {
160
+ assertDeadline(options.deadline)
161
+ const clock = options.clock ?? systemClock
162
+ const wallAtStart = clock.wall()
163
+ const monotonicAtStart = clock.monotonic()
164
+ const admittedRemaining = Math.max(0, options.deadline - wallAtStart)
165
+ return new MonotonicRequestBudget(
166
+ options.deadline,
167
+ options.signal,
168
+ monotonicAtStart + admittedRemaining,
169
+ clock,
170
+ )
171
+ }
172
+
173
+ /** Create a local no-deadline view. Outbound header propagation deliberately omits it. */
174
+ export function createUnboundedRequestBudget(signal: AbortSignal): RequestBudget {
175
+ return new UnboundedRequestBudget(signal)
176
+ }
177
+
178
+ /** Parse the canonical deadline header without trusting or clamping it. */
179
+ export function parseDeadlineHeader(headers: Headers): DeadlineHeaderResult {
180
+ const raw = headers.get(NIFRA_DEADLINE_HEADER)
181
+ if (raw === null) return MISSING_DEADLINE
182
+ // A comma means multiple values were folded together. Ambiguous deadlines fail closed.
183
+ if (!/^[1-9]\d*$/.test(raw)) return MALFORMED_DEADLINE
184
+ const deadline = Number(raw)
185
+ if (!Number.isSafeInteger(deadline)) return MALFORMED_DEADLINE
186
+ return { ok: true, deadline }
187
+ }
188
+
189
+ /**
190
+ * Validate and clamp an inbound absolute deadline. This is pure admission mechanics: callers supply
191
+ * local policy, then own the timer that drives their existing cancellation signal.
192
+ */
193
+ export function admitDeadline(
194
+ headers: Headers,
195
+ options: DeadlineAdmissionOptions = {},
196
+ ): DeadlineAdmission {
197
+ const localTimeoutMs = options.localTimeoutMs ?? 0
198
+ const maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
199
+ assertFiniteNonNegative("localTimeoutMs", localTimeoutMs)
200
+ if (!Number.isFinite(maxInboundDeadlineMs) || maxInboundDeadlineMs <= 0) {
201
+ throw new RangeError(
202
+ "@nifrajs/core/budget: maxInboundDeadlineMs must be a finite positive number",
203
+ )
204
+ }
205
+
206
+ const parsed = parseDeadlineHeader(headers)
207
+ if (!parsed.ok) {
208
+ if (parsed.reason === "malformed") {
209
+ return { ok: false, status: 400, reason: "malformed_deadline" }
210
+ }
211
+ if (localTimeoutMs <= 0) return UNBOUNDED_ADMISSION
212
+ const now = options.wallNow?.() ?? Date.now()
213
+ return {
214
+ ok: true,
215
+ inherited: false,
216
+ timeoutMs: localTimeoutMs,
217
+ deadline: Math.floor(now + localTimeoutMs),
218
+ }
219
+ }
220
+
221
+ const now = options.wallNow?.() ?? Date.now()
222
+ const clientRemaining = parsed.deadline - now
223
+ if (clientRemaining <= 0) {
224
+ return { ok: false, status: 408, reason: "deadline_exceeded" }
225
+ }
226
+ const localCap =
227
+ localTimeoutMs > 0 ? Math.min(localTimeoutMs, maxInboundDeadlineMs) : maxInboundDeadlineMs
228
+ const timeoutMs = Math.max(1, Math.min(clientRemaining, localCap))
229
+ return {
230
+ ok: true,
231
+ inherited: true,
232
+ timeoutMs,
233
+ deadline: Math.floor(Math.min(parsed.deadline, now + localCap)),
234
+ }
235
+ }
236
+
237
+ /** Add this budget's absolute deadline to an outbound request. */
238
+ export function withDeadlineHeader(
239
+ input: DeadlineHeadersInit,
240
+ budget: RequestBudget,
241
+ reserveMs = 0,
242
+ ): Headers {
243
+ assertFiniteNonNegative("reserveMs", reserveMs)
244
+ const headers = new Headers(input)
245
+ if (!Number.isFinite(budget.remaining())) return headers
246
+ const deadline = Math.max(1, Math.floor(budget.deadline - reserveMs))
247
+ headers.set(NIFRA_DEADLINE_HEADER, String(deadline))
248
+ return headers
249
+ }
250
+
251
+ /** Fail before starting work that cannot fit inside the remaining time. */
252
+ export function assertBudgetRemaining(budget: RequestBudget, requiredMs = 0): void {
253
+ assertFiniteNonNegative("requiredMs", requiredMs)
254
+ const remaining = budget.remaining()
255
+ if (remaining <= requiredMs) throw new DeadlineExceededError(remaining, requiredMs)
256
+ }
257
+
258
+ /** True only when a new attempt plus a caller-owned reserve can still fit. */
259
+ export function canAttempt(
260
+ budget: RequestBudget,
261
+ estimatedAttemptMs: number,
262
+ reserveMs = 0,
263
+ ): boolean {
264
+ assertFiniteNonNegative("estimatedAttemptMs", estimatedAttemptMs)
265
+ assertFiniteNonNegative("reserveMs", reserveMs)
266
+ return budget.remaining() > estimatedAttemptMs + reserveMs
267
+ }
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Route effect/capability assurance. Declarations are reflected at build/CI time; runtime beacons
3
+ * are a fail-closed defence at owned effect seams. Static provenance remains the security anchor.
4
+ */
5
+
6
+ import type { DataClassification } from "./classification.ts"
7
+ import {
8
+ CAPABILITY_GUARD,
9
+ type CapabilityGuard,
10
+ type CapabilityUseEvent,
11
+ validCapabilityId,
12
+ } from "./internal/capability-runtime.ts"
13
+ import { NIFRA_ASSURANCE_IDS } from "./internal/route-assurance.ts"
14
+ import { reflectRoutes } from "./reflection.ts"
15
+
16
+ export type { CapabilityUseEvent }
17
+
18
+ export type CapabilityZone = "domain" | "operational"
19
+ export type CapabilityAccess = "read" | "write"
20
+ export type CapabilityIdempotency = "none" | "request" | "durable"
21
+
22
+ export interface CapabilityDefinition {
23
+ readonly id: string
24
+ /** Domain state is subject to HTTP method semantics; operational writes (logs/metrics) are not. */
25
+ readonly zone: CapabilityZone
26
+ readonly access: CapabilityAccess
27
+ /** Evidence required when this capability may execute. Default `none`. */
28
+ readonly idempotency?: CapabilityIdempotency
29
+ }
30
+
31
+ export interface CapabilityImportRule {
32
+ /** Exact module specifier, or a trailing `/*` prefix rule. */
33
+ readonly specifier: string
34
+ readonly capabilities: readonly string[]
35
+ }
36
+
37
+ export interface ForbiddenCapabilityImport {
38
+ /** Exact module specifier, or a trailing `/*` prefix rule. */
39
+ readonly specifier: string
40
+ readonly reason: string
41
+ }
42
+
43
+ export interface CapabilityRouteSelector {
44
+ readonly methods?: readonly string[]
45
+ readonly paths?: readonly string[]
46
+ }
47
+
48
+ export interface CapabilityRouteModule {
49
+ readonly match: CapabilityRouteSelector
50
+ /** Project-relative modules that implement the selected routes. */
51
+ readonly modules: readonly string[]
52
+ }
53
+
54
+ export interface CapabilityProvenancePolicy {
55
+ readonly imports: readonly CapabilityImportRule[]
56
+ readonly forbiddenImports: readonly ForbiddenCapabilityImport[]
57
+ /** Explicit associations for contract/generated routes that source scanning cannot locate. */
58
+ readonly routeModules?: readonly CapabilityRouteModule[]
59
+ }
60
+
61
+ export interface CapabilityPolicy {
62
+ readonly definitions: readonly CapabilityDefinition[]
63
+ /** Required: capability assurance without a static provenance firewall is incomplete. */
64
+ readonly provenance: CapabilityProvenancePolicy
65
+ /** Default `capabilities.lock.json`. */
66
+ readonly lockfile?: string
67
+ }
68
+
69
+ export type CapabilityEvidenceKind = "static" | "runtime"
70
+
71
+ /** Token-only effect evidence. `source` is an adapter/module id, never request or business data. */
72
+ export interface CapabilityEvidence {
73
+ readonly id: string
74
+ readonly kind: CapabilityEvidenceKind
75
+ readonly source: string
76
+ }
77
+
78
+ export interface RouteCapabilityEvidence {
79
+ readonly method: string
80
+ readonly path: string
81
+ /** True only when the route's reachable module graph was actually scanned. */
82
+ readonly covered: boolean
83
+ readonly evidence: readonly CapabilityEvidence[]
84
+ }
85
+
86
+ export interface CapabilityEvidenceSet {
87
+ readonly routes: readonly RouteCapabilityEvidence[]
88
+ }
89
+
90
+ export type CapabilityFindingCode =
91
+ | "unknown-capability"
92
+ | "provenance-uncovered"
93
+ | "undeclared-capability-evidence"
94
+ | "safe-method-domain-write"
95
+ | "missing-request-idempotency"
96
+ | "missing-durable-idempotency"
97
+ | "forbidden-effect-import"
98
+
99
+ export interface CapabilityFinding {
100
+ readonly code: CapabilityFindingCode
101
+ readonly method: string
102
+ readonly path: string
103
+ readonly capability?: string
104
+ readonly message: string
105
+ }
106
+
107
+ export interface AssuredCapabilityRoute {
108
+ readonly method: string
109
+ readonly path: string
110
+ readonly declared: readonly string[]
111
+ readonly evidence: readonly CapabilityEvidence[]
112
+ /** Declared capabilities without static/runtime evidence. Informational, never treated as proof. */
113
+ readonly unproven: readonly string[]
114
+ readonly covered: boolean
115
+ /** Highest data-sensitivity the response carries, when the route declares it. */
116
+ readonly classification?: DataClassification
117
+ }
118
+
119
+ export interface CapabilityAssuranceReport {
120
+ readonly ok: boolean
121
+ readonly routes: readonly AssuredCapabilityRoute[]
122
+ readonly findings: readonly CapabilityFinding[]
123
+ }
124
+
125
+ export interface CapabilitySnapshotRoute {
126
+ readonly method: string
127
+ readonly path: string
128
+ readonly declared: readonly string[]
129
+ readonly evidenced: readonly string[]
130
+ readonly unproven: readonly string[]
131
+ /** Recorded so a route that starts returning `pii`/`secret` flips the lockfile and forces a review. */
132
+ readonly classification?: DataClassification
133
+ }
134
+
135
+ export interface CapabilitySnapshot {
136
+ readonly nifraCapabilities: 1
137
+ readonly routes: readonly CapabilitySnapshotRoute[]
138
+ }
139
+
140
+ const SAFE_METHODS = new Set(["GET", "HEAD"])
141
+
142
+ export { validCapabilityId }
143
+
144
+ function normalizedIds(values: readonly string[], label: string): readonly string[] {
145
+ if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`)
146
+ const out: string[] = []
147
+ const seen = new Set<string>()
148
+ for (const value of values) {
149
+ if (typeof value !== "string" || !validCapabilityId(value)) {
150
+ throw new Error(`capability assurance: invalid ${label} id ${JSON.stringify(value)}`)
151
+ }
152
+ if (!seen.has(value)) {
153
+ seen.add(value)
154
+ out.push(value)
155
+ }
156
+ }
157
+ return Object.freeze(out)
158
+ }
159
+
160
+ function validSpecifier(value: string): boolean {
161
+ return value.trim() === value && value !== "" && !hasControlCharacter(value)
162
+ }
163
+
164
+ function hasControlCharacter(value: string): boolean {
165
+ for (let index = 0; index < value.length; index++) {
166
+ const code = value.charCodeAt(index)
167
+ if (code <= 31 || code === 127) return true
168
+ }
169
+ return false
170
+ }
171
+
172
+ /** Validate and freeze a capability/provenance policy. */
173
+ export function defineCapabilityPolicy(policy: CapabilityPolicy): CapabilityPolicy {
174
+ if (policy.provenance === undefined || policy.provenance === null) {
175
+ throw new Error("capability assurance: provenance policy is required")
176
+ }
177
+ const ids = new Set<string>()
178
+ const definitions = policy.definitions.map((definition): CapabilityDefinition => {
179
+ if (!validCapabilityId(definition.id)) {
180
+ throw new Error(
181
+ `capability assurance: invalid capability id ${JSON.stringify(definition.id)}`,
182
+ )
183
+ }
184
+ if (ids.has(definition.id)) {
185
+ throw new Error(`capability assurance: duplicate capability ${JSON.stringify(definition.id)}`)
186
+ }
187
+ ids.add(definition.id)
188
+ if (definition.zone !== "domain" && definition.zone !== "operational") {
189
+ throw new Error(`capability assurance: invalid zone for ${definition.id}`)
190
+ }
191
+ if (definition.access !== "read" && definition.access !== "write") {
192
+ throw new Error(`capability assurance: invalid access for ${definition.id}`)
193
+ }
194
+ const idempotency = definition.idempotency ?? "none"
195
+ if (idempotency !== "none" && idempotency !== "request" && idempotency !== "durable") {
196
+ throw new Error(`capability assurance: invalid idempotency for ${definition.id}`)
197
+ }
198
+ if (definition.access === "read" && idempotency !== "none") {
199
+ throw new Error(
200
+ `capability assurance: read capability ${definition.id} cannot require idempotency`,
201
+ )
202
+ }
203
+ return Object.freeze({ ...definition, idempotency })
204
+ })
205
+ const imports = policy.provenance.imports.map((rule): CapabilityImportRule => {
206
+ if (!validSpecifier(rule.specifier))
207
+ throw new Error("capability assurance: invalid import specifier")
208
+ const capabilities = normalizedIds(rule.capabilities, "import capability")
209
+ for (const id of capabilities) {
210
+ if (!ids.has(id)) throw new Error(`capability assurance: import references unknown ${id}`)
211
+ }
212
+ return Object.freeze({ specifier: rule.specifier, capabilities })
213
+ })
214
+ const forbiddenImports = policy.provenance.forbiddenImports.map(
215
+ (rule): ForbiddenCapabilityImport => {
216
+ if (!validSpecifier(rule.specifier) || rule.reason.trim() === "") {
217
+ throw new Error("capability assurance: forbidden import needs a specifier and reason")
218
+ }
219
+ return Object.freeze({ specifier: rule.specifier, reason: rule.reason.trim() })
220
+ },
221
+ )
222
+ const routeModules = policy.provenance.routeModules?.map((rule): CapabilityRouteModule => {
223
+ if (!Array.isArray(rule.modules) || rule.modules.length === 0) {
224
+ throw new Error("capability assurance: routeModules entry needs at least one module")
225
+ }
226
+ const modules = rule.modules.map((module) => {
227
+ if (!validSpecifier(module)) throw new Error("capability assurance: invalid route module")
228
+ return module.replace(/^\.\//, "")
229
+ })
230
+ return Object.freeze({
231
+ match: Object.freeze({
232
+ ...(rule.match.methods !== undefined
233
+ ? { methods: Object.freeze(rule.match.methods.map((method) => method.toUpperCase())) }
234
+ : {}),
235
+ ...(rule.match.paths !== undefined ? { paths: Object.freeze([...rule.match.paths]) } : {}),
236
+ }),
237
+ modules: Object.freeze(modules),
238
+ })
239
+ })
240
+ return Object.freeze({
241
+ definitions: Object.freeze(definitions),
242
+ provenance: Object.freeze({
243
+ imports: Object.freeze(imports),
244
+ forbiddenImports: Object.freeze(forbiddenImports),
245
+ ...(routeModules !== undefined ? { routeModules: Object.freeze(routeModules) } : {}),
246
+ }),
247
+ lockfile: policy.lockfile ?? "capabilities.lock.json",
248
+ })
249
+ }
250
+
251
+ function evidenceSourceValid(source: string): boolean {
252
+ return source.length > 0 && source.length <= 256 && !hasControlCharacter(source)
253
+ }
254
+
255
+ function routeKey(method: string, path: string): string {
256
+ return `${method.toUpperCase()}\n${path}`
257
+ }
258
+
259
+ /** Compare declared route capabilities against coverage-qualified static/runtime evidence. */
260
+ export function evaluateCapabilityAssurance(
261
+ source: unknown,
262
+ policyInput: CapabilityPolicy,
263
+ evidenceSet: CapabilityEvidenceSet,
264
+ ): CapabilityAssuranceReport {
265
+ const policy = defineCapabilityPolicy(policyInput)
266
+ const definitions = new Map(policy.definitions.map((definition) => [definition.id, definition]))
267
+ const evidenceByRoute = new Map(
268
+ evidenceSet.routes.map((route) => [routeKey(route.method, route.path), route]),
269
+ )
270
+ const findings: CapabilityFinding[] = []
271
+ const routes: AssuredCapabilityRoute[] = []
272
+
273
+ for (const route of reflectRoutes(source)) {
274
+ const declared = route.capabilities ?? []
275
+ const supplied = evidenceByRoute.get(routeKey(route.method, route.path))
276
+ const covered = supplied?.covered === true
277
+ const evidence: CapabilityEvidence[] = []
278
+ const evidenceIds = new Set<string>()
279
+ for (const item of supplied?.evidence ?? []) {
280
+ if (
281
+ !validCapabilityId(item.id) ||
282
+ (item.kind !== "static" && item.kind !== "runtime") ||
283
+ !evidenceSourceValid(item.source)
284
+ ) {
285
+ continue
286
+ }
287
+ const key = `${item.id}\n${item.kind}\n${item.source}`
288
+ if (
289
+ evidence.some(
290
+ (candidate) => `${candidate.id}\n${candidate.kind}\n${candidate.source}` === key,
291
+ )
292
+ )
293
+ continue
294
+ evidence.push(Object.freeze({ id: item.id, kind: item.kind, source: item.source }))
295
+ evidenceIds.add(item.id)
296
+ }
297
+
298
+ for (const id of declared) {
299
+ if (!definitions.has(id)) {
300
+ findings.push({
301
+ code: "unknown-capability",
302
+ method: route.method,
303
+ path: route.path,
304
+ capability: id,
305
+ message: `${route.method} ${route.path} declares unknown capability ${id}`,
306
+ })
307
+ }
308
+ }
309
+ if (!covered) {
310
+ findings.push({
311
+ code: "provenance-uncovered",
312
+ method: route.method,
313
+ path: route.path,
314
+ message: `${route.method} ${route.path} has no static provenance coverage`,
315
+ })
316
+ }
317
+ for (const id of evidenceIds) {
318
+ if (!declared.includes(id)) {
319
+ findings.push({
320
+ code: "undeclared-capability-evidence",
321
+ method: route.method,
322
+ path: route.path,
323
+ capability: id,
324
+ message: `${route.method} ${route.path} evidence exceeds its declaration: ${id}`,
325
+ })
326
+ }
327
+ }
328
+
329
+ const effective = new Set([...declared, ...evidenceIds])
330
+ const assuranceIds = new Set((route.assurance ?? []).map((item) => item.id))
331
+ for (const id of effective) {
332
+ const definition = definitions.get(id)
333
+ if (definition === undefined) continue
334
+ if (
335
+ SAFE_METHODS.has(route.method) &&
336
+ definition.zone === "domain" &&
337
+ definition.access === "write"
338
+ ) {
339
+ findings.push({
340
+ code: "safe-method-domain-write",
341
+ method: route.method,
342
+ path: route.path,
343
+ capability: id,
344
+ message: `${route.method} ${route.path} cannot carry domain write capability ${id}`,
345
+ })
346
+ }
347
+ if (
348
+ definition.idempotency === "request" &&
349
+ !assuranceIds.has(NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY)
350
+ ) {
351
+ findings.push({
352
+ code: "missing-request-idempotency",
353
+ method: route.method,
354
+ path: route.path,
355
+ capability: id,
356
+ message: `${route.method} ${route.path} capability ${id} requires request idempotency evidence`,
357
+ })
358
+ }
359
+ if (
360
+ definition.idempotency === "durable" &&
361
+ !assuranceIds.has(NIFRA_ASSURANCE_IDS.DURABLE_COMMAND)
362
+ ) {
363
+ findings.push({
364
+ code: "missing-durable-idempotency",
365
+ method: route.method,
366
+ path: route.path,
367
+ capability: id,
368
+ message: `${route.method} ${route.path} capability ${id} requires durable command/provider-key evidence`,
369
+ })
370
+ }
371
+ }
372
+ routes.push(
373
+ Object.freeze({
374
+ method: route.method,
375
+ path: route.path,
376
+ declared: Object.freeze([...declared]),
377
+ evidence: Object.freeze(evidence),
378
+ unproven: Object.freeze(declared.filter((id) => !evidenceIds.has(id))),
379
+ covered,
380
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
381
+ }),
382
+ )
383
+ }
384
+ return Object.freeze({
385
+ ok: findings.length === 0,
386
+ routes: Object.freeze(routes),
387
+ findings: Object.freeze(findings),
388
+ })
389
+ }
390
+
391
+ /**
392
+ * Runtime effect beacon for owned adapters. It fails closed when the route omitted the capability or
393
+ * when no route guard is present. Static provenance is still required: code can bypass a beacon.
394
+ */
395
+ export function useCapability(context: object, capability: string): void {
396
+ if (!validCapabilityId(capability)) {
397
+ throw new Error(
398
+ `capability assurance: invalid runtime capability ${JSON.stringify(capability)}`,
399
+ )
400
+ }
401
+ const guard = (context as { readonly [CAPABILITY_GUARD]?: CapabilityGuard })[CAPABILITY_GUARD]
402
+ if (guard === undefined) {
403
+ throw new Error(
404
+ `capability assurance: ${capability} used on a route with no capability declaration`,
405
+ )
406
+ }
407
+ if (!guard.allowed.includes(capability)) {
408
+ throw new Error(
409
+ `capability assurance: ${capability} is not declared for ${guard.method} ${guard.path}`,
410
+ )
411
+ }
412
+ guard.onUse?.({ capability, method: guard.method, path: guard.path })
413
+ }
414
+
415
+ /** Deterministic, PII-free lockfile material. */
416
+ export function snapshotCapabilities(report: CapabilityAssuranceReport): CapabilitySnapshot {
417
+ const routes = report.routes
418
+ .map(
419
+ (route): CapabilitySnapshotRoute => ({
420
+ method: route.method,
421
+ path: route.path,
422
+ declared: Object.freeze([...route.declared].sort()),
423
+ evidenced: Object.freeze([...new Set(route.evidence.map((item) => item.id))].sort()),
424
+ unproven: Object.freeze([...route.unproven].sort()),
425
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
426
+ }),
427
+ )
428
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
429
+ return Object.freeze({ nifraCapabilities: 1, routes: Object.freeze(routes) })
430
+ }