@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
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Principal, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
|
+
export declare function messageRead(_call: ToolCall, stateDir: string, principal: Principal): Promise<ToolResult>;
|
|
3
|
+
/** No-network stub: appends to the tenant outbox. Mail/WA reuse the same egress list. */
|
|
4
|
+
export declare function messageSend(call: ToolCall, stateDir: string, ref: string, principal: Principal): Promise<ToolResult>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tenantKey } from "@verax-ai/proxy";
|
|
5
|
+
function tenantDir(stateDir, principal) {
|
|
6
|
+
return join(stateDir, "tenants", tenantKey(principal));
|
|
7
|
+
}
|
|
8
|
+
export async function messageRead(_call, stateDir, principal) {
|
|
9
|
+
const path = join(tenantDir(stateDir, principal), "inbox.jsonl");
|
|
10
|
+
let rows = [];
|
|
11
|
+
try {
|
|
12
|
+
const text = await readFile(path, "utf8");
|
|
13
|
+
rows = text
|
|
14
|
+
.split("\n")
|
|
15
|
+
.filter((line) => line !== "")
|
|
16
|
+
.map((line) => JSON.parse(line));
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err.code !== "ENOENT")
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text", text: JSON.stringify(rows) }],
|
|
24
|
+
isError: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** No-network stub: appends to the tenant outbox. Mail/WA reuse the same egress list. */
|
|
28
|
+
export async function messageSend(call, stateDir, ref, principal) {
|
|
29
|
+
const row = {
|
|
30
|
+
to: call.arguments.to,
|
|
31
|
+
text: call.arguments.text,
|
|
32
|
+
ref,
|
|
33
|
+
};
|
|
34
|
+
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,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: JSON.stringify({ queued: true, ref }) }],
|
|
42
|
+
isError: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Authorizes a payment; does not move money. */
|
|
2
|
+
export async function spendAuthorize(call, ref) {
|
|
3
|
+
return {
|
|
4
|
+
content: [
|
|
5
|
+
{
|
|
6
|
+
type: "text",
|
|
7
|
+
text: JSON.stringify({
|
|
8
|
+
authorized: true,
|
|
9
|
+
ref,
|
|
10
|
+
amountMinor: call.arguments.amountMinor,
|
|
11
|
+
currency: call.arguments.currency,
|
|
12
|
+
payee: call.arguments.payee,
|
|
13
|
+
reference: call.arguments.reference,
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
isError: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/unlock.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function pidAlive(pid: number): boolean;
|
|
2
|
+
export declare function readLockFile(path: string): {
|
|
3
|
+
pid: number;
|
|
4
|
+
startedAt: number;
|
|
5
|
+
} | null;
|
|
6
|
+
export declare function runUnlock(stateDir: string, writeErr?: (s: string) => void, options?: {
|
|
7
|
+
force?: boolean;
|
|
8
|
+
unlink?: (path: string) => void;
|
|
9
|
+
}): number;
|
package/dist/unlock.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { userInfo } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function pidAlive(pid) {
|
|
5
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
6
|
+
return false;
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
return err.code === "EPERM";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function readLockFile(path) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
if (typeof raw.pid !== "number" || typeof raw.startedAt !== "number")
|
|
19
|
+
return null;
|
|
20
|
+
return { pid: raw.pid, startedAt: raw.startedAt };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function operatorName() {
|
|
27
|
+
try {
|
|
28
|
+
const name = userInfo().username;
|
|
29
|
+
return name.length > 0 ? name : "unknown";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function errnoCode(err) {
|
|
36
|
+
return err.code ?? "ERR";
|
|
37
|
+
}
|
|
38
|
+
function appendUnlockRow(stateDir, row) {
|
|
39
|
+
appendFileSync(join(stateDir, "unlocks.jsonl"), `${JSON.stringify(row)}\n`, {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function runUnlock(stateDir, writeErr = (s) => process.stderr.write(s), options = {}) {
|
|
44
|
+
const lockPath = join(stateDir, "ledger.lock");
|
|
45
|
+
if (!existsSync(lockPath)) {
|
|
46
|
+
writeErr("no-lock\n");
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const existing = readLockFile(lockPath);
|
|
50
|
+
if (!existing) {
|
|
51
|
+
if (!options.force) {
|
|
52
|
+
writeErr("lock-unreadable\n");
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (pidAlive(existing.pid)) {
|
|
57
|
+
writeErr(`still-running:${existing.pid}\n`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
const operator = operatorName();
|
|
61
|
+
const base = existing
|
|
62
|
+
? {
|
|
63
|
+
removedPid: existing.pid,
|
|
64
|
+
startedAt: existing.startedAt,
|
|
65
|
+
operator,
|
|
66
|
+
}
|
|
67
|
+
: {
|
|
68
|
+
removedPid: null,
|
|
69
|
+
startedAt: null,
|
|
70
|
+
operator,
|
|
71
|
+
reason: "unreadable",
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
appendUnlockRow(stateDir, { atMs: Date.now(), ...base, result: "removing" });
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
writeErr(`record-failed:${errnoCode(err)}\n`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const unlink = options.unlink ?? unlinkSync;
|
|
81
|
+
try {
|
|
82
|
+
unlink(lockPath);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const code = errnoCode(err);
|
|
86
|
+
try {
|
|
87
|
+
appendUnlockRow(stateDir, {
|
|
88
|
+
atMs: Date.now(),
|
|
89
|
+
...base,
|
|
90
|
+
result: "unlink-failed",
|
|
91
|
+
error: code,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (recordErr) {
|
|
95
|
+
writeErr(`record-failed:${errnoCode(recordErr)}\n`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
writeErr(`unlink-failed:${code}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
appendUnlockRow(stateDir, { atMs: Date.now(), ...base, result: "removed" });
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
writeErr(`record-failed:${errnoCode(err)}\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
package/dist/wiring.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createProxy, FileLedger, type EffectSigner, type ExplainOpts, type Principal, type RecordSigner, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
|
+
export type ToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
|
|
3
|
+
export declare const TOOL_NAMES: readonly ["memory.get", "memory.put", "audit.explain", "message.read", "message.send", "spend"];
|
|
4
|
+
export type BodyServices = {
|
|
5
|
+
proxy: ReturnType<typeof createProxy>;
|
|
6
|
+
ledger: FileLedger;
|
|
7
|
+
policyHash: string;
|
|
8
|
+
policyDocument: unknown;
|
|
9
|
+
listTools: () => readonly string[];
|
|
10
|
+
explainOpts: () => Promise<ExplainOpts>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Tool functions live in a Map that is not exported. The only way to
|
|
14
|
+
* reach them at runtime is `proxy.call` -> `inner`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createBodyServices(opts: {
|
|
17
|
+
stateDir: string;
|
|
18
|
+
policyFile: string;
|
|
19
|
+
recordSigner: RecordSigner;
|
|
20
|
+
effectSigner: EffectSigner;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
nonce?: () => string;
|
|
23
|
+
}): BodyServices;
|
package/dist/wiring.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createProxy, FileLedger, loadPolicy, tenantKey, } from "@verax-ai/proxy";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { persistPolicySnapshot } from "./policy-store.js";
|
|
4
|
+
import { inputsPrincipal } from "./inputs-read.js";
|
|
5
|
+
import { memoryBelongsToOtherTenant, memoryGet, memoryPut, readMemoryMeta } from "./tools/memory.js";
|
|
6
|
+
import { auditExplain } from "./tools/audit.js";
|
|
7
|
+
import { messageRead, messageSend } from "./tools/message.js";
|
|
8
|
+
import { spendAuthorize } from "./tools/spend.js";
|
|
9
|
+
import { requestWitnessSign } from "./witness.js";
|
|
10
|
+
export const TOOL_NAMES = [
|
|
11
|
+
"memory.get",
|
|
12
|
+
"memory.put",
|
|
13
|
+
"audit.explain",
|
|
14
|
+
"message.read",
|
|
15
|
+
"message.send",
|
|
16
|
+
"spend",
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Tool functions live in a Map that is not exported. The only way to
|
|
20
|
+
* reach them at runtime is `proxy.call` -> `inner`.
|
|
21
|
+
*/
|
|
22
|
+
export function createBodyServices(opts) {
|
|
23
|
+
const ledger = new FileLedger(opts.stateDir);
|
|
24
|
+
ledger.remoteWitness = (row, resultHash) => requestWitnessSign(opts.stateDir, row, resultHash);
|
|
25
|
+
const policyText = readFileSync(opts.policyFile, "utf8");
|
|
26
|
+
const policyDocument = JSON.parse(policyText);
|
|
27
|
+
const policy = loadPolicy(policyText);
|
|
28
|
+
persistPolicySnapshot(opts.stateDir, policy.hash, policyDocument);
|
|
29
|
+
const now = opts.now ?? (() => Date.now());
|
|
30
|
+
const nonce = opts.nonce ?? (() => crypto.randomUUID());
|
|
31
|
+
const registry = new Map();
|
|
32
|
+
registry.set("memory.get", (call, principal) => memoryGet(call, opts.stateDir, now, principal));
|
|
33
|
+
registry.set("memory.put", (call, principal) => memoryPut(call, opts.stateDir, principal));
|
|
34
|
+
const explainOpts = async () => {
|
|
35
|
+
const env = process.env.VERAX_RECORD_PUBKEY_PIN;
|
|
36
|
+
const pem = env && env.trim() !== "" ? env : opts.recordSigner.publicKeyPem;
|
|
37
|
+
return {
|
|
38
|
+
...(pem
|
|
39
|
+
? {
|
|
40
|
+
issuerTrust: {
|
|
41
|
+
publicKeyPem: pem,
|
|
42
|
+
source: env && env.trim() !== "" ? "env" : "own-key",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
: {}),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
registry.set("audit.explain", async (call) => auditExplain(call, ledger, await explainOpts()));
|
|
49
|
+
registry.set("message.read", (call, principal) => messageRead(call, opts.stateDir, principal));
|
|
50
|
+
registry.set("message.send", (call, principal, ref) => messageSend(call, opts.stateDir, ref ?? "", principal));
|
|
51
|
+
registry.set("spend", (call, _principal, ref) => spendAuthorize(call, ref ?? ""));
|
|
52
|
+
const inner = async (call, principal, ref) => {
|
|
53
|
+
const fn = registry.get(call.name);
|
|
54
|
+
if (!fn) {
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: JSON.stringify({ error: "unknown-tool", name: call.name }) }],
|
|
57
|
+
isError: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return fn(call, principal, ref);
|
|
61
|
+
};
|
|
62
|
+
const proxy = createProxy({
|
|
63
|
+
policy,
|
|
64
|
+
recordSigner: opts.recordSigner,
|
|
65
|
+
effectSigner: opts.effectSigner,
|
|
66
|
+
ledger,
|
|
67
|
+
now,
|
|
68
|
+
nonce,
|
|
69
|
+
inner,
|
|
70
|
+
resolveInput: (id, principal) => readMemoryMeta(opts.stateDir, id, principal),
|
|
71
|
+
checkTenantMismatch: async (call, principal) => {
|
|
72
|
+
if (call.name === "memory.get") {
|
|
73
|
+
const id = call.arguments.id;
|
|
74
|
+
if (typeof id !== "string")
|
|
75
|
+
return false;
|
|
76
|
+
return memoryBelongsToOtherTenant(opts.stateDir, id, tenantKey(principal));
|
|
77
|
+
}
|
|
78
|
+
if (call.name === "audit.explain") {
|
|
79
|
+
const ref = call.arguments.ref;
|
|
80
|
+
if (typeof ref !== "string" || ref === "")
|
|
81
|
+
return false;
|
|
82
|
+
const decisions = await ledger.decisions();
|
|
83
|
+
if (!decisions.some((d) => d.claims.ref === ref))
|
|
84
|
+
return false;
|
|
85
|
+
const owner = await inputsPrincipal(opts.stateDir, ref);
|
|
86
|
+
if (owner === null)
|
|
87
|
+
return true;
|
|
88
|
+
return tenantKey(owner) !== tenantKey(principal);
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
proxy,
|
|
95
|
+
ledger,
|
|
96
|
+
policyHash: policy.hash,
|
|
97
|
+
policyDocument,
|
|
98
|
+
listTools: () => TOOL_NAMES,
|
|
99
|
+
explainOpts,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { writeFileAtomic } from "./atomic-write.ts";
|
|
2
|
+
import { type SignedCheckpoint } from "@cedulon/checkpoint";
|
|
3
|
+
import { type LedgerEffect } from "@verax-ai/proxy";
|
|
4
|
+
export type WitnessListen = {
|
|
5
|
+
pid: number;
|
|
6
|
+
port: number;
|
|
7
|
+
token: string;
|
|
8
|
+
publicKeyPem: string;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
};
|
|
11
|
+
type EffectRowWire = {
|
|
12
|
+
ref: string;
|
|
13
|
+
effectHash: string;
|
|
14
|
+
effectClass: string;
|
|
15
|
+
timestampMs: number;
|
|
16
|
+
actor?: string;
|
|
17
|
+
};
|
|
18
|
+
/** The same writer the listen file goes through, so its test measures it. */
|
|
19
|
+
export declare const writeListenForTest: typeof writeFileAtomic;
|
|
20
|
+
export declare function loadOrCreateWitnessKeys(stateDir: string): {
|
|
21
|
+
privateKeyPem: string;
|
|
22
|
+
publicKeyPem: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function readWitnessListen(stateDir: string): WitnessListen | null;
|
|
25
|
+
export type CheckpointWindow = {
|
|
26
|
+
epoch: number;
|
|
27
|
+
startMs: number;
|
|
28
|
+
endMs: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function requestWitnessCheckpoint(stateDir: string, window: CheckpointWindow): Promise<SignedCheckpoint | null>;
|
|
31
|
+
export declare function requestWitnessSign(stateDir: string, row: EffectRowWire, resultHash: string | undefined): Promise<Pick<LedgerEffect, "receipt" | "attestation" | "witnessClass"> | null>;
|
|
32
|
+
export declare function runWitness(stateDir: string): Promise<void>;
|
|
33
|
+
export {};
|
package/dist/witness.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { randomBytes, generateKeyPairSync } from "node:crypto";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
6
|
+
import { buildCheckpointClaims, checkpointHash, signCheckpoint, totalsFromDecisionRecords, } from "@cedulon/checkpoint";
|
|
7
|
+
import { decisionRecordHash } from "@cedulon/core";
|
|
8
|
+
import { checkpointsPath, signEffectAttestation } from "@verax-ai/proxy";
|
|
9
|
+
import { pidAlive } from "./unlock.js";
|
|
10
|
+
const WITNESS_CLASS = "same-org";
|
|
11
|
+
function listenPath(stateDir) {
|
|
12
|
+
return join(stateDir, "witness.listen.json");
|
|
13
|
+
}
|
|
14
|
+
function statusPath(stateDir) {
|
|
15
|
+
return join(stateDir, "witness-status.jsonl");
|
|
16
|
+
}
|
|
17
|
+
function writePemAtomic(path, pem) {
|
|
18
|
+
writeFileAtomic(path, pem);
|
|
19
|
+
}
|
|
20
|
+
/** The same writer the listen file goes through, so its test measures it. */
|
|
21
|
+
export const writeListenForTest = writeFileAtomic;
|
|
22
|
+
export function loadOrCreateWitnessKeys(stateDir) {
|
|
23
|
+
const dir = join(stateDir, "keys");
|
|
24
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const priv = join(dir, "witness.private.pem");
|
|
26
|
+
const pub = join(dir, "witness.public.pem");
|
|
27
|
+
if (existsSync(priv) && existsSync(pub)) {
|
|
28
|
+
return { privateKeyPem: readFileSync(priv, "utf8"), publicKeyPem: readFileSync(pub, "utf8") };
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(priv) || existsSync(pub)) {
|
|
31
|
+
throw new Error("witness-keys-partial");
|
|
32
|
+
}
|
|
33
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
34
|
+
const pair = {
|
|
35
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
36
|
+
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
37
|
+
};
|
|
38
|
+
writePemAtomic(priv, pair.privateKeyPem);
|
|
39
|
+
writePemAtomic(pub, pair.publicKeyPem);
|
|
40
|
+
return pair;
|
|
41
|
+
}
|
|
42
|
+
export function readWitnessListen(stateDir) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(readFileSync(listenPath(stateDir), "utf8"));
|
|
45
|
+
if (typeof raw.pid !== "number" ||
|
|
46
|
+
typeof raw.port !== "number" ||
|
|
47
|
+
typeof raw.token !== "string" ||
|
|
48
|
+
typeof raw.publicKeyPem !== "string") {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
pid: raw.pid,
|
|
53
|
+
port: raw.port,
|
|
54
|
+
token: raw.token,
|
|
55
|
+
publicKeyPem: raw.publicKeyPem,
|
|
56
|
+
startedAt: typeof raw.startedAt === "number" ? raw.startedAt : 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function appendStatus(stateDir, row) {
|
|
64
|
+
appendFileSync(statusPath(stateDir), `${JSON.stringify(row)}\n`, { encoding: "utf8" });
|
|
65
|
+
}
|
|
66
|
+
function asRow(raw) {
|
|
67
|
+
if (!raw || typeof raw !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const o = raw;
|
|
70
|
+
if (typeof o.ref !== "string" ||
|
|
71
|
+
typeof o.effectHash !== "string" ||
|
|
72
|
+
typeof o.effectClass !== "string" ||
|
|
73
|
+
typeof o.timestampMs !== "number") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ref: o.ref,
|
|
78
|
+
effectHash: o.effectHash,
|
|
79
|
+
effectClass: o.effectClass,
|
|
80
|
+
timestampMs: o.timestampMs,
|
|
81
|
+
...(typeof o.actor === "string" ? { actor: o.actor } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function readJson(req, limit = 65_536) {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let n = 0;
|
|
87
|
+
for await (const chunk of req) {
|
|
88
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
89
|
+
n += buf.length;
|
|
90
|
+
if (n > limit)
|
|
91
|
+
throw new Error("too-large");
|
|
92
|
+
chunks.push(buf);
|
|
93
|
+
}
|
|
94
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
95
|
+
return text === "" ? {} : JSON.parse(text);
|
|
96
|
+
}
|
|
97
|
+
function send(res, status, body) {
|
|
98
|
+
const text = JSON.stringify(body);
|
|
99
|
+
res.writeHead(status, {
|
|
100
|
+
"content-type": "application/json",
|
|
101
|
+
"content-length": Buffer.byteLength(text),
|
|
102
|
+
"x-content-type-options": "nosniff",
|
|
103
|
+
});
|
|
104
|
+
res.end(text);
|
|
105
|
+
}
|
|
106
|
+
function loadDecisions(stateDir) {
|
|
107
|
+
try {
|
|
108
|
+
const text = readFileSync(join(stateDir, "decisions.jsonl"), "utf8");
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const line of text.split("\n")) {
|
|
111
|
+
if (line === "")
|
|
112
|
+
continue;
|
|
113
|
+
out.push(JSON.parse(line));
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "ENOENT")
|
|
119
|
+
return [];
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function lastCheckpointHash(stateDir) {
|
|
124
|
+
try {
|
|
125
|
+
const text = readFileSync(checkpointsPath(stateDir), "utf8").trim();
|
|
126
|
+
if (text === "")
|
|
127
|
+
return null;
|
|
128
|
+
const lines = text.split("\n").filter((l) => l !== "");
|
|
129
|
+
const last = JSON.parse(lines[lines.length - 1] ?? "{}");
|
|
130
|
+
if (typeof last.coseHex !== "string" || !last.claims)
|
|
131
|
+
return null;
|
|
132
|
+
return checkpointHash(last);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
if (err.code === "ENOENT")
|
|
136
|
+
return null;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function signWindowCheckpoint(stateDir, window, keys) {
|
|
141
|
+
const startMs = window.startMs;
|
|
142
|
+
const endMs = window.endMs;
|
|
143
|
+
const inWindow = loadDecisions(stateDir).filter((d) => d.claims.timestampMs >= startMs && d.claims.timestampMs < endMs);
|
|
144
|
+
const claims = buildCheckpointClaims(window.epoch, inWindow, startMs, endMs, lastCheckpointHash(stateDir), totalsFromDecisionRecords, (row) => decisionRecordHash(row));
|
|
145
|
+
const signed = signCheckpoint(claims, keys.privateKeyPem, keys.publicKeyPem);
|
|
146
|
+
appendFileSync(checkpointsPath(stateDir), `${JSON.stringify(signed)}\n`, { encoding: "utf8" });
|
|
147
|
+
return signed;
|
|
148
|
+
}
|
|
149
|
+
export async function requestWitnessCheckpoint(stateDir, window) {
|
|
150
|
+
const listen = readWitnessListen(stateDir);
|
|
151
|
+
if (!listen || !pidAlive(listen.pid))
|
|
152
|
+
return null;
|
|
153
|
+
try {
|
|
154
|
+
const ac = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => ac.abort(), 800);
|
|
156
|
+
const res = await fetch(`http://127.0.0.1:${listen.port}/checkpoint`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "content-type": "application/json" },
|
|
159
|
+
body: JSON.stringify({ token: listen.token, ...window }),
|
|
160
|
+
signal: ac.signal,
|
|
161
|
+
});
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
if (!res.ok)
|
|
164
|
+
return null;
|
|
165
|
+
const body = (await res.json());
|
|
166
|
+
if (typeof body.coseHex !== "string" || !body.claims)
|
|
167
|
+
return null;
|
|
168
|
+
return body;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export async function requestWitnessSign(stateDir, row, resultHash) {
|
|
175
|
+
const listen = readWitnessListen(stateDir);
|
|
176
|
+
if (!listen || !pidAlive(listen.pid)) {
|
|
177
|
+
appendStatus(stateDir, {
|
|
178
|
+
atMs: Date.now(),
|
|
179
|
+
ref: row.ref,
|
|
180
|
+
result: "self-fallback",
|
|
181
|
+
reason: "unreachable",
|
|
182
|
+
});
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const ac = new AbortController();
|
|
187
|
+
const timer = setTimeout(() => ac.abort(), 400);
|
|
188
|
+
const res = await fetch(`http://127.0.0.1:${listen.port}/sign`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "content-type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ token: listen.token, row, resultHash: resultHash ?? null }),
|
|
192
|
+
signal: ac.signal,
|
|
193
|
+
});
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
appendStatus(stateDir, {
|
|
197
|
+
atMs: Date.now(),
|
|
198
|
+
ref: row.ref,
|
|
199
|
+
result: "self-fallback",
|
|
200
|
+
reason: "unreachable",
|
|
201
|
+
});
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const body = (await res.json());
|
|
205
|
+
if (body.witnessClass !== WITNESS_CLASS || !body.receipt || !body.attestation?.coseHex) {
|
|
206
|
+
appendStatus(stateDir, {
|
|
207
|
+
atMs: Date.now(),
|
|
208
|
+
ref: row.ref,
|
|
209
|
+
result: "self-fallback",
|
|
210
|
+
reason: "unreachable",
|
|
211
|
+
});
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
appendStatus(stateDir, { atMs: Date.now(), ref: row.ref, result: "signed", pid: listen.pid });
|
|
215
|
+
return {
|
|
216
|
+
witnessClass: WITNESS_CLASS,
|
|
217
|
+
receipt: body.receipt,
|
|
218
|
+
attestation: body.attestation,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
appendStatus(stateDir, {
|
|
223
|
+
atMs: Date.now(),
|
|
224
|
+
ref: row.ref,
|
|
225
|
+
result: "self-fallback",
|
|
226
|
+
reason: "unreachable",
|
|
227
|
+
});
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export async function runWitness(stateDir) {
|
|
232
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
233
|
+
const existing = readWitnessListen(stateDir);
|
|
234
|
+
if (existing && pidAlive(existing.pid)) {
|
|
235
|
+
process.stderr.write(`witness-already-running:${existing.pid}\n`);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
const keys = loadOrCreateWitnessKeys(stateDir);
|
|
239
|
+
const token = randomBytes(16).toString("hex");
|
|
240
|
+
const server = createServer((req, res) => {
|
|
241
|
+
void (async () => {
|
|
242
|
+
try {
|
|
243
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
244
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
245
|
+
send(res, 200, { ok: true, pid: process.pid });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (req.method === "POST" && url.pathname === "/sign") {
|
|
249
|
+
const body = (await readJson(req));
|
|
250
|
+
if (body.token !== token) {
|
|
251
|
+
send(res, 401, { error: "unauthorized" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const row = asRow(body.row);
|
|
255
|
+
if (!row) {
|
|
256
|
+
send(res, 400, { error: "row-invalid" });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const resultHash = typeof body.resultHash === "string" ? body.resultHash : undefined;
|
|
260
|
+
const signed = signEffectAttestation(row, WITNESS_CLASS, resultHash, keys);
|
|
261
|
+
send(res, 200, { witnessClass: WITNESS_CLASS, ...signed });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (req.method === "POST" && url.pathname === "/checkpoint") {
|
|
265
|
+
const body = (await readJson(req));
|
|
266
|
+
if (body.token !== token) {
|
|
267
|
+
send(res, 401, { error: "unauthorized" });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (typeof body.epoch !== "number" ||
|
|
271
|
+
!Number.isInteger(body.epoch) ||
|
|
272
|
+
typeof body.startMs !== "number" ||
|
|
273
|
+
typeof body.endMs !== "number" ||
|
|
274
|
+
body.endMs <= body.startMs) {
|
|
275
|
+
send(res, 400, { error: "window-invalid" });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const signed = signWindowCheckpoint(stateDir, { epoch: body.epoch, startMs: body.startMs, endMs: body.endMs }, keys);
|
|
279
|
+
send(res, 200, signed);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
send(res, 404, { error: "not-found" });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
send(res, 400, { error: "bad-request" });
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
288
|
+
});
|
|
289
|
+
await new Promise((resolve, reject) => {
|
|
290
|
+
server.once("error", reject);
|
|
291
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
292
|
+
});
|
|
293
|
+
const addr = server.address();
|
|
294
|
+
if (!addr || typeof addr === "string") {
|
|
295
|
+
server.close();
|
|
296
|
+
throw new Error("witness-bind");
|
|
297
|
+
}
|
|
298
|
+
const listen = {
|
|
299
|
+
pid: process.pid,
|
|
300
|
+
port: addr.port,
|
|
301
|
+
token,
|
|
302
|
+
publicKeyPem: keys.publicKeyPem,
|
|
303
|
+
startedAt: Date.now(),
|
|
304
|
+
};
|
|
305
|
+
writeFileAtomic(listenPath(stateDir), `${JSON.stringify(listen)}\n`);
|
|
306
|
+
process.stderr.write(`witness-pid:${process.pid}\n`);
|
|
307
|
+
process.stderr.write(`witness-port:${addr.port}\n`);
|
|
308
|
+
await new Promise((resolve) => {
|
|
309
|
+
const shutdown = () => {
|
|
310
|
+
try {
|
|
311
|
+
unlinkSync(listenPath(stateDir));
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// already gone
|
|
315
|
+
}
|
|
316
|
+
server.close(() => resolve());
|
|
317
|
+
};
|
|
318
|
+
process.once("SIGINT", shutdown);
|
|
319
|
+
process.once("SIGTERM", shutdown);
|
|
320
|
+
});
|
|
321
|
+
}
|