@verax-ai/body 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/approve-cli.d.ts +12 -0
- package/dist/approve-cli.js +93 -0
- package/dist/atomic-write.d.ts +1 -0
- package/dist/atomic-write.js +65 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +59 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +112 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +71 -0
- package/dist/desktop.d.ts +29 -0
- package/dist/desktop.js +341 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +342 -0
- package/dist/halt.d.ts +1 -0
- package/dist/halt.js +12 -0
- package/dist/health-extras.d.ts +11 -0
- package/dist/health-extras.js +47 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/inputs-read.d.ts +12 -0
- package/dist/inputs-read.js +64 -0
- package/dist/inventory-file.d.ts +18 -0
- package/dist/inventory-file.js +41 -0
- package/dist/keys.d.ts +12 -0
- package/dist/keys.js +55 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +34 -0
- package/dist/metrics.d.ts +2 -0
- package/dist/metrics.js +23 -0
- package/dist/no-bypass-scan.d.ts +10 -0
- package/dist/no-bypass-scan.js +95 -0
- package/dist/operator-cli.d.ts +1 -0
- package/dist/operator-cli.js +25 -0
- package/dist/operator-credentials.d.ts +23 -0
- package/dist/operator-credentials.js +56 -0
- package/dist/operator-pairing.d.ts +25 -0
- package/dist/operator-pairing.js +67 -0
- package/dist/policy-store.d.ts +3 -0
- package/dist/policy-store.js +44 -0
- package/dist/reconcile-cli.d.ts +24 -0
- package/dist/reconcile-cli.js +258 -0
- package/dist/revoke.d.ts +1 -0
- package/dist/revoke.js +16 -0
- package/dist/rp-config.d.ts +15 -0
- package/dist/rp-config.js +30 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +484 -0
- package/dist/tools/audit.d.ts +2 -0
- package/dist/tools/audit.js +48 -0
- package/dist/tools/memory.d.ts +10 -0
- package/dist/tools/memory.js +137 -0
- package/dist/tools/message.d.ts +4 -0
- package/dist/tools/message.js +44 -0
- package/dist/tools/spend.d.ts +3 -0
- package/dist/tools/spend.js +19 -0
- package/dist/unlock.d.ts +9 -0
- package/dist/unlock.js +109 -0
- package/dist/wiring.d.ts +23 -0
- package/dist/wiring.js +101 -0
- package/dist/witness.d.ts +33 -0
- package/dist/witness.js +321 -0
- package/package.json +40 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { approvalsLogFor, approvePending, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
|
|
7
|
+
import { createVerifier, readBearer, resourceMetadataUrl, wwwAuthenticate } from "./auth.js";
|
|
8
|
+
import { bumpMetric, bumpUnauthenticated } from "./metrics.js";
|
|
9
|
+
import { isRevokedJti } from "./revoke.js";
|
|
10
|
+
import { loadOrCreateSigners } from "./keys.js";
|
|
11
|
+
import { matchingInputs } from "./inputs-read.js";
|
|
12
|
+
import { readPolicySnapshots } from "./policy-store.js";
|
|
13
|
+
import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
|
|
14
|
+
import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
|
|
15
|
+
import { createBodyServices, TOOL_NAMES } from "./wiring.js";
|
|
16
|
+
const TOOL_META = [
|
|
17
|
+
{
|
|
18
|
+
name: "memory.get",
|
|
19
|
+
description: "Read a memory item. Stale items return { stale: true } without the body.",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
properties: { id: { type: "string" } },
|
|
24
|
+
required: ["id"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "memory.put",
|
|
29
|
+
description: "Write a memory item. source and validUntilMs are required.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
properties: {
|
|
34
|
+
id: { type: "string" },
|
|
35
|
+
body: {},
|
|
36
|
+
source: { type: "object" },
|
|
37
|
+
validFromMs: { type: "number" },
|
|
38
|
+
validUntilMs: { type: "number" },
|
|
39
|
+
},
|
|
40
|
+
required: ["id", "body", "source", "validUntilMs"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "audit.explain",
|
|
45
|
+
description: "Explain a decision ref against the ledger.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
properties: { ref: { type: "string" } },
|
|
50
|
+
required: ["ref"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "message.read",
|
|
55
|
+
description: "Read the local inbox fixture as JSON.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: {},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "message.send",
|
|
64
|
+
description: "Queue a message on the local outbox. Does not open a network.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
to: { type: "string" },
|
|
70
|
+
text: { type: "string" },
|
|
71
|
+
},
|
|
72
|
+
required: ["to", "text"],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "spend",
|
|
77
|
+
description: "Authorizes a payment; does not move money.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: "object",
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
properties: {
|
|
82
|
+
amountMinor: { type: "integer" },
|
|
83
|
+
currency: { type: "string" },
|
|
84
|
+
payee: { type: "string" },
|
|
85
|
+
reference: { type: "string" },
|
|
86
|
+
},
|
|
87
|
+
required: ["amountMinor", "currency", "payee", "reference"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
92
|
+
const responseSlot = new AsyncLocalStorage();
|
|
93
|
+
function contentLengthOverLimit(req) {
|
|
94
|
+
const raw = req.headers["content-length"];
|
|
95
|
+
if (raw === undefined)
|
|
96
|
+
return false;
|
|
97
|
+
const n = Number(Array.isArray(raw) ? raw[0] : raw);
|
|
98
|
+
return Number.isFinite(n) && n > MAX_BODY_BYTES;
|
|
99
|
+
}
|
|
100
|
+
async function readJsonBody(req, max) {
|
|
101
|
+
if (contentLengthOverLimit(req))
|
|
102
|
+
return { ok: false, tooLarge: true };
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let seen = 0;
|
|
105
|
+
try {
|
|
106
|
+
for await (const chunk of req) {
|
|
107
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
108
|
+
seen += buf.length;
|
|
109
|
+
if (seen > max) {
|
|
110
|
+
return { ok: false, tooLarge: true };
|
|
111
|
+
}
|
|
112
|
+
chunks.push(buf);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { ok: false, bad: true };
|
|
117
|
+
}
|
|
118
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
119
|
+
if (text === "")
|
|
120
|
+
return { ok: true, value: undefined };
|
|
121
|
+
try {
|
|
122
|
+
return { ok: true, value: JSON.parse(text) };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { ok: false, bad: true };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function isProtectedResourcePath(pathname, audience) {
|
|
129
|
+
const want = new URL(resourceMetadataUrl(audience)).pathname;
|
|
130
|
+
return pathname === want || pathname === "/.well-known/oauth-protected-resource";
|
|
131
|
+
}
|
|
132
|
+
function send(res, status, body, headers) {
|
|
133
|
+
const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
134
|
+
res.writeHead(status, {
|
|
135
|
+
"content-type": "application/json",
|
|
136
|
+
"content-length": Buffer.byteLength(text),
|
|
137
|
+
"x-content-type-options": "nosniff",
|
|
138
|
+
...headers,
|
|
139
|
+
});
|
|
140
|
+
res.end(text);
|
|
141
|
+
}
|
|
142
|
+
export async function listen(config) {
|
|
143
|
+
const signers = loadOrCreateSigners(config.stateDir);
|
|
144
|
+
const services = createBodyServices({
|
|
145
|
+
stateDir: config.stateDir,
|
|
146
|
+
policyFile: config.policyFile,
|
|
147
|
+
recordSigner: signers.recordSigner,
|
|
148
|
+
effectSigner: signers.effectSigner,
|
|
149
|
+
});
|
|
150
|
+
const verify = createVerifier(config.jwksUrl, config.issuer, config.audience);
|
|
151
|
+
const attachHandlers = (mcp) => {
|
|
152
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_META }));
|
|
153
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
154
|
+
const auth = extra?.authInfo;
|
|
155
|
+
const scopes = new Set(auth?.scopes ?? []);
|
|
156
|
+
const bag = (auth?.extra ?? {});
|
|
157
|
+
const brain = typeof bag.sub === "string" ? bag.sub : (auth?.clientId ?? "unknown");
|
|
158
|
+
try {
|
|
159
|
+
return await services.proxy.call({ name: request.params.name, arguments: (request.params.arguments ?? {}) }, {
|
|
160
|
+
brain,
|
|
161
|
+
scopes,
|
|
162
|
+
...(typeof bag.iss === "string" ? { iss: bag.iss } : {}),
|
|
163
|
+
...(typeof bag.tenant === "string" ? { tenant: bag.tenant } : {}),
|
|
164
|
+
...(typeof bag.org === "string" ? { org: bag.org } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (err instanceof LedgerDenyUnrecorded) {
|
|
169
|
+
await bumpMetric(config.stateDir, "disk_deny_unrecorded");
|
|
170
|
+
const res = responseSlot.getStore();
|
|
171
|
+
if (res && !res.headersSent) {
|
|
172
|
+
send(res, 507, { error: "insufficient-storage" });
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const server = createServer(async (req, res) => {
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
183
|
+
if (req.method === "POST" && contentLengthOverLimit(req)) {
|
|
184
|
+
send(res, 413, { error: "payload-too-large" });
|
|
185
|
+
req.resume();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
189
|
+
const token = readBearer(req.headers.authorization);
|
|
190
|
+
let canRead = false;
|
|
191
|
+
if (token) {
|
|
192
|
+
try {
|
|
193
|
+
const verified = await verify(token);
|
|
194
|
+
// Counts name how often and when the body worked. `verax:read` is a
|
|
195
|
+
// brain scope, so it cannot be the key here; the operator session
|
|
196
|
+
// carries `verax:audit`. A revoked or jti-less token is an
|
|
197
|
+
// unauthenticated probe: liveness only.
|
|
198
|
+
const jti = typeof verified.payload.jti === "string" ? verified.payload.jti : "";
|
|
199
|
+
canRead =
|
|
200
|
+
jti !== "" &&
|
|
201
|
+
!isRevokedJti(config.stateDir, jti) &&
|
|
202
|
+
verified.principal.scopes.has("verax:audit");
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
canRead = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!canRead) {
|
|
209
|
+
send(res, 200, { ok: true });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const decisions = await services.ledger.decisions();
|
|
213
|
+
const effects = await services.ledger.effects();
|
|
214
|
+
const last = decisions[decisions.length - 1];
|
|
215
|
+
send(res, 200, {
|
|
216
|
+
ok: true,
|
|
217
|
+
decisions: decisions.length,
|
|
218
|
+
effects: effects.length,
|
|
219
|
+
lastDecisionMs: last ? last.claims.timestampMs : null,
|
|
220
|
+
lock: services.ledger.lockStatus(),
|
|
221
|
+
heartbeat: readHeartbeat(config.stateDir),
|
|
222
|
+
witness: readWitnessPulse(config.stateDir),
|
|
223
|
+
inventory: inventoryHealth(readInventoryFile(config.inventoryFile)),
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (req.method === "GET" && isProtectedResourcePath(url.pathname, config.audience)) {
|
|
228
|
+
send(res, 200, {
|
|
229
|
+
resource: config.audience,
|
|
230
|
+
authorization_servers: [config.issuer],
|
|
231
|
+
bearer_methods_supported: ["header"],
|
|
232
|
+
scopes_supported: [
|
|
233
|
+
"verax:read",
|
|
234
|
+
"verax:memory",
|
|
235
|
+
"verax:act",
|
|
236
|
+
"verax:pay",
|
|
237
|
+
"verax:audit",
|
|
238
|
+
"verax:approve",
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const apiLedger = req.method === "GET" && url.pathname === "/api/ledger";
|
|
244
|
+
const apiInventory = url.pathname === "/api/inventory";
|
|
245
|
+
const contest = req.method === "POST" && url.pathname.startsWith("/api/contest/");
|
|
246
|
+
const apiApprove = req.method === "POST" && url.pathname === "/api/approve";
|
|
247
|
+
if (url.pathname !== "/mcp" && !apiLedger && !apiInventory && !contest && !apiApprove) {
|
|
248
|
+
send(res, 404, { error: "not-found" });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const token = readBearer(req.headers.authorization);
|
|
252
|
+
if (!token) {
|
|
253
|
+
await bumpUnauthenticated(config.stateDir);
|
|
254
|
+
send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let verified;
|
|
258
|
+
try {
|
|
259
|
+
verified = await verify(token);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
await bumpUnauthenticated(config.stateDir);
|
|
263
|
+
send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const jti = typeof verified.payload.jti === "string" ? verified.payload.jti : "";
|
|
267
|
+
if (jti === "" || isRevokedJti(config.stateDir, jti)) {
|
|
268
|
+
await bumpUnauthenticated(config.stateDir);
|
|
269
|
+
send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
if (apiApprove) {
|
|
274
|
+
// Reading the ledger is not approving from it. The audit scope opens
|
|
275
|
+
// every door above; this one signs a new decision and lets money go,
|
|
276
|
+
// so it has a scope of its own.
|
|
277
|
+
if (!verified.principal.scopes.has("verax:approve")) {
|
|
278
|
+
send(res, 403, { error: "scope-missing" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const parsed = await readJsonBody(req, MAX_BODY_BYTES);
|
|
282
|
+
if (!parsed.ok) {
|
|
283
|
+
send(res, 400, { error: "bad-body" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const asked = (parsed.value ?? {});
|
|
287
|
+
const ref = typeof asked.ref === "string" ? asked.ref : "";
|
|
288
|
+
const sawHash = typeof asked.requestHash === "string" ? asked.requestHash : "";
|
|
289
|
+
if (ref === "" || sawHash === "") {
|
|
290
|
+
send(res, 400, { error: "ref-and-requestHash-required" });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const waiting = loadApprovalsFromDir(config.stateDir).find((row) => row.ref === ref);
|
|
294
|
+
if (!waiting) {
|
|
295
|
+
send(res, 404, { error: "unknown-ref" });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// The list on a phone can be ten minutes old. An approval names the
|
|
299
|
+
// request the operator was looking at, and if that is not what is
|
|
300
|
+
// waiting any more, nothing is approved: "I approved what I saw" is
|
|
301
|
+
// only true when something checks it.
|
|
302
|
+
if (waiting.requestHash !== sawHash) {
|
|
303
|
+
send(res, 409, { error: "stale", requestHash: waiting.requestHash });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const decisions = await services.ledger.decisions();
|
|
307
|
+
const defer = decisions.find((d) => d.claims.ref === ref && d.claims.decision === "defer");
|
|
308
|
+
if (!defer) {
|
|
309
|
+
send(res, 404, { error: "unknown-ref" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Who approved, on the record. The CLI writes the machine's login name,
|
|
313
|
+
// which says nothing about the person holding the phone; the session's
|
|
314
|
+
// own subject does.
|
|
315
|
+
const approver = verified.principal.brain;
|
|
316
|
+
const outcome = await approvePending({
|
|
317
|
+
ledger: services.ledger,
|
|
318
|
+
recordSigner: loadOrCreateSigners(config.stateDir).recordSigner,
|
|
319
|
+
now: () => Date.now(),
|
|
320
|
+
nonce: () => crypto.randomUUID(),
|
|
321
|
+
ref,
|
|
322
|
+
approverId: approver,
|
|
323
|
+
via: "http",
|
|
324
|
+
policyHash: defer.claims.policyHash,
|
|
325
|
+
approvals: approvalsLogFor(services.ledger),
|
|
326
|
+
});
|
|
327
|
+
if (!outcome.ok) {
|
|
328
|
+
const code = outcome.reason === "unknown-ref" || outcome.reason === "snapshot-missing" ? 404 : 409;
|
|
329
|
+
send(res, code, { error: outcome.reason });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
send(res, 200, { allowRef: outcome.allowRef, approver });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (apiLedger || apiInventory || contest) {
|
|
336
|
+
// The audit doors hand out the whole ledger: every tenant's decisions, the
|
|
337
|
+
// inputs documents that name their principals, and the approval snapshots
|
|
338
|
+
// that carry spend arguments. `verax:read` is a brain scope, so it cannot be
|
|
339
|
+
// the key here; the operator session carries `verax:audit`.
|
|
340
|
+
if (!verified.principal.scopes.has("verax:audit")) {
|
|
341
|
+
send(res, 403, { error: "scope-missing" });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (apiInventory) {
|
|
345
|
+
if (req.method !== "GET") {
|
|
346
|
+
send(res, 405, { error: "method-not-allowed" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
send(res, 200, readInventoryFile(config.inventoryFile));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (apiLedger) {
|
|
353
|
+
const from = Number(url.searchParams.get("from") ?? "0");
|
|
354
|
+
const to = Number(url.searchParams.get("to") ?? String(Number.MAX_SAFE_INTEGER));
|
|
355
|
+
const decisions = (await services.ledger.decisions()).filter((d) => d.claims.timestampMs >= from && d.claims.timestampMs < to);
|
|
356
|
+
const effects = (await services.ledger.effects()).filter((e) => e.row.timestampMs >= from && e.row.timestampMs < to);
|
|
357
|
+
const hashes = [...new Set(decisions.map((d) => d.claims.policyHash))];
|
|
358
|
+
send(res, 200, {
|
|
359
|
+
decisions,
|
|
360
|
+
effects,
|
|
361
|
+
policy: { hash: services.policyHash, document: services.policyDocument },
|
|
362
|
+
policies: readPolicySnapshots(config.stateDir, hashes),
|
|
363
|
+
inputs: await matchingInputs(config.stateDir, decisions),
|
|
364
|
+
approvals: loadApprovalsFromDir(config.stateDir),
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const ref = decodeURIComponent(url.pathname.slice("/api/contest/".length));
|
|
369
|
+
const result = await explain(services.ledger, ref, await services.explainOpts());
|
|
370
|
+
send(res, 200, { ...result, reAuditedAt: Date.now() });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (req.method === "GET") {
|
|
374
|
+
// Stateless: no standalone SSE. The SDK client treats 405 as "no GET stream".
|
|
375
|
+
res.writeHead(405, {
|
|
376
|
+
allow: "POST, DELETE",
|
|
377
|
+
"content-type": "application/json",
|
|
378
|
+
"x-content-type-options": "nosniff",
|
|
379
|
+
});
|
|
380
|
+
res.end(JSON.stringify({ error: "method-not-allowed" }));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const mcp = new McpServer({ name: "verax-body", version: "0.0.0" }, { capabilities: { tools: {} } });
|
|
384
|
+
attachHandlers(mcp);
|
|
385
|
+
const transport = new StreamableHTTPServerTransport({
|
|
386
|
+
sessionIdGenerator: undefined,
|
|
387
|
+
enableJsonResponse: true,
|
|
388
|
+
});
|
|
389
|
+
await mcp.connect(transport);
|
|
390
|
+
const parsed = req.method === "POST" ? await readJsonBody(req, MAX_BODY_BYTES) : { ok: true, value: undefined };
|
|
391
|
+
if (parsed.ok === false && "tooLarge" in parsed) {
|
|
392
|
+
send(res, 413, { error: "payload-too-large" });
|
|
393
|
+
req.destroy();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (parsed.ok === false) {
|
|
397
|
+
send(res, 400, { error: "bad-request" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
await responseSlot.run(res, async () => {
|
|
402
|
+
try {
|
|
403
|
+
await transport.handleRequest(Object.assign(req, {
|
|
404
|
+
auth: {
|
|
405
|
+
token,
|
|
406
|
+
clientId: verified.principal.brain,
|
|
407
|
+
scopes: [...verified.principal.scopes],
|
|
408
|
+
extra: {
|
|
409
|
+
sub: verified.principal.brain,
|
|
410
|
+
...(verified.principal.iss ? { iss: verified.principal.iss } : {}),
|
|
411
|
+
...(verified.principal.tenant ? { tenant: verified.principal.tenant } : {}),
|
|
412
|
+
...(verified.principal.org ? { org: verified.principal.org } : {}),
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
}), res, parsed.value);
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
if (res.headersSent && res.statusCode === 507)
|
|
419
|
+
return;
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
if (err instanceof LedgerDenyUnrecorded) {
|
|
426
|
+
if (!res.headersSent) {
|
|
427
|
+
await bumpMetric(config.stateDir, "disk_deny_unrecorded");
|
|
428
|
+
send(res, 507, { error: "insufficient-storage" });
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (res.headersSent && res.statusCode === 507) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const detail = err instanceof Error ? err.message : "fault";
|
|
436
|
+
process.stderr.write(`verax-transport: ${detail}\n`);
|
|
437
|
+
if (!res.headersSent) {
|
|
438
|
+
send(res, 500, { error: "transport" });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
await transport.close();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
if (res.headersSent)
|
|
447
|
+
return;
|
|
448
|
+
const msg = err instanceof Error ? err.message : "";
|
|
449
|
+
if (msg.startsWith("explain-unknown-ref:")) {
|
|
450
|
+
send(res, 404, { error: "unknown-ref" });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (err instanceof URIError) {
|
|
454
|
+
send(res, 400, { error: "bad-request" });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
process.stderr.write(`verax-handler: ${msg || "fault"}\n`);
|
|
458
|
+
send(res, 500, { error: "fault" });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
if (res.headersSent)
|
|
463
|
+
return;
|
|
464
|
+
if (err instanceof TypeError) {
|
|
465
|
+
send(res, 400, { error: "bad-request" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
process.stderr.write(`verax-handler: ${err instanceof Error ? err.message : "fault"}\n`);
|
|
469
|
+
send(res, 500, { error: "fault" });
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
// Swallow so a bad Host cannot reject the request listener.
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
await new Promise((resolve, reject) => {
|
|
476
|
+
server.once("error", reject);
|
|
477
|
+
server.listen(config.bindPort, config.bindHost, () => resolve());
|
|
478
|
+
});
|
|
479
|
+
server.on("close", () => {
|
|
480
|
+
services.ledger.close();
|
|
481
|
+
});
|
|
482
|
+
return server;
|
|
483
|
+
}
|
|
484
|
+
export { TOOL_NAMES };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { explain, spokenReason, } from "@verax-ai/proxy";
|
|
2
|
+
export async function auditExplain(call, ledger, opts) {
|
|
3
|
+
const ref = call.arguments.ref;
|
|
4
|
+
if (typeof ref !== "string" || ref === "") {
|
|
5
|
+
return {
|
|
6
|
+
content: [{ type: "text", text: JSON.stringify({ error: "ref-required" }) }],
|
|
7
|
+
isError: true,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
let result;
|
|
11
|
+
try {
|
|
12
|
+
result = await explain(ledger, ref, opts);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err instanceof Error && err.message.startsWith("explain-unknown-ref:")) {
|
|
16
|
+
// Spoken the same as a tenant-mismatch refuse so the two calls look alike.
|
|
17
|
+
// The operator HTTP contest path still names unknown-ref.
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: JSON.stringify({ error: spokenReason("tenant-mismatch") }) }],
|
|
20
|
+
isError: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
content: [
|
|
27
|
+
{
|
|
28
|
+
type: "text",
|
|
29
|
+
text: JSON.stringify({
|
|
30
|
+
record: result.record.claims,
|
|
31
|
+
effect: result.effect,
|
|
32
|
+
finding: result.finding,
|
|
33
|
+
witnessClass: result.witnessClass,
|
|
34
|
+
guarantee: result.guarantee,
|
|
35
|
+
warnings: result.warnings,
|
|
36
|
+
trustRoot: result.trustRoot,
|
|
37
|
+
pair: result.pair
|
|
38
|
+
? {
|
|
39
|
+
defer: result.pair.defer?.claims ?? null,
|
|
40
|
+
resolution: result.pair.resolution?.claims ?? null,
|
|
41
|
+
}
|
|
42
|
+
: undefined,
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
isError: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Principal, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
|
+
/** True when `id` exists under a different tenant. Does not read legacy `memory/`. */
|
|
3
|
+
export declare function memoryBelongsToOtherTenant(stateDir: string, id: string, selfKey: string): boolean;
|
|
4
|
+
export declare function readMemoryMeta(stateDir: string, id: string, principal?: Principal): Promise<{
|
|
5
|
+
versionHash: string;
|
|
6
|
+
validFromMs: number;
|
|
7
|
+
validUntilMs: number;
|
|
8
|
+
} | null>;
|
|
9
|
+
export declare function memoryGet(call: ToolCall, stateDir: string, now: () => number, principal: Principal): Promise<ToolResult>;
|
|
10
|
+
export declare function memoryPut(call: ToolCall, stateDir: string, principal: Principal): Promise<ToolResult>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { resolve, sep } from "node:path";
|
|
5
|
+
import { canonical } from "@cedulon/core";
|
|
6
|
+
import { tenantKey } from "@verax-ai/proxy";
|
|
7
|
+
function sha256Canonical(value) {
|
|
8
|
+
return createHash("sha256").update(canonical(value), "utf8").digest("hex");
|
|
9
|
+
}
|
|
10
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
+
function jsonResult(value, isError = false) {
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
14
|
+
isError,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function resolveMemoryPath(stateDir, id, principal) {
|
|
18
|
+
if (!ID_RE.test(id))
|
|
19
|
+
return null;
|
|
20
|
+
const memoryDir = resolve(stateDir, "tenants", tenantKey(principal), "memory");
|
|
21
|
+
const path = resolve(memoryDir, `${id}.json`);
|
|
22
|
+
const prefix = memoryDir.endsWith(sep) ? memoryDir : `${memoryDir}${sep}`;
|
|
23
|
+
if (!path.startsWith(prefix))
|
|
24
|
+
return null;
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
/** True when `id` exists under a different tenant. Does not read legacy `memory/`. */
|
|
28
|
+
export function memoryBelongsToOtherTenant(stateDir, id, selfKey) {
|
|
29
|
+
if (!ID_RE.test(id))
|
|
30
|
+
return false;
|
|
31
|
+
const tenantsRoot = resolve(stateDir, "tenants");
|
|
32
|
+
if (!existsSync(tenantsRoot))
|
|
33
|
+
return false;
|
|
34
|
+
let names;
|
|
35
|
+
try {
|
|
36
|
+
names = readdirSync(tenantsRoot);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
if (name === selfKey)
|
|
43
|
+
continue;
|
|
44
|
+
const path = resolve(tenantsRoot, name, "memory", `${id}.json`);
|
|
45
|
+
const prefix = resolve(tenantsRoot, name, "memory");
|
|
46
|
+
const guarded = prefix.endsWith(sep) ? prefix : `${prefix}${sep}`;
|
|
47
|
+
if (!path.startsWith(guarded))
|
|
48
|
+
continue;
|
|
49
|
+
if (existsSync(path))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
export async function readMemoryMeta(stateDir, id, principal) {
|
|
55
|
+
if (!principal)
|
|
56
|
+
return null;
|
|
57
|
+
const path = resolveMemoryPath(stateDir, id, principal);
|
|
58
|
+
if (path === null)
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
const item = JSON.parse(await readFile(path, "utf8"));
|
|
62
|
+
const validFromMs = typeof item.validFromMs === "number" ? item.validFromMs : 0;
|
|
63
|
+
const validUntilMs = typeof item.validUntilMs === "number" ? item.validUntilMs : 0;
|
|
64
|
+
const versionHash = typeof item.versionHash === "string"
|
|
65
|
+
? item.versionHash
|
|
66
|
+
: sha256Canonical({ id: item.id, body: item.body, validFromMs, validUntilMs });
|
|
67
|
+
return { versionHash, validFromMs, validUntilMs };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (err.code === "ENOENT")
|
|
71
|
+
return null;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function memoryGet(call, stateDir, now, principal) {
|
|
76
|
+
const id = call.arguments.id;
|
|
77
|
+
if (typeof id !== "string" || id === "") {
|
|
78
|
+
return jsonResult({ error: "id-required" }, true);
|
|
79
|
+
}
|
|
80
|
+
const path = resolveMemoryPath(stateDir, id, principal);
|
|
81
|
+
if (path === null) {
|
|
82
|
+
return jsonResult({ error: "id-invalid" }, true);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const item = JSON.parse(await readFile(path, "utf8"));
|
|
86
|
+
if (typeof item.validFromMs === "number" && item.validFromMs > now()) {
|
|
87
|
+
return jsonResult({ notYetValid: true, id: item.id, validFromMs: item.validFromMs });
|
|
88
|
+
}
|
|
89
|
+
if (typeof item.validUntilMs === "number" && item.validUntilMs < now()) {
|
|
90
|
+
return jsonResult({ stale: true, id: item.id, validUntilMs: item.validUntilMs });
|
|
91
|
+
}
|
|
92
|
+
return jsonResult(item);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err.code === "ENOENT") {
|
|
96
|
+
return jsonResult({ error: "not-found", id }, true);
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function memoryPut(call, stateDir, principal) {
|
|
102
|
+
const id = call.arguments.id;
|
|
103
|
+
const body = call.arguments.body;
|
|
104
|
+
const source = call.arguments.source;
|
|
105
|
+
const validFromMs = call.arguments.validFromMs;
|
|
106
|
+
const validUntilMs = call.arguments.validUntilMs;
|
|
107
|
+
if (typeof id !== "string" || id === "") {
|
|
108
|
+
return jsonResult({ error: "id-required" }, true);
|
|
109
|
+
}
|
|
110
|
+
const path = resolveMemoryPath(stateDir, id, principal);
|
|
111
|
+
if (path === null) {
|
|
112
|
+
return jsonResult({ error: "id-invalid" }, true);
|
|
113
|
+
}
|
|
114
|
+
if (source === null || typeof source !== "object" || Array.isArray(source)) {
|
|
115
|
+
return jsonResult({ error: "source-required" }, true);
|
|
116
|
+
}
|
|
117
|
+
if (typeof validUntilMs !== "number") {
|
|
118
|
+
return jsonResult({ error: "validUntilMs-required" }, true);
|
|
119
|
+
}
|
|
120
|
+
const fromMs = typeof validFromMs === "number" ? validFromMs : 0;
|
|
121
|
+
const versionHash = sha256Canonical({ id, body, validFromMs: fromMs, validUntilMs });
|
|
122
|
+
const rec = {
|
|
123
|
+
id,
|
|
124
|
+
body,
|
|
125
|
+
source,
|
|
126
|
+
validFromMs: fromMs,
|
|
127
|
+
validUntilMs,
|
|
128
|
+
versionHash,
|
|
129
|
+
};
|
|
130
|
+
const dir = resolve(stateDir, "tenants", tenantKey(principal), "memory");
|
|
131
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
132
|
+
await writeFile(path, `${JSON.stringify(rec)}\n`, {
|
|
133
|
+
encoding: "utf8",
|
|
134
|
+
mode: 0o600,
|
|
135
|
+
});
|
|
136
|
+
return jsonResult({ ok: true, id, versionHash });
|
|
137
|
+
}
|