| 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. |
| 4 | import { describe, it, expect } from "vitest" ; |
| 5 | import { sha256 } from "@noble/hashes/sha2.js" ; |
| 6 | import { newEvent, newKey, pub, hex, HTTP_URL, RELAY_URL, now, sleep, Client } from "./helpers.ts" ; |
| 7 | import { npubEncode } from "nostr-tools/nip19" ; |
| 8 | |
| 9 | function 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 | |
| 14 | async 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 | |
| 28 | function 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 | |
| 33 | function 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. |
| 41 | function 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 | |
| 49 | async 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 | |
| 59 | describe("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 | |
| 129 | function base36(hexKey: string): string { |
| 130 | return BigInt("0x" + hexKey).toString(36).padStart(50, "0" ); |
| 131 | } |