@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
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function asChannelRow(raw, line) {
|
|
5
|
+
if (raw === null || typeof raw !== "object") {
|
|
6
|
+
throw new Error(`channel-row-invalid:${line}`);
|
|
7
|
+
}
|
|
8
|
+
const o = raw;
|
|
9
|
+
if (typeof o.channel !== "string" || o.channel === "") {
|
|
10
|
+
throw new Error(`channel-row-invalid:${line}:channel`);
|
|
11
|
+
}
|
|
12
|
+
if (typeof o.externalId !== "string" || o.externalId === "") {
|
|
13
|
+
throw new Error(`channel-row-invalid:${line}:externalId`);
|
|
14
|
+
}
|
|
15
|
+
if (typeof o.occurredAtMs !== "number" || !Number.isFinite(o.occurredAtMs)) {
|
|
16
|
+
throw new Error(`channel-row-invalid:${line}:occurredAtMs`);
|
|
17
|
+
}
|
|
18
|
+
if (typeof o.subject !== "string" || o.subject === "") {
|
|
19
|
+
throw new Error(`channel-row-invalid:${line}:subject`);
|
|
20
|
+
}
|
|
21
|
+
const row = {
|
|
22
|
+
channel: o.channel,
|
|
23
|
+
externalId: o.externalId,
|
|
24
|
+
occurredAtMs: o.occurredAtMs,
|
|
25
|
+
subject: o.subject,
|
|
26
|
+
};
|
|
27
|
+
if (typeof o.actor === "string")
|
|
28
|
+
row.actor = o.actor;
|
|
29
|
+
if (typeof o.ref === "string")
|
|
30
|
+
row.ref = o.ref;
|
|
31
|
+
if (typeof o.amountMinor === "number")
|
|
32
|
+
row.amountMinor = o.amountMinor;
|
|
33
|
+
if (typeof o.currency === "string")
|
|
34
|
+
row.currency = o.currency;
|
|
35
|
+
if (o.credit === true)
|
|
36
|
+
row.credit = true;
|
|
37
|
+
if (typeof o.descriptor === "string")
|
|
38
|
+
row.descriptor = o.descriptor;
|
|
39
|
+
if (o.datePrecision === "day" || o.datePrecision === "minute")
|
|
40
|
+
row.datePrecision = o.datePrecision;
|
|
41
|
+
return row;
|
|
42
|
+
}
|
|
43
|
+
const VERAX_REF = /verax:([A-Za-z0-9][A-Za-z0-9._-]{0,63})/;
|
|
44
|
+
const CARD_MINUTE_TOLERANCE_MS = 120_000;
|
|
45
|
+
function parseCardDate(raw, format, tzOffsetMinutes) {
|
|
46
|
+
const t = raw.trim();
|
|
47
|
+
const offset = Number.isFinite(tzOffsetMinutes) ? tzOffsetMinutes : null;
|
|
48
|
+
const pattern = format === "YYYY-MM-DD"
|
|
49
|
+
? /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?$/
|
|
50
|
+
: /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
51
|
+
const m = pattern.exec(t);
|
|
52
|
+
if (!m)
|
|
53
|
+
throw new Error(`card-csv-date:${raw}`);
|
|
54
|
+
const year = Number(format === "YYYY-MM-DD" ? m[1] : m[3]);
|
|
55
|
+
const month = Number(m[2]) - 1;
|
|
56
|
+
const day = Number(format === "YYYY-MM-DD" ? m[3] : m[1]);
|
|
57
|
+
if (m[4] !== undefined && offset !== null) {
|
|
58
|
+
const local = Date.UTC(year, month, day, Number(m[4]), Number(m[5]), Number(m[6] ?? 0));
|
|
59
|
+
return { ms: local - offset * 60_000, precision: "minute" };
|
|
60
|
+
}
|
|
61
|
+
return { ms: Date.UTC(year, month, day, 12, 0, 0), precision: "day" };
|
|
62
|
+
}
|
|
63
|
+
function rowToleranceMs(row, fallback) {
|
|
64
|
+
return row.datePrecision === "minute" ? CARD_MINUTE_TOLERANCE_MS : fallback;
|
|
65
|
+
}
|
|
66
|
+
function descriptorHits(text, stamps) {
|
|
67
|
+
const hay = text.toUpperCase();
|
|
68
|
+
return stamps.some((s) => hay.includes(s.toUpperCase()));
|
|
69
|
+
}
|
|
70
|
+
function parseCardAmount(raw, decimal) {
|
|
71
|
+
const thousand = decimal === "," ? "." : ",";
|
|
72
|
+
const cleaned = raw.trim().replaceAll(thousand, "").replace(decimal, ".");
|
|
73
|
+
const n = Number(cleaned);
|
|
74
|
+
if (!Number.isFinite(n))
|
|
75
|
+
throw new Error(`card-csv-amount:${raw}`);
|
|
76
|
+
return Math.round(n * 100);
|
|
77
|
+
}
|
|
78
|
+
/** RFC 4180 cells: delimiter inside quotes, `""` escape, CRLF in a quoted field. */
|
|
79
|
+
function parseCsvRecords(text, delimiter) {
|
|
80
|
+
const src = text.replace(/^\uFEFF/, "");
|
|
81
|
+
const records = [];
|
|
82
|
+
let cells = [];
|
|
83
|
+
let cur = "";
|
|
84
|
+
let inQuotes = false;
|
|
85
|
+
let line = 1;
|
|
86
|
+
let recordStartLine = 1;
|
|
87
|
+
const flushRecord = () => {
|
|
88
|
+
cells.push(cur);
|
|
89
|
+
if (cells.some((c) => c !== "")) {
|
|
90
|
+
records.push({ line: recordStartLine, cells });
|
|
91
|
+
}
|
|
92
|
+
cells = [];
|
|
93
|
+
cur = "";
|
|
94
|
+
recordStartLine = line;
|
|
95
|
+
};
|
|
96
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
97
|
+
const ch = src[i];
|
|
98
|
+
if (inQuotes) {
|
|
99
|
+
if (ch === '"') {
|
|
100
|
+
if (src[i + 1] === '"') {
|
|
101
|
+
cur += '"';
|
|
102
|
+
i += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
inQuotes = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === "\n")
|
|
109
|
+
line += 1;
|
|
110
|
+
cur += ch;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === '"') {
|
|
114
|
+
inQuotes = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === delimiter) {
|
|
118
|
+
cells.push(cur);
|
|
119
|
+
cur = "";
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (ch === "\r" && src[i + 1] === "\n") {
|
|
123
|
+
line += 1;
|
|
124
|
+
flushRecord();
|
|
125
|
+
i += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === "\n") {
|
|
129
|
+
line += 1;
|
|
130
|
+
flushRecord();
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
cur += ch;
|
|
134
|
+
}
|
|
135
|
+
if (inQuotes) {
|
|
136
|
+
throw new Error("card-csv-unclosed-quote");
|
|
137
|
+
}
|
|
138
|
+
if (cur !== "" || cells.length > 0)
|
|
139
|
+
flushRecord();
|
|
140
|
+
return records;
|
|
141
|
+
}
|
|
142
|
+
export function parseCardCsv(text, opts) {
|
|
143
|
+
const delimiter = opts.delimiter ?? ";";
|
|
144
|
+
const decimal = opts.decimal ?? ",";
|
|
145
|
+
const dateFormat = opts.dateFormat ?? "DD.MM.YYYY";
|
|
146
|
+
const columns = opts.columns ?? { date: "Tarih", amount: "Tutar", description: "Açıklama" };
|
|
147
|
+
const records = parseCsvRecords(text, delimiter);
|
|
148
|
+
if (records.length === 0)
|
|
149
|
+
return Object.assign([], { skipped: [] });
|
|
150
|
+
const header = records[0].cells.map((h) => h.trim());
|
|
151
|
+
const dateIdx = header.indexOf(columns.date);
|
|
152
|
+
const amountIdx = header.indexOf(columns.amount);
|
|
153
|
+
const descIdx = header.indexOf(columns.description);
|
|
154
|
+
const idIdx = columns.id ? header.indexOf(columns.id) : -1;
|
|
155
|
+
if (dateIdx < 0 || amountIdx < 0 || descIdx < 0) {
|
|
156
|
+
throw new Error("card-csv-columns");
|
|
157
|
+
}
|
|
158
|
+
const rows = [];
|
|
159
|
+
const skipped = [];
|
|
160
|
+
for (const rec of records.slice(1)) {
|
|
161
|
+
try {
|
|
162
|
+
const cells = rec.cells;
|
|
163
|
+
const date = cells[dateIdx] ?? "";
|
|
164
|
+
const amountRaw = cells[amountIdx] ?? "";
|
|
165
|
+
const description = cells[descIdx] ?? "";
|
|
166
|
+
const signed = parseCardAmount(amountRaw, decimal);
|
|
167
|
+
const amountMinor = Math.abs(signed);
|
|
168
|
+
const parsedDate = parseCardDate(date, dateFormat, opts.tzOffsetMinutes);
|
|
169
|
+
const refHit = VERAX_REF.exec(description);
|
|
170
|
+
const idCell = idIdx >= 0 ? (cells[idIdx] ?? "").trim() : "";
|
|
171
|
+
const externalId = idCell !== ""
|
|
172
|
+
? idCell
|
|
173
|
+
: createHash("sha256").update(`${date}|${amountRaw}|${description}`, "utf8").digest("hex");
|
|
174
|
+
const row = {
|
|
175
|
+
channel: "card",
|
|
176
|
+
externalId,
|
|
177
|
+
occurredAtMs: parsedDate.ms,
|
|
178
|
+
subject: "spend",
|
|
179
|
+
amountMinor,
|
|
180
|
+
currency: opts.currency,
|
|
181
|
+
descriptor: description,
|
|
182
|
+
datePrecision: parsedDate.precision,
|
|
183
|
+
};
|
|
184
|
+
if (refHit)
|
|
185
|
+
row.ref = refHit[1];
|
|
186
|
+
if (signed > 0)
|
|
187
|
+
row.credit = true;
|
|
188
|
+
rows.push(row);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
skipped.push({
|
|
192
|
+
line: rec.line,
|
|
193
|
+
reason: err instanceof Error ? err.message : "card-csv-row",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return Object.assign(rows, { skipped });
|
|
198
|
+
}
|
|
199
|
+
export function parseChannelJsonl(text) {
|
|
200
|
+
const rows = [];
|
|
201
|
+
let n = 0;
|
|
202
|
+
for (const line of text.split("\n")) {
|
|
203
|
+
if (line === "")
|
|
204
|
+
continue;
|
|
205
|
+
n += 1;
|
|
206
|
+
rows.push(asChannelRow(JSON.parse(line), n));
|
|
207
|
+
}
|
|
208
|
+
return rows;
|
|
209
|
+
}
|
|
210
|
+
/** Read-only: does not take ledger.lock. */
|
|
211
|
+
export function loadEffectsFromDir(stateDir) {
|
|
212
|
+
try {
|
|
213
|
+
const text = readFileSync(join(stateDir, "effects.jsonl"), "utf8");
|
|
214
|
+
const rows = [];
|
|
215
|
+
for (const line of text.split("\n")) {
|
|
216
|
+
if (line === "")
|
|
217
|
+
continue;
|
|
218
|
+
rows.push(JSON.parse(line));
|
|
219
|
+
}
|
|
220
|
+
return rows;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (err.code === "ENOENT")
|
|
224
|
+
return [];
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Match a Sent-style channel export to ledger effects. Cedulon audit()
|
|
230
|
+
* is not called; buckets use that vocabulary (ghost / unsent / outOfScope
|
|
231
|
+
* are the unreconciled classes).
|
|
232
|
+
*/
|
|
233
|
+
function nearestEffectDtMs(row, effects) {
|
|
234
|
+
let best;
|
|
235
|
+
for (const effect of effects) {
|
|
236
|
+
const dt = Math.abs(row.occurredAtMs - effect.row.timestampMs);
|
|
237
|
+
if (best === undefined || dt < best)
|
|
238
|
+
best = dt;
|
|
239
|
+
}
|
|
240
|
+
return best;
|
|
241
|
+
}
|
|
242
|
+
function asGhost(row, effects, reason) {
|
|
243
|
+
const dt = nearestEffectDtMs(row, effects);
|
|
244
|
+
const ghost = { ...row };
|
|
245
|
+
if (dt !== undefined)
|
|
246
|
+
ghost.nearestEffectDtMs = dt;
|
|
247
|
+
if (reason !== undefined)
|
|
248
|
+
ghost.reason = reason;
|
|
249
|
+
return ghost;
|
|
250
|
+
}
|
|
251
|
+
function approvalForEffect(approvals, effectRef) {
|
|
252
|
+
if (!approvals)
|
|
253
|
+
return undefined;
|
|
254
|
+
return approvals.find((r) => r.allowRef === effectRef || r.ref === effectRef);
|
|
255
|
+
}
|
|
256
|
+
function effectAmount(effect, approvals) {
|
|
257
|
+
const snap = approvalForEffect(approvals, effect.row.ref);
|
|
258
|
+
const amountMinor = snap && typeof snap.args.amountMinor === "number" ? snap.args.amountMinor : undefined;
|
|
259
|
+
const currency = snap && typeof snap.args.currency === "string" ? snap.args.currency : undefined;
|
|
260
|
+
return { amountMinor, currency };
|
|
261
|
+
}
|
|
262
|
+
function amountsClose(a, b) {
|
|
263
|
+
if (typeof a !== "number" || typeof b !== "number")
|
|
264
|
+
return false;
|
|
265
|
+
return Math.abs(a - b) <= 1;
|
|
266
|
+
}
|
|
267
|
+
/** One side priced and the other not is not a match. Both unpriced (sent) still is. */
|
|
268
|
+
function amountGate(row, fromSnap) {
|
|
269
|
+
const rowPriced = row.amountMinor !== undefined;
|
|
270
|
+
const snapPriced = fromSnap.amountMinor !== undefined;
|
|
271
|
+
if (rowPriced !== snapPriced)
|
|
272
|
+
return "unknown";
|
|
273
|
+
if (!rowPriced)
|
|
274
|
+
return "unpriced";
|
|
275
|
+
if (!amountsClose(row.amountMinor, fromSnap.amountMinor))
|
|
276
|
+
return "mismatch";
|
|
277
|
+
if (row.currency !== undefined && fromSnap.currency !== undefined && row.currency !== fromSnap.currency) {
|
|
278
|
+
return "mismatch";
|
|
279
|
+
}
|
|
280
|
+
return "match";
|
|
281
|
+
}
|
|
282
|
+
function descriptorsFor(effect, approvals, byPayee) {
|
|
283
|
+
if (!byPayee)
|
|
284
|
+
return undefined;
|
|
285
|
+
const snap = approvalForEffect(approvals, effect.row.ref);
|
|
286
|
+
const payee = snap && typeof snap.args.payee === "string" ? snap.args.payee : undefined;
|
|
287
|
+
if (!payee)
|
|
288
|
+
return undefined;
|
|
289
|
+
const stamps = byPayee[payee];
|
|
290
|
+
return stamps && stamps.length > 0 ? stamps : undefined;
|
|
291
|
+
}
|
|
292
|
+
export function reconcile(channelRows, effects, opts) {
|
|
293
|
+
const toleranceMs = opts?.toleranceMs ?? 60_000;
|
|
294
|
+
if (channelRows.length === 0) {
|
|
295
|
+
return {
|
|
296
|
+
scope: { channel: "", windowStartMs: 0, windowEndMs: 0, rowCount: 0 },
|
|
297
|
+
matched: [],
|
|
298
|
+
ghost: [],
|
|
299
|
+
unsent: effects.filter((e) => e.row.effectClass !== "spend").map((e) => e.row),
|
|
300
|
+
authorizedUnpaid: effects.filter((e) => e.row.effectClass === "spend").map((e) => e.row),
|
|
301
|
+
outOfScope: [],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const channel = channelRows[0].channel;
|
|
305
|
+
const times = channelRows.map((r) => r.occurredAtMs);
|
|
306
|
+
const explicit = opts?.window;
|
|
307
|
+
const windowStartMs = explicit ? explicit.startMs : Math.min(...times);
|
|
308
|
+
const windowEndMs = explicit ? explicit.endMs : Math.max(...times);
|
|
309
|
+
const used = new Set();
|
|
310
|
+
const matched = [];
|
|
311
|
+
const ghost = [];
|
|
312
|
+
const outOfScope = [];
|
|
313
|
+
for (const row of channelRows) {
|
|
314
|
+
if (row.channel !== channel) {
|
|
315
|
+
throw new Error(`channel-mixed:${row.channel}`);
|
|
316
|
+
}
|
|
317
|
+
if (row.credit === true) {
|
|
318
|
+
outOfScope.push(row);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (explicit && (row.occurredAtMs < windowStartMs || row.occurredAtMs > windowEndMs)) {
|
|
322
|
+
outOfScope.push(row);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const rowTol = rowToleranceMs(row, toleranceMs);
|
|
326
|
+
const marked = { ...row, toleranceMs: rowTol };
|
|
327
|
+
if (typeof row.ref === "string" && row.ref !== "") {
|
|
328
|
+
const idx = effects.findIndex((e, i) => !used.has(i) &&
|
|
329
|
+
(e.row.ref === row.ref ||
|
|
330
|
+
opts?.approvals?.some((a) => a.ref === row.ref && a.allowRef === e.row.ref)));
|
|
331
|
+
if (idx === -1) {
|
|
332
|
+
ghost.push(asGhost(marked, effects));
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const dt = Math.abs(row.occurredAtMs - effects[idx].row.timestampMs);
|
|
336
|
+
if (dt > rowTol) {
|
|
337
|
+
ghost.push(asGhost(marked, effects));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const fromSnap = effectAmount(effects[idx], opts?.approvals);
|
|
341
|
+
const gate = amountGate(row, fromSnap);
|
|
342
|
+
if (gate === "unknown") {
|
|
343
|
+
ghost.push(asGhost(marked, effects, "amount-unknown"));
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (gate === "mismatch") {
|
|
347
|
+
ghost.push(asGhost(marked, effects, "amount-mismatch"));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
used.add(idx);
|
|
351
|
+
matched.push({
|
|
352
|
+
channel: marked,
|
|
353
|
+
effect: effects[idx].row,
|
|
354
|
+
datePrecision: row.datePrecision,
|
|
355
|
+
toleranceMs: rowTol,
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
let unknownNear = false;
|
|
360
|
+
let descriptorMiss = false;
|
|
361
|
+
const near = effects.findIndex((e, i) => {
|
|
362
|
+
if (used.has(i) || e.row.effectClass !== row.subject)
|
|
363
|
+
return false;
|
|
364
|
+
if (Math.abs(row.occurredAtMs - e.row.timestampMs) > rowTol)
|
|
365
|
+
return false;
|
|
366
|
+
const stamps = descriptorsFor(e, opts?.approvals, opts?.descriptorsByPayee);
|
|
367
|
+
if (stamps) {
|
|
368
|
+
const text = row.descriptor ?? "";
|
|
369
|
+
if (!descriptorHits(text, stamps)) {
|
|
370
|
+
descriptorMiss = true;
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const fromSnap = effectAmount(e, opts?.approvals);
|
|
375
|
+
const gate = amountGate(row, fromSnap);
|
|
376
|
+
if (gate === "unknown") {
|
|
377
|
+
unknownNear = true;
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
if (gate === "mismatch")
|
|
381
|
+
return false;
|
|
382
|
+
return true;
|
|
383
|
+
});
|
|
384
|
+
if (near !== -1) {
|
|
385
|
+
used.add(near);
|
|
386
|
+
matched.push({
|
|
387
|
+
channel: marked,
|
|
388
|
+
effect: effects[near].row,
|
|
389
|
+
datePrecision: row.datePrecision,
|
|
390
|
+
toleranceMs: rowTol,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const reason = descriptorMiss
|
|
395
|
+
? "descriptor-mismatch"
|
|
396
|
+
: unknownNear
|
|
397
|
+
? "amount-unknown"
|
|
398
|
+
: undefined;
|
|
399
|
+
ghost.push(asGhost(marked, effects, reason));
|
|
400
|
+
}
|
|
401
|
+
const lo = windowStartMs - toleranceMs;
|
|
402
|
+
const hi = windowEndMs + toleranceMs;
|
|
403
|
+
const leftover = effects.filter((e, i) => !used.has(i) && e.row.timestampMs >= lo && e.row.timestampMs <= hi);
|
|
404
|
+
const authorizedUnpaid = leftover.filter((e) => e.row.effectClass === "spend").map((e) => e.row);
|
|
405
|
+
const unsent = leftover.filter((e) => e.row.effectClass !== "spend").map((e) => e.row);
|
|
406
|
+
return {
|
|
407
|
+
scope: {
|
|
408
|
+
channel,
|
|
409
|
+
windowStartMs,
|
|
410
|
+
windowEndMs,
|
|
411
|
+
rowCount: channelRows.length,
|
|
412
|
+
...(opts?.skipped ? { skipped: opts.skipped } : {}),
|
|
413
|
+
},
|
|
414
|
+
matched,
|
|
415
|
+
ghost,
|
|
416
|
+
unsent,
|
|
417
|
+
authorizedUnpaid,
|
|
418
|
+
outOfScope,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function spokenReason(reasonCode: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Codes the brain is told, when that string is not the recorded reason.
|
|
2
|
+
* The ledger row always keeps the recorded code. An unmapped code is
|
|
3
|
+
* spoken as itself. */
|
|
4
|
+
const SPOKEN_REASON = {
|
|
5
|
+
"tenant-mismatch": "not-found",
|
|
6
|
+
};
|
|
7
|
+
export function spokenReason(reasonCode) {
|
|
8
|
+
return SPOKEN_REASON[reasonCode] ?? reasonCode;
|
|
9
|
+
}
|
package/dist/tenant.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Fields that enter the tenant key. `aud` is intentionally absent. */
|
|
2
|
+
export type TenantPrincipal = {
|
|
3
|
+
brain: string;
|
|
4
|
+
iss?: string;
|
|
5
|
+
tenant?: string;
|
|
6
|
+
org?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* SHA-256 of `{ iss, sub }` plus `tenant` or `org` when present.
|
|
10
|
+
* `aud` is not a field and must not be added: it names the body.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tenantKey(principal: TenantPrincipal): string;
|
package/dist/tenant.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { sha256Canonical } from "./hash.js";
|
|
2
|
+
/**
|
|
3
|
+
* SHA-256 of `{ iss, sub }` plus `tenant` or `org` when present.
|
|
4
|
+
* `aud` is not a field and must not be added: it names the body.
|
|
5
|
+
*/
|
|
6
|
+
export function tenantKey(principal) {
|
|
7
|
+
const material = {
|
|
8
|
+
iss: principal.iss ?? "",
|
|
9
|
+
sub: principal.brain,
|
|
10
|
+
};
|
|
11
|
+
if (typeof principal.tenant === "string" && principal.tenant !== "") {
|
|
12
|
+
material.tenant = principal.tenant;
|
|
13
|
+
}
|
|
14
|
+
else if (typeof principal.org === "string" && principal.org !== "") {
|
|
15
|
+
material.org = principal.org;
|
|
16
|
+
}
|
|
17
|
+
return sha256Canonical(material);
|
|
18
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { DecisionKind, SignedDecisionRecord } from "@cedulon/core";
|
|
2
|
+
import type { EffectRow, SignedEffectExtract } from "@cedulon/effect-extract";
|
|
3
|
+
/** Who asked. G2 fills `brain` from JWT `sub` and `scopes` from `scope`. */
|
|
4
|
+
export type Principal = {
|
|
5
|
+
brain: string;
|
|
6
|
+
scopes: ReadonlySet<string>;
|
|
7
|
+
/** JWT `iss`. Optional so existing fixtures stay `{ brain, scopes }`. */
|
|
8
|
+
iss?: string;
|
|
9
|
+
/** Explicit JWT `tenant` claim, when the issuer sends one. */
|
|
10
|
+
tenant?: string;
|
|
11
|
+
/** Explicit JWT `org` claim, when the issuer sends one and `tenant` is absent. */
|
|
12
|
+
org?: string;
|
|
13
|
+
};
|
|
14
|
+
/** Same shape as `@cedulon/mcp-guard` so a later wrap can sit on this type. */
|
|
15
|
+
export type ToolCall = {
|
|
16
|
+
name: string;
|
|
17
|
+
arguments: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
/** Same shape as `@cedulon/mcp-guard`. claimedHash is a side field; it does not enter the ledger or audit. */
|
|
20
|
+
export type ToolResult = {
|
|
21
|
+
content: {
|
|
22
|
+
type: "text";
|
|
23
|
+
text: string;
|
|
24
|
+
}[];
|
|
25
|
+
isError: boolean;
|
|
26
|
+
claimedHash?: string;
|
|
27
|
+
};
|
|
28
|
+
export type PolicyDecision = {
|
|
29
|
+
decision: DecisionKind;
|
|
30
|
+
reasonCode: string;
|
|
31
|
+
rule: string | null;
|
|
32
|
+
};
|
|
33
|
+
export type PolicyEvalCtx = {
|
|
34
|
+
spentTodayMinor?: (currency: string) => number;
|
|
35
|
+
};
|
|
36
|
+
export type PolicyLimits = {
|
|
37
|
+
ratePerMinute?: number;
|
|
38
|
+
dailyMax?: number;
|
|
39
|
+
diskFreeBytes: number;
|
|
40
|
+
};
|
|
41
|
+
export type Policy = {
|
|
42
|
+
hash: string;
|
|
43
|
+
approvalTtlMs: number;
|
|
44
|
+
limits: PolicyLimits;
|
|
45
|
+
/** Document-root posture. Absent keeps today's optional `_inputs`. */
|
|
46
|
+
requireInputs?: boolean;
|
|
47
|
+
evaluate(call: ToolCall, principal: Principal, ctx?: PolicyEvalCtx): PolicyDecision;
|
|
48
|
+
rule(id: string | null): {
|
|
49
|
+
id: string;
|
|
50
|
+
text: string;
|
|
51
|
+
} | null;
|
|
52
|
+
};
|
|
53
|
+
export type WitnessClass = "self" | "same-org" | "third-party" | "regulated";
|
|
54
|
+
export type EffectAttestation = {
|
|
55
|
+
coseHex: string;
|
|
56
|
+
};
|
|
57
|
+
export type LedgerEffect = {
|
|
58
|
+
row: EffectRow;
|
|
59
|
+
witnessClass: WitnessClass;
|
|
60
|
+
/** Hash of the ToolResult (or thrown payload). Not an EffectRow field. */
|
|
61
|
+
resultHash?: string;
|
|
62
|
+
/** One-row extract signed at call time. */
|
|
63
|
+
receipt?: SignedEffectExtract;
|
|
64
|
+
/** Signed { ref, effectHash, witnessClass, resultHash } via COSE Sign1. */
|
|
65
|
+
attestation?: EffectAttestation;
|
|
66
|
+
};
|
|
67
|
+
export type RecordSigner = {
|
|
68
|
+
privateKeyPem: string;
|
|
69
|
+
publicKeyPem: string;
|
|
70
|
+
};
|
|
71
|
+
export type EffectSigner = {
|
|
72
|
+
privateKeyPem: string;
|
|
73
|
+
publicKeyPem: string;
|
|
74
|
+
};
|
|
75
|
+
export type ExtractWindow = {
|
|
76
|
+
startMs: number;
|
|
77
|
+
endMs: number;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Third implementations (a later obsigna daemon) must be able to sit here.
|
|
81
|
+
* Append is async on purpose: no synchronous-file assumption.
|
|
82
|
+
*/
|
|
83
|
+
export type Ledger = {
|
|
84
|
+
appendDecision(signed: SignedDecisionRecord): Promise<void>;
|
|
85
|
+
appendDecisionChained(build: (prevRecordHash: string | null) => SignedDecisionRecord): Promise<void>;
|
|
86
|
+
appendEffect(row: EffectRow, witnessClass?: WitnessClass, resultHash?: string): Promise<void>;
|
|
87
|
+
decisions(): Promise<SignedDecisionRecord[]>;
|
|
88
|
+
effects(): Promise<LedgerEffect[]>;
|
|
89
|
+
lastDecisionHash(): Promise<string | null>;
|
|
90
|
+
exportExtract(window: ExtractWindow, signer: EffectSigner): Promise<SignedEffectExtract>;
|
|
91
|
+
};
|
|
92
|
+
export type DecisionInputRow = {
|
|
93
|
+
id: string;
|
|
94
|
+
versionHash: string;
|
|
95
|
+
validFromMs: number;
|
|
96
|
+
validUntilMs: number;
|
|
97
|
+
/** Optional provenance. Same idea as memory.put `source`; additive. */
|
|
98
|
+
source?: Record<string, unknown>;
|
|
99
|
+
};
|
|
100
|
+
export type DecisionInputs = {
|
|
101
|
+
principal: {
|
|
102
|
+
brain: string;
|
|
103
|
+
scopes: string[];
|
|
104
|
+
iss?: string;
|
|
105
|
+
tenant?: string;
|
|
106
|
+
org?: string;
|
|
107
|
+
};
|
|
108
|
+
inputs: DecisionInputRow[];
|
|
109
|
+
approver?: {
|
|
110
|
+
id: string;
|
|
111
|
+
via: ApprovalChannel | "proxy";
|
|
112
|
+
resolves: string;
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
/** How an operator's approval reached the ledger: `verax approve` on the machine, or the body's `/api/approve`. */
|
|
116
|
+
export type ApprovalChannel = "cli" | "http";
|
|
117
|
+
export type InputsLog = {
|
|
118
|
+
append(ref: string, inputs: DecisionInputs): Promise<void>;
|
|
119
|
+
get(ref: string): Promise<DecisionInputs | null>;
|
|
120
|
+
};
|
|
121
|
+
export type ResolvedInput = {
|
|
122
|
+
versionHash: string;
|
|
123
|
+
validFromMs: number;
|
|
124
|
+
validUntilMs: number;
|
|
125
|
+
};
|
|
126
|
+
export type ProxyDeps = {
|
|
127
|
+
policy: Policy;
|
|
128
|
+
recordSigner: RecordSigner;
|
|
129
|
+
effectSigner: EffectSigner;
|
|
130
|
+
ledger: Ledger;
|
|
131
|
+
now: () => number;
|
|
132
|
+
nonce: () => string;
|
|
133
|
+
inner: (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
|
|
134
|
+
inputsLog?: InputsLog;
|
|
135
|
+
resolveInput?: (id: string, principal?: Principal) => Promise<ResolvedInput | null>;
|
|
136
|
+
/** True when the call names another tenant's memory id or decision ref. */
|
|
137
|
+
checkTenantMismatch?: (call: ToolCall, principal: Principal) => Promise<boolean>;
|
|
138
|
+
};
|
|
139
|
+
export type ExplainFinding = {
|
|
140
|
+
code: string | null;
|
|
141
|
+
label: "conditional" | null;
|
|
142
|
+
detail: string | null;
|
|
143
|
+
summary: string;
|
|
144
|
+
notApplicable?: string[];
|
|
145
|
+
};
|
|
146
|
+
export type ExplainChain = {
|
|
147
|
+
intact: boolean;
|
|
148
|
+
breakAt: string | null;
|
|
149
|
+
};
|
|
150
|
+
export type ExplainWarning = {
|
|
151
|
+
id: string;
|
|
152
|
+
code: string;
|
|
153
|
+
detail: string;
|
|
154
|
+
};
|
|
155
|
+
export type ExplainOpts = {
|
|
156
|
+
issuerTrust?: {
|
|
157
|
+
publicKeyPem: string | readonly string[];
|
|
158
|
+
source?: "env" | "own-key";
|
|
159
|
+
};
|
|
160
|
+
extract?: import("@cedulon/effect-extract").SignedEffectExtract | import("@cedulon/x402-adapter").SignedRailExtract;
|
|
161
|
+
inputsLog?: InputsLog;
|
|
162
|
+
};
|
|
163
|
+
export type ExplainTrustRoot = {
|
|
164
|
+
pinned: boolean;
|
|
165
|
+
issuerMatches: boolean | null;
|
|
166
|
+
source: "env" | "own-key" | null;
|
|
167
|
+
};
|
|
168
|
+
export type ExplainPair = {
|
|
169
|
+
defer: SignedDecisionRecord | null;
|
|
170
|
+
resolution: SignedDecisionRecord | null;
|
|
171
|
+
};
|
|
172
|
+
export type ExplainResult = {
|
|
173
|
+
record: SignedDecisionRecord;
|
|
174
|
+
effect: LedgerEffect | null;
|
|
175
|
+
pair?: ExplainPair;
|
|
176
|
+
finding: ExplainFinding;
|
|
177
|
+
witnessClass: WitnessClass | null;
|
|
178
|
+
balanced: boolean;
|
|
179
|
+
chain: ExplainChain;
|
|
180
|
+
guarantee: "unconditional" | "conditional";
|
|
181
|
+
warnings: ExplainWarning[];
|
|
182
|
+
trustRoot: ExplainTrustRoot;
|
|
183
|
+
scope?: {
|
|
184
|
+
accountId: string;
|
|
185
|
+
railId: string;
|
|
186
|
+
windowStartMs: number;
|
|
187
|
+
windowEndMs: number;
|
|
188
|
+
};
|
|
189
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verax-ai/proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.6"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"policy"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@cedulon/audit": "0.13.1",
|
|
23
|
+
"@cedulon/core": "0.13.1",
|
|
24
|
+
"@cedulon/effect-extract": "0.13.1",
|
|
25
|
+
"@cedulon/x402-adapter": "0.13.1"
|
|
26
|
+
},
|
|
27
|
+
"description": "The decision proxy behind VERAX: policy, signed decision records, and the ledger they live in.",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"prepack": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/verax-ai/verax.git",
|
|
35
|
+
"directory": "packages/proxy"
|
|
36
|
+
}
|
|
37
|
+
}
|