@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// What a file is ABOUT, read from the file itself.
|
|
2
|
+
//
|
|
3
|
+
// fileText() (shard.js) is the honest floor: a name, its folders, and whatever
|
|
4
|
+
// the publisher typed. For most libraries that's a filename and nothing else,
|
|
5
|
+
// and a vector built from a filename ranks about as well as one — measured on
|
|
6
|
+
// this storefront, "philosophy reading" put a metalcore mp3 above a
|
|
7
|
+
// phenomenology PDF, and "something spacey" ranked milky-way.mp4 dead last.
|
|
8
|
+
//
|
|
9
|
+
// The file usually knows better than its name. A PDF carries its real title
|
|
10
|
+
// ("Jakob_von_Uexkull_Beyond_Bubbles_On_Umwe.pdf" is truncated mid-word; the
|
|
11
|
+
// document says "Beyond Bubbles: On Umwelt and Biophilosophy" — the two concept
|
|
12
|
+
// words the filename lost). An mp3 carries artist, album and genre. Reading
|
|
13
|
+
// them moved top-1 accuracy on an 11-query probe from 7/11 to 9/11, where
|
|
14
|
+
// filename cleanup alone moved it 7/11 → 7/11.
|
|
15
|
+
//
|
|
16
|
+
// This runs at publish time on the publisher's own machine, over a file already
|
|
17
|
+
// being read to encrypt and upload. Extraction is stdlib-only and every step is
|
|
18
|
+
// best-effort: a malformed PDF, an exotic tag, an unreadable file all fall back
|
|
19
|
+
// to the name, because a weaker vector is not a reason to fail a publish.
|
|
20
|
+
//
|
|
21
|
+
// ponytail: format sniffing by mime, no parser dependencies. PDF and ID3 cover
|
|
22
|
+
// the text and music cases. Images and speech need a model, not a parser — see
|
|
23
|
+
// the note at the bottom.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { inflateSync } from "node:zlib";
|
|
27
|
+
|
|
28
|
+
/** How much extracted content joins the text. The vector is 256-d and averaged
|
|
29
|
+
* over the whole document, so a long tail of boilerplate dilutes the title it
|
|
30
|
+
* was meant to reinforce. */
|
|
31
|
+
export const CONTENT_CHARS = 600;
|
|
32
|
+
|
|
33
|
+
/** Filename → words. Extensions, separators and release-note brackets are
|
|
34
|
+
* tokens the model has to average over and none of them mean anything:
|
|
35
|
+
* "Bilmuri - KINDA HARD [OFFICIAL VISUALIZER].mp3" → "Bilmuri KINDA HARD". */
|
|
36
|
+
export function nameWords(s) {
|
|
37
|
+
return String(s)
|
|
38
|
+
.replace(/\.[a-z0-9]{1,5}$/i, "") // extension
|
|
39
|
+
.replace(/\[[^\]]*\]|\([^)]*\)/g, " ") // [OFFICIAL VISUALIZER], (ver 2 by …)
|
|
40
|
+
.replace(/[_\-.]+/g, " ")
|
|
41
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase
|
|
42
|
+
.replace(/\s+/g, " ").trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The mime never reached the embedded text at all, which is why "movies"
|
|
46
|
+
// matched nothing in a catalog full of video/mp4. Plain words, not mime
|
|
47
|
+
// strings — the query side says "movie", never "video/mp4".
|
|
48
|
+
//
|
|
49
|
+
// Kept short on purpose: a long shared phrase on every video pulls all videos
|
|
50
|
+
// toward each other and away from what makes each one different.
|
|
51
|
+
const KIND = [
|
|
52
|
+
[/^audio\//, "music track"],
|
|
53
|
+
[/^video\//, "video, a movie you watch"],
|
|
54
|
+
[/^image\//, "picture, a photo you look at"],
|
|
55
|
+
[/pdf$/, "PDF document you read"],
|
|
56
|
+
[/^text\//, "text document"],
|
|
57
|
+
];
|
|
58
|
+
export const kindWords = (mime = "") => KIND.find(([re]) => re.test(String(mime)))?.[1] ?? "";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* PDF → its title and the top of its text.
|
|
62
|
+
*
|
|
63
|
+
* Enough of a reader to find words, not a PDF library: pull `/Title` from the
|
|
64
|
+
* info dictionary, then inflate the first few content streams and keep what's
|
|
65
|
+
* inside the string literals the text operators draw. Everything that isn't a
|
|
66
|
+
* flate stream is skipped silently, which is most of a PDF.
|
|
67
|
+
*
|
|
68
|
+
* ponytail: first streams, not first page — page order isn't stream order, so
|
|
69
|
+
* this can catch a cover page instead of an abstract. It still beat the
|
|
70
|
+
* filename by two ranks. Reach for a real parser when a measured miss says to.
|
|
71
|
+
*/
|
|
72
|
+
export function pdfText(buf, max = CONTENT_CHARS) {
|
|
73
|
+
const s = buf.toString("latin1");
|
|
74
|
+
const title = /\/Title\s*\(([^)]{0,200})\)/.exec(s)?.[1] ?? "";
|
|
75
|
+
let out = "", streams = 0;
|
|
76
|
+
for (const m of s.matchAll(/stream\r?\n/g)) {
|
|
77
|
+
if (streams >= 3 || out.length > max) break;
|
|
78
|
+
const start = m.index + m[0].length;
|
|
79
|
+
const end = s.indexOf("endstream", start);
|
|
80
|
+
if (end < 0) break;
|
|
81
|
+
try {
|
|
82
|
+
const page = inflateSync(buf.subarray(start, end)).toString("latin1");
|
|
83
|
+
// Text lives inside ( … ) literals; \) is an escaped paren, not a close.
|
|
84
|
+
const text = [...page.matchAll(/\((?:\\.|[^\\)])*\)/g)].map((x) => x[0].slice(1, -1)).join("");
|
|
85
|
+
if (text.length > 80) { out += `${text} `; streams++; }
|
|
86
|
+
} catch { /* not a flate stream — most of them aren't */ }
|
|
87
|
+
}
|
|
88
|
+
return `${title} ${out}`.replace(/\\\d{3}|\s+/g, " ").trim().slice(0, max);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The four ID3v2 frames that say what a track is. TCON is genre, which is the
|
|
92
|
+
// one a query like "something mellow" can actually reach.
|
|
93
|
+
const ID3_FRAMES = { TIT2: 1, TPE1: 1, TALB: 1, TCON: 1 };
|
|
94
|
+
|
|
95
|
+
/** mp3 → "title artist album genre" from its ID3v2 header, or "". */
|
|
96
|
+
export function id3Text(buf) {
|
|
97
|
+
if (buf.length < 10 || buf.toString("latin1", 0, 3) !== "ID3") return "";
|
|
98
|
+
// Syncsafe integer: 7 bits per byte, so a size can never contain 0xFF and be
|
|
99
|
+
// mistaken for a frame sync. Masking is not optional.
|
|
100
|
+
const size = ((buf[6] & 127) << 21) | ((buf[7] & 127) << 14) | ((buf[8] & 127) << 7) | (buf[9] & 127);
|
|
101
|
+
const end = Math.min(10 + size, buf.length);
|
|
102
|
+
const out = [];
|
|
103
|
+
let o = 10;
|
|
104
|
+
while (o + 10 <= end) {
|
|
105
|
+
const id = buf.toString("latin1", o, o + 4);
|
|
106
|
+
if (!/^[A-Z0-9]{4}$/.test(id)) break; // padding, or v2.2 — either way, done
|
|
107
|
+
const len = buf.readUInt32BE(o + 4);
|
|
108
|
+
if (len <= 0 || o + 10 + len > end) break;
|
|
109
|
+
if (ID3_FRAMES[id]) {
|
|
110
|
+
// Byte 0 of a text frame is its encoding; utf8 decoding of latin1 and
|
|
111
|
+
// utf16 payloads is wrong in ways that are still more searchable than
|
|
112
|
+
// nothing, so strip the nulls and move on.
|
|
113
|
+
const v = buf.toString("utf8", o + 11, o + 10 + len).replace(/\0/g, " ").trim();
|
|
114
|
+
if (v) out.push(v);
|
|
115
|
+
}
|
|
116
|
+
o += 10 + len;
|
|
117
|
+
}
|
|
118
|
+
return out.join(" ").slice(0, CONTENT_CHARS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read whatever the bytes will tell us. Never throws — an unreadable or
|
|
122
|
+
* unsupported file simply contributes nothing. */
|
|
123
|
+
export function contentText(abs, mime = "") {
|
|
124
|
+
try {
|
|
125
|
+
if (/pdf$/.test(mime)) return pdfText(readFileSync(abs));
|
|
126
|
+
if (/^audio\/(mpeg|mp3)$/.test(mime)) return id3Text(readFileSync(abs));
|
|
127
|
+
} catch { /* missing, unreadable, or not what its extension claims */ }
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The text a buyer's query has to match, for one file.
|
|
133
|
+
*
|
|
134
|
+
* Ordered strongest-signal-first — mean pooling doesn't care about position,
|
|
135
|
+
* but the truncation at CONTENT_CHARS does, and what gets cut should be the
|
|
136
|
+
* boilerplate at the end of a cover page, not the title.
|
|
137
|
+
*
|
|
138
|
+
* @param abs absolute path, or null to skip content extraction (the shard bake
|
|
139
|
+
* reads from the chain and has no files).
|
|
140
|
+
*/
|
|
141
|
+
export function enrichText({ name, path, desc, mime, abs = null }) {
|
|
142
|
+
const folders = path.includes("/") ? path.slice(0, path.lastIndexOf("/")).split("/") : [];
|
|
143
|
+
return [
|
|
144
|
+
nameWords(name),
|
|
145
|
+
...folders.map(nameWords),
|
|
146
|
+
desc,
|
|
147
|
+
kindWords(mime),
|
|
148
|
+
abs ? contentText(abs, mime) : "",
|
|
149
|
+
].filter(Boolean).join(" — ");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Images and dialogue-free video are the gap this can't close: there is nothing
|
|
153
|
+
// in a JPEG's bytes that says "scifi western". That needs a captioning model,
|
|
154
|
+
// which is a real dependency and a real runtime, not a parser — and it belongs
|
|
155
|
+
// in the same browser that already runs the embedder at publish time.
|
|
156
|
+
|
|
157
|
+
// ── self-check: `node server/enrich.js` — no files, no model ──────────────────
|
|
158
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
159
|
+
const { deflateSync } = await import("node:zlib");
|
|
160
|
+
const eq = (got, want, msg) => { if (got !== want) throw new Error(`${msg}: got ${JSON.stringify(got)}`); };
|
|
161
|
+
|
|
162
|
+
eq(nameWords("Bilmuri - KINDA HARD [OFFICIAL VISUALIZER].mp3"), "Bilmuri KINDA HARD", "release junk survived");
|
|
163
|
+
eq(nameWords("Jakob_von_Uexkull_Beyond_Bubbles.pdf"), "Jakob von Uexkull Beyond Bubbles", "underscores survived");
|
|
164
|
+
eq(nameWords("milky-way.mp4"), "milky way", "hyphens survived");
|
|
165
|
+
eq(nameWords("myGreatFile.mp4"), "my Great File", "camelCase not split");
|
|
166
|
+
// A name that is nothing but junk must not become empty and take the file
|
|
167
|
+
// out of the index entirely.
|
|
168
|
+
if (!nameWords("track (2).gp5").length) throw new Error("stripping must not empty a name");
|
|
169
|
+
|
|
170
|
+
// The bug that started this: mime never reached the text, so "movies" had
|
|
171
|
+
// nothing to match in a catalog full of video/mp4.
|
|
172
|
+
if (!kindWords("video/mp4").includes("movie")) throw new Error("video must read as a movie");
|
|
173
|
+
if (!kindWords("application/pdf").includes("read")) throw new Error("pdf must read as a document");
|
|
174
|
+
eq(kindWords("application/x-gp5"), "", "unknown mime must add nothing rather than guess");
|
|
175
|
+
eq(kindWords(), "", "missing mime must not throw");
|
|
176
|
+
|
|
177
|
+
// A minimal PDF: info dict + one flate content stream.
|
|
178
|
+
const body = deflateSync(Buffer.from("BT (Beyond Bubbles: On Umwelt and Biophilosophy, a review of the work of Jakob von Uexkull) Tj ET"));
|
|
179
|
+
const pdf = Buffer.concat([Buffer.from("%PDF-1.4\n/Title (REVIEW)\n5 0 obj\n<<>>\nstream\n", "latin1"), body, Buffer.from("\nendstream\n", "latin1")]);
|
|
180
|
+
const got = pdfText(pdf);
|
|
181
|
+
if (!got.includes("REVIEW")) throw new Error(`/Title lost: ${got}`);
|
|
182
|
+
if (!got.includes("Umwelt")) throw new Error(`stream text lost: ${got}`);
|
|
183
|
+
if (pdfText(Buffer.from("not a pdf at all")) !== "") throw new Error("garbage must extract to nothing, not throw");
|
|
184
|
+
if (pdfText(Buffer.from("%PDF-1.4 stream\nnot deflate data endstream")) !== "") throw new Error("undeflatable stream must be skipped");
|
|
185
|
+
if (pdfText(pdf, 10).length > 10) throw new Error("max not honoured");
|
|
186
|
+
|
|
187
|
+
// ID3v2: syncsafe size, four frames, one ignored.
|
|
188
|
+
const frame = (id, text) => { const v = Buffer.from(`\0${text}`, "latin1"); const h = Buffer.alloc(10); h.write(id, 0, "latin1"); h.writeUInt32BE(v.length, 4); return Buffer.concat([h, v]); };
|
|
189
|
+
const frames = Buffer.concat([frame("TIT2", "Kinda Hard"), frame("TPE1", "Bilmuri"), frame("TCON", "Metalcore"), frame("TENC", "ignored")]);
|
|
190
|
+
const head = Buffer.alloc(10); head.write("ID3", 0, "latin1"); head[3] = 3;
|
|
191
|
+
// Syncsafe: 200 is > 127, so it must occupy two 7-bit groups.
|
|
192
|
+
const n = frames.length; head[6] = (n >> 21) & 127; head[7] = (n >> 14) & 127; head[8] = (n >> 7) & 127; head[9] = n & 127;
|
|
193
|
+
const tag = id3Text(Buffer.concat([head, frames]));
|
|
194
|
+
eq(tag, "Kinda Hard Bilmuri Metalcore", "id3 frames wrong");
|
|
195
|
+
if (id3Text(Buffer.from("no tag here")) !== "") throw new Error("untagged mp3 must yield nothing, not throw");
|
|
196
|
+
if (id3Text(Buffer.alloc(3)) !== "") throw new Error("truncated file must not read past its end");
|
|
197
|
+
// A frame claiming to run past the tag must stop the walk, not read garbage.
|
|
198
|
+
const bad = Buffer.concat([head, frame("TIT2", "x")]); bad.writeUInt32BE(9999, 14);
|
|
199
|
+
if (id3Text(bad) !== "") throw new Error("overlong frame must be rejected");
|
|
200
|
+
|
|
201
|
+
// enrichText: order and graceful degradation.
|
|
202
|
+
const t = enrichText({ name: "milky-way.mp4", path: "videos/milky-way.mp4", mime: "video/mp4" });
|
|
203
|
+
if (!t.startsWith("milky way")) throw new Error(`name must lead: ${t}`);
|
|
204
|
+
if (!t.includes("videos") || !t.includes("movie")) throw new Error(`folder and kind missing: ${t}`);
|
|
205
|
+
// No abs path (the shard bake has no files) must still produce usable text.
|
|
206
|
+
if (enrichText({ name: "a.mp3", path: "a.mp3", mime: "audio/mpeg" }) !== "a — music track") throw new Error("bare file text wrong");
|
|
207
|
+
// A file that vanished between prepare and embed must not fail the publish.
|
|
208
|
+
if (!enrichText({ name: "gone.pdf", path: "x/gone.pdf", mime: "application/pdf", abs: "/nope/gone.pdf" }).includes("gone")) throw new Error("missing file must degrade to the name");
|
|
209
|
+
|
|
210
|
+
console.log("enrich.js self-check ok — name hygiene, kind words, pdf title+text, id3 frames, degradation");
|
|
211
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The on-the-wire envelope format, shared by everything that writes it.
|
|
2
|
+
//
|
|
3
|
+
// `ns` — the app's namespace string — is the first argument of the two id
|
|
4
|
+
// derivations and it is NOT defaulted. It is folded into every resourceId ever
|
|
5
|
+
// minted, so it is permanent: changing an app's ns orphans everything that app
|
|
6
|
+
// already published, and two apps that accidentally share one mint into each
|
|
7
|
+
// other's id space. An app pins it once (sond3r: src/pay/envelope.js) and never
|
|
8
|
+
// again. A default here would be a way to pick it by accident.
|
|
9
|
+
//
|
|
10
|
+
// Three implementations used to need these constants to agree — server/settle.js
|
|
11
|
+
// (relay-side publish), src/pay/encrypt.js (browser-side publish) and src/pay/stream.js /
|
|
12
|
+
// src/pay/buy.js (the buyer, unpacking). They agree because they import from here.
|
|
13
|
+
// A mismatch is not a crash: chunk `i` decrypts to garbage, or a key lands where
|
|
14
|
+
// the worker's /access gate isn't looking, and the file goes dark for everyone
|
|
15
|
+
// who paid for it.
|
|
16
|
+
//
|
|
17
|
+
// Browser-safe on purpose: viem only, no node builtins.
|
|
18
|
+
import { concat, encodePacked, hexToBytes, keccak256, numberToHex, stringToBytes } from "viem";
|
|
19
|
+
|
|
20
|
+
/** The bytes32 the publisher hands createResource. Stable per manifest uid. */
|
|
21
|
+
export const uidHashFor = (ns, uid) => keccak256(stringToBytes(`${ns}:${uid}`));
|
|
22
|
+
|
|
23
|
+
/** What that uid becomes on-chain: keccak(publisher ++ uid), derived by the
|
|
24
|
+
* registry (`resource_id_of`) and reproduced here. Two publishers with the same
|
|
25
|
+
* uid get two different resources, which is what makes an id impossible to
|
|
26
|
+
* squat — and it means this derivation must match the contract exactly, or a
|
|
27
|
+
* publish uploads ciphertext to a key no on-chain resource points at.
|
|
28
|
+
*
|
|
29
|
+
* It lives here rather than in server/index.js because the BROWSER derives it
|
|
30
|
+
* too now (it names the R2 key it uploads ciphertext to) and the server derives
|
|
31
|
+
* it when minting the resource. Two copies that disagreed would put the bytes
|
|
32
|
+
* and the resource at different ids, and the file would be unbuyable. Pinned
|
|
33
|
+
* against the registry's own view in sond3r's server/index.js --selfcheck. */
|
|
34
|
+
export const resourceIdFor = (ns, owner, uid) => keccak256(concat([owner, uidHashFor(ns, uid)]));
|
|
35
|
+
|
|
36
|
+
export const NONCE_LEN = 12;
|
|
37
|
+
|
|
38
|
+
/** 64MiB — under Cloudflare's 100MB body cap with room for nonce/tag/headers. */
|
|
39
|
+
export const CHUNK_SIZE = 64 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/** R2 key for chunk `i`. Chunk 0 is the resourceId itself, so the sealed DEK
|
|
42
|
+
* lands exactly where the worker's /access gate looks for it and a small file
|
|
43
|
+
* is still a single object. Every buyer-side unpacker must match this. */
|
|
44
|
+
export const chunkKey = (resourceId, i) =>
|
|
45
|
+
i === 0 ? resourceId : keccak256(encodePacked(["bytes32", "uint32"], [resourceId, i]));
|
|
46
|
+
|
|
47
|
+
/** AES-GCM additional data: the chunk index, big-endian. MUST match src/pay/buy.js. */
|
|
48
|
+
export const aadFor = (i) => hexToBytes(numberToHex(i, { size: 4 }));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compress-then-encrypt: what gets sealed is `flag(1) || bytes`, flag 1 = gzip.
|
|
52
|
+
* ponytail: gzip only when it actually shrinks. mp4/webm/jpeg are already
|
|
53
|
+
* compressed and gzip *grows* them, so the flag makes the store-raw case the
|
|
54
|
+
* fallback instead of a 0.03% tax. MUST match unpack() in src/pay/stream.js.
|
|
55
|
+
*/
|
|
56
|
+
export async function pack(plain) {
|
|
57
|
+
const gz = new Uint8Array(await new Response(
|
|
58
|
+
new Response(plain).body.pipeThrough(new CompressionStream("gzip")),
|
|
59
|
+
).arrayBuffer());
|
|
60
|
+
const win = gz.length < plain.length;
|
|
61
|
+
const out = new Uint8Array((win ? gz.length : plain.length) + 1);
|
|
62
|
+
out[0] = win ? 1 : 0;
|
|
63
|
+
out.set(win ? gz : plain, 1);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The inverse of pack(). It lives here, beside its inverse, because a packer and
|
|
69
|
+
* an unpacker in two repos is a format with two definitions — and the failure is
|
|
70
|
+
* not a crash but a file that uploads fine and is undecryptable forever.
|
|
71
|
+
*/
|
|
72
|
+
export async function unpack(bytes) {
|
|
73
|
+
if (bytes[0] === 0) return bytes.subarray(1);
|
|
74
|
+
// Not 0/1 means these bytes aren't packed at all — almost always ciphertext
|
|
75
|
+
// uploaded before the compression format landed. The publisher re-publishes;
|
|
76
|
+
// there is nothing the buyer can do.
|
|
77
|
+
if (bytes[0] !== 1) throw new Error(`unknown chunk encoding ${bytes[0]} — this file needs re-publishing`);
|
|
78
|
+
return new Uint8Array(await new Response(
|
|
79
|
+
new Response(bytes.subarray(1)).body.pipeThrough(new DecompressionStream("gzip")),
|
|
80
|
+
).arrayBuffer());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** GET the worker's static X25519 pubkey (what DEKs are sealed to). */
|
|
84
|
+
export async function getWorkerPubkey(workerUrl) {
|
|
85
|
+
const res = await fetch(`${workerUrl}/pubkey`);
|
|
86
|
+
if (!res.ok) throw new Error(`/pubkey failed: ${res.status}`);
|
|
87
|
+
const { pubkey } = await res.json();
|
|
88
|
+
return hexToBytes(pubkey);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* POST one already-encrypted chunk to the access worker's R2.
|
|
93
|
+
*
|
|
94
|
+
* `sealedDek` is omitted for objects that are the publisher's own state rather
|
|
95
|
+
* than something a buyer will pay for — the worker then writes no DEK object, so
|
|
96
|
+
* /access has nothing to release. See handleUpload in the worker.
|
|
97
|
+
*/
|
|
98
|
+
export async function putChunk({ workerUrl, uploadToken, key, body, sealedDek, immutable = false }) {
|
|
99
|
+
const res = await fetch(`${workerUrl}/upload/${key}`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": "application/octet-stream",
|
|
103
|
+
...(sealedDek ? { "X-Sealed-Dek": sealedDek } : {}),
|
|
104
|
+
// Only for a key that IS its content's digest. A resourceId is derived
|
|
105
|
+
// from (publisher, uid), so a re-publish rewrites it with new bytes
|
|
106
|
+
// under a new DEK, and an edge still serving the old ones hands every
|
|
107
|
+
// buyer ciphertext that decrypts to garbage. A shard named by its own
|
|
108
|
+
// sha256 cannot have that problem, and is the reason this flag exists.
|
|
109
|
+
...(immutable ? { "X-Immutable": "1" } : {}),
|
|
110
|
+
// The worker's upload gate. On a fresh publisher-owned worker the
|
|
111
|
+
// first request carrying this claims the bucket to it.
|
|
112
|
+
...(uploadToken ? { Authorization: `Bearer ${uploadToken}` } : {}),
|
|
113
|
+
},
|
|
114
|
+
body,
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
const text = await res.text();
|
|
118
|
+
// A quota refusal is the one upload failure a publisher can act on, so its
|
|
119
|
+
// message is passed through instead of buried in a URL and a status code.
|
|
120
|
+
const reason = (() => { try { return JSON.parse(text).error; } catch { return null; } })();
|
|
121
|
+
throw new Error(res.status === 413 && reason ? reason : `${workerUrl}/upload/${key}: ${res.status} ${text}`);
|
|
122
|
+
}
|
|
123
|
+
return res;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Delete every chunk of a published resource, and its sealed DEK, from the
|
|
128
|
+
* worker. Returns the number of chunks it asked the worker to drop.
|
|
129
|
+
*
|
|
130
|
+
* The chunk count comes from the caller's manifest rather than from the worker,
|
|
131
|
+
* because the worker deliberately won't synthesize keys (see handleDelete in
|
|
132
|
+
* webworker/fangorn-access-worker) — the key derivation lives here, next to the
|
|
133
|
+
* upload that used it.
|
|
134
|
+
*
|
|
135
|
+
* Failures are RAISED, not swallowed. A delete that half-worked must not be
|
|
136
|
+
* reported as freed space: the operator is still paying for whatever is left,
|
|
137
|
+
* and silently crediting the publisher for it is how the storage ledger drifts
|
|
138
|
+
* away from the R2 bill.
|
|
139
|
+
*/
|
|
140
|
+
export async function deleteResource({ workerUrl, uploadToken, resourceId, chunks = 1 }) {
|
|
141
|
+
for (let i = 0; i < Math.max(1, chunks); i++) {
|
|
142
|
+
const res = await fetch(`${workerUrl}/upload/${chunkKey(resourceId, i)}`, {
|
|
143
|
+
method: "DELETE",
|
|
144
|
+
headers: uploadToken ? { Authorization: `Bearer ${uploadToken}` } : {},
|
|
145
|
+
});
|
|
146
|
+
// 404 is a worker too old to have the route; that's a real failure, since
|
|
147
|
+
// nothing was deleted and the caller is about to free the allowance.
|
|
148
|
+
if (!res.ok) throw new Error(`${workerUrl} refused to delete chunk ${i} of ${resourceId}: ${res.status} ${res.statusText}`);
|
|
149
|
+
}
|
|
150
|
+
return Math.max(1, chunks);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── self-check: `node publish/envelope.js` ───────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// These vectors are PINNED to the values sond3r's own envelope.js produced before
|
|
156
|
+
// this file was extracted from it, with ns = "sond3r". They are not a style test:
|
|
157
|
+
// every resourceId ever minted by fangorn.tv is derived by this function, and a
|
|
158
|
+
// change here that shifts one byte makes ciphertext already in R2 unreachable from
|
|
159
|
+
// the resource anyone paid for. If this fails, the extraction broke money.
|
|
160
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
161
|
+
const owner = `0x${"11".repeat(20)}`;
|
|
162
|
+
const uid = "Show/S1/a.mp4";
|
|
163
|
+
|
|
164
|
+
const h = uidHashFor("sond3r", uid);
|
|
165
|
+
if (h !== "0x855b20df257479ad7d5d5be0bcddc8c8554bff19edb33adb818872dd3d9e0d04") {
|
|
166
|
+
throw new Error(`uidHashFor drifted: ${h}`);
|
|
167
|
+
}
|
|
168
|
+
const r = resourceIdFor("sond3r", owner, uid);
|
|
169
|
+
if (r !== "0x0f919abdf691fae94b36395e67f7ed3301f0faea6027ffe609612656556a28a5") {
|
|
170
|
+
throw new Error(`resourceIdFor drifted: ${r}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The namespace is what separates two apps' id spaces. Same owner, same uid,
|
|
174
|
+
// different app → different resource, or one app could mint over another's.
|
|
175
|
+
if (resourceIdFor("wiki", owner, uid) === r) throw new Error("ns does not reach the resourceId");
|
|
176
|
+
if (uidHashFor("wiki", uid) === h) throw new Error("ns does not reach the uid hash");
|
|
177
|
+
|
|
178
|
+
// Chunk 0 IS the resourceId — the worker's /access gate looks for the sealed
|
|
179
|
+
// DEK there and nowhere else.
|
|
180
|
+
if (chunkKey(r, 0) !== r) throw new Error("chunk 0 must be the resourceId itself");
|
|
181
|
+
if (chunkKey(r, 1) === r) throw new Error("chunk 1 collided with chunk 0");
|
|
182
|
+
if (aadFor(1).length !== 4) throw new Error("aad must be 4 bytes, big-endian");
|
|
183
|
+
|
|
184
|
+
console.log("envelope.js self-check ok — pinned sond3r ids, ns separation, chunk keys");
|
|
185
|
+
}
|