tinyrelayfile
tinyrelay/scripts/conformance/nip5a.test.ts/

scripts/conformance/nip5a.test.ts raw
1// NIP-5A from outside the relay: discover the host forms from NIP-11, upload
2// the files through Blossom, publish signed manifests, and fetch each site
3// through its canonical single-label hostname.
4import { describe, it, expect } from "vitest";
5import { sha256 } from "@noble/hashes/sha2.js";
6import { newEvent, newKey, pub, hex, HTTP_URL, RELAY_URL, now, sleep, Client } from "./helpers.ts";
7import { npubEncode } from "nostr-tools/nip19";
8
9function blossomToken(sk: Uint8Array, action: string, hash?: string): string {
10 const tags = [["t", action], ...(hash ? [["x", hash]] : []), ["expiration", String(now() + 300)]];
11 return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, action, tags))).toString("base64");
12}
13
14async function upload(sk: Uint8Array, body: string, type: string): Promise<string> {
15 const bytes = new TextEncoder().encode(body);
16 const hash = hex(sha256(bytes));
17 const response = await fetch(`${HTTP_URL}/upload`, {
18 method: "PUT",
19 headers: { authorization: blossomToken(sk, "upload", hash), "content-type": type },
20 body: bytes,
21 });
22 expect(response.status, await response.clone().text()).toBe(200);
23 const descriptor: any = await response.json();
24 expect(descriptor.sha256).toBe(hash);
25 return hash;
26}
27
28function siteHash(paths: string[][]): string {
29 const lines = paths.map((t) => `${t[2]} ${t[1]}\n`).sort().join("");
30 return hex(sha256(new TextEncoder().encode(lines)));
31}
32
33function discoveredHost(template: string, replacements: Record<string, string>): string {
34 let value = template;
35 for (const [key, replacement] of Object.entries(replacements)) value = value.replace(`<${key}>`, replacement);
36 return new URL(value).hostname;
37}
38
39// siteFetch keeps Wrangler runs local while using the canonical hostname
40// when RELAY_URL points at a public relay.
41function siteFetch(host: string, path: string, init: RequestInit = {}) {
42 const endpoint = new URL(HTTP_URL);
43 const local = endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" || endpoint.hostname === "[::1]" || endpoint.hostname === "::1";
44 endpoint.hostname = local ? `${host.split(".")[0]}.localhost` : host;
45 endpoint.pathname = path;
46 return fetch(endpoint, init);
47}
48
49async function waitForSite(host: string): Promise<void> {
50 for (let attempt = 0; attempt < 20; attempt++) {
51 const response = await siteFetch(host, "/");
52 if (response.status === 200) { await response.arrayBuffer(); return; }
53 await response.arrayBuffer();
54 await sleep(100);
55 }
56 throw new Error(`site index did not become visible at ${host}`);
57}
58
59describe("NIP-5A static websites", () => {
60 it("discovers canonical labels, serves root/named/snapshot sites, and applies fallbacks", async () => {
61 const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });
62 expect(infoResponse.status).toBe(200);
63 const doc: any = await infoResponse.json();
64 expect(doc.nsites).toMatchObject({ kinds: [15128, 35128, 5128] });
65 expect(typeof doc.nsites.root).toBe("string");
66 expect(typeof doc.nsites.named).toBe("string");
67 expect(typeof doc.nsites.snapshot).toBe("string");
68
69 const sk = newKey();
70 const index = "<!doctype html><title>root</title>";
71 const blogIndex = "<!doctype html><title>blog</title>";
72 const notFound = "<!doctype html><title>missing</title>";
73 const indexHash = await upload(sk, index, "text/html; charset=utf-8");
74 const blogHash = await upload(sk, blogIndex, "text/html; charset=utf-8");
75 const notFoundHash = await upload(sk, notFound, "text/html; charset=utf-8");
76 const paths = [["path", "/index.html", indexHash], ["path", "/blog/index.html", blogHash], ["path", "/404.html", notFoundHash]];
77 const aggregate = siteHash(paths);
78 const c = await Client.connect(RELAY_URL);
79 try {
80 const root = newEvent(sk, 15128, "", paths);
81 await c.publish(root);
82 const named = newEvent(sk, 35128, "", [...paths, ["d", "demo-site"]]);
83 await c.publish(named);
84 const snapshot = newEvent(sk, 5128, "", [...paths, ["x", aggregate, "aggregate"], ["a", `15128:${pub(sk)}:`]]);
85 await c.publish(snapshot);
86
87 const replacements = { npub: npubEncode(pub(sk)) };
88 const rootHost = discoveredHost(doc.nsites.root, replacements);
89 const namedHost = discoveredHost(doc.nsites.named, { pubkeyB36: base36(pub(sk)), dTag: "demo-site" });
90 const snapshotHost = discoveredHost(doc.nsites.snapshot, { snapshotIdB36: base36(snapshot.id) });
91 // The event acknowledgement and KV index update are separate async
92 // tasks on some deployments, so wait for each discovered label rather
93 // than relying on a fixed delay.
94 for (const host of [rootHost, namedHost, snapshotHost]) await waitForSite(host);
95
96 for (const host of [rootHost, namedHost, snapshotHost]) {
97 const response = await siteFetch(host, "/");
98 expect(response.status, host).toBe(200);
99 expect(await response.text(), host).toBe(index);
100 expect(response.headers.get("content-type")).toContain("text/html");
101 expect(response.headers.get("etag")).toBe(`"${indexHash}"`);
102 expect(response.headers.get("cache-control")).toContain("public");
103 expect(response.headers.get("cache-control")).toContain("no-transform");
104 const head = await siteFetch(host, "/index.html", { method: "HEAD" });
105 expect(head.status).toBe(200);
106 expect(head.headers.get("content-length")).toBe(String(new TextEncoder().encode(index).length));
107
108 const blog = await siteFetch(host, "/blog/");
109 expect(blog.status).toBe(200);
110 expect(await blog.text()).toBe(blogIndex);
111 const fallback = await siteFetch(host, "/does-not-exist.txt");
112 expect(fallback.status).toBe(404);
113 expect(await fallback.text()).toBe(notFound);
114 const redirect = await siteFetch(host, "/blog", { redirect: "manual" });
115 expect(redirect.status).toBe(308);
116 expect(new URL(redirect.headers.get("location")!).pathname).toBe("/blog/");
117 }
118
119 const notFoundSite = await siteFetch(rootHost, "/no-extension", { redirect: "manual" });
120 expect(notFoundSite.status).toBe(308);
121 const missingManifest = await siteFetch(discoveredHost(doc.nsites.root, { npub: npubEncode(pub(newKey())) }), "/");
122 expect(missingManifest.status).toBe(404);
123 } finally {
124 c.close();
125 }
126 });
127});
128
129function base36(hexKey: string): string {
130 return BigInt("0x" + hexKey).toString(36).padStart(50, "0");
131}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs