create-evda 0.1.6 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +5 -2
- package/bin/evda.mjs +98 -7
- package/create-evda.mjs +26 -11
- package/dist/pairing.mjs +4246 -0
- package/lib/circle-plan.mjs +24 -1
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +6 -1
- package/lib/gather.mjs +60 -10
- package/lib/mcp.mjs +81 -6
- package/lib/messages.mjs +205 -0
- package/lib/organize.mjs +124 -20
- package/lib/pairing.mjs +165 -0
- package/lib/parity.mjs +80 -7
- package/lib/roles.mjs +4 -0
- package/lib/routines.mjs +142 -18
- package/lib/setup.mjs +47 -4
- package/lib/sync-rules.mjs +10 -4
- package/lib/tools.mjs +15 -0
- package/package.json +6 -3
- package/skills/evda/SKILL.md +10 -10
- package/templates/AGENTS.md +4 -0
- package/templates/evda-digest.md +1 -0
package/dist/pairing.mjs
ADDED
|
@@ -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
|
+
};
|