1// NIP-5A from outside the relay: discover the host forms from NIP-11, upload2// the files through Blossom, publish signed manifests, and fetch each site3// 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";89function 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}1314async 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}2728function 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}3233function 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}3839// siteFetch keeps Wrangler runs local while using the canonical hostname40// 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}4849async 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}5859describe("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");6869 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);8687 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 async92 // tasks on some deployments, so wait for each discovered label rather93 // than relying on a fixed delay.94 for (const host of [rootHost, namedHost, snapshotHost]) await waitForSite(host);9596 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));107108 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 }118119 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});128129function base36(hexKey: string): string {130 return BigInt("0x" + hexKey).toString(36).padStart(50, "0");131}