create-evda 0.1.7 → 0.1.8

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,4246 @@
1
+ // Generated by create-evda's scripts/build-pairing.mjs from atlas-local's pairing code. Do not edit.
2
+
3
+ // ../atlas-local/src/node/pair-owner.ts
4
+ import { execFileSync } from "node:child_process";
5
+ import { hostname } from "node:os";
6
+
7
+ // ../atlas-local/src/node/pairing.ts
8
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
9
+ import { join as join2, resolve as resolve2 } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ // ../changes/src/changes.ts
13
+ function hashableFields(c) {
14
+ return {
15
+ changesetId: c.changesetId,
16
+ originAtlas: c.originAtlas,
17
+ originUser: c.originUser,
18
+ lamport: c.lamport,
19
+ ts: c.ts,
20
+ kind: c.kind,
21
+ target: c.target,
22
+ payload: c.payload,
23
+ prevHash: c.prevHash
24
+ };
25
+ }
26
+ function canonicalize(value) {
27
+ if (value === null || value === void 0) return JSON.stringify(value ?? null);
28
+ if (typeof value !== "object") return JSON.stringify(value);
29
+ if (Array.isArray(value)) {
30
+ return "[" + value.map(canonicalize).join(",") + "]";
31
+ }
32
+ const obj = value;
33
+ const keys = Object.keys(obj).sort();
34
+ const parts = keys.map(
35
+ (k) => JSON.stringify(k) + ":" + canonicalize(obj[k])
36
+ );
37
+ return "{" + parts.join(",") + "}";
38
+ }
39
+ function utf8Encode(str) {
40
+ const out = [];
41
+ for (let i = 0; i < str.length; i++) {
42
+ const c = str.charCodeAt(i);
43
+ if (c < 128) {
44
+ out.push(c);
45
+ continue;
46
+ }
47
+ if (c < 2048) {
48
+ out.push(192 | c >> 6, 128 | c & 63);
49
+ continue;
50
+ }
51
+ if (c >= 55296 && c <= 56319) {
52
+ const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : 0;
53
+ if (lo >= 56320 && lo <= 57343) {
54
+ const cp = 65536 + (c - 55296 << 10) + (lo - 56320);
55
+ out.push(
56
+ 240 | cp >> 18,
57
+ 128 | cp >> 12 & 63,
58
+ 128 | cp >> 6 & 63,
59
+ 128 | cp & 63
60
+ );
61
+ i++;
62
+ continue;
63
+ }
64
+ out.push(239, 191, 189);
65
+ continue;
66
+ }
67
+ if (c >= 56320 && c <= 57343) {
68
+ out.push(239, 191, 189);
69
+ continue;
70
+ }
71
+ out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63);
72
+ }
73
+ return new Uint8Array(out);
74
+ }
75
+ async function sha256Canonical(value, sha256) {
76
+ const digest = await sha256(utf8Encode(canonicalize(value)));
77
+ return Array.from(digest).map((b) => b.toString(16).padStart(2, "0")).join("");
78
+ }
79
+ async function hashChange(h, sha256) {
80
+ return sha256Canonical(h, sha256);
81
+ }
82
+ async function hashPayload(payload, sha256) {
83
+ return sha256Canonical(payload, sha256);
84
+ }
85
+ async function hashSkeleton(s, sha256) {
86
+ return sha256Canonical(s, sha256);
87
+ }
88
+ function changeSkeleton(c) {
89
+ if (c.payloadHash === void 0) {
90
+ throw new Error(
91
+ `changeSkeleton: Change ${c.id} has no payloadHash (v1 envelope). Verify it with hashChange(hashableFields(c)) instead.`
92
+ );
93
+ }
94
+ return {
95
+ changesetId: c.changesetId,
96
+ originAtlas: c.originAtlas,
97
+ originUser: c.originUser,
98
+ lamport: c.lamport,
99
+ ts: c.ts,
100
+ kind: c.kind,
101
+ target: c.target,
102
+ prevHash: c.prevHash,
103
+ payloadHash: c.payloadHash
104
+ };
105
+ }
106
+ async function buildChange(input, sha256) {
107
+ const payloadHash = await hashPayload(input.payload, sha256);
108
+ const skeleton = {
109
+ changesetId: input.changesetId,
110
+ originAtlas: input.originAtlas,
111
+ originUser: input.originUser,
112
+ lamport: input.lamport,
113
+ ts: input.ts,
114
+ kind: input.kind,
115
+ target: input.target,
116
+ prevHash: input.prevHash,
117
+ payloadHash
118
+ };
119
+ return {
120
+ id: await hashSkeleton(skeleton, sha256),
121
+ changesetId: input.changesetId,
122
+ originAtlas: input.originAtlas,
123
+ originUser: input.originUser,
124
+ lamport: input.lamport,
125
+ ts: input.ts,
126
+ kind: input.kind,
127
+ target: input.target,
128
+ payload: input.payload,
129
+ prevHash: input.prevHash,
130
+ payloadHash,
131
+ state: input.state ?? "local",
132
+ originSignature: input.originSignature ?? null
133
+ };
134
+ }
135
+ async function deterministicGenesisHash(originAtlas, sha256) {
136
+ return sha256Canonical({ kind: "__genesis__", originAtlas }, sha256);
137
+ }
138
+ async function verifyChangeContent(c, sha256) {
139
+ if (c.payloadHash === void 0) {
140
+ return await hashChange(hashableFields(c), sha256) === c.id;
141
+ }
142
+ return await hashSkeleton(changeSkeleton(c), sha256) === c.id;
143
+ }
144
+ async function verifyChain(batch, sha256, known = /* @__PURE__ */ new Set(), verifySignature) {
145
+ const issues = [];
146
+ const sorted = [...batch].sort((a, b) => a.lamport - b.lamport);
147
+ const seen = new Set(known);
148
+ const genesisSeeded = /* @__PURE__ */ new Set();
149
+ for (const c of sorted) {
150
+ if (!genesisSeeded.has(c.originAtlas)) {
151
+ seen.add(await deterministicGenesisHash(c.originAtlas, sha256));
152
+ genesisSeeded.add(c.originAtlas);
153
+ }
154
+ if (!await verifyChangeContent(c, sha256)) {
155
+ issues.push({ change: c, issue: "hash-mismatch" });
156
+ continue;
157
+ }
158
+ if (c.prevHash !== null && !seen.has(c.prevHash)) {
159
+ issues.push({ change: c, issue: "broken-prev" });
160
+ }
161
+ if (verifySignature && c.originSignature !== null) {
162
+ const ok = await verifySignature(c.originAtlas, c.id, c.originSignature);
163
+ if (ok === false) {
164
+ issues.push({ change: c, issue: "bad-signature" });
165
+ continue;
166
+ }
167
+ }
168
+ seen.add(c.id);
169
+ }
170
+ return issues;
171
+ }
172
+ function lwwResolve(extract) {
173
+ return (changes) => {
174
+ if (changes.length === 0) return { kind: "merge-needed", candidates: [] };
175
+ const sorted = [...changes].sort((a, b) => {
176
+ if (a.lamport !== b.lamport) return b.lamport - a.lamport;
177
+ if (a.originAtlas !== b.originAtlas) {
178
+ return a.originAtlas.localeCompare(b.originAtlas);
179
+ }
180
+ return a.id.localeCompare(b.id);
181
+ });
182
+ return { kind: "resolved", value: extract(sorted[0]) };
183
+ };
184
+ }
185
+ var DECISION_NOTE_RULE = {
186
+ subKey: (c) => {
187
+ const p = c.payload;
188
+ return p && typeof p.decisionId === "string" ? p.decisionId : null;
189
+ },
190
+ resolve: lwwResolve((c) => {
191
+ const p = c.payload;
192
+ return {
193
+ actionId: p && typeof p.actionId === "string" ? p.actionId : "",
194
+ actionLabel: p && typeof p.actionLabel === "string" ? p.actionLabel : "",
195
+ note: p && typeof p.note === "string" ? p.note : ""
196
+ };
197
+ })
198
+ };
199
+
200
+ // ../atlas-local/src/bytes.ts
201
+ var B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
202
+ function utf8Decode(bytes) {
203
+ let out = "";
204
+ let index = 0;
205
+ while (index < bytes.length) {
206
+ const lead = bytes[index];
207
+ if (lead <= 127) {
208
+ out += String.fromCharCode(lead);
209
+ index += 1;
210
+ continue;
211
+ }
212
+ let needed;
213
+ let code;
214
+ let lower = 128;
215
+ let upper = 191;
216
+ if (lead >= 194 && lead <= 223) {
217
+ needed = 1;
218
+ code = lead & 31;
219
+ } else if (lead >= 224 && lead <= 239) {
220
+ needed = 2;
221
+ code = lead & 15;
222
+ if (lead === 224) lower = 160;
223
+ if (lead === 237) upper = 159;
224
+ } else if (lead >= 240 && lead <= 244) {
225
+ needed = 3;
226
+ code = lead & 7;
227
+ if (lead === 240) lower = 144;
228
+ if (lead === 244) upper = 143;
229
+ } else {
230
+ out += "\uFFFD";
231
+ index += 1;
232
+ continue;
233
+ }
234
+ let consumed = 1;
235
+ let ok = true;
236
+ for (let offset = 0; offset < needed; offset += 1) {
237
+ const next = bytes[index + 1 + offset];
238
+ if (next === void 0) {
239
+ ok = false;
240
+ break;
241
+ }
242
+ const min = offset === 0 ? lower : 128;
243
+ const max = offset === 0 ? upper : 191;
244
+ if (next < min || next > max) {
245
+ ok = false;
246
+ break;
247
+ }
248
+ code = code << 6 | next & 63;
249
+ consumed += 1;
250
+ }
251
+ if (!ok) {
252
+ out += "\uFFFD";
253
+ index += consumed;
254
+ continue;
255
+ }
256
+ if (code > 65535) {
257
+ const adjusted = code - 65536;
258
+ out += String.fromCharCode(55296 + (adjusted >> 10), 56320 + (adjusted & 1023));
259
+ } else {
260
+ out += String.fromCharCode(code);
261
+ }
262
+ index += 1 + needed;
263
+ }
264
+ return out;
265
+ }
266
+ function base64(bytes) {
267
+ let out = "";
268
+ for (let i = 0; i < bytes.length; i += 3) {
269
+ const b0 = bytes[i] ?? 0;
270
+ const b1 = bytes[i + 1];
271
+ const b2 = bytes[i + 2];
272
+ out += B64_ALPHABET[b0 >> 2];
273
+ out += B64_ALPHABET[(b0 & 3) << 4 | (b1 ?? 0) >> 4];
274
+ out += b1 === void 0 ? "=" : B64_ALPHABET[(b1 & 15) << 2 | (b2 ?? 0) >> 6];
275
+ out += b2 === void 0 ? "=" : B64_ALPHABET[b2 & 63];
276
+ }
277
+ return out;
278
+ }
279
+ function base64url(bytes) {
280
+ let out = "";
281
+ for (const char of base64(bytes)) {
282
+ if (char === "=") break;
283
+ out += char === "+" ? "-" : char === "/" ? "_" : char;
284
+ }
285
+ return out;
286
+ }
287
+ function fromBase64(value) {
288
+ const bytes = [];
289
+ let buffer = 0;
290
+ let bits = 0;
291
+ for (const char of value.trim()) {
292
+ if (char === "=") break;
293
+ const index = B64_ALPHABET.indexOf(char);
294
+ if (index < 0) throw new Error(`not base64: ${JSON.stringify(char)}`);
295
+ buffer = buffer << 6 | index;
296
+ bits += 6;
297
+ if (bits >= 8) {
298
+ bits -= 8;
299
+ bytes.push(buffer >> bits & 255);
300
+ }
301
+ }
302
+ return Uint8Array.from(bytes);
303
+ }
304
+ function concatBytes(parts) {
305
+ let length = 0;
306
+ for (const part of parts) length += part.length;
307
+ const out = new Uint8Array(length);
308
+ let offset = 0;
309
+ for (const part of parts) {
310
+ out.set(part, offset);
311
+ offset += part.length;
312
+ }
313
+ return out;
314
+ }
315
+ function toHex(bytes) {
316
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
317
+ }
318
+
319
+ // ../atlas-local/src/blobs.ts
320
+ async function sealBlob(host, plaintext) {
321
+ if (plaintext.length === 0) throw new Error("blob: refusing to seal empty bytes");
322
+ const { publicKey, privateKey } = await host.crypto.newLinkKeypair();
323
+ const ciphertext = await host.crypto.seal(publicKey, plaintext);
324
+ return {
325
+ ciphertext,
326
+ contentKey: privateKey,
327
+ hash: base64url(await host.crypto.sha256(ciphertext))
328
+ };
329
+ }
330
+ async function openBlob(host, ciphertext, contentKey) {
331
+ return await host.crypto.openWith(contentKey, ciphertext);
332
+ }
333
+
334
+ // ../atlas-local/src/blob-store.ts
335
+ var BlobStore = class {
336
+ constructor(host) {
337
+ this.host = host;
338
+ }
339
+ /** Seal bytes under a fresh content key and keep the ciphertext. */
340
+ async seal(plaintext) {
341
+ const sealed = await sealBlob(this.host, plaintext);
342
+ await this.host.sql.execute(
343
+ "INSERT OR IGNORE INTO blob_store (hash, ciphertext, created_at) VALUES (?, ?, ?)",
344
+ [sealed.hash, sealed.ciphertext, this.host.clock.now()]
345
+ );
346
+ return sealed;
347
+ }
348
+ /** Record that a Change references a blob, so a send of it re-uploads the blob. */
349
+ async link(changeId, hash) {
350
+ await this.host.sql.execute("INSERT OR IGNORE INTO change_blob (change_id, hash) VALUES (?, ?)", [
351
+ changeId,
352
+ hash
353
+ ]);
354
+ }
355
+ /** The ciphertext of every blob the given Changes reference. */
356
+ async forChanges(changeIds) {
357
+ if (changeIds.length === 0) return [];
358
+ const marks = changeIds.map(() => "?").join(", ");
359
+ const rows = await this.host.sql.query(
360
+ `SELECT DISTINCT b.hash AS hash, b.ciphertext AS ciphertext
361
+ FROM change_blob c JOIN blob_store b ON b.hash = c.hash
362
+ WHERE c.change_id IN (${marks})`,
363
+ changeIds
364
+ );
365
+ return rows.map((r) => ({ hash: r.hash, ciphertext: new Uint8Array(r.ciphertext) }));
366
+ }
367
+ };
368
+
369
+ // ../atlas-local/src/storage.ts
370
+ function normalise(relPath) {
371
+ if (relPath.startsWith("/")) throw new Error(`path must be relative: ${relPath}`);
372
+ if (relPath.split("/").some((part) => part === "..")) {
373
+ throw new Error(`path may not traverse: ${relPath}`);
374
+ }
375
+ return relPath;
376
+ }
377
+ function likePrefix(prefix) {
378
+ return `${prefix.replace(/[\\%_]/g, "\\$&")}%`;
379
+ }
380
+ var SqlStorage = class {
381
+ constructor(host) {
382
+ this.host = host;
383
+ }
384
+ get sql() {
385
+ return this.host.sql;
386
+ }
387
+ async readText(relPath) {
388
+ const path = normalise(relPath);
389
+ const rows = await this.sql.query(
390
+ "SELECT text FROM vault_object WHERE path = ?",
391
+ [path]
392
+ );
393
+ const stored = rows[0]?.text;
394
+ if (stored != null) return stored;
395
+ const lines = await this.sql.query(
396
+ "SELECT line FROM vault_append WHERE path = ? ORDER BY seq",
397
+ [path]
398
+ );
399
+ if (lines.length === 0) return null;
400
+ return lines.map((row) => row.line).join("");
401
+ }
402
+ async readBytes(relPath) {
403
+ const rows = await this.sql.query(
404
+ "SELECT bytes FROM vault_object WHERE path = ?",
405
+ [normalise(relPath)]
406
+ );
407
+ const stored = rows[0]?.bytes;
408
+ if (stored == null) return null;
409
+ return stored instanceof Uint8Array ? stored : Uint8Array.from(stored);
410
+ }
411
+ async writeText(relPath, content) {
412
+ await this.put(normalise(relPath), { text: content });
413
+ }
414
+ async writeBytes(relPath, content) {
415
+ await this.put(normalise(relPath), { bytes: content });
416
+ }
417
+ async writeObject(relPath, value) {
418
+ await this.put(normalise(relPath), { body: JSON.stringify(value) });
419
+ }
420
+ async readObject(relPath) {
421
+ const rows = await this.sql.query(
422
+ "SELECT body FROM vault_object WHERE path = ?",
423
+ [normalise(relPath)]
424
+ );
425
+ const body = rows[0]?.body;
426
+ return body == null ? null : JSON.parse(body);
427
+ }
428
+ /**
429
+ * Append a line to a ledger.
430
+ *
431
+ * A row per append, never read-modify-write — the interface says why:
432
+ * ledger-style logs would race. It also makes `appendVersion` and
433
+ * `readTextTail` index reads instead of full-file parses.
434
+ */
435
+ async appendText(relPath, content) {
436
+ await this.sql.execute(
437
+ "INSERT INTO vault_append (path, line, created_at) VALUES (?, ?, ?)",
438
+ [normalise(relPath), content, this.host.clock.now()]
439
+ );
440
+ }
441
+ /** Idempotent — deleting what is absent is the outcome the caller wanted. */
442
+ async delete(relPath) {
443
+ const path = normalise(relPath);
444
+ await this.sql.batch([
445
+ { sql: "DELETE FROM vault_object WHERE path = ?", params: [path] },
446
+ { sql: "DELETE FROM vault_append WHERE path = ?", params: [path] }
447
+ ]);
448
+ }
449
+ async exists(relPath) {
450
+ const path = normalise(relPath);
451
+ const rows = await this.sql.query(
452
+ `SELECT (
453
+ EXISTS (SELECT 1 FROM vault_object WHERE path = ?) OR
454
+ EXISTS (SELECT 1 FROM vault_append WHERE path = ?)
455
+ ) AS n`,
456
+ [path, path]
457
+ );
458
+ return (rows[0]?.n ?? 0) === 1;
459
+ }
460
+ /**
461
+ * Recursive path listing under a prefix.
462
+ *
463
+ * LEAKY by the interface's own admission, and kept for the same narrow
464
+ * reasons — migration, admin, index building. Callers asking "which" or
465
+ * "how many" should use `findEntities` / `count`, which push down.
466
+ */
467
+ async listFiles(prefix) {
468
+ const rows = await this.sql.query(
469
+ `SELECT path FROM vault_object WHERE path LIKE ? ESCAPE '\\'
470
+ UNION
471
+ SELECT DISTINCT path FROM vault_append WHERE path LIKE ? ESCAPE '\\'`,
472
+ [likePrefix(prefix), likePrefix(prefix)]
473
+ );
474
+ return rows.map((row) => row.path);
475
+ }
476
+ /**
477
+ * Pushed-down entity query — kind and tags resolved inside SQLite.
478
+ *
479
+ * This is the local counterpart of the pg backend's JSONB containment. The
480
+ * fallback it replaces reads every candidate row and filters in JS, which
481
+ * on a real mailbox is the difference between a desk that opens and one
482
+ * that stalls.
483
+ */
484
+ async findEntities(query) {
485
+ const { sql, params } = this.entityWhere(query);
486
+ const rows = await this.sql.query(
487
+ `SELECT path, body FROM vault_object ${sql} ORDER BY path${query.limit ? " LIMIT ?" : ""}`,
488
+ query.limit ? [...params, query.limit] : params
489
+ );
490
+ return rows.map((row) => ({ path: row.path, body: JSON.parse(row.body) }));
491
+ }
492
+ /** The same filter, counted rather than fetched. `limit` is ignored. */
493
+ async count(query) {
494
+ const { sql, params } = this.entityWhere(query);
495
+ const rows = await this.sql.query(
496
+ `SELECT count(*) AS n FROM vault_object ${sql}`,
497
+ params
498
+ );
499
+ return rows[0]?.n ?? 0;
500
+ }
501
+ /** Last `maxLines` of a ledger, newest last. An index read, not a parse. */
502
+ async readTextTail(relPath, opts) {
503
+ const rows = await this.sql.query(
504
+ `SELECT line FROM (
505
+ SELECT line, seq FROM vault_append WHERE path = ? ORDER BY seq DESC LIMIT ?
506
+ ) ORDER BY seq`,
507
+ [normalise(relPath), opts.maxLines]
508
+ );
509
+ if (rows.length > 0) return rows.map((row) => row.line).join("");
510
+ return this.readText(relPath);
511
+ }
512
+ /**
513
+ * "Has anything been appended?" without reading it.
514
+ *
515
+ * `MAX(seq)` over an AUTOINCREMENT primary key: monotonic, and an
516
+ * index-only read. Returns null when the path has no append rows, which
517
+ * callers MUST read as "unknown → read it properly" and never as
518
+ * "unchanged" — the interface is emphatic, because a stale change feed is
519
+ * a wrong answer rather than a slow one.
520
+ */
521
+ async appendVersion(relPath) {
522
+ const rows = await this.sql.query(
523
+ "SELECT MAX(seq) AS seq FROM vault_append WHERE path = ?",
524
+ [normalise(relPath)]
525
+ );
526
+ return rows[0]?.seq ?? null;
527
+ }
528
+ entityWhere(query) {
529
+ const clauses = ["body IS NOT NULL"];
530
+ const params = [];
531
+ if (query.prefix) {
532
+ clauses.push("path LIKE ? ESCAPE '\\'");
533
+ params.push(likePrefix(query.prefix));
534
+ }
535
+ if (query.kind) {
536
+ clauses.push("json_extract(body, '$.kind') = ?");
537
+ params.push(query.kind);
538
+ }
539
+ for (const tag of query.tags ?? []) {
540
+ clauses.push(
541
+ `EXISTS (SELECT 1 FROM json_each(body, '$.metadata.tags') WHERE json_each.value = ?)`
542
+ );
543
+ params.push(tag);
544
+ }
545
+ return { sql: `WHERE ${clauses.join(" AND ")}`, params };
546
+ }
547
+ async put(path, content) {
548
+ await this.sql.execute(...unwrap(putStatement(path, content, this.host.clock.now())));
549
+ }
550
+ };
551
+ function putStatement(path, content, now) {
552
+ return {
553
+ sql: `INSERT INTO vault_object (path, body, text, bytes, updated_at)
554
+ VALUES (?, ?, ?, ?, ?)
555
+ ON CONFLICT (path) DO UPDATE SET
556
+ body = excluded.body,
557
+ text = excluded.text,
558
+ bytes = excluded.bytes,
559
+ updated_at = excluded.updated_at`,
560
+ params: [path, content.body ?? null, content.text ?? null, content.bytes ?? null, now]
561
+ };
562
+ }
563
+ function unwrap(statement) {
564
+ return [statement.sql, statement.params];
565
+ }
566
+
567
+ // ../atlas-local/src/change-log.ts
568
+ var ChangeLog = class {
569
+ constructor(host, originAtlas, signingKeyId) {
570
+ this.host = host;
571
+ this.originAtlas = originAtlas;
572
+ this.signingKeyId = signingKeyId;
573
+ }
574
+ /**
575
+ * Head of this origin's chain, or null before it has authored anything.
576
+ *
577
+ * Ordered by lamport, not by insertion: lamport is authoritative and the
578
+ * wallclock `ts` is metadata, which `atlas-changes` is explicit about.
579
+ */
580
+ async head(originAtlas = this.originAtlas) {
581
+ const rows = await this.host.sql.query(
582
+ "SELECT lamport, id FROM change_log WHERE origin_atlas = ? ORDER BY lamport DESC LIMIT 1",
583
+ [originAtlas]
584
+ );
585
+ const row = rows[0];
586
+ return row ? { lamport: row.lamport, hash: row.id } : null;
587
+ }
588
+ /**
589
+ * The Lamport clock: the highest lamport this atlas has seen, from ANY origin.
590
+ *
591
+ * It was this origin's head alone, which made last-write-wins across devices
592
+ * compare two unrelated counters: the device that had authored more Changes
593
+ * won, not the later edit (Spec 08 §3, gap 2). Taking the maximum of
594
+ * everything received restores the clock. `verifyChain` checks hashes and
595
+ * links, not gaps, so a chain that jumps from 3 to 9 stays valid.
596
+ */
597
+ async clock() {
598
+ const rows = await this.host.sql.query(
599
+ "SELECT MAX(lamport) AS m FROM change_log"
600
+ );
601
+ return rows[0]?.m ?? 0;
602
+ }
603
+ /**
604
+ * Author a Change: log it, project it, queue it — atomically.
605
+ *
606
+ * The first Change of an origin chains to `deterministicGenesisHash` rather
607
+ * than to null. `atlas-changes` explains why and it is worth restating: with
608
+ * no fixed anchor, anyone with append access can insert a forged "first"
609
+ * Change claiming no predecessor, and `verifyChain` cannot tell it from the
610
+ * real one. Anchoring closes that.
611
+ */
612
+ async append(input) {
613
+ const head = await this.head();
614
+ const sha256 = (bytes) => this.host.crypto.sha256(bytes);
615
+ const prevHash = head ? head.hash : await deterministicGenesisHash(this.originAtlas, sha256);
616
+ const unsigned = await buildChange({
617
+ changesetId: input.changesetId,
618
+ originAtlas: this.originAtlas,
619
+ originUser: input.originUser,
620
+ lamport: await this.clock() + 1,
621
+ // ISO from the injected clock, never `new Date()` — the host owns time
622
+ // so tests and replay can control it.
623
+ ts: new Date(this.host.clock.now()).toISOString(),
624
+ kind: input.kind,
625
+ target: input.target,
626
+ payload: input.payload,
627
+ prevHash
628
+ }, sha256);
629
+ const change = this.signingKeyId ? {
630
+ ...unsigned,
631
+ originSignature: base64(
632
+ await this.host.crypto.sign(this.signingKeyId, utf8Encode(unsigned.id))
633
+ )
634
+ } : unsigned;
635
+ const now = this.host.clock.now();
636
+ const statements = [insertStatement(change, input.eco, now)];
637
+ for (const projection of input.projects ?? []) {
638
+ statements.push(putStatement(projection.path, { body: JSON.stringify(projection.value) }, now));
639
+ }
640
+ for (const peer of input.notify ?? []) {
641
+ statements.push({
642
+ sql: `INSERT INTO outbox (
643
+ push_batch_id, target_atlas, eco, origin_atlas, from_lamport,
644
+ payload, state, attempts, next_attempt_at, last_error,
645
+ created_at, updated_at
646
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, ?, ?)
647
+ ON CONFLICT (push_batch_id) DO NOTHING`,
648
+ params: [
649
+ peer.pushBatchId,
650
+ peer.targetAtlas,
651
+ input.eco,
652
+ this.originAtlas,
653
+ head?.lamport ?? 0,
654
+ JSON.stringify([change]),
655
+ now,
656
+ now,
657
+ now
658
+ ]
659
+ });
660
+ }
661
+ await this.host.sql.batch(statements);
662
+ return change;
663
+ }
664
+ /**
665
+ * Record a Change authored elsewhere — the inbound half of sync.
666
+ *
667
+ * Separate from `append` because the two differ in the ways that matter:
668
+ * this one does not mint a lamport (the origin already did), does not touch
669
+ * the outbox (we do not re-broadcast a peer's history), and is idempotent
670
+ * on `id`, because at-least-once delivery through a mailbox means the same
671
+ * Change WILL arrive twice.
672
+ */
673
+ async receive(change, eco, projects = []) {
674
+ const now = this.host.clock.now();
675
+ await this.host.sql.batch([
676
+ insertStatement(change, eco, now),
677
+ ...projects.map((p) => putStatement(p.path, { body: JSON.stringify(p.value) }, now))
678
+ ]);
679
+ }
680
+ /** Everything an origin authored after `lamport`, oldest first. */
681
+ async since(lamport, originAtlas = this.originAtlas) {
682
+ const rows = await this.host.sql.query(
683
+ `SELECT body FROM change_log
684
+ WHERE origin_atlas = ? AND lamport > ?
685
+ ORDER BY lamport`,
686
+ [originAtlas, lamport]
687
+ );
688
+ return rows.map((row) => JSON.parse(row.body));
689
+ }
690
+ /** What happened to one entity, oldest first — the canvas history view. */
691
+ async forTarget(target) {
692
+ const rows = await this.host.sql.query(
693
+ "SELECT body FROM change_log WHERE target = ? ORDER BY lamport",
694
+ [target]
695
+ );
696
+ return rows.map((row) => JSON.parse(row.body));
697
+ }
698
+ /**
699
+ * What happened to one entity, oldest first, with the circle each Change was
700
+ * logged in: which circles a file has reached, and what a removal owes them.
701
+ */
702
+ async forTargetIn(target) {
703
+ const rows = await this.host.sql.query(
704
+ "SELECT eco, body FROM change_log WHERE target = ? ORDER BY lamport",
705
+ [target]
706
+ );
707
+ return rows.map((row) => ({ eco: row.eco, change: JSON.parse(row.body) }));
708
+ }
709
+ /** Every Change logged in one circle, oldest first: what a catch-up walks for files it once held. */
710
+ async forEco(eco) {
711
+ const rows = await this.host.sql.query(
712
+ "SELECT body FROM change_log WHERE eco = ? ORDER BY lamport",
713
+ [eco]
714
+ );
715
+ return rows.map((row) => JSON.parse(row.body));
716
+ }
717
+ /** Whether this log already holds Change `id` as received in circle `eco`. */
718
+ async holds(id, eco) {
719
+ const rows = await this.host.sql.query(
720
+ "SELECT count(*) AS n FROM change_log WHERE id = ? AND eco = ?",
721
+ [id, eco]
722
+ );
723
+ return (rows[0]?.n ?? 0) > 0;
724
+ }
725
+ async get(id) {
726
+ const rows = await this.host.sql.query(
727
+ "SELECT body FROM change_log WHERE id = ?",
728
+ [id]
729
+ );
730
+ const row = rows[0];
731
+ return row ? JSON.parse(row.body) : null;
732
+ }
733
+ /** How many Changes this atlas holds, by origin. */
734
+ async countByOrigin() {
735
+ const rows = await this.host.sql.query(
736
+ "SELECT origin_atlas, count(*) AS n FROM change_log GROUP BY origin_atlas"
737
+ );
738
+ return Object.fromEntries(rows.map((row) => [row.origin_atlas, row.n]));
739
+ }
740
+ /**
741
+ * Move a Change's lifecycle state — `local` → `propagating` → `confirmed`.
742
+ *
743
+ * State is per-peer metadata about how THIS atlas views the Change, not a
744
+ * property of the Change itself, which is why it is deliberately outside
745
+ * the hashed content and why changing it does not disturb the chain.
746
+ */
747
+ async setState(id, state) {
748
+ await this.host.sql.execute(
749
+ `UPDATE change_log
750
+ SET state = ?,
751
+ body = json_set(body, '$.state', ?)
752
+ WHERE id = ?`,
753
+ [state, state, id]
754
+ );
755
+ }
756
+ };
757
+ function insertStatement(change, eco, now) {
758
+ const params = [
759
+ change.id,
760
+ change.changesetId,
761
+ change.originAtlas,
762
+ change.lamport,
763
+ change.ts,
764
+ change.kind,
765
+ change.target,
766
+ eco,
767
+ change.state,
768
+ JSON.stringify(change),
769
+ now
770
+ ];
771
+ return {
772
+ // `DO NOTHING` on the content-hash primary key: appending the same Change
773
+ // twice is a no-op, not an error. Through an at-least-once mailbox that
774
+ // is the ordinary case, not an edge one.
775
+ sql: `INSERT INTO change_log (
776
+ id, changeset_id, origin_atlas, lamport, ts, kind, target,
777
+ eco, state, body, created_at
778
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
779
+ ON CONFLICT (id) DO NOTHING`,
780
+ params
781
+ };
782
+ }
783
+
784
+ // ../atlas-local/src/reconcile.ts
785
+ function planReconcile(observed, indexed) {
786
+ const byPath = new Map(indexed.map((row) => [row.path, row]));
787
+ const plan = { mint: [], remove: [], ignored: [] };
788
+ for (const file of observed) {
789
+ const known = byPath.get(file.path);
790
+ byPath.delete(file.path);
791
+ if (!known) {
792
+ plan.mint.push({ path: file.path, reason: "new", sha256: file.sha256 });
793
+ continue;
794
+ }
795
+ if (known.sha256 !== file.sha256) {
796
+ plan.mint.push({ path: file.path, reason: "edited", sha256: file.sha256 });
797
+ continue;
798
+ }
799
+ plan.ignored.push({
800
+ path: file.path,
801
+ reason: known.origin === "applied" ? "echo" : "unchanged",
802
+ sha256: file.sha256
803
+ });
804
+ }
805
+ for (const orphan of byPath.values()) {
806
+ plan.remove.push({ path: orphan.path, reason: "removed", sha256: null });
807
+ }
808
+ return plan;
809
+ }
810
+ var FileIndex = class {
811
+ constructor(sql) {
812
+ this.sql = sql;
813
+ }
814
+ /** Everything we hold under a path prefix. */
815
+ async under(prefix) {
816
+ const rows = await this.sql.query("SELECT path, sha256, origin, stamp FROM file_index WHERE path LIKE ? ESCAPE '\\'", [
817
+ `${prefix.replace(/[\\%_]/g, "\\$&")}%`
818
+ ]);
819
+ return rows;
820
+ }
821
+ async get(path) {
822
+ const rows = await this.sql.query("SELECT path, sha256, origin, stamp FROM file_index WHERE path = ?", [path]);
823
+ return rows[0] ?? null;
824
+ }
825
+ /** Record that we have taken account of these bytes. `origin` is the
826
+ * load-bearing argument — see the v4 migration. */
827
+ async record(path, sha256, origin, now, stamp = null) {
828
+ await this.sql.execute(
829
+ `INSERT INTO file_index (path, sha256, origin, updated_at, stamp) VALUES (?, ?, ?, ?, ?)
830
+ ON CONFLICT (path) DO UPDATE SET sha256 = excluded.sha256,
831
+ origin = excluded.origin,
832
+ updated_at = excluded.updated_at,
833
+ stamp = excluded.stamp`,
834
+ [path, sha256, origin, now, stamp]
835
+ );
836
+ }
837
+ /**
838
+ * Note that a file whose bytes were just hashed and found identical to the
839
+ * stored hash now looks like `stamp` on disk. Only the stamp moves: the hash
840
+ * and the origin are what they were, so an echo stays an echo.
841
+ */
842
+ async restamp(path, sha256, stamp) {
843
+ await this.sql.execute("UPDATE file_index SET stamp = ? WHERE path = ? AND sha256 = ?", [stamp, path, sha256]);
844
+ }
845
+ async forget(path) {
846
+ await this.sql.execute("DELETE FROM file_index WHERE path = ?", [path]);
847
+ }
848
+ };
849
+
850
+ // ../atlas-local/src/vault-scan.ts
851
+ async function hashOf(host, bytes) {
852
+ return toHex(await host.crypto.sha256(bytes));
853
+ }
854
+ function stampOf(stat) {
855
+ return stat.modifiedAtNs === void 0 ? null : `${stat.size}:${stat.modifiedAtNs}`;
856
+ }
857
+ async function scanAndApply(deps, options) {
858
+ const { host, index } = deps;
859
+ const own = options.match ?? (() => true);
860
+ const trust = options.falsify === "stamp-ignored" ? false : options.falsify === "full-trusts-stamp" ? true : options.trustStamp ?? true;
861
+ const files = (await host.files.list(options.prefix, { recursive: true })).filter((f) => own(f.path));
862
+ const rows = (await index.under(options.prefix)).filter((row) => own(row.path));
863
+ const known = new Map(rows.map((row) => [row.path, row]));
864
+ const observed = [];
865
+ const bytesByPath = /* @__PURE__ */ new Map();
866
+ const stampByPath = /* @__PURE__ */ new Map();
867
+ const failed = [];
868
+ let read = 0;
869
+ for (const stat of files) {
870
+ const stamp = stampOf(stat);
871
+ const row = known.get(stat.path);
872
+ if (trust && stamp !== null && row?.stamp === stamp) {
873
+ observed.push({ path: stat.path, sha256: row.sha256 });
874
+ continue;
875
+ }
876
+ try {
877
+ const bytes = await host.files.read(stat.path);
878
+ read += 1;
879
+ const sha256 = await hashOf(host, bytes);
880
+ observed.push({ path: stat.path, sha256 });
881
+ stampByPath.set(stat.path, stamp);
882
+ if (row?.sha256 === sha256) {
883
+ if (row.stamp !== stamp && options.falsify !== "stamp-not-kept") await index.restamp(stat.path, sha256, stamp);
884
+ } else {
885
+ bytesByPath.set(stat.path, bytes);
886
+ }
887
+ } catch (error) {
888
+ failed.push({ path: stat.path, reason: message(error) });
889
+ }
890
+ }
891
+ const indexed = rows.filter((row) => !failed.some((f) => f.path === row.path));
892
+ const plan = planReconcile(observed, indexed);
893
+ let minted = 0;
894
+ for (const item of plan.mint) {
895
+ const bytes = bytesByPath.get(item.path);
896
+ if (!bytes || item.sha256 === null) continue;
897
+ try {
898
+ await options.mint(item, bytes);
899
+ const stamp = options.falsify === "stamp-not-kept" ? null : stampByPath.get(item.path) ?? null;
900
+ await index.record(item.path, item.sha256, "observed", host.clock.now(), stamp);
901
+ minted += 1;
902
+ } catch (error) {
903
+ failed.push({ path: item.path, reason: message(error) });
904
+ }
905
+ }
906
+ let removed = 0;
907
+ for (const item of plan.remove) {
908
+ try {
909
+ await options.mintRemoval(item);
910
+ await index.forget(item.path);
911
+ removed += 1;
912
+ } catch (error) {
913
+ failed.push({ path: item.path, reason: message(error) });
914
+ }
915
+ }
916
+ return {
917
+ scanned: files.length,
918
+ minted,
919
+ removed,
920
+ ignored: plan.ignored.length,
921
+ read,
922
+ echoes: plan.ignored.filter((item) => item.reason === "echo").length,
923
+ failed
924
+ };
925
+ }
926
+ async function applyInbound(deps, path, bytes) {
927
+ const { host, index } = deps;
928
+ await host.files.write(path, bytes);
929
+ await index.record(path, await hashOf(host, bytes), "applied", host.clock.now());
930
+ }
931
+ function message(error) {
932
+ return error instanceof Error ? error.message : String(error);
933
+ }
934
+
935
+ // ../atlas-local/src/fold.ts
936
+ var FILE_SET = "file.set";
937
+ var FILE_REMOVE = "file.remove";
938
+ function conflictPath(path, device, ts) {
939
+ const slash = path.lastIndexOf("/");
940
+ const dir = slash < 0 ? "" : path.slice(0, slash + 1);
941
+ const file = slash < 0 ? path : path.slice(slash + 1);
942
+ const dot = file.lastIndexOf(".");
943
+ const [name, ext] = dot > 0 ? [file.slice(0, dot), file.slice(dot)] : [file, ""];
944
+ return `${dir}${name} (conflict, ${device}, ${ts.slice(0, 10)})${ext}`;
945
+ }
946
+ function before(a, b) {
947
+ return a.lamport !== b.lamport ? a.lamport < b.lamport : a.origin < b.origin;
948
+ }
949
+ function supersedes(payload, localSha) {
950
+ return localSha === payload.baseSha256 || Array.isArray(payload.ancestors) && payload.ancestors.includes(localSha);
951
+ }
952
+ function conflictOriginal(path) {
953
+ const m = /^(.*) \(conflict, [^,/()]+, \d{4}-\d{2}-\d{2}\)(\.[^/.]*)?$/.exec(path);
954
+ if (!m) return null;
955
+ return `${m[1]}${m[2] ?? ""}`;
956
+ }
957
+ var mergeMarkdown = (input) => {
958
+ const { local, incoming, base, union } = input;
959
+ const { payload } = incoming;
960
+ if (local === null || local.sha256 === payload.baseSha256) {
961
+ return { action: "write", how: "fast-forward", text: payload.text };
962
+ }
963
+ if (local.sha256 === payload.sha256) return { action: "none", why: "already there" };
964
+ if (supersedes(payload, local.sha256)) return { action: "write", how: "fast-forward", text: payload.text };
965
+ if (union && base !== null && local.lamport !== null && local.origin !== null && local.text.startsWith(base) && payload.text.startsWith(base)) {
966
+ const mine = { lamport: local.lamport, origin: local.origin, added: local.text.slice(base.length) };
967
+ const theirs = { lamport: incoming.lamport, origin: incoming.origin, added: payload.text.slice(base.length) };
968
+ const [first, second] = before(mine, theirs) ? [mine, theirs] : [theirs, mine];
969
+ const joint = first.added === "" || second.added === "" || first.added.endsWith("\n") ? "" : "\n";
970
+ return { action: "write", how: "union", text: `${base}${first.added}${joint}${second.added}` };
971
+ }
972
+ return {
973
+ action: "conflict",
974
+ conflictPath: conflictPath(input.path, incoming.origin, incoming.ts),
975
+ text: payload.text
976
+ };
977
+ };
978
+ var InboundFold = class {
979
+ constructor(opts) {
980
+ this.opts = opts;
981
+ this.merge = opts.merge ?? mergeMarkdown;
982
+ }
983
+ merge;
984
+ /**
985
+ * Apply one received Change and, only if it was accepted, log it.
986
+ *
987
+ * Applying before logging: a crash between the two redelivers the Change,
988
+ * whose sha then matches what is on disk, and the second pass is a no-op.
989
+ *
990
+ * A Change this log already holds, in this circle, is a no-op before
991
+ * anything is read (HQ-1219). Delivery is at least once: a sender re-sends
992
+ * every unacknowledged batch on its next tick, so an older version can arrive
993
+ * again after a newer one has been applied. Folded a second time it no longer
994
+ * matches the local base, and it became a conflict file beside a file that
995
+ * was already correct: seven of them in Hestia's shadow on 2026-09-22, each
996
+ * an earlier version of a file edited several times between two drains.
997
+ */
998
+ async apply(change, eco) {
999
+ const { host, log } = this.opts;
1000
+ if (this.opts.falsify !== "redelivery-refolded" && await log.holds(change.id, eco)) {
1001
+ return { status: "applied", changeId: change.id, path: change.target, action: "already there" };
1002
+ }
1003
+ if (change.kind !== FILE_SET && change.kind !== FILE_REMOVE) {
1004
+ await log.receive(change, eco);
1005
+ return { status: "applied", changeId: change.id, path: change.target, action: "logged" };
1006
+ }
1007
+ let payload;
1008
+ try {
1009
+ payload = await this.opts.open(change, eco);
1010
+ } catch (error) {
1011
+ return {
1012
+ status: "refused",
1013
+ changeId: change.id,
1014
+ reason: `cannot open the payload for circle ${eco}: ${error instanceof Error ? error.message : String(error)}`
1015
+ };
1016
+ }
1017
+ if (payload.path !== change.target) {
1018
+ return { status: "refused", changeId: change.id, reason: `payload path ${payload.path} is not the Change's target ${change.target}` };
1019
+ }
1020
+ if (payload.path.startsWith("/") || payload.path.split("/").includes("..")) {
1021
+ return { status: "refused", changeId: change.id, reason: `payload path ${payload.path} is not relative to the record root` };
1022
+ }
1023
+ const at = this.opts.localPath?.(eco, payload.path) ?? payload.path;
1024
+ const local = await this.readLocal(at);
1025
+ let result;
1026
+ if (change.kind === FILE_REMOVE) {
1027
+ if (local === null) {
1028
+ result = { status: "applied", changeId: change.id, path: payload.path, action: "absent" };
1029
+ } else if (supersedes(payload, local.sha256)) {
1030
+ const reissue = this.reissues(at, eco, change);
1031
+ await host.files.remove(at);
1032
+ if (!reissue) await this.opts.index.forget(at);
1033
+ await this.opts.onRemove?.(at);
1034
+ result = { status: "applied", changeId: change.id, path: payload.path, action: "removed", ...reissue ? { reissued: true } : {} };
1035
+ } else {
1036
+ result = { status: "applied", changeId: change.id, path: payload.path, action: "kept" };
1037
+ }
1038
+ } else if (payload.blob) {
1039
+ const outcome = await this.applyBlob(change, { ...payload, blob: payload.blob }, local, at, eco);
1040
+ if (outcome.status !== "applied") return outcome;
1041
+ result = outcome;
1042
+ } else {
1043
+ if (typeof payload.text !== "string" || typeof payload.sha256 !== "string") {
1044
+ return { status: "refused", changeId: change.id, reason: `file.set for ${payload.path} carries no text` };
1045
+ }
1046
+ if (await hashOf(host, utf8Encode(payload.text)) !== payload.sha256) {
1047
+ return { status: "refused", changeId: change.id, reason: `file.set for ${payload.path} does not hash to its sha256` };
1048
+ }
1049
+ const history = await this.history(payload.path, eco);
1050
+ const localSide = local && {
1051
+ ...local,
1052
+ ...history.bySha.get(local.sha256) ?? { lamport: null, origin: null }
1053
+ };
1054
+ const decision = this.merge({
1055
+ path: payload.path,
1056
+ local: localSide,
1057
+ incoming: {
1058
+ payload: { ...payload, text: payload.text, sha256: payload.sha256 },
1059
+ lamport: change.lamport,
1060
+ origin: change.originAtlas,
1061
+ ts: change.ts
1062
+ },
1063
+ base: await this.baseText(payload.baseSha256, local?.text ?? null, history.textBySha),
1064
+ union: (this.opts.unionPath?.(at) ?? false) || payload.union === true
1065
+ });
1066
+ const twin = decision.action === "write" && local === null ? await this.ownTextUnder(at, eco, payload.sha256) : false;
1067
+ if (twin) {
1068
+ result = { status: "applied", changeId: change.id, path: payload.path, action: "already there" };
1069
+ } else if (decision.action === "write") {
1070
+ const reissue = this.reissues(at, eco, change);
1071
+ await this.write(at, decision.text, eco, reissue);
1072
+ result = { status: "applied", changeId: change.id, path: payload.path, action: decision.how, ...reissue ? { reissued: true } : {} };
1073
+ } else if (decision.action === "none") {
1074
+ result = { status: "applied", changeId: change.id, path: payload.path, action: "already there" };
1075
+ } else {
1076
+ const beside = this.opts.localPath?.(eco, decision.conflictPath) ?? decision.conflictPath;
1077
+ await this.write(beside, decision.text, eco, this.reissues(at, eco, change));
1078
+ result = {
1079
+ status: "applied",
1080
+ changeId: change.id,
1081
+ path: payload.path,
1082
+ action: "conflict",
1083
+ conflictPath: decision.conflictPath
1084
+ };
1085
+ }
1086
+ }
1087
+ await log.receive(change, eco);
1088
+ return result;
1089
+ }
1090
+ /**
1091
+ * A file.set whose bytes ride the blob lane. Fetched, opened with the key in
1092
+ * the payload, checked against the payload's sha, and only then merged:
1093
+ * fast-forward when the local file is absent or is the base, nothing when it
1094
+ * is already there, and otherwise a conflict file beside the local one. Bytes
1095
+ * that are not text do not union.
1096
+ */
1097
+ async applyBlob(change, payload, local, at, eco) {
1098
+ const { host } = this.opts;
1099
+ if (typeof payload.sha256 !== "string") {
1100
+ return { status: "refused", changeId: change.id, reason: `file.set for ${payload.path} carries no sha256` };
1101
+ }
1102
+ if (local !== null && local.sha256 === payload.sha256) {
1103
+ return { status: "applied", changeId: change.id, path: payload.path, action: "already there" };
1104
+ }
1105
+ if (!this.opts.fetchBlob) {
1106
+ return { status: "refused", changeId: change.id, reason: `file.set for ${payload.path} rides the blob lane and this device fetches no blobs` };
1107
+ }
1108
+ const ciphertext = await this.opts.fetchBlob(payload.blob.hash);
1109
+ if (ciphertext === null) {
1110
+ return { status: "deferred", changeId: change.id, path: payload.path, reason: `blob ${payload.blob.hash} is not on the relay` };
1111
+ }
1112
+ if (await hashOfBase64url(host, ciphertext) !== payload.blob.hash) {
1113
+ return { status: "refused", changeId: change.id, reason: `blob for ${payload.path} does not hash to its name` };
1114
+ }
1115
+ let bytes;
1116
+ try {
1117
+ bytes = await openBlob(host, ciphertext, fromBase64(payload.blob.contentKey));
1118
+ } catch (error) {
1119
+ return { status: "refused", changeId: change.id, reason: `blob for ${payload.path} does not open: ${error instanceof Error ? error.message : String(error)}` };
1120
+ }
1121
+ if (await hashOf(host, bytes) !== payload.sha256) {
1122
+ return { status: "refused", changeId: change.id, reason: `blob for ${payload.path} does not hash to its sha256` };
1123
+ }
1124
+ if (local === null || supersedes(payload, local.sha256)) {
1125
+ const reissue = this.reissues(at, eco, change);
1126
+ if (reissue) await host.files.write(at, bytes);
1127
+ else await applyInbound({ host, index: this.opts.index }, at, bytes);
1128
+ return { status: "applied", changeId: change.id, path: payload.path, action: "fast-forward", ...reissue ? { reissued: true } : {} };
1129
+ }
1130
+ const beside = conflictPath(payload.path, change.originAtlas, change.ts);
1131
+ const besideAt = this.opts.localPath?.(eco, beside) ?? beside;
1132
+ if (this.reissues(at, eco, change)) await host.files.write(besideAt, bytes);
1133
+ else await applyInbound({ host, index: this.opts.index }, besideAt, bytes);
1134
+ return { status: "applied", changeId: change.id, path: payload.path, action: "conflict", conflictPath: beside };
1135
+ }
1136
+ /**
1137
+ * Whether a write into `at` from circle `eco` is the home device folding
1138
+ * another circle's copy of its own file: true when this device knows the
1139
+ * path's home and it is not `eco`, or the Change comes from another holder
1140
+ * (`foreign`). Such a write is left unrecorded, so the next scan mints it
1141
+ * again into the home and every release (HQ-835).
1142
+ */
1143
+ reissues(at, eco, change) {
1144
+ if (!this.opts.homeOf) return false;
1145
+ const home = this.opts.homeOf(at);
1146
+ if (home === null) return false;
1147
+ return home !== eco || (this.opts.foreign?.(change.originAtlas) ?? false);
1148
+ }
1149
+ /** Whether `at` is a conflict file's name and its original already holds `sha` here. */
1150
+ async ownTextUnder(at, eco, sha) {
1151
+ void eco;
1152
+ const original = conflictOriginal(at);
1153
+ if (original === null) return false;
1154
+ const held = await this.readLocal(original);
1155
+ return held !== null && held.sha256 === sha;
1156
+ }
1157
+ /**
1158
+ * The text an edit was based on. First from `change_log`, where every version
1159
+ * this device minted or accepted is held; then, for a version that never
1160
+ * passed through a Change (a file both devices already held when they were
1161
+ * paired), from the local file itself: the prefix of it, ending at a line
1162
+ * boundary, whose sha is the base. A sha match is the proof, so a prefix is
1163
+ * never guessed.
1164
+ */
1165
+ async baseText(baseSha256, localText, known) {
1166
+ if (baseSha256 === null) return null;
1167
+ const held = known.get(baseSha256);
1168
+ if (held !== void 0) return held;
1169
+ if (localText === null) return null;
1170
+ for (let end = localText.length; end >= 0; end -= 1) {
1171
+ if (end !== 0 && end !== localText.length && localText[end - 1] !== "\n") continue;
1172
+ const prefix = localText.slice(0, end);
1173
+ if (await hashOf(this.opts.host, utf8Encode(prefix)) === baseSha256) return prefix;
1174
+ }
1175
+ return null;
1176
+ }
1177
+ /**
1178
+ * Write through `applyInbound`, recorded as applied, so the scanner sees an
1179
+ * echo; or, for a write the scanner must mint again, through the host alone,
1180
+ * leaving the index at the previous sha so the scan reports it edited.
1181
+ */
1182
+ async write(path, text, eco, reissue) {
1183
+ if (reissue) await this.opts.host.files.write(path, utf8Encode(text));
1184
+ else await applyInbound({ host: this.opts.host, index: this.opts.index }, path, utf8Encode(text));
1185
+ await this.opts.onWrite?.(path, text, eco);
1186
+ }
1187
+ async readLocal(path) {
1188
+ const { host } = this.opts;
1189
+ if (await host.files.stat(path) === null) return null;
1190
+ const bytes = await host.files.read(path);
1191
+ return { sha256: await hashOf(host, bytes), text: utf8Decode(bytes) };
1192
+ }
1193
+ /**
1194
+ * Every version of a path this device can open, by sha: its text, and the
1195
+ * newest Change that produced it. The union needs both — the base text, and
1196
+ * the local side's lamport — and both come from `change_log`, which already
1197
+ * holds every Change this device authored or accepted.
1198
+ */
1199
+ async history(path, eco) {
1200
+ const textBySha = /* @__PURE__ */ new Map();
1201
+ const bySha = /* @__PURE__ */ new Map();
1202
+ for (const past of await this.opts.log.forTarget(path)) {
1203
+ if (past.kind !== FILE_SET) continue;
1204
+ try {
1205
+ const opened = await this.opts.open(past, eco);
1206
+ if (typeof opened.sha256 !== "string" || typeof opened.text !== "string") continue;
1207
+ textBySha.set(opened.sha256, opened.text);
1208
+ const seen = bySha.get(opened.sha256);
1209
+ if (!seen || before(seen, { lamport: past.lamport, origin: past.originAtlas })) {
1210
+ bySha.set(opened.sha256, { lamport: past.lamport, origin: past.originAtlas });
1211
+ }
1212
+ } catch {
1213
+ }
1214
+ }
1215
+ return { textBySha, bySha };
1216
+ }
1217
+ };
1218
+ async function hashOfBase64url(host, bytes) {
1219
+ return base64url(await host.crypto.sha256(bytes));
1220
+ }
1221
+
1222
+ // ../atlas-local/src/invites.ts
1223
+ var PENDING_INVITES_PREFIX = "atlas.eco/entities/pending-invites/";
1224
+ var PendingInvites = class {
1225
+ constructor(storage) {
1226
+ this.storage = storage;
1227
+ }
1228
+ path(inviteId) {
1229
+ return `${PENDING_INVITES_PREFIX}${inviteId}.json`;
1230
+ }
1231
+ async put(invite) {
1232
+ await this.storage.writeObject(this.path(invite.inviteId), invite);
1233
+ }
1234
+ async get(inviteId) {
1235
+ return await this.storage.readObject(this.path(inviteId));
1236
+ }
1237
+ async list() {
1238
+ const paths = await this.storage.listFiles(PENDING_INVITES_PREFIX);
1239
+ const invites = [];
1240
+ for (const path of paths) {
1241
+ const invite = await this.storage.readObject(path);
1242
+ if (invite) invites.push(invite);
1243
+ }
1244
+ return invites.sort((a, b) => a.mintedAt - b.mintedAt);
1245
+ }
1246
+ /**
1247
+ * Stop waiting on one — redeemed, expired, or withdrawn.
1248
+ *
1249
+ * Deliberately one verb for all three. A caller that had to say *why* would
1250
+ * be asserting something it may not know: "expired" is a guess about a clock
1251
+ * we do not own, and the relay is the authority on whether it was redeemed.
1252
+ */
1253
+ async forget(inviteId) {
1254
+ await this.storage.delete(this.path(inviteId));
1255
+ }
1256
+ };
1257
+
1258
+ // ../atlas-local/src/relay-client.ts
1259
+ var RelayError = class extends Error {
1260
+ constructor(status, reason, message2) {
1261
+ super(message2);
1262
+ this.status = status;
1263
+ this.reason = reason;
1264
+ this.name = "RelayError";
1265
+ }
1266
+ /**
1267
+ * Retrying will not help.
1268
+ *
1269
+ * 4xx only — a 5xx or a dropped connection is a relay having a bad day, which
1270
+ * is what the outbox's backoff exists for; treating that as permanent would
1271
+ * discard mail because a machine rebooted.
1272
+ *
1273
+ * **429 is the exception, and it is a 4xx.** A rate limit says "not now",
1274
+ * not "not ever", and marking it permanent would have the outbox abandon a
1275
+ * batch the moment a relay got busy — exactly when giving up is worst. The
1276
+ * storage ceiling (507) is temporary for the same reason and already falls
1277
+ * on the retry side of the line, because envelopes expire.
1278
+ */
1279
+ get permanent() {
1280
+ if (this.status === 429) return false;
1281
+ return this.status >= 400 && this.status < 500;
1282
+ }
1283
+ };
1284
+ var RelayClient = class {
1285
+ constructor(host, relayUrl) {
1286
+ this.host = host;
1287
+ this.relayUrl = relayUrl;
1288
+ }
1289
+ info() {
1290
+ return this.json("GET", "/relay/info");
1291
+ }
1292
+ mint(request) {
1293
+ return this.json("POST", "/invite/mint", request);
1294
+ }
1295
+ redeem(request) {
1296
+ return this.json("POST", "/invite/redeem", request);
1297
+ }
1298
+ confirm(request) {
1299
+ return this.json("POST", "/invite/confirm", request);
1300
+ }
1301
+ /**
1302
+ * Put a blob on the relay's second lane. Idempotent — the name is the hash,
1303
+ * so a re-upload is free and extends its life rather than duplicating it.
1304
+ */
1305
+ async putBlob(ciphertext) {
1306
+ const response = await this.send("POST", "/blob", { bytes: ciphertext });
1307
+ return this.decode(response);
1308
+ }
1309
+ /** Fetch a blob's ciphertext. Opening it needs the content key, which never
1310
+ * travels this way. */
1311
+ async getBlob(hash) {
1312
+ const response = await this.send("GET", `/blob/${encodeURIComponent(hash)}`, {});
1313
+ if (response.status !== 200) {
1314
+ throw new Error(`relay GET /blob \u2192 ${response.status}`);
1315
+ }
1316
+ return response.body ?? new Uint8Array();
1317
+ }
1318
+ /**
1319
+ * A blob's ciphertext, or null when the relay has none by that name: expired,
1320
+ * or not uploaded yet. That is a state the fold waits on, not an error;
1321
+ * anything other than 200 or 404 still throws.
1322
+ */
1323
+ async findBlob(hash) {
1324
+ try {
1325
+ return await this.getBlob(hash);
1326
+ } catch (error) {
1327
+ if (error instanceof RelayError && error.status === 404) return null;
1328
+ throw error;
1329
+ }
1330
+ }
1331
+ inviteState(inviteId, request) {
1332
+ return this.json("POST", `/invite/${encodeURIComponent(inviteId)}/state`, request);
1333
+ }
1334
+ // ── the drop lane ──
1335
+ //
1336
+ // Claiming is two calls because the address derives from PUBLIC material.
1337
+ // A signature alone would let anyone holding the contact card take the box
1338
+ // and drain it — and draining deletes. So the first call proves the identity
1339
+ // key vouches for the sealing key, and the second proves possession of that
1340
+ // sealing key by opening what was sealed to it.
1341
+ dropClaim(request) {
1342
+ return this.json("POST", "/drop/claim", request);
1343
+ }
1344
+ dropClaimConfirm(claimId, nonce) {
1345
+ return this.json("POST", `/drop/claim/${encodeURIComponent(claimId)}/confirm`, { nonce });
1346
+ }
1347
+ /** Collect and DELETE. The relay is a spool; the atlas that drains holds the
1348
+ * record from then on. */
1349
+ dropDrain(address, request) {
1350
+ return this.json("POST", `/drop/${address}/drain`, request);
1351
+ }
1352
+ async register(request) {
1353
+ await this.send("POST", "/mailbox/register", { json: request });
1354
+ }
1355
+ /**
1356
+ * Leave a sealed envelope.
1357
+ *
1358
+ * `ttlMs` is an intent the relay clamps to a ceiling it publishes, and asking
1359
+ * for more than the ceiling is an error rather than a silent trim — so a
1360
+ * sender never discovers the limit by losing mail to it.
1361
+ */
1362
+ async post(address, envelope, ttlMs) {
1363
+ const query = ttlMs === void 0 ? "" : `?ttlMs=${ttlMs}`;
1364
+ const response = await this.send("POST", `/mailbox/${address}${query}`, {
1365
+ bytes: envelope
1366
+ });
1367
+ return this.decode(response);
1368
+ }
1369
+ async poll(address, pollCredential, limit) {
1370
+ const query = limit === void 0 ? "" : `?limit=${limit}`;
1371
+ const response = await this.send("GET", `/mailbox/${address}${query}`, {
1372
+ bearer: pollCredential
1373
+ });
1374
+ const raw = this.decode(response);
1375
+ const envelopes = (raw.envelopes ?? []).map((e) => ({
1376
+ ...e,
1377
+ id: e.id ?? String(e.seq),
1378
+ envelope: e.envelope ?? e.body ?? ""
1379
+ }));
1380
+ const more = raw.more ?? (typeof raw.depth === "number" && raw.depth > envelopes.length);
1381
+ return { envelopes, more };
1382
+ }
1383
+ /** Acknowledge — the relay may forget this envelope. */
1384
+ async ack(address, id, pollCredential) {
1385
+ await this.send("DELETE", `/mailbox/${address}/${encodeURIComponent(id)}`, {
1386
+ bearer: pollCredential
1387
+ });
1388
+ }
1389
+ async json(method, path, body) {
1390
+ const response = await this.send(method, path, body === void 0 ? {} : { json: body });
1391
+ return this.decode(response);
1392
+ }
1393
+ async send(method, path, init) {
1394
+ const headers = {};
1395
+ let body;
1396
+ if (init.json !== void 0) {
1397
+ headers["content-type"] = "application/json";
1398
+ body = utf8Encode(JSON.stringify(init.json));
1399
+ } else if (init.bytes !== void 0) {
1400
+ headers["content-type"] = "application/octet-stream";
1401
+ body = init.bytes;
1402
+ }
1403
+ if (init.bearer !== void 0) headers.authorization = `Bearer ${init.bearer}`;
1404
+ const response = await this.host.http.request({
1405
+ method,
1406
+ url: `${this.relayUrl}${path}`,
1407
+ headers,
1408
+ body
1409
+ });
1410
+ if (response.status >= 400) {
1411
+ const parsed = safeJson(response.body);
1412
+ throw new RelayError(
1413
+ response.status,
1414
+ parsed?.error ?? null,
1415
+ `relay ${method} ${path} \u2192 ${response.status}${parsed?.error ? ` (${parsed.error})` : ""}`
1416
+ );
1417
+ }
1418
+ return response;
1419
+ }
1420
+ decode(response) {
1421
+ const value = safeJson(response.body);
1422
+ if (value === null) throw new RelayError(response.status, null, "relay answered unparseable JSON");
1423
+ return value;
1424
+ }
1425
+ };
1426
+ function safeJson(body) {
1427
+ try {
1428
+ return JSON.parse(utf8Decode(body));
1429
+ } catch {
1430
+ return null;
1431
+ }
1432
+ }
1433
+
1434
+ // ../atlas-local/src/mailbox.ts
1435
+ var MAILBOX_ADDRESS_DOMAIN = "xania/mailbox/v1";
1436
+ var MAILBOX_POLL_DOMAIN = "xania/mailbox-poll/v1";
1437
+ var FIELD_SEP = "|";
1438
+ var NUL = Uint8Array.of(0);
1439
+ function mailboxInfo(domain, inviteId, recipientAtlasId) {
1440
+ return concatBytes([
1441
+ utf8Encode(domain),
1442
+ NUL,
1443
+ utf8Encode(inviteId),
1444
+ NUL,
1445
+ utf8Encode(`to:${recipientAtlasId}`)
1446
+ ]);
1447
+ }
1448
+ async function deriveMailboxes(host, input) {
1449
+ const handle = async (recipientAtlasId) => {
1450
+ const [address, pollCredential] = await Promise.all([
1451
+ host.crypto.deriveShared(
1452
+ input.keyId,
1453
+ input.peerSealingPublicKey,
1454
+ mailboxInfo(MAILBOX_ADDRESS_DOMAIN, input.inviteId, recipientAtlasId)
1455
+ ),
1456
+ host.crypto.deriveShared(
1457
+ input.keyId,
1458
+ input.peerSealingPublicKey,
1459
+ mailboxInfo(MAILBOX_POLL_DOMAIN, input.inviteId, recipientAtlasId)
1460
+ )
1461
+ ]);
1462
+ return { address: base64url(address), pollCredential: base64url(pollCredential) };
1463
+ };
1464
+ const [inbound, outbound] = await Promise.all([
1465
+ handle(input.ourAtlasId),
1466
+ handle(input.peerAtlasId)
1467
+ ]);
1468
+ return { inbound, outbound };
1469
+ }
1470
+ function canonicalInviteString(p) {
1471
+ return join([p.inviteId, p.atlasId, p.identityPublicKey, p.sealingPublicKey]);
1472
+ }
1473
+ function canonicalMintString(p) {
1474
+ return join(["xania/invite-mint/v1", p.nonce, p.atlasId, p.identityPublicKey, p.sealingPublicKey]);
1475
+ }
1476
+ function canonicalInviteStateString(p) {
1477
+ return join(["xania/invite-state/v1", p.inviteId, p.atlasId, String(p.issuedAt)]);
1478
+ }
1479
+ function canonicalSealRotationString(p) {
1480
+ return join([
1481
+ "xania/seal-rotate/v1",
1482
+ p.atlasId,
1483
+ String(p.version),
1484
+ p.sealingPublicKey
1485
+ ]);
1486
+ }
1487
+ function canonicalCircleKeyString(p) {
1488
+ return join(["evda/circle-key/v1", p.from, p.to, p.circleId, String(p.epoch), p.sealedKeyHash]);
1489
+ }
1490
+ function canonicalCircleDeclarationString(p) {
1491
+ return join(["evda/circle-declaration/v1", p.from, p.to, p.circleId, p.declarationHash]);
1492
+ }
1493
+ function canonicalCircleRevocationString(p) {
1494
+ return join(["evda/circle-revoked/v1", p.from, p.to, p.circleId, p.at]);
1495
+ }
1496
+ function canonicalHolderDeviceString(p) {
1497
+ return join(["evda/holder-device/v1", p.from, p.to, p.noticeHash]);
1498
+ }
1499
+ function canonicalJson(value) {
1500
+ if (value === void 0) return "null";
1501
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
1502
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
1503
+ const record = value;
1504
+ const keys = Object.keys(record).filter((key) => record[key] !== void 0).sort();
1505
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
1506
+ }
1507
+ function join(fields) {
1508
+ for (const field of fields) {
1509
+ if (typeof field !== "string" || field.length === 0) {
1510
+ throw new Error("canonical: every field must be a non-empty string");
1511
+ }
1512
+ if (field.includes(FIELD_SEP)) {
1513
+ throw new Error('canonical fields must not contain "|"');
1514
+ }
1515
+ }
1516
+ return fields.join(FIELD_SEP);
1517
+ }
1518
+
1519
+ // ../atlas-local/src/outbox.ts
1520
+ function backoffMs(attempts) {
1521
+ const HOUR = 36e5;
1522
+ return Math.min(3e4 * 2 ** Math.max(0, attempts - 1), HOUR);
1523
+ }
1524
+ var Outbox = class {
1525
+ constructor(host) {
1526
+ this.host = host;
1527
+ }
1528
+ /**
1529
+ * Record a batch as owed. Idempotent on `pushBatchId`.
1530
+ *
1531
+ * The caller supplies the id rather than receiving one, because the id has
1532
+ * to be stable across every retry and across restarts — it is the
1533
+ * receiver's idempotency key, and minting a fresh one per attempt is the
1534
+ * exact bug this class exists to not have.
1535
+ */
1536
+ async enqueue(input) {
1537
+ const now = this.host.clock.now();
1538
+ await this.host.sql.execute(
1539
+ `INSERT INTO outbox (
1540
+ push_batch_id, target_atlas, eco, origin_atlas, from_lamport,
1541
+ payload, state, attempts, next_attempt_at, last_error,
1542
+ created_at, updated_at
1543
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, ?, ?)
1544
+ ON CONFLICT (push_batch_id) DO NOTHING`,
1545
+ [
1546
+ input.pushBatchId,
1547
+ input.targetAtlas,
1548
+ input.eco,
1549
+ input.originAtlas,
1550
+ input.fromLamport,
1551
+ JSON.stringify(input.events),
1552
+ now,
1553
+ now,
1554
+ now
1555
+ ]
1556
+ );
1557
+ }
1558
+ /**
1559
+ * Batches that should be attempted now, oldest first.
1560
+ *
1561
+ * `relayed` rows are included: the mailbox took the envelope but the peer
1562
+ * has not acknowledged, and after the backoff elapses we ask again. Only
1563
+ * `acknowledged` and `failed` are terminal.
1564
+ */
1565
+ async due(limit = 20) {
1566
+ const rows = await this.host.sql.query(
1567
+ `SELECT * FROM outbox
1568
+ WHERE state IN ('pending','relayed') AND next_attempt_at <= ?
1569
+ ORDER BY created_at, push_batch_id
1570
+ LIMIT ?`,
1571
+ [this.host.clock.now(), limit]
1572
+ );
1573
+ return rows.map(hydrate);
1574
+ }
1575
+ /** The mailbox accepted the envelope. Not delivery — see `OutboxState`. */
1576
+ async onRelayed(pushBatchId) {
1577
+ const now = this.host.clock.now();
1578
+ const attempts = await this.attemptsOf(pushBatchId);
1579
+ await this.host.sql.execute(
1580
+ `UPDATE outbox
1581
+ SET state = 'relayed', attempts = ?, next_attempt_at = ?,
1582
+ last_error = NULL, updated_at = ?
1583
+ WHERE push_batch_id = ? AND state IN ('pending','relayed')`,
1584
+ [attempts + 1, now + backoffMs(attempts + 1), now, pushBatchId]
1585
+ );
1586
+ }
1587
+ /**
1588
+ * The peer applied the batch. The only success, and terminal.
1589
+ *
1590
+ * `newServerCursor` is the peer's head lamport after applying; the caller
1591
+ * advances its push cursor with it. Kept in the signature to match
1592
+ * `OutboundQueue.onAcknowledged` so the server can adopt this class.
1593
+ */
1594
+ async onAcknowledged(pushBatchId, _newServerCursor, targetAtlas) {
1595
+ const now = this.host.clock.now();
1596
+ const scoped = targetAtlas === void 0 ? "" : " AND target_atlas = ?";
1597
+ await this.host.sql.execute(
1598
+ `UPDATE outbox
1599
+ SET state = 'acknowledged', last_error = NULL, updated_at = ?
1600
+ WHERE push_batch_id = ?${scoped}`,
1601
+ targetAtlas === void 0 ? [now, pushBatchId] : [now, pushBatchId, targetAtlas]
1602
+ );
1603
+ }
1604
+ /**
1605
+ * Delivery failed.
1606
+ *
1607
+ * `permanent` is honoured, which is the whole point — `chain-violation` and
1608
+ * `origin-mismatch` mean the batch is malformed and will fail identically
1609
+ * forever, so retrying is just noise that hides the real problem.
1610
+ */
1611
+ async onFailed(pushBatchId, error, permanent) {
1612
+ const now = this.host.clock.now();
1613
+ const attempts = await this.attemptsOf(pushBatchId) + 1;
1614
+ if (permanent) {
1615
+ await this.host.sql.execute(
1616
+ `UPDATE outbox
1617
+ SET state = 'failed', attempts = ?, last_error = ?, updated_at = ?
1618
+ WHERE push_batch_id = ?`,
1619
+ [attempts, error, now, pushBatchId]
1620
+ );
1621
+ return;
1622
+ }
1623
+ await this.host.sql.execute(
1624
+ `UPDATE outbox
1625
+ SET attempts = ?, next_attempt_at = ?, last_error = ?, updated_at = ?
1626
+ WHERE push_batch_id = ? AND state IN ('pending','relayed')`,
1627
+ [attempts, now + backoffMs(attempts), error, now, pushBatchId]
1628
+ );
1629
+ }
1630
+ async get(pushBatchId) {
1631
+ const rows = await this.host.sql.query(
1632
+ "SELECT * FROM outbox WHERE push_batch_id = ?",
1633
+ [pushBatchId]
1634
+ );
1635
+ const row = rows[0];
1636
+ return row ? hydrate(row) : null;
1637
+ }
1638
+ /**
1639
+ * How much this atlas still owes, by state — the number the control panel
1640
+ * shows and the one a person asks about when they have been on a plane.
1641
+ */
1642
+ async counts() {
1643
+ const rows = await this.host.sql.query(
1644
+ "SELECT state, count(*) AS n FROM outbox GROUP BY state"
1645
+ );
1646
+ const out = {
1647
+ pending: 0,
1648
+ relayed: 0,
1649
+ acknowledged: 0,
1650
+ failed: 0
1651
+ };
1652
+ for (const row of rows) out[row.state] = row.n;
1653
+ return out;
1654
+ }
1655
+ /**
1656
+ * Drop acknowledged batches older than `olderThanMs`.
1657
+ *
1658
+ * Not automatic. An acknowledged batch is the local proof that a peer has
1659
+ * something, and deleting it on a timer the caller did not choose is how an
1660
+ * audit trail quietly disappears.
1661
+ */
1662
+ async prune(olderThanMs) {
1663
+ const cutoff = this.host.clock.now() - olderThanMs;
1664
+ const result = await this.host.sql.execute(
1665
+ "DELETE FROM outbox WHERE state = 'acknowledged' AND updated_at < ?",
1666
+ [cutoff]
1667
+ );
1668
+ return result.rowsAffected;
1669
+ }
1670
+ async attemptsOf(pushBatchId) {
1671
+ const rows = await this.host.sql.query(
1672
+ "SELECT attempts FROM outbox WHERE push_batch_id = ?",
1673
+ [pushBatchId]
1674
+ );
1675
+ return rows[0]?.attempts ?? 0;
1676
+ }
1677
+ };
1678
+ function hydrate(row) {
1679
+ return {
1680
+ pushBatchId: row.push_batch_id,
1681
+ targetAtlas: row.target_atlas,
1682
+ eco: row.eco,
1683
+ originAtlas: row.origin_atlas,
1684
+ fromLamport: row.from_lamport,
1685
+ events: JSON.parse(row.payload),
1686
+ state: row.state,
1687
+ attempts: row.attempts,
1688
+ nextAttemptAt: row.next_attempt_at,
1689
+ lastError: row.last_error,
1690
+ createdAt: row.created_at
1691
+ };
1692
+ }
1693
+
1694
+ // ../atlas-local/src/peers.ts
1695
+ var PEERS_PREFIX = "atlas.eco/entities/peers/";
1696
+ var Peers = class {
1697
+ constructor(storage) {
1698
+ this.storage = storage;
1699
+ }
1700
+ path(atlasId) {
1701
+ return `${PEERS_PREFIX}${atlasId}.json`;
1702
+ }
1703
+ async put(peer) {
1704
+ await this.storage.writeObject(this.path(peer.atlasId), peer);
1705
+ }
1706
+ async get(atlasId) {
1707
+ return await this.storage.readObject(this.path(atlasId));
1708
+ }
1709
+ async list() {
1710
+ const paths = await this.storage.listFiles(PEERS_PREFIX);
1711
+ const peers = [];
1712
+ for (const path of paths) {
1713
+ const peer = await this.storage.readObject(path);
1714
+ if (peer) peers.push(peer);
1715
+ }
1716
+ return peers.sort((a, b) => a.atlasId.localeCompare(b.atlasId));
1717
+ }
1718
+ /**
1719
+ * Forget a peer.
1720
+ *
1721
+ * Local only, and say so rather than implying more: it stops this atlas
1722
+ * sending to them and stops it accepting what they send, because an unpinned
1723
+ * origin fails strict verification. It does not reach their copy of anything
1724
+ * they already hold, and it cannot — that is what "your record is yours" means
1725
+ * when it is someone else's record.
1726
+ */
1727
+ async forget(atlasId) {
1728
+ await this.storage.delete(this.path(atlasId));
1729
+ }
1730
+ };
1731
+
1732
+ // ../atlas-local/src/rendezvous.ts
1733
+ var PENDING_REDEMPTIONS_PREFIX = "atlas.eco/entities/pending-redemptions/";
1734
+ var MAX_DRAIN_PAGES = 50;
1735
+ async function addressingKey(host, keyId) {
1736
+ await host.crypto.ensureSealKey(keyId);
1737
+ const addressing = await host.crypto.sealAddressingPublicKey(keyId);
1738
+ if (!addressing) throw new Error(`no addressing key for ${keyId}`);
1739
+ return addressing;
1740
+ }
1741
+ async function mintInvite(deps, relayUrl, opts = {}) {
1742
+ const { host, keyId, atlasId } = deps;
1743
+ const identityPublicKey = base64(await host.crypto.ensureKey(keyId));
1744
+ const sealingPublicKey = base64(await addressingKey(host, keyId));
1745
+ const nonce = `mn_${toHex(await host.crypto.randomBytes(12))}`;
1746
+ const relay = new RelayClient(host, relayUrl);
1747
+ const minted = await relay.mint({
1748
+ atlasId,
1749
+ identityPublicKey,
1750
+ sealingPublicKey,
1751
+ nonce,
1752
+ proof: base64(
1753
+ await host.crypto.sign(
1754
+ keyId,
1755
+ utf8Encode(
1756
+ canonicalMintString({ nonce, atlasId, identityPublicKey, sealingPublicKey })
1757
+ )
1758
+ )
1759
+ ),
1760
+ // The relay reads `expiresAt`; `ttlMs` alone was ignored and every invite lived 24 hours (HQ-1217).
1761
+ ...opts.ttlMs === void 0 ? {} : { ttlMs: opts.ttlMs, expiresAt: host.clock.now() + opts.ttlMs }
1762
+ });
1763
+ const link = {
1764
+ inviteId: minted.inviteId,
1765
+ secret: minted.secret,
1766
+ // The relay's own answer, not the URL we dialled: behind a proxy the public
1767
+ // name is not ours to know, and an invite carrying an address the recipient
1768
+ // cannot reach is well-formed and useless.
1769
+ relayUrl: minted.relayUrl,
1770
+ expiresAt: minted.expiresAt
1771
+ };
1772
+ await new PendingInvites(deps.storage).put({
1773
+ ...link,
1774
+ mintedAt: host.clock.now(),
1775
+ ...opts.label === void 0 ? {} : { label: opts.label }
1776
+ });
1777
+ return link;
1778
+ }
1779
+ async function redeemInvite(deps, invite) {
1780
+ const { host, keyId, atlasId } = deps;
1781
+ const relay = new RelayClient(host, invite.relayUrl);
1782
+ const identityPublicKey = base64(await host.crypto.ensureKey(keyId));
1783
+ const sealingPublicKey = base64(await addressingKey(host, keyId));
1784
+ const proof = base64(
1785
+ await host.crypto.sign(
1786
+ keyId,
1787
+ utf8Encode(
1788
+ canonicalInviteString({
1789
+ inviteId: invite.inviteId,
1790
+ atlasId,
1791
+ identityPublicKey,
1792
+ sealingPublicKey
1793
+ })
1794
+ )
1795
+ )
1796
+ );
1797
+ const challenge = await relay.redeem({
1798
+ inviteId: invite.inviteId,
1799
+ secret: invite.secret,
1800
+ atlasId,
1801
+ identityPublicKey,
1802
+ sealingPublicKey,
1803
+ proof
1804
+ });
1805
+ const nonce = await host.crypto.unseal(keyId, fromBase64(challenge.sealedNonce));
1806
+ const result = await relay.confirm({
1807
+ challengeId: challenge.challengeId,
1808
+ nonce: base64(nonce)
1809
+ });
1810
+ return await pinAndRegister(deps, result, "inviter");
1811
+ }
1812
+ async function collectInvite(deps, invite) {
1813
+ const { host, keyId, atlasId } = deps;
1814
+ const relay = new RelayClient(host, invite.relayUrl);
1815
+ const issuedAt = host.clock.now();
1816
+ const proof = base64(
1817
+ await host.crypto.sign(
1818
+ keyId,
1819
+ utf8Encode(
1820
+ canonicalInviteStateString({ inviteId: invite.inviteId, atlasId, issuedAt })
1821
+ )
1822
+ )
1823
+ );
1824
+ const state = await relay.inviteState(invite.inviteId, { atlasId, issuedAt, proof });
1825
+ if (state.status !== "redeemed") return null;
1826
+ return await pinAndRegister(deps, state, "redeemer");
1827
+ }
1828
+ async function pinAndRegister(deps, result, peerRole) {
1829
+ const { host, keyId, atlasId, storage } = deps;
1830
+ assertPinnable(result.peer);
1831
+ const parked = `${PENDING_REDEMPTIONS_PREFIX}${result.inviteId}.json`;
1832
+ await storage.writeObject(parked, result);
1833
+ const refusal = await verifyPeerProof(deps, result.peer, peerRole);
1834
+ if (refusal !== null) {
1835
+ throw new Error(
1836
+ `refusing to pin ${result.peer.atlasId}: ${refusal}. The response is kept at ${parked}; nothing was registered.`
1837
+ );
1838
+ }
1839
+ const { inbound, outbound } = await deriveMailboxes(host, {
1840
+ keyId,
1841
+ peerSealingPublicKey: fromBase64(result.peer.sealingPublicKey),
1842
+ inviteId: result.inviteId,
1843
+ ourAtlasId: atlasId,
1844
+ peerAtlasId: result.peer.atlasId
1845
+ });
1846
+ const relay = new RelayClient(host, result.relayUrl);
1847
+ await relay.register({
1848
+ grant: result.registrationGrant,
1849
+ address: inbound.address,
1850
+ // The HASH, never the credential. The relay needs to recognise a caller,
1851
+ // not to be able to become one.
1852
+ pollCredentialHash: base64(await host.crypto.sha256(utf8Encode(inbound.pollCredential)))
1853
+ });
1854
+ const peer = {
1855
+ atlasId: result.peer.atlasId,
1856
+ identityPublicKey: result.peer.identityPublicKey,
1857
+ sealingPublicKey: result.peer.sealingPublicKey,
1858
+ // Content starts as addressing and diverges at the peer's first rotation.
1859
+ contentPublicKey: result.peer.sealingPublicKey,
1860
+ contentKeyVersion: 0,
1861
+ relayUrl: result.relayUrl,
1862
+ inviteId: result.inviteId,
1863
+ inboundAddress: inbound.address,
1864
+ outboundAddress: outbound.address,
1865
+ pinnedAt: host.clock.now(),
1866
+ keyVersion: 1
1867
+ };
1868
+ await new Peers(storage).put(peer);
1869
+ await storage.delete(parked);
1870
+ const version = await host.crypto.sealKeyVersion(keyId);
1871
+ if (version > 0) {
1872
+ try {
1873
+ await announceRotation(deps, peer, version);
1874
+ } catch {
1875
+ }
1876
+ }
1877
+ return peer;
1878
+ }
1879
+ async function announceRotation(deps, peer, version) {
1880
+ const { host, keyId, atlasId } = deps;
1881
+ const current = await host.crypto.sealPublicKey(keyId);
1882
+ if (!current) throw new Error(`no sealing key for ${keyId}`);
1883
+ const sealingPublicKey = base64(current);
1884
+ await postSealed(deps, peer, {
1885
+ v: 1,
1886
+ type: "rotate",
1887
+ atlasId,
1888
+ version,
1889
+ sealingPublicKey,
1890
+ signature: base64(
1891
+ await host.crypto.sign(
1892
+ keyId,
1893
+ utf8Encode(canonicalSealRotationString({ atlasId, version, sealingPublicKey }))
1894
+ )
1895
+ )
1896
+ });
1897
+ }
1898
+ async function verifyPeerProof(deps, peer, peerRole) {
1899
+ if (!peer.proof || !peer.proofContext) {
1900
+ return "the relay returned no proof of possession";
1901
+ }
1902
+ let canonical;
1903
+ try {
1904
+ canonical = peerRole === "inviter" ? canonicalMintString({
1905
+ nonce: peer.proofContext,
1906
+ atlasId: peer.atlasId,
1907
+ identityPublicKey: peer.identityPublicKey,
1908
+ sealingPublicKey: peer.sealingPublicKey
1909
+ }) : canonicalInviteString({
1910
+ inviteId: peer.proofContext,
1911
+ atlasId: peer.atlasId,
1912
+ identityPublicKey: peer.identityPublicKey,
1913
+ sealingPublicKey: peer.sealingPublicKey
1914
+ });
1915
+ } catch (error) {
1916
+ return `proof material is malformed: ${error instanceof Error ? error.message : String(error)}`;
1917
+ }
1918
+ const ok = await deps.host.crypto.verify(
1919
+ fromBase64(peer.identityPublicKey),
1920
+ utf8Encode(canonical),
1921
+ fromBase64(peer.proof)
1922
+ );
1923
+ return ok ? null : "the proof does not verify against the identity key presented";
1924
+ }
1925
+ function assertPinnable(peer) {
1926
+ if (!peer.atlasId) throw new Error("relay returned a peer with no atlasId");
1927
+ for (const [name, value] of [
1928
+ ["identity", peer.identityPublicKey],
1929
+ ["sealing", peer.sealingPublicKey]
1930
+ ]) {
1931
+ if (fromBase64(value).length !== 32) {
1932
+ throw new Error(`relay returned a ${name} key that is not 32 bytes`);
1933
+ }
1934
+ }
1935
+ }
1936
+ async function notifyFor(deps, eco, changesetId) {
1937
+ const peers = await new Peers(deps.storage).list();
1938
+ const readers = deps.grants ? await deps.grants.readers(eco) : "all";
1939
+ return peers.filter((peer) => readers === "all" || readers.includes(peer.atlasId)).map((peer) => ({ targetAtlas: peer.atlasId, pushBatchId: `${changesetId}>${peer.atlasId}` }));
1940
+ }
1941
+ async function sendDue(deps, limit = 20) {
1942
+ const { host, storage } = deps;
1943
+ const outbox = new Outbox(host);
1944
+ const peers = new Peers(storage);
1945
+ const blobs = new BlobStore(host);
1946
+ const report = { relayed: 0, failed: 0, unknownPeers: 0 };
1947
+ for (const row of await outbox.due(limit)) {
1948
+ const peer = await peers.get(row.targetAtlas);
1949
+ if (!peer) {
1950
+ report.unknownPeers += 1;
1951
+ continue;
1952
+ }
1953
+ const payload = {
1954
+ v: 1,
1955
+ type: "push",
1956
+ pushBatchId: row.pushBatchId,
1957
+ eco: row.eco,
1958
+ originAtlas: row.originAtlas,
1959
+ fromLamport: row.fromLamport,
1960
+ events: row.events
1961
+ };
1962
+ try {
1963
+ const relay = new RelayClient(host, peer.relayUrl);
1964
+ for (const blob of await blobs.forChanges(row.events.map((e) => e.id))) {
1965
+ const put = await relay.putBlob(blob.ciphertext);
1966
+ if (put.hash !== blob.hash) {
1967
+ throw new Error(`relay named blob ${blob.hash} as ${put.hash}; refusing to send a Change whose blob it altered`);
1968
+ }
1969
+ }
1970
+ await postSealed(deps, peer, payload);
1971
+ await outbox.onRelayed(row.pushBatchId);
1972
+ report.relayed += 1;
1973
+ } catch (error) {
1974
+ const permanent = error instanceof RelayError && error.permanent;
1975
+ await outbox.onFailed(row.pushBatchId, String(error), permanent);
1976
+ report.failed += 1;
1977
+ }
1978
+ }
1979
+ return report;
1980
+ }
1981
+ async function postSealed(deps, peer, payload) {
1982
+ const { host } = deps;
1983
+ const sealed = await host.crypto.seal(
1984
+ fromBase64(peer.contentPublicKey),
1985
+ utf8Encode(JSON.stringify(payload))
1986
+ );
1987
+ await new RelayClient(host, peer.relayUrl).post(peer.outboundAddress, sealed);
1988
+ }
1989
+ var CIRCLE_KEYS_SENT_PREFIX = "atlas.eco/entities/circle-keys-sent/";
1990
+ var sentKeyPath = (peer, circleId) => `${CIRCLE_KEYS_SENT_PREFIX}${peer}--${circleId}.json`;
1991
+ async function deliverCircleKeys(deps) {
1992
+ const report = { delivered: 0, failed: 0, reasons: [] };
1993
+ const { grants, host, storage, atlasId, keyId } = deps;
1994
+ if (!grants?.circles || !grants.mayShareKey) return report;
1995
+ const peers = await new Peers(storage).list();
1996
+ for (const circleId of await grants.circles()) {
1997
+ const key = await host.crypto.circleKey(circleId);
1998
+ if (!key) continue;
1999
+ if (!await grants.mayShareKey(atlasId, circleId)) continue;
2000
+ const readers = await grants.readers(circleId);
2001
+ for (const peer of peers) {
2002
+ if (readers !== "all" && !readers.includes(peer.atlasId)) continue;
2003
+ const sent = await storage.readObject(sentKeyPath(peer.atlasId, circleId));
2004
+ if ((sent?.epoch ?? 0) >= key.epoch) continue;
2005
+ try {
2006
+ const sealed = await host.crypto.sealCircleKeyTo(circleId, key.epoch, fromBase64(peer.contentPublicKey));
2007
+ const sealedKeyHash = base64(await host.crypto.sha256(sealed));
2008
+ const signature = await host.crypto.sign(
2009
+ keyId,
2010
+ utf8Encode(
2011
+ canonicalCircleKeyString({ from: atlasId, to: peer.atlasId, circleId, epoch: key.epoch, sealedKeyHash })
2012
+ )
2013
+ );
2014
+ await postSealed(deps, peer, {
2015
+ v: 1,
2016
+ type: "circle-key",
2017
+ from: atlasId,
2018
+ to: peer.atlasId,
2019
+ circleId,
2020
+ epoch: key.epoch,
2021
+ sealedKey: base64(sealed),
2022
+ signature: base64(signature)
2023
+ });
2024
+ await storage.writeObject(sentKeyPath(peer.atlasId, circleId), { epoch: key.epoch, at: host.clock.now() });
2025
+ if (sent === null) await oweCatchUp(deps, peer.atlasId, circleId, "join");
2026
+ report.delivered += 1;
2027
+ } catch (error) {
2028
+ report.failed += 1;
2029
+ report.reasons.push(`${peer.atlasId}/${circleId}: ${error instanceof Error ? error.message : String(error)}`);
2030
+ }
2031
+ }
2032
+ }
2033
+ return report;
2034
+ }
2035
+ async function rekeyCircle(deps, circleId) {
2036
+ if (deps.grants?.mayShareKey && !await deps.grants.mayShareKey(deps.atlasId, circleId)) {
2037
+ throw new Error(`${deps.atlasId} holds no owner grant on circle ${circleId}, so it cannot re-key it`);
2038
+ }
2039
+ const { epoch } = await deps.host.crypto.newCircleKey(circleId);
2040
+ return { epoch, delivery: await deliverCircleKeys(deps) };
2041
+ }
2042
+ async function applyCircleKey(deps, peer, payload) {
2043
+ if (payload.from !== peer.atlasId) return `circle key names ${payload.from}, not peer ${peer.atlasId}`;
2044
+ if (payload.to !== deps.atlasId) return `circle key is for ${payload.to}, not ${deps.atlasId}`;
2045
+ if (!Number.isInteger(payload.epoch) || payload.epoch < 1) return `circle key epoch ${String(payload.epoch)} is not >= 1`;
2046
+ const sealed = fromBase64(payload.sealedKey);
2047
+ let canonical;
2048
+ try {
2049
+ canonical = canonicalCircleKeyString({
2050
+ from: payload.from,
2051
+ to: payload.to,
2052
+ circleId: payload.circleId,
2053
+ epoch: payload.epoch,
2054
+ sealedKeyHash: base64(await deps.host.crypto.sha256(sealed))
2055
+ });
2056
+ } catch (error) {
2057
+ return `circle key is malformed: ${error instanceof Error ? error.message : String(error)}`;
2058
+ }
2059
+ const ok = await deps.host.crypto.verify(
2060
+ fromBase64(peer.identityPublicKey),
2061
+ utf8Encode(canonical),
2062
+ fromBase64(payload.signature)
2063
+ );
2064
+ if (!ok) return "circle key does not verify against the pinned identity key";
2065
+ const { grants } = deps;
2066
+ if (!grants?.mayShareKey) return "this device reads no grants, so it accepts no circle key";
2067
+ if (!await grants.mayShareKey(peer.atlasId, payload.circleId)) {
2068
+ return `${peer.atlasId} holds no owner grant on circle ${payload.circleId}`;
2069
+ }
2070
+ const readers = await grants.readers(payload.circleId);
2071
+ if (readers !== "all" && !readers.includes(deps.atlasId)) {
2072
+ return `this device holds no grant on circle ${payload.circleId}`;
2073
+ }
2074
+ try {
2075
+ await deps.host.crypto.importCircleKey(deps.keyId, payload.circleId, payload.epoch, sealed);
2076
+ } catch (error) {
2077
+ return `circle key does not open: ${error instanceof Error ? error.message : String(error)}`;
2078
+ }
2079
+ return null;
2080
+ }
2081
+ var CATCH_UP_OWED_PREFIX = "atlas.eco/entities/catch-up-owed/";
2082
+ var CATCH_UP_BATCH = 25;
2083
+ var owedPath = (peer, circleId) => `${CATCH_UP_OWED_PREFIX}${peer}--${circleId}.json`;
2084
+ async function oweCatchUp(deps, peer, circleId, why) {
2085
+ await deps.storage.writeObject(owedPath(peer, circleId), { peer, circleId, why, at: deps.host.clock.now() });
2086
+ }
2087
+ async function deliverCatchUp(deps) {
2088
+ const report = { delivered: 0, changes: 0, failed: 0, reasons: [] };
2089
+ const { catchUp, grants, host, storage } = deps;
2090
+ if (!catchUp) return report;
2091
+ const peers = new Peers(storage);
2092
+ const blobs = new BlobStore(host);
2093
+ const snapshots = /* @__PURE__ */ new Map();
2094
+ for (const file of (await storage.listFiles(CATCH_UP_OWED_PREFIX)).sort()) {
2095
+ const owed = await storage.readObject(file);
2096
+ if (!owed) continue;
2097
+ const peer = await peers.get(owed.peer);
2098
+ try {
2099
+ const readers = grants ? await grants.readers(owed.circleId) : "all";
2100
+ if (!peer || readers !== "all" && !readers.includes(owed.peer)) {
2101
+ await storage.delete(file);
2102
+ continue;
2103
+ }
2104
+ let events = snapshots.get(owed.circleId);
2105
+ if (!events) {
2106
+ events = await catchUp.snapshot(owed.circleId);
2107
+ snapshots.set(owed.circleId, events);
2108
+ }
2109
+ const relay = new RelayClient(host, peer.relayUrl);
2110
+ for (let i = 0; i < events.length; i += CATCH_UP_BATCH) {
2111
+ const batch = events.slice(i, i + CATCH_UP_BATCH);
2112
+ for (const blob of await blobs.forChanges(batch.map((e) => e.id))) {
2113
+ const put = await relay.putBlob(blob.ciphertext);
2114
+ if (put.hash !== blob.hash) throw new Error(`relay named blob ${blob.hash} as ${put.hash}`);
2115
+ }
2116
+ await postSealed(deps, peer, {
2117
+ v: 1,
2118
+ type: "push",
2119
+ pushBatchId: `catch-up:${owed.circleId}:${batch[0].id}>${peer.atlasId}`,
2120
+ eco: owed.circleId,
2121
+ originAtlas: deps.atlasId,
2122
+ fromLamport: batch[0].lamport - 1,
2123
+ events: batch
2124
+ });
2125
+ }
2126
+ await storage.delete(file);
2127
+ report.delivered += 1;
2128
+ report.changes += events.length;
2129
+ } catch (error) {
2130
+ report.failed += 1;
2131
+ report.reasons.push(`${owed.peer}/${owed.circleId}: ${error instanceof Error ? error.message : String(error)}`);
2132
+ }
2133
+ }
2134
+ return report;
2135
+ }
2136
+ var CIRCLE_DECLARATIONS_SENT_PREFIX = "atlas.eco/entities/circle-declarations-sent/";
2137
+ var sentDeclarationPath = (peer, circleId) => `${CIRCLE_DECLARATIONS_SENT_PREFIX}${peer}--${circleId}.json`;
2138
+ async function declarationHash(host, declaration) {
2139
+ return base64(await host.crypto.sha256(utf8Encode(canonicalJson(declaration))));
2140
+ }
2141
+ async function deliverCircleDeclarations(deps) {
2142
+ const report = { delivered: 0, revoked: 0, failed: 0, reasons: [] };
2143
+ const { grants, host, storage, atlasId, keyId } = deps;
2144
+ if (!grants?.circles || !grants.mayShareKey || !grants.declaration) return report;
2145
+ const own = grants.ownRecord ? await grants.ownRecord() : null;
2146
+ const siblings = /* @__PURE__ */ new Set();
2147
+ const peers = [];
2148
+ for (const peer of await new Peers(storage).list()) {
2149
+ if (own !== null && grants.recordOf && await grants.recordOf(peer.atlasId) === own) siblings.add(peer.atlasId);
2150
+ else peers.push(peer);
2151
+ }
2152
+ for (const circleId of await grants.circles()) {
2153
+ if (!await grants.mayShareKey(atlasId, circleId)) continue;
2154
+ const declaration = await grants.declaration(circleId);
2155
+ if (!declaration) continue;
2156
+ const hash = await declarationHash(host, declaration);
2157
+ const readers = await grants.readers(circleId);
2158
+ for (const peer of peers) {
2159
+ const path = sentDeclarationPath(peer.atlasId, circleId);
2160
+ const sent = await storage.readObject(path);
2161
+ const holds = readers === "all" || readers.includes(peer.atlasId);
2162
+ try {
2163
+ if (holds) {
2164
+ if (sent?.hash === hash && sent.revokedAt === void 0) continue;
2165
+ const signature = await host.crypto.sign(
2166
+ keyId,
2167
+ utf8Encode(canonicalCircleDeclarationString({ from: atlasId, to: peer.atlasId, circleId, declarationHash: hash }))
2168
+ );
2169
+ await postSealed(deps, peer, {
2170
+ v: 1,
2171
+ type: "circle-declaration",
2172
+ from: atlasId,
2173
+ to: peer.atlasId,
2174
+ circleId,
2175
+ declaration,
2176
+ signature: base64(signature)
2177
+ });
2178
+ await storage.writeObject(path, { hash, at: host.clock.now() });
2179
+ if (sent?.revokedAt !== void 0) await oweCatchUp(deps, peer.atlasId, circleId, "thaw");
2180
+ report.delivered += 1;
2181
+ } else if (sent && sent.revokedAt === void 0) {
2182
+ const at = new Date(host.clock.now()).toISOString();
2183
+ const signature = await host.crypto.sign(
2184
+ keyId,
2185
+ utf8Encode(canonicalCircleRevocationString({ from: atlasId, to: peer.atlasId, circleId, at }))
2186
+ );
2187
+ await postSealed(deps, peer, { v: 1, type: "circle-revoked", from: atlasId, to: peer.atlasId, circleId, at, signature: base64(signature) });
2188
+ await storage.writeObject(path, { ...sent, revokedAt: host.clock.now() });
2189
+ report.revoked += 1;
2190
+ }
2191
+ } catch (error) {
2192
+ report.failed += 1;
2193
+ report.reasons.push(`${peer.atlasId}/${circleId}: ${error instanceof Error ? error.message : String(error)}`);
2194
+ }
2195
+ }
2196
+ }
2197
+ return report;
2198
+ }
2199
+ var isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2200
+ var nonEmptyString = (value) => typeof value === "string" && value.length > 0;
2201
+ function malformedDeclaration(d) {
2202
+ if (!isRecord(d)) return "declaration is not an object";
2203
+ if (!nonEmptyString(d.id) || d.id.includes("/") || d.id.includes("|")) return "declaration has no usable id";
2204
+ if (d.namespace !== `${d.id}.eco`) return `declaration namespace ${String(d.namespace)} does not name circle ${d.id}`;
2205
+ if (!nonEmptyString(d.title)) return "declaration has no title";
2206
+ if (d.public !== false) return "a public circle travels as its export, not as a declaration";
2207
+ if (d.release !== "never" && !(Array.isArray(d.release) && d.release.length > 0 && d.release.every(nonEmptyString))) return "declaration release is neither never nor a list of circle names";
2208
+ if (!Array.isArray(d.grants) || d.grants.length === 0) return "declaration carries no grants";
2209
+ for (const g of d.grants) {
2210
+ if (!isRecord(g)) return "a grant in the declaration is not an object";
2211
+ if (!nonEmptyString(g.principalId)) return "a grant in the declaration names no principal";
2212
+ if (g.role !== "owner" && g.role !== "editor" && g.role !== "viewer") return `a grant in the declaration has role ${String(g.role)}`;
2213
+ if (!isRecord(g.holder) || !nonEmptyString(g.holder.kind)) return `the grant to ${g.principalId} names no holder kind`;
2214
+ if (!nonEmptyString(g.grantedAt) || !nonEmptyString(g.grantedBy)) return `the grant to ${g.principalId} says not when or by whom`;
2215
+ }
2216
+ return null;
2217
+ }
2218
+ async function applyCircleDeclaration(deps, peer, payload) {
2219
+ if (payload.from !== peer.atlasId) return `circle declaration names ${payload.from}, not peer ${peer.atlasId}`;
2220
+ if (payload.to !== deps.atlasId) return `circle declaration is for ${payload.to}, not ${deps.atlasId}`;
2221
+ const malformed = malformedDeclaration(payload.declaration);
2222
+ if (malformed !== null) return `circle declaration is malformed: ${malformed}`;
2223
+ if (payload.declaration.id !== payload.circleId) return `circle declaration declares ${payload.declaration.id} inside an envelope for ${payload.circleId}`;
2224
+ let canonical;
2225
+ try {
2226
+ canonical = canonicalCircleDeclarationString({
2227
+ from: payload.from,
2228
+ to: payload.to,
2229
+ circleId: payload.circleId,
2230
+ declarationHash: await declarationHash(deps.host, payload.declaration)
2231
+ });
2232
+ } catch (error) {
2233
+ return `circle declaration is malformed: ${error instanceof Error ? error.message : String(error)}`;
2234
+ }
2235
+ let ok = false;
2236
+ try {
2237
+ ok = await deps.host.crypto.verify(fromBase64(peer.identityPublicKey), utf8Encode(canonical), fromBase64(payload.signature));
2238
+ } catch {
2239
+ ok = false;
2240
+ }
2241
+ if (!ok) return "circle declaration does not verify against the pinned identity key";
2242
+ if (!deps.grants?.adoptDeclaration) return "this device reads no grants, so it holds no declared circle";
2243
+ return await deps.grants.adoptDeclaration(payload.from, payload.declaration);
2244
+ }
2245
+ async function applyCircleRevocation(deps, peer, payload) {
2246
+ if (payload.from !== peer.atlasId) return `circle revocation names ${payload.from}, not peer ${peer.atlasId}`;
2247
+ if (payload.to !== deps.atlasId) return `circle revocation is for ${payload.to}, not ${deps.atlasId}`;
2248
+ if (!nonEmptyString(payload.at) || Number.isNaN(Date.parse(payload.at))) return `circle revocation carries no instant`;
2249
+ let canonical;
2250
+ try {
2251
+ canonical = canonicalCircleRevocationString({ from: payload.from, to: payload.to, circleId: payload.circleId, at: payload.at });
2252
+ } catch (error) {
2253
+ return `circle revocation is malformed: ${error instanceof Error ? error.message : String(error)}`;
2254
+ }
2255
+ let ok = false;
2256
+ try {
2257
+ ok = await deps.host.crypto.verify(fromBase64(peer.identityPublicKey), utf8Encode(canonical), fromBase64(payload.signature));
2258
+ } catch {
2259
+ ok = false;
2260
+ }
2261
+ if (!ok) return "circle revocation does not verify against the pinned identity key";
2262
+ if (!deps.grants?.freezeCircle) return "this device reads no grants, so it freezes no circle";
2263
+ return await deps.grants.freezeCircle(payload.from, payload.circleId, payload.at);
2264
+ }
2265
+ async function noticeHash(host, notice) {
2266
+ return base64(await host.crypto.sha256(utf8Encode(canonicalJson(notice))));
2267
+ }
2268
+ function malformedNotice(n) {
2269
+ if (!isRecord(n)) return "notice is not an object";
2270
+ if (!nonEmptyString(n.record)) return "notice names no record";
2271
+ if (n.op !== "add" && n.op !== "retire") return `notice op ${String(n.op)} is neither add nor retire`;
2272
+ if (!isRecord(n.device) || !nonEmptyString(n.device.atlasId) || !nonEmptyString(n.device.identityPublicKey)) return "notice names no device and key";
2273
+ if (!nonEmptyString(n.at) || Number.isNaN(Date.parse(n.at))) return "notice carries no instant";
2274
+ if (n.op === "add" && n.invite !== void 0) {
2275
+ if (!isRecord(n.invite) || !nonEmptyString(n.invite.inviteId) || !nonEmptyString(n.invite.secret) || !nonEmptyString(n.invite.relayUrl)) return "the invite for an added device is malformed";
2276
+ }
2277
+ return null;
2278
+ }
2279
+ async function applyHolderDevice(deps, peer, payload, report) {
2280
+ if (payload.from !== peer.atlasId) return `holder-device notice names ${payload.from}, not peer ${peer.atlasId}`;
2281
+ if (payload.to !== deps.atlasId) return `holder-device notice is for ${payload.to}, not ${deps.atlasId}`;
2282
+ const malformed = malformedNotice(payload.notice);
2283
+ if (malformed !== null) return `holder-device notice is malformed: ${malformed}`;
2284
+ const notice = payload.notice;
2285
+ let ok = false;
2286
+ try {
2287
+ const canonical = canonicalHolderDeviceString({ from: payload.from, to: payload.to, noticeHash: await noticeHash(deps.host, notice) });
2288
+ ok = await deps.host.crypto.verify(fromBase64(peer.identityPublicKey), utf8Encode(canonical), fromBase64(payload.signature));
2289
+ } catch {
2290
+ ok = false;
2291
+ }
2292
+ if (!ok) return "holder-device notice does not verify against the pinned identity key";
2293
+ const { grants } = deps;
2294
+ if (!grants?.admitDevice || !grants.retireDevice) return "this device reads no grants, so it keeps no device lists";
2295
+ const introducer = { atlasId: peer.atlasId, identityPublicKey: peer.identityPublicKey };
2296
+ const peers = new Peers(deps.storage);
2297
+ const sibling = grants.ownRecord ? notice.record === await grants.ownRecord() : false;
2298
+ if (notice.op === "add") {
2299
+ const self = notice.device.atlasId === deps.atlasId;
2300
+ if (self && !sibling) return `${peer.atlasId} lists this device as a device of ${notice.record}; only a device of this record's own holder lists it`;
2301
+ const known = self ? null : await peers.get(notice.device.atlasId);
2302
+ if (self && notice.device.identityPublicKey !== base64(await deps.host.crypto.ensureKey(deps.keyId))) {
2303
+ return `the notice lists this device under an identity key that is not its own`;
2304
+ }
2305
+ if (known && known.identityPublicKey !== notice.device.identityPublicKey) {
2306
+ return `${notice.device.atlasId} is already pinned here under a different identity key`;
2307
+ }
2308
+ if (!notice.invite && !sibling && !known) {
2309
+ return `an added device of another holder comes with an invite, so this device can pin it`;
2310
+ }
2311
+ if (!known && !self && notice.invite) {
2312
+ let pinned;
2313
+ try {
2314
+ pinned = await redeemInvite(deps, notice.invite);
2315
+ } catch (error) {
2316
+ return `the invite for ${notice.device.atlasId} does not redeem: ${error instanceof Error ? error.message : String(error)}`;
2317
+ }
2318
+ if (pinned.atlasId !== notice.device.atlasId || pinned.identityPublicKey !== notice.device.identityPublicKey) {
2319
+ await peers.forget(pinned.atlasId);
2320
+ return `the invite pinned ${pinned.atlasId} with a key that is not the one ${peer.atlasId} introduced for ${notice.device.atlasId}; forgotten`;
2321
+ }
2322
+ }
2323
+ const refusal2 = await grants.admitDevice(notice.record, notice.device, introducer, notice.at);
2324
+ if (refusal2 !== null && !known && !self && notice.invite) await peers.forget(notice.device.atlasId);
2325
+ return refusal2;
2326
+ }
2327
+ if (sibling) {
2328
+ return await grants.retireDevice(notice.record, notice.device.atlasId, peer.atlasId, notice.at);
2329
+ }
2330
+ const affected = [];
2331
+ for (const circleId of grants.circles ? await grants.circles() : []) {
2332
+ const readers = await grants.readers(circleId);
2333
+ if (readers === "all" || !readers.includes(notice.device.atlasId)) continue;
2334
+ if (grants.mayShareKey && await grants.mayShareKey(deps.atlasId, circleId)) affected.push(circleId);
2335
+ }
2336
+ const refusal = await grants.retireDevice(notice.record, notice.device.atlasId, peer.atlasId, notice.at);
2337
+ if (refusal !== null) return refusal;
2338
+ for (const circleId of affected) {
2339
+ const { epoch } = await rekeyCircle(deps, circleId);
2340
+ (report.rekeyed ??= []).push(`${circleId}@${epoch}`);
2341
+ }
2342
+ return null;
2343
+ }
2344
+ async function rotateSealingKey(deps, opts = {}) {
2345
+ const { host, keyId, atlasId, storage } = deps;
2346
+ const keepVersions = opts.keepVersions ?? 2;
2347
+ const { version } = await host.crypto.rotateSealKey(keyId);
2348
+ const report = { version, announced: 0, failed: 0, dropped: 0 };
2349
+ for (const peer of await new Peers(storage).list()) {
2350
+ try {
2351
+ await announceRotation(deps, peer, version);
2352
+ report.announced += 1;
2353
+ } catch {
2354
+ report.failed += 1;
2355
+ }
2356
+ }
2357
+ report.dropped = await host.crypto.dropRetiredSealKeys(keyId, keepVersions);
2358
+ return report;
2359
+ }
2360
+ async function applyRotation(deps, peer, payload) {
2361
+ if (payload.atlasId !== peer.atlasId) {
2362
+ return `rotation names ${payload.atlasId}, not peer ${peer.atlasId}`;
2363
+ }
2364
+ if (!Number.isInteger(payload.version) || payload.version <= peer.contentKeyVersion) {
2365
+ return `rotation to version ${String(payload.version)} does not move forwards`;
2366
+ }
2367
+ if (fromBase64(payload.sealingPublicKey).length !== 32) {
2368
+ return "rotation carries a key that is not 32 bytes";
2369
+ }
2370
+ const canonical = canonicalSealRotationString({
2371
+ atlasId: payload.atlasId,
2372
+ version: payload.version,
2373
+ sealingPublicKey: payload.sealingPublicKey
2374
+ });
2375
+ const ok = await deps.host.crypto.verify(
2376
+ fromBase64(peer.identityPublicKey),
2377
+ utf8Encode(canonical),
2378
+ fromBase64(payload.signature)
2379
+ );
2380
+ if (!ok) return "rotation does not verify against the pinned identity key";
2381
+ await new Peers(deps.storage).put({
2382
+ ...peer,
2383
+ contentPublicKey: payload.sealingPublicKey,
2384
+ contentKeyVersion: payload.version
2385
+ });
2386
+ return null;
2387
+ }
2388
+ function causeChain(error) {
2389
+ const parts = [];
2390
+ let cur = error;
2391
+ for (let i = 0; cur !== void 0 && cur !== null && i < 5; i += 1) {
2392
+ if (cur instanceof Error) {
2393
+ const code = cur.code;
2394
+ parts.push(typeof code === "string" && !cur.message.includes(code) ? `${code} ${cur.message}` : cur.message);
2395
+ cur = cur.cause;
2396
+ } else {
2397
+ parts.push(String(cur));
2398
+ cur = void 0;
2399
+ }
2400
+ }
2401
+ return parts.join(" <- ");
2402
+ }
2403
+ async function pollPeers(deps, limit = 50) {
2404
+ const report = {
2405
+ applied: 0,
2406
+ unreachable: 0,
2407
+ rotations: 0,
2408
+ rejected: 0,
2409
+ acknowledged: 0,
2410
+ reasons: []
2411
+ };
2412
+ const peers = await new Peers(deps.storage).list();
2413
+ for (const peer of peers) {
2414
+ try {
2415
+ await pollPeer(deps, peer, limit, report);
2416
+ } catch (error) {
2417
+ report.unreachable += 1;
2418
+ report.reasons.push(`${peer.atlasId}: ${causeChain(error)}`);
2419
+ }
2420
+ }
2421
+ if (peers.length > 0 && report.unreachable === peers.length) {
2422
+ throw new Error(`no peer could be reached: ${report.reasons.join("; ")}`);
2423
+ }
2424
+ return report;
2425
+ }
2426
+ async function pollPeer(deps, peer, limit, report, pagesLeft = MAX_DRAIN_PAGES) {
2427
+ const { host, keyId, storage } = deps;
2428
+ const relay = new RelayClient(host, peer.relayUrl);
2429
+ const { inbound } = await deriveMailboxes(host, {
2430
+ keyId,
2431
+ peerSealingPublicKey: fromBase64(peer.sealingPublicKey),
2432
+ inviteId: peer.inviteId,
2433
+ ourAtlasId: deps.atlasId,
2434
+ peerAtlasId: peer.atlasId
2435
+ });
2436
+ const delivery = await relay.poll(inbound.address, inbound.pollCredential, limit);
2437
+ const log = new ChangeLog(host, deps.atlasId, keyId);
2438
+ const outbox = new Outbox(host);
2439
+ for (const item of delivery.envelopes) {
2440
+ let payload;
2441
+ try {
2442
+ const opened = await host.crypto.unseal(keyId, fromBase64(item.envelope));
2443
+ payload = JSON.parse(utf8Decode(opened));
2444
+ } catch {
2445
+ report.rejected += 1;
2446
+ report.reasons.push(`unopenable envelope ${item.id}`);
2447
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2448
+ continue;
2449
+ }
2450
+ if (payload.type === "rotate") {
2451
+ const refusal = await applyRotation(deps, peer, payload);
2452
+ if (refusal === null) {
2453
+ report.rotations += 1;
2454
+ } else {
2455
+ report.rejected += 1;
2456
+ report.reasons.push(`${item.id}: ${refusal}`);
2457
+ }
2458
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2459
+ continue;
2460
+ }
2461
+ if (payload.type === "circle-declaration") {
2462
+ const refusal = await applyCircleDeclaration(deps, peer, payload);
2463
+ if (refusal === null) {
2464
+ report.declarations = (report.declarations ?? 0) + 1;
2465
+ } else {
2466
+ report.rejected += 1;
2467
+ report.reasons.push(`${item.id}: ${refusal}`);
2468
+ }
2469
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2470
+ continue;
2471
+ }
2472
+ if (payload.type === "circle-revoked") {
2473
+ const refusal = await applyCircleRevocation(deps, peer, payload);
2474
+ if (refusal === null) {
2475
+ report.revocations = (report.revocations ?? 0) + 1;
2476
+ } else {
2477
+ report.rejected += 1;
2478
+ report.reasons.push(`${item.id}: ${refusal}`);
2479
+ }
2480
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2481
+ continue;
2482
+ }
2483
+ if (payload.type === "holder-device") {
2484
+ const refusal = await applyHolderDevice(deps, peer, payload, report);
2485
+ if (refusal === null) {
2486
+ report.devices = (report.devices ?? 0) + 1;
2487
+ } else {
2488
+ report.rejected += 1;
2489
+ report.reasons.push(`${item.id}: ${refusal}`);
2490
+ }
2491
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2492
+ continue;
2493
+ }
2494
+ if (payload.type === "circle-key") {
2495
+ const refusal = await applyCircleKey(deps, peer, payload);
2496
+ if (refusal === null) {
2497
+ report.circleKeys = (report.circleKeys ?? 0) + 1;
2498
+ } else {
2499
+ report.rejected += 1;
2500
+ report.reasons.push(`${item.id}: ${refusal}`);
2501
+ }
2502
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2503
+ continue;
2504
+ }
2505
+ if (payload.type === "ack") {
2506
+ await outbox.onAcknowledged(payload.pushBatchId, payload.newCursor, peer.atlasId);
2507
+ report.acknowledged += 1;
2508
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2509
+ continue;
2510
+ }
2511
+ const verdict = await verifyInbound(deps, peer, payload);
2512
+ if (verdict !== null) {
2513
+ report.rejected += 1;
2514
+ report.reasons.push(`${item.id}: ${verdict}`);
2515
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2516
+ continue;
2517
+ }
2518
+ if (deps.fold) {
2519
+ let accepted = 0;
2520
+ let deferred = 0;
2521
+ for (const change of payload.events) {
2522
+ const result = await deps.fold.apply(change, payload.eco);
2523
+ (report.folds ??= []).push(result);
2524
+ if (result.status === "refused") {
2525
+ report.rejected += 1;
2526
+ report.reasons.push(`${item.id}: ${result.changeId}: ${result.reason}`);
2527
+ } else if (result.status === "deferred") {
2528
+ deferred += 1;
2529
+ } else {
2530
+ accepted += 1;
2531
+ }
2532
+ }
2533
+ report.applied += accepted;
2534
+ if (deferred > 0) {
2535
+ report.deferred = (report.deferred ?? 0) + 1;
2536
+ report.reasons.push(`${item.id}: ${deferred} Change(s) wait on a blob`);
2537
+ continue;
2538
+ }
2539
+ if (accepted === 0) {
2540
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2541
+ continue;
2542
+ }
2543
+ } else {
2544
+ for (const change of payload.events) {
2545
+ await log.receive(change, payload.eco);
2546
+ }
2547
+ report.applied += payload.events.length;
2548
+ }
2549
+ await postSealed(deps, peer, {
2550
+ v: 1,
2551
+ type: "ack",
2552
+ pushBatchId: payload.pushBatchId,
2553
+ newCursor: payload.events.at(-1)?.lamport ?? payload.fromLamport
2554
+ });
2555
+ await relay.ack(inbound.address, item.id, inbound.pollCredential);
2556
+ }
2557
+ if (delivery.more && delivery.envelopes.length > 0 && pagesLeft > 1) {
2558
+ await pollPeer(deps, peer, limit, report, pagesLeft - 1);
2559
+ } else if (delivery.more && pagesLeft <= 1) {
2560
+ report.reasons.push(`${peer.atlasId}: stopped after ${MAX_DRAIN_PAGES} pages, more waiting`);
2561
+ }
2562
+ void storage;
2563
+ }
2564
+ async function verifyInbound(deps, peer, payload) {
2565
+ if (payload.v !== 1) return `unsupported envelope version ${String(payload.v)}`;
2566
+ if (payload.originAtlas !== peer.atlasId) {
2567
+ return `origin ${payload.originAtlas} is not peer ${peer.atlasId}`;
2568
+ }
2569
+ if (!Array.isArray(payload.events) || payload.events.length === 0) return "empty batch";
2570
+ if (deps.grants && !await deps.grants.mayAuthor(payload.originAtlas, payload.eco)) {
2571
+ return `origin ${payload.originAtlas} holds no owner or editor grant on circle ${payload.eco}`;
2572
+ }
2573
+ const pinned = fromBase64(peer.identityPublicKey);
2574
+ for (const change of payload.events) {
2575
+ if (change.originAtlas !== payload.originAtlas) {
2576
+ return `change ${change.id} claims origin ${change.originAtlas}`;
2577
+ }
2578
+ if (!change.originSignature) return `change ${change.id} is unsigned`;
2579
+ const ok = await deps.host.crypto.verify(
2580
+ pinned,
2581
+ utf8Encode(change.id),
2582
+ fromBase64(change.originSignature)
2583
+ );
2584
+ if (!ok) return `change ${change.id} does not verify against the pinned key`;
2585
+ }
2586
+ const issues = await verifyChain(payload.events, (bytes) => deps.host.crypto.sha256(bytes));
2587
+ const blocking = issues.filter((issue) => issue.issue !== "broken-prev");
2588
+ if (blocking.length > 0) {
2589
+ return `chain: ${blocking.map((issue) => `${issue.issue}@${issue.change.lamport}`).join(", ")}`;
2590
+ }
2591
+ return null;
2592
+ }
2593
+
2594
+ // ../atlas-local/src/schema.ts
2595
+ var MIGRATIONS = [
2596
+ [
2597
+ `CREATE TABLE IF NOT EXISTS outbox (
2598
+ push_batch_id TEXT PRIMARY KEY,
2599
+ target_atlas TEXT NOT NULL,
2600
+ eco TEXT NOT NULL,
2601
+ origin_atlas TEXT NOT NULL,
2602
+ from_lamport INTEGER NOT NULL,
2603
+ payload TEXT NOT NULL,
2604
+ state TEXT NOT NULL CHECK (
2605
+ state IN ('pending','relayed','acknowledged','failed')
2606
+ ),
2607
+ attempts INTEGER NOT NULL DEFAULT 0,
2608
+ next_attempt_at INTEGER NOT NULL,
2609
+ last_error TEXT,
2610
+ created_at INTEGER NOT NULL,
2611
+ updated_at INTEGER NOT NULL
2612
+ )`,
2613
+ // The only hot query: "what is due to send now". Ordering by
2614
+ // created_at keeps a peer's batches in the order they were authored,
2615
+ // which the receiver's lamport check requires.
2616
+ `CREATE INDEX IF NOT EXISTS outbox_due
2617
+ ON outbox (state, next_attempt_at, created_at)`
2618
+ ],
2619
+ // Migration 2 — the vault.
2620
+ //
2621
+ // `path` is the LOGICAL vault address and is suffix-free. The fs backend
2622
+ // appends `.yaml` because there the bytes really are YAML in a file; here
2623
+ // the row is JSON in SQLite, so a suffix would be a lie — the same
2624
+ // conclusion the Postgres backend reached. Anything that filters this
2625
+ // column on `.yaml` is a bug that returns empty, which reads as "there is
2626
+ // nothing here". See docs/canon/architecture/vault/entity-keys.md.
2627
+ //
2628
+ // Three content columns and exactly one is ever set, enforced by CHECK
2629
+ // rather than convention: a path is text, or an object, or bytes. Storing
2630
+ // objects as TEXT rather than BLOB is what lets `json_extract` push kind
2631
+ // and tag filters down into SQLite instead of reading every row and
2632
+ // filtering in JS — the local equivalent of the pg backend's GIN
2633
+ // containment, and the difference between a desk that opens and one that
2634
+ // stalls on a real mailbox.
2635
+ [
2636
+ `CREATE TABLE IF NOT EXISTS vault_object (
2637
+ path TEXT PRIMARY KEY,
2638
+ body TEXT,
2639
+ text TEXT,
2640
+ bytes BLOB,
2641
+ updated_at INTEGER NOT NULL,
2642
+ CHECK ((body IS NOT NULL) + (text IS NOT NULL) + (bytes IS NOT NULL) = 1)
2643
+ )`,
2644
+ // Kind is the only folder axis, so it is the only generated column worth
2645
+ // indexing. Tags are matched through json_each, which needs no index at
2646
+ // this corpus size and would need a shadow table to have one.
2647
+ `CREATE INDEX IF NOT EXISTS vault_by_kind
2648
+ ON vault_object (json_extract(body, '$.kind'))
2649
+ WHERE body IS NOT NULL`,
2650
+ // Prefix scans (`listFiles`, and every query bounded by an eco dir) are
2651
+ // range scans on the primary key, so no extra index is needed — but the
2652
+ // append log is queried by path too and benefits from recency ordering.
2653
+ `CREATE TABLE IF NOT EXISTS vault_append (
2654
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
2655
+ path TEXT NOT NULL,
2656
+ line TEXT NOT NULL,
2657
+ created_at INTEGER NOT NULL
2658
+ )`,
2659
+ `CREATE INDEX IF NOT EXISTS vault_append_by_path
2660
+ ON vault_append (path, seq)`
2661
+ ],
2662
+ // Migration 3 — the Change log.
2663
+ //
2664
+ // The full Change rides in `body` as JSON; the columns beside it are the
2665
+ // ones queried. `id` is the content hash, so the primary key is doing
2666
+ // integrity work as well as identity — appending the same Change twice is
2667
+ // a no-op rather than a fork.
2668
+ //
2669
+ // UNIQUE (origin_atlas, lamport) is the load-bearing constraint. Two
2670
+ // Changes at the same lamport from one origin IS a forked chain, and the
2671
+ // database refusing it is better than `verifyChain` discovering it later
2672
+ // on a peer's machine.
2673
+ [
2674
+ `CREATE TABLE IF NOT EXISTS change_log (
2675
+ id TEXT PRIMARY KEY,
2676
+ changeset_id TEXT NOT NULL,
2677
+ origin_atlas TEXT NOT NULL,
2678
+ lamport INTEGER NOT NULL,
2679
+ ts TEXT NOT NULL,
2680
+ kind TEXT NOT NULL,
2681
+ target TEXT NOT NULL,
2682
+ eco TEXT NOT NULL,
2683
+ state TEXT NOT NULL,
2684
+ body TEXT NOT NULL,
2685
+ created_at INTEGER NOT NULL,
2686
+ UNIQUE (origin_atlas, lamport)
2687
+ )`,
2688
+ // Reading a chain, and finding the head to extend it.
2689
+ `CREATE INDEX IF NOT EXISTS change_by_origin
2690
+ ON change_log (origin_atlas, lamport)`,
2691
+ // "What happened to this entity" — the audit view, and how a canvas
2692
+ // shows its own history.
2693
+ `CREATE INDEX IF NOT EXISTS change_by_target
2694
+ ON change_log (target, lamport)`
2695
+ ],
2696
+ // Migration 4 — lexical search.
2697
+ //
2698
+ // FTS5 with porter stemming, so 'discharge' finds 'discharging'. This is
2699
+ // the whole search story locally and that is not a compromise:
2700
+ // SEARCH_EMBEDDINGS_DISABLED=1 is the default on every box shipped today,
2701
+ // so lexical-only is the shipped reality rather than a downgrade.
2702
+ //
2703
+ // An external-content table was considered and rejected. It would avoid
2704
+ // storing the text twice, but it requires triggers kept in lockstep with
2705
+ // vault_object and silently returns wrong rows when they drift — and what
2706
+ // gets indexed is rarely the raw body anyway (an email's text is its
2707
+ // subject plus its parsed plain-text part, not its JSON).
2708
+ [
2709
+ `CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5 (
2710
+ path UNINDEXED,
2711
+ title,
2712
+ text,
2713
+ tokenize = 'porter unicode61'
2714
+ )`
2715
+ ],
2716
+ // v4 — the reconcile ledger for files WE DO NOT OWN.
2717
+ //
2718
+ // When a person's own folder is the record and we are the index beside it,
2719
+ // "has this file changed" cannot be answered from the file: markdown carries
2720
+ // no version and no Lamport clock. So it is answered by content hash against
2721
+ // what we last saw.
2722
+ //
2723
+ // `origin` is the whole reason this is a table rather than a hash cache.
2724
+ // `observed` means the person wrote it; `applied` means WE wrote it while
2725
+ // projecting an inbound Change. Without that distinction our own write fires
2726
+ // the watcher, looks like a local edit, mints a second Change, and two
2727
+ // devices ping-pong a file between them forever.
2728
+ [
2729
+ `CREATE TABLE IF NOT EXISTS file_index (
2730
+ path TEXT PRIMARY KEY,
2731
+ sha256 TEXT NOT NULL,
2732
+ origin TEXT NOT NULL CHECK (origin IN ('observed', 'applied')),
2733
+ updated_at INTEGER NOT NULL
2734
+ )`,
2735
+ `CREATE INDEX IF NOT EXISTS file_index_sha ON file_index (sha256)`
2736
+ ],
2737
+ // v6 — which circle each search row belongs to (Spec 08 §5).
2738
+ //
2739
+ // A side table rather than a column on `search_index`: an FTS5 table cannot
2740
+ // gain a column without being rebuilt, and a query that filters by circle is
2741
+ // a join on `path` either way. The filter is applied in the query, never
2742
+ // left to a caller to remember.
2743
+ [
2744
+ `CREATE TABLE IF NOT EXISTS search_circle (
2745
+ path TEXT PRIMARY KEY,
2746
+ eco TEXT NOT NULL
2747
+ )`,
2748
+ `CREATE INDEX IF NOT EXISTS search_circle_eco ON search_circle (eco)`
2749
+ ],
2750
+ // v7 — sealed blobs this device minted, and which Change references each
2751
+ // (Spec 08 §3, "Offline"; HQ-1219).
2752
+ //
2753
+ // The relay forgets a blob after 3 days while the Change that names it is
2754
+ // re-sent for as long as the peer takes to acknowledge. The sender cannot
2755
+ // re-seal the file (a new seal is a new hash, and the Change already names
2756
+ // the old one), so it keeps the CIPHERTEXT it uploaded and puts it back on
2757
+ // every send. Ciphertext only: the content key travels inside the sealed
2758
+ // Change payload and is not stored here.
2759
+ [
2760
+ `CREATE TABLE IF NOT EXISTS blob_store (
2761
+ hash TEXT PRIMARY KEY,
2762
+ ciphertext BLOB NOT NULL,
2763
+ created_at INTEGER NOT NULL
2764
+ )`,
2765
+ `CREATE TABLE IF NOT EXISTS change_blob (
2766
+ change_id TEXT NOT NULL,
2767
+ hash TEXT NOT NULL,
2768
+ PRIMARY KEY (change_id, hash)
2769
+ )`
2770
+ ],
2771
+ // v8 — what a file looked like on disk when its hash was taken (HQ-1219).
2772
+ //
2773
+ // `<size>:<mtime in ns>`, from the listing that preceded the read. A scan
2774
+ // that finds the same stamp reuses the stored hash instead of reading the
2775
+ // file again, which is what turns a tick on a 3,300 file record from reading
2776
+ // every byte into reading only what changed. Null until a scan that could
2777
+ // stat the file has hashed it, and null always means "read it".
2778
+ [`ALTER TABLE file_index ADD COLUMN stamp TEXT`]
2779
+ ];
2780
+ var MIGRATION_TABLE = `CREATE TABLE IF NOT EXISTS atlas_local_migration (
2781
+ version INTEGER PRIMARY KEY,
2782
+ applied_at INTEGER NOT NULL
2783
+ )`;
2784
+ async function migrate(sql, now) {
2785
+ await sql.execute(MIGRATION_TABLE);
2786
+ const applied = await sql.query(
2787
+ "SELECT version FROM atlas_local_migration ORDER BY version"
2788
+ );
2789
+ const current = applied.length;
2790
+ if (current > MIGRATIONS.length) {
2791
+ throw new Error(
2792
+ `store is at schema v${current} but this build knows v${MIGRATIONS.length} \u2014 it was written by a newer Xania. Refusing to downgrade.`
2793
+ );
2794
+ }
2795
+ for (let index = current; index < MIGRATIONS.length; index++) {
2796
+ const version = index + 1;
2797
+ const statements = (MIGRATIONS[index] ?? []).map((s) => ({ sql: s }));
2798
+ await sql.batch([
2799
+ ...statements,
2800
+ {
2801
+ sql: "INSERT INTO atlas_local_migration (version, applied_at) VALUES (?, ?)",
2802
+ params: [version, now]
2803
+ }
2804
+ ]);
2805
+ }
2806
+ return MIGRATIONS.length;
2807
+ }
2808
+
2809
+ // ../atlas-local/src/search.ts
2810
+ function indexLocation(home, recordId) {
2811
+ if (!recordId || /[/\\]/.test(recordId) || recordId === "." || recordId === "..") {
2812
+ throw new Error(`not a record id: ${recordId}`);
2813
+ }
2814
+ return `${home.replace(/\/+$/, "")}/.evda/${recordId}`;
2815
+ }
2816
+ function assertIndexOutsideRecord(location, recordRoot) {
2817
+ const root = recordRoot.replace(/\/+$/, "");
2818
+ if (location === root || location.startsWith(`${root}/`)) {
2819
+ throw new Error(`refusing an index inside the record (${location} is under ${root})`);
2820
+ }
2821
+ }
2822
+
2823
+ // ../atlas-local/src/drops.ts
2824
+ var DROP_DOMAIN = "xania/drop/v1";
2825
+ var DROP_POLL_DOMAIN = "xania/drop-poll/v1";
2826
+ var EMPTY_SALT = new Uint8Array(0);
2827
+ function dropInfo(domain, atlasId) {
2828
+ const d = utf8Encode(domain);
2829
+ const to = utf8Encode(`to:${atlasId}`);
2830
+ const out = new Uint8Array(d.length + 1 + to.length);
2831
+ out.set(d, 0);
2832
+ out[d.length] = 0;
2833
+ out.set(to, d.length + 1);
2834
+ return out;
2835
+ }
2836
+ function base64url2(bytes) {
2837
+ return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2838
+ }
2839
+ async function dropAddressFor(host, sealingPublicKey, atlasId) {
2840
+ if (sealingPublicKey.length !== 32) {
2841
+ throw new Error("dropAddress: sealing public key must be 32 raw bytes");
2842
+ }
2843
+ if (!atlasId || atlasId.includes("|")) {
2844
+ throw new Error("dropAddress: atlasId must be non-empty and contain no '|'");
2845
+ }
2846
+ return base64url2(
2847
+ await host.crypto.hkdf(sealingPublicKey, EMPTY_SALT, dropInfo(DROP_DOMAIN, atlasId), 32)
2848
+ );
2849
+ }
2850
+ async function ownDropAddress(deps) {
2851
+ const sealing = await deps.host.crypto.sealPublicKey(deps.keyId);
2852
+ if (!sealing) throw new Error("no sealing key: cannot derive a drop address");
2853
+ return dropAddressFor(deps.host, sealing, deps.atlasId);
2854
+ }
2855
+ function canonicalDropPollString(p) {
2856
+ const issuedAt = String(p.issuedAt);
2857
+ for (const [name, f] of [["atlasId", p.atlasId], ["issuedAt", issuedAt]]) {
2858
+ if (!f || f.includes("|")) {
2859
+ throw new Error(`dropPoll: ${name} must be non-empty and contain no '|'`);
2860
+ }
2861
+ }
2862
+ return [DROP_POLL_DOMAIN, p.atlasId, issuedAt].join("|");
2863
+ }
2864
+ function canonicalClaimString(p) {
2865
+ return ["xania/invite-mint/v1", p.nonce, p.atlasId, p.identityPublicKey, p.sealingPublicKey].join("|");
2866
+ }
2867
+ async function claimDropBox(deps, relayUrl) {
2868
+ const { host, keyId, atlasId } = deps;
2869
+ const identityRaw = await host.crypto.publicKey(keyId);
2870
+ const sealingRaw = await host.crypto.sealPublicKey(keyId);
2871
+ if (!identityRaw || !sealingRaw) throw new Error("claimDropBox: identity or sealing key missing");
2872
+ const identityPublicKey = base64(identityRaw);
2873
+ const sealingPublicKey = base64(sealingRaw);
2874
+ const nonce = `dcn_${base64url2(await host.crypto.randomBytes(12))}`;
2875
+ const proof = base64(
2876
+ await host.crypto.sign(
2877
+ keyId,
2878
+ utf8Encode(canonicalClaimString({ nonce, atlasId, identityPublicKey, sealingPublicKey }))
2879
+ )
2880
+ );
2881
+ const relay = new RelayClient(host, relayUrl);
2882
+ const claimed = await relay.dropClaim({
2883
+ atlasId,
2884
+ sealingPublicKey,
2885
+ identityPublicKey,
2886
+ nonce,
2887
+ proof
2888
+ });
2889
+ if (claimed.alreadyClaimed) return { address: claimed.address, alreadyClaimed: true };
2890
+ if (!claimed.claimId || !claimed.envelope) {
2891
+ throw new Error("claimDropBox: relay issued no possession challenge");
2892
+ }
2893
+ const nonceBack = utf8Decode(
2894
+ await host.crypto.unseal(keyId, fromBase64(claimed.envelope))
2895
+ );
2896
+ await relay.dropClaimConfirm(claimed.claimId, nonceBack);
2897
+ return { address: claimed.address, alreadyClaimed: false };
2898
+ }
2899
+ async function drainDrops(deps, relayUrl) {
2900
+ const { host, keyId, atlasId } = deps;
2901
+ const address = await ownDropAddress(deps);
2902
+ const issuedAt = host.clock.now();
2903
+ const proof = base64(
2904
+ await host.crypto.sign(keyId, utf8Encode(canonicalDropPollString({ atlasId, issuedAt })))
2905
+ );
2906
+ const relay = new RelayClient(host, relayUrl);
2907
+ const drained = await relay.dropDrain(address, { atlasId, issuedAt, proof });
2908
+ const collected = [];
2909
+ let unopenable = 0;
2910
+ for (const item of drained.items) {
2911
+ try {
2912
+ collected.push({
2913
+ id: item.id,
2914
+ receivedAt: item.receivedAt,
2915
+ bytes: await host.crypto.unseal(keyId, fromBase64(item.body))
2916
+ });
2917
+ } catch {
2918
+ unopenable += 1;
2919
+ }
2920
+ }
2921
+ return { collected, unopenable };
2922
+ }
2923
+
2924
+ // ../atlas-local/src/sync.ts
2925
+ var SyncEngine = class {
2926
+ constructor(deps, options = {}) {
2927
+ this.deps = deps;
2928
+ this.intervalMs = options.intervalMs ?? 3e4;
2929
+ this.rotateAfterPairing = options.rotateAfterPairing ?? true;
2930
+ this.dropRelayUrl = options.dropRelayUrl ?? null;
2931
+ }
2932
+ status = "idle";
2933
+ lastAttemptAt = null;
2934
+ lastSuccessAt = null;
2935
+ consecutiveFailures = 0;
2936
+ lastError = null;
2937
+ nextDueAt = 0;
2938
+ running = false;
2939
+ intervalMs;
2940
+ rotateAfterPairing;
2941
+ dropRelayUrl;
2942
+ /** Claiming is idempotent, so this is an optimisation rather than
2943
+ * correctness — it keeps a round trip out of every cycle, not a second box
2944
+ * out of existence. */
2945
+ dropBoxClaimed = false;
2946
+ get host() {
2947
+ return this.deps.host;
2948
+ }
2949
+ async state() {
2950
+ const counts = await new Outbox(this.host).counts();
2951
+ return {
2952
+ status: this.status,
2953
+ lastAttemptAt: this.lastAttemptAt,
2954
+ lastSuccessAt: this.lastSuccessAt,
2955
+ consecutiveFailures: this.consecutiveFailures,
2956
+ lastError: this.lastError,
2957
+ nextDueAt: this.nextDueAt,
2958
+ // `relayed` counts too: the relay took it, the peer has not acknowledged,
2959
+ // and a person asking "has it gone?" is owed the honest answer.
2960
+ owed: counts.pending + counts.relayed,
2961
+ peers: (await new Peers(this.deps.storage).list()).length,
2962
+ pendingInvites: (await new PendingInvites(this.deps.storage).list()).length
2963
+ };
2964
+ }
2965
+ /**
2966
+ * Run a cycle if one is due. Safe to call as often as a shell likes.
2967
+ *
2968
+ * The re-entrancy guard is load-bearing rather than tidy: a tick that spans
2969
+ * several round trips can easily outlive its own interval, and two concurrent
2970
+ * cycles would double-post from the outbox and race each other's
2971
+ * acknowledgements.
2972
+ */
2973
+ async tick() {
2974
+ if (this.running) return { ran: false, skipped: "already running" };
2975
+ const now = this.host.clock.now();
2976
+ if (now < this.nextDueAt) return { ran: false, skipped: "not due" };
2977
+ return this.run();
2978
+ }
2979
+ /** Run now, whatever the schedule says — a person pressing sync. */
2980
+ async syncNow() {
2981
+ if (this.running) return { ran: false, skipped: "already running" };
2982
+ return this.run();
2983
+ }
2984
+ async run() {
2985
+ this.running = true;
2986
+ this.status = "syncing";
2987
+ const startedAt = this.host.clock.now();
2988
+ this.lastAttemptAt = startedAt;
2989
+ const report = { ran: true };
2990
+ try {
2991
+ report.pinned = await this.collectPending();
2992
+ const declarations = await deliverCircleDeclarations(this.deps);
2993
+ if (declarations.delivered + declarations.revoked + declarations.failed > 0) report.declarations = declarations;
2994
+ const keys = await deliverCircleKeys(this.deps);
2995
+ if (keys.delivered + keys.failed > 0) report.keys = keys;
2996
+ const catchUp = await deliverCatchUp(this.deps);
2997
+ if (catchUp.delivered + catchUp.failed > 0) report.catchUp = catchUp;
2998
+ report.sent = await sendDue(this.deps);
2999
+ report.received = await pollPeers(this.deps);
3000
+ const drops = await this.collectDrops();
3001
+ if (drops) report.drops = drops;
3002
+ const rotated = await this.rotateIfFirstPairing();
3003
+ if (rotated !== null) report.rotatedTo = rotated;
3004
+ this.consecutiveFailures = 0;
3005
+ this.lastError = null;
3006
+ this.lastSuccessAt = this.host.clock.now();
3007
+ this.status = "idle";
3008
+ this.nextDueAt = this.lastSuccessAt + this.intervalMs;
3009
+ } catch (error) {
3010
+ this.consecutiveFailures += 1;
3011
+ this.lastError = error instanceof Error ? error.message : String(error);
3012
+ this.status = "offline";
3013
+ this.nextDueAt = this.host.clock.now() + backoffMs(this.consecutiveFailures);
3014
+ report.error = this.lastError;
3015
+ } finally {
3016
+ this.running = false;
3017
+ }
3018
+ return report;
3019
+ }
3020
+ /**
3021
+ * Claim the drop box if it is not claimed yet, then drain it.
3022
+ *
3023
+ * Inside the try, so a drop relay that is down fails the cycle and takes the
3024
+ * same backoff as everything else — a lane that failed quietly would look
3025
+ * like an atlas nobody is sending to, which is indistinguishable from working.
3026
+ *
3027
+ * Returns null when no drop relay is configured, which is the default.
3028
+ */
3029
+ async collectDrops() {
3030
+ if (!this.dropRelayUrl) return null;
3031
+ if (!this.dropBoxClaimed) {
3032
+ await claimDropBox(this.deps, this.dropRelayUrl);
3033
+ this.dropBoxClaimed = true;
3034
+ }
3035
+ const { collected, unopenable } = await drainDrops(this.deps, this.dropRelayUrl);
3036
+ return { collected: collected.length, unopenable };
3037
+ }
3038
+ /**
3039
+ * Ask the relay about every invite still outstanding.
3040
+ *
3041
+ * One failure does not abandon the rest — invites may sit on different
3042
+ * relays, and one being unreachable says nothing about another.
3043
+ */
3044
+ async collectPending() {
3045
+ const invites = new PendingInvites(this.deps.storage);
3046
+ const pinned = [];
3047
+ const now = this.host.clock.now();
3048
+ for (const invite of await invites.list()) {
3049
+ if (invite.expiresAt <= now) {
3050
+ await invites.forget(invite.inviteId);
3051
+ continue;
3052
+ }
3053
+ try {
3054
+ const peer = await collectInvite(this.deps, {
3055
+ inviteId: invite.inviteId,
3056
+ relayUrl: invite.relayUrl
3057
+ });
3058
+ if (peer) {
3059
+ await invites.forget(invite.inviteId);
3060
+ pinned.push(peer.atlasId);
3061
+ }
3062
+ } catch {
3063
+ }
3064
+ }
3065
+ return pinned;
3066
+ }
3067
+ /**
3068
+ * Rotate once, at the first pairing.
3069
+ *
3070
+ * Version 0 seals the rotation notice and nothing else afterwards, which is
3071
+ * the pattern the rotation design argues for and which nothing previously
3072
+ * scheduled. Later rotations are a policy this does not yet have.
3073
+ */
3074
+ async rotateIfFirstPairing() {
3075
+ if (!this.rotateAfterPairing) return null;
3076
+ const version = await this.host.crypto.sealKeyVersion(this.deps.keyId);
3077
+ if (version !== 0) return null;
3078
+ const peers = await new Peers(this.deps.storage).list();
3079
+ if (peers.length === 0) return null;
3080
+ const rotation = await rotateSealingKey(this.deps);
3081
+ return rotation.version;
3082
+ }
3083
+ };
3084
+
3085
+ // ../atlas-local/src/node/host.ts
3086
+ import { DatabaseSync } from "node:sqlite";
3087
+ import {
3088
+ chmodSync,
3089
+ existsSync,
3090
+ mkdirSync,
3091
+ readdirSync,
3092
+ readFileSync,
3093
+ renameSync,
3094
+ rmSync,
3095
+ statSync,
3096
+ lstatSync,
3097
+ writeFileSync
3098
+ } from "node:fs";
3099
+ import {
3100
+ dirname as dirnamePath,
3101
+ join as joinPath,
3102
+ relative as relativePath,
3103
+ resolve as resolvePath,
3104
+ sep as pathSep
3105
+ } from "node:path";
3106
+ import {
3107
+ createCipheriv,
3108
+ createDecipheriv,
3109
+ createHash,
3110
+ createPrivateKey,
3111
+ createPublicKey,
3112
+ diffieHellman,
3113
+ generateKeyPairSync,
3114
+ hkdfSync,
3115
+ randomBytes,
3116
+ sign,
3117
+ verify
3118
+ } from "node:crypto";
3119
+ var TestClock = class {
3120
+ constructor(current = 17e11) {
3121
+ this.current = current;
3122
+ }
3123
+ now() {
3124
+ return this.current;
3125
+ }
3126
+ advance(ms) {
3127
+ this.current += ms;
3128
+ }
3129
+ };
3130
+ var SEAL_VERSION = 1;
3131
+ var SEAL_KDF_DOMAIN = Buffer.from("xania/seal/v1");
3132
+ function rawPublicKey(key) {
3133
+ const { x } = key.export({ format: "jwk" });
3134
+ return Buffer.from(x, "base64url");
3135
+ }
3136
+ function importX25519Public(raw) {
3137
+ return createPublicKey({
3138
+ key: { kty: "OKP", crv: "X25519", x: Buffer.from(raw).toString("base64url") },
3139
+ format: "jwk"
3140
+ });
3141
+ }
3142
+ function rawPrivateKey(key) {
3143
+ const { d } = key.export({ format: "jwk" });
3144
+ return Buffer.from(d, "base64url");
3145
+ }
3146
+ var X25519_PKCS8 = "302e020100300506032b656e04220420";
3147
+ var ED25519_PKCS8 = "302e020100300506032b657004220420";
3148
+ function importPrivateFromRaw(raw, prefixHex) {
3149
+ return createPrivateKey({
3150
+ key: Buffer.concat([Buffer.from(prefixHex, "hex"), Buffer.from(raw)]),
3151
+ format: "der",
3152
+ type: "pkcs8"
3153
+ });
3154
+ }
3155
+ function importX25519Private(raw) {
3156
+ return importPrivateFromRaw(raw, X25519_PKCS8);
3157
+ }
3158
+ function importEd25519Private(seed) {
3159
+ return importPrivateFromRaw(seed, ED25519_PKCS8);
3160
+ }
3161
+ function importEd25519Public(raw) {
3162
+ return createPublicKey({
3163
+ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(raw).toString("base64url") },
3164
+ format: "jwk"
3165
+ });
3166
+ }
3167
+ function openSealed(privateKey, envelope) {
3168
+ if (envelope.length < 1 + 32 + 16) {
3169
+ throw new Error(`envelope is ${envelope.length} bytes, too short to be one`);
3170
+ }
3171
+ if (envelope[0] !== SEAL_VERSION) {
3172
+ throw new Error(`envelope version ${envelope[0]} is not ${SEAL_VERSION}`);
3173
+ }
3174
+ const bytes = Buffer.from(envelope);
3175
+ const header = bytes.subarray(0, 1 + 32);
3176
+ const ephemeralPublic = bytes.subarray(1, 1 + 32);
3177
+ const body = bytes.subarray(1 + 32, bytes.length - 16);
3178
+ const tag = bytes.subarray(bytes.length - 16);
3179
+ const agreed = diffieHellman({
3180
+ privateKey,
3181
+ publicKey: importX25519Public(new Uint8Array(ephemeralPublic))
3182
+ });
3183
+ const { key, nonce } = sealDerive(
3184
+ agreed,
3185
+ new Uint8Array(ephemeralPublic),
3186
+ rawPublicKey(createPublicKey(privateKey))
3187
+ );
3188
+ const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
3189
+ decipher.setAAD(header, { plaintextLength: body.length });
3190
+ decipher.setAuthTag(tag);
3191
+ try {
3192
+ return new Uint8Array(Buffer.concat([decipher.update(body), decipher.final()]));
3193
+ } catch {
3194
+ throw new Error("unseal failed: not sealed to this key, or altered in transit");
3195
+ }
3196
+ }
3197
+ function sealDerive(shared, ephemeralPublic, recipientPublic) {
3198
+ const info = Buffer.concat([
3199
+ SEAL_KDF_DOMAIN,
3200
+ Buffer.from(ephemeralPublic),
3201
+ Buffer.from(recipientPublic)
3202
+ ]);
3203
+ const okm = Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), info, 44));
3204
+ return { key: okm.subarray(0, 32), nonce: okm.subarray(32, 44) };
3205
+ }
3206
+ async function sealTo(recipientPublicKey, plaintext) {
3207
+ const ephemeral = generateKeyPairSync("x25519");
3208
+ const ephemeralPublic = rawPublicKey(ephemeral.publicKey);
3209
+ const shared = diffieHellman({
3210
+ privateKey: ephemeral.privateKey,
3211
+ publicKey: importX25519Public(recipientPublicKey)
3212
+ });
3213
+ const header = Buffer.concat([Buffer.from([SEAL_VERSION]), ephemeralPublic]);
3214
+ const { key, nonce } = sealDerive(shared, ephemeralPublic, recipientPublicKey);
3215
+ const cipher = createCipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
3216
+ cipher.setAAD(header, { plaintextLength: plaintext.length });
3217
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
3218
+ return new Uint8Array(Buffer.concat([header, body, cipher.getAuthTag()]));
3219
+ }
3220
+ function bind(value) {
3221
+ if (typeof value === "boolean") return value ? 1 : 0;
3222
+ return value;
3223
+ }
3224
+ function diskFiles(root, clock2, options = {}) {
3225
+ const abs = (path) => {
3226
+ const full = resolvePath(root, path);
3227
+ if (full !== root && !full.startsWith(root + pathSep)) {
3228
+ throw new Error(`path escapes the files root: ${path}`);
3229
+ }
3230
+ return full;
3231
+ };
3232
+ const toRel = (full) => relativePath(root, full).split(pathSep).join("/");
3233
+ const statOf = (full) => {
3234
+ const st = statSync(full, { bigint: true });
3235
+ return {
3236
+ path: toRel(full),
3237
+ size: Number(st.size),
3238
+ modifiedAt: Number(st.mtimeMs) || clock2.now(),
3239
+ modifiedAtNs: st.mtimeNs.toString()
3240
+ };
3241
+ };
3242
+ return {
3243
+ read: async (path) => new Uint8Array(readFileSync(abs(path))),
3244
+ write: async (path, bytes) => {
3245
+ const full = abs(path);
3246
+ mkdirSync(dirnamePath(full), { recursive: true });
3247
+ writeFileSync(full, bytes);
3248
+ },
3249
+ remove: async (path) => {
3250
+ rmSync(abs(path), { force: true });
3251
+ },
3252
+ list: async (prefix, opts) => {
3253
+ const out = [];
3254
+ const walk = (dir) => {
3255
+ if (!existsSync(dir)) return;
3256
+ for (const name of readdirSync(dir).sort()) {
3257
+ const full = joinPath(dir, name);
3258
+ const st = lstatSync(full);
3259
+ if (st.isSymbolicLink()) continue;
3260
+ const rel = toRel(full);
3261
+ if (options.skip?.(rel, st)) continue;
3262
+ if (st.isDirectory()) {
3263
+ walk(full);
3264
+ continue;
3265
+ }
3266
+ if (!rel.startsWith(prefix)) continue;
3267
+ if (opts?.recursive !== true && rel.slice(prefix.length).includes("/")) continue;
3268
+ out.push(statOf(full));
3269
+ }
3270
+ };
3271
+ const slash = prefix.lastIndexOf("/");
3272
+ walk(slash < 0 ? root : abs(prefix.slice(0, slash)));
3273
+ return out;
3274
+ },
3275
+ stat: async (path) => {
3276
+ const full = abs(path);
3277
+ return existsSync(full) && statSync(full).isFile() ? statOf(full) : null;
3278
+ }
3279
+ };
3280
+ }
3281
+ function hkdfSha256(ikm, salt, info, length) {
3282
+ return new Uint8Array(
3283
+ hkdfSync("sha256", ikm, Buffer.from(salt), Buffer.from(info), length)
3284
+ );
3285
+ }
3286
+ var EMPTY_SALT2 = new Uint8Array(0);
3287
+ function sqliteVault(db) {
3288
+ db.exec(`CREATE TABLE IF NOT EXISTS host_key (
3289
+ slot TEXT PRIMARY KEY, -- 'id:<keyId>' or 'seal:<keyId>#<version>'
3290
+ pkcs8 BLOB NOT NULL -- the private half; the public is derived
3291
+ )`);
3292
+ const qGet = db.prepare("SELECT pkcs8 FROM host_key WHERE slot = ?");
3293
+ const qPut = db.prepare("INSERT OR REPLACE INTO host_key (slot, pkcs8) VALUES (?, ?)");
3294
+ const qDel = db.prepare("DELETE FROM host_key WHERE slot = ?");
3295
+ const qList = db.prepare("SELECT slot FROM host_key WHERE slot LIKE ?");
3296
+ return {
3297
+ kind: "sqlite",
3298
+ get: (slot) => {
3299
+ const row = qGet.get(slot);
3300
+ return row?.pkcs8 ? new Uint8Array(row.pkcs8) : null;
3301
+ },
3302
+ put: (slot, pkcs8) => {
3303
+ qPut.run(slot, pkcs8);
3304
+ },
3305
+ delete: (slot) => {
3306
+ qDel.run(slot);
3307
+ },
3308
+ slots: (prefix) => qList.all(`${prefix}%`).map((r) => r.slot)
3309
+ };
3310
+ }
3311
+ var deviceKeyFile = (stateDir) => joinPath(stateDir, "keys.json");
3312
+ var KEY_FILE_MODE = 384;
3313
+ function fileVault(db, options) {
3314
+ const path = resolvePath(options.path);
3315
+ const dir = dirnamePath(path);
3316
+ mkdirSync(dir, { recursive: true });
3317
+ const load = () => {
3318
+ if (!existsSync(path)) return {};
3319
+ const mode = statSync(path).mode & 511;
3320
+ if (mode !== KEY_FILE_MODE) {
3321
+ throw new Error(`key file ${path} is mode ${mode.toString(8)}; it must be ${KEY_FILE_MODE.toString(8)} and nothing else`);
3322
+ }
3323
+ const text = readFileSync(path, "utf8");
3324
+ return text.trim() === "" ? {} : JSON.parse(text);
3325
+ };
3326
+ const save = (all) => {
3327
+ const tmp = `${path}.tmp-${process.pid}`;
3328
+ writeFileSync(tmp, `${JSON.stringify(all, null, 2)}
3329
+ `, { mode: KEY_FILE_MODE });
3330
+ chmodSync(tmp, KEY_FILE_MODE);
3331
+ renameSync(tmp, path);
3332
+ chmodSync(path, KEY_FILE_MODE);
3333
+ };
3334
+ db.exec("CREATE TABLE IF NOT EXISTS host_key_slot (slot TEXT PRIMARY KEY)");
3335
+ const qAdd = db.prepare("INSERT OR IGNORE INTO host_key_slot (slot) VALUES (?)");
3336
+ const qDel = db.prepare("DELETE FROM host_key_slot WHERE slot = ?");
3337
+ const qList = db.prepare("SELECT slot FROM host_key_slot WHERE slot LIKE ?");
3338
+ return {
3339
+ kind: "file",
3340
+ get: (slot) => {
3341
+ const hex = load()[slot];
3342
+ return hex ? new Uint8Array(Buffer.from(hex, "hex")) : null;
3343
+ },
3344
+ put: (slot, pkcs8) => {
3345
+ const hex = Buffer.from(pkcs8).toString("hex");
3346
+ const all = load();
3347
+ all[slot] = hex;
3348
+ save(all);
3349
+ if (load()[slot] !== hex) throw new Error(`key file write of ${slot} did not read back`);
3350
+ qAdd.run(slot);
3351
+ },
3352
+ delete: (slot) => {
3353
+ const all = load();
3354
+ if (slot in all) {
3355
+ delete all[slot];
3356
+ save(all);
3357
+ }
3358
+ qDel.run(slot);
3359
+ },
3360
+ slots: (prefix) => qList.all(`${prefix}%`).map((r) => r.slot)
3361
+ };
3362
+ }
3363
+ function createNodeHost(options = {}) {
3364
+ const dbPath = options.dbPath ?? ":memory:";
3365
+ const db = new DatabaseSync(dbPath);
3366
+ db.exec("PRAGMA busy_timeout = 30000");
3367
+ db.exec(`
3368
+ CREATE TABLE IF NOT EXISTS host_seal_version (
3369
+ key_id TEXT PRIMARY KEY,
3370
+ version INTEGER NOT NULL
3371
+ );
3372
+ `);
3373
+ const vault = typeof options.keyVault === "function" ? options.keyVault(db) : options.keyVault ?? sqliteVault(db);
3374
+ const qVerGet = db.prepare("SELECT version FROM host_seal_version WHERE key_id = ?");
3375
+ const qVerPut = db.prepare(
3376
+ "INSERT OR REPLACE INTO host_seal_version (key_id, version) VALUES (?, ?)"
3377
+ );
3378
+ class KeyStore {
3379
+ constructor(prefix) {
3380
+ this.prefix = prefix;
3381
+ }
3382
+ #cache = /* @__PURE__ */ new Map();
3383
+ get(slot) {
3384
+ const hit = this.#cache.get(slot);
3385
+ if (hit) return hit;
3386
+ const pkcs8 = vault.get(`${this.prefix}:${slot}`);
3387
+ if (!pkcs8) return void 0;
3388
+ const privateKey = createPrivateKey({
3389
+ key: Buffer.from(pkcs8),
3390
+ format: "der",
3391
+ type: "pkcs8"
3392
+ });
3393
+ const pair = { privateKey, publicKey: createPublicKey(privateKey) };
3394
+ this.#cache.set(slot, pair);
3395
+ return pair;
3396
+ }
3397
+ set(slot, pair) {
3398
+ this.#cache.set(slot, pair);
3399
+ vault.put(
3400
+ `${this.prefix}:${slot}`,
3401
+ new Uint8Array(pair.privateKey.export({ format: "der", type: "pkcs8" }))
3402
+ );
3403
+ }
3404
+ has(slot) {
3405
+ return this.get(slot) !== void 0;
3406
+ }
3407
+ /** Returns whether anything was there — `dropRetiredSealKeys` counts on it.
3408
+ * Deletes from BOTH halves: a retired key that survives in the cache is a
3409
+ * key that is still readable, which is the whole thing rotation destroys. */
3410
+ delete(slot) {
3411
+ const existed = this.has(slot);
3412
+ this.#cache.delete(slot);
3413
+ vault.delete(`${this.prefix}:${slot}`);
3414
+ return existed;
3415
+ }
3416
+ /** Every slot this store holds. Reads the table rather than the cache, so a
3417
+ * fresh process sees the keys it persisted last time. */
3418
+ entries() {
3419
+ const out = [];
3420
+ for (const slot of vault.slots(`${this.prefix}:`)) {
3421
+ const bare = slot.slice(this.prefix.length + 1);
3422
+ const pair = this.get(bare);
3423
+ if (pair) out.push([bare, pair]);
3424
+ }
3425
+ return out;
3426
+ }
3427
+ }
3428
+ class VersionStore {
3429
+ get(keyId) {
3430
+ const row = qVerGet.get(keyId);
3431
+ return row?.version;
3432
+ }
3433
+ set(keyId, version) {
3434
+ qVerPut.run(keyId, version);
3435
+ }
3436
+ }
3437
+ const clock2 = options.clock ?? new TestClock();
3438
+ const files = /* @__PURE__ */ new Map();
3439
+ const secrets = new Map(Object.entries(options.secrets ?? {}));
3440
+ const keys = new KeyStore("id");
3441
+ const sealKeys = new KeyStore("seal");
3442
+ const sealVersions = new VersionStore();
3443
+ const sealSlot = (keyId, version) => `${keyId}#${version}`;
3444
+ const circleKeys = new KeyStore("circle");
3445
+ const circleVersionKey = (circleId) => `circle:${circleId}`;
3446
+ const heldEpochs = (circleId) => circleKeys.entries().map(([slot]) => slot.split("#")).filter(([id]) => id === circleId).map(([, epoch]) => Number(epoch)).sort((a, b) => b - a);
3447
+ const unsealWithDevice = (keyId, envelope) => {
3448
+ const current = sealVersions.get(keyId) ?? 0;
3449
+ for (let version = current; version >= 0; version -= 1) {
3450
+ const pair = sealKeys.get(sealSlot(keyId, version));
3451
+ if (!pair) continue;
3452
+ try {
3453
+ return openSealed(pair.privateKey, envelope);
3454
+ } catch {
3455
+ }
3456
+ }
3457
+ throw new Error("unseal failed: not sealed to this key, or altered in transit");
3458
+ };
3459
+ let httpHandler = null;
3460
+ const exec = (sql, params = []) => {
3461
+ const result = db.prepare(sql).run(...params.map(bind));
3462
+ return {
3463
+ rowsAffected: Number(result.changes),
3464
+ lastInsertId: result.lastInsertRowid === void 0 || Number(result.lastInsertRowid) === 0 ? null : Number(result.lastInsertRowid)
3465
+ };
3466
+ };
3467
+ return {
3468
+ info: { platform: "web", appVersion: "0.0.0-test", dataDir: "/tmp/atlas-local-test" },
3469
+ sql: {
3470
+ query: async (sql, params = []) => db.prepare(sql).all(...params.map(bind)),
3471
+ execute: async (sql, params = []) => exec(sql, params),
3472
+ batch: async (statements) => {
3473
+ db.exec("BEGIN");
3474
+ try {
3475
+ const results = statements.map((s) => exec(s.sql, s.params ?? []));
3476
+ db.exec("COMMIT");
3477
+ return results;
3478
+ } catch (error) {
3479
+ db.exec("ROLLBACK");
3480
+ throw error;
3481
+ }
3482
+ }
3483
+ },
3484
+ files: options.filesRoot ? diskFiles(resolvePath(options.filesRoot), clock2) : {
3485
+ read: async (path) => {
3486
+ const bytes = files.get(path);
3487
+ if (!bytes) throw new Error(`no such file: ${path}`);
3488
+ return bytes;
3489
+ },
3490
+ write: async (path, bytes) => {
3491
+ files.set(path, bytes);
3492
+ },
3493
+ remove: async (path) => {
3494
+ files.delete(path);
3495
+ },
3496
+ list: async (prefix, opts) => [...files.entries()].filter(([path]) => path.startsWith(prefix)).filter(([path]) => opts?.recursive === true || !path.slice(prefix.length).includes("/")).map(([path, bytes]) => ({ path, size: bytes.length, modifiedAt: clock2.now() })),
3497
+ stat: async (path) => {
3498
+ const bytes = files.get(path);
3499
+ return bytes ? { path, size: bytes.length, modifiedAt: clock2.now() } : null;
3500
+ }
3501
+ },
3502
+ http: {
3503
+ request: async (req) => {
3504
+ if (httpHandler) return httpHandler(req);
3505
+ if (options.network) {
3506
+ const res = await fetch(req.url, {
3507
+ method: req.method,
3508
+ ...req.headers ? { headers: { ...req.headers } } : {},
3509
+ ...req.body ? { body: Buffer.from(req.body) } : {}
3510
+ });
3511
+ return {
3512
+ status: res.status,
3513
+ headers: Object.fromEntries(res.headers.entries()),
3514
+ body: new Uint8Array(await res.arrayBuffer())
3515
+ };
3516
+ }
3517
+ throw new Error(`node-host makes no network calls (attempted ${req.method} ${req.url})`);
3518
+ }
3519
+ },
3520
+ crypto: {
3521
+ // Ed25519 is REAL here, and was not always: it began as a stable hash
3522
+ // per key id, on the reasoning that the genuine signing path lives in
3523
+ // the Rust host. That reasoning held exactly until core code had to
3524
+ // REJECT a signature. A stub whose `verify` answers "true for anything
3525
+ // 64 bytes long" admits a forgery, and the test that should have caught
3526
+ // it passes — the same shape of hole the relay design keeps naming.
3527
+ // Core code that decides whether to trust a Change needs a host that
3528
+ // genuinely refuses.
3529
+ ensureKey: async (keyId) => {
3530
+ let pair = keys.get(keyId);
3531
+ if (!pair) {
3532
+ pair = generateKeyPairSync("ed25519");
3533
+ keys.set(keyId, pair);
3534
+ }
3535
+ return new Uint8Array(rawPublicKey(pair.publicKey));
3536
+ },
3537
+ publicKey: async (keyId) => {
3538
+ const pair = keys.get(keyId);
3539
+ return pair ? new Uint8Array(rawPublicKey(pair.publicKey)) : null;
3540
+ },
3541
+ sign: async (keyId, message2) => {
3542
+ const pair = keys.get(keyId);
3543
+ if (!pair) throw new Error(`no such key: ${keyId}`);
3544
+ return new Uint8Array(sign(null, Buffer.from(message2), pair.privateKey));
3545
+ },
3546
+ verify: async (publicKey, message2, signature) => {
3547
+ try {
3548
+ return verify(
3549
+ null,
3550
+ Buffer.from(message2),
3551
+ importEd25519Public(publicKey),
3552
+ Buffer.from(signature)
3553
+ );
3554
+ } catch {
3555
+ return false;
3556
+ }
3557
+ },
3558
+ sha256: async (bytes) => new Uint8Array(createHash("sha256").update(Buffer.from(bytes)).digest()),
3559
+ randomBytes: async (count) => new Uint8Array(randomBytes(count)),
3560
+ // Sealing IS real here, unlike the signing above, and for a reason: core
3561
+ // code that handles envelopes needs a host whose `unseal` genuinely
3562
+ // FAILS for the wrong recipient and for altered bytes. A stub that
3563
+ // round-trips would let a mailbox that mixes up recipients pass its own
3564
+ // tests. Node has X25519 and ChaCha20-Poly1305, so honesty is ~15 lines.
3565
+ //
3566
+ // Interop with the Rust host is NOT claimed and is not needed — both
3567
+ // ends of a real envelope are the same implementation. The layout is
3568
+ // kept identical only so the two read the same way.
3569
+ ensureSealKey: async (keyId) => {
3570
+ const version = sealVersions.get(keyId) ?? 0;
3571
+ let pair = sealKeys.get(sealSlot(keyId, version));
3572
+ if (!pair) {
3573
+ pair = generateKeyPairSync("x25519");
3574
+ sealKeys.set(sealSlot(keyId, version), pair);
3575
+ }
3576
+ return rawPublicKey(pair.publicKey);
3577
+ },
3578
+ sealPublicKey: async (keyId) => {
3579
+ const pair = sealKeys.get(sealSlot(keyId, sealVersions.get(keyId) ?? 0));
3580
+ return pair ? rawPublicKey(pair.publicKey) : null;
3581
+ },
3582
+ sealKeyVersion: async (keyId) => sealVersions.get(keyId) ?? 0,
3583
+ // Version 0 always — see the contract for why an invite must not carry
3584
+ // the rotating key.
3585
+ sealAddressingPublicKey: async (keyId) => {
3586
+ const pair = sealKeys.get(sealSlot(keyId, 0));
3587
+ return pair ? new Uint8Array(rawPublicKey(pair.publicKey)) : null;
3588
+ },
3589
+ // Retire, never replace: a peer who has been offline is still sealing to
3590
+ // the version it last heard about. `dropRetiredSealKeys` is what actually
3591
+ // makes an epoch unreadable.
3592
+ rotateSealKey: async (keyId) => {
3593
+ const current = sealVersions.get(keyId) ?? 0;
3594
+ if (!sealKeys.has(sealSlot(keyId, current))) {
3595
+ sealKeys.set(sealSlot(keyId, current), generateKeyPairSync("x25519"));
3596
+ }
3597
+ const next = current + 1;
3598
+ const pair = generateKeyPairSync("x25519");
3599
+ sealKeys.set(sealSlot(keyId, next), pair);
3600
+ sealVersions.set(keyId, next);
3601
+ return { publicKey: new Uint8Array(rawPublicKey(pair.publicKey)), version: next };
3602
+ },
3603
+ // The forward-secrecy step, and the only irreversible one here. Version 0
3604
+ // survives regardless — addressing depends on it.
3605
+ dropRetiredSealKeys: async (keyId, keep) => {
3606
+ const current = sealVersions.get(keyId) ?? 0;
3607
+ if (current === 0) return 0;
3608
+ const floor = Math.max(0, current - keep);
3609
+ let dropped = 0;
3610
+ for (let version = 1; version < floor; version += 1) {
3611
+ if (sealKeys.delete(sealSlot(keyId, version))) dropped += 1;
3612
+ }
3613
+ return dropped;
3614
+ },
3615
+ seal: async (recipientPublicKey, plaintext) => sealTo(recipientPublicKey, plaintext),
3616
+ // Same envelope, a caller-supplied key. Shares `openSealed` with unseal so
3617
+ // the two cannot drift into disagreeing about the layout.
3618
+ newLinkKeypair: async () => {
3619
+ const pair = generateKeyPairSync("x25519");
3620
+ return {
3621
+ publicKey: new Uint8Array(rawPublicKey(pair.publicKey)),
3622
+ privateKey: new Uint8Array(rawPrivateKey(pair.privateKey))
3623
+ };
3624
+ },
3625
+ openWith: async (privateKey, envelope) => {
3626
+ if (privateKey.length !== 32) {
3627
+ throw new Error("x25519 private key must be 32 bytes");
3628
+ }
3629
+ return openSealed(importX25519Private(privateKey), envelope);
3630
+ },
3631
+ // Walk backwards from the current version to the addressing key. Mail on
3632
+ // a relay was sealed before this device rotated, and a peer that has been
3633
+ // offline is still using an older version.
3634
+ unseal: async (keyId, envelope) => {
3635
+ const current = sealVersions.get(keyId) ?? 0;
3636
+ let found = false;
3637
+ for (let version = current; version >= 0; version -= 1) {
3638
+ const pair = sealKeys.get(sealSlot(keyId, version));
3639
+ if (!pair) continue;
3640
+ found = true;
3641
+ try {
3642
+ return openSealed(pair.privateKey, envelope);
3643
+ } catch {
3644
+ }
3645
+ }
3646
+ if (!found) throw new Error(`no such seal key: ${keyId}`);
3647
+ throw new Error("unseal failed: not sealed to this key, or altered in transit");
3648
+ },
3649
+ // The shared secret never leaves this function — only a key derived for
3650
+ // the purpose `info` names. See the contract's doc comment for why that
3651
+ // is not fussiness.
3652
+ // Version 0 always. Deriving from the rotating key would move every
3653
+ // mailbox address on every rotation — see the contract.
3654
+ deriveShared: async (keyId, peerPublicKey, info) => {
3655
+ const pair = sealKeys.get(sealSlot(keyId, 0));
3656
+ if (!pair) throw new Error(`no such seal key: ${keyId}`);
3657
+ const shared = diffieHellman({
3658
+ privateKey: pair.privateKey,
3659
+ publicKey: importX25519Public(peerPublicKey)
3660
+ });
3661
+ return hkdfSha256(shared, EMPTY_SALT2, info, 32);
3662
+ },
3663
+ // HKDF-SHA256 alone, for key material the caller already holds — the
3664
+ // drop-lane address derives from a raw public key, so there is no ECDH to
3665
+ // do. Fixed to SHA-256 with no algorithm parameter, on purpose: a member
3666
+ // that took one would be a generic crypto escape hatch, and every future
3667
+ // host would implement a switch instead of a call.
3668
+ hkdf: async (ikm, salt, info, length) => {
3669
+ if (!Number.isInteger(length) || length <= 0) {
3670
+ throw new Error("hkdf: length must be a positive integer");
3671
+ }
3672
+ if (length > 255 * 32) throw new Error("hkdf: length exceeds 255 * 32 bytes");
3673
+ if (ikm.length === 0) throw new Error("hkdf: refusing to derive from empty key material");
3674
+ return hkdfSha256(ikm, salt, info, length);
3675
+ },
3676
+ // ── Circle keys ────────────────────────────────────────────────────
3677
+ // Stored in the host's key vault beside the device keys: SQLite rows by
3678
+ // default, which is the exposure Spec 08 §2 names, and the macOS Keychain
3679
+ // under `createDeviceHost` (ruling 5, HQ-1219).
3680
+ newCircleKey: async (circleId) => {
3681
+ const epoch = (sealVersions.get(circleVersionKey(circleId)) ?? 0) + 1;
3682
+ const pair = generateKeyPairSync("x25519");
3683
+ circleKeys.set(sealSlot(circleId, epoch), pair);
3684
+ sealVersions.set(circleVersionKey(circleId), epoch);
3685
+ return { publicKey: new Uint8Array(rawPublicKey(pair.publicKey)), epoch };
3686
+ },
3687
+ circleKey: async (circleId) => {
3688
+ const epoch = sealVersions.get(circleVersionKey(circleId));
3689
+ if (epoch === void 0) return null;
3690
+ const pair = circleKeys.get(sealSlot(circleId, epoch));
3691
+ return pair ? { publicKey: new Uint8Array(rawPublicKey(pair.publicKey)), epoch } : null;
3692
+ },
3693
+ sealCircleKeyTo: async (circleId, epoch, devicePublicKey) => {
3694
+ const pair = circleKeys.get(sealSlot(circleId, epoch));
3695
+ if (!pair) throw new Error(`no key for circle ${circleId} at epoch ${epoch}`);
3696
+ const sealed = await sealTo(devicePublicKey, rawPrivateKey(pair.privateKey));
3697
+ return sealed;
3698
+ },
3699
+ importCircleKey: async (keyId, circleId, epoch, sealedKey) => {
3700
+ if (!Number.isInteger(epoch) || epoch < 1) throw new Error(`circle epoch must be >= 1, got ${epoch}`);
3701
+ const scalar = unsealWithDevice(keyId, sealedKey);
3702
+ if (scalar.length !== 32) throw new Error("a circle key is 32 bytes");
3703
+ const privateKey = importX25519Private(scalar);
3704
+ const pair = { privateKey, publicKey: createPublicKey(privateKey) };
3705
+ circleKeys.set(sealSlot(circleId, epoch), pair);
3706
+ sealVersions.set(
3707
+ circleVersionKey(circleId),
3708
+ Math.max(sealVersions.get(circleVersionKey(circleId)) ?? 0, epoch)
3709
+ );
3710
+ return new Uint8Array(rawPublicKey(pair.publicKey));
3711
+ },
3712
+ openCircle: async (circleId, envelope) => {
3713
+ const epochs = heldEpochs(circleId);
3714
+ if (epochs.length === 0) throw new Error(`no key held for circle ${circleId}`);
3715
+ for (const epoch of epochs) {
3716
+ const pair = circleKeys.get(sealSlot(circleId, epoch));
3717
+ if (!pair) continue;
3718
+ try {
3719
+ return openSealed(pair.privateKey, envelope);
3720
+ } catch {
3721
+ }
3722
+ }
3723
+ throw new Error(`no held epoch of circle ${circleId} opens this envelope`);
3724
+ }
3725
+ },
3726
+ secrets: {
3727
+ get: async (key) => secrets.get(key) ?? null,
3728
+ set: async (key, value) => {
3729
+ secrets.set(key, value);
3730
+ },
3731
+ delete: async (key) => {
3732
+ secrets.delete(key);
3733
+ }
3734
+ },
3735
+ clock: clock2,
3736
+ installKey: (keyId, material) => {
3737
+ if (material.identity) {
3738
+ const privateKey = importEd25519Private(material.identity);
3739
+ keys.set(keyId, { privateKey, publicKey: createPublicKey(privateKey) });
3740
+ }
3741
+ if (material.sealing) {
3742
+ const privateKey = importX25519Private(material.sealing);
3743
+ sealKeys.set(sealSlot(keyId, 0), { privateKey, publicKey: createPublicKey(privateKey) });
3744
+ }
3745
+ for (const [version, scalar] of Object.entries(material.content ?? {})) {
3746
+ const privateKey = importX25519Private(scalar);
3747
+ sealKeys.set(sealSlot(keyId, Number(version)), {
3748
+ privateKey,
3749
+ publicKey: createPublicKey(privateKey)
3750
+ });
3751
+ sealVersions.set(keyId, Math.max(sealVersions.get(keyId) ?? 0, Number(version)));
3752
+ }
3753
+ },
3754
+ exportKey: (keyId) => {
3755
+ const identityPair = keys.get(keyId);
3756
+ const content = {};
3757
+ for (const [slot, pair] of sealKeys.entries()) {
3758
+ const [id, rawVersion] = slot.split("#");
3759
+ if (id !== keyId) continue;
3760
+ const version = Number(rawVersion);
3761
+ if (version > 0) content[version] = new Uint8Array(rawPrivateKey(pair.privateKey));
3762
+ }
3763
+ return {
3764
+ identity: identityPair ? new Uint8Array(rawPrivateKey(identityPair.privateKey)) : null,
3765
+ content,
3766
+ version: sealVersions.get(keyId) ?? 0
3767
+ };
3768
+ },
3769
+ serveHttp: (handler) => {
3770
+ httpHandler = handler;
3771
+ },
3772
+ keyVaultKind: vault.kind,
3773
+ close: () => db.close()
3774
+ };
3775
+ }
3776
+
3777
+ // ../atlas-local/src/node/person-record-dir.ts
3778
+ import { resolve } from "node:path";
3779
+ var PERSON_RECORD = resolve(import.meta.dirname, "../../../person-record");
3780
+
3781
+ // ../atlas-local/src/node/pairing.ts
3782
+ var PAIR_FALSIFY_MODES = ["no-expiry", "grant-all", "keep-grant", "no-rekey", "reuse-pairing"];
3783
+ var FALSIFY = process.env.EVDA_PAIR_FALSIFY ?? "";
3784
+ if (FALSIFY && !PAIR_FALSIFY_MODES.includes(FALSIFY)) throw new Error(`unknown EVDA_PAIR_FALSIFY=${FALSIFY}`);
3785
+ var layer = null;
3786
+ var personRecordRoot = PERSON_RECORD;
3787
+ function usePersonRecord(dir) {
3788
+ personRecordRoot = resolve2(dir);
3789
+ layer = null;
3790
+ }
3791
+ async function personLayer() {
3792
+ if (layer) return layer;
3793
+ const lib = (name) => import(pathToFileURL(join2(personRecordRoot, "lib", name)).href);
3794
+ const [fc, mf, gr, ci] = await Promise.all([lib("filechanges.mjs"), lib("manifest.mjs"), lib("grants.mjs"), lib("circles.mjs")]);
3795
+ layer = { ...fc, openEvda: mf.openEvda, grant: gr.grant, revoke: gr.revoke, readCircles: ci.readCircles };
3796
+ return layer;
3797
+ }
3798
+ var DEFAULT_RELAY = "https://evda-relay.fly.dev";
3799
+ var PAIR_CODE_TTL_MS = 10 * 60 * 1e3;
3800
+ var DEFAULT_DAY_DIR = "record/daily";
3801
+ var NO_PRIVATE_PARTS = "Your EV\xD0A has no private parts yet, so there is nothing to give a device. Split it into parts first with `evda circles declare --plan <file>`, then pair again.";
3802
+ var b64url = (s) => base64(utf8Encode(s)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3803
+ var unb64url = (s) => utf8Decode(fromBase64(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - s.length % 4) % 4)));
3804
+ function encodePairCode(c) {
3805
+ const fields = [c.inviteId, c.secret];
3806
+ if (c.relayUrl !== DEFAULT_RELAY || c.dayDir !== DEFAULT_DAY_DIR) fields.push(c.relayUrl);
3807
+ if (c.dayDir !== DEFAULT_DAY_DIR) fields.push(c.dayDir);
3808
+ return `evda1.${b64url(fields.join("\n"))}`;
3809
+ }
3810
+ function decodePairCode(code) {
3811
+ const bad = new Error("That is not a pairing code. Copy it again from the computer your EV\xD0A is on.");
3812
+ const trimmed = code.trim();
3813
+ const link = /^evda:\/\/pair\?c=([^&]*)(&.*)?$/s.exec(trimmed);
3814
+ const text = link ? link[1] : trimmed;
3815
+ if (!text.startsWith("evda1.")) throw bad;
3816
+ let fields;
3817
+ try {
3818
+ fields = unb64url(text.slice("evda1.".length)).split("\n");
3819
+ } catch {
3820
+ throw bad;
3821
+ }
3822
+ const [inviteId, secret, relayUrl, dayDir] = fields;
3823
+ if (!inviteId || !secret || fields.length > 4) throw bad;
3824
+ return { inviteId, secret, relayUrl: relayUrl || DEFAULT_RELAY, dayDir: dayDir || DEFAULT_DAY_DIR };
3825
+ }
3826
+ var pairQrPayload = (code) => `evda://pair?c=${code}`;
3827
+ async function openRecordDevice(o) {
3828
+ const pr = await personLayer();
3829
+ const recordDir = resolve2(o.recordDir);
3830
+ const evda = pr.openEvda(recordDir);
3831
+ const atlasId = evda.manifest.id;
3832
+ if (typeof atlasId !== "string" || atlasId === "") throw new Error(NO_PRIVATE_PARTS);
3833
+ const stateDir = indexLocation(resolve2(o.home), atlasId);
3834
+ assertIndexOutsideRecord(stateDir, recordDir);
3835
+ mkdirSync2(stateDir, { recursive: true });
3836
+ const host = createNodeHost({
3837
+ dbPath: join2(stateDir, "atlas.db"),
3838
+ clock: { now: () => Date.now() },
3839
+ network: true,
3840
+ filesRoot: recordDir,
3841
+ keyVault: (o.keys ?? "file") === "file" ? (db) => fileVault(db, { path: deviceKeyFile(stateDir) }) : sqliteVault
3842
+ });
3843
+ await migrate(host.sql, host.clock.now());
3844
+ await host.crypto.ensureKey("identity");
3845
+ const index = new FileIndex(host.sql);
3846
+ const log = new ChangeLog(host, atlasId, "identity");
3847
+ const sealer = pr.circleCrypto(host.crypto);
3848
+ const blobs = new BlobStore(host);
3849
+ const grants = pr.grantView(evda);
3850
+ const deps = { host, storage: new SqlStorage(host), atlasId, keyId: "identity", grants };
3851
+ const open = async (change, eco) => {
3852
+ const circle = pr.circleById(grants.index, eco);
3853
+ if (!circle) throw new Error(`no circle ${eco} in this record`);
3854
+ return sealer.open(circle, change.payload);
3855
+ };
3856
+ const relayUrl = o.relayUrl ?? DEFAULT_RELAY;
3857
+ deps.fold = new InboundFold({
3858
+ host,
3859
+ index,
3860
+ log,
3861
+ open,
3862
+ unionPath: pr.unionPaths(evda),
3863
+ merge: mergeMarkdown,
3864
+ localPath: (eco, address) => pr.addressing(grants.index).local(eco, address),
3865
+ homeOf: (rel) => pr.addressing(grants.index).homeOf(rel),
3866
+ foreign: (origin) => grants.isForeign(origin),
3867
+ fetchBlob: async (hash) => new RelayClient(host, relayUrl).findBlob(hash)
3868
+ });
3869
+ deps.catchUp = pr.catchUp({
3870
+ evda,
3871
+ view: () => grants,
3872
+ log,
3873
+ seal: (c, p) => sealer.seal(c, p),
3874
+ open,
3875
+ changesetId: async () => `cs_${toHex(await host.crypto.randomBytes(8))}`,
3876
+ unionPath: pr.unionPaths(evda),
3877
+ blobs: { seal: (b) => blobs.seal(b), link: (id, h) => blobs.link(id, h) }
3878
+ });
3879
+ return {
3880
+ atlasId,
3881
+ recordDir,
3882
+ stateDir,
3883
+ host,
3884
+ evda,
3885
+ deps,
3886
+ engine: new SyncEngine(deps, { intervalMs: 1 }),
3887
+ index,
3888
+ log,
3889
+ folds: [],
3890
+ refusals: [],
3891
+ close: () => host.close()
3892
+ };
3893
+ }
3894
+ async function scanRecord(d) {
3895
+ const pr = await personLayer();
3896
+ const view = d.deps.grants;
3897
+ const sealer = pr.circleCrypto(d.host.crypto);
3898
+ const blobs = new BlobStore(d.host);
3899
+ const minter = pr.fileMinter({
3900
+ log: d.log,
3901
+ index: view.index,
3902
+ seal: (c, p) => sealer.seal(c, p),
3903
+ notifyFor: (eco, id) => notifyFor(d.deps, eco, id),
3904
+ previousSha: async (path) => (await d.index.get(path))?.sha256 ?? null,
3905
+ changesetId: async () => `cs_${toHex(await d.host.crypto.randomBytes(8))}`,
3906
+ unionPath: pr.unionPaths(d.evda),
3907
+ readText: (rel) => existsSync2(join2(d.recordDir, rel)) ? readFileSync2(join2(d.recordDir, rel), "utf8") : null,
3908
+ blobs: { seal: (b) => blobs.seal(b), link: (id, h) => blobs.link(id, h) }
3909
+ });
3910
+ const total = { scanned: 0, minted: 0, removed: 0, ignored: 0, read: 0, echoes: 0, failed: [] };
3911
+ for (const { prefix, match } of pr.scanPrefixes(view.index, view.holds(d.atlasId))) {
3912
+ const r = await scanAndApply({ host: d.host, index: d.index }, { prefix, match, mint: minter.mint, mintRemoval: minter.mintRemoval });
3913
+ total.scanned += r.scanned;
3914
+ total.minted += r.minted;
3915
+ total.removed += r.removed;
3916
+ total.ignored += r.ignored;
3917
+ total.echoes += r.echoes;
3918
+ total.read += r.read;
3919
+ total.failed.push(...r.failed);
3920
+ }
3921
+ return total;
3922
+ }
3923
+ async function exchange(d) {
3924
+ const report = await d.engine.syncNow();
3925
+ d.deps.grants.reload();
3926
+ d.folds.push(...report.received?.folds ?? []);
3927
+ d.refusals.push(...report.received?.reasons ?? []);
3928
+ if (report.error) throw new Error(`sync failed: ${report.error}`);
3929
+ return report;
3930
+ }
3931
+ async function settle(d, rounds = 50) {
3932
+ for (let i = 0, calm = 0; i < rounds && calm < 2; i += 1) {
3933
+ const r = await exchange(d);
3934
+ const moved = (r.sent?.relayed ?? 0) + (r.received?.applied ?? 0) + (r.keys?.delivered ?? 0) + (r.received?.circleKeys ?? 0) + (r.declarations?.delivered ?? 0) + (r.catchUp?.delivered ?? 0) + (r.received?.acknowledged ?? 0) + (r.pinned?.length ?? 0);
3935
+ calm = moved === 0 ? calm + 1 : 0;
3936
+ }
3937
+ }
3938
+ var pairedFile = (d) => join2(d.stateDir, "paired-devices.json");
3939
+ function readPaired(d) {
3940
+ try {
3941
+ return JSON.parse(readFileSync2(pairedFile(d), "utf8"));
3942
+ } catch {
3943
+ return [];
3944
+ }
3945
+ }
3946
+ var writePaired = (d, list) => writeFileSync2(pairedFile(d), `${JSON.stringify(list, null, 2)}
3947
+ `, { mode: 384 });
3948
+ async function ownedCircles(d) {
3949
+ const out = [];
3950
+ for (const c of d.deps.grants.index) {
3951
+ if (c.public || c.mount) continue;
3952
+ if (await d.deps.grants.mayShareKey(d.atlasId, c.id)) out.push(c);
3953
+ }
3954
+ return out;
3955
+ }
3956
+ async function ensureCircleKeys(d) {
3957
+ for (const c of await ownedCircles(d)) if (!await d.host.crypto.circleKey(c.id)) await d.host.crypto.newCircleKey(c.id);
3958
+ }
3959
+ async function startPairing(d, opts) {
3960
+ const name = opts.name.trim();
3961
+ if (!name) throw new Error("Give the device a name, such as phone.");
3962
+ const list = readPaired(d);
3963
+ if (list.some((p) => p.name === name && !p.removedAt)) throw new Error(`There is already a device called ${name}. Remove it first, or pick another name.`);
3964
+ const owned = await ownedCircles(d);
3965
+ if (owned.length === 0) throw new Error(NO_PRIVATE_PARTS);
3966
+ const unknown = opts.circles.filter((c) => !owned.some((o) => o.name === c));
3967
+ if (unknown.length) throw new Error(`This record has no private part called ${unknown.join(", ")}. It has: ${owned.map((o) => o.name).join(", ")}.`);
3968
+ if (opts.circles.length === 0) throw new Error("Name at least one part of the record the device may read and add to.");
3969
+ await ensureCircleKeys(d);
3970
+ const ttlMs = opts.ttlMs ?? PAIR_CODE_TTL_MS;
3971
+ const link = await mintInvite(d.deps, opts.relayUrl ?? DEFAULT_RELAY, FALSIFY === "no-expiry" ? { label: name } : { label: name, ttlMs });
3972
+ await new PendingInvites(d.deps.storage).forget(link.inviteId);
3973
+ list.push({
3974
+ name,
3975
+ atlasId: null,
3976
+ circles: [...opts.circles],
3977
+ inviteId: link.inviteId,
3978
+ relayUrl: link.relayUrl,
3979
+ expiresAt: link.expiresAt,
3980
+ createdAt: new Date(d.host.clock.now()).toISOString()
3981
+ });
3982
+ writePaired(d, list);
3983
+ const code = encodePairCode({ relayUrl: link.relayUrl, inviteId: link.inviteId, secret: link.secret, dayDir: opts.dayDir ?? DEFAULT_DAY_DIR });
3984
+ return { name, code, qr: pairQrPayload(code), expiresAt: link.expiresAt, circles: [...opts.circles] };
3985
+ }
3986
+ async function collectPairings(d) {
3987
+ const pr = await personLayer();
3988
+ const list = readPaired(d);
3989
+ const paired = [];
3990
+ for (const p of list) {
3991
+ if (p.removedAt || p.atlasId && FALSIFY !== "reuse-pairing") continue;
3992
+ const peer = await collectInvite(d.deps, { inviteId: p.inviteId, relayUrl: p.relayUrl }).catch(() => null);
3993
+ if (!peer) continue;
3994
+ if (peer.atlasId === d.atlasId || d.deps.grants.index.some((c) => c.mount?.holder === peer.atlasId)) continue;
3995
+ const circles = FALSIFY === "grant-all" ? (await ownedCircles(d)).map((c) => c.name) : p.circles;
3996
+ const at = new Date(d.host.clock.now()).toISOString();
3997
+ for (const circle of circles) {
3998
+ pr.grant(d.evda, { circle, to: { id: peer.atlasId, kind: "person", name: p.name }, role: "editor", by: d.atlasId, at }, { overwrite: true });
3999
+ }
4000
+ p.atlasId = peer.atlasId;
4001
+ p.pairedAt = at;
4002
+ paired.push(p.name);
4003
+ }
4004
+ if (paired.length) {
4005
+ writePaired(d, list);
4006
+ d.deps.grants.reload();
4007
+ }
4008
+ return paired;
4009
+ }
4010
+ async function syncRecord(d) {
4011
+ const folds = d.folds.length;
4012
+ const refusals = d.refusals.length;
4013
+ await settle(d);
4014
+ const paired = await collectPairings(d);
4015
+ if (paired.length || (await ownedCircles(d)).length) await ensureCircleKeys(d).catch(() => void 0);
4016
+ const scan = await scanRecord(d);
4017
+ if (scan.failed.length) throw new Error(`could not read ${scan.failed.length} file(s): ${JSON.stringify(scan.failed[0])}`);
4018
+ await settle(d);
4019
+ return {
4020
+ paired,
4021
+ // What changed a file here. A Change for bytes already present ("already there", "logged") is
4022
+ // the other side's echo of what this device sent, and is not news to the person.
4023
+ received: d.folds.slice(folds).filter((f) => f.status === "applied" && !["already there", "logged", "kept", "absent"].includes(f.action)).length,
4024
+ sent: scan.minted + scan.removed,
4025
+ refused: d.refusals.slice(refusals)
4026
+ };
4027
+ }
4028
+ function listDevices(d, now = Date.now()) {
4029
+ return readPaired(d).map((p) => ({
4030
+ name: p.name,
4031
+ state: p.removedAt ? "removed" : p.atlasId ? "paired" : p.expiresAt <= now ? "code expired" : "waiting for the device to use its code",
4032
+ circles: p.circles,
4033
+ since: p.removedAt ?? p.pairedAt ?? p.createdAt
4034
+ }));
4035
+ }
4036
+ async function removeDevice(d, name) {
4037
+ const pr = await personLayer();
4038
+ const list = readPaired(d);
4039
+ const p = list.find((x) => x.name === name && !x.removedAt);
4040
+ if (!p) throw new Error(`There is no paired device called ${name}.`);
4041
+ const rekeyed = [];
4042
+ if (p.atlasId) {
4043
+ const granted = d.deps.grants.index.filter((c) => !c.public && !c.mount);
4044
+ for (const c of granted) {
4045
+ if (FALSIFY === "keep-grant") break;
4046
+ if (pr.revoke(d.evda, { circle: c.name, id: p.atlasId })) rekeyed.push(c.name);
4047
+ }
4048
+ d.deps.grants.reload();
4049
+ if (FALSIFY !== "no-rekey") for (const c of granted.filter((x) => rekeyed.includes(x.name))) await d.host.crypto.newCircleKey(c.id);
4050
+ }
4051
+ p.removedAt = new Date(d.host.clock.now()).toISOString();
4052
+ writePaired(d, list);
4053
+ return { name, rekeyed };
4054
+ }
4055
+ var JOINED_FILE = "joined.json";
4056
+ async function joinWithCode(code, o) {
4057
+ const c = decodePairCode(code);
4058
+ const dir = resolve2(o.dir);
4059
+ if (existsSync2(join2(dir, "evda.json"))) throw new Error(`${dir} already holds a record. Join into an empty folder.`);
4060
+ const atlasId = `device-${toHex(crypto.getRandomValues(new Uint8Array(8)))}.atlas`;
4061
+ mkdirSync2(join2(dir, "Mounts"), { recursive: true });
4062
+ mkdirSync2(join2(dir, "Circles"), { recursive: true });
4063
+ mkdirSync2(join2(dir, "Grants"), { recursive: true });
4064
+ mkdirSync2(join2(dir, ".evda"), { recursive: true });
4065
+ writeFileSync2(join2(dir, ".evda/version"), "1\n");
4066
+ writeFileSync2(join2(dir, "evda.json"), `${JSON.stringify({
4067
+ evda: 1,
4068
+ id: atlasId,
4069
+ name: "A paired device",
4070
+ owner: { name: "this device", kind: "person" },
4071
+ created: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
4072
+ layers: ["record", "circles"],
4073
+ mapping: { circles: "Circles", grants: "Grants", mounts: "Mounts" }
4074
+ }, null, 2)}
4075
+ `);
4076
+ const d = await openRecordDevice({ recordDir: dir, home: o.home, relayUrl: c.relayUrl, ...o.keys ? { keys: o.keys } : {} });
4077
+ try {
4078
+ await redeemInvite(d.deps, { inviteId: c.inviteId, secret: c.secret, relayUrl: c.relayUrl });
4079
+ } catch (error) {
4080
+ d.close();
4081
+ const status = error.status;
4082
+ if (status === 403 || status === 409 || /40[39]/.test(String(error))) {
4083
+ throw new Error("That code has expired or was already used. Make a new one on the computer your EV\xD0A is on.");
4084
+ }
4085
+ throw new Error(`Could not reach your EV\xD0A through the relay: ${error instanceof Error ? error.message : String(error)}`);
4086
+ }
4087
+ writeFileSync2(join2(d.stateDir, JOINED_FILE), `${JSON.stringify({ relayUrl: c.relayUrl, dayDir: c.dayDir }, null, 2)}
4088
+ `);
4089
+ return d;
4090
+ }
4091
+ async function openJoinedDevice(o) {
4092
+ const pr = await personLayer();
4093
+ const id = pr.openEvda(resolve2(o.dir)).manifest.id;
4094
+ const joined = JSON.parse(readFileSync2(join2(indexLocation(resolve2(o.home), id), JOINED_FILE), "utf8"));
4095
+ const d = await openRecordDevice({ recordDir: o.dir, home: o.home, relayUrl: joined.relayUrl, ...o.keys ? { keys: o.keys } : {} });
4096
+ return Object.assign(d, { dayDir: joined.dayDir });
4097
+ }
4098
+ var two = (n) => String(n).padStart(2, "0");
4099
+ var dayOf = (t) => `${t.getFullYear()}-${two(t.getMonth() + 1)}-${two(t.getDate())}`;
4100
+ function writeNote(d, text, o) {
4101
+ const now = o.now ?? /* @__PURE__ */ new Date();
4102
+ const held = d.deps.grants.index.filter((c) => c.mount && !c.frozen);
4103
+ if (held.length === 0) {
4104
+ throw new Error(d.deps.grants.index.some((c) => c.mount) ? "This device was removed from your EV\xD0A, so it cannot add to it anymore." : "This device has not received anything from your EV\xD0A yet. Sync once your computer has.");
4105
+ }
4106
+ const withDays = held.filter((c) => existsSync2(join2(d.recordDir, c.mount.rel, o.dayDir)));
4107
+ const target = withDays[0] ?? (held.length === 1 ? held[0] : void 0);
4108
+ if (!target) throw new Error("This device cannot tell which part of your EV\xD0A holds the journal. Pair it again with the journal included.");
4109
+ const rel = `${target.mount.rel}/${o.dayDir}/${dayOf(now)}.md`;
4110
+ const abs = join2(d.recordDir, rel);
4111
+ mkdirSync2(join2(abs, ".."), { recursive: true });
4112
+ const before2 = existsSync2(abs) ? readFileSync2(abs, "utf8") : `# ${dayOf(now)}
4113
+ `;
4114
+ writeFileSync2(abs, `${before2.replace(/\n*$/, "\n")}
4115
+ ## ${two(now.getHours())}:${two(now.getMinutes())}
4116
+
4117
+ ${text.replace(/\n*$/, "")}
4118
+ `);
4119
+ return rel;
4120
+ }
4121
+ async function heldParts(d) {
4122
+ const pr = await personLayer();
4123
+ const titles = new Map((pr.readCircles(d.evda) ?? []).map((c) => [c.name, c.manifest?.title ?? c.name]));
4124
+ const count = (abs) => {
4125
+ if (!existsSync2(abs)) return 0;
4126
+ let n = 0;
4127
+ for (const name of readdirSync2(abs)) {
4128
+ const p = join2(abs, name);
4129
+ n += statSync2(p).isDirectory() ? count(p) : 1;
4130
+ }
4131
+ return n;
4132
+ };
4133
+ return d.deps.grants.index.filter((c) => c.mount).map((c) => ({ name: titles.get(c.name) ?? c.name, files: count(join2(d.recordDir, c.mount.rel)), removed: c.frozen !== null }));
4134
+ }
4135
+
4136
+ // ../atlas-local/src/node/pair-owner.ts
4137
+ function computerName(env = process.env) {
4138
+ const pick = (s) => (s ?? "").replace(/\s+/g, " ").trim().slice(0, 64);
4139
+ if (pick(env.EVDA_COMPUTER_NAME)) return pick(env.EVDA_COMPUTER_NAME);
4140
+ if (process.platform === "darwin") {
4141
+ try {
4142
+ const n = pick(execFileSync("/usr/sbin/scutil", ["--get", "ComputerName"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2e3 }));
4143
+ if (n) return n;
4144
+ } catch {
4145
+ }
4146
+ }
4147
+ return pick(hostname().replace(/\.local$/, "")) || "your computer";
4148
+ }
4149
+ var namedQr = (qr, computer) => `${qr}&n=${encodeURIComponent(computer)}`;
4150
+ var clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
4151
+ var when = (iso) => new Date(iso).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
4152
+ var PLAIN = /^(Your EVÐA|This record|There is|Give the device|Name at least|That code|This device|Could not reach)/;
4153
+ function plain(error) {
4154
+ const message2 = error instanceof Error ? error.message : String(error);
4155
+ if (PLAIN.test(message2)) return new Error(message2);
4156
+ if (/^sync failed|fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network|relay/i.test(message2)) {
4157
+ return new Error("Could not reach the relay that carries your EV\xD0A between devices. Check the connection and try again.");
4158
+ }
4159
+ return new Error(`Pairing stopped: ${message2.replace(/[0-9a-f]{16,}|[A-Za-z0-9_-]{32,}|\S+\.atlas\b|inv_\S+/g, "(hidden)")}`);
4160
+ }
4161
+ async function withOwner(o, fn) {
4162
+ let d = null;
4163
+ try {
4164
+ d = await openRecordDevice({ recordDir: o.record, home: o.home, relayUrl: o.relayUrl ?? DEFAULT_RELAY });
4165
+ return await fn(d);
4166
+ } catch (error) {
4167
+ throw plain(error);
4168
+ } finally {
4169
+ d?.close();
4170
+ }
4171
+ }
4172
+ function pairDevice(o, opts = {}) {
4173
+ return withOwner(o, async (d) => {
4174
+ const name = (opts.name ?? "phone").trim();
4175
+ const parts = opts.parts?.length ? opts.parts : ["journal"];
4176
+ const p = await startPairing(d, { name, circles: parts, relayUrl: o.relayUrl ?? DEFAULT_RELAY, ...opts.ttlMs ? { ttlMs: opts.ttlMs } : {} });
4177
+ const computer = computerName();
4178
+ return {
4179
+ name: p.name,
4180
+ code: p.code,
4181
+ qr: namedQr(p.qr, computer),
4182
+ computer,
4183
+ expiresAt: p.expiresAt,
4184
+ parts: p.circles,
4185
+ lines: [
4186
+ `Code for ${p.name}: ${p.code}`,
4187
+ `It works once, until ${clock(p.expiresAt)}. Type it on the device, or scan: ${p.qr}`,
4188
+ `${p.name} will be able to read and add to: ${p.circles.join(", ")}. Nothing else in your EV\xD0A reaches it.`,
4189
+ "It is added the next time this computer syncs after the device uses the code (evda sync)."
4190
+ ],
4191
+ phoneSteps: [
4192
+ "Open the EV\xD0A app on the phone and choose to join an EV\xD0A.",
4193
+ `Scan the QR link or paste the code. It works once, until ${clock(p.expiresAt)}.`,
4194
+ "Once the phone says it joined, sync on this computer (evda sync, or ask me to), then sync on the phone.",
4195
+ `The phone then holds ${p.circles.join(", ")}, and what you write there lands in today's page here on the next sync.`
4196
+ ]
4197
+ };
4198
+ });
4199
+ }
4200
+ function deviceList(o) {
4201
+ return withOwner(o, async (d) => listDevices(d).map((l) => ({ name: l.name, state: l.state, parts: [...l.circles], since: l.since })));
4202
+ }
4203
+ async function deviceLines(o) {
4204
+ const list = await deviceList(o);
4205
+ if (list.length === 0) return ["No devices are paired with this EV\xD0A."];
4206
+ return list.map((l) => `${l.name}: ${l.state}, reads and adds to ${l.parts.join(", ")} (since ${when(l.since)})`);
4207
+ }
4208
+ function removeDeviceWords(o, name) {
4209
+ return withOwner(o, async (d) => {
4210
+ await removeDevice(d, name);
4211
+ const s = await syncRecord(d);
4212
+ return [
4213
+ `Removed ${name}. It can no longer add to your EV\xD0A, and nothing new reaches it. What it already had stays on it.`,
4214
+ ...s.refused.length ? [`Turned away ${s.refused.length} change(s) from a device that is no longer allowed.`] : []
4215
+ ];
4216
+ });
4217
+ }
4218
+ function syncOnce(o) {
4219
+ return withOwner(o, async (d) => {
4220
+ const s = await syncRecord(d);
4221
+ return { paired: [...s.paired], received: s.received, sent: s.sent, refused: s.refused.length };
4222
+ });
4223
+ }
4224
+ async function syncWords(o) {
4225
+ const s = await syncOnce(o);
4226
+ return [
4227
+ ...s.paired.map((n) => `${n} is now paired.`),
4228
+ `Synced: ${s.received} change(s) in, ${s.sent} out.${s.refused ? ` Turned away ${s.refused} from a device that is no longer allowed.` : ""}`
4229
+ ];
4230
+ }
4231
+ export {
4232
+ DEFAULT_RELAY,
4233
+ decodePairCode,
4234
+ deviceLines,
4235
+ deviceList,
4236
+ heldParts,
4237
+ joinWithCode,
4238
+ openJoinedDevice,
4239
+ pairDevice,
4240
+ removeDeviceWords,
4241
+ syncOnce,
4242
+ syncRecord,
4243
+ syncWords,
4244
+ usePersonRecord,
4245
+ writeNote
4246
+ };