@nifrajs/core 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assurance.d.ts +13 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +30 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +32 -6
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +20 -0
- package/dist/classification.d.ts.map +1 -1
- package/dist/classification.js +120 -0
- package/dist/classification.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/idempotency.d.ts +59 -12
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +191 -20
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/invariants.d.ts +66 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +483 -0
- package/dist/invariants.js.map +1 -0
- package/dist/ledger.d.ts +154 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +271 -0
- package/dist/ledger.js.map +1 -0
- package/dist/manifest.d.ts +81 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +394 -0
- package/dist/manifest.js.map +1 -0
- package/dist/mount.d.ts +18 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +11 -0
- package/dist/mount.js.map +1 -0
- package/dist/reflection.d.ts +3 -3
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +5 -5
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +17 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/server.d.ts +23 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +179 -26
- package/dist/server/server.js.map +1 -1
- package/package.json +26 -1
- package/src/assurance.ts +33 -0
- package/src/capabilities.ts +60 -4
- package/src/classification.ts +145 -0
- package/src/errors.ts +1 -0
- package/src/idempotency.ts +245 -30
- package/src/index.ts +5 -1
- package/src/invariants.ts +600 -0
- package/src/ledger.ts +430 -0
- package/src/manifest.ts +543 -0
- package/src/mount.ts +24 -0
- package/src/reflection.ts +11 -7
- package/src/server/context.ts +20 -1
- package/src/server/server.ts +254 -30
package/src/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
|
+
}
|