tinyrelayfile
tinyrelay/tinyclient/blossom-manifests.js/

tinyclient/blossom-manifests.js raw
1// BUD-16 and BUD-17 MessagePack manifests. Go templates embed this browser
2// source directly so the same codec serves pages and file controls.
3(() => {
4 "use strict";
5 // Manifest codec and tree reader. The API encodes and validates file and
6 // directory manifests, builds chunked trees and reads them through caller
7 // supplied fetch functions.
8 const CHUNK_SIZE = 2 * 1024 * 1024;
9 const MAX_LINKS = 174;
10 const MAX_DEPTH = 64;
11 const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;
12 const textEncoder = new TextEncoder(),
13 textDecoder = new TextDecoder("utf-8", {fatal: true});
14 const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;
15 const hashBytes = h => globalThis.tiny.util.fromHex(h, "expected lowercase SHA-256 hash");
16 const keyBytes = k => {
17 if (k === undefined) 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 =>
32 typeof n === "string" &&
33 n.length > 0 &&
34 n !== "." &&
35 n !== ".." &&
36 wellFormed(n) &&
37 !n.includes("/") &&
38 !n.includes("\0") &&
39 textEncoder.encode(n).length <= 1024;
40 const int = n => Number.isSafeInteger(n) && n >= 0;
41 const utf8cmp = (a, b) => {
42 const x = textEncoder.encode(a),
43 y = textEncoder.encode(b);
44 for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];
45 return x.length - y.length;
46 };
47 class MessagePackWriter {
48 constructor() {
49 this.output = [];
50 }
51 push(...x) {
52 this.output.push(...x);
53 }
54 u8(n) {
55 this.output.push(n & 255);
56 }
57 bytes() {
58 return Uint8Array.from(this.output);
59 }
60 }
61 const writeInteger = (w, n) => {
62 if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");
63 if (n >= 0) {
64 if (n < 128) w.u8(n);
65 else if (n < 256) w.push(204, n);
66 else if (n < 65536) w.push(205, n >> 8, n);
67 else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);
68 else {
69 w.u8(207);
70 let x = BigInt(n);
71 for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));
72 }
73 } else if (n >= -32) w.u8(256 + n);
74 else if (n >= -128) w.push(208, n & 255);
75 else if (n >= -32768) w.push(209, (n >> 8) & 255, n & 255);
76 else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);
77 else {
78 w.u8(211);
79 let x = BigInt.asUintN(64, BigInt(n));
80 for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));
81 }
82 };
83 const writeValue = (w, v) => {
84 if (v === null) return w.u8(192);
85 if (v === false) return w.u8(194);
86 if (v === true) return w.u8(195);
87 if (typeof v === "number") {
88 if (Number.isInteger(v)) return writeInteger(w, v);
89 if (!Number.isFinite(v)) throw Error("metadata number must be finite");
90 w.u8(203);
91 const b = new DataView(new ArrayBuffer(8));
92 b.setFloat64(0, v);
93 for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));
94 return;
95 }
96 if (typeof v === "string") {
97 const b = textEncoder.encode(v);
98 if (b.length < 32) w.u8(160 | b.length);
99 else if (b.length < 256) w.push(217, b.length);
100 else if (b.length < 65536) w.push(218, b.length >> 8, b.length);
101 else throw new Error("string too large");
102 w.push(...b);
103 return;
104 }
105 if (v instanceof Uint8Array) {
106 if (v.length < 256) w.push(196, v.length);
107 else if (v.length < 65536) w.push(197, v.length >> 8, v.length);
108 else throw new Error("binary value too large");
109 w.push(...v);
110 return;
111 }
112 if (Array.isArray(v)) {
113 if (v.length < 16) w.u8(144 | v.length);
114 else if (v.length < 65536) w.push(220, v.length >> 8, v.length);
115 else throw new Error("array too large");
116 v.forEach(x => writeValue(w, x));
117 return;
118 }
119 if (v && typeof v === "object") {
120 const keys = Object.keys(v).sort(utf8cmp);
121 if (keys.length < 16) w.u8(128 | keys.length);
122 else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);
123 else throw new Error("map too large");
124 keys.forEach(k => {
125 writeValue(w, k);
126 writeValue(w, v[k]);
127 });
128 return;
129 }
130 throw new Error("unsupported manifest value");
131 };
132 class MessagePackReader {
133 constructor(b) {
134 this.input = asBytes(b);
135 this.offset = 0;
136 }
137 need(n) {
138 if (this.offset + n > this.input.length) throw new Error("truncated MessagePack");
139 }
140 u8() {
141 this.need(1);
142 return this.input[this.offset++];
143 }
144 n(len) {
145 this.need(len);
146 const x = this.input.slice(this.offset, this.offset + len);
147 this.offset += len;
148 return x;
149 }
150 }
151 const readMap = (r, n, depth) => {
152 const o = Object.create(null);
153 for (let i = 0; i < n; i++) {
154 const k = readValue(r, depth + 1);
155 if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");
156 o[k] = readValue(r, depth + 1);
157 }
158 return o;
159 };
160 const readValue = (r, depth = 0) => {
161 if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");
162 const t = r.u8();
163 if (t <= 127) return t;
164 if (t >= 224) return t - 256;
165 if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));
166 if ((t & 240) === 144) return Array.from({length: t & 15}, () => readValue(r, depth + 1));
167 if ((t & 240) === 128) return readMap(r, t & 15, depth);
168 if (t === 192) return null;
169 if (t === 194) return false;
170 if (t === 195) return true;
171 let n;
172 if (t === 204) n = r.u8();
173 else if (t === 205) n = (r.u8() << 8) | r.u8();
174 else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
175 else if (t === 207) {
176 let x = 0n;
177 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());
178 if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");
179 n = Number(x);
180 } else if (t === 208) n = (r.u8() << 24) >> 24;
181 else if (t === 209) {
182 n = (r.u8() << 8) | r.u8();
183 if (n & 32768) n -= 65536;
184 } else if (t === 210) {
185 n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
186 if (n >= 2147483648) n -= 4294967296;
187 } else if (t === 211) {
188 let x = 0n;
189 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());
190 if (x & 0x8000000000000000n) x -= 0x10000000000000000n;
191 if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER))
192 throw Error("integer exceeds safe range");
193 n = Number(x);
194 } else if (t === 202) {
195 const b = new ArrayBuffer(4),
196 d = new DataView(b);
197 for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());
198 n = d.getFloat32(0);
199 } else if (t === 203) {
200 const b = new ArrayBuffer(8),
201 d = new DataView(b);
202 for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());
203 n = d.getFloat64(0);
204 } else if (t === 217) n = textDecoder.decode(r.n(r.u8()));
205 else if (t === 218) n = textDecoder.decode(r.n((r.u8() << 8) | r.u8()));
206 else if (t === 196) n = r.n(r.u8());
207 else if (t === 197) n = r.n((r.u8() << 8) | r.u8());
208 else if (t === 220) {
209 n = (r.u8() << 8) | r.u8();
210 return Array.from({length: n}, () => readValue(r, depth + 1));
211 } else if (t === 222) {
212 n = (r.u8() << 8) | r.u8();
213 return readMap(r, n, depth);
214 } else throw Error("unsupported MessagePack type");
215 return n;
216 };
217 const validateMeta = (m, depth = 0) => {
218 if (m === undefined) return;
219 if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array)
220 throw Error("metadata must be a JSON map");
221 const keys = Object.keys(m);
222 if (keys.length > 64) throw Error("too many metadata keys");
223 for (const [k, v] of Object.entries(m)) {
224 if (typeof k !== "string" || textEncoder.encode(k).length > 256) throw Error("invalid metadata key");
225 if (
226 v === undefined ||
227 typeof v === "bigint" ||
228 typeof v === "function" ||
229 typeof v === "symbol" ||
230 (typeof v === "number" && !Number.isFinite(v))
231 )
232 throw Error("metadata is not JSON-compatible");
233 if (v && typeof v === "object") {
234 if (Array.isArray(v)) {
235 if (v.length > 256) throw Error("metadata array too large");
236 v.forEach(x => validateMeta({x}, depth + 1));
237 } else validateMeta(v, depth + 1);
238 }
239 }
240 };
241 const validateLink = (x, nodeType) => {
242 if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");
243 if (Object.keys(x).some(k => !["h", "k", "m", "n", "s", "t"].includes(k)))
244 throw Error("unknown manifest link field");
245 if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");
246 if (x.k !== undefined) keyBytes(x.k);
247 if (x.m !== undefined) validateMeta(x.m);
248 if (!int(x.s)) throw Error("invalid link size");
249 if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");
250 if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");
251 if (nodeType === 1 && x.n !== undefined) throw Error("file links cannot have names");
252 if (nodeType === 3 && x.n !== undefined) throw Error("fanout links cannot have names");
253 if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");
254 if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");
255 };
256 const validateNode = (node, depth = 0) => {
257 if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");
258 if (
259 !node ||
260 typeof node !== "object" ||
261 Array.isArray(node) ||
262 !Array.isArray(node.l) ||
263 !int(node.t) ||
264 ![1, 2, 3].includes(node.t) ||
265 Object.keys(node).some(k => k !== "l" && k !== "t")
266 )
267 throw Error("invalid manifest node");
268 if (node.l.length > MAX_LINKS) throw Error("too many manifest links");
269 const seen = new Set(),
270 links = node.l;
271 for (const x of links) {
272 validateLink(x, node.t);
273 if (node.t === 2) {
274 if (seen.has(x.n)) throw Error("duplicate directory entry");
275 seen.add(x.n);
276 }
277 if (node.t === 3) {
278 if (
279 !x.m ||
280 !int(x.m.count) ||
281 x.m.count < 1 ||
282 !nameOK(x.m.first) ||
283 !nameOK(x.m.last) ||
284 utf8cmp(x.m.first, x.m.last) > 0
285 )
286 throw Error("invalid fanout bounds");
287 }
288 }
289 if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0))
290 throw Error("directory links are not canonical");
291 return node;
292 };
293 const encodeManifest = node => {
294 validateNode(node);
295 const w = new MessagePackWriter();
296 w.u8(130);
297 writeValue(w, "l");
298 if (node.l.length < 16) w.u8(144 | node.l.length);
299 else w.push(220, node.l.length >> 8, node.l.length);
300 for (const x of node.l) {
301 const fields = [
302 "h",
303 ...(x.k !== undefined ? ["k"] : []),
304 ...(x.m !== undefined ? ["m"] : []),
305 ...(x.n !== undefined ? ["n"] : []),
306 "s",
307 "t"
308 ];
309 if (fields.length < 16) w.u8(128 | fields.length);
310 else w.push(222, fields.length >> 8, fields.length);
311 for (const k of fields) {
312 writeValue(w, k);
313 writeValue(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);
314 }
315 }
316 writeValue(w, "t");
317 writeValue(w, node.t);
318 const out = w.bytes();
319 if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");
320 return out;
321 };
322 const decodeManifest = (bytes, options = {}) => {
323 const b = asBytes(bytes);
324 if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");
325 const r = new MessagePackReader(b),
326 node = readValue(r);
327 if (r.offset !== b.length) throw Error("trailing MessagePack data");
328 validateNode(node);
329 if (node.t === 3)
330 for (const x of node.l) {
331 if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");
332 }
333 return node;
334 };
335 const manifestHash = async node => hex(await sha256(encodeManifest(node)));
336 const leaf = (hash, size, key) => {
337 const x = {h: hashBytes(hash), s: size, t: 0};
338 if (key !== undefined) x.k = keyBytes(key);
339 return x;
340 };
341 const storeNode = async (node, options, manifests) => {
342 const bytes = encodeManifest(node),
343 computed = hex(await sha256(bytes));
344 let result = {hash: computed};
345 if (options.store) {
346 const stored = await options.store({hash: computed, bytes, node});
347 if (stored) {
348 if (typeof stored === "string") result.hash = stored;
349 else result = {...result, ...stored};
350 }
351 }
352 if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");
353 if (result.key !== undefined) keyBytes(result.key);
354 manifests.push({hash: result.hash, bytes, node, key: result.key});
355 return result;
356 };
357 const buildFile = async (chunks, options = {}) => {
358 const max = options.maxLinks ?? MAX_LINKS;
359 if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");
360 const leaves = chunks.map(c =>
361 typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key)
362 );
363 if (leaves.some(x => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");
364 const manifests = [];
365 let level = leaves;
366 while (level.length > max) {
367 const next = [];
368 for (let i = 0; i < level.length; i += max) {
369 const group = level.slice(i, i + max),
370 child = {l: group, t: 1},
371 stored = await storeNode(child, options, manifests);
372 next.push({
373 h: hashBytes(stored.hash),
374 ...(stored.key ? {k: keyBytes(stored.key)} : {}),
375 s: group.reduce((n, x) => n + x.s, 0),
376 t: 1
377 });
378 }
379 level = next;
380 }
381 const root = {l: level, t: 1};
382 await storeNode(root, options, manifests);
383 return options.returnDetails ? {root, manifests} : root;
384 };
385 const buildDirectory = async (entries, options = {}) => {
386 if (!Array.isArray(entries)) throw Error("entries must be an array");
387 const max = options.maxLinks ?? MAX_LINKS;
388 if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");
389 const sorted = entries
390 .map(e => {
391 const x = {
392 h: hashBytes(e.hash),
393 n: e.name,
394 s: e.size || 0,
395 t: e.type || 0
396 };
397 if (e.key !== undefined) x.k = keyBytes(e.key);
398 if (e.metadata !== undefined) x.m = e.metadata;
399 return x;
400 })
401 .sort((a, b) => utf8cmp(a.n, b.n));
402 for (let i = 1; i < sorted.length; i++)
403 if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");
404 const manifests = [];
405 if (sorted.length <= max) {
406 const directory = {l: sorted, t: 2};
407 await storeNode(directory, options, manifests);
408 return options.returnDetails ? {root: directory, manifests} : directory;
409 }
410 let level = [];
411 for (let i = 0; i < sorted.length; i += max) {
412 const l = sorted.slice(i, i + max),
413 child = {l, t: 2},
414 stored = await storeNode(child, options, manifests);
415 level.push({
416 h: hashBytes(stored.hash),
417 ...(stored.key ? {k: keyBytes(stored.key)} : {}),
418 m: {count: l.length, first: l[0].n, last: l[l.length - 1].n},
419 s: l.reduce((n, x) => n + x.s, 0),
420 t: 2
421 });
422 }
423 while (level.length > max) {
424 const next = [];
425 for (let i = 0; i < level.length; i += max) {
426 const l = level.slice(i, i + max),
427 child = {l, t: 3},
428 stored = await storeNode(child, options, manifests);
429 next.push({
430 h: hashBytes(stored.hash),
431 ...(stored.key ? {k: keyBytes(stored.key)} : {}),
432 m: {
433 count: l.reduce((n, x) => n + x.m.count, 0),
434 first: l[0].m.first,
435 last: l[l.length - 1].m.last
436 },
437 s: l.reduce((n, x) => n + x.s, 0),
438 t: 3
439 });
440 }
441 level = next;
442 }
443 const root = {l: level, t: 3};
444 await storeNode(root, options, manifests);
445 return options.returnDetails ? {root, manifests} : root;
446 };
447 const flattenDirectory = (node, out = []) => {
448 validateNode(node);
449 if (node.t === 2) {
450 out.push(...node.l);
451 return out;
452 }
453 if (node.t !== 3) throw Error("not a directory");
454 for (const x of node.l) if (x.child) flattenDirectory(x.child, out);
455 return out;
456 };
457 const resolveDirectory = async (root, fetchNode, options = {}) => {
458 if (typeof fetchNode !== "function") throw Error("fetchNode is required");
459 const maxDepth = options.maxDepth ?? MAX_DEPTH,
460 maxEntries = options.maxEntries ?? 1e5,
461 maxManifests = options.maxManifests ?? 1e4,
462 maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
463 if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0))
464 throw Error("invalid directory limits");
465 let count = 0,
466 manifestCount = 0,
467 fetched = 0;
468 const active = new Set();
469 const walk = async (node, depth) => {
470 if (depth > maxDepth) throw Error("directory recursion limit exceeded");
471 if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");
472 validateNode(node);
473 if (node.t === 2) {
474 count += node.l.length;
475 if (count > maxEntries) throw Error("directory entry limit exceeded");
476 return node.l.slice();
477 }
478 if (node.t !== 3) throw Error("not a directory");
479 const result = [];
480 let previous = "";
481 for (const link of node.l) {
482 if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");
483 previous = link.m.last;
484 const linkHash = hex(link.h);
485 if (active.has(linkHash)) throw Error("manifest cycle detected");
486 active.add(linkHash);
487 let raw = asBytes(await fetchNode(linkHash, link));
488 fetched += raw.length;
489 if (fetched > maxBytes) throw Error("directory byte limit exceeded");
490 if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");
491 if (link.k) {
492 if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");
493 raw = asBytes(await options.decrypt(raw, link));
494 }
495 const child = decodeManifest(raw);
496 if (child.t !== link.t) throw Error("fanout child type mismatch");
497 const links = await walk(child, depth + 1);
498 active.delete(linkHash);
499 if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last)
500 throw Error("fanout bounds mismatch");
501 result.push(...links);
502 }
503 for (let i = 1; i < result.length; i++)
504 if (utf8cmp(result[i - 1].n, result[i].n) >= 0) throw Error("duplicate or unordered directory entry");
505 return result;
506 };
507 return walk(root, 0);
508 };
509 const readFile = async (root, fetchBlob, options = {}) => {
510 if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");
511 const maxDepth = options.maxDepth ?? MAX_DEPTH,
512 maxBytes = options.maxBytes ?? 512 * 1024 * 1024,
513 maxManifests = options.maxManifests ?? 1e4;
514 const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;
515 if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0))
516 throw Error("invalid file limits");
517 let used = 0,
518 fetched = 0,
519 manifests = 0;
520 const active = new Set(),
521 parts = [];
522 const readNode = async (node, depth) => {
523 if (depth > maxDepth) throw Error("file recursion limit exceeded");
524 validateNode(node);
525 if (node.t !== 1) throw Error("not a file manifest");
526 if (++manifests > maxManifests) throw Error("manifest count limit exceeded");
527 for (const link of node.l) {
528 const hash = hex(link.h);
529 if (active.has(hash)) throw Error("manifest cycle detected");
530 active.add(hash);
531 let raw = asBytes(await fetchBlob(hash, link));
532 fetched += raw.length;
533 if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");
534 if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");
535 if (link.t === 1) {
536 if (typeof options.decrypt !== "function" && link.k)
537 throw Error("encrypted manifest requires decrypt callback");
538 if (link.k) raw = asBytes(await options.decrypt(raw, link));
539 const child = decodeManifest(raw);
540 if (child.t !== 1) throw Error("file child type mismatch");
541 const before = used;
542 await readNode(child, depth + 1);
543 if (used - before !== link.s) throw Error("file manifest size mismatch");
544 } else {
545 if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");
546 const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;
547 if (data.length !== link.s) throw Error("file chunk size mismatch");
548 used += data.length;
549 if (used > maxBytes) throw Error("file byte limit exceeded");
550 parts.push(data);
551 }
552 active.delete(hash);
553 }
554 };
555 await readNode(root, 0);
556 const out = new Uint8Array(used);
557 let at = 0;
558 for (const p of parts) {
559 out.set(p, at);
560 at += p.length;
561 }
562 return out;
563 };
564 const api = Object.freeze({
565 CHUNK_SIZE,
566 MAX_LINKS,
567 encodeManifest,
568 decodeManifest,
569 encode: encodeManifest,
570 decode: decodeManifest,
571 manifestHash,
572 buildFile,
573 buildDirectory,
574 flattenDirectory,
575 resolveDirectory,
576 readFile,
577 sha256,
578 hex
579 });
580 globalThis.tiny = globalThis.tiny || {};
581 globalThis.tiny.blossom = {...globalThis.tiny.blossom, manifests: api};
582})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs