clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / test / object / sites.test.ts
sites.test.ts 16.4K
1import { SELF, env, runInDurableObject } from "cloudflare:test";2import { describe, it, expect } from "vitest";3import { generateSecretKey } from "nostr-tools/pure";4import { aggregate, base36, checkSite, parseSite, siteKey, siteLabel, syncSiteIndex, unbase36 } from "../../src/sites.ts";5import type { Relay } from "../../src/relay.ts";6import { upload } from "../helpers/media.ts";7import { ev, pk, rpc, now, nip98, info } from "../helpers/relay.ts";8import { WS } from "../helpers/ws.ts";910const paths = [["path", "/index.html", "ab".repeat(32)]];11describe("sites", () => {12 it("validates manifests, names, aggregates and snapshot references", () => {13 const sk = generateSecretKey();14 const root = ev(sk, 15128, "", paths);15 expect(checkSite(root)).toBe("");16 expect(checkSite(ev(sk, 15128, "", [...paths, ["d", "no"]]))).toMatch(/^invalid:/);17 for (const d of ["", "a-", "UPPER", "a".repeat(14)]) expect(checkSite(ev(sk, 35128, "", [...paths, ["d", d]]))).toMatch(/^invalid:/);18 for (const path of ["index.html", "/dir/", "/../a.html", "//a.html", "/a.html?x", "/a\nb.html"]) expect(checkSite(ev(sk, 15128, "", [["path", path, paths[0][2]]]))).toMatch(/^invalid:/);19 expect(checkSite(ev(sk, 15128, "", [...paths, ...paths]))).toMatch(/duplicate/);20 const hash = aggregate(root);21 expect(aggregate({ tags: [["title", "ignored"], ...paths].reverse() })).toBe(hash);22 expect(checkSite(ev(sk, 5128, "", [...paths, ["x", hash, "aggregate"], ["a", `15128:${pk(sk)}:`]]))).toBe("");23 expect(checkSite(ev(sk, 5128, "", paths))).toMatch(/aggregate/);24 expect(checkSite(ev(sk, 15128, "", [...paths, ["x", "00".repeat(32), "aggregate"]]))).toMatch(/aggregate/);25 });2627 it("round trips all label forms, leading zeros and the full 256-bit boundary", () => {28 const sk = generateSecretKey();29 for (const e of [ev(sk, 15128, "", paths), ev(sk, 35128, "", [...paths, ["d", "blog"]]), ev(sk, 5128, "", paths)]) {30 expect(parseSite(siteLabel(e))).toMatchObject(e.kind === 5128 ? { kind: e.kind, id: e.id } : { kind: e.kind, pubkey: e.pubkey });31 }32 for (const hex of ["00".repeat(32), "00".repeat(31) + "01", "ff".repeat(32)]) expect(unbase36(base36(hex))).toBe(hex);33 expect(unbase36("z".repeat(50))).toBeNull();34 for (const label of ["bad", "v" + "z".repeat(50), base36(pk(sk)) + "blog-", "npub1" + "q".repeat(58)]) expect(parseSite(label)).toBeNull();35 });3637 it("indexes accepted versions and clears routes after deletion and expiry", async () => {38 const name = "sites-index";39 const host = name + ".bind.ws";40 const sk = generateSecretKey();41 await rpc(host, sk, "claim");42 const c = await WS.connect(host);43 const first = ev(sk, 15128, "", paths, now() - 1);44 expect((await c.ok(first)).ok).toBe(true);45 await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());46 const key = siteKey(siteLabel(first));47 expect(JSON.parse((await env.HOSTS.get(key))!)).toEqual({ name, event: first.id });48 const next = ev(sk, 15128, "new", paths);49 expect((await c.ok(next)).ok).toBe(true);50 await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());51 expect(JSON.parse((await env.HOSTS.get(key))!).event).toBe(next.id);52 expect((await c.ok(ev(sk, 5, "", [["e", next.id]]))).ok).toBe(true);53 await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());54 expect(await env.HOSTS.get(key)).toBeNull();55 const expires = ev(sk, 35128, "", [...paths, ["d", "temp"], ["expiration", String(now() + 60)]]);56 expect((await c.ok(expires)).ok).toBe(true);57 await runInDurableObject(env.RELAY.getByName(name), async (r) => {58 await r.syncSites();59 r.store.sweepExpired(now() + 61);60 await r.syncSites();61 });62 expect(await env.HOSTS.get(siteKey(siteLabel(expires)))).toBeNull();63 c.ws.close();64 });65 it("lists manifests with deduplicated sizes and pending mirror files", async () => {66 const name = "sites-list", host = name + ".bind.ws", sk = generateSecretKey();67 await rpc(host, sk, "claim");68 const file = await upload(host, sk, "listed site");69 const missing = "cd".repeat(32);70 const tags = [["path", "/index.html", file.sha], ["path", "/copy.html", file.sha], ["path", "/missing.js", missing]];71 const c = await WS.connect(host);72 const root = ev(sk, 15128, "", tags), named = ev(sk, 35128, "", [...tags, ["d", "docs"]]);73 const snapTags = [...tags, ["a", `15128:${pk(sk)}:`], ["x", aggregate({ tags }), "aggregate"]];74 const snapshot = ev(sk, 5128, "", snapTags);75 for (const e of [root, named, snapshot]) expect((await c.ok(e)).ok).toBe(true);76 const listed = (await rpc(host, sk, "listsites")).result;77 expect(listed).toHaveLength(3);78 expect(listed.find((s: any) => s.id === root.id)).toMatchObject({ d: "", paths: 3, size: "listed site".length, missing: 1, url: "https://" + siteLabel(root) + ".bind.ws" });79 expect(listed.find((s: any) => s.id === named.id)).toMatchObject({ d: "docs", size: "listed site".length });80 expect(listed.find((s: any) => s.id === snapshot.id)).toMatchObject({ d: "", kind: 5128 });81 expect((await rpc(host, generateSecretKey(), "listsites")).status).toBe(403);82 const many = ev(sk, 35128, "", [["d", "large"], ...Array.from({ length: 120 }, (_, i) => ["path", `/file${i}.txt`, i.toString(16).padStart(64, "0")])]);83 expect((await c.ok(many)).ok).toBe(true);84 expect((await rpc(host, sk, "listsites")).result.find((s: any) => s.id === many.id)).toMatchObject({ paths: 120, size: 0, missing: 120 });85 c.ws.close();86 });87 it("serves all hostname forms, directories, fallback pages and verified metadata", async () => {88 const name = "sites-serve", host = name + ".bind.ws", sk = generateSecretKey();89 await rpc(host, sk, "claim");90 const file = await upload(host, sk, "hello site"), missing = await upload(host, sk, "custom missing");91 const tags = [["path", "/index.html", file.sha], ["path", "/blog/index.html", file.sha], ["path", "/404.html", missing.sha]];92 const c = await WS.connect(host);93 const events = [ev(sk, 15128, "", tags), ev(sk, 35128, "", [...tags, ["d", "blog"]]), ev(sk, 5128, "", [...tags, ["a", `15128:${pk(sk)}:`], ["x", aggregate({ tags }), "aggregate"]])];94 for (const e of events) expect((await c.ok(e)).ok).toBe(true);95 await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());96 for (const e of events) {97 const url = "http://" + siteLabel(e) + ".bind.ws";98 const res = await SELF.fetch(url + "/");99 expect(res.status).toBe(200);100 expect(await res.text()).toBe("hello site");101 expect(res.headers.get("content-type")).toBe("text/plain");102 expect(res.headers.get("content-length")).toBe("10");103 expect(res.headers.get("etag")).toBe('"' + file.sha + '"');104 expect(res.headers.get("cache-control")).toContain("no-transform");105 const head = await SELF.fetch(url + "/", { method: "HEAD" });106 expect(head.status).toBe(200); expect(await head.text()).toBe("");107 expect((await SELF.fetch(url + "/", { headers: { "if-none-match": res.headers.get("etag")! } })).status).toBe(304);108 expect((await SELF.fetch(url + "/blog", { redirect: "manual" })).headers.get("location")).toBe(url + "/blog/");109 expect(await (await SELF.fetch(url + "/blog/")).text()).toBe("hello site");110 const no = await SELF.fetch(url + "/absent.html"); expect(no.status).toBe(404); expect(await no.text()).toBe("custom missing");111 // Content negotiation and relay paths cannot escape the site door.112 expect(await (await SELF.fetch(url + "/", { headers: { accept: "application/nostr+json" } })).text()).toBe("hello site");113 expect((await SELF.fetch(url + "/", { method: "POST", headers: { "content-type": "application/nostr+json+rpc" }, body: '{"method":"claim"}' })).status).toBe(405);114 expect((await SELF.fetch(url + "/", { headers: { upgrade: "websocket" } })).status).toBe(405);115 }116 const url = "http://" + siteLabel(events[0]) + ".bind.ws/";117 await rpc(host, sk, "setpolicy", { reads: "members" });118 expect((await SELF.fetch(url)).status).toBe(401);119 expect((await SELF.fetch(url, { headers: { authorization: await nip98(generateSecretKey(), url) } })).status).toBe(403);120 const member = await SELF.fetch(url, { headers: { authorization: await nip98(sk, url) } });121 expect(member.status).toBe(200); expect(member.headers.get("cache-control")).toContain("no-store");122 expect(member.headers.get("cache-control")).toContain("no-transform");123 await rpc(host, sk, "setpolicy", { reads: "open", features: { sites: false } });124 expect((await SELF.fetch(url)).status).toBe(404); expect((await info(host)).nsites).toBeUndefined();125 await rpc(host, sk, "setpolicy", { features: { sites: true } });126 expect((await info(host)).nsites.host).toBe("bind.ws");127 await env.MEDIA.put(name + "/" + file.sha, "corrupt");128 expect((await SELF.fetch(url)).status).toBe(404);129 c.ws.close();130 });131132 it("rejects invalid and unknown site labels and ignores a forged routing header", async () => {133 expect((await SELF.fetch("http://" + "z".repeat(55) + ".bind.ws/", { redirect: "manual" })).status).toBe(404);134 const unknown = siteLabel(ev(generateSecretKey(), 15128, "", paths));135 expect((await SELF.fetch("http://" + unknown + ".bind.ws/", { redirect: "manual" })).status).toBe(404);136 const res = await SELF.fetch("http://sites-normal.bind.ws/", { headers: { "x-relay-site": unknown, accept: "application/nostr+json" } });137 expect((await res.json<any>()).name).toBe("sites-normal");138 });139140});141142async function publishForIndex(host: string, sk: Uint8Array, ...events: ReturnType<typeof ev>[]) {143 const c = await WS.connect(host);144 for (const e of events) expect((await c.ok(e)).ok).toBe(true);145 c.ws.close();146}147148async function syncIndex(name: string) {149 await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());150}151152describe("site hostname index cleanup", () => {153 it("keeps a replacement route through a throttled write and a split outbox batch", async () => {154 const name = "idx-retry", sk = generateSecretKey();155 await rpc(name + ".bind.ws", sk, "claim");156 await runInDurableObject(env.RELAY.getByName(name), async (r) => {157 r.store.onSitesChanged = () => {};158 let value: string | null = null, writes = 0, deletes = 0, fail = false;159 const hosts = {160 get: async () => value,161 put: async (_key: string, next: string) => { if (fail) throw new Error("KV write limit"); writes++; value = next; },162 delete: async () => { deletes++; value = null; },163 };164 const subject = { sql: r.sql, store: r.store, slug: name, hosts } as unknown as Relay;165 const first = ev(sk, 15128, "first", paths, now() - 3);166 const second = ev(sk, 15128, "second", paths, now() - 2);167 const third = ev(sk, 15128, "third", paths, now() - 1);168 expect(r.store.save(first, now())).toBe("");169 await syncSiteIndex(subject);170 const original = value;171 writes = 0;172 expect(r.store.save(second, now())).toBe("");173 expect(r.store.save(third, now())).toBe("");174 fail = true;175 await expect(syncSiteIndex(subject)).rejects.toThrow("KV write limit");176 expect(value).toBe(original);177 expect(deletes).toBe(0);178 expect(r.sql.exec("SELECT * FROM site_outbox").toArray()).toHaveLength(4);179 fail = false;180 await syncSiteIndex(subject);181 expect(value).toBe(JSON.stringify({ name, event: third.id }));182 expect(writes).toBe(1);183 expect(deletes).toBe(0);184 expect(r.sql.exec("SELECT * FROM site_outbox").toArray()).toHaveLength(0);185186 // The first batch ends on the old version's removal; the successor187 // is already durable, and its insertion remains for the next batch.188 for (let i = 0; i < 99; i++) r.sql.exec("INSERT INTO site_outbox(raw,removed) VALUES(?,1)", JSON.stringify(third));189 const fourth = ev(sk, 15128, "fourth", paths);190 expect(r.store.save(fourth, now())).toBe("");191 writes = 0;192 expect(await syncSiteIndex(subject)).toBe(true);193 expect(value).toBe(JSON.stringify({ name, event: fourth.id }));194 expect(await syncSiteIndex(subject)).toBe(false);195 expect(writes).toBe(1);196 expect(deletes).toBe(0);197 for (let i = 0; i < 100; i++) r.sql.exec("INSERT INTO site_outbox(raw,removed) VALUES(?,0)", JSON.stringify(first));198 writes = 0;199 expect(await syncSiteIndex(subject)).toBe(false);200 expect(value).toBe(JSON.stringify({ name, event: fourth.id }));201 expect(writes).toBe(0);202 });203 });204205 it("clears a mapping when management deletes its manifest", async () => {206 const name = "idx-manage";207 const sk = generateSecretKey();208 await rpc(name + ".bind.ws", sk, "claim");209 const e = ev(sk, 15128, "", paths);210 await publishForIndex(name + ".bind.ws", sk, e);211 await syncIndex(name);212 const key = siteKey(siteLabel(e));213 expect(await env.HOSTS.get(key)).toContain(name);214 expect((await rpc(name + ".bind.ws", sk, "deleteevent", e.id)).result).toBe(true);215 await syncIndex(name);216 expect(await env.HOSTS.get(key)).toBeNull();217 });218219 it("clears root and named mappings through NIP-09 a tags", async () => {220 const name = "idx-nip09";221 const sk = generateSecretKey();222 await rpc(name + ".bind.ws", sk, "claim");223 const root = ev(sk, 15128, "", paths);224 const named = ev(sk, 35128, "", [...paths, ["d", "docs"]]);225 await publishForIndex(name + ".bind.ws", sk, root, named);226 await syncIndex(name);227 const rootKey = siteKey(siteLabel(root));228 const namedKey = siteKey(siteLabel(named));229 expect(await env.HOSTS.get(rootKey)).toContain(name);230 expect(await env.HOSTS.get(namedKey)).toContain(name);231 const deletion = ev(sk, 5, "", [["a", `15128:${pk(sk)}:`], ["a", `35128:${pk(sk)}:docs`]]);232 await publishForIndex(name + ".bind.ws", sk, deletion);233 await syncIndex(name);234 expect(await env.HOSTS.get(rootKey)).toBeNull();235 expect(await env.HOSTS.get(namedKey)).toBeNull();236 });237238 it("clears mappings when retention and vanish delete manifests", async () => {239 const retentionName = "idx-retain";240 const retentionKey = generateSecretKey();241 await rpc(retentionName + ".bind.ws", retentionKey, "claim");242 const old = ev(retentionKey, 15128, "", paths, now() - 3 * 86400);243 await publishForIndex(retentionName + ".bind.ws", retentionKey, old);244 await syncIndex(retentionName);245 const oldKey = siteKey(siteLabel(old));246 expect(await env.HOSTS.get(oldKey)).toContain(retentionName);247 await rpc(retentionName + ".bind.ws", retentionKey, "setretention", 15128, 1);248 await runInDurableObject(env.RELAY.getByName(retentionName), (r) => r.sweepRetention(now()));249 await syncIndex(retentionName);250 expect(await env.HOSTS.get(oldKey)).toBeNull();251252 const vanishName = "idx-vanish";253 const vanishKey = generateSecretKey();254 await rpc(vanishName + ".bind.ws", vanishKey, "claim");255 const e = ev(vanishKey, 15128, "", paths);256 await publishForIndex(vanishName + ".bind.ws", vanishKey, e);257 await syncIndex(vanishName);258 const key = siteKey(siteLabel(e));259 expect(await env.HOSTS.get(key)).toContain(vanishName);260 await publishForIndex(vanishName + ".bind.ws", vanishKey, ev(vanishKey, 62, "", [["relay", "ALL_RELAYS"]]));261 await syncIndex(vanishName);262 expect(await env.HOSTS.get(key)).toBeNull();263 });264265 it("clears only its own mapping when another relay has the same site", async () => {266 const sk = generateSecretKey();267 const first = "idx-copy-a";268 const second = "idx-copy-b";269 await rpc(first + ".bind.ws", sk, "claim");270 await rpc(second + ".bind.ws", sk, "claim");271 const e = ev(sk, 15128, "", paths);272 await publishForIndex(first + ".bind.ws", sk, e);273 await syncIndex(first);274 await publishForIndex(second + ".bind.ws", sk, e);275 await syncIndex(second);276 const key = siteKey(siteLabel(e));277 expect(JSON.parse((await env.HOSTS.get(key))!).name).toBe(second);278 expect((await rpc(first + ".bind.ws", sk, "deleteevent", e.id)).result).toBe(true);279 await syncIndex(first);280 expect(JSON.parse((await env.HOSTS.get(key))!).name).toBe(second);281 });282283 it("clears site mappings during relay teardown", async () => {284 const name = "idx-teardown";285 const sk = generateSecretKey();286 await rpc(name + ".bind.ws", sk, "claim");287 const e = ev(sk, 15128, "", paths);288 await publishForIndex(name + ".bind.ws", sk, e);289 await syncIndex(name);290 const key = siteKey(siteLabel(e));291 expect(await env.HOSTS.get(key)).toContain(name);292 expect((await rpc(name + ".bind.ws", sk, "deleterelay", name)).result).toEqual({ deleted: true, name });293 expect(await env.HOSTS.get(key)).toBeNull();294 });295});