@nifrajs/core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/assurance.d.ts +13 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +20 -0
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +30 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +32 -6
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/classification.d.ts +20 -0
  10. package/dist/classification.d.ts.map +1 -1
  11. package/dist/classification.js +120 -0
  12. package/dist/classification.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/idempotency.d.ts +59 -12
  17. package/dist/idempotency.d.ts.map +1 -1
  18. package/dist/idempotency.js +191 -20
  19. package/dist/idempotency.js.map +1 -1
  20. package/dist/index.d.ts +5 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/invariants.d.ts +66 -0
  25. package/dist/invariants.d.ts.map +1 -0
  26. package/dist/invariants.js +483 -0
  27. package/dist/invariants.js.map +1 -0
  28. package/dist/ledger.d.ts +154 -0
  29. package/dist/ledger.d.ts.map +1 -0
  30. package/dist/ledger.js +271 -0
  31. package/dist/ledger.js.map +1 -0
  32. package/dist/manifest.d.ts +81 -0
  33. package/dist/manifest.d.ts.map +1 -0
  34. package/dist/manifest.js +394 -0
  35. package/dist/manifest.js.map +1 -0
  36. package/dist/mount.d.ts +18 -0
  37. package/dist/mount.d.ts.map +1 -0
  38. package/dist/mount.js +11 -0
  39. package/dist/mount.js.map +1 -0
  40. package/dist/reflection.d.ts +3 -3
  41. package/dist/reflection.d.ts.map +1 -1
  42. package/dist/reflection.js +5 -5
  43. package/dist/reflection.js.map +1 -1
  44. package/dist/server/context.d.ts +17 -1
  45. package/dist/server/context.d.ts.map +1 -1
  46. package/dist/server/server.d.ts +23 -2
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +179 -26
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +26 -1
  51. package/src/assurance.ts +33 -0
  52. package/src/capabilities.ts +60 -4
  53. package/src/classification.ts +145 -0
  54. package/src/errors.ts +1 -0
  55. package/src/idempotency.ts +245 -30
  56. package/src/index.ts +5 -1
  57. package/src/invariants.ts +600 -0
  58. package/src/ledger.ts +430 -0
  59. package/src/manifest.ts +543 -0
  60. package/src/mount.ts +24 -0
  61. package/src/reflection.ts +11 -7
  62. package/src/server/context.ts +20 -1
  63. package/src/server/server.ts +254 -30
