@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/proxy.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { signDecisionRecord } from "@cedulon/core";
|
|
4
|
+
import { approvePending, approvalsLogFor, drainApprovalCommands } from "./approvals.js";
|
|
5
|
+
import { diskProbe } from "./disk.js";
|
|
6
|
+
import { effectDescriptor, sha256Canonical } from "./hash.js";
|
|
7
|
+
import { inputsLogFor } from "./inputs.js";
|
|
8
|
+
import { countedWork, hasPrimaryEffect, lookupDecisionByRef, lookupReauthByHash, lookupResolvedBy, noteResolution, noteTenantRef, } from "./ledger.js";
|
|
9
|
+
import { spokenReason } from "./spoken-reason.js";
|
|
10
|
+
import { tenantKey } from "./tenant.js";
|
|
11
|
+
function scopedClaimsRef(principal, raw) {
|
|
12
|
+
if (principal.iss || principal.tenant || principal.org) {
|
|
13
|
+
return `${tenantKey(principal)}:${raw}`;
|
|
14
|
+
}
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
function principalInputs(principal) {
|
|
18
|
+
return {
|
|
19
|
+
brain: principal.brain,
|
|
20
|
+
scopes: [...principal.scopes].sort(),
|
|
21
|
+
...(principal.iss ? { iss: principal.iss } : {}),
|
|
22
|
+
...(principal.tenant ? { tenant: principal.tenant } : {}),
|
|
23
|
+
...(principal.org ? { org: principal.org } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export class LedgerDenyUnrecorded extends Error {
|
|
27
|
+
reasonCode;
|
|
28
|
+
constructor(reasonCode, cause) {
|
|
29
|
+
super(`ledger-deny-unrecorded:${reasonCode}`);
|
|
30
|
+
this.name = "LedgerDenyUnrecorded";
|
|
31
|
+
this.reasonCode = reasonCode;
|
|
32
|
+
if (cause !== undefined)
|
|
33
|
+
this.cause = cause;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
37
|
+
function denied(reasonCode, ref, call) {
|
|
38
|
+
const spoken = spokenReason(reasonCode);
|
|
39
|
+
if (spoken !== reasonCode) {
|
|
40
|
+
const payload = { error: spoken };
|
|
41
|
+
if (call && typeof call.arguments.id === "string") {
|
|
42
|
+
payload.id = call.arguments.id;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: `denied:${reasonCode}:${ref}` }],
|
|
51
|
+
isError: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function deferred(ref) {
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: `deferred:approval-required:${ref}` }],
|
|
57
|
+
isError: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function allowedReplay(ref) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: `allowed:${ref}` }],
|
|
63
|
+
isError: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function deepFreeze(value) {
|
|
67
|
+
if (value === null || typeof value !== "object")
|
|
68
|
+
return value;
|
|
69
|
+
Object.freeze(value);
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
for (const item of value)
|
|
72
|
+
deepFreeze(item);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
for (const key of Object.keys(value)) {
|
|
76
|
+
deepFreeze(value[key]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function thrownPayload(err) {
|
|
82
|
+
if (err instanceof Error) {
|
|
83
|
+
return { name: err.name, message: err.message };
|
|
84
|
+
}
|
|
85
|
+
return { name: "thrown" };
|
|
86
|
+
}
|
|
87
|
+
function declaredInputs(args) {
|
|
88
|
+
if (!Object.prototype.hasOwnProperty.call(args, "_inputs"))
|
|
89
|
+
return null;
|
|
90
|
+
const raw = args._inputs;
|
|
91
|
+
if (!Array.isArray(raw))
|
|
92
|
+
return "invalid";
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const item of raw) {
|
|
95
|
+
if (item === null || typeof item !== "object" || Array.isArray(item))
|
|
96
|
+
return "invalid";
|
|
97
|
+
const rec = item;
|
|
98
|
+
if (typeof rec.id !== "string" || rec.id === "" || typeof rec.versionHash !== "string" || rec.versionHash === "") {
|
|
99
|
+
return "invalid";
|
|
100
|
+
}
|
|
101
|
+
const row = {
|
|
102
|
+
id: rec.id,
|
|
103
|
+
versionHash: rec.versionHash,
|
|
104
|
+
};
|
|
105
|
+
if (rec.source !== undefined) {
|
|
106
|
+
if (rec.source === null || typeof rec.source !== "object" || Array.isArray(rec.source)) {
|
|
107
|
+
return "invalid";
|
|
108
|
+
}
|
|
109
|
+
row.source = rec.source;
|
|
110
|
+
}
|
|
111
|
+
out.push(row);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function readRef(args) {
|
|
116
|
+
if (!Object.prototype.hasOwnProperty.call(args, "_ref"))
|
|
117
|
+
return null;
|
|
118
|
+
const raw = args._ref;
|
|
119
|
+
if (typeof raw !== "string" || !REF_RE.test(raw))
|
|
120
|
+
return "invalid";
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
function toolCallOf(call) {
|
|
124
|
+
const args = { ...call.arguments };
|
|
125
|
+
delete args._inputs;
|
|
126
|
+
delete args._ref;
|
|
127
|
+
return { name: call.name, arguments: args };
|
|
128
|
+
}
|
|
129
|
+
export function createProxy(deps) {
|
|
130
|
+
const inputsLog = deps.inputsLog ?? inputsLogFor(deps.ledger);
|
|
131
|
+
const approvals = approvalsLogFor(deps.ledger);
|
|
132
|
+
deps.ledger.effectSigner = deps.effectSigner;
|
|
133
|
+
const ledgerDir = deps.ledger.dir;
|
|
134
|
+
const stateDir = typeof ledgerDir === "string" ? ledgerDir : null;
|
|
135
|
+
if (stateDir === null) {
|
|
136
|
+
process.stderr.write("verax-proxy: halt and disk limits are inactive without a ledger directory\n");
|
|
137
|
+
}
|
|
138
|
+
async function writeRecord(opts) {
|
|
139
|
+
const inputsHash = sha256Canonical(opts.inputs);
|
|
140
|
+
await inputsLog.append(opts.ref, opts.inputs);
|
|
141
|
+
await deps.ledger.appendDecisionChained((prevRecordHash) => signDecisionRecord({
|
|
142
|
+
decider: opts.decider ?? "verax-proxy",
|
|
143
|
+
subject: opts.subject,
|
|
144
|
+
requestHash: opts.requestHash,
|
|
145
|
+
policyHash: deps.policy.hash,
|
|
146
|
+
inputsHash,
|
|
147
|
+
decision: opts.decision,
|
|
148
|
+
reasonCode: opts.reasonCode,
|
|
149
|
+
ref: opts.ref,
|
|
150
|
+
effectHash: opts.effectHash,
|
|
151
|
+
// The class of effect this decision names, in this channel's own
|
|
152
|
+
// vocabulary: the call it decided about. An allow must carry one, and
|
|
153
|
+
// a refusal names what it refused, so the audit can hold a decision
|
|
154
|
+
// and an effect row to the same word.
|
|
155
|
+
effectClass: opts.subject,
|
|
156
|
+
timestampMs: opts.timestampMs,
|
|
157
|
+
nonce: opts.ref,
|
|
158
|
+
prevRecordHash,
|
|
159
|
+
}, deps.recordSigner.privateKeyPem, deps.recordSigner.publicKeyPem));
|
|
160
|
+
if (opts.inputs.approver?.resolves) {
|
|
161
|
+
const kind = opts.reasonCode === "expired" ? "expired" : opts.decision === "allow" ? "allow" : null;
|
|
162
|
+
if (kind)
|
|
163
|
+
noteResolution(deps.ledger, opts.inputs.approver.resolves, { ref: opts.ref, kind });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function writeRecordOrUnrecorded(opts) {
|
|
167
|
+
if (diskProbe.failAppend) {
|
|
168
|
+
throw new LedgerDenyUnrecorded("ledger-disk-low");
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
await writeRecord(opts);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (opts.reasonCode === "ledger-disk-low") {
|
|
175
|
+
throw new LedgerDenyUnrecorded("ledger-disk-low", err);
|
|
176
|
+
}
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function rateBound(timestampMs, wouldCount) {
|
|
181
|
+
if (!wouldCount)
|
|
182
|
+
return null;
|
|
183
|
+
const limits = deps.policy.limits;
|
|
184
|
+
if (limits.ratePerMinute === undefined && limits.dailyMax === undefined)
|
|
185
|
+
return null;
|
|
186
|
+
const counts = countedWork(deps.ledger, timestampMs);
|
|
187
|
+
if (!counts.ok)
|
|
188
|
+
return "rate-limited";
|
|
189
|
+
if (limits.ratePerMinute !== undefined && counts.minute >= limits.ratePerMinute)
|
|
190
|
+
return "rate-limited";
|
|
191
|
+
if (limits.dailyMax !== undefined && counts.day >= limits.dailyMax)
|
|
192
|
+
return "daily-limited";
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
function utcDayStart(ms) {
|
|
196
|
+
const d = new Date(ms);
|
|
197
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
198
|
+
}
|
|
199
|
+
function spentTodayMinorOf(rows, nowMs, currency) {
|
|
200
|
+
const start = utcDayStart(nowMs);
|
|
201
|
+
const end = start + 86_400_000;
|
|
202
|
+
let sum = 0;
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
if (row.subject !== "spend")
|
|
205
|
+
continue;
|
|
206
|
+
if (row.status !== "pending" && row.status !== "approved")
|
|
207
|
+
continue;
|
|
208
|
+
const created = row.createdAtMs ?? row.expiresAtMs - deps.policy.approvalTtlMs;
|
|
209
|
+
if (created < start || created >= end)
|
|
210
|
+
continue;
|
|
211
|
+
if (row.args.currency !== currency)
|
|
212
|
+
continue;
|
|
213
|
+
const amt = row.args.amountMinor;
|
|
214
|
+
if (typeof amt === "number")
|
|
215
|
+
sum += amt;
|
|
216
|
+
}
|
|
217
|
+
return sum;
|
|
218
|
+
}
|
|
219
|
+
async function spendReauth(requestHash, inputs, timestampMs, subject) {
|
|
220
|
+
const existingReauth = await lookupReauthByHash(deps.ledger, requestHash);
|
|
221
|
+
if (existingReauth)
|
|
222
|
+
return denied("spend-reauth-required", existingReauth);
|
|
223
|
+
const ref = deps.nonce();
|
|
224
|
+
await writeRecord({
|
|
225
|
+
decision: "deny",
|
|
226
|
+
reasonCode: "spend-reauth-required",
|
|
227
|
+
ref,
|
|
228
|
+
requestHash,
|
|
229
|
+
inputs,
|
|
230
|
+
effectHash: null,
|
|
231
|
+
subject,
|
|
232
|
+
timestampMs,
|
|
233
|
+
});
|
|
234
|
+
return denied("spend-reauth-required", ref);
|
|
235
|
+
}
|
|
236
|
+
async function runInner(call, principal, ref) {
|
|
237
|
+
const dispatchedName = call.name;
|
|
238
|
+
const dispatchedArgs = deepFreeze(structuredClone(call.arguments));
|
|
239
|
+
const dispatchedHash = sha256Canonical(effectDescriptor(dispatchedName, dispatchedArgs));
|
|
240
|
+
const thrownHash = sha256Canonical(effectDescriptor(dispatchedName, dispatchedArgs, true));
|
|
241
|
+
const frozenCall = Object.freeze({
|
|
242
|
+
name: dispatchedName,
|
|
243
|
+
arguments: dispatchedArgs,
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
const result = await deps.inner(frozenCall, principal, ref);
|
|
247
|
+
const row = {
|
|
248
|
+
ref,
|
|
249
|
+
effectHash: dispatchedHash,
|
|
250
|
+
effectClass: dispatchedName,
|
|
251
|
+
timestampMs: deps.now(),
|
|
252
|
+
actor: principal.brain,
|
|
253
|
+
};
|
|
254
|
+
await deps.ledger.appendEffect(row, "self", sha256Canonical(result));
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
const row = {
|
|
259
|
+
ref,
|
|
260
|
+
effectHash: thrownHash,
|
|
261
|
+
effectClass: `${dispatchedName}:threw`,
|
|
262
|
+
timestampMs: deps.now(),
|
|
263
|
+
actor: principal.brain,
|
|
264
|
+
};
|
|
265
|
+
await deps.ledger.appendEffect(row, "self", sha256Canonical(thrownPayload(err)));
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function resolveInputs(call, principal, timestampMs) {
|
|
270
|
+
const declared = declaredInputs(call.arguments);
|
|
271
|
+
if (declared === "invalid") {
|
|
272
|
+
return {
|
|
273
|
+
inputs: { principal: principalInputs(principal), inputs: [] },
|
|
274
|
+
reasonCode: "input-invalid",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (declared === null && deps.policy.requireInputs === true) {
|
|
278
|
+
return {
|
|
279
|
+
inputs: { principal: principalInputs(principal), inputs: [] },
|
|
280
|
+
reasonCode: "inputs-required",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const rows = [];
|
|
284
|
+
if (declared) {
|
|
285
|
+
for (const item of declared) {
|
|
286
|
+
const got = deps.resolveInput ? await deps.resolveInput(item.id, principal) : null;
|
|
287
|
+
if (!got ||
|
|
288
|
+
got.versionHash !== item.versionHash ||
|
|
289
|
+
got.validFromMs > timestampMs ||
|
|
290
|
+
got.validUntilMs < timestampMs) {
|
|
291
|
+
return {
|
|
292
|
+
inputs: { principal: principalInputs(principal), inputs: [] },
|
|
293
|
+
reasonCode: "input-invalid",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const row = {
|
|
297
|
+
id: item.id,
|
|
298
|
+
versionHash: item.versionHash,
|
|
299
|
+
validFromMs: got.validFromMs,
|
|
300
|
+
validUntilMs: got.validUntilMs,
|
|
301
|
+
};
|
|
302
|
+
if (item.source)
|
|
303
|
+
row.source = item.source;
|
|
304
|
+
rows.push(row);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
inputs: { principal: principalInputs(principal), inputs: rows },
|
|
309
|
+
reasonCode: null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
approvals,
|
|
314
|
+
inputsLog,
|
|
315
|
+
async call(call, principal) {
|
|
316
|
+
if (stateDir) {
|
|
317
|
+
await drainApprovalCommands(stateDir, async (cmd) => {
|
|
318
|
+
const defer = await lookupDecisionByRef(deps.ledger, cmd.ref);
|
|
319
|
+
if (!defer || defer.decision !== "defer")
|
|
320
|
+
return;
|
|
321
|
+
await approvePending({
|
|
322
|
+
ledger: deps.ledger,
|
|
323
|
+
recordSigner: deps.recordSigner,
|
|
324
|
+
now: deps.now,
|
|
325
|
+
nonce: deps.nonce,
|
|
326
|
+
ref: cmd.ref,
|
|
327
|
+
approverId: cmd.approverId,
|
|
328
|
+
// Only `verax approve` queues a command, when the ledger is locked.
|
|
329
|
+
via: "cli",
|
|
330
|
+
policyHash: defer.policyHash,
|
|
331
|
+
approvals,
|
|
332
|
+
inputsLog,
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const dispatched = toolCallOf(call);
|
|
337
|
+
const requestHash = sha256Canonical(dispatched);
|
|
338
|
+
const timestampMs = deps.now();
|
|
339
|
+
const resolved = await resolveInputs(call, principal, timestampMs);
|
|
340
|
+
if (stateDir && existsSync(join(stateDir, "halted"))) {
|
|
341
|
+
const ref = deps.nonce();
|
|
342
|
+
await writeRecord({
|
|
343
|
+
decision: "deny",
|
|
344
|
+
reasonCode: "halted",
|
|
345
|
+
ref,
|
|
346
|
+
requestHash,
|
|
347
|
+
inputs: resolved.inputs,
|
|
348
|
+
effectHash: null,
|
|
349
|
+
subject: call.name,
|
|
350
|
+
timestampMs,
|
|
351
|
+
});
|
|
352
|
+
return denied("halted", ref);
|
|
353
|
+
}
|
|
354
|
+
if (stateDir && diskProbe.freeBytes(stateDir) < deps.policy.limits.diskFreeBytes) {
|
|
355
|
+
const ref = deps.nonce();
|
|
356
|
+
await writeRecordOrUnrecorded({
|
|
357
|
+
decision: "deny",
|
|
358
|
+
reasonCode: "ledger-disk-low",
|
|
359
|
+
ref,
|
|
360
|
+
requestHash,
|
|
361
|
+
inputs: resolved.inputs,
|
|
362
|
+
effectHash: null,
|
|
363
|
+
subject: call.name,
|
|
364
|
+
timestampMs,
|
|
365
|
+
});
|
|
366
|
+
return denied("ledger-disk-low", ref);
|
|
367
|
+
}
|
|
368
|
+
const given = readRef(call.arguments);
|
|
369
|
+
if (given === "invalid") {
|
|
370
|
+
const ref = deps.nonce();
|
|
371
|
+
await writeRecord({
|
|
372
|
+
decision: "deny",
|
|
373
|
+
reasonCode: "ref-invalid",
|
|
374
|
+
ref,
|
|
375
|
+
requestHash,
|
|
376
|
+
inputs: resolved.inputs,
|
|
377
|
+
effectHash: null,
|
|
378
|
+
subject: call.name,
|
|
379
|
+
timestampMs,
|
|
380
|
+
});
|
|
381
|
+
return denied("ref-invalid", ref);
|
|
382
|
+
}
|
|
383
|
+
if (typeof given === "string") {
|
|
384
|
+
// The ledger, the approvals snapshot and the resolution index all key on the
|
|
385
|
+
// scoped ref. The brain only ever sees and resends the raw `_ref` it chose.
|
|
386
|
+
const scopedRef = scopedClaimsRef(principal, given);
|
|
387
|
+
const existing = await lookupDecisionByRef(deps.ledger, given, tenantKey(principal));
|
|
388
|
+
if (existing) {
|
|
389
|
+
if (existing.requestHash !== requestHash) {
|
|
390
|
+
const ref = deps.nonce();
|
|
391
|
+
await writeRecord({
|
|
392
|
+
decision: "deny",
|
|
393
|
+
reasonCode: "ref-reuse",
|
|
394
|
+
ref,
|
|
395
|
+
requestHash,
|
|
396
|
+
inputs: resolved.inputs,
|
|
397
|
+
effectHash: null,
|
|
398
|
+
subject: call.name,
|
|
399
|
+
timestampMs,
|
|
400
|
+
});
|
|
401
|
+
return denied("ref-reuse", ref);
|
|
402
|
+
}
|
|
403
|
+
if (existing.decision === "defer") {
|
|
404
|
+
const snap = await approvals.get(scopedRef);
|
|
405
|
+
const boundHit = lookupResolvedBy(deps.ledger, scopedRef);
|
|
406
|
+
if (boundHit?.kind === "expired") {
|
|
407
|
+
if (snap?.status === "pending")
|
|
408
|
+
await approvals.updateStatus(scopedRef, "expired");
|
|
409
|
+
return denied("expired", boundHit.ref);
|
|
410
|
+
}
|
|
411
|
+
if (snap && timestampMs > snap.expiresAtMs) {
|
|
412
|
+
const expireRef = deps.nonce();
|
|
413
|
+
await writeRecord({
|
|
414
|
+
decision: "deny",
|
|
415
|
+
reasonCode: "expired",
|
|
416
|
+
ref: expireRef,
|
|
417
|
+
requestHash,
|
|
418
|
+
inputs: {
|
|
419
|
+
...resolved.inputs,
|
|
420
|
+
approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
|
|
421
|
+
},
|
|
422
|
+
effectHash: null,
|
|
423
|
+
subject: call.name,
|
|
424
|
+
timestampMs,
|
|
425
|
+
});
|
|
426
|
+
await approvals.updateStatus(scopedRef, "expired");
|
|
427
|
+
return denied("expired", expireRef);
|
|
428
|
+
}
|
|
429
|
+
const allowRef = snap?.allowRef ?? (boundHit?.kind === "allow" ? boundHit.ref : undefined);
|
|
430
|
+
const allow = allowRef ? await lookupDecisionByRef(deps.ledger, allowRef) : null;
|
|
431
|
+
if (allow?.ref && allow.decision === "allow" && allow.reasonCode === "approved-by-operator") {
|
|
432
|
+
const bound = await inputsLog.get(allow.ref);
|
|
433
|
+
if (bound?.approver?.resolves && bound.approver.resolves !== scopedRef) {
|
|
434
|
+
return deferred(given);
|
|
435
|
+
}
|
|
436
|
+
if (!snap?.allowRef) {
|
|
437
|
+
await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
|
|
438
|
+
}
|
|
439
|
+
if (await hasPrimaryEffect(deps.ledger, allow.ref))
|
|
440
|
+
return allowedReplay(allow.ref);
|
|
441
|
+
if (allow.subject === "spend") {
|
|
442
|
+
return spendReauth(requestHash, resolved.inputs, timestampMs, call.name);
|
|
443
|
+
}
|
|
444
|
+
return runInner(dispatched, principal, allow.ref);
|
|
445
|
+
}
|
|
446
|
+
return deferred(given);
|
|
447
|
+
}
|
|
448
|
+
if (existing.decision === "deny") {
|
|
449
|
+
return denied(existing.reasonCode, given, call);
|
|
450
|
+
}
|
|
451
|
+
if (existing.decision === "allow" && existing.ref) {
|
|
452
|
+
if (await hasPrimaryEffect(deps.ledger, existing.ref)) {
|
|
453
|
+
return allowedReplay(existing.ref);
|
|
454
|
+
}
|
|
455
|
+
if (existing.subject === "spend") {
|
|
456
|
+
return spendReauth(requestHash, resolved.inputs, timestampMs, call.name);
|
|
457
|
+
}
|
|
458
|
+
return runInner(dispatched, principal, existing.ref);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
let spendCtx;
|
|
463
|
+
if (dispatched.name === "spend") {
|
|
464
|
+
const approvalRows = await approvals.listAll();
|
|
465
|
+
spendCtx = {
|
|
466
|
+
spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const verdict = deps.policy.evaluate(dispatched, principal, spendCtx);
|
|
470
|
+
let reasonCode = resolved.reasonCode ?? verdict.reasonCode;
|
|
471
|
+
let decision = resolved.reasonCode ? "deny" : verdict.decision;
|
|
472
|
+
const bound = rateBound(timestampMs, decision === "allow" || decision === "defer");
|
|
473
|
+
if (bound) {
|
|
474
|
+
decision = "deny";
|
|
475
|
+
reasonCode = bound;
|
|
476
|
+
}
|
|
477
|
+
if (!bound &&
|
|
478
|
+
decision === "allow" &&
|
|
479
|
+
deps.checkTenantMismatch &&
|
|
480
|
+
(await deps.checkTenantMismatch(dispatched, principal))) {
|
|
481
|
+
decision = "deny";
|
|
482
|
+
reasonCode = "tenant-mismatch";
|
|
483
|
+
}
|
|
484
|
+
const ref = typeof given === "string" ? scopedClaimsRef(principal, given) : deps.nonce();
|
|
485
|
+
// What the brain is told: its own `_ref`, so a retry carries the same key back.
|
|
486
|
+
const shown = typeof given === "string" ? given : ref;
|
|
487
|
+
const allow = decision === "allow";
|
|
488
|
+
await writeRecord({
|
|
489
|
+
decision,
|
|
490
|
+
reasonCode,
|
|
491
|
+
ref,
|
|
492
|
+
requestHash,
|
|
493
|
+
inputs: resolved.inputs,
|
|
494
|
+
effectHash: allow ? sha256Canonical(effectDescriptor(dispatched.name, dispatched.arguments)) : null,
|
|
495
|
+
subject: call.name,
|
|
496
|
+
timestampMs,
|
|
497
|
+
});
|
|
498
|
+
if (typeof given === "string") {
|
|
499
|
+
noteTenantRef(deps.ledger, tenantKey(principal), given);
|
|
500
|
+
}
|
|
501
|
+
if (decision === "defer") {
|
|
502
|
+
const rule = deps.policy.rule(verdict.rule);
|
|
503
|
+
const amount = dispatched.name === "spend" ? dispatched.arguments.amountMinor : dispatched.arguments.amount;
|
|
504
|
+
const payee = dispatched.arguments.payee;
|
|
505
|
+
const currency = dispatched.arguments.currency;
|
|
506
|
+
await approvals.append({
|
|
507
|
+
ref,
|
|
508
|
+
requestHash,
|
|
509
|
+
subject: dispatched.name,
|
|
510
|
+
args: dispatched.arguments,
|
|
511
|
+
ruleId: verdict.rule,
|
|
512
|
+
ruleText: rule?.text ?? null,
|
|
513
|
+
inputsSummary: {
|
|
514
|
+
count: resolved.inputs.inputs.length,
|
|
515
|
+
ids: resolved.inputs.inputs.map((i) => i.id),
|
|
516
|
+
},
|
|
517
|
+
...(amount !== undefined ? { amount } : {}),
|
|
518
|
+
...(payee !== undefined ? { payee } : {}),
|
|
519
|
+
...(currency !== undefined ? { currency } : {}),
|
|
520
|
+
createdAtMs: timestampMs,
|
|
521
|
+
expiresAtMs: timestampMs + deps.policy.approvalTtlMs,
|
|
522
|
+
status: "pending",
|
|
523
|
+
brain: principal.brain,
|
|
524
|
+
});
|
|
525
|
+
return deferred(shown);
|
|
526
|
+
}
|
|
527
|
+
if (!allow) {
|
|
528
|
+
return denied(reasonCode, shown, call);
|
|
529
|
+
}
|
|
530
|
+
return runInner(dispatched, principal, ref);
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { EffectRow } from "@cedulon/effect-extract";
|
|
2
|
+
import type { ApprovalRow } from "./approvals.ts";
|
|
3
|
+
import type { LedgerEffect } from "./types.ts";
|
|
4
|
+
export type ChannelRow = {
|
|
5
|
+
channel: string;
|
|
6
|
+
externalId: string;
|
|
7
|
+
occurredAtMs: number;
|
|
8
|
+
subject: string;
|
|
9
|
+
actor?: string;
|
|
10
|
+
ref?: string;
|
|
11
|
+
nearestEffectDtMs?: number;
|
|
12
|
+
amountMinor?: number;
|
|
13
|
+
currency?: string;
|
|
14
|
+
credit?: boolean;
|
|
15
|
+
reason?: string;
|
|
16
|
+
/** Statement text when parsed from a card CSV. */
|
|
17
|
+
descriptor?: string;
|
|
18
|
+
datePrecision?: "day" | "minute";
|
|
19
|
+
/** Window used for this row. Set by reconcile. */
|
|
20
|
+
toleranceMs?: number;
|
|
21
|
+
};
|
|
22
|
+
export type CardCsvSkip = {
|
|
23
|
+
line: number;
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
export type ParsedCardCsv = ChannelRow[] & {
|
|
27
|
+
skipped: CardCsvSkip[];
|
|
28
|
+
};
|
|
29
|
+
export type ReconcileReport = {
|
|
30
|
+
scope: {
|
|
31
|
+
channel: string;
|
|
32
|
+
windowStartMs: number;
|
|
33
|
+
windowEndMs: number;
|
|
34
|
+
rowCount: number;
|
|
35
|
+
skipped?: CardCsvSkip[];
|
|
36
|
+
/** Policy snapshot hash when card descriptors were read from one file. */
|
|
37
|
+
policyHash?: string;
|
|
38
|
+
};
|
|
39
|
+
matched: Array<{
|
|
40
|
+
channel: ChannelRow;
|
|
41
|
+
effect: EffectRow;
|
|
42
|
+
datePrecision?: "day" | "minute";
|
|
43
|
+
toleranceMs?: number;
|
|
44
|
+
}>;
|
|
45
|
+
ghost: ChannelRow[];
|
|
46
|
+
unsent: EffectRow[];
|
|
47
|
+
authorizedUnpaid: EffectRow[];
|
|
48
|
+
outOfScope: ChannelRow[];
|
|
49
|
+
};
|
|
50
|
+
export type CardCsvOpts = {
|
|
51
|
+
currency: string;
|
|
52
|
+
columns?: {
|
|
53
|
+
date: string;
|
|
54
|
+
amount: string;
|
|
55
|
+
description: string;
|
|
56
|
+
id?: string;
|
|
57
|
+
};
|
|
58
|
+
delimiter?: ";" | ",";
|
|
59
|
+
decimal?: "," | ".";
|
|
60
|
+
dateFormat?: "DD.MM.YYYY" | "YYYY-MM-DD";
|
|
61
|
+
/**
|
|
62
|
+
* Minutes east of UTC that the statement's printed times are in (Istanbul: 180).
|
|
63
|
+
* A statement prints local wall-clock time and names no zone, so without this the
|
|
64
|
+
* time cannot be placed on the clock and the row stays a day row.
|
|
65
|
+
*/
|
|
66
|
+
tzOffsetMinutes?: number;
|
|
67
|
+
};
|
|
68
|
+
export declare function parseCardCsv(text: string, opts: CardCsvOpts): ParsedCardCsv;
|
|
69
|
+
export declare function parseChannelJsonl(text: string): ChannelRow[];
|
|
70
|
+
/** Read-only: does not take ledger.lock. */
|
|
71
|
+
export declare function loadEffectsFromDir(stateDir: string): LedgerEffect[];
|
|
72
|
+
export declare function reconcile(channelRows: readonly ChannelRow[], effects: readonly LedgerEffect[], opts?: {
|
|
73
|
+
toleranceMs?: number;
|
|
74
|
+
window?: {
|
|
75
|
+
startMs: number;
|
|
76
|
+
endMs: number;
|
|
77
|
+
};
|
|
78
|
+
approvals?: readonly ApprovalRow[];
|
|
79
|
+
skipped?: CardCsvSkip[];
|
|
80
|
+
descriptorsByPayee?: Readonly<Record<string, readonly string[]>>;
|
|
81
|
+
}): ReconcileReport;
|