create-evda 0.1.5 → 0.1.7
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 +114 -0
- package/README.md +74 -7
- package/bin/evda.mjs +427 -9
- package/create-evda.mjs +64 -16
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +640 -0
- package/lib/gather.mjs +858 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +456 -36
- package/lib/messages.mjs +205 -0
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +176 -32
- package/lib/parity.mjs +184 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +106 -1
- package/lib/routines.mjs +142 -18
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +124 -4
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +93 -0
- package/lib/tools.mjs +133 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +67 -8
- package/templates/AGENTS.md +122 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +31 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// File Changes: what the person layer hands the substrate's scan and sync (Spec 08, sections 1 to 3).
|
|
2
|
+
//
|
|
3
|
+
// The substrate walks a folder it does not own and calls a `mint` callback for every new or edited
|
|
4
|
+
// file (`atlas-local/src/vault-scan.ts`, `scanAndApply`), and its own comment says why the callback
|
|
5
|
+
// is ours: it does not know what a person's markdown means. This module is that callback for plain
|
|
6
|
+
// files, plus the three things the sync path needs from the person layer and must not read itself:
|
|
7
|
+
//
|
|
8
|
+
// circleIndex which circle each path belongs to, by the circle's opaque id
|
|
9
|
+
// grantView who holds a circle (routing) and who may write into it (receipt)
|
|
10
|
+
// circleCrypto a payload sealed to its circle, and opened only with the circle's key
|
|
11
|
+
// releasesOf the circles a marked file is released into, beyond its home (HQ-1351)
|
|
12
|
+
// addressing a file's address on the wire, and where it lands on a device that mounts the
|
|
13
|
+
// circle from another holder (HQ-835)
|
|
14
|
+
//
|
|
15
|
+
// A file Change:
|
|
16
|
+
//
|
|
17
|
+
// kind: file.set | file.remove
|
|
18
|
+
// target: <the file's address: its path in the HOME holder's record>
|
|
19
|
+
// eco: <opaque circle id>
|
|
20
|
+
// payload: sealed to the circle's current epoch, opening to { path, sha256, baseSha256, text }
|
|
21
|
+
//
|
|
22
|
+
// A released file (Spec 07 section 3) is one Change per circle it reaches: one sealed to its home
|
|
23
|
+
// circle, and one more sealed to each released circle, all with the same address and bytes. A
|
|
24
|
+
// device that holds a circle from another holder (its manifest carries `mount`) writes the file at
|
|
25
|
+
// `<mount>/<address>` and, when it edits the file there, mints the Change back at the address.
|
|
26
|
+
//
|
|
27
|
+
// This package has no runtime dependencies, so nothing here imports the substrate. Whoever composes
|
|
28
|
+
// a device passes the pieces in: the Change log, the host's crypto, and a routing function.
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { readCircles, frontmatterOf, matchesWhere, heldBy } from "./circles.mjs";
|
|
33
|
+
import { readGrants } from "./grants.mjs";
|
|
34
|
+
import { adoptDeclaration, declarationOf, freezeCircle } from "./declarations.mjs";
|
|
35
|
+
import { admitDevice, devicesOf, readDevices, recordOf, retireDevice } from "./devices.mjs";
|
|
36
|
+
import { resolveRole } from "./roles.mjs";
|
|
37
|
+
import { boundaries, insideAny } from "./records.mjs";
|
|
38
|
+
import { EvdaError } from "./errors.mjs";
|
|
39
|
+
|
|
40
|
+
export const FILE_SET = "file.set";
|
|
41
|
+
export const FILE_REMOVE = "file.remove";
|
|
42
|
+
export const SEALED_FORMAT = "evda-circle/1";
|
|
43
|
+
export const PUBLIC_FORMAT = "evda-public/1";
|
|
44
|
+
|
|
45
|
+
// Every circle in the record, with the prefixes it covers and its opaque id. Throws on a malformed
|
|
46
|
+
// circle: syncing a record whose boundary declaration is broken is worse than not syncing it.
|
|
47
|
+
//
|
|
48
|
+
// One failure is not a broken declaration here: a member with nothing on disk. A device that holds
|
|
49
|
+
// no grant on a circle was never sent its files, so on that device the member is absent by design.
|
|
50
|
+
// The member is still indexed, so the device knows which circle the path belongs to.
|
|
51
|
+
export function circleIndex(evda) {
|
|
52
|
+
const circles = readCircles(evda) ?? [];
|
|
53
|
+
const broken = circles.find((c) => c.problems.some((p) => p.level === "fail" && p.code !== "MISSING_MEMBER"));
|
|
54
|
+
if (broken) {
|
|
55
|
+
const first = broken.problems.find((p) => p.level === "fail" && p.code !== "MISSING_MEMBER");
|
|
56
|
+
throw new EvdaError(`circle "${broken.name}" is malformed: ${first.message}`, { code: "BAD_CIRCLE" });
|
|
57
|
+
}
|
|
58
|
+
const index = circles.map((c) => ({
|
|
59
|
+
name: c.name,
|
|
60
|
+
id: c.id,
|
|
61
|
+
public: c.manifest.public === true,
|
|
62
|
+
// A directory member covers everything under it, so its prefix ends in "/"; a file member covers
|
|
63
|
+
// exactly one path. An absent member could be either, so it covers both readings.
|
|
64
|
+
members: [
|
|
65
|
+
...Object.values(c.members)
|
|
66
|
+
.filter((m) => m.kind === "path")
|
|
67
|
+
.map((m) => ({ rel: m.rel.replace(/\/+$/, ""), isDir: m.isDir })),
|
|
68
|
+
...c.problems
|
|
69
|
+
.filter((p) => p.code === "MISSING_MEMBER")
|
|
70
|
+
.map((p) => ({ rel: p.member.rel.replace(/\/+$/, ""), isDir: null })),
|
|
71
|
+
],
|
|
72
|
+
// The rule members: files gathered by their frontmatter, released out of their home.
|
|
73
|
+
rules: Object.values(c.members).filter((m) => m.kind === "rule").map((m) => m.where),
|
|
74
|
+
// Where a file living here may be released to: "never" or circle names.
|
|
75
|
+
release: c.release,
|
|
76
|
+
// Set when this circle is another holder's, held here: { holder, rel }.
|
|
77
|
+
mount: c.mount,
|
|
78
|
+
// Set when a held circle's grant was revoked: { since, by }. Read-only from then on.
|
|
79
|
+
frozen: c.frozen,
|
|
80
|
+
}));
|
|
81
|
+
// Where this record's circles stop (HQ-1371): every sub-record root when the record declares
|
|
82
|
+
// "subrecords": "bounded", none otherwise. A path inside one is in no circle of this record.
|
|
83
|
+
index.boundaries = boundaries(evda);
|
|
84
|
+
return index;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The circles a file is released into beyond its home, by its frontmatter mark and its home's
|
|
88
|
+
// release list (Spec 07 section 3, HQ-1351): the same test `released()` in circles.mjs runs for
|
|
89
|
+
// doctor, for one file. A file in no circle, in a public circle, in a mounted circle, or in a home
|
|
90
|
+
// that releases nothing is released nowhere. `text` is the file's contents; a file that is not
|
|
91
|
+
// markdown carries no mark.
|
|
92
|
+
export function releasesOf(index, rel, text) {
|
|
93
|
+
const home = circleForPath(index, rel);
|
|
94
|
+
if (!home || home.public || home.mount || home.release === "never" || !Array.isArray(home.release)) return [];
|
|
95
|
+
const front = typeof text === "string" && /\.md$/i.test(rel) ? frontmatterOf(text) : null;
|
|
96
|
+
if (!front) return [];
|
|
97
|
+
// A sealed or just-noted entry stays in its home circle whatever its mark says (HQ-1414), as
|
|
98
|
+
// released() refuses it for doctor and publish: a mark never carries either one to another holder.
|
|
99
|
+
if (heldBy(front, "sync")) return [];
|
|
100
|
+
return index.filter((c) => c !== home && !c.public && home.release.includes(c.name) && c.rules.some((where) => matchesWhere(front, where)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The conflict file's original, when `rel` is named as one: `<name> (conflict, <device>, <date>)<ext>`
|
|
104
|
+
// (atlas-local `conflictPath`). Null otherwise.
|
|
105
|
+
export function conflictOriginal(rel) {
|
|
106
|
+
const m = /^(.*) \(conflict, [^,/()]+, \d{4}-\d{2}-\d{2}\)(\.[^/.]*)?$/.exec(rel);
|
|
107
|
+
return m ? `${m[1]}${m[2] ?? ""}` : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Every circle a local file reaches: its home, then each release (HQ-1351). A conflict file also goes
|
|
111
|
+
// wherever its original goes (HQ-835): the disagreement came from a circle the original reaches, and
|
|
112
|
+
// every holder of that circle is owed both texts, whatever the incoming text's own mark says.
|
|
113
|
+
// `readText(rel)` reads a local file, or returns null; absent, only the file's own mark counts.
|
|
114
|
+
export function circlesFor(index, rel, text, readText = null) {
|
|
115
|
+
const home = circleForPath(index, rel);
|
|
116
|
+
if (!home) return [];
|
|
117
|
+
const out = [home, ...releasesOf(index, rel, text)];
|
|
118
|
+
const original = conflictOriginal(rel);
|
|
119
|
+
if (original && readText) {
|
|
120
|
+
const originalText = readText(original);
|
|
121
|
+
if (typeof originalText === "string") {
|
|
122
|
+
for (const c of releasesOf(index, original, originalText)) if (!out.includes(c)) out.push(c);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The circles a file's address has reached on this device and not yet been removed from: for each
|
|
129
|
+
// circle it was logged in, whether its latest Change there is a file.set. Mounted circles are
|
|
130
|
+
// another holder's namespace and are left out, since an address there is theirs.
|
|
131
|
+
async function reachedBy(log, index, address) {
|
|
132
|
+
if (typeof log.forTargetIn !== "function") return [];
|
|
133
|
+
const latest = new Map();
|
|
134
|
+
for (const { eco, change } of await log.forTargetIn(address)) latest.set(eco, change);
|
|
135
|
+
return [...latest.entries()]
|
|
136
|
+
.filter(([, change]) => change.kind === FILE_SET)
|
|
137
|
+
.map(([eco]) => circleById(index, eco))
|
|
138
|
+
.filter((c) => c && !c.mount);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Where a file is on the wire and on this device (HQ-835). The wire carries a file at its ADDRESS,
|
|
142
|
+
// its path in the home holder's record. On the home holder's devices that is the path itself. On a
|
|
143
|
+
// device holding the circle from another holder, the circle is mounted, and the file lands at
|
|
144
|
+
// `<mount>/<address>`; an edit made there is minted back at the address.
|
|
145
|
+
export function addressing(index) {
|
|
146
|
+
const mountOf = (eco) => circleById(index, eco)?.mount ?? null;
|
|
147
|
+
return {
|
|
148
|
+
// The local path for a Change's target, given the circle it arrived in.
|
|
149
|
+
local(eco, address) {
|
|
150
|
+
const mount = mountOf(eco);
|
|
151
|
+
return mount ? `${mount.rel}/${address}` : address;
|
|
152
|
+
},
|
|
153
|
+
// A local path's address, given the circle it is minted into.
|
|
154
|
+
address(circle, rel) {
|
|
155
|
+
if (!circle.mount) return rel;
|
|
156
|
+
const prefix = `${circle.mount.rel}/`;
|
|
157
|
+
if (!rel.startsWith(prefix)) throw new EvdaError(`${rel} is in mounted circle "${circle.name}" but not under its mount ${prefix}.`, { code: "BAD_MOUNT" });
|
|
158
|
+
return rel.slice(prefix.length);
|
|
159
|
+
},
|
|
160
|
+
// The opaque id of the circle a local path is homed in on THIS record, or null. A path under a
|
|
161
|
+
// mount is another holder's file, homed in their record, so it has no home here.
|
|
162
|
+
homeOf(rel) {
|
|
163
|
+
const c = circleForPath(index, rel);
|
|
164
|
+
return c && !c.mount ? c.id : null;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A member at the record root ("" after normalising) is a directory member covering every path.
|
|
170
|
+
const covers = (member, rel) =>
|
|
171
|
+
member.rel === "" ? member.isDir !== false
|
|
172
|
+
: member.isDir === null ? rel === member.rel || rel.startsWith(`${member.rel}/`)
|
|
173
|
+
: member.isDir ? rel.startsWith(`${member.rel}/`)
|
|
174
|
+
: rel === member.rel;
|
|
175
|
+
|
|
176
|
+
// The member a path belongs to, by LONGEST matching path (HQ-1364): a circle declared at the record
|
|
177
|
+
// root ("") claims everything, and a circle whose member is a longer prefix carves its subtree out of
|
|
178
|
+
// it, so `02_Areas/Health & Energy/Therapy/x.md` is therapy, not health, not core. Two circles with
|
|
179
|
+
// members of the SAME length covering one path is still refused: one access answer per entry.
|
|
180
|
+
// Returns { circle, member } or null.
|
|
181
|
+
export function memberForPath(index, rel) {
|
|
182
|
+
if (index.boundaries && insideAny(index.boundaries, rel)) return null;
|
|
183
|
+
let best = null;
|
|
184
|
+
let tie = null;
|
|
185
|
+
for (const c of index) {
|
|
186
|
+
for (const m of c.members) {
|
|
187
|
+
if (!covers(m, rel)) continue;
|
|
188
|
+
if (best === null || m.rel.length > best.member.rel.length) { best = { circle: c, member: m }; tie = null; }
|
|
189
|
+
else if (m.rel.length === best.member.rel.length && c !== best.circle) tie = c;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (tie) {
|
|
193
|
+
const names = [...new Set([best.circle.name, tie.name])];
|
|
194
|
+
throw new EvdaError(`${rel} is covered by ${names.length} circles (${names.join(", ")}) at the same depth; an entry has one access answer.`, { code: "TWO_CIRCLES" });
|
|
195
|
+
}
|
|
196
|
+
return best;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The one circle a path belongs to, or null.
|
|
200
|
+
export function circleForPath(index, rel) {
|
|
201
|
+
return memberForPath(index, rel)?.circle ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const circleById = (index, id) => index.find((c) => c.id === id) ?? null;
|
|
205
|
+
|
|
206
|
+
// What a scan walks for the circles a device holds, one entry per path member: `prefix` is where the
|
|
207
|
+
// listing starts (`dir/` for a directory member, "" for the record root, the path itself for a file
|
|
208
|
+
// member) and `match(path)` says whether a listed path is this member's, which is true only when the
|
|
209
|
+
// longest-prefix resolution lands on this very member. So a file member matches exactly one path
|
|
210
|
+
// (`CLAUDE.md`, never `CLAUDE.md.bak`), and a root member matches everything no longer member of any
|
|
211
|
+
// circle claims. A device scans only what it holds, so a circle it is not granted is never read,
|
|
212
|
+
// hashed or minted on it.
|
|
213
|
+
export function scanPrefixes(index, holds) {
|
|
214
|
+
const isMember = (m, path) => {
|
|
215
|
+
let hit;
|
|
216
|
+
try { hit = memberForPath(index, path); } catch { return false; }
|
|
217
|
+
return hit !== null && hit.member.rel === m.rel && hit.circle.id === m.circle.id;
|
|
218
|
+
};
|
|
219
|
+
return index.filter(holds).flatMap((c) =>
|
|
220
|
+
c.members
|
|
221
|
+
.filter((m) => m.isDir !== null)
|
|
222
|
+
.map((m) => ({ circle: c, prefix: m.rel === "" ? "" : m.isDir ? `${m.rel}/` : m.rel, match: (path) => isMember({ rel: m.rel, circle: c }, path) })),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Who holds which circle, read from the grant files. The substrate's `CircleGrants`.
|
|
227
|
+
//
|
|
228
|
+
// A grant names a circle by its name, on the owner's devices; the wire names it by opaque id. This
|
|
229
|
+
// is where the two meet, and nothing past it sees a name.
|
|
230
|
+
//
|
|
231
|
+
// A grant names a HOLDER by record id; the substrate routes to, and hears from, DEVICES by atlas id
|
|
232
|
+
// (HQ-835). The device lists under the grants role (`lib/devices.mjs`) are where the two meet: a
|
|
233
|
+
// record id resolves to its active devices for routing, and a device resolves to the record it
|
|
234
|
+
// speaks for before any grant is looked up. A record with no list is one device of the same id.
|
|
235
|
+
//
|
|
236
|
+
// The view re-reads the record when a declaration, a revocation or a device notice from another
|
|
237
|
+
// holder lands on it (`adoptDeclaration`, `freezeCircle`, `admitDevice`, `retireDevice`), because
|
|
238
|
+
// those write the very files it is built from, and what follows in the same poll is judged against
|
|
239
|
+
// the new view. `reload()` does the same on request.
|
|
240
|
+
export function grantView(evda) {
|
|
241
|
+
const load = () => ({
|
|
242
|
+
index: circleIndex(evda),
|
|
243
|
+
grants: (readGrants(evda) ?? []).filter((g) => g.problems.every((p) => p.level !== "fail")),
|
|
244
|
+
devices: readDevices(evda) ?? { lists: new Map(), problems: [] },
|
|
245
|
+
});
|
|
246
|
+
let state = load();
|
|
247
|
+
const reload = () => {
|
|
248
|
+
state = load();
|
|
249
|
+
};
|
|
250
|
+
const named = (eco) => circleById(state.index, eco);
|
|
251
|
+
const onCircle = (name) => state.grants.filter((g) => g.circle === name);
|
|
252
|
+
// The holder a device speaks for; null for a retired device.
|
|
253
|
+
const holderOf = (atlasId) => recordOf(state.devices, atlasId);
|
|
254
|
+
return {
|
|
255
|
+
get index() {
|
|
256
|
+
return state.index;
|
|
257
|
+
},
|
|
258
|
+
reload,
|
|
259
|
+
// A public circle is held by the record's own devices and by no other holder, who reaches the
|
|
260
|
+
// public set through its export. When the record lists its own devices (grants/devices/<own
|
|
261
|
+
// id>.yaml), a public Change routes to them alone; a record that lists none routes it to every
|
|
262
|
+
// pinned peer, as before devices were listed.
|
|
263
|
+
async readers(eco) {
|
|
264
|
+
const c = named(eco);
|
|
265
|
+
if (!c) return [];
|
|
266
|
+
if (c.public) {
|
|
267
|
+
const own = evda.manifest?.id;
|
|
268
|
+
return typeof own === "string" && state.devices.lists.has(own) ? devicesOf(state.devices, own) : "all";
|
|
269
|
+
}
|
|
270
|
+
return [...new Set(onCircle(c.name).flatMap((g) => devicesOf(state.devices, g.principalId)))];
|
|
271
|
+
},
|
|
272
|
+
async mayAuthor(atlasId, eco) {
|
|
273
|
+
const c = named(eco);
|
|
274
|
+
if (!c) return false;
|
|
275
|
+
const holder = holderOf(atlasId);
|
|
276
|
+
if (holder === null) return false;
|
|
277
|
+
const writes = (g) => g.principalId === holder && (g.role === "owner" || g.role === "editor");
|
|
278
|
+
// A public circle takes no grant, so writing into one takes an OWNER grant on some circle of
|
|
279
|
+
// the record: a device of the record's own holder. Never a viewer, and never another holder,
|
|
280
|
+
// who holds an editor grant at most and must not be able to publish in this record's name.
|
|
281
|
+
return c.public ? state.grants.some((g) => g.principalId === holder && g.role === "owner") : onCircle(c.name).some(writes);
|
|
282
|
+
},
|
|
283
|
+
// A frozen circle is held in the sense of "kept", not "worked on": it is not scanned or minted into.
|
|
284
|
+
holds(atlasId) {
|
|
285
|
+
const holder = holderOf(atlasId);
|
|
286
|
+
return (c) => !c.frozen && (c.public || (holder !== null && onCircle(c.name).some((g) => g.principalId === holder)));
|
|
287
|
+
},
|
|
288
|
+
// The private circles, by opaque id: the ones with a key to deliver.
|
|
289
|
+
async circles() {
|
|
290
|
+
return state.index.filter((c) => !c.public).map((c) => c.id);
|
|
291
|
+
},
|
|
292
|
+
// Only an owner hands out a circle's key (Spec 08 §2). Checked by the sender before delivering
|
|
293
|
+
// and by the receiver before importing, so a viewer device can neither give a key nor be the
|
|
294
|
+
// source of one.
|
|
295
|
+
async mayShareKey(atlasId, eco) {
|
|
296
|
+
const c = named(eco);
|
|
297
|
+
if (!c || c.public) return false;
|
|
298
|
+
const holder = holderOf(atlasId);
|
|
299
|
+
return holder !== null && onCircle(c.name).some((g) => g.principalId === holder && g.role === "owner");
|
|
300
|
+
},
|
|
301
|
+
// The declaration of a circle this record homes, for the wire (HQ-835); null for a public
|
|
302
|
+
// circle, a circle held from someone else, or one that is not here.
|
|
303
|
+
async declaration(eco) {
|
|
304
|
+
const c = named(eco);
|
|
305
|
+
if (!c || c.public || c.mount) return null;
|
|
306
|
+
return declarationOf(evda, c.name);
|
|
307
|
+
},
|
|
308
|
+
// Hold a circle another holder declared, then read the record again. A refusal names why. The
|
|
309
|
+
// sender is a device; the declaration names its owner by record id.
|
|
310
|
+
async adoptDeclaration(from, declaration) {
|
|
311
|
+
const holder = holderOf(from);
|
|
312
|
+
if (holder === null) return `${from} is a retired device here and declares nothing`;
|
|
313
|
+
const r = adoptDeclaration(evda, holder, declaration);
|
|
314
|
+
if (!r.ok) return r.why;
|
|
315
|
+
reload();
|
|
316
|
+
return null;
|
|
317
|
+
},
|
|
318
|
+
// Freeze a held circle whose grant was removed, then read the record again.
|
|
319
|
+
async freezeCircle(from, eco, at) {
|
|
320
|
+
const holder = holderOf(from);
|
|
321
|
+
if (holder === null) return `${from} is a retired device here and revokes nothing`;
|
|
322
|
+
const r = freezeCircle(evda, holder, eco, at);
|
|
323
|
+
if (!r.ok) return r.why;
|
|
324
|
+
reload();
|
|
325
|
+
return null;
|
|
326
|
+
},
|
|
327
|
+
// The record a device speaks for, or null.
|
|
328
|
+
async recordOf(atlasId) {
|
|
329
|
+
return holderOf(atlasId);
|
|
330
|
+
},
|
|
331
|
+
// Whether a device speaks for another holder than this record, or for none: the fold's `foreign`,
|
|
332
|
+
// so an edit from another holder into a file this record homes is re-minted to every holder.
|
|
333
|
+
isForeign(atlasId) {
|
|
334
|
+
return holderOf(atlasId) !== evda.manifest?.id;
|
|
335
|
+
},
|
|
336
|
+
// The active devices of a record id.
|
|
337
|
+
async devicesOf(record) {
|
|
338
|
+
return devicesOf(state.devices, record);
|
|
339
|
+
},
|
|
340
|
+
// This record's own id: a device-list notice naming it comes from a sibling device.
|
|
341
|
+
async ownRecord() {
|
|
342
|
+
return typeof evda.manifest?.id === "string" && evda.manifest.id !== "" ? evda.manifest.id : null;
|
|
343
|
+
},
|
|
344
|
+
// The active devices of a record id, with the identity key listed for each. No list: none, since
|
|
345
|
+
// no key is known for a device nobody listed.
|
|
346
|
+
async deviceList(record) {
|
|
347
|
+
const list = state.devices.lists.get(record);
|
|
348
|
+
if (!list) return [];
|
|
349
|
+
return list.devices.filter((d) => typeof d.retiredAt !== "string" || d.retiredAt === "").map((d) => ({ atlasId: d.atlasId, identityPublicKey: d.identityPublicKey ?? "" }));
|
|
350
|
+
},
|
|
351
|
+
// A holder's device introduced a new one: list it, then read the record again.
|
|
352
|
+
async admitDevice(record, device, introducer, at) {
|
|
353
|
+
const r = admitDevice(evda, { record, device, introducer, at });
|
|
354
|
+
if (!r.ok) return r.why;
|
|
355
|
+
reload();
|
|
356
|
+
return null;
|
|
357
|
+
},
|
|
358
|
+
// A holder's device retired one of its devices: mark it, then read the record again.
|
|
359
|
+
async retireDevice(record, atlasId, by, at) {
|
|
360
|
+
const r = retireDevice(evda, { record, atlasId, by, at });
|
|
361
|
+
if (!r.ok) return r.why;
|
|
362
|
+
reload();
|
|
363
|
+
return null;
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Paths under an append-only role, where two devices' concurrent appends union instead of
|
|
369
|
+
// conflicting: the daily notes and the ledger (Spec 08, section 3).
|
|
370
|
+
export function unionPaths(evda) {
|
|
371
|
+
const roots = ["record/daily", "ledger"]
|
|
372
|
+
.map((role) => resolveRole(evda, role))
|
|
373
|
+
.filter((r) => r.mapped)
|
|
374
|
+
.map((r) => `${r.rel.replace(/\/+$/, "")}/`);
|
|
375
|
+
return (rel) => roots.some((root) => rel.startsWith(root));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Seal a payload to its circle, and open one. `crypto` is the host's HostCrypto.
|
|
379
|
+
//
|
|
380
|
+
// A public circle needs no key, so its payload travels in the clear inside the device envelope. A
|
|
381
|
+
// private circle's payload is sealed to the circle's current epoch; a device with no key for the
|
|
382
|
+
// circle cannot open it, which is what makes a Change routed to the wrong device fail closed.
|
|
383
|
+
export function circleCrypto(crypto) {
|
|
384
|
+
return {
|
|
385
|
+
async seal(circle, payload) {
|
|
386
|
+
if (circle.public) return { format: PUBLIC_FORMAT, clear: payload };
|
|
387
|
+
const key = await crypto.circleKey(circle.id);
|
|
388
|
+
if (!key) throw new EvdaError(`this device holds no key for circle "${circle.name}", so it cannot write into it.`, { code: "NO_CIRCLE_KEY" });
|
|
389
|
+
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
390
|
+
return { format: SEALED_FORMAT, epoch: key.epoch, sealed: Buffer.from(await crypto.seal(key.publicKey, bytes)).toString("base64") };
|
|
391
|
+
},
|
|
392
|
+
async open(circle, sealed) {
|
|
393
|
+
if (sealed?.format === PUBLIC_FORMAT) {
|
|
394
|
+
// A private circle's payload in the clear is refused, not read: it means a seal was skipped.
|
|
395
|
+
if (!circle.public) throw new Error(`circle payload arrived unsealed for a private circle`);
|
|
396
|
+
return sealed.clear;
|
|
397
|
+
}
|
|
398
|
+
if (sealed?.format !== SEALED_FORMAT || typeof sealed.sealed !== "string") throw new Error("not a circle payload");
|
|
399
|
+
const opened = await crypto.openCircle(circle.id, new Uint8Array(Buffer.from(sealed.sealed, "base64")));
|
|
400
|
+
return JSON.parse(new TextDecoder().decode(opened));
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The mint callbacks for `scanAndApply`.
|
|
406
|
+
//
|
|
407
|
+
// log the substrate's ChangeLog (`append`)
|
|
408
|
+
// index circleIndex(evda)
|
|
409
|
+
// seal (circle, payload) -> sealed payload, from circleCrypto
|
|
410
|
+
// notifyFor (eco, changesetId) -> [{ targetAtlas, pushBatchId }], the routing
|
|
411
|
+
// previousSha (path) -> the sha this device last recorded for the path, or null: the edit's base
|
|
412
|
+
// changesetId () -> a fresh changeset id
|
|
413
|
+
// unionPath optional (path) -> whether the path is under an append-only role. Written into the
|
|
414
|
+
// payload as `union: true`, so a device holding the circle from another holder, which
|
|
415
|
+
// has no mapping for the home record, still unions where the home does.
|
|
416
|
+
// onMinted optional ({ change, circle, payload }) -> after the Change is appended, once per
|
|
417
|
+
// circle the file reaches
|
|
418
|
+
// blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a file above
|
|
419
|
+
// BLOB_THRESHOLD bytes, or one that is not UTF-8, rides the blob lane (Spec 08 §1). Its
|
|
420
|
+
// bytes are sealed once under a fresh content key and kept by the device; the payload
|
|
421
|
+
// carries { hash, contentKey } in place of the text. Absent, such a file is minted as text.
|
|
422
|
+
//
|
|
423
|
+
// Throwing is how a callback says "not recorded": the scan leaves the path for the next run.
|
|
424
|
+
export const BLOB_THRESHOLD = 256 * 1024;
|
|
425
|
+
|
|
426
|
+
const utf8OrNull = (bytes) => {
|
|
427
|
+
try {
|
|
428
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
export function fileMinter({ log, index, seal, notifyFor, previousSha, changesetId, originUser = null, onMinted, blobs, unionPath = null, readText = null }) {
|
|
435
|
+
const at = addressing(index);
|
|
436
|
+
const circleOf = (path) => {
|
|
437
|
+
const c = circleForPath(index, path);
|
|
438
|
+
if (!c) throw new EvdaError(`${path} is in no circle, so it has no one to sync to.`, { code: "NO_CIRCLE" });
|
|
439
|
+
return c;
|
|
440
|
+
};
|
|
441
|
+
// One Change per circle the file reaches: its home first, then each release. Each is sealed to
|
|
442
|
+
// that circle's key and routed to that circle's readers, with its own changeset id, so a device
|
|
443
|
+
// holding two of the circles is owed two rows rather than one row twice.
|
|
444
|
+
const append = async (kind, path, payload, circles, blobHash = null) => {
|
|
445
|
+
let first = null;
|
|
446
|
+
for (const circle of circles) {
|
|
447
|
+
const id = await changesetId();
|
|
448
|
+
const change = await log.append({
|
|
449
|
+
changesetId: id,
|
|
450
|
+
eco: circle.id,
|
|
451
|
+
originUser,
|
|
452
|
+
kind,
|
|
453
|
+
target: payload.path,
|
|
454
|
+
payload: await seal(circle, payload),
|
|
455
|
+
notify: await notifyFor(circle.id, id),
|
|
456
|
+
});
|
|
457
|
+
if (blobHash !== null) await blobs.link(change.id, blobHash);
|
|
458
|
+
await onMinted?.({ change, circle, payload });
|
|
459
|
+
first ??= change;
|
|
460
|
+
}
|
|
461
|
+
return first;
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
async mint(item, bytes) {
|
|
465
|
+
const home = circleOf(item.path);
|
|
466
|
+
const address = at.address(home, item.path);
|
|
467
|
+
const text = utf8OrNull(bytes);
|
|
468
|
+
const circles = home.mount ? [home] : circlesFor(index, item.path, text, readText);
|
|
469
|
+
const union = unionPath?.(item.path) === true ? { union: true } : {};
|
|
470
|
+
// A circle the file reached before and reaches no longer (its mark was taken off, or its home
|
|
471
|
+
// changed) is sent a removal, so devices holding only that circle lose it (Spec 08 section 2,
|
|
472
|
+
// an entry moving between circles). Minted after the new version, as a move is.
|
|
473
|
+
const withdrawn = home.mount ? [] : (await reachedBy(log, index, address)).filter((c) => !circles.includes(c));
|
|
474
|
+
const withdraw = async () => {
|
|
475
|
+
if (withdrawn.length > 0) await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, withdrawn);
|
|
476
|
+
};
|
|
477
|
+
if (blobs && (text === null || bytes.length > BLOB_THRESHOLD)) {
|
|
478
|
+
const sealed = await blobs.seal(bytes);
|
|
479
|
+
const blob = { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") };
|
|
480
|
+
await append(FILE_SET, item.path, { path: address, sha256: item.sha256, baseSha256: await previousSha(item.path), blob }, circles, sealed.hash);
|
|
481
|
+
await withdraw();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// ignoreBOM keeps a byte-order mark in the text, so the receiver's hash of the text is the
|
|
485
|
+
// scanner's hash of the bytes. Without a blob lane, a file that is not UTF-8 is decoded
|
|
486
|
+
// lossily, fails that check on receipt, and is refused there.
|
|
487
|
+
const payload = {
|
|
488
|
+
path: address,
|
|
489
|
+
sha256: item.sha256,
|
|
490
|
+
baseSha256: await previousSha(item.path),
|
|
491
|
+
text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes),
|
|
492
|
+
...union,
|
|
493
|
+
};
|
|
494
|
+
await append(FILE_SET, item.path, payload, circles);
|
|
495
|
+
await withdraw();
|
|
496
|
+
},
|
|
497
|
+
// A removal reaches every circle the file reached: the mark went with the bytes, so the circles
|
|
498
|
+
// are read off this device's log, each one whose latest Change for the address is a file.set,
|
|
499
|
+
// with the home first (HQ-835). On a device holding the circle from another holder, the home
|
|
500
|
+
// (the mounted circle) only.
|
|
501
|
+
async mintRemoval(item) {
|
|
502
|
+
const home = circleOf(item.path);
|
|
503
|
+
const address = at.address(home, item.path);
|
|
504
|
+
const circles = [home, ...(home.mount ? [] : (await reachedBy(log, index, address)).filter((c) => c !== home))];
|
|
505
|
+
await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, circles);
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The catch-up for a circle this device homes (HQ-835): what `deliverCatchUp` in atlas-local sends,
|
|
511
|
+
// once, to a device that joined the circle or whose copy thawed. The core decides when and to whom;
|
|
512
|
+
// this says what the circle holds.
|
|
513
|
+
//
|
|
514
|
+
// evda the record
|
|
515
|
+
// view () -> the current grant view (its `index`)
|
|
516
|
+
// log the substrate's ChangeLog: `append`, `forTargetIn`, `forEco`
|
|
517
|
+
// seal (circle, payload) -> sealed payload, from circleCrypto, at the circle's CURRENT epoch
|
|
518
|
+
// open (change, eco) -> payload, or throws: reads the earlier versions for `ancestors`
|
|
519
|
+
// changesetId () -> a fresh changeset id
|
|
520
|
+
// unionPath optional, as for fileMinter
|
|
521
|
+
// blobs optional, as for fileMinter
|
|
522
|
+
// exclude optional (rel) -> true for a path the sync rules leave out (sync-rules `excluder`),
|
|
523
|
+
// so a catch-up sends no more than the scan would
|
|
524
|
+
//
|
|
525
|
+
// `snapshot(circleId)` mints, into the log and routed to nobody, one file.set per file the circle
|
|
526
|
+
// reaches now (its home members and every file released into it, conflict files included), and one
|
|
527
|
+
// file.remove per address the circle reached before and reaches no longer. Each payload carries
|
|
528
|
+
// `ancestors`: every sha this device logged for the address before the current one, from any circle
|
|
529
|
+
// it can open. A receiver holding any of them fast-forwards; a receiver holding a version nobody
|
|
530
|
+
// here has seen keeps it and gets the current text beside it, by the ordinary merge table.
|
|
531
|
+
export function catchUp({ evda, view, log, seal, open, changesetId, originUser = null, unionPath = null, blobs = null, exclude = null }) {
|
|
532
|
+
const readText = (rel) => {
|
|
533
|
+
const abs = join(evda.dir, rel);
|
|
534
|
+
return existsSync(abs) && statSync(abs).isFile() ? readFileSync(abs, "utf8") : null;
|
|
535
|
+
};
|
|
536
|
+
const walk = (prefix) => {
|
|
537
|
+
const out = [];
|
|
538
|
+
const start = join(evda.dir, prefix);
|
|
539
|
+
if (!existsSync(start)) return out;
|
|
540
|
+
if (statSync(start).isFile()) return [prefix];
|
|
541
|
+
const stack = [prefix.replace(/\/+$/, "")];
|
|
542
|
+
while (stack.length > 0) {
|
|
543
|
+
const dir = stack.pop();
|
|
544
|
+
for (const name of readdirSync(join(evda.dir, dir)).sort()) {
|
|
545
|
+
if (name.startsWith(".")) continue;
|
|
546
|
+
const rel = dir === "" ? name : `${dir}/${name}`;
|
|
547
|
+
const st = statSync(join(evda.dir, rel));
|
|
548
|
+
if (st.isDirectory()) stack.push(rel);
|
|
549
|
+
else if (st.isFile()) out.push(rel);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
};
|
|
554
|
+
const shaOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
|
|
555
|
+
|
|
556
|
+
// Every sha logged for an address before `current`, oldest first, from Changes this device can open.
|
|
557
|
+
const ancestorsOf = async (address, current) => {
|
|
558
|
+
const out = [];
|
|
559
|
+
for (const { eco, change } of await log.forTargetIn(address)) {
|
|
560
|
+
if (change.kind !== FILE_SET) continue;
|
|
561
|
+
try {
|
|
562
|
+
const p = await open(change, eco);
|
|
563
|
+
if (typeof p?.sha256 === "string" && p.sha256 !== current && !out.includes(p.sha256)) out.push(p.sha256);
|
|
564
|
+
} catch {
|
|
565
|
+
// A version this device cannot open is not one it can vouch for.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
async snapshot(circleId) {
|
|
573
|
+
const index = view().index;
|
|
574
|
+
const target = circleById(index, circleId);
|
|
575
|
+
if (!target) throw new EvdaError(`no circle ${circleId} in this record.`, { code: "NO_CIRCLE" });
|
|
576
|
+
if (target.mount) throw new EvdaError(`circle "${target.name}" is held from ${target.mount.holder}; only its home catches a device up.`, { code: "NOT_OWNER" });
|
|
577
|
+
const at = addressing(index);
|
|
578
|
+
const present = new Map();
|
|
579
|
+
for (const { prefix, match } of scanPrefixes(index, (c) => !c.mount && !c.frozen)) {
|
|
580
|
+
for (const rel of walk(prefix)) {
|
|
581
|
+
if (!match(rel) || present.has(rel) || exclude?.(rel)) continue;
|
|
582
|
+
const bytes = readFileSync(join(evda.dir, rel));
|
|
583
|
+
const text = utf8OrNull(bytes);
|
|
584
|
+
if (!circlesFor(index, rel, text, readText).includes(target)) continue;
|
|
585
|
+
present.set(rel, { bytes, text });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const minted = [];
|
|
589
|
+
const append = async (kind, payload, blobHash = null) => {
|
|
590
|
+
const id = await changesetId();
|
|
591
|
+
const change = await log.append({ changesetId: id, eco: target.id, originUser, kind, target: payload.path, payload: await seal(target, payload), notify: [] });
|
|
592
|
+
if (blobHash !== null) await blobs.link(change.id, blobHash);
|
|
593
|
+
minted.push(change);
|
|
594
|
+
};
|
|
595
|
+
const addresses = new Set();
|
|
596
|
+
for (const [rel, { bytes, text }] of [...present.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
597
|
+
const address = at.address(circleForPath(index, rel), rel);
|
|
598
|
+
addresses.add(address);
|
|
599
|
+
const sha256 = await shaOf(bytes);
|
|
600
|
+
const ancestors = await ancestorsOf(address, sha256);
|
|
601
|
+
const base = { path: address, sha256, baseSha256: ancestors.at(-1) ?? null, ancestors };
|
|
602
|
+
if (blobs && (text === null || bytes.length > BLOB_THRESHOLD)) {
|
|
603
|
+
const sealed = await blobs.seal(bytes);
|
|
604
|
+
await append(FILE_SET, { ...base, blob: { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") } }, sealed.hash);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
await append(FILE_SET, { ...base, text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), ...(unionPath?.(rel) === true ? { union: true } : {}) });
|
|
608
|
+
}
|
|
609
|
+
// Addresses the circle held before and does not now: removed, or no longer released into it.
|
|
610
|
+
const gone = new Set();
|
|
611
|
+
for (const change of await log.forEco(target.id)) {
|
|
612
|
+
if (change.kind === FILE_SET && !addresses.has(change.target)) gone.add(change.target);
|
|
613
|
+
}
|
|
614
|
+
for (const address of [...gone].sort()) {
|
|
615
|
+
const ancestors = await ancestorsOf(address, null);
|
|
616
|
+
if (ancestors.length === 0) continue;
|
|
617
|
+
await append(FILE_REMOVE, { path: address, sha256: null, baseSha256: ancestors.at(-1), ancestors });
|
|
618
|
+
}
|
|
619
|
+
return minted;
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}
|
package/lib/folders.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { SCHEMES } from "./references.mjs";
|
|
|
27
27
|
import { EvdaError } from "./errors.mjs";
|
|
28
28
|
|
|
29
29
|
export const FOLDER_MANIFEST = "folder.evda.json";
|
|
30
|
-
export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis"];
|
|
30
|
+
export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
|
|
31
31
|
export const FOLDER_KEYS = ["type", "connectors", "views"];
|
|
32
32
|
export const VIEW_KEYS_REQUIRED = ["view", "of", "in", "path"];
|
|
33
33
|
export const VIEW_KEYS = [...VIEW_KEYS_REQUIRED, "status", "group"];
|