package/src/ledger.ts ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Effect ledger: a per-request, append-only, ordered record of side-effect intents and outcomes.
3
+ *
4
+ * Every owned effect seam (a repository write, an external call, an email send) records one entry via
5
+ * the `useCapability` beacon. The entry type is deliberately **token-only**: capability ids, phase,
6
+ * an adapter/resource token, dimensionless cost counters, an optional keyed digest, and an error code.
7
+ * There is no free-form payload field — request bodies, rows, and values cannot enter the ledger, so
8
+ * redaction holds by construction rather than by filtering. The sealed ledger carries the **route
9
+ * pattern** (`/users/:id`), never the concrete URL, so path parameters cannot leak either.
10
+ *
11
+ * The ledger is the substrate that audit, replay, reconciliation, and cost accounting read.
12
+ * This module ships the primitive: the entry contract, a bounded per-request ledger with an optional
13
+ * tamper-evident hash chain, a sink seam, an in-memory sink, and a keyed digest helper. Durable,
14
+ * encrypted, or retention-managed sinks implement the same {@link LedgerSink} seam externally.
15
+ */
16
+
17
+ import { validCapabilityId } from "./internal/capability-runtime.ts"
18
+
19
+ /** Lifecycle phase of one effect. `intent` precedes execution; the rest describe its outcome. */
20
+ export type EffectPhase = "intent" | "committed" | "failed" | "compensated"
21
+
22
+ const EFFECT_PHASES: ReadonlySet<string> = new Set(["intent", "committed", "failed", "compensated"])
23
+
24
+ /**
25
+ * Dimensionless resource counters (`{ ms: 12, calls: 1, bytes: 512 }`). Counters carry *how much
26
+ * resource* an effect consumed; mapping counters to money/pricing is deliberately out of scope here.
27
+ */
28
+ export type EffectCost = Readonly<Record<string, number>>
29
+
30
+ /** Caller-supplied fields for one entry. Everything else (`seq`, `at`) is assigned by the ledger. */
31
+ export interface EffectEntryInput {
32
+ /** Capability token (`db.write`, `payments.charge`). Must be a valid capability id. */
33
+ readonly capability: string
34
+ /** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
35
+ readonly phase?: EffectPhase
36
+ /** Adapter/resource token (`repo:orders`, `provider:payments`). A token — never a value or a row. */
37
+ readonly target?: string
38
+ /** Dimensionless counters; see {@link EffectCost}. At most {@link MAX_COST_AXES} axes. */
39
+ readonly cost?: EffectCost
40
+ /** Optional keyed-HMAC digest of the effect payload (see {@link computeEffectDigest}); hex, 64 chars. */
41
+ readonly digest?: string
42
+ /** Outcome error as a bounded token code — never a message, never a stack. */
43
+ readonly error?: { readonly code: string }
44
+ }
45
+
46
+ /** One recorded effect. Frozen; token-only by construction (no payload field exists). */
47
+ export interface EffectEntry {
48
+ /** Monotonic position within the request (0-based). The replay/simulation order. */
49
+ readonly seq: number
50
+ /** Milliseconds from the ledger's (injectable) clock at append time. */
51
+ readonly at: number
52
+ readonly capability: string
53
+ readonly phase: EffectPhase
54
+ readonly target?: string
55
+ readonly cost?: EffectCost
56
+ readonly digest?: string
57
+ readonly error?: { readonly code: string }
58
+ }
59
+
60
+ /** Tamper-evidence over the route identity, declarations, and sealed entries. */
61
+ export interface EffectChain {
62
+ /** `hashes[i]` = SHA-256(hex) over (`hashes[i-1]` + canonical entry `i`). */
63
+ readonly hashes: readonly string[]
64
+ /** Last entry hash, or the route-header hash for an empty ledger. Anchor/sign this externally. */
65
+ readonly head: string
66
+ }
67
+
68
+ /** The immutable result of sealing a request's ledger. Token-only; safe to hand to any sink. */
69
+ export interface SealedEffectLedger {
70
+ readonly method: string
71
+ /** The registered route pattern (`/users/:id`) — never the concrete request URL. */
72
+ readonly path: string
73
+ readonly entries: readonly EffectEntry[]
74
+ /** The route's declared capability tokens — the runtime-enforcement view: recorded ⊆ declared is
75
+ * guaranteed by the beacon, and `declared` minus the recorded ids is the unused declaration set. */
76
+ readonly declared: readonly string[]
77
+ /** Present when the ledger was created with `chain: true`. */
78
+ readonly chain?: EffectChain
79
+ }
80
+
81
+ /**
82
+ * Receives each sealed ledger once per request (only when it has entries). Implementations must not
83
+ * assume a payload: the ledger is token-only. A durable/tenant-scoped sink lives behind this seam.
84
+ */
85
+ export type LedgerSink = (ledger: SealedEffectLedger) => void | Promise<void>
86
+
87
+ /** Thrown by `append` when the per-request entry bound is exceeded. Fails the request closed. */
88
+ export class EffectLedgerOverflowError extends Error {
89
+ constructor(public readonly maxEntries: number) {
90
+ super(`effect ledger overflow: more than ${maxEntries} entries in one request`)
91
+ this.name = "EffectLedgerOverflowError"
92
+ }
93
+ }
94
+
95
+ /** Thrown by `append` after `seal()` — e.g. an effect attempted while streaming a response body. */
96
+ export class EffectLedgerSealedError extends Error {
97
+ constructor() {
98
+ super("effect ledger is sealed: effects cannot be recorded after the response settles")
99
+ this.name = "EffectLedgerSealedError"
100
+ }
101
+ }
102
+
103
+ /** Per-request ledger. `append` is synchronous (hot-path safe); `seal` is idempotent and async. */
104
+ export interface RequestLedger {
105
+ /** Validate, freeze, and record one entry. Throws on invalid input, overflow, or after seal. */
106
+ append(input: EffectEntryInput): EffectEntry
107
+ /** Entries recorded so far (frozen snapshot view). */
108
+ entries(): readonly EffectEntry[]
109
+ readonly size: number
110
+ readonly sealed: boolean
111
+ /** Finalize the ledger (computing the chain when enabled). Idempotent — always the same result. */
112
+ seal(): Promise<SealedEffectLedger>
113
+ }
114
+
115
+ export interface CreateRequestLedgerOptions {
116
+ /** HTTP method of the matched route. */
117
+ readonly method: string
118
+ /** The registered route pattern — callers must never pass the concrete request URL. */
119
+ readonly path: string
120
+ /** The route's declared capability tokens, surfaced verbatim on the sealed ledger. Default `[]`. */
121
+ readonly declared?: readonly string[]
122
+ /** Entry bound; exceeding it throws {@link EffectLedgerOverflowError}. Default {@link DEFAULT_MAX_ENTRIES}. */
123
+ readonly maxEntries?: number
124
+ /** Compute the tamper-evident hash chain at seal. Default false. */
125
+ readonly chain?: boolean
126
+ /** Injectable monotonic clock (ms) for deterministic tests. Default `performance.now`. */
127
+ readonly clock?: () => number
128
+ }
129
+
130
+ /** Per-request entry bound. Generous for real handlers, small enough to stop a runaway loop. */
131
+ export const DEFAULT_MAX_ENTRIES = 1_000
132
+
133
+ /** Most cost axes one entry may carry. */
134
+ export const MAX_COST_AXES = 8
135
+
136
+ const TARGET_MAX_LENGTH = 128
137
+ const ERROR_CODE = /^[a-z][a-z0-9_.-]{0,63}$/
138
+ const DIGEST_HEX = /^[0-9a-f]{64}$/
139
+
140
+ function printableToken(value: string, maxLength: number): boolean {
141
+ if (value.length === 0 || value.length > maxLength) return false
142
+ for (let index = 0; index < value.length; index++) {
143
+ const code = value.charCodeAt(index)
144
+ if (code < 33 || code > 126) return false
145
+ }
146
+ return true
147
+ }
148
+
149
+ function validateCost(cost: EffectCost): EffectCost {
150
+ const keys = Object.keys(cost)
151
+ if (keys.length > MAX_COST_AXES) {
152
+ throw new TypeError(`effect ledger: cost carries more than ${MAX_COST_AXES} axes`)
153
+ }
154
+ for (const key of keys) {
155
+ if (!validCapabilityId(key)) {
156
+ throw new TypeError(`effect ledger: invalid cost axis ${JSON.stringify(key)}`)
157
+ }
158
+ const value = cost[key]
159
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
160
+ throw new TypeError(`effect ledger: cost axis ${key} must be a finite non-negative number`)
161
+ }
162
+ }
163
+ return Object.freeze({ ...cost })
164
+ }
165
+
166
+ const defaultClock = (): number => performance.now()
167
+
168
+ class BoundedRequestLedger implements RequestLedger {
169
+ private readonly items: EffectEntry[] = []
170
+ private readonly method: string
171
+ private readonly path: string
172
+ private readonly declared: readonly string[]
173
+ private readonly maxEntries: number
174
+ private readonly chain: boolean
175
+ private readonly clock: () => number
176
+ private sealResult: Promise<SealedEffectLedger> | undefined
177
+
178
+ constructor(options: CreateRequestLedgerOptions) {
179
+ if (!printableToken(options.method, 16) || !options.path.startsWith("/")) {
180
+ throw new TypeError("effect ledger: method/path must be a route pattern")
181
+ }
182
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
183
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
184
+ throw new TypeError("effect ledger: maxEntries must be a positive safe integer")
185
+ }
186
+ const declared = options.declared ?? []
187
+ for (const id of declared) {
188
+ if (!validCapabilityId(id)) {
189
+ throw new TypeError(`effect ledger: invalid declared capability ${JSON.stringify(id)}`)
190
+ }
191
+ }
192
+ this.method = options.method.toUpperCase()
193
+ this.path = options.path
194
+ this.declared = Object.freeze([...declared])
195
+ this.maxEntries = maxEntries
196
+ this.chain = options.chain ?? false
197
+ this.clock = options.clock ?? defaultClock
198
+ }
199
+
200
+ get size(): number {
201
+ return this.items.length
202
+ }
203
+
204
+ get sealed(): boolean {
205
+ return this.sealResult !== undefined
206
+ }
207
+
208
+ append(input: EffectEntryInput): EffectEntry {
209
+ if (this.sealResult !== undefined) throw new EffectLedgerSealedError()
210
+ if (this.items.length >= this.maxEntries) throw new EffectLedgerOverflowError(this.maxEntries)
211
+ if (!validCapabilityId(input.capability)) {
212
+ throw new TypeError(`effect ledger: invalid capability ${JSON.stringify(input.capability)}`)
213
+ }
214
+ const phase = input.phase ?? "intent"
215
+ if (!EFFECT_PHASES.has(phase)) {
216
+ throw new TypeError(`effect ledger: invalid phase ${JSON.stringify(phase)}`)
217
+ }
218
+ if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
219
+ throw new TypeError("effect ledger: target must be a bounded printable token")
220
+ }
221
+ if (input.digest !== undefined && !DIGEST_HEX.test(input.digest)) {
222
+ throw new TypeError("effect ledger: digest must be 64 lowercase hex characters")
223
+ }
224
+ if (input.error !== undefined && !ERROR_CODE.test(input.error.code)) {
225
+ throw new TypeError("effect ledger: error code must be a bounded lowercase token")
226
+ }
227
+ // Only the known token fields are copied — a stray `payload`-like property never survives append.
228
+ const at = this.clock()
229
+ if (!Number.isFinite(at) || at < 0) {
230
+ throw new TypeError("effect ledger: clock must return a finite non-negative number")
231
+ }
232
+ const entry: EffectEntry = Object.freeze({
233
+ seq: this.items.length,
234
+ at,
235
+ capability: input.capability,
236
+ phase,
237
+ ...(input.target !== undefined ? { target: input.target } : {}),
238
+ ...(input.cost !== undefined ? { cost: validateCost(input.cost) } : {}),
239
+ ...(input.digest !== undefined ? { digest: input.digest } : {}),
240
+ ...(input.error !== undefined ? { error: Object.freeze({ code: input.error.code }) } : {}),
241
+ })
242
+ this.items.push(entry)
243
+ return entry
244
+ }
245
+
246
+ entries(): readonly EffectEntry[] {
247
+ return Object.freeze([...this.items])
248
+ }
249
+
250
+ seal(): Promise<SealedEffectLedger> {
251
+ this.sealResult ??= this.sealOnce()
252
+ return this.sealResult
253
+ }
254
+
255
+ private async sealOnce(): Promise<SealedEffectLedger> {
256
+ const entries = Object.freeze([...this.items])
257
+ const base = {
258
+ method: this.method,
259
+ path: this.path,
260
+ entries,
261
+ declared: this.declared,
262
+ }
263
+ if (!this.chain) return Object.freeze(base)
264
+ const hashes: string[] = []
265
+ // Seed the chain with the route identity and declared authority. Otherwise an anchored entry
266
+ // chain could be copied onto a different route/declaration without changing its head.
267
+ let previous = await sha256Hex(
268
+ JSON.stringify({ method: this.method, path: this.path, declared: [...this.declared].sort() }),
269
+ )
270
+ for (const entry of entries) {
271
+ previous = await sha256Hex(`${previous}${canonicalEntry(entry)}`)
272
+ hashes.push(previous)
273
+ }
274
+ return Object.freeze({
275
+ ...base,
276
+ chain: Object.freeze({ hashes: Object.freeze(hashes), head: previous }),
277
+ })
278
+ }
279
+ }
280
+
281
+ /** Create a bounded per-request ledger. The server wires one per capability-declaring route. */
282
+ export function createRequestLedger(options: CreateRequestLedgerOptions): RequestLedger {
283
+ return new BoundedRequestLedger(options)
284
+ }
285
+
286
+ /** Canonical, key-ordered serialization of one entry — the hash-chain input. */
287
+ function canonicalEntry(entry: EffectEntry): string {
288
+ const cost =
289
+ entry.cost === undefined
290
+ ? ""
291
+ : `,"cost":{${Object.keys(entry.cost)
292
+ .sort()
293
+ .map((key) => `${JSON.stringify(key)}:${JSON.stringify((entry.cost as EffectCost)[key])}`)
294
+ .join(",")}}`
295
+ return (
296
+ `{"seq":${entry.seq},"at":${JSON.stringify(entry.at)},"capability":${JSON.stringify(entry.capability)},"phase":${JSON.stringify(entry.phase)}` +
297
+ `${entry.target !== undefined ? `,"target":${JSON.stringify(entry.target)}` : ""}` +
298
+ cost +
299
+ `${entry.digest !== undefined ? `,"digest":${JSON.stringify(entry.digest)}` : ""}` +
300
+ `${entry.error !== undefined ? `,"error":${JSON.stringify(entry.error.code)}` : ""}}`
301
+ )
302
+ }
303
+
304
+ const encoder = new TextEncoder()
305
+
306
+ async function sha256Hex(value: string): Promise<string> {
307
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)))
308
+ let hex = ""
309
+ for (let index = 0; index < digest.length; index++) {
310
+ hex += (digest[index] as number).toString(16).padStart(2, "0")
311
+ }
312
+ return hex
313
+ }
314
+
315
+ export interface MemoryLedgerSinkOptions {
316
+ /** Retain at most this many sealed ledgers (oldest evicted first). Default 1000. */
317
+ readonly maxLedgers?: number
318
+ }
319
+
320
+ export interface MemoryLedgerSink {
321
+ readonly sink: LedgerSink
322
+ /** Sealed ledgers received so far, oldest first. */
323
+ readonly ledgers: readonly SealedEffectLedger[]
324
+ clear(): void
325
+ }
326
+
327
+ /** Bounded in-memory sink for tests and local development. Token-only, like every sink. */
328
+ export function createMemoryLedgerSink(options: MemoryLedgerSinkOptions = {}): MemoryLedgerSink {
329
+ const maxLedgers = options.maxLedgers ?? 1_000
330
+ if (!Number.isSafeInteger(maxLedgers) || maxLedgers < 1) {
331
+ throw new TypeError("effect ledger: maxLedgers must be a positive safe integer")
332
+ }
333
+ const ledgers: SealedEffectLedger[] = []
334
+ return {
335
+ sink: (ledger) => {
336
+ ledgers.push(ledger)
337
+ if (ledgers.length > maxLedgers) ledgers.shift()
338
+ },
339
+ get ledgers(): readonly SealedEffectLedger[] {
340
+ return Object.freeze([...ledgers])
341
+ },
342
+ clear: () => {
343
+ ledgers.length = 0
344
+ },
345
+ }
346
+ }
347
+
348
+ /** Minimum digest key material. A short key would make the keyed digest brute-forceable. */
349
+ export const MIN_DIGEST_KEY_BYTES = 16
350
+
351
+ /** Fresh random digest key (32 bytes). Per-process by default — persist one externally to correlate across restarts. */
352
+ export function randomEffectDigestKey(): Uint8Array {
353
+ const bytes = new Uint8Array(32)
354
+ crypto.getRandomValues(bytes)
355
+ return bytes
356
+ }
357
+
358
+ /**
359
+ * Keyed HMAC-SHA-256 digest (hex) of an effect payload, for replay/reconciliation matching without
360
+ * storing the payload. Keyed on purpose: a bare hash of low-entropy data (an email, a flag) is
361
+ * brute-forceable and would itself leak. Digest the **whole** effect payload, never a single field.
362
+ */
363
+ export async function computeEffectDigest(
364
+ key: Uint8Array | CryptoKey,
365
+ payload: Uint8Array,
366
+ ): Promise<string> {
367
+ let cryptoKey: CryptoKey
368
+ if (key instanceof Uint8Array) {
369
+ if (key.byteLength < MIN_DIGEST_KEY_BYTES) {
370
+ throw new TypeError(
371
+ `effect ledger: digest key must be at least ${MIN_DIGEST_KEY_BYTES} bytes`,
372
+ )
373
+ }
374
+ cryptoKey = await crypto.subtle.importKey(
375
+ "raw",
376
+ key as unknown as ArrayBuffer,
377
+ { name: "HMAC", hash: "SHA-256" },
378
+ false,
379
+ ["sign"],
380
+ )
381
+ } else {
382
+ const algorithm = key.algorithm as unknown as {
383
+ readonly name?: unknown
384
+ readonly hash?: { readonly name?: unknown }
385
+ readonly length?: unknown
386
+ }
387
+ if (
388
+ algorithm.name !== "HMAC" ||
389
+ algorithm.hash?.name !== "SHA-256" ||
390
+ typeof algorithm.length !== "number" ||
391
+ algorithm.length < MIN_DIGEST_KEY_BYTES * 8 ||
392
+ !key.usages.includes("sign")
393
+ ) {
394
+ throw new TypeError(
395
+ "effect ledger: CryptoKey must be an HMAC-SHA-256 signing key of at least 128 bits",
396
+ )
397
+ }
398
+ cryptoKey = key
399
+ }
400
+ const mac = new Uint8Array(
401
+ await crypto.subtle.sign("HMAC", cryptoKey, payload as unknown as ArrayBuffer),
402
+ )
403
+ let hex = ""
404
+ for (let index = 0; index < mac.length; index++) {
405
+ hex += (mac[index] as number).toString(16).padStart(2, "0")
406
+ }
407
+ return hex
408
+ }
409
+
410
+ const EFFECT_LEDGER = Symbol("nifra.effect.ledger")
411
+
412
+ /** Framework wiring: attach a per-request ledger to a handler context. Not for application code. */
413
+ export function attachEffectLedger(context: object, ledger: RequestLedger): void {
414
+ ;(context as Record<PropertyKey, unknown>)[EFFECT_LEDGER as unknown as string] = ledger
415
+ }
416
+
417
+ /** The request's effect ledger, when the server enabled one for this route. Read-only access. */
418
+ export function effectLedgerOf(context: object): RequestLedger | undefined {
419
+ return (context as { readonly [EFFECT_LEDGER]?: RequestLedger })[EFFECT_LEDGER]
420
+ }
421
+
422
+ /** Server-level effect ledger configuration (see `server({ effectLedger })`). */
423
+ export interface EffectLedgerOptions {
424
+ /** Receives each request's sealed ledger (only when it recorded entries). */
425
+ readonly sink: LedgerSink
426
+ /** Per-request entry bound. Default {@link DEFAULT_MAX_ENTRIES}. */
427
+ readonly maxEntries?: number
428
+ /** Compute the tamper-evident hash chain at seal. Default false. */
429
+ readonly chain?: boolean
430
+ }