create-evda 0.1.7 → 0.1.9

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,246 @@
1
+ // Is this enclave image's measurement in Sigstore's public log, signed by the workflow that built it?
2
+ // (HQ-2083, Spec 08 section 8: "The client checks inclusion in that log, never a list EVÐA hosts.")
3
+ //
4
+ // The phone runs this before it trusts a hosted EVÐA: the attested VM reports a measurement, and the
5
+ // phone accepts it only if a Sigstore bundle proves that exact digest was logged by EVÐA's CI. This
6
+ // file is the reference the Swift port follows, and its tests run against a real bundle from the
7
+ // public log, so the port can replay the same fixture.
8
+ //
9
+ // What it checks, all offline against the pinned trust root (lib/sigstore-trust.json):
10
+ // 1. the DSSE envelope holds an in-toto statement whose subject carries the digest asked about;
11
+ // 2. the envelope's signature verifies under the leaf certificate's key;
12
+ // 3. the leaf chains to Fulcio's intermediate and root, is for code signing, and was valid when the
13
+ // log integrated the entry; its SAN and OIDC issuer are exactly the signer asked about;
14
+ // 4. the log entry (Rekor `dsse` 0.0.1) names this payload, this signature and this certificate;
15
+ // 5. an RFC 6962 inclusion proof takes that entry to a root hash, and that root and tree size are
16
+ // the ones in a checkpoint signed by Rekor's key.
17
+ //
18
+ // Not checked here, on purpose: the signed entry timestamp (the inclusion proof plus a signed
19
+ // checkpoint is the stronger claim), certificate transparency SCTs, and Rekor v2 logs, which use other
20
+ // entry kinds. A bundle from a log this file does not know is refused rather than half-checked.
21
+ //
22
+ // Every failure returns { ok: false, reason } in words; nothing a bundle contains can make it throw.
23
+
24
+ import { createHash, createPublicKey, verify as verifySig, X509Certificate } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+
27
+ export const SIGSTORE_TRUST = JSON.parse(readFileSync(new URL("./sigstore-trust.json", import.meta.url), "utf8"));
28
+
29
+ /** The in-toto predicate type EVÐA's CI logs an enclave image under. */
30
+ export const ENCLAVE_STATEMENT_TYPE = "https://evda.io/enclave-measurement/v1";
31
+
32
+ /** The only signer whose enclave measurements the phone accepts: EVÐA's image workflow on main. */
33
+ export const ENCLAVE_SIGNER = Object.freeze({
34
+ issuer: "https://token.actions.githubusercontent.com",
35
+ san: "https://github.com/mikedouzinas/evda/.github/workflows/enclave-image.yml@refs/heads/main",
36
+ });
37
+
38
+ const OID_ISSUER_V1 = "1.3.6.1.4.1.57264.1.1";
39
+ const OID_ISSUER_V2 = "1.3.6.1.4.1.57264.1.8";
40
+ const OID_SAN = "2.5.29.17";
41
+ const EKU_CODE_SIGNING = "1.3.6.1.5.5.7.3.3";
42
+
43
+ const sha256 = (b) => createHash("sha256").update(b).digest();
44
+ const fromB64 = (s) => (typeof s === "string" && s.length ? Buffer.from(s, "base64") : null);
45
+ const fail = (reason) => ({ ok: false, reason });
46
+
47
+ // ─── DER, only as much as a certificate's extensions need ────────────────────────────────────────
48
+
49
+ function tlv(buf, at) {
50
+ if (at + 2 > buf.length) throw new Error("short");
51
+ const tag = buf[at];
52
+ let len = buf[at + 1];
53
+ let hdr = 2;
54
+ if (len & 0x80) {
55
+ const n = len & 0x7f;
56
+ if (n === 0 || n > 4 || at + 2 + n > buf.length) throw new Error("length");
57
+ len = 0;
58
+ for (let i = 0; i < n; i++) len = len * 256 + buf[at + 2 + i];
59
+ hdr += n;
60
+ }
61
+ const start = at + hdr;
62
+ if (start + len > buf.length) throw new Error("overrun");
63
+ return { tag, start, end: start + len, next: start + len };
64
+ }
65
+
66
+ function children(buf, node) {
67
+ const out = [];
68
+ for (let at = node.start; at < node.end;) { const c = tlv(buf, at); out.push(c); at = c.next; }
69
+ return out;
70
+ }
71
+
72
+ function oid(bytes) {
73
+ const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40];
74
+ let v = 0;
75
+ for (let i = 1; i < bytes.length; i++) { v = v * 128 + (bytes[i] & 0x7f); if (!(bytes[i] & 0x80)) { parts.push(v); v = 0; } }
76
+ return parts.join(".");
77
+ }
78
+
79
+ /** The OIDC issuer and the SAN URIs and emails of a Fulcio leaf, read from its DER. */
80
+ export function certificateIdentity(der) {
81
+ const buf = Buffer.from(der);
82
+ const tbs = children(buf, tlv(buf, 0))[0];
83
+ const extWrap = children(buf, tbs).find((c) => c.tag === 0xa3);
84
+ const out = { issuer: null, sans: [] };
85
+ if (!extWrap) return out;
86
+ let v1 = null;
87
+ for (const ext of children(buf, children(buf, extWrap)[0])) {
88
+ const parts = children(buf, ext);
89
+ const id = oid(buf.subarray(parts[0].start, parts[0].end));
90
+ const value = parts.at(-1);
91
+ const raw = buf.subarray(value.start, value.end);
92
+ if (id === OID_ISSUER_V2) { const s = tlv(raw, 0); out.issuer = raw.subarray(s.start, s.end).toString("utf8"); }
93
+ else if (id === OID_ISSUER_V1) v1 = raw.toString("utf8");
94
+ else if (id === OID_SAN) {
95
+ for (const g of children(raw, tlv(raw, 0))) if (g.tag === 0x86 || g.tag === 0x81) out.sans.push(raw.subarray(g.start, g.end).toString("utf8"));
96
+ }
97
+ }
98
+ out.issuer ??= v1;
99
+ return out;
100
+ }
101
+
102
+ // ─── RFC 6962 inclusion, and the checkpoint that signs its root ──────────────────────────────────
103
+
104
+ const node = (l, r) => sha256(Buffer.concat([Buffer.from([1]), l, r]));
105
+
106
+ /** True when `proof` takes `leaf` (a leaf hash) at `index` to `root` in a tree of `size`. */
107
+ export function verifyInclusion({ index, size, leaf, proof, root }) {
108
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(size) || index < 0 || index >= size) return false;
109
+ if (!Buffer.isBuffer(leaf) || !Buffer.isBuffer(root) || !Array.isArray(proof)) return false;
110
+ // BigInt, because a log index passes 2^31 and JavaScript's bitwise operators stop at 32 bits.
111
+ const i = BigInt(index);
112
+ const x = i ^ BigInt(size - 1);
113
+ const inner = x === 0n ? 0 : x.toString(2).length;
114
+ const border = (i >> BigInt(inner)).toString(2).replace(/0/g, "").length;
115
+ if (proof.length !== inner + border) return false;
116
+ let h = leaf;
117
+ for (let k = 0; k < inner; k++) h = ((i >> BigInt(k)) & 1n) === 0n ? node(h, proof[k]) : node(proof[k], h);
118
+ for (let k = inner; k < proof.length; k++) h = node(proof[k], h);
119
+ return h.equals(root);
120
+ }
121
+
122
+ /** Verifies a Rekor checkpoint (a signed note) against one pinned log; returns its origin, size and root. */
123
+ export function verifyCheckpoint(envelope, log) {
124
+ if (typeof envelope !== "string") return fail("the checkpoint is missing");
125
+ const split = envelope.indexOf("\n\n");
126
+ if (split < 0) return fail("the checkpoint is not a signed note");
127
+ const body = envelope.slice(0, split + 1);
128
+ const [origin, sizeLine, rootLine] = body.split("\n");
129
+ const keyId = fromB64(log.logId);
130
+ const key = createPublicKey({ key: fromB64(log.publicKey), format: "der", type: "spki" });
131
+ const host = new URL(log.baseUrl).host;
132
+ for (const line of envelope.slice(split + 2).split("\n")) {
133
+ const m = /^\u2014 (\S+) (\S+)$/.exec(line);
134
+ if (!m || m[1] !== host) continue;
135
+ const sig = Buffer.from(m[2], "base64");
136
+ if (sig.length < 5 || !sig.subarray(0, 4).equals(keyId.subarray(0, 4))) continue;
137
+ if (!verifySig("sha256", Buffer.from(body), key, sig.subarray(4))) continue;
138
+ const size = Number(sizeLine);
139
+ if (!origin?.startsWith(`${host} - `) || !Number.isSafeInteger(size) || !/^[A-Za-z0-9+/]+=*$/.test(rootLine ?? "")) return fail("the checkpoint is signed but malformed");
140
+ return { ok: true, origin, size, root: Buffer.from(rootLine, "base64") };
141
+ }
142
+ return fail(`the checkpoint carries no valid signature from ${host}`);
143
+ }
144
+
145
+ // ─── the bundle ──────────────────────────────────────────────────────────────────────────────────
146
+
147
+ function pae(type, payload) {
148
+ const t = Buffer.from(type, "utf8");
149
+ return Buffer.concat([Buffer.from(`DSSEv1 ${t.length} `), t, Buffer.from(` ${payload.length} `), payload]);
150
+ }
151
+
152
+ const pem = (der) => `-----BEGIN CERTIFICATE-----\n${der.toString("base64").match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----\n`;
153
+
154
+ function checkChain(leaf, trust, at) {
155
+ const chain = trust.fulcio.chain.map((c) => new X509Certificate(fromB64(c)));
156
+ const certs = [leaf, ...chain];
157
+ for (let i = 0; i < certs.length; i++) {
158
+ const issuer = certs[Math.min(i + 1, certs.length - 1)];
159
+ if (!certs[i].checkIssued(issuer) || !certs[i].verify(issuer.publicKey)) return `the certificate chain breaks at ${certs[i].subject || "the leaf"}`;
160
+ if (at < new Date(certs[i].validFrom) || at > new Date(certs[i].validTo)) return "a certificate in the chain was not valid when the entry was logged";
161
+ }
162
+ if (leaf.ca) return "the signing certificate is a CA, not a Fulcio leaf";
163
+ if (!(leaf.keyUsage ?? []).includes(EKU_CODE_SIGNING)) return "the signing certificate is not for code signing";
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Confirms `digest` ({ algorithm, hex }) is the subject of an in-toto statement that `signer`
169
+ * ({ issuer, san }) signed through Fulcio and that Rekor logged, from a Sigstore bundle (v0.3, DSSE).
170
+ * Pass `predicateType` to also require the statement's type (ENCLAVE_STATEMENT_TYPE for an image).
171
+ */
172
+ export function verifyLoggedMeasurement({ bundle, digest, signer, predicateType, trust = SIGSTORE_TRUST } = {}) {
173
+ try {
174
+ return check({ bundle, digest, signer, predicateType, trust });
175
+ } catch (err) {
176
+ return fail(`the bundle could not be read: ${err.message}`);
177
+ }
178
+ }
179
+
180
+ function check({ bundle, digest, signer, predicateType, trust }) {
181
+ if (!signer?.issuer || !signer?.san) return fail("no signer was named, so nothing says who must have logged it");
182
+ if (!digest?.algorithm || !/^[0-9a-f]+$/.test(digest?.hex ?? "")) return fail("no digest was named");
183
+ if (!bundle || typeof bundle !== "object") return fail("there is no bundle");
184
+
185
+ // 1. The statement.
186
+ const env = bundle.dsseEnvelope;
187
+ const payload = fromB64(env?.payload);
188
+ const sigs = env?.signatures;
189
+ if (!payload || !Array.isArray(sigs) || sigs.length !== 1) return fail("the bundle has no single-signature DSSE envelope");
190
+ if (env.payloadType !== "application/vnd.in-toto+json") return fail("the envelope does not hold an in-toto statement");
191
+ const statement = JSON.parse(payload.toString("utf8"));
192
+ if (!/^https:\/\/in-toto\.io\/Statement\/v(1|0\.1)$/.test(statement?._type ?? "")) return fail("the envelope does not hold an in-toto statement");
193
+ if (predicateType && statement.predicateType !== predicateType) return fail(`the statement type is ${statement.predicateType}, not ${predicateType}`);
194
+ const subjects = Array.isArray(statement.subject) ? statement.subject : [];
195
+ if (!subjects.some((s) => typeof s?.digest?.[digest.algorithm] === "string" && s.digest[digest.algorithm].toLowerCase() === digest.hex)) {
196
+ return fail(`the statement does not name ${digest.algorithm} digest ${digest.hex.slice(0, 16)}...`);
197
+ }
198
+
199
+ // 2 and 3. The signature, the certificate and who it was issued to.
200
+ const leafDer = fromB64(bundle.verificationMaterial?.certificate?.rawBytes);
201
+ if (!leafDer) return fail("the bundle carries no signing certificate");
202
+ const leaf = new X509Certificate(leafDer);
203
+ const sig = fromB64(sigs[0].sig);
204
+ if (!sig || !verifySig("sha256", pae(env.payloadType, payload), leaf.publicKey, sig)) return fail("the envelope's signature does not verify under its certificate");
205
+
206
+ const entries = bundle.verificationMaterial?.tlogEntries;
207
+ if (!Array.isArray(entries) || entries.length === 0) return fail("the bundle carries no log entry");
208
+ const entry = entries[0];
209
+ const integratedTime = Number(entry.integratedTime);
210
+ if (!Number.isSafeInteger(integratedTime)) return fail("the log entry has no integration time");
211
+ const chainProblem = checkChain(leaf, trust, new Date(integratedTime * 1000));
212
+ if (chainProblem) return fail(chainProblem);
213
+ const id = certificateIdentity(leafDer);
214
+ if (id.issuer !== signer.issuer) return fail(`the certificate's issuer is ${id.issuer}, not ${signer.issuer}`);
215
+ if (!id.sans.includes(signer.san)) return fail(`the certificate's signer is ${id.sans.join(", ") || "nobody"}, not ${signer.san}`);
216
+
217
+ // 4. The log entry names exactly this envelope.
218
+ const log = trust.tlogs.find((l) => l.logId === entry.logId?.keyId);
219
+ if (!log) return fail("the entry is from a log this phone does not trust");
220
+ if (integratedTime * 1000 < Date.parse(log.validFrom)) return fail("the entry predates the log's key");
221
+ const bodyBytes = fromB64(entry.canonicalizedBody);
222
+ if (!bodyBytes) return fail("the log entry has no body");
223
+ const body = JSON.parse(bodyBytes.toString("utf8"));
224
+ if (body.kind !== "dsse" || body.apiVersion !== "0.0.1" || entry.kindVersion?.kind !== "dsse" || entry.kindVersion?.version !== "0.0.1") {
225
+ return fail(`the log entry is ${body.kind} ${body.apiVersion}, and only dsse 0.0.1 is understood`);
226
+ }
227
+ if (body.spec?.payloadHash?.algorithm !== "sha256" || body.spec.payloadHash.value !== sha256(payload).toString("hex")) return fail("the log entry is for a different statement");
228
+ const logged = body.spec.signatures;
229
+ if (!Array.isArray(logged) || logged.length !== 1 || logged[0].signature !== sigs[0].sig) return fail("the log entry is for a different signature");
230
+ if (fromB64(logged[0].verifier)?.toString("utf8") !== pem(leafDer)) return fail("the log entry is for a different certificate");
231
+
232
+ // 5. The entry is in the tree whose root Rekor signed.
233
+ const proof = entry.inclusionProof;
234
+ if (!proof) return fail("the log entry has no inclusion proof");
235
+ const index = Number(proof.logIndex);
236
+ const size = Number(proof.treeSize);
237
+ const root = fromB64(proof.rootHash) ?? Buffer.alloc(0);
238
+ const hashes = Array.isArray(proof.hashes) ? proof.hashes.map((h) => Buffer.from(h, "base64")) : [];
239
+ const leafHash = sha256(Buffer.concat([Buffer.from([0]), bodyBytes]));
240
+ if (root.length !== 32 || !verifyInclusion({ index, size, leaf: leafHash, proof: hashes, root })) return fail("the inclusion proof does not reach the root");
241
+ const cp = verifyCheckpoint(proof.checkpoint?.envelope, log);
242
+ if (!cp.ok) return fail(cp.reason);
243
+ if (cp.size !== size || !cp.root.equals(root)) return fail("the checkpoint signs a different tree than the inclusion proof");
244
+
245
+ return { ok: true, logIndex: index, treeSize: size, integratedTime, statement };
246
+ }
package/lib/messages.mjs CHANGED
@@ -1,5 +1,8 @@
1
- // Dispatch through the record (HQ-1246): the owner's agent writes a message, the phone app shows it
2
- // as a card, and the owner's reply comes back as a file the agent reads on its next run.
1
+ // Dispatch through the record (HQ-1246): the owner's agent writes a message, the EVÐA phone app
2
+ // shows it as a card once it is paired and has synced, and the owner's reply comes back as a file
3
+ // the agent reads on its next run. This module only writes the file -- it has no way to see whether
4
+ // a phone ever reads it, so callers (lib/mcp.mjs, bin/evda.mjs) must not claim the card happened
5
+ // (HQ-2161: the tool told the model it had shown a card when the owner's phone app was not running).
3
6
  //
4
7
  // The format, which the phone app (iris-mobile, the EVÐA target) reads exactly:
5
8
  //
@@ -0,0 +1,403 @@
1
+ // Device pairing for the evda CLI and the MCP (HQ-1217): pair a phone, list and remove devices, sync.
2
+ //
3
+ // The pairing code itself is atlas-local's (atlas/block/atlas-local/src/node/pairing.ts), shipped
4
+ // in this package as one bundled file, dist/pairing.mjs (scripts/build-pairing.mjs). It is loaded
5
+ // only when a pairing command runs, because it needs node:sqlite, which the rest of this package
6
+ // does not: an owner on an older Node still has every other command.
7
+ //
8
+ // Everything a person reads from here is plain words. The one credential shown is the pairing code,
9
+ // because typing or scanning it on the phone is the point; no id, key or hash is printed.
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { EvdaError } from "./errors.mjs";
16
+ import { openEvda } from "./manifest.mjs";
17
+ import { SYNC_BUSY, acquireSyncLock, withSyncLock } from "./sync-lock.mjs";
18
+
19
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+ export const PAIRING_BUNDLE = join(PKG, "dist", "pairing.mjs");
21
+
22
+ // node:sqlite is unflagged from Node 22.13 (and 23.4); before that it needs --experimental-sqlite.
23
+ export const PAIRING_NODE = "22.13";
24
+
25
+ /** Why pairing cannot run on this Node, in plain words, or null when it can. */
26
+ export function pairingUnsupported(version = process.versions.node) {
27
+ const [major, minor] = String(version).split(".").map(Number);
28
+ const ok = major > 23 || (major === 23 && minor >= 4) || (major === 22 && minor >= 13);
29
+ return ok ? null : `Pairing a device needs Node ${PAIRING_NODE} or newer, and this is Node ${version}. Everything else in evda works on this Node; install a newer one to pair.`;
30
+ }
31
+
32
+ let loaded = null;
33
+
34
+ /** The bundled pairing module, pointed at this package's own lib/. Throws in plain words when it cannot load. */
35
+ export async function loadPairing({ bundle = PAIRING_BUNDLE, version = process.versions.node } = {}) {
36
+ const unsupported = pairingUnsupported(version);
37
+ if (unsupported) throw new EvdaError(unsupported, { code: "NODE_TOO_OLD", exit: 2 });
38
+ if (loaded?.bundle === bundle) return loaded.mod;
39
+ if (!existsSync(bundle)) {
40
+ throw new EvdaError("This copy of evda was installed without device pairing. Run it from the published package (npx -p create-evda evda pair), or build it here with `node scripts/build-pairing.mjs`.", { code: "NOT_BUILT", exit: 2 });
41
+ }
42
+ // Node 22 and 23 announce node:sqlite as experimental on stderr. That line is for developers, not
43
+ // for someone pairing a phone, so it is dropped while the bundle loads and nothing else is.
44
+ const emit = process.emitWarning;
45
+ process.emitWarning = function (warning, ...rest) {
46
+ if (/SQLite/i.test(String(warning?.message ?? warning))) return;
47
+ return emit.call(process, warning, ...rest);
48
+ };
49
+ try {
50
+ const mod = await import(pathToFileURL(bundle).href);
51
+ mod.usePersonRecord(PKG);
52
+ loaded = { bundle, mod };
53
+ return mod;
54
+ } catch (error) {
55
+ // A Node that has the version but not the module (started with --no-experimental-sqlite, or a
56
+ // build without it) fails here, and is told the same thing in the same words.
57
+ if (error?.code === "ERR_UNKNOWN_BUILTIN_MODULE") {
58
+ throw new EvdaError(`Pairing a device needs Node ${PAIRING_NODE} or newer with its built-in SQLite, and this Node cannot load it. Everything else in evda works on this Node.`, { code: "NODE_TOO_OLD", exit: 2 });
59
+ }
60
+ throw error;
61
+ } finally {
62
+ process.emitWarning = emit;
63
+ }
64
+ }
65
+
66
+ // A refusal wrote nothing and is exit 2; anything else (the relay unreachable) is exit 1.
67
+ const REFUSALS = /^(Stopped: this disk has|Your EVÐA has no private|Your EVÐA is busy|Your EVÐA's keys|This record has no private part|This record has private parts|This record keeps its day folder|There is already a device|There is no paired device|Give the device a name|Name at least one part)/;
68
+ function asEvdaError(error) {
69
+ if (error instanceof EvdaError) return error;
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ return new EvdaError(message, { code: "PAIRING", exit: REFUSALS.test(message) ? 2 : 1 });
72
+ }
73
+
74
+ /** Where the computer keeps its device state: `<home>/.evda/<record>/`, never inside the record. */
75
+ export function ownerOptions(dir, { env = process.env, relay } = {}) {
76
+ return { record: dir, home: resolve(env.HOME || homedir()), relayUrl: relay || env.EVDA_RELAY || undefined };
77
+ }
78
+
79
+ const partsList = (value) => (Array.isArray(value) ? value : String(value ?? "").split(",")).map((s) => String(s).trim()).filter(Boolean);
80
+
81
+ export async function pair(dir, { name, parts, relay, env } = {}) {
82
+ const p = await loadPairing();
83
+ try {
84
+ return await p.pairDevice(ownerOptions(dir, { env, relay }), { name: name ?? "phone", parts: partsList(parts) });
85
+ } catch (error) {
86
+ throw asEvdaError(error);
87
+ }
88
+ }
89
+
90
+ export const NO_DEVICES = "No devices are paired with this EVÐA.";
91
+
92
+ // The computer's list of devices lives beside its device state (pairing.ts, paired-devices.json).
93
+ // A record with no id, or no list there, has paired nothing: that is answered without loading the
94
+ // bundle, so listing devices on a fresh record, or on a Node or a copy that cannot pair, is an
95
+ // answer and not an error.
96
+ function pairedList(dir, env) {
97
+ let id;
98
+ try { id = openEvda(dir).manifest.id; } catch { return null; }
99
+ if (typeof id !== "string" || !id || /[/\\]/.test(id)) return null;
100
+ return join(resolve(env.HOME || homedir()), ".evda", id, "paired-devices.json");
101
+ }
102
+
103
+ export async function devices(dir, { relay, env = process.env, bundle } = {}) {
104
+ const list = pairedList(dir, env);
105
+ if (!list || !existsSync(list)) return [NO_DEVICES];
106
+ const p = await loadPairing(bundle ? { bundle } : {});
107
+ try {
108
+ return await p.deviceLines(ownerOptions(dir, { env, relay }));
109
+ } catch (error) {
110
+ throw asEvdaError(error);
111
+ }
112
+ }
113
+
114
+ /** The devices as data, for an app (HQ-1925): `[{name, state, parts, since}]`, empty when nothing is paired. No id. */
115
+ export async function deviceData(dir, { relay, env = process.env, bundle } = {}) {
116
+ const list = pairedList(dir, env);
117
+ if (!list || !existsSync(list)) return [];
118
+ const p = await loadPairing(bundle ? { bundle } : {});
119
+ try {
120
+ return await p.deviceList(ownerOptions(dir, { env, relay }));
121
+ } catch (error) {
122
+ throw asEvdaError(error);
123
+ }
124
+ }
125
+
126
+ /** One sync as data, for an app (HQ-1925): `{paired: [names], received, sent, refused}`. No id. */
127
+ export async function syncData(dir, { relay, env } = {}) {
128
+ const p = await loadPairing();
129
+ try {
130
+ return await withSyncLock(dir, () => p.syncOnce(ownerOptions(dir, { env, relay })), { env });
131
+ } catch (error) {
132
+ throw asEvdaError(error);
133
+ }
134
+ }
135
+
136
+ // Finish a pairing whose code was used, without scanning the record (HQ-2017). It never waits: a
137
+ // sync that holds the record answers `busy`, and that sync collects the code itself. The Mac app
138
+ // runs this every few seconds while a code is waiting.
139
+ async function finishWith(dir, fn, { env = process.env } = {}) {
140
+ let release;
141
+ try {
142
+ release = await acquireSyncLock(dir, { waitMs: 0, env });
143
+ } catch (error) {
144
+ if (error?.code === "SYNC_BUSY") return null;
145
+ throw error;
146
+ }
147
+ try {
148
+ return await fn();
149
+ } finally {
150
+ await release();
151
+ }
152
+ }
153
+
154
+ /** One finish as data, for an app: `{paired: [names], waiting: [names], busy?: line}`. No id. */
155
+ export async function finishData(dir, { relay, env = process.env } = {}) {
156
+ const list = pairedList(dir, env);
157
+ if (!list || !existsSync(list)) return { paired: [], waiting: [] };
158
+ const p = await loadPairing();
159
+ try {
160
+ const r = await finishWith(dir, () => p.finishPairing(ownerOptions(dir, { env, relay })), { env });
161
+ return r ?? { paired: [], waiting: (await p.deviceList(ownerOptions(dir, { env, relay }))).filter((d) => d.state.startsWith("waiting")).map((d) => d.name), busy: SYNC_BUSY };
162
+ } catch (error) {
163
+ throw asEvdaError(error);
164
+ }
165
+ }
166
+
167
+ /** One finish, in plain words. */
168
+ export async function finish(dir, { relay, env = process.env } = {}) {
169
+ const r = await finishData(dir, { relay, env });
170
+ if (r.busy) return [r.busy];
171
+ const lines = [
172
+ ...r.paired.map((n) => `${n} is now paired.`),
173
+ // What went to it (HQ-2106): a finish that paired a device says what it sent and what failed.
174
+ ...(r.paired.length ? [`Sent ${(r.sent ?? 0).toLocaleString("en-US")} change(s) of your record to it.`] : []),
175
+ ...(r.errors ?? []).map((e) => `Could not send everything yet: ${e}. The next sync sends the rest.`),
176
+ ...r.waiting.map((n) => `Waiting for ${n} to use its code.`),
177
+ ];
178
+ return lines.length ? lines : ["No device is waiting to be added."];
179
+ }
180
+
181
+ /**
182
+ * What is syncing on this computer now, as data for an app (HQ-2041): `{holder: {by, op, since} | null,
183
+ * owed}`, read from the state's lock and outbox. A record that never paired anything answers
184
+ * `{holder: null, owed: null}` without loading the bundle. No id.
185
+ */
186
+ export async function statusData(dir, { relay, env = process.env, bundle } = {}) {
187
+ const list = pairedList(dir, env);
188
+ if (!list || !existsSync(list)) return { holder: null, owed: null };
189
+ const p = await loadPairing(bundle ? { bundle } : {});
190
+ try {
191
+ return await p.syncState(ownerOptions(dir, { env, relay }));
192
+ } catch (error) {
193
+ throw asEvdaError(error);
194
+ }
195
+ }
196
+
197
+ // Removing a device syncs at its end (the re-keyed parts go out at once), so it holds the record's
198
+ // sync lock as a sync does (HQ-2040): a remove and a sync never talk to the relay for one record at once.
199
+ export async function remove(dir, name, { relay, env } = {}) {
200
+ if (!name || !String(name).trim()) throw new EvdaError("Say which device to remove: evda remove <device>. `evda devices` lists them.", { code: "USAGE", exit: 2 });
201
+ const p = await loadPairing();
202
+ try {
203
+ return await withSyncLock(dir, () => p.removeDeviceWords(ownerOptions(dir, { env, relay }), String(name).trim()), { env });
204
+ } catch (error) {
205
+ throw asEvdaError(error);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * What removing a device would do, in plain words, read only (HQ-2040): the MCP says this and asks
211
+ * before it removes anything. Refuses a name that is not a device here, naming the ones that are.
212
+ */
213
+ export async function describeRemoval(dir, name, { env = process.env } = {}) {
214
+ const wanted = String(name ?? "").trim();
215
+ if (!wanted) throw new EvdaError("Say which device to remove. evda_devices lists them.", { code: "USAGE", exit: 2 });
216
+ const list = (await deviceData(dir, { env })).filter((d) => d.state !== "removed");
217
+ const d = list.find((x) => x.name === wanted);
218
+ if (!d) {
219
+ throw new EvdaError(`There is no device called ${wanted} to remove. ${list.length ? `The devices are: ${list.map((x) => x.name).join(", ")}.` : NO_DEVICES}`, { code: "NO_DEVICE", exit: 2 });
220
+ }
221
+ if (d.state !== "paired") {
222
+ return [`${wanted} has not used its code (${d.state}). Removing it cancels the code, so it can never be used. Nothing else changes.`];
223
+ }
224
+ return [
225
+ `Removing ${wanted} would:`,
226
+ `1. take away its access to ${d.parts.join(", ")}, so it can no longer add to your EVÐA;`,
227
+ "2. change the keys of those parts, so nothing written from now on reaches it;",
228
+ "3. sync, so the change goes out at once.",
229
+ `What ${wanted} already has stays on it; removing cannot reach a copy already opened. It cannot be undone: to use ${wanted} again, pair it anew.`,
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * The relay mailboxes this computer reads, one per paired device, for the watcher that takes in a
235
+ * device's note within seconds (HQ-2068). Empty when nothing is paired; null when a sync holds the
236
+ * state, so the watcher looks again later instead of waiting on it. Held in memory, never printed.
237
+ */
238
+ export async function inboundMailboxes(dir, { relay, env = process.env, bundle } = {}) {
239
+ const list = pairedList(dir, env);
240
+ if (!list || !existsSync(list)) return [];
241
+ const p = await loadPairing(bundle ? { bundle } : {});
242
+ return p.inboundMailboxes(ownerOptions(dir, { env, relay }));
243
+ }
244
+
245
+ /** Every device's sync state, in plain words (HQ-2040). */
246
+ export async function syncStatus(dir, { relay, env = process.env, bundle } = {}) {
247
+ const list = pairedList(dir, env);
248
+ if (!list || !existsSync(list)) return [NO_DEVICES];
249
+ const p = await loadPairing(bundle ? { bundle } : {});
250
+ try {
251
+ return await p.syncStatusWords(ownerOptions(dir, { env, relay }));
252
+ } catch (error) {
253
+ throw asEvdaError(error);
254
+ }
255
+ }
256
+
257
+ /** Every device with when this computer last heard from it, naming the computer (HQ-2040). */
258
+ export async function deviceContacts(dir, { relay, env = process.env, bundle } = {}) {
259
+ const list = pairedList(dir, env);
260
+ if (!list || !existsSync(list)) return [NO_DEVICES];
261
+ const p = await loadPairing(bundle ? { bundle } : {});
262
+ try {
263
+ return await p.deviceContactLines(ownerOptions(dir, { env, relay }));
264
+ } catch (error) {
265
+ throw asEvdaError(error);
266
+ }
267
+ }
268
+
269
+ // Finishing a pairing from the MCP (HQ-2040). Both forms poll finishData, which takes the record's
270
+ // sync lock without waiting and the state's lock without waiting: a running sync answers busy and
271
+ // collects the code itself. So nothing here ever holds a lock between polls, and nothing runs in
272
+ // a child process.
273
+ export const FINISH_POLL_MS = 2_000;
274
+ export const FINISH_WAIT_MAX_S = 120;
275
+ const pausing = (ms) => new Promise((ok) => setTimeout(ok, ms));
276
+
277
+ /**
278
+ * Wait up to `seconds` for a device that has a code open to use it, adding it when it does. Returns
279
+ * plain lines. Stops as soon as no device is waiting, whatever the deadline.
280
+ */
281
+ export async function finishWaiting(dir, { seconds = 60, relay, env = process.env, pollMs } = {}) {
282
+ const every = pollMs ?? (Number(env.EVDA_FINISH_POLL_MS) > 0 ? Number(env.EVDA_FINISH_POLL_MS) : FINISH_POLL_MS);
283
+ const wait = Math.max(0, Math.min(FINISH_WAIT_MAX_S, Number(seconds) || 0)) * 1000;
284
+ const deadline = Date.now() + wait;
285
+ const paired = [];
286
+ let r = await finishData(dir, { relay, env });
287
+ paired.push(...r.paired);
288
+ while (r.waiting.length && Date.now() + every <= deadline) {
289
+ await pausing(every);
290
+ r = await finishData(dir, { relay, env });
291
+ paired.push(...r.paired);
292
+ }
293
+ const lines = paired.map((n) => `${n} is now paired.`);
294
+ if (r.waiting.length) lines.push(`Still waiting for ${r.waiting.join(", ")} to use the code. It is added on its own when it does, while this server runs, until the code runs out.`);
295
+ if (lines.length === 0) lines.push("No device is waiting to be added.");
296
+ return lines;
297
+ }
298
+
299
+ // One watcher a record, in this process. It stops when nothing is waiting, when `until` passes, or
300
+ // when stopWatching is called; its timer never keeps the process alive.
301
+ const watchers = new Map();
302
+
303
+ /**
304
+ * After a code is made, keep finishing in the background until the device uses it or the code runs
305
+ * out (HQ-2040): the MCP's evda_pair completes the join itself, bounded by the code's own expiry.
306
+ */
307
+ export function watchPairing(dir, { until, relay, env = process.env, everyMs } = {}) {
308
+ const every = everyMs ?? (Number(env.EVDA_FINISH_POLL_MS) > 0 ? Number(env.EVDA_FINISH_POLL_MS) : FINISH_POLL_MS);
309
+ const key = resolve(dir);
310
+ const prior = watchers.get(key);
311
+ const w = { until: Math.max(until ?? 0, prior?.until ?? 0), timer: null, paired: [...(prior?.paired ?? [])], stopped: false };
312
+ if (prior) clearTimeout(prior.timer);
313
+ watchers.set(key, w);
314
+ const tick = async () => {
315
+ if (w.stopped) return;
316
+ try {
317
+ const r = await finishData(dir, { relay, env });
318
+ w.paired.push(...r.paired);
319
+ if (!r.waiting.length && !r.busy) return stop();
320
+ } catch {
321
+ // the relay unreachable for a moment: try again on the next tick, until the code runs out
322
+ }
323
+ if (Date.now() >= w.until) return stop();
324
+ schedule();
325
+ };
326
+ const schedule = () => {
327
+ w.timer = setTimeout(tick, every);
328
+ w.timer.unref?.();
329
+ };
330
+ const stop = () => {
331
+ w.stopped = true;
332
+ clearTimeout(w.timer);
333
+ if (watchers.get(key) === w) watchers.delete(key);
334
+ };
335
+ schedule();
336
+ return { paired: w.paired, stop, get running() { return !w.stopped; } };
337
+ }
338
+
339
+ /** Stop every watcher in this process (for tests, and a server that is closing). */
340
+ export function stopWatching() {
341
+ for (const w of watchers.values()) { w.stopped = true; clearTimeout(w.timer); }
342
+ watchers.clear();
343
+ }
344
+
345
+ // Both syncs hold the record's sync lock (lib/sync-lock.mjs), so the Mac app, launchd, the MCP and a
346
+ // terminal never sync one record at the same time.
347
+ export async function sync(dir, { relay, env } = {}) {
348
+ const p = await loadPairing();
349
+ try {
350
+ return await withSyncLock(dir, () => p.syncWords(ownerOptions(dir, { env, relay })), { env });
351
+ } catch (error) {
352
+ throw asEvdaError(error);
353
+ }
354
+ }
355
+
356
+ // Give back the space the computer's sync state holds for sealed copies nothing needs (HQ-2017):
357
+ // media, which travels by name only, and copies no device is still owed. Prints what it would remove
358
+ // unless `yes`. Holds the state's lock, as a sync does.
359
+ export async function compact(dir, { yes = false, env = process.env } = {}) {
360
+ const p = await loadPairing();
361
+ try {
362
+ const { record, home } = ownerOptions(dir, { env });
363
+ return await p.compactWords({ record, home, yes: yes === true });
364
+ } catch (error) {
365
+ throw asEvdaError(error);
366
+ }
367
+ }
368
+
369
+ /** The MCP tool's answer: the code, the QR link, and what to do on the phone, step by step. */
370
+ export function pairText(r) {
371
+ return [
372
+ `Pairing code for ${r.name}: ${r.code}`,
373
+ `QR link (make it a QR code, or open it on the phone): ${r.qr}`,
374
+ "",
375
+ "On the phone:",
376
+ ...r.phoneSteps.map((s, i) => `${i + 1}. ${s}`),
377
+ "",
378
+ `${r.name} will be able to read and add to ${r.parts.join(", ")}, and nothing else. The code is a key until it is used: show it only to the owner.`,
379
+ ].join("\n");
380
+ }
381
+
382
+ /**
383
+ * evda_pair's answer (HQ-2040): which computer the phone pairs to, the code, the QR as text, the
384
+ * steps, and that this server finishes the join on its own until the code runs out.
385
+ */
386
+ export function pairToolText(r, { qrText, until } = {}) {
387
+ const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
388
+ return [
389
+ `This pairs ${r.name} to ${r.computer}, the computer this EVÐA server runs on. ${r.name} will sync with the copy of the record on ${r.computer}, and ${r.computer} is where it shows up in the device list.`,
390
+ "",
391
+ `Pairing code for ${r.name}: ${r.code}`,
392
+ `QR link: ${r.qr}`,
393
+ ...(qrText ? ["", "The QR code to scan with the phone's camera (show it to the owner exactly as it is, in a code block):", "", qrText] : []),
394
+ "",
395
+ "On the phone:",
396
+ "1. Scan the QR code with the camera, or open the EVÐA app, choose to join an EVÐA, and paste the code.",
397
+ `2. Say yes to "Pair with ${r.computer}?". The code works once, until ${clock(r.expiresAt)}.`,
398
+ `3. Nothing to do here: this server adds ${r.name} on its own once it uses the code, until ${clock(until ?? r.expiresAt)}. evda_finish_pairing waits for it and says when it is in.`,
399
+ `4. ${r.name} then holds ${r.parts.join(", ")}, and what is written there lands in today's page on ${r.computer} on the next sync.`,
400
+ "",
401
+ `${r.name} will be able to read and add to ${r.parts.join(", ")}, and nothing else. The code is a key until it is used: show it only to the owner.`,
402
+ ].join("\n");
403
+ }