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