@uuaid/pillar 0.2.0-alpha.10

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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Zestro WebSocket bridge.
3
+ *
4
+ * Wire protocol — verified against the OFFICIAL client contract
5
+ * (Zestro app.asar: dist/cli/maestro-client.ts, sourcesContent from
6
+ * maestro-cli.js.map) and the app's own server/renderer handlers:
7
+ * ws://127.0.0.1:53522/<token>/ws
8
+ * -> { type: "send_command", requestId: "req_...", sessionId: <agentId>,
9
+ * command: <str>, inputMode: "ai", tabId?: <uuid>, force?: true }
10
+ * <- { type: "command_result", success, sessionId, tabId?, requestId } // echoes requestId
11
+ * <- { type: "error", message, sessionId } // NO requestId echo
12
+ *
13
+ * Correlation is on `requestId`, NOT `id` — the server echoes only
14
+ * `requestId`, so a client correlating on `id` can never match a reply.
15
+ *
16
+ * Semantics (verified in app code, all four layers):
17
+ * - command_result arrives IMMEDIATELY after the renderer receives the
18
+ * command — it means "consumed by the renderer / spawn initiated", NOT
19
+ * "the agent's turn completed". The turn answer returns out-of-band.
20
+ * - A busy target session WITHOUT force is rejected by the server
21
+ * ({type:"error"}), and the renderer has TWO more busy drops of its own.
22
+ * Errors carry no requestId, so they must be handled single-flight.
23
+ * - force=true on a BUSY tab spawns a SECOND agent process resuming the
24
+ * same session transcript (handle-spawn.js: no live-process guard,
25
+ * --resume <agentSessionId>). Concurrent, not queued. Opt-in only.
26
+ *
27
+ * Token lives in ~/Library/Application Support/zestro/cli-server.json.
28
+ * Agent id for ZZ was verified to be 7fbce535-3a2f-4e6b-96e6-64926403efa5.
29
+ */
30
+ import { WebSocket } from "ws";
31
+
32
+ /** Server rejected the command (busy session, bad payload, ...). */
33
+ export class ZestroServerError extends Error {
34
+ constructor(message, code = "ZESTRO_SERVER_ERROR") {
35
+ super(message);
36
+ this.name = "ZestroServerError";
37
+ this.code = code;
38
+ }
39
+ }
40
+
41
+ export class ZestroBridge {
42
+ constructor({ url, token, sessionId, tabId, timeoutMs = 30000, force = false }) {
43
+ if (!url || !token || !sessionId) {
44
+ throw new Error("ZestroBridge requires { url, token, sessionId }");
45
+ }
46
+ // Accept either the token-embedded URL or the base URL.
47
+ if (!url.includes("/ws")) {
48
+ if (!url.endsWith("/")) url += "/";
49
+ url = `${url}${token}/ws`;
50
+ }
51
+ this.url = url;
52
+ this.sessionId = sessionId;
53
+ this.tabId = tabId ?? null;
54
+ this.timeoutMs = timeoutMs;
55
+ this.force = force;
56
+ this._ws = null;
57
+ this._pending = new Map();
58
+ }
59
+
60
+ connect() {
61
+ return new Promise((resolve, reject) => {
62
+ const ws = new WebSocket(this.url);
63
+ ws.on("open", () => { this._ws = ws; resolve(); });
64
+ ws.on("error", (e) => { if (!this._ws) reject(e); });
65
+ ws.on("close", () => {
66
+ for (const p of this._pending.values()) p.reject(new Error("bridge closed"));
67
+ this._pending.clear();
68
+ this._ws = null;
69
+ });
70
+ ws.on("message", (buf) => {
71
+ let msg;
72
+ try { msg = JSON.parse(buf.toString()); } catch { return; }
73
+
74
+ // `echo` = the app did not recognize the message type (older build).
75
+ if (msg.type === "echo") {
76
+ const original = msg.data ?? {};
77
+ const reqId = original.requestId ?? msg.originalRequestId;
78
+ if (reqId && this._pending.has(reqId)) {
79
+ this._settle(reqId, p => p.reject(new ZestroServerError(
80
+ `Zestro app does not support '${original.type ?? "unknown"}' (older build?)`,
81
+ "ZESTRO_UNSUPPORTED"
82
+ )));
83
+ }
84
+ return;
85
+ }
86
+
87
+ // Exact correlation: server echoes requestId on command_result.
88
+ if (msg.requestId && this._pending.has(msg.requestId)) {
89
+ this._settle(msg.requestId, p => p.resolve(msg));
90
+ return;
91
+ }
92
+
93
+ // Single-flight: server `error` replies carry NO requestId (verified:
94
+ // sendError sends {type:'error', message, ...extra}). The bridge keeps
95
+ // at most one request in flight, so route orphan replies to it.
96
+ if (msg.type === "error") {
97
+ const message = msg.message ?? "zestro server error";
98
+ const code = /busy/i.test(message) ? "SESSION_BUSY" : "ZESTRO_SERVER_ERROR";
99
+ this._settleOldest(p => p.reject(new ZestroServerError(message, code)));
100
+ return;
101
+ }
102
+ if (msg.type === "command_result") {
103
+ this._settleOldest(p => p.resolve(msg));
104
+ }
105
+ });
106
+ });
107
+ }
108
+
109
+ _settle(requestId, fn) {
110
+ const p = this._pending.get(requestId);
111
+ if (p) {
112
+ this._pending.delete(requestId);
113
+ fn(p);
114
+ }
115
+ }
116
+
117
+ _settleOldest(fn) {
118
+ const first = this._pending.entries().next();
119
+ if (!first.done) {
120
+ const [requestId, p] = first.value;
121
+ this._pending.delete(requestId);
122
+ fn(p);
123
+ }
124
+ }
125
+
126
+ sendCommand({ command, inputMode = "ai", force } = {}) {
127
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
128
+ return Promise.reject(new Error("zestro bridge not connected"));
129
+ }
130
+ const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
131
+ return new Promise((resolve, reject) => {
132
+ const timer = setTimeout(() => {
133
+ this._pending.delete(requestId);
134
+ reject(new Error(`zestro send_command ${requestId} timed out after ${this.timeoutMs}ms`));
135
+ }, this.timeoutMs);
136
+ this._pending.set(requestId, {
137
+ resolve: (msg) => { clearTimeout(timer); resolve(msg); },
138
+ reject: (e) => { clearTimeout(timer); reject(e); },
139
+ });
140
+ this._ws.send(JSON.stringify({
141
+ type: "send_command",
142
+ requestId,
143
+ sessionId: this.sessionId,
144
+ command,
145
+ inputMode,
146
+ ...(this.tabId ? { tabId: this.tabId } : {}),
147
+ ...((force ?? this.force) ? { force: true } : {}),
148
+ }));
149
+ });
150
+ }
151
+
152
+ close() {
153
+ if (this._ws) { try { this._ws.close(); } catch (_e) {} this._ws = null; }
154
+ }
155
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * End-to-end payload encryption — FUNDAMENTAL, not optional.
3
+ *
4
+ * Every envelope's payload is sealed to the recipient's key. There is no
5
+ * plaintext mode. A carrier (or any relay) sees only ciphertext.
6
+ *
7
+ * Construction (sealed box over the agent's existing Ed25519 identity):
8
+ * 1. Recipient's Ed25519 public key --birational map--> X25519 public key
9
+ * (libsodium crypto_sign_ed25519_pk_to_curve25519 equivalent;
10
+ * @noble/curves ed25519.utils.toMontgomery)
11
+ * 2. Sender mints an EPHEMERAL X25519 keypair per envelope (forward secrecy
12
+ * per message: compromising the sender's long-term key never decrypts
13
+ * past traffic; compromising the recipient's does — that's the classic
14
+ * sealed-box tradeoff, addressed by key rotation policy, not this layer).
15
+ * 3. shared = X25519(ephemeralPriv, recipientX25519Pub)
16
+ * 4. key = HKDF-SHA256(shared, salt = epk || recipientX25519Pub,
17
+ * info = "uuaid-pillar-e2e/1") -> 32 bytes
18
+ * 5. AES-256-GCM(key, iv = random 12 bytes) over JCS(payload)
19
+ * AAD = envelope id (binds ciphertext to THIS envelope; a ciphertext
20
+ * transplanted into another envelope fails the GCM tag).
21
+ *
22
+ * Identity binding: callers MUST verify sha256(recipientEdPub) derives the
23
+ * recipient's UUAID local-id before sealing (seal() in envelope.mjs does it).
24
+ */
25
+ import { createHash, createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
26
+ import { ed25519, x25519 } from "@noble/curves/ed25519.js";
27
+ import { jcs } from "../identity/jcs.mjs";
28
+
29
+ export const E2E_ALG = "x25519-hkdf-sha256-aes256gcm";
30
+ const HKDF_INFO = "uuaid-pillar-e2e/1";
31
+
32
+ /** Convert an Ed25519 public key (hex) to its X25519 (Montgomery) form. */
33
+ export function edPubToX25519(edPubHex) {
34
+ return Buffer.from(ed25519.utils.toMontgomery(Buffer.from(edPubHex, "hex")));
35
+ }
36
+
37
+ /** Convert an Ed25519 seed (32 raw private bytes) to an X25519 secret. */
38
+ export function edSeedToX25519(seed32) {
39
+ return Buffer.from(ed25519.utils.toMontgomerySecret(seed32));
40
+ }
41
+
42
+ function deriveKey(shared, epk, recipientXPub) {
43
+ const salt = Buffer.concat([epk, recipientXPub]);
44
+ return Buffer.from(hkdfSync("sha256", shared, salt, HKDF_INFO, 32));
45
+ }
46
+
47
+ /**
48
+ * Seal a payload object to a recipient's Ed25519 public key.
49
+ * Returns the `enc` block for the envelope.
50
+ */
51
+ export function sealPayload(recipientEdPubHex, payload, { aad }) {
52
+ if (!aad) throw new Error("sealPayload: aad (envelope id) required");
53
+ const recipientXPub = edPubToX25519(recipientEdPubHex);
54
+ const ephemeralSecret = randomBytes(32);
55
+ const epk = Buffer.from(x25519.getPublicKey(ephemeralSecret));
56
+ const shared = Buffer.from(x25519.getSharedSecret(ephemeralSecret, recipientXPub));
57
+ const key = deriveKey(shared, epk, recipientXPub);
58
+ const iv = randomBytes(12);
59
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
60
+ cipher.setAAD(Buffer.from(aad, "utf-8"));
61
+ const plaintext = Buffer.from(jcs(payload), "utf-8");
62
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
63
+ const tag = cipher.getAuthTag();
64
+ return {
65
+ alg: E2E_ALG,
66
+ epk: epk.toString("hex"),
67
+ iv: iv.toString("hex"),
68
+ ct: Buffer.concat([ct, tag]).toString("hex"), // tag appended, last 16 bytes
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Open a sealed payload with the recipient's Ed25519 seed (32 bytes).
74
+ * Throws on any integrity failure (wrong key, tamper, transplanted aad).
75
+ */
76
+ export function openPayload(recipientSeed32, enc, { aad }) {
77
+ if (!enc || enc.alg !== E2E_ALG) throw new Error(`openPayload: unsupported alg ${enc?.alg}`);
78
+ if (!aad) throw new Error("openPayload: aad (envelope id) required");
79
+ const xSecret = edSeedToX25519(recipientSeed32);
80
+ const recipientXPub = Buffer.from(x25519.getPublicKey(xSecret));
81
+ const epk = Buffer.from(enc.epk, "hex");
82
+ const shared = Buffer.from(x25519.getSharedSecret(xSecret, epk));
83
+ const key = deriveKey(shared, epk, recipientXPub);
84
+ const raw = Buffer.from(enc.ct, "hex");
85
+ const ct = raw.subarray(0, raw.length - 16);
86
+ const tag = raw.subarray(raw.length - 16);
87
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(enc.iv, "hex"));
88
+ decipher.setAAD(Buffer.from(aad, "utf-8"));
89
+ decipher.setAuthTag(tag);
90
+ const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);
91
+ return JSON.parse(plaintext.toString("utf-8"));
92
+ }
93
+
94
+ /** sha256 helper for identity binding checks. */
95
+ export function sha256Hex(buf) {
96
+ return createHash("sha256").update(buf).digest("hex");
97
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * RFC 8785 JCS canonicalization. Same implementation as the pillar-core reference,
3
+ * kept independent so the pillar-node package has no upward dependency.
4
+ *
5
+ * Numbers: integers only (safe for the wire we design; enforce it). Floats
6
+ * throw because deterministic ECMA-262 number serialization requires more code
7
+ * than we want in a reference impl and this package's message payloads never
8
+ * legitimately contain non-integer numbers.
9
+ */
10
+ export function jcs(value) {
11
+ return _ser(value);
12
+ }
13
+
14
+ function _ser(v) {
15
+ if (v === null) return "null";
16
+ if (v === true) return "true";
17
+ if (v === false) return "false";
18
+ if (typeof v === "string") return JSON.stringify(v);
19
+ if (typeof v === "number") {
20
+ if (!Number.isFinite(v)) throw new Error("JCS: NaN/Infinity not permitted");
21
+ if (Number.isInteger(v)) return String(v);
22
+ throw new Error("JCS: non-integer numbers not permitted; use string decimal encoding");
23
+ }
24
+ if (typeof v === "bigint") return v.toString();
25
+ if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
26
+ if (typeof v === "object") {
27
+ // UTF-16 code unit sort — the RFC 8785 requirement.
28
+ const keys = Object.keys(v).sort((a, b) => {
29
+ const ab = Buffer.from(a, "utf16le"), bb = Buffer.from(b, "utf16le");
30
+ return Buffer.compare(ab, bb);
31
+ });
32
+ return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
33
+ }
34
+ throw new Error("JCS: unsupported type " + typeof v);
35
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Keychain — persistent identity for a pillar peer.
3
+ *
4
+ * Every agent has exactly one identity: an Ed25519 keypair. The private key
5
+ * is stored encrypted at rest with a password derived from the OS keyring
6
+ * (Windows Credential Manager / macOS Keychain / GNOME libsecret) if
7
+ * available, or a user-supplied passphrase otherwise.
8
+ *
9
+ * The keychain is deliberately simple: one keypair, one UUAID, one file.
10
+ * Rotation is a new keypair + a new signed profile in the DHT.
11
+ */
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
13
+ import { dirname } from "node:path";
14
+ import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, hkdfSync, createCipheriv, createDecipheriv } from "node:crypto";
15
+
16
+ const KEYCHAIN_VERSION = 1;
17
+
18
+ export class Keychain {
19
+ constructor(path) {
20
+ this.path = path;
21
+ this._identity = null; // { uuaid, publicKeyHex, privateKey (KeyObject), createdAt }
22
+ }
23
+
24
+ exists() {
25
+ return existsSync(this.path);
26
+ }
27
+
28
+ /**
29
+ * Generate a fresh identity. Never overwrites.
30
+ *
31
+ * uuaidNamespace: default "foundation" per IAASO-1001 today; will support
32
+ * federated forms once federation profile ratifies.
33
+ * objectType: default "agent"; overridable to "service" etc.
34
+ * localId: default random UUIDv4-like; caller may supply.
35
+ */
36
+ static generate({ uuaidNamespace = "foundation", objectType = "agent", localId = null } = {}) {
37
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
38
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
39
+ const rawPub = pubDer.slice(pubDer.length - 32);
40
+ const localIdActual = localId ?? Keychain._localIdFromKey(rawPub);
41
+ const uuaid = `uuaid:${uuaidNamespace}:${objectType}:${localIdActual}`;
42
+ return {
43
+ uuaid,
44
+ publicKey,
45
+ privateKey,
46
+ publicKeyHex: Buffer.from(rawPub).toString("hex"),
47
+ createdAt: new Date().toISOString(),
48
+ };
49
+ }
50
+
51
+ static _localIdFromKey(rawPub) {
52
+ // Derive a stable, publishable local id from the public key. This makes
53
+ // the UUAID self-authenticating: given the UUAID, anyone can independently
54
+ // check that the public key hashes to the expected local-id.
55
+ const h = createHash("sha256").update(rawPub).digest();
56
+ // First 16 bytes -> UUID-style dash format for readability.
57
+ const b = h.slice(0, 16);
58
+ const hex = Buffer.from(b).toString("hex");
59
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
60
+ }
61
+
62
+ /**
63
+ * Load an identity from disk.
64
+ */
65
+ load({ passphrase } = {}) {
66
+ if (!this.exists()) throw new Error("keychain not initialized; run `pillar init` first");
67
+ const blob = JSON.parse(readFileSync(this.path, "utf-8"));
68
+ if (blob.version !== KEYCHAIN_VERSION) throw new Error(`unsupported keychain version ${blob.version}`);
69
+ const salt = Buffer.from(blob.salt, "hex");
70
+ const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
71
+ const iv = Buffer.from(blob.iv, "hex");
72
+ const ct = Buffer.from(blob.ciphertext, "base64");
73
+ const tag = Buffer.from(blob.tag, "hex");
74
+ const decipher = createDecipheriv("aes-256-gcm", Buffer.from(key), iv);
75
+ decipher.setAuthTag(tag);
76
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
77
+ const seed = plain; // 32-byte raw ed25519 seed
78
+ const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
79
+ const privateKey = createPrivateKey({ key: Buffer.concat([pkcs8Prefix, seed]), format: "der", type: "pkcs8" });
80
+ const publicKey = createPublicKey(privateKey);
81
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
82
+ const rawPub = pubDer.slice(pubDer.length - 32);
83
+ const publicKeyHex = Buffer.from(rawPub).toString("hex");
84
+ // Sanity: derived UUAID must match stored UUAID.
85
+ const derivedLocalId = Keychain._localIdFromKey(rawPub);
86
+ const parts = blob.uuaid.split(":");
87
+ if (parts.length < 4 || parts[3] !== derivedLocalId) {
88
+ throw new Error("keychain integrity failure: UUAID does not match keypair");
89
+ }
90
+ this._identity = { uuaid: blob.uuaid, publicKey, privateKey, publicKeyHex, createdAt: blob.createdAt };
91
+ return this._identity;
92
+ }
93
+
94
+ /**
95
+ * Save identity to disk, encrypted with the passphrase.
96
+ */
97
+ save(identity, { passphrase } = {}) {
98
+ const seedDer = identity.privateKey.export({ type: "pkcs8", format: "der" });
99
+ const seed = seedDer.slice(seedDer.length - 32);
100
+ const salt = randomBytes(16);
101
+ const key = hkdfSync("sha256", Buffer.from(passphrase ?? "", "utf-8"), salt, "uuaid-pillar-keychain-v1", 32);
102
+ const iv = randomBytes(12);
103
+ const cipher = createCipheriv("aes-256-gcm", Buffer.from(key), iv);
104
+ const ct = Buffer.concat([cipher.update(seed), cipher.final()]);
105
+ const tag = cipher.getAuthTag();
106
+ const blob = {
107
+ version: KEYCHAIN_VERSION,
108
+ uuaid: identity.uuaid,
109
+ createdAt: identity.createdAt,
110
+ salt: salt.toString("hex"),
111
+ iv: iv.toString("hex"),
112
+ ciphertext: ct.toString("base64"),
113
+ tag: tag.toString("hex"),
114
+ // Public material is stored in the clear so `pillar profile` doesn't need a passphrase.
115
+ publicKeyHex: identity.publicKeyHex,
116
+ };
117
+ mkdirSync(dirname(this.path), { recursive: true });
118
+ writeFileSync(this.path, JSON.stringify(blob, null, 2));
119
+ chmodSync(this.path, 0o600);
120
+ this._identity = identity;
121
+ }
122
+
123
+ publicView() {
124
+ if (!this.exists()) return null;
125
+ const blob = JSON.parse(readFileSync(this.path, "utf-8"));
126
+ return { uuaid: blob.uuaid, publicKeyHex: blob.publicKeyHex, createdAt: blob.createdAt };
127
+ }
128
+
129
+ sign(data) {
130
+ if (!this._identity) throw new Error("identity not loaded");
131
+ return sign(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), this._identity.privateKey);
132
+ }
133
+
134
+ static verifyDetached(publicKeyHex, data, signatureBytes) {
135
+ const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
136
+ const der = Buffer.concat([spkiPrefix, Buffer.from(publicKeyHex, "hex")]);
137
+ const pub = createPublicKey({ key: der, format: "der", type: "spki" });
138
+ return verify(null, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"), pub, signatureBytes);
139
+ }
140
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @uuaid/pillar — v0.2.0-alpha.0
3
+ *
4
+ * IAASO-3050 reference: every agent runs its own PDP pillar, discovers peers
5
+ * via a Kademlia DHT, exchanges cryptographically signed messages over libp2p.
6
+ *
7
+ * Entry point. Not intended for import in browsers today — browser build
8
+ * lives at @uuaid/pillar-browser (later).
9
+ */
10
+ export { Keychain } from "./identity/keychain.mjs";
11
+ export { jcs } from "./identity/jcs.mjs";
12
+ export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
13
+ export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
14
+ export { Mailbox } from "./mailbox/mailbox.mjs";
15
+ export { Pillar } from "./pillar.mjs";
16
+ export { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
17
+ export { CarrierClient } from "./net/carrier-client.mjs";
18
+ export { CarrierServer } from "./net/carrier-server.mjs";
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Mailbox — per-agent SQLite outbox + inbox with per-log chain hashes.
3
+ *
4
+ * Every agent owns its own state. This is the local truth. Peers observe
5
+ * envelopes in flight, but the sender's outbox chain and the recipient's
6
+ * inbox chain are the authoritative record from each party's perspective.
7
+ * Disagreements are provable via chain-hash comparison.
8
+ *
9
+ * Two tables:
10
+ * outbox: envelopes this agent has sent. Chain-hashed.
11
+ * inbox : envelopes this agent has received. Chain-hashed independently.
12
+ *
13
+ * A "chained hash" over a log is:
14
+ * chain[0] = sha256("uuaid-pillar-mailbox-v1|" + envelope_sha256[0])
15
+ * chain[n] = sha256(chain[n-1] + envelope_sha256[n])
16
+ * Where envelope_sha256[i] = sha256(JCS(envelope[i])).
17
+ */
18
+ import Database from "better-sqlite3";
19
+ import { createHash } from "node:crypto";
20
+ import { mkdirSync } from "node:fs";
21
+ import { dirname } from "node:path";
22
+ import { jcs } from "../identity/jcs.mjs";
23
+
24
+ const SCHEMA = `
25
+ create table if not exists outbox (
26
+ seq integer primary key autoincrement,
27
+ envelope_id text not null unique,
28
+ envelope_sha256 text not null,
29
+ recipient text not null,
30
+ created_at text not null,
31
+ envelope_json text not null,
32
+ chain_hash text not null,
33
+ status text not null default 'pending',
34
+ delivered_at text,
35
+ ack_sha256 text
36
+ );
37
+ create index if not exists outbox_recipient on outbox (recipient);
38
+ create index if not exists outbox_status on outbox (status);
39
+
40
+ create table if not exists inbox (
41
+ seq integer primary key autoincrement,
42
+ envelope_id text not null unique,
43
+ envelope_sha256 text not null,
44
+ sender text not null,
45
+ received_at text not null,
46
+ envelope_json text not null,
47
+ chain_hash text not null,
48
+ read_at text
49
+ );
50
+ create index if not exists inbox_sender on inbox (sender);
51
+
52
+ create table if not exists carrier_cursors (
53
+ carrier text primary key,
54
+ seq integer not null
55
+ );
56
+
57
+ create table if not exists chain_state (
58
+ log_name text primary key,
59
+ chain_head text not null
60
+ );
61
+ `;
62
+
63
+ const CHAIN_SEED = "uuaid-pillar-mailbox-v1|";
64
+
65
+ export class Mailbox {
66
+ constructor(path) {
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ this.db = new Database(path);
69
+ this.db.pragma("journal_mode = WAL");
70
+ this.db.pragma("synchronous = NORMAL");
71
+ this.db.exec(SCHEMA);
72
+ }
73
+
74
+ close() { try { this.db.close(); } catch (_e) {} }
75
+
76
+ /** Carrier polling cursors: carrier URL -> highest seq we have consumed. */
77
+ getCarrierCursors() {
78
+ const rows = this.db.prepare("select carrier, seq from carrier_cursors").all();
79
+ return Object.fromEntries(rows.map(r => [r.carrier, r.seq]));
80
+ }
81
+
82
+ setCarrierCursor(carrier, seq) {
83
+ this.db.prepare(
84
+ "insert into carrier_cursors (carrier, seq) values (?, ?) on conflict(carrier) do update set seq = max(seq, excluded.seq)"
85
+ ).run(carrier, seq);
86
+ }
87
+
88
+ _getHead(logName) {
89
+ const row = this.db.prepare("select chain_head from chain_state where log_name = ?").get(logName);
90
+ return row?.chain_head ?? null;
91
+ }
92
+ _setHead(logName, hash) {
93
+ this.db.prepare("insert into chain_state (log_name, chain_head) values (?, ?) on conflict (log_name) do update set chain_head = excluded.chain_head").run(logName, hash);
94
+ }
95
+
96
+ static envelopeSha(envelope) {
97
+ return createHash("sha256").update(jcs(envelope)).digest("hex");
98
+ }
99
+
100
+ /**
101
+ * Append an outgoing envelope. Envelope must be a JS object that is JCS-canonicalizable
102
+ * and MUST include at least `id`, `recipient`, `createdAt`. Chain hash is computed
103
+ * atomically with the insert; concurrent appends are serialized via the SQLite transaction.
104
+ */
105
+ appendOutbound(envelope) {
106
+ return this.db.transaction(() => {
107
+ const envSha = Mailbox.envelopeSha(envelope);
108
+ const prev = this._getHead("outbox");
109
+ const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
110
+ const chain = createHash("sha256").update(chainInput).digest("hex");
111
+ this.db.prepare(`
112
+ insert into outbox (envelope_id, envelope_sha256, recipient, created_at, envelope_json, chain_hash, status)
113
+ values (?, ?, ?, ?, ?, ?, 'pending')
114
+ `).run(envelope.id, envSha, envelope.recipient, envelope.createdAt, JSON.stringify(envelope), chain);
115
+ this._setHead("outbox", chain);
116
+ return { envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
117
+ })();
118
+ }
119
+
120
+ /**
121
+ * Append an incoming envelope. Same shape. MUST be idempotent on envelope.id;
122
+ * a duplicate delivery does NOT re-append and does NOT advance the chain.
123
+ */
124
+ appendInbound(envelope) {
125
+ return this.db.transaction(() => {
126
+ const existing = this.db.prepare("select envelope_id, envelope_sha256 from inbox where envelope_id = ?").get(envelope.id);
127
+ if (existing) {
128
+ return { duplicate: true, envelopeId: envelope.id, envelopeSha256: existing.envelope_sha256 };
129
+ }
130
+ const envSha = Mailbox.envelopeSha(envelope);
131
+ const prev = this._getHead("inbox");
132
+ const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
133
+ const chain = createHash("sha256").update(chainInput).digest("hex");
134
+ this.db.prepare(`
135
+ insert into inbox (envelope_id, envelope_sha256, sender, received_at, envelope_json, chain_hash)
136
+ values (?, ?, ?, ?, ?, ?)
137
+ `).run(envelope.id, envSha, envelope.sender ?? envelope.from ?? "unknown", new Date().toISOString(), JSON.stringify(envelope), chain);
138
+ this._setHead("inbox", chain);
139
+ return { duplicate: false, envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
140
+ })();
141
+ }
142
+
143
+ markOutboundDelivered(envelopeId, ackSha256) {
144
+ this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
145
+ .run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
146
+ }
147
+
148
+ outboxTip() { return this._getHead("outbox") ?? "0".repeat(64); }
149
+ inboxTip() { return this._getHead("inbox") ?? "0".repeat(64); }
150
+
151
+ outboxSince(seq = 0, limit = 100) {
152
+ return this.db.prepare("select * from outbox where seq > ? order by seq asc limit ?").all(seq, limit);
153
+ }
154
+ inboxSince(seq = 0, limit = 100) {
155
+ return this.db.prepare("select * from inbox where seq > ? order by seq asc limit ?").all(seq, limit);
156
+ }
157
+
158
+ /** Envelopes never shown to the operator (read_at null). Oldest first. */
159
+ inboxUnread(limit = 100) {
160
+ return this.db.prepare("select * from inbox where read_at is null order by seq asc limit ?").all(limit);
161
+ }
162
+
163
+ markInboxRead(envelopeId) {
164
+ this.db.prepare("update inbox set read_at = ? where envelope_id = ? and read_at is null")
165
+ .run(new Date().toISOString(), envelopeId);
166
+ }
167
+
168
+ /**
169
+ * Recompute the outbox chain from scratch and compare to stored chain_hash column.
170
+ * Returns { ok, badSeq? }. Used by adversarial tests + operator-driven audits.
171
+ */
172
+ auditOutbox() {
173
+ const rows = this.db.prepare("select seq, envelope_sha256, chain_hash from outbox order by seq asc").all();
174
+ let prev = null;
175
+ for (const r of rows) {
176
+ const chainInput = prev == null ? CHAIN_SEED + r.envelope_sha256 : prev + r.envelope_sha256;
177
+ const expected = createHash("sha256").update(chainInput).digest("hex");
178
+ if (expected !== r.chain_hash) return { ok: false, badSeq: r.seq };
179
+ prev = r.chain_hash;
180
+ }
181
+ return { ok: true };
182
+ }
183
+ auditInbox() {
184
+ const rows = this.db.prepare("select seq, envelope_sha256, chain_hash from inbox order by seq asc").all();
185
+ let prev = null;
186
+ for (const r of rows) {
187
+ const chainInput = prev == null ? CHAIN_SEED + r.envelope_sha256 : prev + r.envelope_sha256;
188
+ const expected = createHash("sha256").update(chainInput).digest("hex");
189
+ if (expected !== r.chain_hash) return { ok: false, badSeq: r.seq };
190
+ prev = r.chain_hash;
191
+ }
192
+ return { ok: true };
193
+ }
194
+ }