- commit
- efc0c4439bc2070f5cd7a788d2a8d0468a39e62f
- parent
- 96951c95682adfc827b1c651f2feaa2d396e574c
- author
- Dami <dami.etoile@gmail.com>
@@ -1,5 +1,5 @@
11 // BUD-15 draft (June 2026), BUD-15: Client-side CHK encrypted blobs.2-// https://raw.githubusercontent.com/mmalmi/blossom/codex/bud-15-chk-encryption/buds/15.md2+// https://github.com/hzrd149/blossom/pull/10433 // Keys are client-only values. Callers must not include k in requests to a server.44 (() => {55 "use strict";@@ -9,65 +9,102 @@
99 const salt = encoder.encode("hashtree-chk");1010 const info = encoder.encode("encryption-key");1111 12- const hex = bytes => Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");13- const bytes = value => {14- if (value instanceof Uint8Array) return value;15- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);16- throw new TypeError("expected Uint8Array");17- };18- const fromHex = value => {19- if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw new Error("expected a 64-character lowercase hex key");12+ const {bytes, hex, sha256: digest} = globalThis.tiny.util;13+ const fromHex = (value) => {14+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))15+ throw new Error("expected a 64-character lowercase hex key");2016 const result = new Uint8Array(32);21- for (let index = 0; index < result.length; index++) result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);17+ for (let index = 0; index < result.length; index++)18+ result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);2219 return result;2320 };24- const equal = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);25- const digest = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));26- const aesKey = async key => {27- const material = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);28- return crypto.subtle.deriveKey({name: "HKDF", hash: "SHA-256", salt, info}, material,29- {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"]);21+ const equal = (left, right) =>22+ left.length === right.length &&23+ left.every((value, index) => value === right[index]);24+ const aesKey = async (key) => {25+ const material = await crypto.subtle.importKey("raw", key, "HKDF", false, [26+ "deriveKey",27+ ]);28+ return crypto.subtle.deriveKey(29+ { name: "HKDF", hash: "SHA-256", salt, info },30+ material,31+ { name: "AES-GCM", length: 256 },32+ false,33+ ["encrypt", "decrypt"],34+ );3035 };3136 32- const encryptCHK = async plaintext => {37+ const encryptCHK = async (plaintext) => {3338 const input = bytes(plaintext);3439 const key = await digest(input);35- const encrypted = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(key), input));36- return {ciphertext: encrypted, key: hex(key), hash: hex(await digest(encrypted))};40+ const encrypted = new Uint8Array(41+ await crypto.subtle.encrypt(42+ { name: "AES-GCM", iv: zeroNonce },43+ await aesKey(key),44+ input,45+ ),46+ );47+ return {48+ ciphertext: encrypted,49+ key: hex(key),50+ hash: hex(await digest(encrypted)),51+ };3752 };3853 3954 const decryptCHK = async (ciphertext, key, hash) => {4055 const input = bytes(ciphertext);4156 const chkKey = fromHex(key);42- if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a 64-character lowercase blob hash");43- if (hex(await digest(input)) !== hash) throw new Error("ciphertext hash mismatch");57+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))58+ throw new Error("expected a 64-character lowercase blob hash");59+ if (hex(await digest(input)) !== hash)60+ throw new Error("ciphertext hash mismatch");4461 let plain;4562 try {46- plain = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(chkKey), input));63+ plain = new Uint8Array(64+ await crypto.subtle.decrypt(65+ { name: "AES-GCM", iv: zeroNonce },66+ await aesKey(chkKey),67+ input,68+ ),69+ );4770 } catch {4871 throw new Error("CHK decryption failed");4972 }50- if (!equal(await digest(plain), chkKey)) throw new Error("plaintext hash mismatch");73+ if (!equal(await digest(plain), chkKey))74+ throw new Error("plaintext hash mismatch");5175 return plain;5276 };5377 5478 const createURI = (hash, extension, key) => {55- if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a 64-character lowercase blob hash");56- const path = "blossom:" + hash + (extension ? "." + String(extension).replace(/^\.+/, "") : "");79+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))80+ throw new Error("expected a 64-character lowercase blob hash");81+ const path =82+ "blossom:" +83+ hash +84+ (extension ? "." + String(extension).replace(/^\.+/, "") : "");5785 fromHex(key);58- const params = new URLSearchParams({enc: "chk-v1", k: key});86+ const params = new URLSearchParams({ enc: "chk-v1", k: key });5987 return path + "?" + params;6088 };6189 62- const parseURI = value => {63- if (typeof value !== "string" || !value.startsWith("blossom:")) throw new Error("expected a Blossom URI");64- const parsed = new URL(value.slice("blossom:".length), "https://blossom.invalid");65- const match = parsed.pathname.match(/^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/);66- if (!match || parsed.searchParams.get("enc") !== "chk-v1") throw new Error("invalid CHK Blossom URI");90+ const parseURI = (value) => {91+ if (typeof value !== "string" || !value.startsWith("blossom:"))92+ throw new Error("expected a Blossom URI");93+ const parsed = new URL(94+ value.slice("blossom:".length),95+ "https://blossom.invalid",96+ );97+ const match = parsed.pathname.match(98+ /^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/,99+ );100+ if (!match || parsed.searchParams.get("enc") !== "chk-v1")101+ throw new Error("invalid CHK Blossom URI");67102 const key = parsed.searchParams.get("k");68103 fromHex(key);69- return {hash: match[1], extension: match[2] || "", key};104+ return { hash: match[1], extension: match[2] || "", key };70105 };71106 72- globalThis.TinyBlossomEncryption = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI});107+ const api = Object.freeze({ encryptCHK, decryptCHK, createURI, parseURI });108+ globalThis.tiny = globalThis.tiny || {};109+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, encryption: api };73110 })();@@ -1,466 +1,644 @@
1+// BUD-16 and BUD-17 MessagePack manifests. This is maintained browser source,2+// embedded directly by the Go templates; it is intentionally not bundled.13 (() => {2- (() => {3- "use strict";4- const CHUNK_SIZE = 2 * 1024 * 1024;5- const MAX_LINKS = 174;6- const MAX_DEPTH = 64;7- const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;8- const te = new TextEncoder(), td = new TextDecoder("utf-8", { fatal: true });9- const asBytes = (v) => v instanceof Uint8Array ? v : ArrayBuffer.isView(v) ? new Uint8Array(v.buffer, v.byteOffset, v.byteLength) : (() => {10- throw new TypeError("expected bytes");11- })();12- const hashBytes = (h) => {13- if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h)) throw new Error("expected lowercase SHA-256 hash");14- return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));15- };16- const keyBytes = (k) => {17- if (k === void 0) return;18- const b = asBytes(k);19- if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");20- return b;21- };22- const wellFormed = (value) => {23- for (let i = 0; i < value.length; i++) {24- const c = value.charCodeAt(i);25- if (c >= 55296 && c <= 56319) {26- if (++i >= value.length || value.charCodeAt(i) < 56320 || value.charCodeAt(i) > 57343) return false;27- } else if (c >= 56320 && c <= 57343) return false;28- }29- return true;30- };31- const nameOK = (n) => typeof n === "string" && n.length > 0 && n !== "." && n !== ".." && wellFormed(n) && !n.includes("/") && !n.includes("\0") && te.encode(n).length <= 1024;32- const int = (n) => Number.isSafeInteger(n) && n >= 0;33- const utf8cmp = (a, b) => {34- const x = te.encode(a), y = te.encode(b);35- for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];36- return x.length - y.length;37- };38- class Writer {39- constructor() {40- this.a = [];41- }42- push(...x) {43- this.a.push(...x);44- }45- u8(n) {46- this.a.push(n & 255);47- }48- bytes() {49- return Uint8Array.from(this.a);50- }4+ "use strict";5+ const CHUNK_SIZE = 2 * 1024 * 1024;6+ const MAX_LINKS = 174;7+ const MAX_DEPTH = 64;8+ const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;9+ const textEncoder = new TextEncoder(),10+ textDecoder = new TextDecoder("utf-8", { fatal: true });11+ const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;12+ const hashBytes = (h) => {13+ if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h))14+ throw new Error("expected lowercase SHA-256 hash");15+ return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));16+ };17+ const keyBytes = (k) => {18+ if (k === undefined) return;19+ const b = asBytes(k);20+ if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");21+ return b;22+ };23+ const wellFormed = (value) => {24+ for (let i = 0; i < value.length; i++) {25+ const c = value.charCodeAt(i);26+ if (c >= 55296 && c <= 56319) {27+ if (28+ ++i >= value.length ||29+ value.charCodeAt(i) < 56320 ||30+ value.charCodeAt(i) > 5734331+ )32+ return false;33+ } else if (c >= 56320 && c <= 57343) return false;5134 }52- const putInt = (w, n) => {53- if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");54- if (n >= 0) {55- if (n < 128) w.u8(n);56- else if (n < 256) w.push(204, n);57- else if (n < 65536) w.push(205, n >> 8, n);58- else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);59- else {60- w.u8(207);61- let x = BigInt(n);62- for (let i = 7; i >= 0; i--) w.u8(Number(x >> BigInt(i * 8) & 255n));63- }64- } else if (n >= -32) w.u8(256 + n);65- else if (n >= -128) w.push(208, n & 255);66- else if (n >= -32768) w.push(209, n >> 8 & 255, n & 255);67- else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);35+ return true;36+ };37+ const nameOK = (n) =>38+ typeof n === "string" &&39+ n.length > 0 &&40+ n !== "." &&41+ n !== ".." &&42+ wellFormed(n) &&43+ !n.includes("/") &&44+ !n.includes("\0") &&45+ textEncoder.encode(n).length <= 1024;46+ const int = (n) => Number.isSafeInteger(n) && n >= 0;47+ const utf8cmp = (a, b) => {48+ const x = textEncoder.encode(a),49+ y = textEncoder.encode(b);50+ for (let i = 0; i < Math.min(x.length, y.length); i++)51+ if (x[i] !== y[i]) return x[i] - y[i];52+ return x.length - y.length;53+ };54+ class MessagePackWriter {55+ constructor() {56+ this.output = [];57+ }58+ push(...x) {59+ this.output.push(...x);60+ }61+ u8(n) {62+ this.output.push(n & 255);63+ }64+ bytes() {65+ return Uint8Array.from(this.output);66+ }67+ }68+ const writeInteger = (w, n) => {69+ if (!Number.isSafeInteger(n))70+ throw new Error("manifest integers must be safe integers");71+ if (n >= 0) {72+ if (n < 128) w.u8(n);73+ else if (n < 256) w.push(204, n);74+ else if (n < 65536) w.push(205, n >> 8, n);75+ else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);6876 else {69- w.u8(211);70- let x = BigInt.asUintN(64, BigInt(n));71- for (let i = 7; i >= 0; i--) w.u8(Number(x >> BigInt(i * 8) & 255n));72- }73- };74- const put = (w, v) => {75- if (v === null) return w.u8(192);76- if (v === false) return w.u8(194);77- if (v === true) return w.u8(195);78- if (typeof v === "number") {79- if (Number.isInteger(v)) return putInt(w, v);80- if (!Number.isFinite(v)) throw Error("metadata number must be finite");81- w.u8(203);82- const b = new DataView(new ArrayBuffer(8));83- b.setFloat64(0, v);84- for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));85- return;86- }87- if (typeof v === "string") {88- const b = te.encode(v);89- if (b.length < 32) w.u8(160 | b.length);90- else if (b.length < 256) w.push(217, b.length);91- else if (b.length < 65536) w.push(218, b.length >> 8, b.length);92- else throw new Error("string too large");93- w.push(...b);94- return;95- }96- if (v instanceof Uint8Array) {97- if (v.length < 256) w.push(196, v.length);98- else if (v.length < 65536) w.push(197, v.length >> 8, v.length);99- else throw new Error("binary value too large");100- w.push(...v);101- return;102- }103- if (Array.isArray(v)) {104- if (v.length < 16) w.u8(144 | v.length);105- else if (v.length < 65536) w.push(220, v.length >> 8, v.length);106- else throw new Error("array too large");107- v.forEach((x) => put(w, x));108- return;77+ w.u8(207);78+ let x = BigInt(n);79+ for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));10980 }81+ } else if (n >= -32) w.u8(256 + n);82+ else if (n >= -128) w.push(208, n & 255);83+ else if (n >= -32768) w.push(209, (n >> 8) & 255, n & 255);84+ else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);85+ else {86+ w.u8(211);87+ let x = BigInt.asUintN(64, BigInt(n));88+ for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));89+ }90+ };91+ const writeValue = (w, v) => {92+ if (v === null) return w.u8(192);93+ if (v === false) return w.u8(194);94+ if (v === true) return w.u8(195);95+ if (typeof v === "number") {96+ if (Number.isInteger(v)) return writeInteger(w, v);97+ if (!Number.isFinite(v)) throw Error("metadata number must be finite");98+ w.u8(203);99+ const b = new DataView(new ArrayBuffer(8));100+ b.setFloat64(0, v);101+ for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));102+ return;103+ }104+ if (typeof v === "string") {105+ const b = textEncoder.encode(v);106+ if (b.length < 32) w.u8(160 | b.length);107+ else if (b.length < 256) w.push(217, b.length);108+ else if (b.length < 65536) w.push(218, b.length >> 8, b.length);109+ else throw new Error("string too large");110+ w.push(...b);111+ return;112+ }113+ if (v instanceof Uint8Array) {114+ if (v.length < 256) w.push(196, v.length);115+ else if (v.length < 65536) w.push(197, v.length >> 8, v.length);116+ else throw new Error("binary value too large");117+ w.push(...v);118+ return;119+ }120+ if (Array.isArray(v)) {121+ if (v.length < 16) w.u8(144 | v.length);122+ else if (v.length < 65536) w.push(220, v.length >> 8, v.length);123+ else throw new Error("array too large");124+ v.forEach((x) => writeValue(w, x));125+ return;126+ }127+ if (v && typeof v === "object") {128+ const keys = Object.keys(v).sort(utf8cmp);129+ if (keys.length < 16) w.u8(128 | keys.length);130+ else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);131+ else throw new Error("map too large");132+ keys.forEach((k) => {133+ writeValue(w, k);134+ writeValue(w, v[k]);135+ });136+ return;137+ }138+ throw new Error("unsupported manifest value");139+ };140+ class MessagePackReader {141+ constructor(b) {142+ this.input = asBytes(b);143+ this.offset = 0;144+ }145+ need(n) {146+ if (this.offset + n > this.input.length) throw new Error("truncated MessagePack");147+ }148+ u8() {149+ this.need(1);150+ return this.input[this.offset++];151+ }152+ n(len) {153+ this.need(len);154+ const x = this.input.slice(this.offset, this.offset + len);155+ this.offset += len;156+ return x;157+ }158+ }159+ const readMap = (r, n, depth) => {160+ const o = Object.create(null);161+ for (let i = 0; i < n; i++) {162+ const k = readValue(r, depth + 1);163+ if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k))164+ throw Error("invalid map key");165+ o[k] = readValue(r, depth + 1);166+ }167+ return o;168+ };169+ const readValue = (r, depth = 0) => {170+ if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");171+ const t = r.u8();172+ if (t <= 127) return t;173+ if (t >= 224) return t - 256;174+ if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));175+ if ((t & 240) === 144)176+ return Array.from({ length: t & 15 }, () => readValue(r, depth + 1));177+ if ((t & 240) === 128) return readMap(r, t & 15, depth);178+ if (t === 192) return null;179+ if (t === 194) return false;180+ if (t === 195) return true;181+ let n;182+ if (t === 204) n = r.u8();183+ else if (t === 205) n = (r.u8() << 8) | r.u8();184+ else if (t === 206)185+ n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();186+ else if (t === 207) {187+ let x = 0n;188+ for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());189+ if (x > BigInt(Number.MAX_SAFE_INTEGER))190+ throw Error("integer exceeds safe range");191+ n = Number(x);192+ } else if (t === 208) n = (r.u8() << 24) >> 24;193+ else if (t === 209) {194+ n = (r.u8() << 8) | r.u8();195+ if (n & 32768) n -= 65536;196+ } else if (t === 210) {197+ n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();198+ if (n >= 2147483648) n -= 4294967296;199+ } else if (t === 211) {200+ let x = 0n;201+ for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());202+ if (x & 0x8000000000000000n) x -= 0x10000000000000000n;203+ if (204+ x > BigInt(Number.MAX_SAFE_INTEGER) ||205+ x < BigInt(Number.MIN_SAFE_INTEGER)206+ )207+ throw Error("integer exceeds safe range");208+ n = Number(x);209+ } else if (t === 202) {210+ const b = new ArrayBuffer(4),211+ d = new DataView(b);212+ for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());213+ n = d.getFloat32(0);214+ } else if (t === 203) {215+ const b = new ArrayBuffer(8),216+ d = new DataView(b);217+ for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());218+ n = d.getFloat64(0);219+ } else if (t === 217) n = textDecoder.decode(r.n(r.u8()));220+ else if (t === 218) n = textDecoder.decode(r.n((r.u8() << 8) | r.u8()));221+ else if (t === 196) n = r.n(r.u8());222+ else if (t === 197) n = r.n((r.u8() << 8) | r.u8());223+ else if (t === 220) {224+ n = (r.u8() << 8) | r.u8();225+ return Array.from({ length: n }, () => readValue(r, depth + 1));226+ } else if (t === 222) {227+ n = (r.u8() << 8) | r.u8();228+ return readMap(r, n, depth);229+ } else throw Error("unsupported MessagePack type");230+ return n;231+ };232+ const validateMeta = (m, depth = 0) => {233+ if (m === undefined) return;234+ if (235+ depth > MAX_DEPTH ||236+ !m ||237+ Array.isArray(m) ||238+ typeof m !== "object" ||239+ m instanceof Uint8Array240+ )241+ throw Error("metadata must be a JSON map");242+ const keys = Object.keys(m);243+ if (keys.length > 64) throw Error("too many metadata keys");244+ for (const [k, v] of Object.entries(m)) {245+ if (typeof k !== "string" || textEncoder.encode(k).length > 256)246+ throw Error("invalid metadata key");247+ if (248+ v === undefined ||249+ typeof v === "bigint" ||250+ typeof v === "function" ||251+ typeof v === "symbol" ||252+ (typeof v === "number" && !Number.isFinite(v))253+ )254+ throw Error("metadata is not JSON-compatible");110255 if (v && typeof v === "object") {111- const keys = Object.keys(v).sort(utf8cmp);112- if (keys.length < 16) w.u8(128 | keys.length);113- else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);114- else throw new Error("map too large");115- keys.forEach((k) => {116- put(w, k);117- put(w, v[k]);118- });119- return;256+ if (Array.isArray(v)) {257+ if (v.length > 256) throw Error("metadata array too large");258+ v.forEach((x) => validateMeta({ x }, depth + 1));259+ } else validateMeta(v, depth + 1);120260 }121- throw new Error("unsupported manifest value");122- };123- class Reader {124- constructor(b) {125- this.b = asBytes(b);126- this.i = 0;127- }128- need(n) {129- if (this.i + n > this.b.length) throw new Error("truncated MessagePack");261+ }262+ };263+ const validateLink = (x, nodeType) => {264+ if (!x || typeof x !== "object" || Array.isArray(x))265+ throw Error("invalid manifest link");266+ if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k)))267+ throw Error("unknown manifest link field");268+ if (!(x.h instanceof Uint8Array) || x.h.length !== 32)269+ throw Error("link hash must be 32 bytes");270+ if (x.k !== undefined) keyBytes(x.k);271+ if (x.m !== undefined) validateMeta(x.m);272+ if (!int(x.s)) throw Error("invalid link size");273+ if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");274+ if (nodeType === 1 && x.t !== 0 && x.t !== 1)275+ throw Error("invalid file link type");276+ if (nodeType === 1 && x.n !== undefined)277+ throw Error("file links cannot have names");278+ if (nodeType === 3 && x.n !== undefined)279+ throw Error("fanout links cannot have names");280+ if (nodeType === 3 && x.t !== 2 && x.t !== 3)281+ throw Error("invalid fanout child type");282+ if (nodeType === 2 && !nameOK(x.n))283+ throw Error("invalid directory entry name");284+ };285+ const validateNode = (node, depth = 0) => {286+ if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");287+ if (288+ !node ||289+ typeof node !== "object" ||290+ Array.isArray(node) ||291+ !Array.isArray(node.l) ||292+ !int(node.t) ||293+ ![1, 2, 3].includes(node.t) ||294+ Object.keys(node).some((k) => k !== "l" && k !== "t")295+ )296+ throw Error("invalid manifest node");297+ if (node.l.length > MAX_LINKS) throw Error("too many manifest links");298+ const seen = new Set(),299+ links = node.l;300+ for (const x of links) {301+ validateLink(x, node.t);302+ if (node.t === 2) {303+ if (seen.has(x.n)) throw Error("duplicate directory entry");304+ seen.add(x.n);130305 }131- u8() {132- this.need(1);133- return this.b[this.i++];306+ if (node.t === 3) {307+ if (308+ !x.m ||309+ !int(x.m.count) ||310+ x.m.count < 1 ||311+ !nameOK(x.m.first) ||312+ !nameOK(x.m.last) ||313+ utf8cmp(x.m.first, x.m.last) > 0314+ )315+ throw Error("invalid fanout bounds");134316 }135- n(len) {136- this.need(len);137- const x = this.b.slice(this.i, this.i + len);138- this.i += len;139- return x;317+ }318+ if (319+ node.t === 2 &&320+ links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)321+ )322+ throw Error("directory links are not canonical");323+ return node;324+ };325+ const encodeManifest = (node) => {326+ validateNode(node);327+ const w = new MessagePackWriter();328+ w.u8(130);329+ writeValue(w, "l");330+ if (node.l.length < 16) w.u8(144 | node.l.length);331+ else w.push(220, node.l.length >> 8, node.l.length);332+ for (const x of node.l) {333+ const fields = [334+ "h",335+ ...(x.k !== undefined ? ["k"] : []),336+ ...(x.m !== undefined ? ["m"] : []),337+ ...(x.n !== undefined ? ["n"] : []),338+ "s",339+ "t",340+ ];341+ if (fields.length < 16) w.u8(128 | fields.length);342+ else w.push(222, fields.length >> 8, fields.length);343+ for (const k of fields) {344+ writeValue(w, k);345+ writeValue(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);140346 }141347 }142- const readMap = (r, n, depth) => {143- const o = /* @__PURE__ */ Object.create(null);144- for (let i = 0; i < n; i++) {145- const k = get(r, depth + 1);146- if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");147- o[k] = get(r, depth + 1);348+ writeValue(w, "t");349+ writeValue(w, node.t);350+ const out = w.bytes();351+ if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");352+ return out;353+ };354+ const decodeManifest = (bytes, options = {}) => {355+ const b = asBytes(bytes);356+ if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");357+ const r = new MessagePackReader(b),358+ node = readValue(r);359+ if (r.offset !== b.length) throw Error("trailing MessagePack data");360+ validateNode(node);361+ if (node.t === 3)362+ for (const x of node.l) {363+ if (x.m.count > Number(options.maxEntries || 1e9))364+ throw Error("fanout count limit exceeded");148365 }149- return o;150- };151- const get = (r, depth = 0) => {152- if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");153- const t = r.u8();154- if (t <= 127) return t;155- if (t >= 224) return t - 256;156- if ((t & 224) === 160) return td.decode(r.n(t & 31));157- if ((t & 240) === 144) return Array.from({ length: t & 15 }, () => get(r, depth + 1));158- if ((t & 240) === 128) return readMap(r, t & 15, depth);159- if (t === 192) return null;160- if (t === 194) return false;161- if (t === 195) return true;162- let n;163- if (t === 204) n = r.u8();164- else if (t === 205) n = r.u8() << 8 | r.u8();165- else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();166- else if (t === 207) {167- let x = 0n;168- for (let i = 0; i < 8; i++) x = x << 8n | BigInt(r.u8());169- if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");170- n = Number(x);171- } else if (t === 208) n = r.u8() << 24 >> 24;172- else if (t === 209) {173- n = r.u8() << 8 | r.u8();174- if (n & 32768) n -= 65536;175- } else if (t === 210) {176- n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();177- if (n >= 2147483648) n -= 4294967296;178- } else if (t === 211) {179- let x = 0n;180- for (let i = 0; i < 8; i++) x = x << 8n | BigInt(r.u8());181- if (x & 0x8000000000000000n) x -= 0x10000000000000000n;182- if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER)) throw Error("integer exceeds safe range");183- n = Number(x);184- } else if (t === 202) {185- const b = new ArrayBuffer(4), d = new DataView(b);186- for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());187- n = d.getFloat32(0);188- } else if (t === 203) {189- const b = new ArrayBuffer(8), d = new DataView(b);190- for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());191- n = d.getFloat64(0);192- } else if (t === 217) n = td.decode(r.n(r.u8()));193- else if (t === 218) n = td.decode(r.n(r.u8() << 8 | r.u8()));194- else if (t === 196) n = r.n(r.u8());195- else if (t === 197) n = r.n(r.u8() << 8 | r.u8());196- else if (t === 220) {197- n = r.u8() << 8 | r.u8();198- return Array.from({ length: n }, () => get(r, depth + 1));199- } else if (t === 222) {200- n = r.u8() << 8 | r.u8();201- return readMap(r, n, depth);202- } else throw Error("unsupported MessagePack type");203- return n;204- };205- const validateMeta = (m, depth = 0) => {206- if (m === void 0) return;207- if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array) throw Error("metadata must be a JSON map");208- const keys = Object.keys(m);209- if (keys.length > 64) throw Error("too many metadata keys");210- for (const [k, v] of Object.entries(m)) {211- if (typeof k !== "string" || te.encode(k).length > 256) throw Error("invalid metadata key");212- if (v === void 0 || typeof v === "bigint" || typeof v === "function" || typeof v === "symbol" || typeof v === "number" && !Number.isFinite(v)) throw Error("metadata is not JSON-compatible");213- if (v && typeof v === "object") {214- if (Array.isArray(v)) {215- if (v.length > 256) throw Error("metadata array too large");216- v.forEach((x) => validateMeta({ x }, depth + 1));217- } else validateMeta(v, depth + 1);218- }366+ return node;367+ };368+ const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));369+ const leaf = (hash, size, key) => {370+ const x = { h: hashBytes(hash), s: size, t: 0 };371+ if (key !== undefined) x.k = keyBytes(key);372+ return x;373+ };374+ const storeNode = async (node, options, manifests) => {375+ const bytes = encodeManifest(node),376+ computed = hex(await sha256(bytes));377+ let result = { hash: computed };378+ if (options.store) {379+ const stored = await options.store({ hash: computed, bytes, node });380+ if (stored) {381+ if (typeof stored === "string") result.hash = stored;382+ else result = { ...result, ...stored };219383 }220- };221- const validateLink = (x, nodeType) => {222- if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");223- if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k))) throw Error("unknown manifest link field");224- if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");225- if (x.k !== void 0) keyBytes(x.k);226- if (x.m !== void 0) validateMeta(x.m);227- if (!int(x.s)) throw Error("invalid link size");228- if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");229- if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");230- if (nodeType === 1 && x.n !== void 0) throw Error("file links cannot have names");231- if (nodeType === 3 && x.n !== void 0) throw Error("fanout links cannot have names");232- if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");233- if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");234- };235- const validateNode = (node, depth = 0) => {236- if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");237- if (!node || typeof node !== "object" || Array.isArray(node) || !Array.isArray(node.l) || !int(node.t) || ![1, 2, 3].includes(node.t) || Object.keys(node).some((k) => k !== "l" && k !== "t")) throw Error("invalid manifest node");238- if (node.l.length > MAX_LINKS) throw Error("too many manifest links");239- const seen = /* @__PURE__ */ new Set(), links = node.l;240- for (const x of links) {241- validateLink(x, node.t);242- if (node.t === 2) {243- if (seen.has(x.n)) throw Error("duplicate directory entry");244- seen.add(x.n);245- }246- if (node.t === 3) {247- if (!x.m || !int(x.m.count) || x.m.count < 1 || !nameOK(x.m.first) || !nameOK(x.m.last) || utf8cmp(x.m.first, x.m.last) > 0) throw Error("invalid fanout bounds");248- }384+ }385+ if (!/^[0-9a-f]{64}$/.test(result.hash))386+ throw Error("store returned invalid manifest hash");387+ if (result.key !== undefined) keyBytes(result.key);388+ manifests.push({ hash: result.hash, bytes, node, key: result.key });389+ return result;390+ };391+ const buildFile = async (chunks, options = {}) => {392+ const max = options.maxLinks ?? MAX_LINKS;393+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)394+ throw Error("invalid maxLinks");395+ const leaves = chunks.map((c) =>396+ typeof c === "string"397+ ? leaf(c, options.chunkSize || 0)398+ : leaf(c.hash, c.size, c.key),399+ );400+ if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE))401+ throw Error("invalid chunk size");402+ const manifests = [];403+ let level = leaves;404+ while (level.length > max) {405+ const next = [];406+ for (let i = 0; i < level.length; i += max) {407+ const group = level.slice(i, i + max),408+ child = { l: group, t: 1 },409+ stored = await storeNode(child, options, manifests);410+ next.push({411+ h: hashBytes(stored.hash),412+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),413+ s: group.reduce((n, x) => n + x.s, 0),414+ t: 1,415+ });249416 }250- if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)) throw Error("directory links are not canonical");251- return node;252- };253- const encodeManifest = (node) => {254- validateNode(node);255- const w = new Writer();256- w.u8(130);257- put(w, "l");258- if (node.l.length < 16) w.u8(144 | node.l.length);259- else w.push(220, node.l.length >> 8, node.l.length);260- for (const x of node.l) {261- const fields = ["h", ...x.k !== void 0 ? ["k"] : [], ...x.m !== void 0 ? ["m"] : [], ...x.n !== void 0 ? ["n"] : [], "s", "t"];262- if (fields.length < 16) w.u8(128 | fields.length);263- else w.push(222, fields.length >> 8, fields.length);264- for (const k of fields) {265- put(w, k);266- put(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);267- }417+ level = next;418+ }419+ const root = { l: level, t: 1 };420+ await storeNode(root, options, manifests);421+ return options.returnDetails ? { root, manifests } : root;422+ };423+ const buildDirectory = async (entries, options = {}) => {424+ if (!Array.isArray(entries)) throw Error("entries must be an array");425+ const max = options.maxLinks ?? MAX_LINKS;426+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)427+ throw Error("invalid maxLinks");428+ const sorted = entries429+ .map((e) => {430+ const x = {431+ h: hashBytes(e.hash),432+ n: e.name,433+ s: e.size || 0,434+ t: e.type || 0,435+ };436+ if (e.key !== undefined) x.k = keyBytes(e.key);437+ if (e.metadata !== undefined) x.m = e.metadata;438+ return x;439+ })440+ .sort((a, b) => utf8cmp(a.n, b.n));441+ for (let i = 1; i < sorted.length; i++)442+ if (sorted[i - 1].n === sorted[i].n)443+ throw Error("duplicate directory entry");444+ const manifests = [];445+ if (sorted.length <= max) {446+ const directory = { l: sorted, t: 2 };447+ await storeNode(directory, options, manifests);448+ return options.returnDetails ? { root: directory, manifests } : directory;449+ }450+ let level = [];451+ for (let i = 0; i < sorted.length; i += max) {452+ const l = sorted.slice(i, i + max),453+ child = { l, t: 2 },454+ stored = await storeNode(child, options, manifests);455+ level.push({456+ h: hashBytes(stored.hash),457+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),458+ m: { count: l.length, first: l[0].n, last: l[l.length - 1].n },459+ s: l.reduce((n, x) => n + x.s, 0),460+ t: 2,461+ });462+ }463+ while (level.length > max) {464+ const next = [];465+ for (let i = 0; i < level.length; i += max) {466+ const l = level.slice(i, i + max),467+ child = { l, t: 3 },468+ stored = await storeNode(child, options, manifests);469+ next.push({470+ h: hashBytes(stored.hash),471+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),472+ m: {473+ count: l.reduce((n, x) => n + x.m.count, 0),474+ first: l[0].m.first,475+ last: l[l.length - 1].m.last,476+ },477+ s: l.reduce((n, x) => n + x.s, 0),478+ t: 3,479+ });268480 }269- put(w, "t");270- put(w, node.t);271- const out = w.bytes();272- if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");481+ level = next;482+ }483+ const root = { l: level, t: 3 };484+ await storeNode(root, options, manifests);485+ return options.returnDetails ? { root, manifests } : root;486+ };487+ const flattenDirectory = (node, out = []) => {488+ validateNode(node);489+ if (node.t === 2) {490+ out.push(...node.l);273491 return out;274- };275- const decodeManifest = (bytes, options = {}) => {276- const b = asBytes(bytes);277- if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");278- const r = new Reader(b), node = get(r);279- if (r.i !== b.length) throw Error("trailing MessagePack data");492+ }493+ if (node.t !== 3) throw Error("not a directory");494+ for (const x of node.l) if (x.child) flattenDirectory(x.child, out);495+ return out;496+ };497+ const resolveDirectory = async (root, fetchNode, options = {}) => {498+ if (typeof fetchNode !== "function") throw Error("fetchNode is required");499+ const maxDepth = options.maxDepth ?? MAX_DEPTH,500+ maxEntries = options.maxEntries ?? 1e5,501+ maxManifests = options.maxManifests ?? 1e4,502+ maxBytes = options.maxBytes ?? 64 * 1024 * 1024;503+ if (504+ [maxDepth, maxEntries, maxManifests, maxBytes].some(505+ (value) => !Number.isSafeInteger(value) || value < 0,506+ )507+ )508+ throw Error("invalid directory limits");509+ let count = 0,510+ manifestCount = 0,511+ fetched = 0;512+ const active = new Set();513+ const walk = async (node, depth) => {514+ if (depth > maxDepth) throw Error("directory recursion limit exceeded");515+ if (++manifestCount > maxManifests)516+ throw Error("manifest count limit exceeded");280517 validateNode(node);281- if (node.t === 3) for (const x of node.l) {282- if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");518+ if (node.t === 2) {519+ count += node.l.length;520+ if (count > maxEntries) throw Error("directory entry limit exceeded");521+ return node.l.slice();283522 }284- return node;285- };286- const sha256 = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", asBytes(b)));287- const hex = (b) => Array.from(asBytes(b), (x) => x.toString(16).padStart(2, "0")).join("");288- const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));289- const leaf = (hash, size, key) => {290- const x = { h: hashBytes(hash), s: size, t: 0 };291- if (key !== void 0) x.k = keyBytes(key);292- return x;293- };294- const storeNode = async (node, options, manifests) => {295- const bytes = encodeManifest(node), computed = hex(await sha256(bytes));296- let result = { hash: computed };297- if (options.store) {298- const stored = await options.store({ hash: computed, bytes, node });299- if (stored) {300- if (typeof stored === "string") result.hash = stored;301- else result = { ...result, ...stored };523+ if (node.t !== 3) throw Error("not a directory");524+ const result = [];525+ let previous = "";526+ for (const link of node.l) {527+ if (previous && utf8cmp(previous, link.m.first) >= 0)528+ throw Error("fanout bounds are not ordered");529+ previous = link.m.last;530+ const linkHash = hex(link.h);531+ if (active.has(linkHash)) throw Error("manifest cycle detected");532+ active.add(linkHash);533+ let raw = asBytes(await fetchNode(linkHash, link));534+ fetched += raw.length;535+ if (fetched > maxBytes) throw Error("directory byte limit exceeded");536+ if (hex(await sha256(raw)) !== linkHash)537+ throw Error("manifest hash mismatch");538+ if (link.k) {539+ if (typeof options.decrypt !== "function")540+ throw Error("encrypted manifest requires decrypt callback");541+ raw = asBytes(await options.decrypt(raw, link));302542 }543+ const child = decodeManifest(raw);544+ if (child.t !== link.t) throw Error("fanout child type mismatch");545+ const links = await walk(child, depth + 1);546+ active.delete(linkHash);547+ if (548+ links.length !== link.m.count ||549+ links[0]?.n !== link.m.first ||550+ links.at(-1)?.n !== link.m.last551+ )552+ throw Error("fanout bounds mismatch");553+ result.push(...links);303554 }304- if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");305- if (result.key !== void 0) keyBytes(result.key);306- manifests.push({ hash: result.hash, bytes, node, key: result.key });555+ for (let i = 1; i < result.length; i++)556+ if (utf8cmp(result[i - 1].n, result[i].n) >= 0)557+ throw Error("duplicate or unordered directory entry");307558 return result;308559 };309- const buildFile = async (chunks, options = {}) => {310- const max = options.maxLinks ?? MAX_LINKS;311- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");312- const leaves = chunks.map((c) => typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key));313- if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");314- const manifests = [];315- let level = leaves;316- while (level.length > max) {317- const next = [];318- for (let i = 0; i < level.length; i += max) {319- const group = level.slice(i, i + max), child = { l: group, t: 1 }, stored = await storeNode(child, options, manifests);320- next.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, s: group.reduce((n, x) => n + x.s, 0), t: 1 });321- }322- level = next;323- }324- const root = { l: level, t: 1 };325- await storeNode(root, options, manifests);326- return options.returnDetails ? { root, manifests } : root;327- };328- const buildDirectory = async (entries, options = {}) => {329- if (!Array.isArray(entries)) throw Error("entries must be an array");330- const max = options.maxLinks ?? MAX_LINKS;331- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");332- const sorted = entries.map((e) => {333- const x = { h: hashBytes(e.hash), n: e.name, s: e.size || 0, t: e.type || 0 };334- if (e.key !== void 0) x.k = keyBytes(e.key);335- if (e.metadata !== void 0) x.m = e.metadata;336- return x;337- }).sort((a, b) => utf8cmp(a.n, b.n));338- for (let i = 1; i < sorted.length; i++) if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");339- const manifests = [];340- if (sorted.length <= max) {341- const root2 = { l: sorted, t: 2 };342- await storeNode(root2, options, manifests);343- return options.returnDetails ? { root: root2, manifests } : root2;344- }345- let level = [];346- for (let i = 0; i < sorted.length; i += max) {347- const l = sorted.slice(i, i + max), child = { l, t: 2 }, stored = await storeNode(child, options, manifests);348- level.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, m: { count: l.length, first: l[0].n, last: l[l.length - 1].n }, s: l.reduce((n, x) => n + x.s, 0), t: 2 });349- }350- while (level.length > max) {351- const next = [];352- for (let i = 0; i < level.length; i += max) {353- const l = level.slice(i, i + max), child = { l, t: 3 }, stored = await storeNode(child, options, manifests);354- next.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, m: { count: l.reduce((n, x) => n + x.m.count, 0), first: l[0].m.first, last: l[l.length - 1].m.last }, s: l.reduce((n, x) => n + x.s, 0), t: 3 });355- }356- level = next;357- }358- const root = { l: level, t: 3 };359- await storeNode(root, options, manifests);360- return options.returnDetails ? { root, manifests } : root;361- };362- const flattenDirectory = (node, out = []) => {560+ return walk(root, 0);561+ };562+ const readFile = async (root, fetchBlob, options = {}) => {563+ if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");564+ const maxDepth = options.maxDepth ?? MAX_DEPTH,565+ maxBytes = options.maxBytes ?? 512 * 1024 * 1024,566+ maxManifests = options.maxManifests ?? 1e4;567+ const maxFetchedBytes =568+ options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;569+ if (570+ [maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(571+ (value) => !Number.isSafeInteger(value) || value < 0,572+ )573+ )574+ throw Error("invalid file limits");575+ let used = 0,576+ fetched = 0,577+ manifests = 0;578+ const active = new Set(),579+ parts = [];580+ const readNode = async (node, depth) => {581+ if (depth > maxDepth) throw Error("file recursion limit exceeded");363582 validateNode(node);364- if (node.t === 2) {365- out.push(...node.l);366- return out;367- }368- if (node.t !== 3) throw Error("not a directory");369- for (const x of node.l) if (x.child) flattenDirectory(x.child, out);370- return out;371- };372- const resolveDirectory = async (root, fetchNode, options = {}) => {373- if (typeof fetchNode !== "function") throw Error("fetchNode is required");374- const maxDepth = options.maxDepth ?? MAX_DEPTH, maxEntries = options.maxEntries ?? 1e5, maxManifests = options.maxManifests ?? 1e4, maxBytes = options.maxBytes ?? 64 * 1024 * 1024;375- if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0)) throw Error("invalid directory limits");376- let count = 0, manifestCount = 0, fetched = 0;377- const active = /* @__PURE__ */ new Set();378- const walk = async (node, depth) => {379- if (depth > maxDepth) throw Error("directory recursion limit exceeded");380- if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");381- validateNode(node);382- if (node.t === 2) {383- count += node.l.length;384- if (count > maxEntries) throw Error("directory entry limit exceeded");385- return node.l.slice();386- }387- if (node.t !== 3) throw Error("not a directory");388- const result = [];389- let previous = "";390- for (const link of node.l) {391- if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");392- previous = link.m.last;393- const linkHash = hex(link.h);394- if (active.has(linkHash)) throw Error("manifest cycle detected");395- active.add(linkHash);396- let raw = asBytes(await fetchNode(linkHash, link));397- fetched += raw.length;398- if (fetched > maxBytes) throw Error("directory byte limit exceeded");399- if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");400- if (link.k) {401- if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");402- raw = asBytes(await options.decrypt(raw, link));403- }583+ if (node.t !== 1) throw Error("not a file manifest");584+ if (++manifests > maxManifests)585+ throw Error("manifest count limit exceeded");586+ for (const link of node.l) {587+ const hash = hex(link.h);588+ if (active.has(hash)) throw Error("manifest cycle detected");589+ active.add(hash);590+ let raw = asBytes(await fetchBlob(hash, link));591+ fetched += raw.length;592+ if (fetched > maxFetchedBytes)593+ throw Error("file fetched byte limit exceeded");594+ if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");595+ if (link.t === 1) {596+ if (typeof options.decrypt !== "function" && link.k)597+ throw Error("encrypted manifest requires decrypt callback");598+ if (link.k) raw = asBytes(await options.decrypt(raw, link));404599 const child = decodeManifest(raw);405- if (child.t !== link.t) throw Error("fanout child type mismatch");406- const links = await walk(child, depth + 1);407- active.delete(linkHash);408- if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last) throw Error("fanout bounds mismatch");409- result.push(...links);410- }411- for (let i = 1; i < result.length; i++) if (utf8cmp(result[i - 1].n, result[i].n) >= 0) throw Error("duplicate or unordered directory entry");412- return result;413- };414- return walk(root, 0);415- };416- const readFile = async (root, fetchBlob, options = {}) => {417- if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");418- const maxDepth = options.maxDepth ?? MAX_DEPTH, maxBytes = options.maxBytes ?? 512 * 1024 * 1024, maxManifests = options.maxManifests ?? 1e4;419- const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;420- if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0)) throw Error("invalid file limits");421- let used = 0, fetched = 0, manifests = 0;422- const active = /* @__PURE__ */ new Set(), parts = [];423- const readNode = async (node, depth) => {424- if (depth > maxDepth) throw Error("file recursion limit exceeded");425- validateNode(node);426- if (node.t !== 1) throw Error("not a file manifest");427- if (++manifests > maxManifests) throw Error("manifest count limit exceeded");428- for (const link of node.l) {429- const hash = hex(link.h);430- if (active.has(hash)) throw Error("manifest cycle detected");431- active.add(hash);432- let raw = asBytes(await fetchBlob(hash, link));433- fetched += raw.length;434- if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");435- if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");436- if (link.t === 1) {437- if (typeof options.decrypt !== "function" && link.k) throw Error("encrypted manifest requires decrypt callback");438- if (link.k) raw = asBytes(await options.decrypt(raw, link));439- const child = decodeManifest(raw);440- if (child.t !== 1) throw Error("file child type mismatch");441- const before = used;442- await readNode(child, depth + 1);443- if (used - before !== link.s) throw Error("file manifest size mismatch");444- } else {445- if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");446- const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;447- if (data.length !== link.s) throw Error("file chunk size mismatch");448- used += data.length;449- if (used > maxBytes) throw Error("file byte limit exceeded");450- parts.push(data);451- }452- active.delete(hash);600+ if (child.t !== 1) throw Error("file child type mismatch");601+ const before = used;602+ await readNode(child, depth + 1);603+ if (used - before !== link.s)604+ throw Error("file manifest size mismatch");605+ } else {606+ if (link.k && typeof options.decrypt !== "function")607+ throw Error("encrypted chunk requires decrypt callback");608+ const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;609+ if (data.length !== link.s) throw Error("file chunk size mismatch");610+ used += data.length;611+ if (used > maxBytes) throw Error("file byte limit exceeded");612+ parts.push(data);453613 }454- };455- await readNode(root, 0);456- const out = new Uint8Array(used);457- let at = 0;458- for (const p of parts) {459- out.set(p, at);460- at += p.length;614+ active.delete(hash);461615 }462- return out;463616 };464- globalThis.TinyBlossomManifests = Object.freeze({ CHUNK_SIZE, MAX_LINKS, encodeManifest, decodeManifest, encode: encodeManifest, decode: decodeManifest, manifestHash, buildFile, buildDirectory, flattenDirectory, resolveDirectory, readFile, sha256, hex });465- })();617+ await readNode(root, 0);618+ const out = new Uint8Array(used);619+ let at = 0;620+ for (const p of parts) {621+ out.set(p, at);622+ at += p.length;623+ }624+ return out;625+ };626+ const api = Object.freeze({627+ CHUNK_SIZE,628+ MAX_LINKS,629+ encodeManifest,630+ decodeManifest,631+ encode: encodeManifest,632+ decode: decodeManifest,633+ manifestHash,634+ buildFile,635+ buildDirectory,636+ flattenDirectory,637+ resolveDirectory,638+ readFile,639+ sha256,640+ hex,641+ });642+ globalThis.tiny = globalThis.tiny || {};643+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, manifests: api };466644 })();@@ -1,80 +1,159 @@
11 // BUD-14 draft multipart uploads.2-// https://raw.githubusercontent.com/hzrd149/blossom/multi-part-uploads-2/buds/14.md2+// https://github.com/hzrd149/blossom/pull/10233 // Upload state is deliberately held by the caller. It does not survive a page44 // navigation because BUD-14 has no offset discovery protocol.55 (() => {66 "use strict";77 8- const sha256 = async bytes => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));9- const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");10- const asBytes = async value => {8+ const {sha256, hex} = globalThis.tiny.util;9+ const asBytes = async (value) => {1110 if (value instanceof Uint8Array) return value;12- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);11+ if (ArrayBuffer.isView(value))12+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);1313 if (value instanceof ArrayBuffer) return new Uint8Array(value);1414 if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());1515 throw new TypeError("expected Blob, ArrayBuffer, or Uint8Array");1616 };17- const responseBody = async response => {18- try { return await response.json(); } catch { return null; }17+ const responseBody = async (response) => {18+ try {19+ return await response.json();20+ } catch {21+ return null;22+ }23+ };24+ const abortError = () => {25+ const error = new Error("upload canceled");26+ error.name = "AbortError";27+ return error;1928 };20- const abortError = () => { const error = new Error("upload canceled"); error.name = "AbortError"; return error; };2129 const verifyDescriptor = (descriptor, hash, size) => {22- if (!descriptor || typeof descriptor !== "object" || descriptor.sha256 !== hash || descriptor.size !== size) throw new Error("invalid Blossom upload descriptor");30+ if (31+ !descriptor ||32+ typeof descriptor !== "object" ||33+ descriptor.sha256 !== hash ||34+ descriptor.size !== size35+ )36+ throw new Error("invalid Blossom upload descriptor");2337 return descriptor;2438 };2539 2640 const uploadTask = async (source, options = {}, controller) => {2741 const bytes = await asBytes(source);2842 const hash = options.hash || hex(await sha256(bytes));29- if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a lowercase SHA-256 hash");30- if (options.hash && hash !== hex(await sha256(bytes))) throw new Error("upload hash mismatch");43+ if (!/^[0-9a-f]{64}$/.test(hash))44+ throw new Error("expected a lowercase SHA-256 hash");45+ if (options.hash && hash !== hex(await sha256(bytes)))46+ throw new Error("upload hash mismatch");3147 const type = options.type || source.type || "application/octet-stream";3248 const base = options.url || options.endpoint;33- if (typeof base !== "string" || base === "") throw new Error("upload URL is required");49+ if (typeof base !== "string" || base === "")50+ throw new Error("upload URL is required");3451 let parsed;35- try { parsed = new URL(base, globalThis.location?.href || "https://blossom.invalid"); } catch { throw new Error("invalid upload URL"); }36- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("upload URL must use HTTP or HTTPS");37- if (parsed.username || parsed.password || parsed.hash || parsed.search) throw new Error("upload URL must not contain credentials, query, or fragment");38- const extension = options.extension ? String(options.extension).replace(/^\.+/, "") : "";39- if (extension && !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error("invalid file extension");52+ try {53+ parsed = new URL(54+ base,55+ globalThis.location?.href || "https://blossom.invalid",56+ );57+ } catch {58+ throw new Error("invalid upload URL");59+ }60+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")61+ throw new Error("upload URL must use HTTP or HTTPS");62+ if (parsed.username || parsed.password || parsed.hash || parsed.search)63+ throw new Error(64+ "upload URL must not contain credentials, query, or fragment",65+ );66+ const extension = options.extension67+ ? String(options.extension).replace(/^\.+/, "")68+ : "";69+ if (extension && !/^[a-z0-9]{1,8}$/.test(extension))70+ throw new Error("invalid file extension");4071 const path = parsed.pathname;41- const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path) ? base : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");72+ const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(73+ path,74+ )75+ ? base76+ : base.replace(/\/$/, "") +77+ "/" +78+ hash +79+ (extension ? "." + extension : "");4280 const url = target;4381 const fetcher = options.fetch || globalThis.fetch;4482 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");45- const chunkSize = Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;46- const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;47- const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 0;83+ const chunkSize =84+ Number.isSafeInteger(options.chunkSize) && options.chunkSize > 085+ ? options.chunkSize86+ : 5 * 1024 * 1024;87+ const timeoutMs =88+ Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 089+ ? options.timeoutMs90+ : 30000;91+ const retries =92+ Number.isSafeInteger(options.retries) && options.retries >= 093+ ? options.retries94+ : 0;4895 const state = options.state instanceof Map ? options.state : new Map();49- const notify = (offset, length, status) => options.onState?.({offset, length, status, state});50- const progress = sent => options.onProgress?.({sent, total: bytes.byteLength, fraction: bytes.byteLength ? sent / bytes.byteLength : 1});96+ const notify = (offset, length, status) =>97+ options.onState?.({ offset, length, status, state });98+ const progress = (sent) =>99+ options.onProgress?.({100+ sent,101+ total: bytes.byteLength,102+ fraction: bytes.byteLength ? sent / bytes.byteLength : 1,103+ });51104 const request = async (method, body, headers, authBody = body) => {52105 if (controller.signal.aborted) throw abortError();53106 const requestController = new AbortController();54107 let timer, rejectCanceled;55- const cancel = () => { requestController.abort(); rejectCanceled?.(abortError()); };56- controller.signal.addEventListener("abort", cancel, {once: true});108+ const cancel = () => {109+ requestController.abort();110+ rejectCanceled?.(abortError());111+ };112+ controller.signal.addEventListener("abort", cancel, { once: true });57113 try {58114 return await Promise.race([59115 (async () => {60- const requestHeaders = {...headers};116+ const requestHeaders = { ...headers };61117 if (options.authorize && method !== "OPTIONS") {62- const value = await options.authorize(url, method, authBody, {hash, type});63- if (typeof value === "string") requestHeaders.authorization = value;64- else if (value && typeof value === "object") Object.assign(requestHeaders, value);118+ const value = await options.authorize(url, method, authBody, {119+ hash,120+ type,121+ });122+ if (typeof value === "string")123+ requestHeaders.authorization = value;124+ else if (value && typeof value === "object")125+ Object.assign(requestHeaders, value);65126 }66127 if (requestController.signal.aborted) throw abortError();67- const response = await fetcher(url, {method, headers: requestHeaders, body: method === "OPTIONS" ? undefined : body, signal: requestController.signal});128+ const response = await fetcher(url, {129+ method,130+ headers: requestHeaders,131+ body: method === "OPTIONS" ? undefined : body,132+ signal: requestController.signal,133+ });68134 // Keep the request deadline and cancellation active until the69135 // descriptor body has arrived, not only until headers arrive.70- const descriptor = response.status === 200 || response.status === 201 ? await responseBody(response) : null;136+ const descriptor =137+ response.status === 200 || response.status === 201138+ ? await responseBody(response)139+ : null;71140 if (!descriptor) await response.body?.cancel?.();72- return {status: response.status, ok: response.ok, headers: response.headers, json: async () => descriptor};141+ return {142+ status: response.status,143+ ok: response.ok,144+ headers: response.headers,145+ json: async () => descriptor,146+ };73147 })(),74148 new Promise((_, reject) => {75- timer = setTimeout(() => { requestController.abort(); reject(Error("upload request timed out")); }, timeoutMs);149+ timer = setTimeout(() => {150+ requestController.abort();151+ reject(Error("upload request timed out"));152+ }, timeoutMs);153+ }),154+ new Promise((_, reject) => {155+ rejectCanceled = reject;76156 }),77- new Promise((_, reject) => { rejectCanceled = reject; })78157 ]);79158 } finally {80159 clearTimeout(timer);@@ -88,7 +167,10 @@
88167 if (controller.signal.aborted) throw abortError();89168 try {90169 const result = await action();91- if (!result.ok && result.status >= 500 && attempt++ < retries) { notify(offset, length, "retrying"); continue; }170+ if (!result.ok && result.status >= 500 && attempt++ < retries) {171+ notify(offset, length, "retrying");172+ continue;173+ }92174 return result;93175 } catch (error) {94176 if (controller.signal.aborted) throw abortError();@@ -102,45 +184,112 @@
102184 if (options.probe !== false) {103185 try {104186 const response = await request("OPTIONS", null, {});105- supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");106- } catch { supportsPatch = false; }187+ supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(188+ response.headers?.get?.("allow") || "",189+ );190+ } catch {191+ supportsPatch = false;192+ }107193 }108194 if (!supportsPatch) {109- const response = await retry(() => request("PUT", bytes, {"content-type": type}), 0, bytes.byteLength);110- if (!response.ok) throw new Error("Blossom upload failed: " + response.status);111- return {hash, descriptor: verifyDescriptor(await responseBody(response), hash, bytes.byteLength), state};195+ const response = await retry(196+ () => request("PUT", bytes, { "content-type": type }),197+ 0,198+ bytes.byteLength,199+ );200+ if (!response.ok)201+ throw new Error("Blossom upload failed: " + response.status);202+ return {203+ hash,204+ descriptor: verifyDescriptor(205+ await responseBody(response),206+ hash,207+ bytes.byteLength,208+ ),209+ state,210+ };112211 }113212 114213 const metadata = state.meta;115- if (!metadata || metadata.hash !== hash || metadata.size !== bytes.byteLength || metadata.chunkSize !== chunkSize || metadata.url !== url || metadata.type !== type || Date.now() - (metadata.lastAcceptedAt || 0) >= 60000) {214+ if (215+ !metadata ||216+ metadata.hash !== hash ||217+ metadata.size !== bytes.byteLength ||218+ metadata.chunkSize !== chunkSize ||219+ metadata.url !== url ||220+ metadata.type !== type ||221+ Date.now() - (metadata.lastAcceptedAt || 0) >= 60000222+ ) {116223 state.clear();117- state.meta = {hash, size: bytes.byteLength, chunkSize, url, type, lastAcceptedAt: 0};224+ state.meta = {225+ hash,226+ size: bytes.byteLength,227+ chunkSize,228+ url,229+ type,230+ lastAcceptedAt: 0,231+ };118232 }119233 let sent = 0;120234 let replayed = false;121235 while (true) {122- for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {123- const end = Math.min(offset + chunkSize, bytes.byteLength);124- const chunk = bytes.slice(offset, end);125- const length = chunk.byteLength;126- const prior = state.get(offset);127- if (!replayed && prior?.status === 204 && Date.now() - (state.meta.lastAcceptedAt || 0) < 60000) { sent = end; progress(sent); continue; }128- const result = await retry(() => request("PATCH", chunk, {129- "upload-type": type,130- "upload-length": String(bytes.byteLength),131- "upload-offset": String(offset),132- "content-type": "application/octet-stream"133- }, options.authorizationMode === "final-hash" ? bytes : chunk), offset, length);134- if (![200, 201, 204].includes(result.status)) throw new Error("Blossom chunk upload failed: " + result.status);135- state.set(offset, {offset, length, status: result.status});136- state.meta.lastAcceptedAt = Date.now();137- sent = end;138- notify(offset, length, result.status === 204 ? "uploaded" : "complete");139- progress(sent);140- if (result.status !== 204) return {hash, descriptor: verifyDescriptor(await responseBody(result), hash, bytes.byteLength), state};141- if (bytes.byteLength === 0) break;236+ for (237+ let offset = 0;238+ offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0);239+ offset += chunkSize240+ ) {241+ const end = Math.min(offset + chunkSize, bytes.byteLength);242+ const chunk = bytes.slice(offset, end);243+ const length = chunk.byteLength;244+ const prior = state.get(offset);245+ if (246+ !replayed &&247+ prior?.status === 204 &&248+ Date.now() - (state.meta.lastAcceptedAt || 0) < 60000249+ ) {250+ sent = end;251+ progress(sent);252+ continue;253+ }254+ const result = await retry(255+ () =>256+ request(257+ "PATCH",258+ chunk,259+ {260+ "upload-type": type,261+ "upload-length": String(bytes.byteLength),262+ "upload-offset": String(offset),263+ "content-type": "application/octet-stream",264+ },265+ options.authorizationMode === "final-hash" ? bytes : chunk,266+ ),267+ offset,268+ length,269+ );270+ if (![200, 201, 204].includes(result.status))271+ throw new Error("Blossom chunk upload failed: " + result.status);272+ state.set(offset, { offset, length, status: result.status });273+ state.meta.lastAcceptedAt = Date.now();274+ sent = end;275+ notify(offset, length, result.status === 204 ? "uploaded" : "complete");276+ progress(sent);277+ if (result.status !== 204)278+ return {279+ hash,280+ descriptor: verifyDescriptor(281+ await responseBody(result),282+ hash,283+ bytes.byteLength,284+ ),285+ state,286+ };287+ if (bytes.byteLength === 0) break;288+ }289+ if (!replayed) {290+ replayed = true;291+ continue;142292 }143- if (!replayed) { replayed = true; continue; }144293 throw new Error("multipart upload ended without completion");145294 }146295 };@@ -149,12 +298,17 @@
149298 const controller = new AbortController();150299 if (options.signal) {151300 if (options.signal.aborted) controller.abort();152- else options.signal.addEventListener("abort", () => controller.abort(), {once: true});301+ else302+ options.signal.addEventListener("abort", () => controller.abort(), {303+ once: true,304+ });153305 }154306 const task = uploadTask(source, options, controller);155307 task.cancel = () => controller.abort();156308 return task;157309 };158310 159- globalThis.TinyBlossomUpload = Object.freeze({upload});311+ const api = Object.freeze({ upload });312+ globalThis.tiny = globalThis.tiny || {};313+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, upload: api };160314 })();@@ -18,8 +18,8 @@
1818 if (status) status.textContent = text;1919 };2020 const errorText = err => err instanceof Error ? err.message : String(err);21- const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");22- const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));21+ const {hex, sha256} = window.tiny.util;22+ const sha256hex = async bytes => hex(await sha256(bytes));2323 const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };2424 const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");2525 const validRelay = value => {@@ -102,7 +102,7 @@
102102 document.getElementById("menu")?.focus();103103 });104104 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});105- window.tiny = {root, localPath, sha256hex, authorization, signedFetch};105+ Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch});106106 window.tinySignedFetch = signedFetch;107107 108108 const signIn = async () => {@@ -127,6 +127,7 @@
127127 const setSigner = signer => {128128 window.tinySigner = signer;129129 window.nostr = {signEvent: event => window.tinySigner.signEvent(event)};130+ document.dispatchEvent(new CustomEvent("tiny:signer"));130131 if (document.getElementById("signer-status")) {131132 say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue.");132133 }@@ -244,7 +244,7 @@
244244 blossomURI() {245245 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));246246 const extension = params.has("manifest") ? (["2", "3"].includes(params.get("node") || "2") ? "bdir" : params.get("node") === "1" ? "bfile" : "bin") : "bin";247- const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? TinyBlossomEncryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();247+ const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? tiny.blossom.encryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();248248 return tiny.root ? uri : uri + (uri.includes("?") ? "&" : "?") + "xs=" + encodeURIComponent(new URL(location.href).host);249249 }250250 shareLink() {@@ -280,8 +280,8 @@
280280 const plaintextHash = await tiny.sha256hex(plaintext);281281 let ciphertext, fragment;282282 if (scheme === "chk") {283- if (!globalThis.TinyBlossomEncryption) throw Error("CHK encryption is unavailable. Reload this page.");284- const encrypted = await TinyBlossomEncryption.encryptCHK(plaintext);283+ if (!tiny.blossom.encryption) throw Error("CHK encryption is unavailable. Reload this page.");284+ const encrypted = await tiny.blossom.encryption.encryptCHK(plaintext);285285 ciphertext = encrypted.ciphertext;286286 fragment = "enc=chk-v1&key=" + encrypted.key;287287 } else {@@ -313,8 +313,8 @@
313313 const actualHash = await tiny.sha256hex(ciphertext);314314 this.uploadController = new AbortController();315315 let descriptor;316- if (globalThis.TinyBlossomUpload?.upload) {317- const task = TinyBlossomUpload.upload(ciphertext, {316+ if (tiny.blossom.upload?.upload) {317+ const task = tiny.blossom.upload.upload(ciphertext, {318318 url: new URL(tiny.localPath("/"), location.href).href, hash: actualHash,319319 type: "application/octet-stream", state, signal: this.uploadController.signal,320320 authorize: (url, verb, body) => tiny.authorization(url, verb, body),@@ -362,7 +362,7 @@
362362 const encrypted = new Uint8Array(await response.arrayBuffer());363363 if (await tiny.sha256hex(encrypted) !== this.hash()) throw Error("ciphertext hash mismatch");364364 let plain;365- if (params.get("enc") === "chk-v1") plain = await TinyBlossomEncryption.decryptCHK(encrypted, params.get("key"), this.hash());365+ if (params.get("enc") === "chk-v1") plain = await tiny.blossom.encryption.decryptCHK(encrypted, params.get("key"), this.hash());366366 else {367367 const key = await crypto.subtle.importKey("raw", fromB64url(params.get("key")), "AES-GCM", false, ["decrypt"]);368368 plain = await crypto.subtle.decrypt({name: "AES-GCM", iv: fromB64url(params.get("iv"))}, key, encrypted);@@ -379,15 +379,15 @@
379379 if (!recipient) { this.say("Enter a public key or npub.", true); return; }380380 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));381381 const signer = globalThis.tinySigner || globalThis.nostr;382- if (!globalThis.TinyFileMessages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }382+ if (!tiny.files.messages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }383383 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }384384 this.sharing = true;385385 try {386386 const key = fromB64url(params.get("key")), nonce = fromB64url(params.get("iv"));387387 if (key.length !== 32 || nonce.length !== 12) throw Error("Invalid file key or nonce.");388- const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");388+ const {hex} = tiny.util;389389 this.say("Preparing sealed NIP-17 delivery…");390- await TinyFileMessages.share({recipient, fileURL: new URL(tiny.localPath("/files/raw?hash=" + this.hash()), location.href).href, ciphertextHash: this.hash(), plaintextHash: params.get("ox") || undefined, key: hex(key), nonce: hex(nonce), mimeType: params.get("type") || this.type(), size: Number(this.getAttribute("size") || 0) || undefined}, {signer});390+ await tiny.files.messages.share({recipient, fileURL: new URL(tiny.localPath("/files/raw?hash=" + this.hash()), location.href).href, ciphertextHash: this.hash(), plaintextHash: params.get("ox") || undefined, key: hex(key), nonce: hex(nonce), mimeType: params.get("type") || this.type(), size: Number(this.getAttribute("size") || 0) || undefined}, {signer});391391 this.say("Encrypted message delivered; sender copy saved.");392392 } catch (error) { this.say("NIP-17 delivery failed: " + error.message, true); } finally { this.sharing = false; }393393 }@@ -467,8 +467,11 @@
467467 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});468468 const result = await response.json();469469 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");470- this.report("Published. Reload to see the update.");470+ this.report("Published.");471471 form.reset();472+ // Refresh through the shared navigation so the thread shows the new473+ // event without a full reload.474+ await tiny.navigate?.(globalThis.location?.href);472475 }473476 }474477 @@ -708,11 +711,12 @@
708711 this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));709712 }710713 711- // whenSigned waits briefly for a signer, since a remote one resumes async.712- whenSigned(fn, tries = 20) {714+ // whenSigned runs once a signer is available; the bridge announces a715+ // connected or resumed remote signer with the tiny:signer event.716+ whenSigned(fn) {713717 if (window.nostr?.signEvent) return fn();714- if (tries <= 0) { this.say("connect a signer, then reload this page"); return; }715- setTimeout(() => this.whenSigned(fn, tries - 1), 500);718+ this.say("connect a signer to load this list");719+ document.addEventListener("tiny:signer", () => fn(), {once: true});716720 }717721 718722 say(text, error) {@@ -929,6 +933,8 @@
929933 throw error;930934 } finally { if (serial === navigationSerial) setBusy(false); }931935 };936+ // navigate re-requests a page in place; components use it after a publish.937+ tiny.navigate = href => load(new URL(href, location.href), false);932938 document.addEventListener("fx:config", event => {933939 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");934940 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;@@ -7,7 +7,12 @@
77 88 function api() {99 const signer = root.NostrSigner;10- if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {10+ if (11+ !signer ||12+ !signer.nip44 ||13+ !signer.finalizeEvent ||14+ !signer.generateSecretKey15+ ) {1116 throw new Error("NIP-44 support is unavailable");1217 }1318 return signer;@@ -16,7 +21,11 @@
1621 function relayURL(value) {1722 if (typeof value !== "string" || value.length > 2048) return null;1823 let url;19- try { url = new URL(value); } catch { return null; }24+ try {25+ url = new URL(value);26+ } catch {27+ return null;28+ }2029 if (url.protocol !== "wss:" && url.protocol !== "ws:") return null;2130 if (url.username || url.password || url.hash) return null;2231 return url.toString().replace(/\/$/, "");@@ -24,15 +33,27 @@
2433 2534 function relayList(event, pubkey) {2635 if (Array.isArray(event)) {27- event = event.filter((candidate) => candidate?.kind === 10050 && candidate.pubkey === pubkey)36+ event = event37+ .filter(38+ (candidate) =>39+ candidate?.kind === 10050 && candidate.pubkey === pubkey,40+ )2841 .sort((a, b) => b.created_at - a.created_at)[0];2942 }3043 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {3144 throw new Error("recipient has no valid kind 10050 relay list");3245 }3346 const signer = api();34- if (!signer.verifyEvent(event)) throw new Error("invalid kind 10050 relay list signature");35- const relays = [...new Set(event.tags.filter((tag) => tag[0] === "relay").map((tag) => relayURL(tag[1])).filter(Boolean))];47+ if (!signer.verifyEvent(event))48+ throw new Error("invalid kind 10050 relay list signature");49+ const relays = [50+ ...new Set(51+ event.tags52+ .filter((tag) => tag[0] === "relay")53+ .map((tag) => relayURL(tag[1]))54+ .filter(Boolean),55+ ),56+ ];3657 if (!relays.length) throw new Error("kind 10050 relay list is empty");3758 return relays.slice(0, MAX_RELAYS);3859 }@@ -41,7 +62,8 @@
4162 const text = String(value || "").trim();4263 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();4364 const decoded = api().decodeNpub?.(text);44- if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();65+ if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded))66+ return decoded.toLowerCase();4567 throw new Error("public key must be a 64 character hex key or npub");4668 }4769 @@ -49,10 +71,15 @@
4971 const ms = Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;5072 let timer;5173 try {52- return await Promise.race([Promise.resolve(value), new Promise((_, reject) => {53- timer = setTimeout(() => reject(new Error(label + " timed out")), ms);54- })]);55- } finally { clearTimeout(timer); }74+ return await Promise.race([75+ Promise.resolve(value),76+ new Promise((_, reject) => {77+ timer = setTimeout(() => reject(new Error(label + " timed out")), ms);78+ }),79+ ]);80+ } finally {81+ clearTimeout(timer);82+ }5683 }5784 5885 function sent(result) {@@ -63,70 +90,166 @@
6390 }6491 6592 function past(now) {66- return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));93+ return Math.max(94+ 0,95+ Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)),96+ );6797 }6898 6999 async function publicKey(signer, getPublicKey) {70- const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();71- if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw new Error("invalid signer public key");100+ const key = getPublicKey101+ ? await getPublicKey()102+ : await signer.getPublicKey();103+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key))104+ throw new Error("invalid signer public key");72105 return key.toLowerCase();73106 }74107 75108 async function queryRelayList(pubkey, options) {76- if (!root.tiny?.signedFetch) throw new Error("signed local query is unavailable");77- const response = await root.tiny.signedFetch("/query", "POST", JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]), { contentType: "application/json" });109+ if (!root.tiny?.signedFetch)110+ throw new Error("signed local query is unavailable");111+ const response = await root.tiny.signedFetch(112+ "/query",113+ "POST",114+ JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]),115+ { contentType: "application/json" },116+ );78117 return response.json();79118 }80119 81120 function publishEvent(event, relays, options) {82121 const signer = options.signer || root.tinySigner || root.nostr;83122 const WebSocketImpl = options.WebSocket || root.WebSocket;84- if (typeof WebSocketImpl !== "function") throw new Error("WebSocket support is unavailable");85- const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;86- return Promise.all(relays.map((url) => new Promise((resolve) => {87- let socket;88- let timer;89- let settled = false;90- let authID = "";91- let authAttempts = 0;92- let eventSent = false;93- const finish = (ok) => { if (settled) return; settled = true; clearTimeout(timer); try { socket?.close(); } catch {} resolve(ok); };94- const sendEvent = () => { if (!settled && !eventSent) { eventSent = true; socket.send(JSON.stringify(["EVENT", event])); } };95- try {96- socket = new WebSocketImpl(url);97- timer = setTimeout(() => finish(false), timeout);98- socket.onopen = sendEvent;99- socket.onmessage = async (message) => {100- let frame;101- try { frame = JSON.parse(typeof message.data === "string" ? message.data : ""); } catch { return; }102- if (settled || !Array.isArray(frame)) return;103- if (frame[0] === "OK" && frame[1] === authID) {104- if (frame[2] === true) { eventSent = false; sendEvent(); } else finish(false);105- return;106- }107- if (frame[0] === "OK" && frame[1] === event.id) {108- if (frame[2] !== true && String(frame[3] || "").toLowerCase().startsWith("auth-required")) { eventSent = false; return; }109- finish(frame[2] === true); return;110- }111- if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;112- if (typeof frame[1] !== "string" || frame[1].length > 4096) { finish(false); return; }113- if (authAttempts++ >= 1) { finish(false); return; }114- try {115- const auth = await signer.signEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [["relay", url], ["challenge", frame[1]], ["method", "AUTH"]], content: "" });116- if (auth && !settled && auth.kind === 22242 && auth.content === "" && auth.tags.length === 3 && auth.tags[0][0] === "relay" && auth.tags[0][1] === url && auth.tags[1][0] === "challenge" && auth.tags[1][1] === frame[1] && auth.tags[2][0] === "method" && auth.tags[2][1] === "AUTH" && api().verifyEvent(auth) && auth.pubkey === await publicKey(signer)) { authID = auth.id; socket.send(JSON.stringify(["AUTH", auth])); }117- else finish(false);118- } catch { finish(false); }119- };120- socket.onerror = () => finish(false);121- socket.onclose = () => finish(false);122- } catch { finish(false); }123- })));123+ if (typeof WebSocketImpl !== "function")124+ throw new Error("WebSocket support is unavailable");125+ const timeout =126+ Number.isFinite(options.timeoutMs) && options.timeoutMs > 0127+ ? options.timeoutMs128+ : 10000;129+ return Promise.all(130+ relays.map(131+ (url) =>132+ new Promise((resolve) => {133+ let socket;134+ let timer;135+ let settled = false;136+ let authID = "";137+ let authAttempts = 0;138+ let eventSent = false;139+ const finish = (ok) => {140+ if (settled) return;141+ settled = true;142+ clearTimeout(timer);143+ try {144+ socket?.close();145+ } catch {}146+ resolve(ok);147+ };148+ const sendEvent = () => {149+ if (!settled && !eventSent) {150+ eventSent = true;151+ socket.send(JSON.stringify(["EVENT", event]));152+ }153+ };154+ try {155+ socket = new WebSocketImpl(url);156+ timer = setTimeout(() => finish(false), timeout);157+ socket.onopen = sendEvent;158+ socket.onmessage = async (message) => {159+ let frame;160+ try {161+ frame = JSON.parse(162+ typeof message.data === "string" ? message.data : "",163+ );164+ } catch {165+ return;166+ }167+ if (settled || !Array.isArray(frame)) return;168+ if (frame[0] === "OK" && frame[1] === authID) {169+ if (frame[2] === true) {170+ eventSent = false;171+ sendEvent();172+ } else finish(false);173+ return;174+ }175+ if (frame[0] === "OK" && frame[1] === event.id) {176+ if (177+ frame[2] !== true &&178+ String(frame[3] || "")179+ .toLowerCase()180+ .startsWith("auth-required")181+ ) {182+ eventSent = false;183+ return;184+ }185+ finish(frame[2] === true);186+ return;187+ }188+ if (189+ frame[0] !== "AUTH" ||190+ typeof signer?.signEvent !== "function"191+ )192+ return;193+ if (typeof frame[1] !== "string" || frame[1].length > 4096) {194+ finish(false);195+ return;196+ }197+ if (authAttempts++ >= 1) {198+ finish(false);199+ return;200+ }201+ try {202+ const auth = await signer.signEvent({203+ kind: 22242,204+ created_at: Math.floor(Date.now() / 1000),205+ tags: [206+ ["relay", url],207+ ["challenge", frame[1]],208+ ["method", "AUTH"],209+ ],210+ content: "",211+ });212+ if (213+ auth &&214+ !settled &&215+ auth.kind === 22242 &&216+ auth.content === "" &&217+ auth.tags.length === 3 &&218+ auth.tags[0][0] === "relay" &&219+ auth.tags[0][1] === url &&220+ auth.tags[1][0] === "challenge" &&221+ auth.tags[1][1] === frame[1] &&222+ auth.tags[2][0] === "method" &&223+ auth.tags[2][1] === "AUTH" &&224+ api().verifyEvent(auth) &&225+ auth.pubkey === (await publicKey(signer))226+ ) {227+ authID = auth.id;228+ socket.send(JSON.stringify(["AUTH", auth]));229+ } else finish(false);230+ } catch {231+ finish(false);232+ }233+ };234+ socket.onerror = () => finish(false);235+ socket.onclose = () => finish(false);236+ } catch {237+ finish(false);238+ }239+ }),240+ ),241+ );124242 }125243 126244 function unsignedRumor(input, sender, now) {127- if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw new Error("fileURL must be an HTTP(S) URL");128- if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw new Error("ciphertextHash must be SHA-256 hex");129- if (!input.key || !input.nonce) throw new Error("encrypted file messages require a decryption key and nonce");245+ if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL))246+ throw new Error("fileURL must be an HTTP(S) URL");247+ if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || ""))248+ throw new Error("ciphertextHash must be SHA-256 hex");249+ if (!input.key || !input.nonce)250+ throw new Error(251+ "encrypted file messages require a decryption key and nonce",252+ );130253 const tags = [["p", input.recipient]];131254 if (input.mimeType) tags.push(["file-type", input.mimeType]);132255 tags.push(["encryption-algorithm", "aes-gcm"]);@@ -134,27 +257,51 @@
134257 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);135258 tags.push(["x", input.ciphertextHash.toLowerCase()]);136259 if (input.plaintextHash) {137- if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw new Error("plaintextHash must be SHA-256 hex");260+ if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash))261+ throw new Error("plaintextHash must be SHA-256 hex");138262 tags.push(["ox", input.plaintextHash.toLowerCase()]);139263 }140264 if (input.size != null) tags.push(["size", String(input.size)]);141- const rumor = { pubkey: sender, created_at: Math.floor(now), kind: 15, tags, content: input.fileURL };265+ const rumor = {266+ pubkey: sender,267+ created_at: Math.floor(now),268+ kind: 15,269+ tags,270+ content: input.fileURL,271+ };142272 rumor.id = api().getEventHash(rumor);143273 return rumor;144274 }145275 146276 async function encryptFor(signer, recipient, plaintext) {147- if (typeof signer.nip44Encrypt === "function") return signer.nip44Encrypt(recipient, plaintext);148- if (signer.nip44?.encrypt) return signer.nip44.encrypt(recipient, plaintext);277+ if (typeof signer.nip44Encrypt === "function")278+ return signer.nip44Encrypt(recipient, plaintext);279+ if (signer.nip44?.encrypt)280+ return signer.nip44.encrypt(recipient, plaintext);149281 throw new Error("signer has no NIP-44 encrypt method");150282 }151283 152284 async function seal(rumor, recipient, signer, now) {153285 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));154- const unsigned = { kind: 13, tags: [], content, created_at: past(now), pubkey: rumor.pubkey };286+ const unsigned = {287+ kind: 13,288+ tags: [],289+ content,290+ created_at: past(now),291+ pubkey: rumor.pubkey,292+ };155293 const event = await signer.signEvent(unsigned);156- if (!event || event.kind !== 13 || event.pubkey !== rumor.pubkey || event.tags.length || event.content !== unsigned.content || event.created_at !== unsigned.created_at) throw new Error("signer returned an invalid NIP-17 seal");157- if (!api().verifyEvent(event)) throw new Error("signer returned an invalid seal signature");294+ if (295+ !event ||296+ event.kind !== 13 ||297+ event.pubkey !== rumor.pubkey ||298+ event.tags.length ||299+ event.content !== unsigned.content ||300+ event.created_at !== unsigned.created_at301+ )302+ throw new Error("signer returned an invalid NIP-17 seal");303+ if (!api().verifyEvent(event))304+ throw new Error("signer returned an invalid seal signature");158305 return event;159306 }160307 @@ -164,46 +311,89 @@
164311 const pubkey = signer.getPublicKey(secret);165312 const key = signer.nip44.getConversationKey(secret, recipient);166313 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);167- const event = signer.finalizeEvent({ kind: 1059, tags: [["p", recipient]], content, created_at: past(now) }, secret);168- if (!signer.verifyEvent(event)) throw new Error("failed to verify gift wrap");314+ const event = signer.finalizeEvent(315+ { kind: 1059, tags: [["p", recipient]], content, created_at: past(now) },316+ secret,317+ );318+ if (!signer.verifyEvent(event))319+ throw new Error("failed to verify gift wrap");169320 return { event, secret, pubkey };170321 }171322 172323 async function build(input, options = {}) {173324 const signer = options.signer || root.tinySigner || root.nostr;174- if (!signer || typeof signer.signEvent !== "function" || (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)) throw new Error("a NIP-44 signer is required");325+ if (326+ !signer ||327+ typeof signer.signEvent !== "function" ||328+ (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)329+ )330+ throw new Error("a NIP-44 signer is required");175331 const recipient = normalizePubkey(input.recipient);176332 const sender = await publicKey(signer, options.getPublicKey);177- const now = (options.now || Date.now() / 1000);333+ const now = options.now || Date.now() / 1000;178334 const rumor = unsignedRumor({ ...input, recipient }, sender, now);179335 const receiverSeal = await seal(rumor, recipient, signer, now);180336 const senderSeal = await seal(rumor, sender, signer, now);181337 const receiver = wrap(receiverSeal, recipient, now).event;182338 const own = wrap(senderSeal, sender, now).event;183- return { rumor, events: [{ recipient, event: receiver }, { recipient: sender, event: own }] };339+ return {340+ rumor,341+ events: [342+ { recipient, event: receiver },343+ { recipient: sender, event: own },344+ ],345+ };184346 }185347 186348 async function share(input, options = {}) {187349 const signer = options.signer || root.tinySigner || root.nostr;188- const query = options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));189- const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));190- if (typeof query !== "function" || typeof publish !== "function") throw new Error("private NIP-17 delivery is unavailable");350+ const query =351+ options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));352+ const publish =353+ options.publish ||354+ ((event, relays) => publishEvent(event, relays, options));355+ if (typeof query !== "function" || typeof publish !== "function")356+ throw new Error("private NIP-17 delivery is unavailable");191357 const sender = await publicKey(signer, options.getPublicKey);192358 const recipient = normalizePubkey(input.recipient);193359 const timeout = options.timeoutMs;194- const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);195- const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);360+ const receiverList = relayList(361+ await bounded(query(recipient), timeout, "recipient relay lookup"),362+ recipient,363+ );364+ const senderList = relayList(365+ await bounded(query(sender), timeout, "sender relay lookup"),366+ sender,367+ );196368 const built = await build(input, options);197- if (built.rumor.pubkey !== sender) throw new Error("signer changed during file sharing; try again");369+ if (built.rumor.pubkey !== sender)370+ throw new Error("signer changed during file sharing; try again");198371 const deliveries = [];199372 for (const target of built.events) {200373 const relays = target.recipient === recipient ? receiverList : senderList;201- const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");202- if (!sent(delivery)) throw new Error("NIP-17 delivery failed on every inbox relay");374+ const delivery = await bounded(375+ publish(target.event, relays),376+ timeout,377+ "NIP-17 delivery",378+ );379+ if (!sent(delivery))380+ throw new Error("NIP-17 delivery failed on every inbox relay");203381 deliveries.push(delivery);204382 }205- return { ...built, relays: { recipient: receiverList, sender: senderList }, deliveries };383+ return {384+ ...built,385+ relays: { recipient: receiverList, sender: senderList },386+ deliveries,387+ };206388 }207389 208- root.TinyFileMessages = { build, share, relayList, queryRelayList, publishEvent };390+ const apiObject = Object.freeze({391+ build,392+ share,393+ relayList,394+ queryRelayList,395+ publishEvent,396+ });397+ root.tiny = root.tiny || {};398+ root.tiny.files = { ...root.tiny.files, messages: apiObject };209399 })(globalThis);@@ -8,100 +8,184 @@
88 const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;99 const MAX_FETCH_BYTES = 16 * 1024 * 1024 + 16;1010 const directoryType = "application/vnd.blossom.directory+msgpack";11- const codec = () => globalThis.TinyBlossomManifests;12- const hex = value => codec().hex(value);13- const fromHex = value => {14- if (!/^[0-9a-f]{64}$/.test(value || "")) throw Error("Invalid hash or encryption key.");15- return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));11+ const codec = () =>12+ globalThis.tiny?.blossom?.manifests;13+ const encryption = () =>14+ globalThis.tiny?.blossom?.encryption;15+ const upload = () =>16+ globalThis.tiny?.blossom?.upload;17+ const hex = (value) => globalThis.tiny.util.hex(value);18+ const fromHex = (value) => {19+ if (!/^[0-9a-f]{64}$/.test(value || ""))20+ throw Error("Invalid hash or encryption key.");21+ return Uint8Array.from(value.match(/../g), (byte) => parseInt(byte, 16));1622 };1723 const element = (tag, text) => {1824 const node = document.createElement(tag);1925 if (text !== undefined) node.textContent = text;2026 return node;2127 };22- const checkAbort = signal => {23- if (signal?.aborted) throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});28+ const checkAbort = (signal) => {29+ if (signal?.aborted)30+ throw Object.assign(31+ Error("Upload canceled. Retry while this page stays open."),32+ { name: "AbortError" },33+ );2434 };25- const validName = name => typeof name === "string" && name.length > 0 && name.isWellFormed() && ![".", ".."].includes(name) && !/[\/\u0000]/.test(name) && new TextEncoder().encode(name).length <= 1024;26- const folderNode = () => ({directories: new Map(), files: new Map()});35+ const validName = (name) =>36+ typeof name === "string" &&37+ name.length > 0 &&38+ name.isWellFormed() &&39+ ![".", ".."].includes(name) &&40+ !/[\/\u0000]/.test(name) &&41+ new TextEncoder().encode(name).length <= 1024;42+ const folderNode = () => ({ directories: new Map(), files: new Map() });2743 2844 function selectionTree(files) {29- if (!files.length || files.length > MAX_FILES) throw Error("Choose a folder with 1 to 10,000 files.");45+ if (!files.length || files.length > MAX_FILES)46+ throw Error("Choose a folder with 1 to 10,000 files.");3047 const root = folderNode();3148 let total = 0;32- const paths = files.map(file => (file.webkitRelativePath || file.name).split("/"));33- const commonRoot = paths.every(path => path.length > 1 && path[0] === paths[0][0]);49+ const paths = files.map((file) =>50+ (file.webkitRelativePath || file.name).split("/"),51+ );52+ const commonRoot = paths.every(53+ (path) => path.length > 1 && path[0] === paths[0][0],54+ );3455 for (let index = 0; index < files.length; index++) {3556 const file = files[index];3657 const path = commonRoot ? paths[index].slice(1) : paths[index];37- if (path.length > MAX_DEPTH || path.some(name => !validName(name))) throw Error("Invalid folder path.");38- if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");58+ if (path.length > MAX_DEPTH || path.some((name) => !validName(name)))59+ throw Error("Invalid folder path.");60+ if (61+ !Number.isSafeInteger(file.size) ||62+ file.size < 0 ||63+ file.size > MAX_FILE_BYTES64+ )65+ throw Error("Files must be at most 256 MiB in the browser workspace.");3966 total += file.size;40- if (total > MAX_FOLDER_BYTES) throw Error("The selected folder exceeds 1 GiB.");67+ if (total > MAX_FOLDER_BYTES)68+ throw Error("The selected folder exceeds 1 GiB.");4169 let directory = root;4270 for (const name of path.slice(0, -1)) {43- if (directory.files.has(name)) throw Error("A file and folder use the same name.");44- if (!directory.directories.has(name)) directory.directories.set(name, folderNode());71+ if (directory.files.has(name))72+ throw Error("A file and folder use the same name.");73+ if (!directory.directories.has(name))74+ directory.directories.set(name, folderNode());4575 directory = directory.directories.get(name);4676 }4777 const name = path.at(-1);48- if (directory.files.has(name) || directory.directories.has(name)) throw Error("Duplicate folder entry.");78+ if (directory.files.has(name) || directory.directories.has(name))79+ throw Error("Duplicate folder entry.");4980 directory.files.set(name, file);5081 }51- return {root, name: commonRoot ? paths[0][0] : "folder"};82+ return { root, name: commonRoot ? paths[0][0] : "folder" };5283 }5384 5485 async function storeEncrypted(plaintext, type, signal) {5586 checkAbort(signal);56- const encrypted = await TinyBlossomEncryption.encryptCHK(plaintext);87+ const encrypted = await encryption().encryptCHK(plaintext);5788 checkAbort(signal);5889 const url = new URL(tiny.localPath("/"), location.href).href;59- const result = await TinyBlossomUpload.upload(encrypted.ciphertext, {60- url, hash: encrypted.hash, type, signal,61- authorize: (target, method, body) => tiny.authorization(target, method, body)90+ const result = await upload().upload(encrypted.ciphertext, {91+ url,92+ hash: encrypted.hash,93+ type,94+ signal,95+ authorize: (target, method, body) =>96+ tiny.authorization(target, method, body),6297 });63- if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length) throw Error("Relay returned an unexpected blob descriptor.");64- return {hash: encrypted.hash, key: fromHex(encrypted.key)};98+ if (99+ result.descriptor.sha256 !== encrypted.hash ||100+ result.descriptor.size !== encrypted.ciphertext.length101+ )102+ throw Error("Relay returned an unexpected blob descriptor.");103+ return { hash: encrypted.hash, key: fromHex(encrypted.key) };65104 }66105 67- const manifestStore = signal => async ({bytes, node}) => storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal);68- const rootReference = details => {106+ const manifestStore =107+ (signal) =>108+ async ({ bytes, node }) =>109+ storeEncrypted(110+ bytes,111+ node.t === 2 || node.t === 3112+ ? directoryType113+ : "application/octet-stream",114+ signal,115+ );116+ const rootReference = (details) => {69117 const stored = details.manifests.at(-1);70- return {hash: stored.hash, key: stored.key, type: details.root.t, size: details.root.l.reduce((sum, link) => sum + link.s, 0)};118+ return {119+ hash: stored.hash,120+ key: stored.key,121+ type: details.root.t,122+ size: details.root.l.reduce((sum, link) => sum + link.s, 0),123+ };71124 };72125 73126 async function storeFile(file, signal, progress) {74- if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");127+ if (file.size > MAX_FILE_BYTES)128+ throw Error("Files must be at most 256 MiB in the browser workspace.");75129 const chunks = [];76- for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {130+ for (131+ let offset = 0;132+ offset < file.size || offset === 0;133+ offset += codec().CHUNK_SIZE134+ ) {77135 checkAbort(signal);78- const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());79- const stored = await storeEncrypted(plain, "application/octet-stream", signal);80- chunks.push({...stored, size: plain.length});136+ const plain = new Uint8Array(137+ await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer(),138+ );139+ const stored = await storeEncrypted(140+ plain,141+ "application/octet-stream",142+ signal,143+ );144+ chunks.push({ ...stored, size: plain.length });81145 progress?.(plain.length);82146 if (file.size === 0) break;83147 }84- if (chunks.length === 1) return {...chunks[0], type: 0};85- return rootReference(await codec().buildFile(chunks, {store: manifestStore(signal), returnDetails: true}));148+ if (chunks.length === 1) return { ...chunks[0], type: 0 };149+ return rootReference(150+ await codec().buildFile(chunks, {151+ store: manifestStore(signal),152+ returnDetails: true,153+ }),154+ );86155 }87156 88157 async function storeDirectory(directory, signal, progress) {89158 const entries = [];90159 for (const [name, file] of directory.files) {91160 const stored = await storeFile(file, signal, progress);92- entries.push({...stored, name, metadata: {type: file.type || "application/octet-stream"}});161+ entries.push({162+ ...stored,163+ name,164+ metadata: { type: file.type || "application/octet-stream" },165+ });93166 }94167 for (const [name, child] of directory.directories) {95168 const stored = await storeDirectory(child, signal, progress);96- entries.push({...stored, name});169+ entries.push({ ...stored, name });97170 }98- return rootReference(await codec().buildDirectory(entries, {store: manifestStore(signal), returnDetails: true}));171+ return rootReference(172+ await codec().buildDirectory(entries, {173+ store: manifestStore(signal),174+ returnDetails: true,175+ }),176+ );99177 }100178 101179 const shareURL = (reference, name, type) => {102180 const url = new URL(tiny.localPath("/file"), location.href);103181 url.searchParams.set("hash", reference.hash);104- url.hash = new URLSearchParams({manifest: reference.hash, key: hex(reference.key), node: String(reference.type), name, type: type || "application/octet-stream"}).toString();182+ url.hash = new URLSearchParams({183+ manifest: reference.hash,184+ key: hex(reference.key),185+ node: String(reference.type),186+ name,187+ type: type || "application/octet-stream",188+ }).toString();105189 return url.href;106190 };107191 @@ -109,16 +193,24 @@
109193 connectedCallback() {110194 if (this.bound) return;111195 this.bound = true;112- this.innerHTML = '<h3>Folders and large files</h3><p>Encrypt folders and split large files into portable chunks. Deduplicated encryption reveals matching content and permits guesses about predictable files.</p><form data-folder><label>Folder <input type="file" name="folder" webkitdirectory directory multiple required></label><button>Encrypt and store folder</button></form><form data-chunked><label>File to split into chunks <input type="file" name="file" required></label><button>Encrypt and store file</button></form><p><button type="button" data-cancel disabled>Cancel upload</button> <button type="button" data-retry disabled>Retry upload</button></p><label>Share link <input data-tree-share readonly></label><p><a data-open hidden>Browse stored content</a></p><output role="status"></output>';196+ this.innerHTML =197+ '<h3>Folders and large files</h3><p>Encrypt folders and split large files into portable chunks. Deduplicated encryption reveals matching content and permits guesses about predictable files.</p><form data-folder><label>Folder <input type="file" name="folder" webkitdirectory directory multiple required></label><button>Encrypt and store folder</button></form><form data-chunked><label>File to split into chunks <input type="file" name="file" required></label><button>Encrypt and store file</button></form><p><button type="button" data-cancel disabled>Cancel upload</button> <button type="button" data-retry disabled>Retry upload</button></p><label>Share link <input data-tree-share readonly></label><p><a data-open hidden>Browse stored content</a></p><output role="status"></output>';113198 this.out = this.querySelector("output");114- for (const form of this.querySelectorAll("form")) form.addEventListener("submit", event => {115- event.preventDefault();116- this.run(form).catch(error => this.say(error.message, true));117- });118- this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());119- this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(error => this.say(error.message, true)));199+ for (const form of this.querySelectorAll("form"))200+ form.addEventListener("submit", (event) => {201+ event.preventDefault();202+ this.run(form).catch((error) => this.say(error.message, true));203+ });204+ this.querySelector("[data-cancel]").addEventListener("click", () =>205+ this.controller?.abort(),206+ );207+ this.querySelector("[data-retry]").addEventListener("click", () =>208+ this.run().catch((error) => this.say(error.message, true)),209+ );210+ }211+ disconnectedCallback() {212+ this.controller?.abort();120213 }121- disconnectedCallback() { this.controller?.abort(); }122214 say(message, error = false) {123215 this.out.textContent = message;124216 if (error) this.out.dataset.error = "";@@ -129,9 +221,10 @@
129221 if (form) {130222 const folder = form.elements.namedItem("folder");131223 const input = folder || form.elements.namedItem("file");132- this.selection = {files: [...input.files], folder: Boolean(folder)};224+ this.selection = { files: [...input.files], folder: Boolean(folder) };133225 }134- if (!this.selection?.files.length) throw Error("Choose a file or folder first.");226+ if (!this.selection?.files.length)227+ throw Error("Choose a file or folder first.");135228 this.busy = true;136229 this.controller = new AbortController();137230 this.querySelector("[data-cancel]").disabled = false;@@ -140,23 +233,38 @@
140233 try {141234 let reference, name, type;142235 let sent = 0;143- const progress = size => { sent += size; this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…"); };236+ const progress = (size) => {237+ sent += size;238+ this.say(239+ "Stored " +240+ sent.toLocaleString() +241+ " bytes. Encrypting the remaining content…",242+ );243+ };144244 this.say("Encrypting selected content…");145245 if (this.selection.folder) {146246 const selected = selectionTree(this.selection.files);147247 name = selected.name;148- reference = await storeDirectory(selected.root, this.controller.signal, progress);248+ reference = await storeDirectory(249+ selected.root,250+ this.controller.signal,251+ progress,252+ );149253 } else {150254 const file = this.selection.files[0];151- name = file.name; type = file.type;255+ name = file.name;256+ type = file.type;152257 reference = await storeFile(file, this.controller.signal, progress);153258 }154259 checkAbort(this.controller.signal);155260 const url = shareURL(reference, name, type);156261 this.querySelector("[data-tree-share]").value = url;157262 const open = this.querySelector("[data-open]");158- open.href = url; open.hidden = false;159- this.say("Stored. Keep the complete share link to decrypt and browse this content.");263+ open.href = url;264+ open.hidden = false;265+ this.say(266+ "Stored. Keep the complete share link to decrypt and browse this content.",267+ );160268 completed = true;161269 return url;162270 } finally {@@ -170,79 +278,134 @@
170278 171279 async function fetchBlob(hash, signal) {172280 fromHex(hash);173- const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {signal});281+ const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {282+ signal,283+ });174284 if (!response.ok) throw Error("Download failed (" + response.status + ").");175- if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");285+ if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES)286+ throw Error("Linked blob exceeds the browser fetch limit.");176287 const reader = response.body?.getReader();177288 if (!reader) {178289 const bytes = new Uint8Array(await response.arrayBuffer());179- if (bytes.length > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");290+ if (bytes.length > MAX_FETCH_BYTES)291+ throw Error("Linked blob exceeds the browser fetch limit.");180292 return bytes;181293 }182294 const parts = [];183295 let size = 0;184296 try {185297 for (;;) {186- const {value, done} = await reader.read();298+ const { value, done } = await reader.read();187299 if (done) break;188300 size += value.length;189- if (size > MAX_FETCH_BYTES) { await reader.cancel(); throw Error("Linked blob exceeds the browser fetch limit."); }301+ if (size > MAX_FETCH_BYTES) {302+ await reader.cancel();303+ throw Error("Linked blob exceeds the browser fetch limit.");304+ }190305 parts.push(value);191306 }192- } finally { reader.releaseLock(); }307+ } finally {308+ reader.releaseLock();309+ }193310 const bytes = new Uint8Array(size);194311 let offset = 0;195- for (const part of parts) { bytes.set(part, offset); offset += part.length; }312+ for (const part of parts) {313+ bytes.set(part, offset);314+ offset += part.length;315+ }196316 return bytes;197317 }198318 199319 async function plaintext(reference, signal) {200320 const hash = hex(reference.h);201321 const raw = await fetchBlob(hash, signal);202- if (hex(await codec().sha256(raw)) !== hash) throw Error("Blob hash mismatch.");203- return reference.k ? TinyBlossomEncryption.decryptCHK(raw, hex(reference.k), hash) : raw;322+ if (hex(await codec().sha256(raw)) !== hash)323+ throw Error("Blob hash mismatch.");324+ return reference.k325+ ? encryption().decryptCHK(raw, hex(reference.k), hash)326+ : raw;204327 }205- const decrypt = (raw, link) => TinyBlossomEncryption.decryptCHK(raw, hex(link.k), hex(link.h));328+ const decrypt = (raw, link) =>329+ encryption().decryptCHK(raw, hex(link.k), hex(link.h));206330 207331 class Root extends HTMLElement {208332 connectedCallback() {209333 if (this.bound) return;210334 this.bound = true;211335 const params = new URLSearchParams(location.hash.slice(1));212- if (!params.has("manifest") && this.getAttribute("type") !== directoryType) { this.hidden = true; return; }336+ if (337+ !params.has("manifest") &&338+ this.getAttribute("type") !== directoryType339+ ) {340+ this.hidden = true;341+ return;342+ }213343 this.hidden = false;214- this.load().catch(error => this.fail(error.message));344+ this.load().catch((error) => this.fail(error.message));215345 }216346 disconnectedCallback() {217347 this.controller?.abort();218348 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);219349 }350+ // fail reports an error without discarding a listing that has already351+ // rendered, so one failed download leaves the folder browsable.220352 fail(message) {221353 const error = element("p", "Content unavailable: " + message);222354 error.setAttribute("role", "alert");223- this.replaceChildren(error);355+ this.querySelector("p[role=alert]")?.remove();356+ if (this.querySelector("h2")) this.querySelector("h2").after(error);357+ else this.replaceChildren(error);224358 }225359 async load() {226360 this.controller?.abort();227361 this.controller = new AbortController();228362 const params = new URLSearchParams(location.hash.slice(1));229- const reference = {h: fromHex(params.get("manifest") || this.getAttribute("hash"))};363+ const reference = {364+ h: fromHex(params.get("manifest") || this.getAttribute("hash")),365+ };230366 if (params.has("key")) reference.k = fromHex(params.get("key"));231367 const type = params.has("node") ? Number(params.get("node")) : 2;232- if (![0, 1, 2, 3].includes(type)) throw Error("Unsupported manifest type.");368+ if (![0, 1, 2, 3].includes(type))369+ throw Error("Unsupported manifest type.");233370 const data = await plaintext(reference, this.controller.signal);234371 const heading = element("h2", params.get("name") || "Stored content");235372 this.replaceChildren(heading);236- if (type === 0) { this.offerDownload(data, params.get("name") || "file", params.get("type")); return; }373+ if (type === 0) {374+ this.offerDownload(375+ data,376+ params.get("name") || "file",377+ params.get("type"),378+ );379+ return;380+ }237381 const root = codec().decodeManifest(data);238- if (root.t !== type) throw Error("Manifest type does not match its reference.");382+ if (root.t !== type)383+ throw Error("Manifest type does not match its reference.");239384 if (type === 1) {240385 const button = element("button", "Download decrypted file");241386 button.type = "button";242- button.addEventListener("click", () => this.download({...reference, t: 1, s: root.l.reduce((sum, link) => sum + link.s, 0), n: params.get("name") || "file", m: {type: params.get("type")}}).catch(error => this.fail(error.message)));243- this.append(button); return;387+ button.addEventListener("click", () =>388+ this.download({389+ ...reference,390+ t: 1,391+ s: root.l.reduce((sum, link) => sum + link.s, 0),392+ n: params.get("name") || "file",393+ m: { type: params.get("type") },394+ }).catch((error) => this.fail(error.message)),395+ );396+ this.append(button);397+ return;244398 }245- const entries = await codec().resolveDirectory(root, (hash) => fetchBlob(hash, this.controller.signal), {decrypt, maxDepth: MAX_DEPTH, maxEntries: MAX_FILES, maxBytes: 64 * 1024 * 1024});399+ const entries = await codec().resolveDirectory(400+ root,401+ (hash) => fetchBlob(hash, this.controller.signal),402+ {403+ decrypt,404+ maxDepth: MAX_DEPTH,405+ maxEntries: MAX_FILES,406+ maxBytes: 64 * 1024 * 1024,407+ },408+ );246409 const list = element("ul");247410 if (!entries.length) this.append(element("p", "This folder is empty."));248411 for (const link of entries) {@@ -251,14 +414,24 @@
251414 const open = element("a", link.n + "/");252415 const url = new URL(tiny.localPath("/file"), location.href);253416 url.searchParams.set("hash", hex(link.h));254- const fragment = new URLSearchParams({manifest: hex(link.h), node: String(link.t), name: link.n});417+ const fragment = new URLSearchParams({418+ manifest: hex(link.h),419+ node: String(link.t),420+ name: link.n,421+ });255422 if (link.k) fragment.set("key", hex(link.k));256- url.hash = fragment.toString(); open.href = url.href;423+ url.hash = fragment.toString();424+ open.href = url.href;257425 item.append(open);258426 } else {259- const button = element("button", link.n + " (" + link.s.toLocaleString() + " bytes)");427+ const button = element(428+ "button",429+ link.n + " (" + link.s.toLocaleString() + " bytes)",430+ );260431 button.type = "button";261- button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));432+ button.addEventListener("click", () =>433+ this.download(link).catch((error) => this.fail(error.message)),434+ );262435 item.append(button);263436 }264437 list.append(item);@@ -270,18 +443,28 @@
270443 let data;271444 if (link.t === 0) data = await plaintext(link, this.controller.signal);272445 else if (link.t === 1) {273- const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));274- data = await codec().readFile(manifest, hash => fetchBlob(hash, this.controller.signal), {decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES});446+ const manifest = codec().decodeManifest(447+ await plaintext(link, this.controller.signal),448+ );449+ data = await codec().readFile(450+ manifest,451+ (hash) => fetchBlob(hash, this.controller.signal),452+ { decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES },453+ );275454 } else throw Error("Unsupported file link.");276- if (data.length !== link.s) throw Error("File size does not match its directory entry.");455+ if (data.length !== link.s)456+ throw Error("File size does not match its directory entry.");277457 this.offerDownload(data, link.n, link.m?.type);278458 return data;279459 }280460 offerDownload(data, name, type) {281461 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);282- this.downloadURL = URL.createObjectURL(new Blob([data], {type: type || "application/octet-stream"}));462+ this.downloadURL = URL.createObjectURL(463+ new Blob([data], { type: type || "application/octet-stream" }),464+ );283465 const link = element("a", "Save " + name);284- link.href = this.downloadURL; link.download = name;466+ link.href = this.downloadURL;467+ link.download = name;285468 this.append(link);286469 link.click();287470 }@@ -56,6 +56,7 @@
5656 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>5757 </tr></tbody></table>5858 <footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> » {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer>59+<script>{{script "tiny.js"}}</script>5960 <script>{{script "bridge.js"}}</script>6061 <script>{{script "blossom-encryption.js"}}</script>6162 <script>{{script "blossom-manifests.js"}}</script>@@ -3,21 +3,32 @@
33 (() => {44 "use strict";55 6- const validURL = value => {6+ const validURL = (value) => {77 try {88 const parsed = new URL(String(value).trim());9- return (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&10- parsed.hostname !== "" && !parsed.username && !parsed.password &&11- parsed.search === "" && parsed.hash === "";12- } catch { return false; }9+ return (10+ (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&11+ parsed.hostname !== "" &&12+ !parsed.username &&13+ !parsed.password &&14+ parsed.search === "" &&15+ parsed.hash === ""16+ );17+ } catch {18+ return false;19+ }1320 };14- const relay = value => {21+ const relay = (value) => {1522 const parsed = new URL(String(value || "").trim());1623 parsed.hostname = parsed.hostname.toLowerCase();17- if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/, "");24+ if (parsed.pathname.length > 1)25+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");1826 return parsed.toString().replace(/\/$/, "");1927 };20- const mergeRows = (rows, urls) => rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));28+ const mergeRows = (rows, urls) =>29+ rows30+ .filter((row) => !(Array.isArray(row) && row[0] === "g"))31+ .concat(urls.map((value) => ["g", value]));2132 const signer = () => window.tinySigner || window.nostr;2233 const publicKey = async () => {2334 const active = signer();@@ -29,39 +40,76 @@
2940 const active = signer();3041 if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);3142 if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);32- if (window.tiny?.nip44Encrypt) return window.tiny.nip44Encrypt(pubkey, text);43+ if (window.tiny?.nip44Encrypt)44+ return window.tiny.nip44Encrypt(pubkey, text);3345 throw Error("The connected signer does not support NIP-44.");3446 };3547 const decrypt = async (pubkey, text) => {3648 const active = signer();3749 if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);3850 if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);39- if (window.tiny?.nip44Decrypt) return window.tiny.nip44Decrypt(pubkey, text);51+ if (window.tiny?.nip44Decrypt)52+ return window.tiny.nip44Decrypt(pubkey, text);4053 throw Error("The connected signer does not support NIP-44.");4154 };42- const readList = async pubkey => {43- const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);44- const response = await window.tiny.signedFetch("/query", "POST", body, {contentType: "application/json"});45- if (!response || response.ok === false) throw Error("Could not read the private relay list.");55+ const readList = async (pubkey) => {56+ const body = JSON.stringify([57+ { kinds: [10318], authors: [pubkey], limit: 1 },58+ ]);59+ const response = await window.tiny.signedFetch("/query", "POST", body, {60+ contentType: "application/json",61+ });62+ if (!response || response.ok === false)63+ throw Error("Could not read the private relay list.");4664 const rows = await response.json();47- if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");65+ if (!Array.isArray(rows) || rows.length > 1)66+ throw Error("The private relay list response is malformed.");4867 const event = rows[0];49- if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");68+ if (rows.length && !acceptEvent(event, pubkey))69+ throw Error("The private relay list event is invalid.");5070 return event;5171 };5272 const acceptEvent = (event, expectedPubkey) => {53- if (!event || event.kind !== 10318 || event.pubkey !== expectedPubkey || !Array.isArray(event.tags) || typeof event.content !== "string") return false;73+ if (74+ !event ||75+ event.kind !== 10318 ||76+ event.pubkey !== expectedPubkey ||77+ !Array.isArray(event.tags) ||78+ typeof event.content !== "string"79+ )80+ return false;5481 if (typeof window.NostrSigner?.verifyEvent !== "function") return false;55- try { return window.NostrSigner.verifyEvent(event) === true; } catch { return false; }82+ try {83+ return window.NostrSigner.verifyEvent(event) === true;84+ } catch {85+ return false;86+ }5687 };57- const publish = async event => {58- const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});88+ const publish = async (event) => {89+ const response = await window.tiny.signedFetch(90+ "/events",91+ "POST",92+ JSON.stringify(event),93+ { contentType: "application/json" },94+ );5995 const text = await response.text();6096 let value;61- try { value = JSON.parse(text); } catch { value = undefined; }62- if (value?.error || value?.accepted === false) throw Error(value.error || value.message || "The relay rejected the event.");97+ try {98+ value = JSON.parse(text);99+ } catch {100+ value = undefined;101+ }102+ if (value?.error || value?.accepted === false)103+ throw Error(104+ value.error || value.message || "The relay rejected the event.",105+ );63106 };64- const wireEvent = content => ({kind: 10318, created_at: Math.floor(Date.now() / 1000), tags: [], content});107+ const wireEvent = (content) => ({108+ kind: 10318,109+ created_at: Math.floor(Date.now() / 1000),110+ tags: [],111+ content,112+ });65113 66114 class PrivateServices extends HTMLElement {67115 connectedCallback() {@@ -70,23 +118,33 @@
70118 this.form = this.querySelector("form");71119 this.lines = this.querySelector("textarea[name=lines]");72120 this.output = this.querySelector("output");73- this.form?.addEventListener("submit", event => {121+ this.form?.addEventListener("submit", (event) => {74122 event.preventDefault();75123 if (this.busy) return;76124 this.busy = true;77125 this.form.querySelector("button").disabled = true;78- this.save().catch(error => this.say("Error: " + error.message, true)).finally(() => {79- this.busy = false;80- this.form.querySelector("button").disabled = false;81- });126+ this.save()127+ .catch((error) => this.say("Error: " + error.message, true))128+ .finally(() => {129+ this.busy = false;130+ this.form.querySelector("button").disabled = false;131+ });82132 });83- this.whenSigned(() => this.load().then(() => { this.form.querySelector("button").disabled = false; }).catch(error => this.say("Error: " + error.message, true)));133+ this.whenSigned(() =>134+ this.load()135+ .then(() => {136+ this.form.querySelector("button").disabled = false;137+ })138+ .catch((error) => this.say("Error: " + error.message, true)),139+ );84140 }85141 86- whenSigned(fn, tries = 20) {142+ // whenSigned runs once a signer is available. The bridge announces a143+ // connected or resumed remote signer with the tiny:signer event.144+ whenSigned(fn) {87145 if (signer()?.signEvent || window.nostr?.signEvent) return fn();88- if (tries <= 0) { this.say("Connect a signer, then reload this page."); return; }89- setTimeout(() => this.whenSigned(fn, tries - 1), 500);146+ this.say("Connect a signer to edit this list.");147+ document.addEventListener("tiny:signer", () => fn(), { once: true });90148 }91149 92150 say(text, error) {@@ -99,17 +157,37 @@
99157 this.say("Reading encrypted list…");100158 const pubkey = await publicKey();101159 const event = await readList(pubkey);102- if (!event) { this.say("No private relay list published yet."); return; }160+ if (!event) {161+ this.say("No private relay list published yet.");162+ return;163+ }103164 const rows = JSON.parse(await decrypt(pubkey, event.content));104- if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");105- const urls = rows.filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1])).map(row => relay(row[1]));165+ if (!Array.isArray(rows))166+ throw Error("The encrypted list is not a JSON array.");167+ const urls = rows168+ .filter(169+ (row) => Array.isArray(row) && row[0] === "g" && validURL(row[1]),170+ )171+ .map((row) => relay(row[1]));106172 this.lines.value = urls.join("\n");107- this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");173+ this.say(174+ "Loaded " +175+ urls.length +176+ " private relay" +177+ (urls.length === 1 ? "" : "s") +178+ ".",179+ );108180 }109181 110182 async save() {111- const rawURLs = this.lines.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean);112- if (rawURLs.some(value => !validURL(value))) throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");183+ const rawURLs = this.lines.value184+ .split(/\r?\n/)185+ .map((value) => value.trim())186+ .filter(Boolean);187+ if (rawURLs.some((value) => !validURL(value)))188+ throw Error(189+ "Use ws:// or wss:// URLs without credentials, queries or fragments.",190+ );113191 const urls = rawURLs.map(relay);114192 const unique = [...new Set(urls)];115193 const pubkey = await publicKey();@@ -117,19 +195,42 @@
117195 let rows = [];118196 if (existing) {119197 rows = JSON.parse(await decrypt(pubkey, existing.content));120- if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");198+ if (!Array.isArray(rows))199+ throw Error("The encrypted list is not a JSON array.");121200 }122201 rows = mergeRows(rows, unique);123202 const content = await encrypt(pubkey, JSON.stringify(rows));124203 const active = signer();125204 if (!active?.signEvent) throw Error("Connect a signer first.");126205 const event = await active.signEvent(wireEvent(content));127- if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0) throw Error("The signer returned an invalid private relay list event.");206+ if (207+ !acceptEvent(event, pubkey) ||208+ event.content !== content ||209+ event.tags.length !== 0210+ )211+ throw Error("The signer returned an invalid private relay list event.");128212 await publish(event);129- this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");213+ this.say(214+ "Published " +215+ unique.length +216+ " private relay" +217+ (unique.length === 1 ? "" : "s") +218+ ".",219+ );130220 }131221 }132222 133- window.PrivateServices = {validURL, canonicalURL: relay, mergeRows, encrypt, decrypt, wireEvent, acceptEvent, readList};223+ const api = Object.freeze({224+ validURL,225+ canonicalURL: relay,226+ mergeRows,227+ encrypt,228+ decrypt,229+ wireEvent,230+ acceptEvent,231+ readList,232+ });233+ window.tiny = window.tiny || {};234+ window.tiny.files = { ...window.tiny.files, privateServices: api };134235 customElements.define("private-services", PrivateServices);135236 })();@@ -29,6 +29,9 @@ var styleCSS string
2929 //go:embed bridge.js3030 var bridgeJS string3131 32+//go:embed tiny.js33+var tinyJS string34+3235 //go:embed components.js3336 var componentsJS string3437 @@ -128,7 +131,7 @@ func repoView(query url.Values) string {
128131 }129132 130133 func parseTemplates() (*template.Template, error) {131- scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}134+ scripts := map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}132135 funcs := template.FuncMap{133136 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },134137 "script": func(name string) (template.JS, error) {@@ -0,0 +1,18 @@
1+// Shared browser helpers. This file is loaded before feature modules.2+(() => {3+ "use strict";4+ const tiny = globalThis.tiny || {};5+ const bytes = value => {6+ if (value instanceof Uint8Array) return value;7+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);8+ if (value instanceof ArrayBuffer) return new Uint8Array(value);9+ throw new TypeError("expected bytes");10+ };11+ const hex = value => Array.from(bytes(value), byte => byte.toString(16).padStart(2, "0")).join("");12+ const sha256 = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));13+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256});14+ // Feature modules add to these groups; guards can read them before load.15+ tiny.blossom = tiny.blossom || {};16+ tiny.files = tiny.files || {};17+ globalThis.tiny = tiny;18+})();@@ -7,7 +7,7 @@
77 "test:conformance": "node scripts/conformance/run.mjs",88 "test:webmcp": "node --test scripts/webmcp.test.mjs",99 "test:signer": "node --test scripts/signer.test.mjs",10- "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs",10+ "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs scripts/tiny-namespace.test.mjs",1111 "test:collaboration": "node --test scripts/collaboration-compose.test.mjs"1212 },1313 "devDependencies": {@@ -5,10 +5,11 @@ import test from "node:test";
55 import vm from "node:vm";66 77 const source = await readFile(new URL("../internal/webui/blossom-encryption.js", import.meta.url), "utf8");8+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");89 const sandbox = {crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer};910 sandbox.globalThis = sandbox;10-vm.runInNewContext(source, sandbox);11-const api = sandbox.TinyBlossomEncryption;11+vm.runInNewContext(shared + source, sandbox);12+const api = sandbox.tiny.blossom.encryption;1213 const utf8 = value => new TextEncoder().encode(value);1314 const hex = bytes => Buffer.from(bytes).toString("hex");1415 @@ -4,10 +4,11 @@ import test from "node:test";
44 import vm from "node:vm";55 66 const source = await readFile(new URL("../internal/webui/blossom-manifests.js", import.meta.url), "utf8");7+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");78 const sandbox = {TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, crypto: (await import("node:crypto")).webcrypto};89 sandbox.globalThis = sandbox;9-vm.runInNewContext(source, sandbox);10-const api = sandbox.TinyBlossomManifests;10+vm.runInNewContext(shared + source, sandbox);11+const api = sandbox.tiny.blossom.manifests;1112 const hash = value => value.repeat(64);1213 const bytes = hex => Uint8Array.from(hex.match(/../g), value => parseInt(value, 16));1314 @@ -4,10 +4,11 @@ import test from "node:test";
44 import vm from "node:vm";55 66 const source = await readFile(new URL("../internal/webui/blossom-manifests.js", import.meta.url), "utf8");7+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");78 const sandbox = {TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, crypto: (await import("node:crypto")).webcrypto};89 sandbox.globalThis = sandbox;9-vm.runInNewContext(source, sandbox);10-const api = sandbox.TinyBlossomManifests;10+vm.runInNewContext(shared + source, sandbox);11+const api = sandbox.tiny.blossom.manifests;1112 const bytes = hex => Uint8Array.from(hex.match(/../g), x => parseInt(x, 16));1213 const hash = c => c.repeat(64);1314 @@ -6,10 +6,11 @@ import test from "node:test";
66 import vm from "node:vm";77 88 const source = await readFile(new URL("../internal/webui/blossom-upload.js", import.meta.url), "utf8");9+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");910 const sandbox = {crypto: webcrypto, Blob, ArrayBuffer, Uint8Array, AbortController, Map, setTimeout, clearTimeout, URL};1011 sandbox.globalThis = sandbox;11-vm.runInNewContext(source, sandbox);12-const upload = sandbox.TinyBlossomUpload.upload;12+vm.runInNewContext(shared + source, sandbox);13+const upload = sandbox.tiny.blossom.upload.upload;1314 const digest = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");1415 const response = (status, headers = {}, body = null) => ({1516 status, ok: status >= 200 && status < 300,@@ -5,7 +5,9 @@ import vm from "node:vm";
55 import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools";66 77 globalThis.NostrSigner = { generateSecretKey, getEventHash, getPublicKey, finalizeEvent, verifyEvent, nip44, decodeNpub: (value) => nip19.decode(value).data };8+globalThis.tiny = {files: {}};89 vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), { filename: "file-messages.js" });10+const fileMessages = globalThis.tiny.files.messages;911 1012 const alice = generateSecretKey();1113 const bob = generateSecretKey();@@ -19,7 +21,7 @@ const signer = {
1921 const list = (pubkey, relays = ["wss://inbox.example"]) => finalizeEvent({ kind: 10050, tags: relays.map((relay) => ["relay", relay]), content: "", created_at: 100 }, pubkey === alicePub ? alice : bob);2022 2123 test("build creates independently decryptable NIP-17 file gift wraps", async () => {22- const result = await TinyFileMessages.build({ fileURL: "https://files.example/abc", ciphertextHash: "a".repeat(64), plaintextHash: "b".repeat(64), mimeType: "image/png", key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, now: 200 });24+ const result = await fileMessages.build({ fileURL: "https://files.example/abc", ciphertextHash: "a".repeat(64), plaintextHash: "b".repeat(64), mimeType: "image/png", key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, now: 200 });2325 assert.equal(result.rumor.pubkey, alicePub);2426 assert.equal(result.rumor.id, getEventHash(result.rumor));2527 assert.equal(result.rumor.kind, 15);@@ -43,7 +45,7 @@ test("build creates independently decryptable NIP-17 file gift wraps", async ()
4345 4446 test("share requires valid inbox lists and publishes only to those lists", async () => {4547 const published = [];46- const result = await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "c".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {48+ const result = await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "c".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {4749 signer,4850 queryRelayList: async (pubkey) => list(pubkey, pubkey === bobPub ? ["wss://bob.example"] : ["ws://127.0.0.1:7777"]),4951 publish: async (event, relays) => { published.push({ event, relays }); return true; },@@ -57,7 +59,7 @@ test("share requires valid inbox lists and publishes only to those lists", async
5759 5860 test("share refuses to send when either inbox list is absent", async () => {5961 let sends = 0;60- await assert.rejects(() => TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "d".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {62+ await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "d".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {6163 signer,6264 queryRelayList: async () => null,6365 publish: async () => { sends++; },@@ -67,18 +69,18 @@ test("share refuses to send when either inbox list is absent", async () => {
6769 6870 test("share accepts npub recipients and rejects signer identity mismatches", async () => {6971 const published = [];70- await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "e".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: nip19.npubEncode(bobPub) }, {72+ await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "e".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: nip19.npubEncode(bobPub) }, {7173 signer,7274 queryRelayList: async (pubkey) => list(pubkey),7375 publish: async (event) => { published.push(event); return true; },7476 });7577 assert.equal(published.length, 2);7678 const mismatched = { ...signer, async signEvent(event) { return finalizeEvent(event, bob); } };77- await assert.rejects(() => TinyFileMessages.build({ fileURL: "https://files.example/a", ciphertextHash: "f".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer: mismatched }), /invalid NIP-17 seal/);79+ await assert.rejects(() => fileMessages.build({ fileURL: "https://files.example/a", ciphertextHash: "f".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer: mismatched }), /invalid NIP-17 seal/);7880 });7981 8082 test("share rejects an all-failed publish result", async () => {81- await assert.rejects(() => TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "1".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {83+ await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "1".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {8284 signer,8385 queryRelayList: async (pubkey) => list(pubkey),8486 publish: async () => [false, false],@@ -100,7 +102,7 @@ test("default transport queries locally and publishes only inbox WebSockets", as
100102 send(data) { const event = JSON.parse(data)[1]; setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK", event.id, true, ""]) }), 0); }101103 close() { this.closed = true; }102104 };103- const result = await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "2".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, timeoutMs: 1000 });105+ const result = await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "2".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, timeoutMs: 1000 });104106 assert.equal(queries.length, 2);105107 assert.deepEqual(sockets.map((socket) => socket.url), ["wss://bob.example", "wss://alice.example"]);106108 assert.deepEqual(result.deliveries, [[true], [true]]);@@ -125,7 +127,7 @@ test("default transport answers one NIP-42 challenge and retries the event", asy
125127 }126128 close() {}127129 };128- const result = await TinyFileMessages.publishEvent({ id: "1".repeat(64) }, ["wss://inbox.example"], { signer, timeoutMs: 1000 });130+ const result = await fileMessages.publishEvent({ id: "1".repeat(64) }, ["wss://inbox.example"], { signer, timeoutMs: 1000 });129131 assert.deepEqual(result, [true]);130132 assert.equal(eventAttempts, 2);131133 delete globalThis.WebSocket;@@ -50,13 +50,14 @@ const parse = (html, parent) => {
5050 const load = (workspace = false) => {5151 const document = new Node("document"); document.documentElement = new Node("html"); document.body = new Node("body"); document.createElement = name => name === "form" ? new Form() : new Node(name); document.addEventListener = () => {}; document.dispatchEvent = () => {};5252 const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }};53- const window = {document, customElements, tiny: {}, location: {href: "https://relay.test/files", origin: "https://relay.test", pathname: "/files", hash: ""}, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})};53+ const window = {document, customElements, tiny: {blossom: {}, files: {}}, location: {href: "https://relay.test/files", origin: "https://relay.test", pathname: "/files", hash: ""}, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})};5454 class HTMLElement extends Node {}5555 Object.assign(globalThis, {window, document, customElements, HTMLElement, location: window.location, Blob, URL, CustomEvent: class {}});5656 globalThis.tiny = window.tiny;5757 Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("clipboard unavailable"); }}}});5858 globalThis.btoa = value => Buffer.from(value, "binary").toString("base64"); globalThis.atob = value => Buffer.from(value, "base64").toString("binary");59- if (workspace) { vm.runInThisContext(fs.readFileSync("internal/webui/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/file-workspace.js", "utf8")); } else { delete globalThis.TinyBlossomUpload; }59+ vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"));60+ if (workspace) { vm.runInThisContext(fs.readFileSync("internal/webui/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/file-workspace.js", "utf8")); } else { delete globalThis.tiny.blossom.upload; }6061 vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8"));6162 return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"]};6263 };@@ -187,7 +188,7 @@ test("sharing a newly uploaded file uses its current key, hash and ciphertext si
187188 shareForm.querySelector("input").value = "b".repeat(64);188189 globalThis.nostr = {signEvent: async value => value};189190 let input;190- globalThis.TinyFileMessages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};191+ globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};191192 await element.share(shareForm);192193 assert.ok(input, element.output.textContent);193194 assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded));@@ -241,7 +242,7 @@ test("retry keeps the original ciphertext and key after interrupted encrypted up
241242 window.tiny.localPath = path => path;242243 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");243244 const sent = [];244- globalThis.TinyBlossomUpload = {upload: async (bytes, options) => {245+ globalThis.tiny.blossom.upload = {upload: async (bytes, options) => {245246 assert.equal(options.url, "https://relay.test/");246247 sent.push(bytes.slice());247248 if (sent.length === 1) throw Error("connection interrupted");@@ -18,8 +18,9 @@ const sandbox = {
1818 NostrSigner: {verifyEvent: () => true}1919 };2020 sandbox.window = sandbox;21+ sandbox.tiny = {files: {}};2122 vm.runInNewContext(source, sandbox);22-const api = sandbox.PrivateServices;23+const api = sandbox.tiny.files.privateServices;2324 2425 test("accepts tenant paths while rejecting unsafe relay URLs", () => {2526 assert.equal(api.validURL("wss://Relay.Example/r/Private"), true);@@ -5,6 +5,7 @@ import test from "node:test";
55 import vm from "node:vm";66 77 const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");8+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");89 910 async function page({signedIn = false, bunkerResult = null} = {}) {1011 const elements = new Map();@@ -15,6 +16,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) {
1516 const requests = [];1617 const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};1718 const sandbox = {19+ tiny: {},1820 document: {getElementById: id => elements.get(id), querySelector: () => null, addEventListener() {}, documentElement: {dataset: {}}},1921 crypto: webcrypto, TextEncoder, URL, Uint8Array, ArrayBuffer,2022 btoa: value => Buffer.from(value).toString("base64"),@@ -32,7 +34,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) {
3234 fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); }3335 };3436 sandbox.window = sandbox;35- vm.runInNewContext(bridge, sandbox);37+ vm.runInNewContext(shared + bridge, sandbox);3638 await new Promise(resolve => setTimeout(resolve, 0));3739 return {elements, saved, requests, sandbox};3840 }@@ -0,0 +1,25 @@
1+import assert from "node:assert/strict";2+import {webcrypto} from "node:crypto";3+import {readFile} from "node:fs/promises";4+import test from "node:test";5+import vm from "node:vm";6+7+const read = name => readFile(new URL(`../internal/webui/${name}`, import.meta.url), "utf8");8+const [tiny, encryption, manifests, upload] = await Promise.all([9+ read("tiny.js"), read("blossom-encryption.js"), read("blossom-manifests.js"), read("blossom-upload.js")10+]);11+12+test("feature modules bootstrap through the shared tiny namespace", () => {13+ const sandbox = {crypto: webcrypto, TextEncoder, TextDecoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, Blob, Map, setTimeout, clearTimeout};14+ sandbox.globalThis = sandbox;15+ vm.runInNewContext(tiny + encryption + manifests + upload, sandbox);16+ assert.equal(typeof sandbox.tiny.util.hex, "function");17+ assert.equal(typeof sandbox.tiny.blossom.encryption.encryptCHK, "function");18+ assert.equal(typeof sandbox.tiny.blossom.manifests.encodeManifest, "function");19+ assert.equal(typeof sandbox.tiny.blossom.upload.upload, "function");20+ assert.equal("TinyBlossomEncryption" in sandbox, false);21+ assert.equal("TinyBlossomManifests" in sandbox, false);22+ assert.equal("TinyBlossomUpload" in sandbox, false);23+ vm.runInNewContext(tiny + encryption + manifests + upload, sandbox);24+ assert.equal(typeof sandbox.tiny.blossom.manifests.decodeManifest, "function");25+});