@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.
@@ -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
+ }