@fangorn-network/westmarch 0.0.1-dev
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/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/publish/graph.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Filesystem tree <-> Fangorn graph.
|
|
2
|
+
//
|
|
3
|
+
// An app's content is an open-ended tree of folders and files — the tree IS the
|
|
4
|
+
// structure, no fixed schema. Publishing snapshots it into a Fangorn commit
|
|
5
|
+
// graph: a vertex per folder and per published file, with parent→child
|
|
6
|
+
// `contains` edges. `replace: true` gives snapshot semantics, so the remote
|
|
7
|
+
// graph is always exactly the current library.
|
|
8
|
+
//
|
|
9
|
+
// A file vertex carries whatever pointer its app needs to fetch the bytes
|
|
10
|
+
// (sond3r puts the x402f resourceId/price/workerUrl there); the bytes themselves
|
|
11
|
+
// live wherever the app put them, not in the graph. Vertices are keyed by
|
|
12
|
+
// relative path.
|
|
13
|
+
//
|
|
14
|
+
// ── what an app has to pin ──────────────────────────────────────────────────
|
|
15
|
+
// `opts.file` is the string used for the fs node's `type`, the vertex `tag` and
|
|
16
|
+
// the payload's `kind`. It goes on-chain, so an app picks it once and never
|
|
17
|
+
// again: sond3r pins "video" (historical — it means "a file for sale", and every
|
|
18
|
+
// vertex already committed says so). A new app should just take the default.
|
|
19
|
+
//
|
|
20
|
+
// `opts.passages` is the sidecar of timestamped text hanging off a file —
|
|
21
|
+
// subtitle cues, chapter marks, a lecture transcript, a changelog. It's optional
|
|
22
|
+
// (an app whose files have no timeline passes null) and its names are on-chain
|
|
23
|
+
// too: sond3r pins the "subtitles" spelling throughout.
|
|
24
|
+
|
|
25
|
+
/** Nest a flat node list into a tree. Each node: { path, name, type:"folder"|
|
|
26
|
+
* <file type>, parent?, ...extra }. Parent is derived from the path when
|
|
27
|
+
* omitted. Returns root nodes; folders first, then alpha; children recursively
|
|
28
|
+
* sorted. */
|
|
29
|
+
export function nest(nodes) {
|
|
30
|
+
const byPath = new Map();
|
|
31
|
+
for (const n of nodes) byPath.set(n.path, { ...n, children: [] });
|
|
32
|
+
const roots = [];
|
|
33
|
+
for (const n of byPath.values()) {
|
|
34
|
+
const parent = n.parent != null ? n.parent : n.path.includes("/") ? n.path.slice(0, n.path.lastIndexOf("/")) : "";
|
|
35
|
+
if (parent && byPath.has(parent)) byPath.get(parent).children.push(n);
|
|
36
|
+
else roots.push(n);
|
|
37
|
+
}
|
|
38
|
+
const sort = (arr) => {
|
|
39
|
+
arr.sort((a, b) => (a.type !== b.type ? (a.type === "folder" ? -1 : 1) : a.name.localeCompare(b.name)));
|
|
40
|
+
for (const x of arr) sort(x.children);
|
|
41
|
+
};
|
|
42
|
+
sort(roots);
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The defaults a new app should take as-is. sond3r overrides both — see the
|
|
47
|
+
* header for why they can never change once something is published. */
|
|
48
|
+
export const DEFAULTS = {
|
|
49
|
+
file: "file",
|
|
50
|
+
passages: null, // or { tag, rel, parentField, idSuffix, field, embedField }
|
|
51
|
+
// Which keys of a node's `published` blob reach the on-chain payload. `null`
|
|
52
|
+
// lifts the whole blob, which is right for a new app — the pointer is opaque
|
|
53
|
+
// to this file and always has been. An app that already has vertices
|
|
54
|
+
// committed pins the list instead, because a key that starts appearing (or
|
|
55
|
+
// stops) changes every payload it publishes from then on.
|
|
56
|
+
pointerFields: null,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Vertex id for a file's passage list. */
|
|
60
|
+
export const passagesId = (path, suffix) => `${path}${suffix}`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A file the publisher has marked NOT for sale (or, in an app with nothing to
|
|
64
|
+
* sell, every file). It still becomes a vertex — named, described, embedded,
|
|
65
|
+
* searchable — but it carries no purchase pointer, so it costs no per-item
|
|
66
|
+
* transaction.
|
|
67
|
+
*
|
|
68
|
+
* This is what makes bulk ingest affordable. `commitStateRoot` is ONE tx for a
|
|
69
|
+
* graph of any size, so a million catalog entries publish for the price of one
|
|
70
|
+
* transaction; only the subset actually for sale pays per item.
|
|
71
|
+
*/
|
|
72
|
+
export const isCatalogEntry = (n) => n.forSale === false;
|
|
73
|
+
|
|
74
|
+
/** Does this fs node belong in the published graph at all? */
|
|
75
|
+
export const inGraph = (n, { file } = DEFAULTS) => n.type === file && (!!n.published || isCatalogEntry(n));
|
|
76
|
+
|
|
77
|
+
/** Flat fs nodes → { vertices:[{id,tag,payload}], edges:[{rel,from,to}] }.
|
|
78
|
+
* Published files and catalog entries become vertices; folders are kept only
|
|
79
|
+
* if they contain (transitively) one, so a viewer never sees empty folders. */
|
|
80
|
+
export function buildTreeGraph(nodes, opts = {}) {
|
|
81
|
+
const { file, passages, pointerFields } = { ...DEFAULTS, ...opts };
|
|
82
|
+
const pointer = (p) => (!pointerFields ? p
|
|
83
|
+
: Object.fromEntries(pointerFields.filter((k) => p[k] !== undefined).map((k) => [k, p[k]])));
|
|
84
|
+
|
|
85
|
+
const keep = new Set(); // paths to emit: graph-worthy files + their ancestors
|
|
86
|
+
for (const n of nodes) {
|
|
87
|
+
if (!inGraph(n, { file })) continue;
|
|
88
|
+
keep.add(n.path);
|
|
89
|
+
let p = n.path;
|
|
90
|
+
while (p.includes("/")) { p = p.slice(0, p.lastIndexOf("/")); keep.add(p); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const vertices = [];
|
|
94
|
+
const edges = [];
|
|
95
|
+
const parentOf = (path) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
|
|
96
|
+
|
|
97
|
+
// Ancestors nobody described. `keep` collects every kept file's folders so the
|
|
98
|
+
// `contains` edges below have somewhere to hang, but vertices are emitted by
|
|
99
|
+
// walking `nodes` — and a caller may hand over a FLAT list of files with no
|
|
100
|
+
// folder entries at all. The browser sends the folders because it has a tree
|
|
101
|
+
// on screen; `scripts/publish.mjs` sends `[{path: "supercuts/x.mp4"}]` and
|
|
102
|
+
// nothing else, and the commit died on the chain SDK's own integrity check:
|
|
103
|
+
// edge references unknown local id (or invalid CID): test
|
|
104
|
+
// Sorted, so a parent is always emitted before the child that contains it.
|
|
105
|
+
const described = new Set(nodes.map((n) => n.path));
|
|
106
|
+
for (const p of [...keep].filter((x) => !described.has(x)).sort()) {
|
|
107
|
+
vertices.push({ id: p, tag: "folder", payload: { kind: "folder", name: p.slice(p.lastIndexOf("/") + 1), path: p } });
|
|
108
|
+
const up = parentOf(p);
|
|
109
|
+
if (up && keep.has(up)) edges.push({ rel: "contains", from: up, to: p });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const n of nodes) {
|
|
113
|
+
if (!keep.has(n.path)) continue;
|
|
114
|
+
if (n.type === "folder") {
|
|
115
|
+
vertices.push({ id: n.path, tag: "folder", payload: { kind: "folder", name: n.name, path: n.path } });
|
|
116
|
+
} else {
|
|
117
|
+
vertices.push({
|
|
118
|
+
id: n.path, tag: file,
|
|
119
|
+
payload: {
|
|
120
|
+
// `mime` is what tells a consumer what the bytes actually are.
|
|
121
|
+
kind: file, name: n.name, path: n.path, mime: n.mime ?? "application/octet-stream",
|
|
122
|
+
// Semantic content: the publisher's own description, and the
|
|
123
|
+
// vector for it. Both public by construction — the vector is
|
|
124
|
+
// derived from text that's already in the clear here, and it's
|
|
125
|
+
// what lets ANY aggregator bake a searchable shard without
|
|
126
|
+
// running a model or asking the publisher for anything more.
|
|
127
|
+
// A catalog entry has ONLY this half, which is the point.
|
|
128
|
+
...(n.desc ? { desc: n.desc } : {}),
|
|
129
|
+
...(n.embed ? { embed: n.embed } : {}),
|
|
130
|
+
// Content the publisher doesn't host — a mirror, or an
|
|
131
|
+
// annotation pointing at the thing it describes. The bytes are
|
|
132
|
+
// already public, so the pointer IS the url and there's nothing
|
|
133
|
+
// to mint. Carried here because a vertex that can be found but
|
|
134
|
+
// not opened is worth very little.
|
|
135
|
+
...(n.url ? { url: n.url } : {}),
|
|
136
|
+
// Whatever the app needs to fetch (and charge for) the bytes.
|
|
137
|
+
// Absent on a catalog entry — and its absence is the signal,
|
|
138
|
+
// checked everywhere downstream, so there is no second
|
|
139
|
+
// "forSale" field to contradict it.
|
|
140
|
+
...(n.published ? pointer(n.published) : {}),
|
|
141
|
+
...(n.published && n.price != null ? { price: String(n.price) } : {}),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Passages are PUBLIC and live in the graph itself, not behind whatever
|
|
146
|
+
// gates the bytes: a sibling vertex of timestamped text, for embedding +
|
|
147
|
+
// semantic search. It deliberately carries the back-pointer field and NO
|
|
148
|
+
// `path`, which is what keeps it out of treeFromGraph's viewer tree.
|
|
149
|
+
const cues = passages && n.type === file ? n[passages.field] : null;
|
|
150
|
+
if (cues?.length) {
|
|
151
|
+
const id = passagesId(n.path, passages.idSuffix);
|
|
152
|
+
// `embed.vecs` is POSITIONAL against the passage list — nothing else
|
|
153
|
+
// binds a vector to its passage. Committing them in the same vertex is
|
|
154
|
+
// what keeps them from drifting apart.
|
|
155
|
+
vertices.push({
|
|
156
|
+
id, tag: passages.tag,
|
|
157
|
+
payload: {
|
|
158
|
+
kind: passages.tag, [passages.parentField]: n.path, name: n.name,
|
|
159
|
+
[passages.field]: cues, ...(n[passages.embedField] ? { embed: n[passages.embedField] } : {}),
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
edges.push({ rel: passages.rel, from: n.path, to: id });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const parent = parentOf(n.path);
|
|
166
|
+
if (parent && keep.has(parent)) edges.push({ rel: "contains", from: parent, to: n.path });
|
|
167
|
+
}
|
|
168
|
+
return { vertices, edges };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Remote graph contents { vertices:[{cid?,payload}] } → nested tree for the
|
|
172
|
+
* viewer. Rebuilt from each vertex payload's `path` (authoritative), so it
|
|
173
|
+
* doesn't depend on how edges were serialized. */
|
|
174
|
+
export function treeFromGraph(contents) {
|
|
175
|
+
const flat = (contents.vertices ?? [])
|
|
176
|
+
.map((v) => v.payload)
|
|
177
|
+
.filter((p) => p && p.path)
|
|
178
|
+
.map((p) => ({ ...p, type: p.kind })); // kind → type for nest()
|
|
179
|
+
return nest(flat);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── self-check: fs nodes → graph → tree round-trips structure ─────────────────
|
|
183
|
+
// `process` doesn't exist in the browser, and consumers import nest() from here
|
|
184
|
+
// to build a viewer tree — so this guard has to survive being evaluated by a
|
|
185
|
+
// browser.
|
|
186
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
187
|
+
// sond3r's pinned names, which is also the case with every option exercised.
|
|
188
|
+
const SOND3R = {
|
|
189
|
+
file: "video",
|
|
190
|
+
pointerFields: ["resourceId", "workerUrl", "plaintextHash", "chunks", "size", "chunkSize"],
|
|
191
|
+
passages: { tag: "subtitles", rel: "subtitles", parentField: "videoPath", idSuffix: "#subtitles", field: "cues", embedField: "cueEmbed" },
|
|
192
|
+
};
|
|
193
|
+
const pub = (resourceId, hash) => ({ resourceId, workerUrl: "w", plaintextHash: hash });
|
|
194
|
+
const nodes = [
|
|
195
|
+
{ path: "Show", name: "Show", type: "folder" },
|
|
196
|
+
{ path: "Show/S1", name: "S1", type: "folder" },
|
|
197
|
+
{ path: "Show/S1/b.mp4", name: "b.mp4", type: "video", mime: "video/mp4", price: "1000", published: pub("0xbb", "0x02"), cues: [{ start: 0, end: 2, text: "four to the floor" }] },
|
|
198
|
+
{ path: "Show/S1/a.mp4", name: "a.mp4", type: "video", mime: "video/mp4", price: "1000", published: pub("0xaa", "0x01") },
|
|
199
|
+
{ path: "Show/empty", name: "empty", type: "folder" }, // no published file → pruned
|
|
200
|
+
{ path: "draft.mp4", name: "draft.mp4", type: "video", price: "1000" }, // unpublished → dropped
|
|
201
|
+
];
|
|
202
|
+
const g = buildTreeGraph(nodes, SOND3R);
|
|
203
|
+
if (g.vertices.some((v) => v.id === "Show/empty")) throw new Error("empty folder not pruned");
|
|
204
|
+
if (g.vertices.some((v) => v.id === "draft.mp4")) throw new Error("unpublished file not dropped");
|
|
205
|
+
|
|
206
|
+
// Catalog entries: in the graph, searchable, carrying NO purchase pointer.
|
|
207
|
+
// This is the bulk-ingest path — a vertex that costs no per-item transaction —
|
|
208
|
+
// so the thing to guard is that it never looks buyable to anything downstream.
|
|
209
|
+
const withEntry = buildTreeGraph([
|
|
210
|
+
...nodes,
|
|
211
|
+
{ path: "Papers", name: "Papers", type: "folder" },
|
|
212
|
+
{ path: "Papers/abstract.txt", name: "abstract.txt", type: "video", mime: "text/plain", price: "1000", forSale: false, desc: "on umwelt", embed: { model: "m", dim: 256, vec: "AA==" }, url: "https://archive.org/download/x/y.pdf" },
|
|
213
|
+
], SOND3R);
|
|
214
|
+
const entry = withEntry.vertices.find((v) => v.id === "Papers/abstract.txt");
|
|
215
|
+
if (!entry) throw new Error("catalog entry dropped from the graph");
|
|
216
|
+
if (entry.payload.desc !== "on umwelt" || entry.payload.embed?.vec !== "AA==") throw new Error("a catalog entry must keep the semantic half — it is the only half it has");
|
|
217
|
+
if ("resourceId" in entry.payload) throw new Error("a catalog entry must carry no resourceId — that is what makes it free to publish");
|
|
218
|
+
if ("price" in entry.payload) throw new Error("a price with nothing to buy is a lie");
|
|
219
|
+
if (entry.payload.url !== "https://archive.org/download/x/y.pdf") throw new Error("free content's url lost — the entry is findable but unplayable without it");
|
|
220
|
+
if (!withEntry.vertices.some((v) => v.id === "Papers")) throw new Error("a folder holding only catalog entries was pruned");
|
|
221
|
+
if (!withEntry.edges.some((e) => e.rel === "contains" && e.from === "Papers" && e.to === "Papers/abstract.txt")) throw new Error("catalog entry not linked to its folder");
|
|
222
|
+
if (withEntry.vertices.some((v) => v.id === "draft.mp4")) throw new Error("forSale defaulting changed — staged files must stay private");
|
|
223
|
+
|
|
224
|
+
const contents = { vertices: g.vertices.map((v) => ({ payload: v.payload })) };
|
|
225
|
+
const tree = treeFromGraph(contents);
|
|
226
|
+
const s1 = tree[0].children.find((c) => c.name === "S1");
|
|
227
|
+
if (tree[0].name !== "Show" || !s1) throw new Error("structure lost");
|
|
228
|
+
if (s1.children.map((c) => c.name).join(",") !== "a.mp4,b.mp4") throw new Error("files not sorted/nested");
|
|
229
|
+
if (s1.children[0].resourceId !== "0xaa") throw new Error("file pointer lost");
|
|
230
|
+
|
|
231
|
+
// Passages become their own vertex, reachable from the file, and must NOT show
|
|
232
|
+
// up as a phantom entry in the viewer's folder tree.
|
|
233
|
+
const subs = g.vertices.find((v) => v.id === passagesId("Show/S1/b.mp4", "#subtitles"));
|
|
234
|
+
if (!subs || subs.payload.cues[0].text !== "four to the floor") throw new Error("passages lost");
|
|
235
|
+
if (subs.payload.videoPath !== "Show/S1/b.mp4") throw new Error("back-pointer lost — the passages belong to no file");
|
|
236
|
+
if (!g.edges.some((e) => e.rel === "subtitles" && e.from === "Show/S1/b.mp4" && e.to === subs.id)) throw new Error("file→passages edge missing");
|
|
237
|
+
if (s1.children.length !== 2) throw new Error("passages vertex leaked into the tree");
|
|
238
|
+
|
|
239
|
+
// Semantic content rides into the graph: the description on the file vertex,
|
|
240
|
+
// the vectors alongside the passages that produced them. Absent when not
|
|
241
|
+
// supplied — an undefined `embed` key would serialize into every payload.
|
|
242
|
+
const withMeta = buildTreeGraph(nodes.map((n) => (n.path === "Show/S1/b.mp4"
|
|
243
|
+
? { ...n, desc: "deep spanish house", embed: { model: "m", dim: 256, vec: "AA==" }, cueEmbed: { model: "m", dim: 256, vecs: ["AA=="] } }
|
|
244
|
+
: n)), SOND3R);
|
|
245
|
+
const b = withMeta.vertices.find((v) => v.id === "Show/S1/b.mp4").payload;
|
|
246
|
+
if (b.desc !== "deep spanish house") throw new Error("description lost on the way to the graph");
|
|
247
|
+
if (b.embed?.vec !== "AA==") throw new Error("file vector lost on the way to the graph");
|
|
248
|
+
const bs = withMeta.vertices.find((v) => v.id === passagesId("Show/S1/b.mp4", "#subtitles")).payload;
|
|
249
|
+
if (bs.embed?.vecs?.[0] !== "AA==") throw new Error("passage vectors lost on the way to the graph");
|
|
250
|
+
const a = withMeta.vertices.find((v) => v.id === "Show/S1/a.mp4").payload;
|
|
251
|
+
if ("desc" in a || "embed" in a) throw new Error("empty semantic fields must be omitted, not serialized");
|
|
252
|
+
|
|
253
|
+
// A pinned pointer list drops everything else, so a field added upstream (the
|
|
254
|
+
// relay stamps `enc` and `mime` onto `published`) cannot silently start
|
|
255
|
+
// appearing in committed payloads.
|
|
256
|
+
const stamped = buildTreeGraph([{ path: "x.mp4", name: "x.mp4", type: "video", price: "1", published: { ...pub("0xcc", "0x03"), chunks: 2, size: 9, chunkSize: 4, enc: "v2", mime: "video/mp4" } }], SOND3R);
|
|
257
|
+
const xp = stamped.vertices[0].payload;
|
|
258
|
+
if ("enc" in xp) throw new Error("an unlisted pointer field reached the payload");
|
|
259
|
+
if (xp.chunks !== 2 || xp.chunkSize !== 4 || xp.size !== 9) throw new Error("chunk geometry lost — a buyer cannot reassemble the file");
|
|
260
|
+
// …and with no pin, the blob is opaque and passes through whole.
|
|
261
|
+
const loose = buildTreeGraph([{ path: "n.md", name: "n.md", type: "file", forSale: false, published: { at: "ipfs://x" } }]);
|
|
262
|
+
if (loose.vertices[0].payload.at !== "ipfs://x") throw new Error("an unpinned pointer must pass through whole");
|
|
263
|
+
|
|
264
|
+
// ── and the same tree under the defaults a new app takes ────────────────
|
|
265
|
+
// The whole reason this file is parameterized: an app with no sale and no
|
|
266
|
+
// timeline gets vertices with no app-specific spelling anywhere in them.
|
|
267
|
+
const wiki = buildTreeGraph([
|
|
268
|
+
{ path: "notes", name: "notes", type: "folder" },
|
|
269
|
+
{ path: "notes/umwelt.md", name: "umwelt.md", type: "file", mime: "text/markdown", forSale: false, desc: "on umwelt", cues: [{ start: 0, text: "ignored" }] },
|
|
270
|
+
]);
|
|
271
|
+
const note = wiki.vertices.find((v) => v.id === "notes/umwelt.md");
|
|
272
|
+
if (note.tag !== "file" || note.payload.kind !== "file") throw new Error("default tag must be `file`, not an app's spelling");
|
|
273
|
+
if (wiki.vertices.some((v) => v.tag === "subtitles" || v.id.includes("#"))) throw new Error("passages: null must emit no sidecar, even when the node carries cues");
|
|
274
|
+
if (buildTreeGraph(nodes).vertices.length) throw new Error("sond3r's `video` nodes must not publish under the default file type");
|
|
275
|
+
|
|
276
|
+
// A FLAT file list — no folder nodes at all, which is what a headless
|
|
277
|
+
// publisher sends. Every `contains` edge must land on a vertex that exists,
|
|
278
|
+
// because the chain SDK rejects the commit otherwise and does it AFTER the
|
|
279
|
+
// bytes have been encrypted, uploaded and paid for in bandwidth.
|
|
280
|
+
{
|
|
281
|
+
const g = buildTreeGraph([
|
|
282
|
+
{ path: "supercuts/a.mp4", name: "a.mp4", type: "video", desc: "x", published: pub("0xaa", "0x01") },
|
|
283
|
+
{ path: "deep/nest/b.mp4", name: "b.mp4", type: "video", desc: "y", forSale: false },
|
|
284
|
+
], SOND3R);
|
|
285
|
+
const ids = new Set(g.vertices.map((v) => v.id));
|
|
286
|
+
for (const e of g.edges) {
|
|
287
|
+
if (!ids.has(e.from)) throw new Error(`edge from a vertex that was never emitted: ${e.from}`);
|
|
288
|
+
if (e.rel === "contains" && !ids.has(e.to)) throw new Error(`contains edge to nothing: ${e.to}`);
|
|
289
|
+
}
|
|
290
|
+
for (const f of ["supercuts", "deep", "deep/nest"]) {
|
|
291
|
+
if (!ids.has(f)) throw new Error(`ancestor ${f} must be emitted as a folder vertex`);
|
|
292
|
+
}
|
|
293
|
+
if (!g.edges.some((e) => e.rel === "contains" && e.from === "deep" && e.to === "deep/nest")) {
|
|
294
|
+
throw new Error("a synthesized folder must be contained by its own parent");
|
|
295
|
+
}
|
|
296
|
+
const order = g.vertices.map((v) => v.id);
|
|
297
|
+
if (order.indexOf("deep") > order.indexOf("deep/nest")) throw new Error("a parent must be emitted before its child");
|
|
298
|
+
// A caller that DOES describe its folders must not get them twice.
|
|
299
|
+
const both = buildTreeGraph([
|
|
300
|
+
{ path: "Show", name: "Show", type: "folder" },
|
|
301
|
+
{ path: "Show/b.mp4", name: "b.mp4", type: "video", desc: "z", forSale: false },
|
|
302
|
+
], SOND3R);
|
|
303
|
+
if (both.vertices.filter((v) => v.id === "Show").length !== 1) throw new Error("a described folder must not be duplicated");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log("graph.js self-check ok — sond3r's pinned names, passages vertex + edge + tree exclusion, catalog entries carry no pointer, a flat file list still gets its folder vertices, defaults stay app-neutral");
|
|
307
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The publisher's working library, kept in their own bucket.
|
|
2
|
+
//
|
|
3
|
+
// It records what a relay must never hold: the per-file price, description, and
|
|
4
|
+
// the stable `uid` that keeps a file's PAID identity across a rename. Storing it
|
|
5
|
+
// in the bucket rather than on the relay is what lets a library follow its
|
|
6
|
+
// publisher between machines, and what stops the relay from being the thing that
|
|
7
|
+
// remembers who sells what.
|
|
8
|
+
//
|
|
9
|
+
// It is a CACHE of something already public — the commit graph carries every
|
|
10
|
+
// published file's path, price, description and pointer — so an empty bucket is
|
|
11
|
+
// recoverable (`manifestFromTree`) rather than fatal.
|
|
12
|
+
//
|
|
13
|
+
// `ns` is the app's namespace and is NOT defaulted, for the same reason
|
|
14
|
+
// envelope.js refuses to default it: the key is derived from it, so a changed ns
|
|
15
|
+
// silently addresses a DIFFERENT manifest and the next publish re-mints every
|
|
16
|
+
// uid in a library that looks empty.
|
|
17
|
+
import { keccak256, stringToBytes } from "viem";
|
|
18
|
+
import { putChunk } from "./envelope.js";
|
|
19
|
+
|
|
20
|
+
/** R2 key for `owner`'s manifest under app `ns`. A bytes32, because that is the
|
|
21
|
+
* only key shape the worker accepts — see isObjectKey. */
|
|
22
|
+
export const manifestKey = (ns, owner) => keccak256(stringToBytes(`${ns}:manifest:${owner.toLowerCase()}`));
|
|
23
|
+
|
|
24
|
+
// A FUNCTION, not a constant. `{ ...EMPTY }` is a shallow copy — every caller
|
|
25
|
+
// would share one `files` object, so in any process that serves more than one
|
|
26
|
+
// publisher a first-publish read hands back the previous publisher's staged
|
|
27
|
+
// entries and the next write commits them to the wrong library.
|
|
28
|
+
const empty = () => ({ files: {} });
|
|
29
|
+
|
|
30
|
+
/** Read `owner`'s manifest back. A worker that has never seen one answers 404,
|
|
31
|
+
* which is the ordinary first-publish state, not an error. */
|
|
32
|
+
export async function readManifest({ ns, workerUrl, uploadToken, owner }) {
|
|
33
|
+
const res = await fetch(`${workerUrl}/upload/${manifestKey(ns, owner)}`, {
|
|
34
|
+
headers: uploadToken ? { Authorization: `Bearer ${uploadToken}` } : {},
|
|
35
|
+
});
|
|
36
|
+
if (res.status === 404) return empty();
|
|
37
|
+
if (!res.ok) throw new Error(`could not read your library manifest from ${workerUrl}: ${res.status} ${await res.text()}`);
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(new TextDecoder().decode(await res.arrayBuffer()));
|
|
40
|
+
} catch {
|
|
41
|
+
// Better to say so than to hand back an empty manifest, which would look
|
|
42
|
+
// like an empty library and re-mint every uid on the next publish.
|
|
43
|
+
throw new Error(`your library manifest at ${workerUrl} is corrupt — it did not parse as JSON`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeManifest({ ns, workerUrl, uploadToken, owner, manifest }) {
|
|
48
|
+
await putChunk({
|
|
49
|
+
workerUrl, uploadToken, key: manifestKey(ns, owner),
|
|
50
|
+
body: new TextEncoder().encode(JSON.stringify(manifest, null, 2)),
|
|
51
|
+
});
|
|
52
|
+
return manifest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const newUid = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rebuild manifest entries from a publisher's own on-chain library tree.
|
|
59
|
+
*
|
|
60
|
+
* Two things it deliberately does not recover:
|
|
61
|
+
* - `uid`. resourceId is keccak(owner ++ keccak(ns+":"+uid)), which is one-way.
|
|
62
|
+
* It does not matter: identity is carried by `published.resourceId`, and every
|
|
63
|
+
* path that could re-derive an id prefers the recorded one. So a fresh uid on
|
|
64
|
+
* a recovered entry never changes what a buyer already paid for.
|
|
65
|
+
* - Unpublished files. They were never on-chain.
|
|
66
|
+
*/
|
|
67
|
+
export function manifestFromTree(files, { defaultPrice = "1000", newUid: uid = newUid } = {}) {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const n of files ?? []) {
|
|
70
|
+
if (!n?.path) continue;
|
|
71
|
+
out[n.path] = {
|
|
72
|
+
uid: uid(),
|
|
73
|
+
...(n.mime ? { mime: n.mime } : {}),
|
|
74
|
+
...(n.desc ? { desc: n.desc } : {}),
|
|
75
|
+
price: String(n.price ?? defaultPrice),
|
|
76
|
+
// No resourceId in the payload means a free catalog entry — that
|
|
77
|
+
// absence is the signal everywhere downstream, so it round-trips as
|
|
78
|
+
// `forSale: false` rather than as a price with nothing to buy.
|
|
79
|
+
...(n.resourceId
|
|
80
|
+
? {
|
|
81
|
+
published: {
|
|
82
|
+
resourceId: n.resourceId, workerUrl: n.workerUrl, plaintextHash: n.plaintextHash,
|
|
83
|
+
chunks: n.chunks ?? 1, size: n.size, chunkSize: n.chunkSize, mime: n.mime,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
: { forSale: false }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── self-check: `node publish/manifest.js` ───────────────────────────────────
|
|
93
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
94
|
+
const assert = (c, m) => { if (!c) throw new Error(m); };
|
|
95
|
+
|
|
96
|
+
// PINNED. sond3r's manifests are already in R2 under this exact key; if this
|
|
97
|
+
// value moves, every existing publisher's library reads back as empty and the
|
|
98
|
+
// next publish re-mints every uid they own.
|
|
99
|
+
const owner = "0x147c24c5Ea2f1EE1ac42AD16820De23bBba45Ef6";
|
|
100
|
+
assert(manifestKey("sond3r", owner) === keccak256(stringToBytes(`sond3r:manifest:${owner.toLowerCase()}`)),
|
|
101
|
+
"the sond3r manifest key must not move");
|
|
102
|
+
assert(manifestKey("sond3r", owner) === manifestKey("sond3r", owner.toLowerCase()),
|
|
103
|
+
"address casing is not identity");
|
|
104
|
+
assert(manifestKey("sond3r", owner) !== manifestKey("other", owner),
|
|
105
|
+
"two apps must not share one manifest key");
|
|
106
|
+
|
|
107
|
+
// A 404 is the first-publish state, and it must not be confused with a read
|
|
108
|
+
// that failed — one returns an empty library, the other has to raise.
|
|
109
|
+
const real = globalThis.fetch;
|
|
110
|
+
try {
|
|
111
|
+
globalThis.fetch = async () => ({ status: 404 });
|
|
112
|
+
assert((await readManifest({ ns: "x", workerUrl: "http://w", owner })).files &&
|
|
113
|
+
Object.keys((await readManifest({ ns: "x", workerUrl: "http://w", owner })).files).length === 0,
|
|
114
|
+
"404 is an empty library");
|
|
115
|
+
globalThis.fetch = async () => ({ status: 500, ok: false, text: async () => "boom" });
|
|
116
|
+
await readManifest({ ns: "x", workerUrl: "http://w", owner }).then(
|
|
117
|
+
() => { throw new Error("a failed read must raise, not look empty"); },
|
|
118
|
+
(e) => assert(/could not read/.test(e.message), e.message));
|
|
119
|
+
globalThis.fetch = async () => ({ status: 200, ok: true, arrayBuffer: async () => new TextEncoder().encode("{not json").buffer });
|
|
120
|
+
await readManifest({ ns: "x", workerUrl: "http://w", owner }).then(
|
|
121
|
+
() => { throw new Error("corrupt JSON must raise — an empty manifest re-mints every uid"); },
|
|
122
|
+
(e) => assert(/corrupt/.test(e.message), e.message));
|
|
123
|
+
|
|
124
|
+
// The empty read must not be a shared object two callers can edit.
|
|
125
|
+
globalThis.fetch = async () => ({ status: 404 });
|
|
126
|
+
const a = await readManifest({ ns: "x", workerUrl: "http://w", owner });
|
|
127
|
+
a.files.poisoned = true;
|
|
128
|
+
assert(!(await readManifest({ ns: "x", workerUrl: "http://w", owner })).files.poisoned,
|
|
129
|
+
"the empty manifest must be a fresh object");
|
|
130
|
+
} finally { globalThis.fetch = real; }
|
|
131
|
+
|
|
132
|
+
// A tree with no resourceId is a free catalog entry, not a price with
|
|
133
|
+
// nothing behind it.
|
|
134
|
+
const built = manifestFromTree(
|
|
135
|
+
[{ path: "a.mp4", resourceId: "0xabc", chunks: 2, price: 500 }, { path: "b.txt" }, { bad: 1 }],
|
|
136
|
+
{ newUid: () => "uid" });
|
|
137
|
+
assert(Object.keys(built).length === 2, "a node with no path is not a file");
|
|
138
|
+
assert(built["a.mp4"].published.resourceId === "0xabc" && built["a.mp4"].price === "500", "a sold file keeps its pointer");
|
|
139
|
+
assert(built["b.txt"].forSale === false && !built["b.txt"].published, "no resourceId means a free catalog entry");
|
|
140
|
+
|
|
141
|
+
console.log("manifest.js self-check ok — key pinned + ns-scoped, 404 vs failure, corrupt read raises, free entries");
|
|
142
|
+
}
|