@verax-ai/body 0.3.0 → 0.4.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/keys.d.ts CHANGED
@@ -6,6 +6,12 @@ export declare class KeysPartialError extends Error {
6
6
  readonly code = 78;
7
7
  constructor();
8
8
  }
9
+ export declare class KeyAlgorithmError extends Error {
10
+ readonly code = 78;
11
+ constructor(which: string);
12
+ }
13
+ /** A public key this process will verify with. Anything but Ed25519 stops start. */
14
+ export declare function assertEd25519PublicKey(pem: string, which: string): void;
9
15
  export declare function loadOrCreateSigners(stateDir: string): {
10
16
  recordSigner: KeyPair;
11
17
  effectSigner: KeyPair;
package/dist/keys.js CHANGED
@@ -1,4 +1,4 @@
1
- import { generateKeyPairSync } from "node:crypto";
1
+ import { createPublicKey, generateKeyPairSync } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { EX_CONFIG } from "./config.js";
@@ -9,6 +9,25 @@ export class KeysPartialError extends Error {
9
9
  this.name = "KeysPartialError";
10
10
  }
11
11
  }
12
+ export class KeyAlgorithmError extends Error {
13
+ code = EX_CONFIG;
14
+ constructor(which) {
15
+ super(`${which} public key is not ed25519`);
16
+ this.name = "KeyAlgorithmError";
17
+ }
18
+ }
19
+ /** A public key this process will verify with. Anything but Ed25519 stops start. */
20
+ export function assertEd25519PublicKey(pem, which) {
21
+ let type;
22
+ try {
23
+ type = createPublicKey(pem).asymmetricKeyType;
24
+ }
25
+ catch {
26
+ throw new KeyAlgorithmError(which);
27
+ }
28
+ if (type !== "ed25519")
29
+ throw new KeyAlgorithmError(which);
30
+ }
12
31
  function pair() {
13
32
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
14
33
  return {
@@ -33,23 +52,27 @@ export function loadOrCreateSigners(stateDir) {
33
52
  if (present !== 0 && present !== 4) {
34
53
  throw new KeysPartialError();
35
54
  }
36
- if (present === 0) {
37
- const record = pair();
38
- const effect = pair();
39
- writePemAtomic(recPriv, record.privateKeyPem);
40
- writePemAtomic(recPub, record.publicKeyPem);
41
- writePemAtomic(effPriv, effect.privateKeyPem);
42
- writePemAtomic(effPub, effect.publicKeyPem);
43
- return { recordSigner: record, effectSigner: effect };
44
- }
45
- return {
46
- recordSigner: {
47
- privateKeyPem: readFileSync(recPriv, "utf8"),
48
- publicKeyPem: readFileSync(recPub, "utf8"),
49
- },
50
- effectSigner: {
51
- privateKeyPem: readFileSync(effPriv, "utf8"),
52
- publicKeyPem: readFileSync(effPub, "utf8"),
53
- },
54
- };
55
+ const loaded = present === 0
56
+ ? (() => {
57
+ const record = pair();
58
+ const effect = pair();
59
+ writePemAtomic(recPriv, record.privateKeyPem);
60
+ writePemAtomic(recPub, record.publicKeyPem);
61
+ writePemAtomic(effPriv, effect.privateKeyPem);
62
+ writePemAtomic(effPub, effect.publicKeyPem);
63
+ return { recordSigner: record, effectSigner: effect };
64
+ })()
65
+ : {
66
+ recordSigner: {
67
+ privateKeyPem: readFileSync(recPriv, "utf8"),
68
+ publicKeyPem: readFileSync(recPub, "utf8"),
69
+ },
70
+ effectSigner: {
71
+ privateKeyPem: readFileSync(effPriv, "utf8"),
72
+ publicKeyPem: readFileSync(effPub, "utf8"),
73
+ },
74
+ };
75
+ assertEd25519PublicKey(loaded.recordSigner.publicKeyPem, "record");
76
+ assertEd25519PublicKey(loaded.effectSigner.publicKeyPem, "effect");
77
+ return loaded;
55
78
  }
package/dist/main.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { JwksFileError } from "./auth.js";
2
2
  import { EX_CONFIG, loadConfig, overlayInventoryArg } from "./config.js";
3
- import { KeysPartialError } from "./keys.js";
3
+ import { watchDesktopParent } from "./desktop-parent.js";
4
+ import { KeyAlgorithmError, KeysPartialError } from "./keys.js";
4
5
  import { listen } from "./server.js";
5
6
  export async function main(env = process.env, argv = process.argv) {
7
+ watchDesktopParent({ env });
6
8
  const overlaid = overlayInventoryArg(env, argv);
7
9
  if ("error" in overlaid) {
8
10
  process.stderr.write(`${overlaid.error}\n`);
@@ -15,6 +17,10 @@ export async function main(env = process.env, argv = process.argv) {
15
17
  }
16
18
  try {
17
19
  const server = await listen(loaded.value);
20
+ const addr = server.address();
21
+ if (addr && typeof addr === "object") {
22
+ process.stderr.write(`listening ${addr.address}:${addr.port}\n`);
23
+ }
18
24
  const shutdown = () => {
19
25
  server.close(() => process.exit(0));
20
26
  };
@@ -26,6 +32,10 @@ export async function main(env = process.env, argv = process.argv) {
26
32
  process.stderr.write("keys-partial\n");
27
33
  process.exit(err.code);
28
34
  }
35
+ if (err instanceof KeyAlgorithmError) {
36
+ process.stderr.write(`${err.message}\n`);
37
+ process.exit(err.code);
38
+ }
29
39
  if (err instanceof JwksFileError) {
30
40
  process.stderr.write(`${err.message}\n`);
31
41
  process.exit(err.code);
@@ -52,8 +52,9 @@ export function scanNoBypass(base = pkgRoot, extra = []) {
52
52
  if (rel === "src/no-bypass-scan.ts")
53
53
  continue;
54
54
  const wiring = rel === "src/wiring.ts";
55
- // Desktop supervises issuer/body/panel. It is not a tool-call path.
56
- const desktop = rel === "src/desktop.ts";
55
+ // Desktop supervises issuer/body/panel. Install copies the body under another
56
+ // account. Neither file is a tool-call path.
57
+ const desktop = rel === "src/desktop.ts" || rel === "src/install.ts";
57
58
  // The downstream spike attaches one stdio MCP server. Not a tool-call path.
58
59
  const downstream = rel === "src/downstream.ts";
59
60
  const originalLines = text.split(/\r?\n/);
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { loadApprovalsFromDir, loadEffectsFromDir, loadPolicy, parseCardCsv, parseChannelJsonl, reconcile } from "@verax-ai/proxy";
3
+ import { loadApprovalsFromDir, loadDecisionRefsFromDir, loadEffectsFromDir, loadPolicy, parseCardCsv, parseChannelJsonl, reconcile, } from "@verax-ai/proxy";
4
4
  const SNAPSHOT_NAME = /^[0-9a-f]{64}\.json$/;
5
5
  function descriptorsFromDoc(doc) {
6
6
  const out = {};
@@ -229,11 +229,13 @@ export function runReconcile(argv, writeErr = (s) => process.stderr.write(s)) {
229
229
  : undefined;
230
230
  const channel = card ?? parseChannelJsonl(raw);
231
231
  const effects = loadEffectsFromDir(parsed.stateDir);
232
+ const decisionRefs = loadDecisionRefsFromDir(parsed.stateDir);
232
233
  const loaded = parsed.channel === "card" ? loadDescriptorsFromDir(parsed.stateDir) : undefined;
233
234
  const descriptorsByPayee = loaded?.descriptorsByPayee;
234
235
  const report = reconcile(channel, effects, {
235
236
  toleranceMs: parsed.toleranceMs,
236
237
  window: parsed.window,
238
+ decisionRefs,
237
239
  approvals: parsed.channel === "card" ? loadApprovalsFromDir(parsed.stateDir) : undefined,
238
240
  skipped: card?.skipped,
239
241
  ...(descriptorsByPayee && Object.keys(descriptorsByPayee).length > 0
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Server } from "node:http";
2
- import type { BodyConfig } from "./config.ts";
2
+ import { type BodyConfig } from "./config.ts";
3
3
  import { TOOL_NAMES } from "./wiring.ts";
4
4
  export declare const TOOL_META: ({
5
5
  name: string;
@@ -184,5 +184,18 @@ export declare const TOOL_META: ({
184
184
  required: string[];
185
185
  };
186
186
  })[];
187
+ /** `malformed` falls through so `Host: [` stays the existing bad-request. */
188
+ export declare function loopbackHostDecision(hostHeader: string | undefined, bindPort: number): "allow" | "deny" | "malformed";
189
+ /**
190
+ * Second look at a bearer that already verified. The first look runs before
191
+ * the body is read; this one runs after the body and before dispatch or
192
+ * approval. `exp` is seconds since the epoch. A passed `exp` is dead even
193
+ * inside the verifier's clock tolerance: that tolerance applied at the door,
194
+ * and the body has since arrived. A missing `exp` is dead too.
195
+ */
196
+ export declare function bearerStillLive(payload: {
197
+ jti?: unknown;
198
+ exp?: unknown;
199
+ }, stateDir: string): boolean;
187
200
  export declare function listen(config: BodyConfig): Promise<Server>;
188
201
  export { TOOL_NAMES };
package/dist/server.js CHANGED
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
4
4
  import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
+ import { isLoopbackHost } from "./config.js";
7
8
  import { approvalsLogFor, approvePending, createApprovalBudgetGuard, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
8
9
  import { createVerifier, readBearer, resourceMetadataUrl, wwwAuthenticate } from "./auth.js";
9
10
  import { bumpMetric, bumpUnauthenticated } from "./metrics.js";
@@ -11,7 +12,7 @@ import { isRevokedJti } from "./revoke.js";
11
12
  import { loadOrCreateSigners } from "./keys.js";
12
13
  import { matchingInputs } from "./inputs-read.js";
13
14
  import { readPolicySnapshots } from "./policy-store.js";
14
- import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
15
+ import { readHeartbeat, readInstallHealthNonce, readWitnessPulse } from "./health-extras.js";
15
16
  import { agentsWindow } from "./agents.js";
16
17
  import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
17
18
  import { createBodyServices, TOOL_NAMES } from "./wiring.js";
@@ -170,10 +171,48 @@ export const TOOL_META = [
170
171
  },
171
172
  ];
172
173
  const MAX_BODY_BYTES = 1024 * 1024;
174
+ // Node's default requestTimeout is 300s. A stalled body would otherwise stay
175
+ // authorised until that default. Headers are bounded tighter than the body.
176
+ const REQUEST_TIMEOUT_MS = 30_000;
177
+ const HEADERS_TIMEOUT_MS = 20_000;
173
178
  // Unbounded GET /api/ledger stringified ~690 MB at 200k rows and threw Invalid string length (HTTP 500).
174
179
  const DEFAULT_LEDGER_LIMIT = 1000;
175
180
  const MAX_LEDGER_LIMIT = 5000;
176
181
  const responseSlot = new AsyncLocalStorage();
182
+ /** `malformed` falls through so `Host: [` stays the existing bad-request. */
183
+ export function loopbackHostDecision(hostHeader, bindPort) {
184
+ if (hostHeader === undefined || hostHeader.trim() === "")
185
+ return "deny";
186
+ const raw = hostHeader.trim();
187
+ if (raw.startsWith("[") && !raw.includes("]"))
188
+ return "malformed";
189
+ let name = raw;
190
+ let port;
191
+ if (raw.startsWith("[")) {
192
+ const end = raw.indexOf("]");
193
+ name = raw.slice(1, end);
194
+ const rest = raw.slice(end + 1);
195
+ if (rest === "")
196
+ port = undefined;
197
+ else if (/^:[0-9]+$/.test(rest))
198
+ port = Number(rest.slice(1));
199
+ else
200
+ return "deny";
201
+ }
202
+ else {
203
+ const colon = raw.lastIndexOf(":");
204
+ if (colon > 0 && /^[0-9]+$/.test(raw.slice(colon + 1))) {
205
+ name = raw.slice(0, colon);
206
+ port = Number(raw.slice(colon + 1));
207
+ }
208
+ }
209
+ const lowered = name.toLowerCase();
210
+ if (lowered !== "127.0.0.1" && lowered !== "localhost" && lowered !== "::1")
211
+ return "deny";
212
+ if (port === undefined)
213
+ return bindPort === 80 ? "allow" : "deny";
214
+ return port === bindPort ? "allow" : "deny";
215
+ }
177
216
  function contentLengthOverLimit(req) {
178
217
  const raw = req.headers["content-length"];
179
218
  if (raw === undefined)
@@ -209,6 +248,21 @@ async function readJsonBody(req, max) {
209
248
  return { ok: false, bad: true };
210
249
  }
211
250
  }
251
+ /**
252
+ * Second look at a bearer that already verified. The first look runs before
253
+ * the body is read; this one runs after the body and before dispatch or
254
+ * approval. `exp` is seconds since the epoch. A passed `exp` is dead even
255
+ * inside the verifier's clock tolerance: that tolerance applied at the door,
256
+ * and the body has since arrived. A missing `exp` is dead too.
257
+ */
258
+ export function bearerStillLive(payload, stateDir) {
259
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
260
+ if (jti === "" || isRevokedJti(stateDir, jti))
261
+ return false;
262
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp))
263
+ return false;
264
+ return payload.exp * 1000 > Date.now();
265
+ }
212
266
  function isProtectedResourcePath(pathname, audience) {
213
267
  const want = new URL(resourceMetadataUrl(audience)).pathname;
214
268
  return pathname === want || pathname === "/.well-known/oauth-protected-resource";
@@ -308,7 +362,7 @@ export async function listen(config) {
308
362
  throw err;
309
363
  }
310
364
  const toolMeta = [...TOOL_META, ...extraTools.map(downstreamMeta)];
311
- const verify = createVerifier(config.jwksUrl, config.issuer, config.audience, config.jwksFile);
365
+ const verify = createVerifier(config.jwksUrl, config.issuer, config.audience, config.jwksFile, config.jwksPin);
312
366
  const attachHandlers = (mcp) => {
313
367
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
314
368
  mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
@@ -340,6 +394,25 @@ export async function listen(config) {
340
394
  };
341
395
  const server = createServer(async (req, res) => {
342
396
  try {
397
+ if (isLoopbackHost(config.bindHost)) {
398
+ const hostHeader = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host;
399
+ const listenPort = server.address().port;
400
+ const hostDecision = loopbackHostDecision(hostHeader, listenPort);
401
+ if (hostDecision === "deny") {
402
+ send(res, 400, { error: "host-not-allowed" });
403
+ req.resume();
404
+ return;
405
+ }
406
+ }
407
+ if (req.headers.origin !== undefined) {
408
+ const origin = Array.isArray(req.headers.origin) ? (req.headers.origin[0] ?? "") : req.headers.origin;
409
+ const allowed = config.allowedOrigins ?? [];
410
+ if (!allowed.includes(origin)) {
411
+ send(res, 403, { error: "origin-not-allowed" });
412
+ req.resume();
413
+ return;
414
+ }
415
+ }
343
416
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
344
417
  if (req.method === "POST" && contentLengthOverLimit(req)) {
345
418
  send(res, 413, { error: "payload-too-large" });
@@ -347,6 +420,14 @@ export async function listen(config) {
347
420
  return;
348
421
  }
349
422
  if (req.method === "GET" && url.pathname === "/healthz") {
423
+ // Local mode reads VERAX_JWKS_FILE. Any process that can read that key
424
+ // file can mint verax:audit, so counts are not a door here: liveness only.
425
+ const installNonce = readInstallHealthNonce(config.stateDir);
426
+ const nonceField = installNonce === null ? {} : { nonce: installNonce };
427
+ if (config.jwksFile) {
428
+ send(res, 200, { ok: true, ...nonceField });
429
+ return;
430
+ }
350
431
  const token = readBearer(req.headers.authorization);
351
432
  let canRead = false;
352
433
  if (token) {
@@ -367,7 +448,7 @@ export async function listen(config) {
367
448
  }
368
449
  }
369
450
  if (!canRead) {
370
- send(res, 200, { ok: true });
451
+ send(res, 200, { ok: true, ...nonceField });
371
452
  return;
372
453
  }
373
454
  // The ledger counts its own lines as it writes them; asking it is free.
@@ -389,6 +470,7 @@ export async function listen(config) {
389
470
  }
390
471
  send(res, 200, {
391
472
  ok: true,
473
+ ...nonceField,
392
474
  ...counted,
393
475
  lock: services.ledger.lockStatus(),
394
476
  heartbeat: readHeartbeat(config.stateDir),
@@ -446,6 +528,21 @@ export async function listen(config) {
446
528
  send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
447
529
  return;
448
530
  }
531
+ const refuseStaleBearer = async () => {
532
+ if (bearerStillLive(verified.payload, config.stateDir))
533
+ return false;
534
+ await bumpUnauthenticated(config.stateDir);
535
+ send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
536
+ return true;
537
+ };
538
+ // Local mode: any process that can read the key file can mint
539
+ // verax:approve or verax:audit, so those scopes are not honoured at all.
540
+ // Nothing is written; the refusal is the whole answer.
541
+ if (config.jwksFile &&
542
+ (verified.principal.scopes.has("verax:approve") || verified.principal.scopes.has("verax:audit"))) {
543
+ send(res, 403, { error: "local-mode-operator-scope" });
544
+ return;
545
+ }
449
546
  try {
450
547
  if (apiApprove) {
451
548
  // Reading the ledger is not approving from it. The audit scope opens
@@ -456,6 +553,8 @@ export async function listen(config) {
456
553
  return;
457
554
  }
458
555
  const parsed = await readJsonBody(req, MAX_BODY_BYTES);
556
+ if (await refuseStaleBearer())
557
+ return;
459
558
  if (!parsed.ok) {
460
559
  send(res, 400, { error: "bad-body" });
461
560
  return;
@@ -504,9 +603,14 @@ export async function listen(config) {
504
603
  policy: services.policy,
505
604
  approvals,
506
605
  now: () => Date.now(),
606
+ ledger: services.ledger,
507
607
  }),
508
608
  });
509
609
  if (!outcome.ok) {
610
+ if (outcome.reason === "halted") {
611
+ send(res, 409, { error: "approve-halted" });
612
+ return;
613
+ }
510
614
  const code = outcome.reason === "unknown-ref" || outcome.reason === "snapshot-missing" ? 404 : 409;
511
615
  send(res, code, {
512
616
  error: outcome.reason,
@@ -518,6 +622,8 @@ export async function listen(config) {
518
622
  return;
519
623
  }
520
624
  if (apiLedger || apiInventory || contest || apiAgents) {
625
+ if (await refuseStaleBearer())
626
+ return;
521
627
  // The audit doors hand out the whole ledger: every tenant's decisions, the
522
628
  // inputs documents that name their principals, and the approval snapshots
523
629
  // that carry spend arguments. `verax:read` is a brain scope, so it cannot be
@@ -612,6 +718,8 @@ export async function listen(config) {
612
718
  });
613
719
  await mcp.connect(transport);
614
720
  const parsed = req.method === "POST" ? await readJsonBody(req, MAX_BODY_BYTES) : { ok: true, value: undefined };
721
+ if (await refuseStaleBearer())
722
+ return;
615
723
  if (parsed.ok === false && "tooLarge" in parsed) {
616
724
  send(res, 413, { error: "payload-too-large" });
617
725
  req.destroy();
@@ -696,6 +804,8 @@ export async function listen(config) {
696
804
  // Swallow so a bad Host cannot reject the request listener.
697
805
  }
698
806
  });
807
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
808
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
699
809
  await new Promise((resolve, reject) => {
700
810
  server.once("error", reject);
701
811
  server.listen(config.bindPort, config.bindHost, () => resolve());
@@ -704,6 +814,18 @@ export async function listen(config) {
704
814
  services.ledger.close();
705
815
  void closeAll(sessions);
706
816
  });
817
+ const bound = server.address();
818
+ if (!bound || typeof bound === "string") {
819
+ await new Promise((resolve) => server.close(() => resolve()));
820
+ throw new Error("listen-port");
821
+ }
822
+ try {
823
+ services.ledger.recordListenPort(bound.port);
824
+ }
825
+ catch (err) {
826
+ await new Promise((resolve) => server.close(() => resolve()));
827
+ throw err;
828
+ }
707
829
  return server;
708
830
  }
709
831
  export { TOOL_NAMES };
@@ -1,2 +1,5 @@
1
- import { type ExplainOpts, type Ledger, type ToolCall, type ToolResult } from "@verax-ai/proxy";
2
- export declare function auditExplain(call: ToolCall, ledger: Ledger, opts?: ExplainOpts): Promise<ToolResult>;
1
+ import { type ExplainOpts, type Ledger, type Principal, type ToolCall, type ToolResult } from "@verax-ai/proxy";
2
+ export declare function auditExplain(call: ToolCall, ledger: Ledger, opts?: ExplainOpts, caller?: {
3
+ principal: Principal;
4
+ stateDir: string;
5
+ }): Promise<ToolResult>;
@@ -1,5 +1,6 @@
1
- import { explain, spokenReason, } from "@verax-ai/proxy";
2
- export async function auditExplain(call, ledger, opts) {
1
+ import { explain, spokenReason, tenantKey, } from "@verax-ai/proxy";
2
+ import { inputsPrincipal } from "../inputs-read.js";
3
+ export async function auditExplain(call, ledger, opts, caller) {
3
4
  const ref = call.arguments.ref;
4
5
  if (typeof ref !== "string" || ref === "") {
5
6
  return {
@@ -7,6 +8,15 @@ export async function auditExplain(call, ledger, opts) {
7
8
  isError: true,
8
9
  };
9
10
  }
11
+ if (caller) {
12
+ const owner = await inputsPrincipal(caller.stateDir, ref);
13
+ if (owner !== null && tenantKey(owner) !== tenantKey(caller.principal)) {
14
+ return {
15
+ content: [{ type: "text", text: JSON.stringify({ error: spokenReason("tenant-mismatch") }) }],
16
+ isError: true,
17
+ };
18
+ }
19
+ }
10
20
  let result;
11
21
  try {
12
22
  result = await explain(ledger, ref, opts);
@@ -1,13 +1,27 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import { resolve, sep } from "node:path";
5
5
  import { canonical } from "@cedulon/core";
6
6
  import { tenantKey } from "@verax-ai/proxy";
7
+ import { memoryQuotaBytes } from "../config.js";
7
8
  function sha256Canonical(value) {
8
9
  return createHash("sha256").update(canonical(value), "utf8").digest("hex");
9
10
  }
10
11
  const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
+ /** One in-process chain per tenant memory directory. The body is one process per state dir. */
13
+ const memoryQuotaChain = new Map();
14
+ function withMemoryQuotaLock(key, fn) {
15
+ const previous = memoryQuotaChain.get(key) ?? Promise.resolve();
16
+ const run = previous.then(fn, fn);
17
+ const settled = run.then(() => undefined, () => undefined);
18
+ memoryQuotaChain.set(key, settled);
19
+ void settled.then(() => {
20
+ if (memoryQuotaChain.get(key) === settled)
21
+ memoryQuotaChain.delete(key);
22
+ });
23
+ return run;
24
+ }
11
25
  function jsonResult(value, isError = false) {
12
26
  return {
13
27
  content: [{ type: "text", text: JSON.stringify(value) }],
@@ -135,10 +149,31 @@ export async function memoryPut(call, stateDir, principal) {
135
149
  versionHash,
136
150
  };
137
151
  const dir = resolve(stateDir, "tenants", tenantKey(principal), "memory");
138
- await mkdir(dir, { recursive: true, mode: 0o700 });
139
- await writeFile(path, `${JSON.stringify(rec)}\n`, {
140
- encoding: "utf8",
141
- mode: 0o600,
152
+ const payload = `${JSON.stringify(rec)}\n`;
153
+ return withMemoryQuotaLock(dir, async () => {
154
+ let used = 0;
155
+ try {
156
+ for (const name of await readdir(dir)) {
157
+ const file = resolve(dir, name);
158
+ if (file === path)
159
+ continue;
160
+ const info = await stat(file);
161
+ if (info.isFile())
162
+ used += info.size;
163
+ }
164
+ }
165
+ catch (err) {
166
+ if (err.code !== "ENOENT")
167
+ throw err;
168
+ }
169
+ if (used + Buffer.byteLength(payload) > memoryQuotaBytes()) {
170
+ return jsonResult({ error: "memory-quota" }, true);
171
+ }
172
+ await mkdir(dir, { recursive: true, mode: 0o700 });
173
+ await writeFile(path, payload, {
174
+ encoding: "utf8",
175
+ mode: 0o600,
176
+ });
177
+ return jsonResult({ ok: true, id, versionHash });
142
178
  });
143
- return jsonResult({ ok: true, id, versionHash });
144
179
  }
@@ -1,7 +1,21 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
1
+ import { appendFileSync, mkdirSync, statSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tenantKey } from "@verax-ai/proxy";
5
+ import { messageQuotaBytes } from "../config.js";
6
+ /** One in-process chain per tenant directory. The body is one process per state dir. */
7
+ const messageQuotaChain = new Map();
8
+ function withMessageQuotaLock(key, fn) {
9
+ const previous = messageQuotaChain.get(key) ?? Promise.resolve();
10
+ const run = previous.then(fn, fn);
11
+ const settled = run.then(() => undefined, () => undefined);
12
+ messageQuotaChain.set(key, settled);
13
+ void settled.then(() => {
14
+ if (messageQuotaChain.get(key) === settled)
15
+ messageQuotaChain.delete(key);
16
+ });
17
+ return run;
18
+ }
5
19
  function tenantDir(stateDir, principal) {
6
20
  return join(stateDir, "tenants", tenantKey(principal));
7
21
  }
@@ -32,13 +46,28 @@ export async function messageSend(call, stateDir, ref, principal) {
32
46
  ref,
33
47
  };
34
48
  const dir = tenantDir(stateDir, principal);
35
- mkdirSync(dir, { recursive: true, mode: 0o700 });
36
- appendFileSync(join(dir, "outbox.jsonl"), `${JSON.stringify(row)}\n`, {
37
- encoding: "utf8",
38
- mode: 0o600,
49
+ const file = join(dir, "outbox.jsonl");
50
+ const payload = `${JSON.stringify(row)}\n`;
51
+ return withMessageQuotaLock(dir, async () => {
52
+ let used = 0;
53
+ try {
54
+ used = statSync(file).size;
55
+ }
56
+ catch (err) {
57
+ if (err.code !== "ENOENT")
58
+ throw err;
59
+ }
60
+ if (used + Buffer.byteLength(payload) > messageQuotaBytes()) {
61
+ return {
62
+ content: [{ type: "text", text: JSON.stringify({ error: "message-quota" }) }],
63
+ isError: true,
64
+ };
65
+ }
66
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ appendFileSync(file, payload, { encoding: "utf8", mode: 0o600 });
68
+ return {
69
+ content: [{ type: "text", text: JSON.stringify({ queued: true, ref }) }],
70
+ isError: false,
71
+ };
39
72
  });
40
- return {
41
- content: [{ type: "text", text: JSON.stringify({ queued: true, ref }) }],
42
- isError: false,
43
- };
44
73
  }
package/dist/unlock.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export declare function pidAlive(pid: number): boolean;
2
- export declare function readLockFile(path: string): {
2
+ export type LockRecord = {
3
3
  pid: number;
4
4
  startedAt: number;
5
- } | null;
5
+ port?: number;
6
+ };
7
+ /** A lock from before the body recorded its port has no `port`. That is still a lock. */
8
+ export declare function readLockFile(path: string): LockRecord | null;
6
9
  export declare function runUnlock(stateDir: string, writeErr?: (s: string) => void, options?: {
7
10
  force?: boolean;
8
11
  unlink?: (path: string) => void;
package/dist/unlock.js CHANGED
@@ -12,12 +12,17 @@ export function pidAlive(pid) {
12
12
  return err.code === "EPERM";
13
13
  }
14
14
  }
15
+ /** A lock from before the body recorded its port has no `port`. That is still a lock. */
15
16
  export function readLockFile(path) {
16
17
  try {
17
18
  const raw = JSON.parse(readFileSync(path, "utf8"));
18
19
  if (typeof raw.pid !== "number" || typeof raw.startedAt !== "number")
19
20
  return null;
20
- return { pid: raw.pid, startedAt: raw.startedAt };
21
+ const record = { pid: raw.pid, startedAt: raw.startedAt };
22
+ if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) {
23
+ record.port = raw.port;
24
+ }
25
+ return record;
21
26
  }
22
27
  catch {
23
28
  return null;