@verax-ai/proxy 0.1.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/approvals.d.ts +91 -0
- package/dist/approvals.js +282 -0
- package/dist/checkpoints.d.ts +3 -0
- package/dist/checkpoints.js +31 -0
- package/dist/disk.d.ts +6 -0
- package/dist/disk.js +13 -0
- package/dist/explain.d.ts +13 -0
- package/dist/explain.js +211 -0
- package/dist/hash.d.ts +4 -0
- package/dist/hash.js +10 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/inputs.d.ts +13 -0
- package/dist/inputs.js +49 -0
- package/dist/ledger.d.ts +130 -0
- package/dist/ledger.js +683 -0
- package/dist/policy.d.ts +37 -0
- package/dist/policy.js +265 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +533 -0
- package/dist/reconcile.d.ts +81 -0
- package/dist/reconcile.js +420 -0
- package/dist/spoken-reason.d.ts +1 -0
- package/dist/spoken-reason.js +9 -0
- package/dist/tenant.d.ts +12 -0
- package/dist/tenant.js +18 -0
- package/dist/types.d.ts +189 -0
- package/dist/types.js +1 -0
- package/package.json +37 -0
- package/policy/default.json +30 -0
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
/** I/O seam so append-cost tests can count rereads without mocking node:fs. */
|
|
5
|
+
export const ledgerFs = {
|
|
6
|
+
readFile,
|
|
7
|
+
readFileSync,
|
|
8
|
+
open,
|
|
9
|
+
};
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { canonical, decisionRecordHash } from "@cedulon/core";
|
|
12
|
+
import { coseToHex, signCoseSign1 } from "@cedulon/cose";
|
|
13
|
+
import { signEffectExtract, } from "@cedulon/effect-extract";
|
|
14
|
+
import { tenantKey } from "./tenant.js";
|
|
15
|
+
import { sha256Canonical } from "./hash.js";
|
|
16
|
+
const DEFAULT_WITNESS = "self";
|
|
17
|
+
/** One async tail so read-then-append cannot fork the chain. */
|
|
18
|
+
class SerialQueue {
|
|
19
|
+
tail = Promise.resolve();
|
|
20
|
+
enqueue(fn) {
|
|
21
|
+
const run = this.tail.then(fn, fn);
|
|
22
|
+
this.tail = run.then(() => undefined, () => undefined);
|
|
23
|
+
return run;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function lineOf(value) {
|
|
27
|
+
return `${canonical(value)}\n`;
|
|
28
|
+
}
|
|
29
|
+
function parseJsonl(text) {
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const line of text.split("\n")) {
|
|
32
|
+
if (line === "")
|
|
33
|
+
continue;
|
|
34
|
+
rows.push(JSON.parse(line));
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
async function readJsonl(path) {
|
|
39
|
+
try {
|
|
40
|
+
return parseJsonl(await ledgerFs.readFile(path, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === "ENOENT")
|
|
44
|
+
return [];
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function readJsonlSync(path) {
|
|
49
|
+
try {
|
|
50
|
+
return parseJsonl(ledgerFs.readFileSync(path, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.code === "ENOENT")
|
|
54
|
+
return [];
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Write then fsync so a crash cannot drop a committed line. */
|
|
59
|
+
export async function appendDurable(path, line) {
|
|
60
|
+
const fh = await ledgerFs.open(path, "a");
|
|
61
|
+
try {
|
|
62
|
+
await fh.write(line);
|
|
63
|
+
await fh.sync();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await fh.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function assertDirPrivate(dir) {
|
|
70
|
+
if (process.platform === "win32") {
|
|
71
|
+
// Stated, not a silent pass: Windows ACL is not measured here.
|
|
72
|
+
return "not checked on this platform";
|
|
73
|
+
}
|
|
74
|
+
const mode = statSync(dir).mode & 0o777;
|
|
75
|
+
if ((mode & 0o077) !== 0) {
|
|
76
|
+
throw new Error(`ledger-dir-not-private:${mode.toString(8)}`);
|
|
77
|
+
}
|
|
78
|
+
return "owner-only";
|
|
79
|
+
}
|
|
80
|
+
function ensureLedgerDir(dir) {
|
|
81
|
+
if (!existsSync(dir)) {
|
|
82
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
}
|
|
84
|
+
return assertDirPrivate(dir);
|
|
85
|
+
}
|
|
86
|
+
export function signEffectAttestation(row, witnessClass, resultHash, signer) {
|
|
87
|
+
return signedEffectFields(row, witnessClass, resultHash, signer);
|
|
88
|
+
}
|
|
89
|
+
function signedEffectFields(row, witnessClass, resultHash, signer) {
|
|
90
|
+
if (!signer)
|
|
91
|
+
return {};
|
|
92
|
+
const clean = asEffectRow(row);
|
|
93
|
+
const receipt = signEffectExtract({
|
|
94
|
+
deciderId: "verax-proxy",
|
|
95
|
+
channelId: "verax-body",
|
|
96
|
+
windowStartMs: clean.timestampMs,
|
|
97
|
+
windowEndMs: clean.timestampMs + 1,
|
|
98
|
+
effects: [clean],
|
|
99
|
+
}, signer.privateKeyPem, signer.publicKeyPem);
|
|
100
|
+
const payload = Buffer.from(canonical({
|
|
101
|
+
ref: clean.ref,
|
|
102
|
+
effectHash: clean.effectHash,
|
|
103
|
+
witnessClass,
|
|
104
|
+
resultHash: resultHash ?? null,
|
|
105
|
+
}), "utf8");
|
|
106
|
+
const attestation = {
|
|
107
|
+
coseHex: coseToHex(signCoseSign1(payload, signer.privateKeyPem, "application/json")),
|
|
108
|
+
};
|
|
109
|
+
return { receipt, attestation };
|
|
110
|
+
}
|
|
111
|
+
function asEffectRow(row) {
|
|
112
|
+
return {
|
|
113
|
+
ref: row.ref,
|
|
114
|
+
effectHash: row.effectHash,
|
|
115
|
+
effectClass: row.effectClass,
|
|
116
|
+
timestampMs: row.timestampMs,
|
|
117
|
+
...(row.actor !== undefined ? { actor: row.actor } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function asStoredEffect(row, witnessClass, resultHash, signer) {
|
|
121
|
+
const stored = {
|
|
122
|
+
row: asEffectRow(row),
|
|
123
|
+
witnessClass,
|
|
124
|
+
...signedEffectFields(row, witnessClass, resultHash, signer),
|
|
125
|
+
};
|
|
126
|
+
if (resultHash !== undefined)
|
|
127
|
+
stored.resultHash = resultHash;
|
|
128
|
+
return stored;
|
|
129
|
+
}
|
|
130
|
+
function indexRowOf(signed) {
|
|
131
|
+
if (typeof signed.claims.ref !== "string")
|
|
132
|
+
return null;
|
|
133
|
+
return {
|
|
134
|
+
ref: signed.claims.ref,
|
|
135
|
+
requestHash: signed.claims.requestHash,
|
|
136
|
+
decision: signed.claims.decision,
|
|
137
|
+
reasonCode: signed.claims.reasonCode,
|
|
138
|
+
subject: signed.claims.subject,
|
|
139
|
+
policyHash: signed.claims.policyHash,
|
|
140
|
+
timestampMs: signed.claims.timestampMs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function resolutionKindOf(row) {
|
|
144
|
+
if (row.decision === "allow")
|
|
145
|
+
return "allow";
|
|
146
|
+
if (row.reasonCode === "expired")
|
|
147
|
+
return "expired";
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function noteReauth(map, row) {
|
|
151
|
+
if (row.reasonCode !== "spend-reauth-required")
|
|
152
|
+
return;
|
|
153
|
+
if (!map.has(row.requestHash))
|
|
154
|
+
map.set(row.requestHash, row.ref);
|
|
155
|
+
}
|
|
156
|
+
function noteCounted(times, row) {
|
|
157
|
+
if (row.decision === "allow" || row.decision === "defer")
|
|
158
|
+
times.push(row.timestampMs);
|
|
159
|
+
}
|
|
160
|
+
export class MemoryLedger {
|
|
161
|
+
permissionCheck = "owner-only";
|
|
162
|
+
effectSigner;
|
|
163
|
+
_decisions = [];
|
|
164
|
+
_effects = [];
|
|
165
|
+
byRef = new Map();
|
|
166
|
+
resolvedBy = new Map();
|
|
167
|
+
reauthByHash = new Map();
|
|
168
|
+
countedAt = [];
|
|
169
|
+
countsReadable = true;
|
|
170
|
+
q = new SerialQueue();
|
|
171
|
+
lookupByRef(ref) {
|
|
172
|
+
return this.byRef.get(ref) ?? null;
|
|
173
|
+
}
|
|
174
|
+
noteTenantRef(key, ref) {
|
|
175
|
+
const row = this.byRef.get(ref);
|
|
176
|
+
if (row)
|
|
177
|
+
this.byRef.set(`${key}:${ref}`, row);
|
|
178
|
+
}
|
|
179
|
+
lookupResolvedBy(deferRef) {
|
|
180
|
+
return this.resolvedBy.get(deferRef) ?? null;
|
|
181
|
+
}
|
|
182
|
+
noteResolution(deferRef, hit) {
|
|
183
|
+
this.resolvedBy.set(deferRef, hit);
|
|
184
|
+
}
|
|
185
|
+
lookupReauthByHash(requestHash) {
|
|
186
|
+
return this.reauthByHash.get(requestHash) ?? null;
|
|
187
|
+
}
|
|
188
|
+
countedTimes() {
|
|
189
|
+
return this.countedAt;
|
|
190
|
+
}
|
|
191
|
+
hasPrimaryEffect(ref) {
|
|
192
|
+
return this._effects.some((e) => e.row.ref === ref && e.row.effectClass !== "duplicate-effect");
|
|
193
|
+
}
|
|
194
|
+
async appendDecision(signed) {
|
|
195
|
+
return this.q.enqueue(async () => {
|
|
196
|
+
this._decisions.push(signed);
|
|
197
|
+
const row = indexRowOf(signed);
|
|
198
|
+
if (row) {
|
|
199
|
+
this.byRef.set(row.ref, row);
|
|
200
|
+
noteReauth(this.reauthByHash, row);
|
|
201
|
+
noteCounted(this.countedAt, row);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async appendDecisionChained(build) {
|
|
206
|
+
return this.q.enqueue(async () => {
|
|
207
|
+
const last = this._decisions[this._decisions.length - 1];
|
|
208
|
+
const signed = build(last ? decisionRecordHash(last) : null);
|
|
209
|
+
this._decisions.push(signed);
|
|
210
|
+
const row = indexRowOf(signed);
|
|
211
|
+
if (row) {
|
|
212
|
+
this.byRef.set(row.ref, row);
|
|
213
|
+
noteReauth(this.reauthByHash, row);
|
|
214
|
+
noteCounted(this.countedAt, row);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async appendEffect(row, witnessClass = DEFAULT_WITNESS, resultHash) {
|
|
219
|
+
return this.q.enqueue(async () => this.appendEffectUnlocked(row, witnessClass, resultHash));
|
|
220
|
+
}
|
|
221
|
+
async appendEffectUnlocked(row, witnessClass, resultHash) {
|
|
222
|
+
const existing = this._effects.find((e) => e.row.ref === row.ref && e.row.effectClass !== "duplicate-effect");
|
|
223
|
+
if (existing && row.effectClass !== "duplicate-effect") {
|
|
224
|
+
this._effects.push(asStoredEffect({
|
|
225
|
+
ref: row.ref,
|
|
226
|
+
effectHash: sha256Canonical({ refused: "duplicate-effect", ref: row.ref }),
|
|
227
|
+
effectClass: "duplicate-effect",
|
|
228
|
+
timestampMs: row.timestampMs,
|
|
229
|
+
actor: row.actor,
|
|
230
|
+
}, DEFAULT_WITNESS, resultHash, undefined));
|
|
231
|
+
throw new Error(`duplicate-effect:${row.ref}`);
|
|
232
|
+
}
|
|
233
|
+
this._effects.push(asStoredEffect(row, witnessClass, resultHash, this.effectSigner));
|
|
234
|
+
}
|
|
235
|
+
async decisions() {
|
|
236
|
+
return [...this._decisions];
|
|
237
|
+
}
|
|
238
|
+
async effects() {
|
|
239
|
+
return [...this._effects];
|
|
240
|
+
}
|
|
241
|
+
async lastDecisionHash() {
|
|
242
|
+
const last = this._decisions[this._decisions.length - 1];
|
|
243
|
+
return last ? decisionRecordHash(last) : null;
|
|
244
|
+
}
|
|
245
|
+
async exportExtract(window, signer) {
|
|
246
|
+
return signWindow(this._effects, window, signer);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function pidAlive(pid) {
|
|
250
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
process.kill(pid, 0);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
return err.code === "EPERM";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function readLock(path) {
|
|
261
|
+
try {
|
|
262
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
263
|
+
if (typeof raw.pid !== "number" || typeof raw.startedAt !== "number")
|
|
264
|
+
return null;
|
|
265
|
+
return {
|
|
266
|
+
pid: raw.pid,
|
|
267
|
+
startedAt: raw.startedAt,
|
|
268
|
+
...(typeof raw.token === "string" ? { token: raw.token } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export class FileLedger {
|
|
276
|
+
permissionCheck;
|
|
277
|
+
dir;
|
|
278
|
+
effectSigner;
|
|
279
|
+
/** Optional signer in another process. Null means stay `self`. */
|
|
280
|
+
remoteWitness;
|
|
281
|
+
decisionsPath;
|
|
282
|
+
effectsPath;
|
|
283
|
+
lockPath;
|
|
284
|
+
q = new SerialQueue();
|
|
285
|
+
token = randomBytes(16).toString("hex");
|
|
286
|
+
closed = false;
|
|
287
|
+
lost = false;
|
|
288
|
+
tailHash = null;
|
|
289
|
+
effectRefs = new Set();
|
|
290
|
+
byRef = new Map();
|
|
291
|
+
resolvedBy = new Map();
|
|
292
|
+
reauthByHash = new Map();
|
|
293
|
+
countedAt = [];
|
|
294
|
+
countsReadable = true;
|
|
295
|
+
copyDir;
|
|
296
|
+
heartbeatPath;
|
|
297
|
+
decisionCount = 0;
|
|
298
|
+
effectCount = 0;
|
|
299
|
+
constructor(dir) {
|
|
300
|
+
this.dir = dir;
|
|
301
|
+
this.permissionCheck = ensureLedgerDir(dir);
|
|
302
|
+
this.decisionsPath = join(dir, "decisions.jsonl");
|
|
303
|
+
this.effectsPath = join(dir, "effects.jsonl");
|
|
304
|
+
this.lockPath = join(dir, "ledger.lock");
|
|
305
|
+
this.copyDir = join(dir, "evidence-copy");
|
|
306
|
+
this.heartbeatPath = join(dir, "heartbeat.json");
|
|
307
|
+
this.acquireLock();
|
|
308
|
+
this.loadCaches();
|
|
309
|
+
}
|
|
310
|
+
/** After lock handoff a new instance reloads; the lost owner cannot append. */
|
|
311
|
+
loadCaches() {
|
|
312
|
+
this.effectRefs.clear();
|
|
313
|
+
this.byRef.clear();
|
|
314
|
+
this.resolvedBy.clear();
|
|
315
|
+
this.reauthByHash.clear();
|
|
316
|
+
this.countedAt.length = 0;
|
|
317
|
+
this.countsReadable = true;
|
|
318
|
+
let decisions = [];
|
|
319
|
+
try {
|
|
320
|
+
decisions = readJsonlSync(this.decisionsPath);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
this.countsReadable = false;
|
|
324
|
+
}
|
|
325
|
+
this.decisionCount = decisions.length;
|
|
326
|
+
const last = decisions[decisions.length - 1];
|
|
327
|
+
this.tailHash = last ? decisionRecordHash(last) : null;
|
|
328
|
+
const rowsByRef = new Map();
|
|
329
|
+
for (const rec of decisions) {
|
|
330
|
+
const row = indexRowOf(rec);
|
|
331
|
+
if (row) {
|
|
332
|
+
this.byRef.set(row.ref, row);
|
|
333
|
+
const list = rowsByRef.get(row.ref) ?? [];
|
|
334
|
+
list.push(row);
|
|
335
|
+
rowsByRef.set(row.ref, list);
|
|
336
|
+
noteReauth(this.reauthByHash, row);
|
|
337
|
+
noteCounted(this.countedAt, row);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
this.loadTenantRefs(rowsByRef);
|
|
341
|
+
this.effectCount = 0;
|
|
342
|
+
for (const effect of readJsonlSync(this.effectsPath)) {
|
|
343
|
+
this.effectCount += 1;
|
|
344
|
+
if (effect.row.effectClass !== "duplicate-effect")
|
|
345
|
+
this.effectRefs.add(effect.row.ref);
|
|
346
|
+
}
|
|
347
|
+
this.loadResolvedBy();
|
|
348
|
+
}
|
|
349
|
+
/** Pair inputs.jsonl with decisions so a restart keeps per-tenant `_ref` keys. */
|
|
350
|
+
loadTenantRefs(rowsByRef) {
|
|
351
|
+
let text;
|
|
352
|
+
try {
|
|
353
|
+
text = ledgerFs.readFileSync(join(this.dir, "inputs.jsonl"), "utf8");
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
if (err.code === "ENOENT")
|
|
357
|
+
return;
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
const seen = new Map();
|
|
361
|
+
for (const line of text.split("\n")) {
|
|
362
|
+
if (line === "")
|
|
363
|
+
continue;
|
|
364
|
+
const row = JSON.parse(line);
|
|
365
|
+
if (typeof row.ref !== "string")
|
|
366
|
+
continue;
|
|
367
|
+
const principal = row.inputs?.principal;
|
|
368
|
+
if (!principal || typeof principal.brain !== "string")
|
|
369
|
+
continue;
|
|
370
|
+
const idx = seen.get(row.ref) ?? 0;
|
|
371
|
+
seen.set(row.ref, idx + 1);
|
|
372
|
+
const indexed = rowsByRef.get(row.ref)?.[idx];
|
|
373
|
+
if (!indexed)
|
|
374
|
+
continue;
|
|
375
|
+
const key = tenantKey({
|
|
376
|
+
brain: principal.brain,
|
|
377
|
+
iss: principal.iss,
|
|
378
|
+
tenant: principal.tenant,
|
|
379
|
+
org: principal.org,
|
|
380
|
+
});
|
|
381
|
+
this.byRef.set(`${key}:${row.ref}`, indexed);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Single pass over inputs.jsonl. FileInputsLog itself stays cache-less. */
|
|
385
|
+
loadResolvedBy() {
|
|
386
|
+
let text;
|
|
387
|
+
try {
|
|
388
|
+
text = ledgerFs.readFileSync(join(this.dir, "inputs.jsonl"), "utf8");
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
if (err.code === "ENOENT")
|
|
392
|
+
return;
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
for (const line of text.split("\n")) {
|
|
396
|
+
if (line === "")
|
|
397
|
+
continue;
|
|
398
|
+
const row = JSON.parse(line);
|
|
399
|
+
if (typeof row.ref !== "string")
|
|
400
|
+
continue;
|
|
401
|
+
const resolves = row.inputs?.approver?.resolves;
|
|
402
|
+
if (typeof resolves !== "string")
|
|
403
|
+
continue;
|
|
404
|
+
const indexed = this.byRef.get(row.ref);
|
|
405
|
+
if (!indexed)
|
|
406
|
+
continue;
|
|
407
|
+
const kind = resolutionKindOf(indexed);
|
|
408
|
+
if (kind)
|
|
409
|
+
this.resolvedBy.set(resolves, { ref: row.ref, kind });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
lockBody() {
|
|
413
|
+
return `${JSON.stringify({ pid: process.pid, startedAt: Date.now(), token: this.token })}\n`;
|
|
414
|
+
}
|
|
415
|
+
ownsLock() {
|
|
416
|
+
const existing = readLock(this.lockPath);
|
|
417
|
+
return existing?.token === this.token;
|
|
418
|
+
}
|
|
419
|
+
/** Lock file on disk, not a process counter. */
|
|
420
|
+
lockStatus() {
|
|
421
|
+
return this.ownsLock() ? "held" : "free";
|
|
422
|
+
}
|
|
423
|
+
markLost() {
|
|
424
|
+
this.lost = true;
|
|
425
|
+
}
|
|
426
|
+
assertOwned() {
|
|
427
|
+
if (this.lost || !this.ownsLock()) {
|
|
428
|
+
this.markLost();
|
|
429
|
+
throw new Error("ledger-lost-lock");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
lockedError() {
|
|
433
|
+
const existing = readLock(this.lockPath);
|
|
434
|
+
const pid = existing?.pid ?? "unknown";
|
|
435
|
+
if (typeof existing?.pid === "number" && !pidAlive(existing.pid)) {
|
|
436
|
+
return new Error(`ledger-locked-stale:${pid}\nrun: verax unlock ${this.dir}`);
|
|
437
|
+
}
|
|
438
|
+
return new Error(`ledger-locked:${pid}`);
|
|
439
|
+
}
|
|
440
|
+
acquireLock() {
|
|
441
|
+
const body = this.lockBody();
|
|
442
|
+
try {
|
|
443
|
+
writeFileSync(this.lockPath, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
if (err.code === "EEXIST")
|
|
447
|
+
throw this.lockedError();
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
close() {
|
|
452
|
+
if (this.closed)
|
|
453
|
+
return;
|
|
454
|
+
this.closed = true;
|
|
455
|
+
if (!this.ownsLock()) {
|
|
456
|
+
this.lost = true;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
unlinkSync(this.lockPath);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
if (err.code !== "ENOENT")
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async appendDecision(signed) {
|
|
468
|
+
return this.q.enqueue(async () => {
|
|
469
|
+
this.assertOwned();
|
|
470
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
471
|
+
const line = lineOf(signed);
|
|
472
|
+
await appendDurable(this.decisionsPath, line);
|
|
473
|
+
await this.pulse("decisions.jsonl", line, "decision");
|
|
474
|
+
this.tailHash = decisionRecordHash(signed);
|
|
475
|
+
const row = indexRowOf(signed);
|
|
476
|
+
if (row) {
|
|
477
|
+
this.byRef.set(row.ref, row);
|
|
478
|
+
noteReauth(this.reauthByHash, row);
|
|
479
|
+
noteCounted(this.countedAt, row);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
async appendDecisionChained(build) {
|
|
484
|
+
return this.q.enqueue(async () => {
|
|
485
|
+
this.assertOwned();
|
|
486
|
+
const prev = await this.lastDecisionHashUnlocked();
|
|
487
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
488
|
+
const signed = build(prev);
|
|
489
|
+
const line = lineOf(signed);
|
|
490
|
+
await appendDurable(this.decisionsPath, line);
|
|
491
|
+
await this.pulse("decisions.jsonl", line, "decision");
|
|
492
|
+
this.tailHash = decisionRecordHash(signed);
|
|
493
|
+
const row = indexRowOf(signed);
|
|
494
|
+
if (row) {
|
|
495
|
+
this.byRef.set(row.ref, row);
|
|
496
|
+
noteReauth(this.reauthByHash, row);
|
|
497
|
+
noteCounted(this.countedAt, row);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
async appendEffect(row, witnessClass = DEFAULT_WITNESS, resultHash) {
|
|
502
|
+
return this.q.enqueue(async () => {
|
|
503
|
+
this.assertOwned();
|
|
504
|
+
return this.appendEffectUnlocked(row, witnessClass, resultHash);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
async appendEffectUnlocked(row, witnessClass = DEFAULT_WITNESS, resultHash) {
|
|
508
|
+
if (row.effectClass !== "duplicate-effect" && this.effectRefs.has(row.ref)) {
|
|
509
|
+
const dup = lineOf(asStoredEffect({
|
|
510
|
+
ref: row.ref,
|
|
511
|
+
effectHash: sha256Canonical({ refused: "duplicate-effect", ref: row.ref }),
|
|
512
|
+
effectClass: "duplicate-effect",
|
|
513
|
+
timestampMs: row.timestampMs,
|
|
514
|
+
actor: row.actor,
|
|
515
|
+
}, DEFAULT_WITNESS, resultHash, undefined));
|
|
516
|
+
await appendDurable(this.effectsPath, dup);
|
|
517
|
+
await this.pulse("effects.jsonl", dup, "effect");
|
|
518
|
+
throw new Error(`duplicate-effect:${row.ref}`);
|
|
519
|
+
}
|
|
520
|
+
const line = lineOf(await this.storeEffect(row, witnessClass, resultHash));
|
|
521
|
+
await appendDurable(this.effectsPath, line);
|
|
522
|
+
await this.pulse("effects.jsonl", line, "effect");
|
|
523
|
+
if (row.effectClass !== "duplicate-effect")
|
|
524
|
+
this.effectRefs.add(row.ref);
|
|
525
|
+
}
|
|
526
|
+
async decisions() {
|
|
527
|
+
return readJsonl(this.decisionsPath);
|
|
528
|
+
}
|
|
529
|
+
async effects() {
|
|
530
|
+
return readJsonl(this.effectsPath);
|
|
531
|
+
}
|
|
532
|
+
async lastDecisionHash() {
|
|
533
|
+
return this.lastDecisionHashUnlocked();
|
|
534
|
+
}
|
|
535
|
+
async lastDecisionHashUnlocked() {
|
|
536
|
+
return this.tailHash;
|
|
537
|
+
}
|
|
538
|
+
async exportExtract(window, signer) {
|
|
539
|
+
return signWindow(await this.effects(), window, signer);
|
|
540
|
+
}
|
|
541
|
+
lookupByRef(ref) {
|
|
542
|
+
return this.byRef.get(ref) ?? null;
|
|
543
|
+
}
|
|
544
|
+
noteTenantRef(key, ref) {
|
|
545
|
+
const row = this.byRef.get(ref);
|
|
546
|
+
if (row)
|
|
547
|
+
this.byRef.set(`${key}:${ref}`, row);
|
|
548
|
+
}
|
|
549
|
+
lookupResolvedBy(deferRef) {
|
|
550
|
+
return this.resolvedBy.get(deferRef) ?? null;
|
|
551
|
+
}
|
|
552
|
+
noteResolution(deferRef, hit) {
|
|
553
|
+
this.resolvedBy.set(deferRef, hit);
|
|
554
|
+
}
|
|
555
|
+
lookupReauthByHash(requestHash) {
|
|
556
|
+
return this.reauthByHash.get(requestHash) ?? null;
|
|
557
|
+
}
|
|
558
|
+
countedTimes() {
|
|
559
|
+
return this.countedAt;
|
|
560
|
+
}
|
|
561
|
+
hasPrimaryEffect(ref) {
|
|
562
|
+
return this.effectRefs.has(ref);
|
|
563
|
+
}
|
|
564
|
+
async pulse(name, line, kind) {
|
|
565
|
+
mkdirSync(this.copyDir, { recursive: true, mode: 0o700 });
|
|
566
|
+
appendFileSync(join(this.copyDir, name), line, { encoding: "utf8" });
|
|
567
|
+
if (kind === "decision")
|
|
568
|
+
this.decisionCount += 1;
|
|
569
|
+
else
|
|
570
|
+
this.effectCount += 1;
|
|
571
|
+
writeFileSync(this.heartbeatPath, `${JSON.stringify({
|
|
572
|
+
atMs: Date.now(),
|
|
573
|
+
pid: process.pid,
|
|
574
|
+
lastDecisionN: this.decisionCount,
|
|
575
|
+
lastEffectN: this.effectCount,
|
|
576
|
+
})}\n`, { encoding: "utf8", mode: 0o600 });
|
|
577
|
+
}
|
|
578
|
+
async storeEffect(row, witnessClass, resultHash) {
|
|
579
|
+
if (this.remoteWitness && row.effectClass !== "duplicate-effect") {
|
|
580
|
+
const remote = await this.remoteWitness(row, resultHash);
|
|
581
|
+
if (remote?.attestation && remote.receipt && remote.witnessClass && remote.witnessClass !== "self") {
|
|
582
|
+
const stored = {
|
|
583
|
+
row: asEffectRow(row),
|
|
584
|
+
witnessClass: remote.witnessClass,
|
|
585
|
+
receipt: remote.receipt,
|
|
586
|
+
attestation: remote.attestation,
|
|
587
|
+
};
|
|
588
|
+
if (resultHash !== undefined)
|
|
589
|
+
stored.resultHash = resultHash;
|
|
590
|
+
return stored;
|
|
591
|
+
}
|
|
592
|
+
return asStoredEffect(row, DEFAULT_WITNESS, resultHash, this.effectSigner);
|
|
593
|
+
}
|
|
594
|
+
return asStoredEffect(row, witnessClass, resultHash, this.effectSigner);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function signWindow(effects, window, signer) {
|
|
598
|
+
const rows = effects
|
|
599
|
+
.filter((e) => e.row.timestampMs >= window.startMs && e.row.timestampMs < window.endMs)
|
|
600
|
+
.map((e) => asEffectRow(e.row));
|
|
601
|
+
return signEffectExtract({
|
|
602
|
+
deciderId: "verax-proxy",
|
|
603
|
+
channelId: "verax-body",
|
|
604
|
+
windowStartMs: window.startMs,
|
|
605
|
+
windowEndMs: window.endMs,
|
|
606
|
+
effects: rows,
|
|
607
|
+
}, signer.privateKeyPem, signer.publicKeyPem);
|
|
608
|
+
}
|
|
609
|
+
export async function lookupDecisionByRef(ledger, ref, key) {
|
|
610
|
+
const extra = ledger;
|
|
611
|
+
if (typeof extra.lookupByRef === "function") {
|
|
612
|
+
if (key)
|
|
613
|
+
return extra.lookupByRef(`${key}:${ref}`);
|
|
614
|
+
return extra.lookupByRef(ref);
|
|
615
|
+
}
|
|
616
|
+
const found = (await ledger.decisions()).find((d) => d.claims.ref === ref);
|
|
617
|
+
return found ? indexRowOf(found) : null;
|
|
618
|
+
}
|
|
619
|
+
export function noteTenantRef(ledger, key, ref) {
|
|
620
|
+
const extra = ledger;
|
|
621
|
+
if (typeof extra.noteTenantRef === "function")
|
|
622
|
+
extra.noteTenantRef(key, ref);
|
|
623
|
+
}
|
|
624
|
+
export function lookupResolvedBy(ledger, deferRef) {
|
|
625
|
+
const extra = ledger;
|
|
626
|
+
if (typeof extra.lookupResolvedBy === "function")
|
|
627
|
+
return extra.lookupResolvedBy(deferRef);
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
export function noteResolution(ledger, deferRef, hit) {
|
|
631
|
+
const extra = ledger;
|
|
632
|
+
if (typeof extra.noteResolution === "function")
|
|
633
|
+
extra.noteResolution(deferRef, hit);
|
|
634
|
+
}
|
|
635
|
+
export function countedWork(ledger, nowMs) {
|
|
636
|
+
const extra = ledger;
|
|
637
|
+
if (extra.countsReadable === false)
|
|
638
|
+
return { ok: false };
|
|
639
|
+
if (typeof extra.countedTimes !== "function")
|
|
640
|
+
return { ok: false };
|
|
641
|
+
const times = extra.countedTimes();
|
|
642
|
+
const minuteStart = nowMs - 60_000;
|
|
643
|
+
const day = new Date(nowMs);
|
|
644
|
+
const dayStart = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
|
|
645
|
+
let minute = 0;
|
|
646
|
+
let dayCount = 0;
|
|
647
|
+
for (const t of times) {
|
|
648
|
+
if (t >= minuteStart && t <= nowMs)
|
|
649
|
+
minute += 1;
|
|
650
|
+
if (t >= dayStart && t <= nowMs)
|
|
651
|
+
dayCount += 1;
|
|
652
|
+
}
|
|
653
|
+
return { ok: true, minute, day: dayCount };
|
|
654
|
+
}
|
|
655
|
+
export async function lookupReauthByHash(ledger, requestHash) {
|
|
656
|
+
const extra = ledger;
|
|
657
|
+
if (typeof extra.lookupReauthByHash === "function")
|
|
658
|
+
return extra.lookupReauthByHash(requestHash);
|
|
659
|
+
for (const d of await ledger.decisions()) {
|
|
660
|
+
if (d.claims.reasonCode === "spend-reauth-required" && d.claims.requestHash === requestHash && d.claims.ref) {
|
|
661
|
+
return d.claims.ref;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
export async function hasPrimaryEffect(ledger, ref) {
|
|
667
|
+
const extra = ledger;
|
|
668
|
+
if (typeof extra.hasPrimaryEffect === "function")
|
|
669
|
+
return extra.hasPrimaryEffect(ref);
|
|
670
|
+
return (await ledger.effects()).some((e) => e.row.ref === ref && e.row.effectClass !== "duplicate-effect");
|
|
671
|
+
}
|
|
672
|
+
export function witnessClassSummary(effects) {
|
|
673
|
+
const out = {
|
|
674
|
+
self: 0,
|
|
675
|
+
"same-org": 0,
|
|
676
|
+
"third-party": 0,
|
|
677
|
+
regulated: 0,
|
|
678
|
+
};
|
|
679
|
+
for (const e of effects) {
|
|
680
|
+
out[e.witnessClass] += 1;
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|