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 / domains.test.ts
domains.test.ts 12.0K
1// Custom domains: a hostname the owner controls, mapped to the relay through2// KV and registered with Cloudflare for SaaS. The Cloudflare API is a fake3// fetch here; routing goes through the real worker and the KV binding.4import { SELF, env, runInDurableObject } from "cloudflare:test";5import { describe, it, expect } from "vitest";6import { generateSecretKey } from "nostr-tools/pure";7import type { Relay } from "../../src/relay.ts";8import { Hostnames, checkHostname, MAX_CUSTOM_HOSTS } from "../../src/domains.ts";9import { siteLabel } from "../../src/sites.ts";10import { upload } from "../helpers/media.ts";11import { WS } from "../helpers/ws.ts";12import { rpc, info, ev, nip98 } from "../helpers/relay.ts";1314// fakeCloudflare answers the custom hostnames API and records every call.15function fakeCloudflare(opts: { active?: boolean; refuse?: boolean } = {}) {16 const calls: { method: string; url: string; body: any }[] = [];17 let n = 0;18 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } });19 const fetcher = async (url: string, init?: RequestInit) => {20 const method = init?.method ?? "GET";21 const body = init?.body ? JSON.parse(String(init.body)) : null;22 calls.push({ method, url, body });23 if (!url.startsWith("https://api.cloudflare.com/client/v4/zones/zone-1/custom_hostnames")) return json({ success: false, errors: [{ code: 7003, message: "no such route" }] }, 404);24 if ((init?.headers as Record<string, string>)?.authorization !== "Bearer token") return json({ success: false, errors: [{ code: 10000, message: "Authentication error" }] }, 403);25 if (opts.refuse) return json({ success: false, errors: [{ code: 1406, message: "Invalid custom hostname" }] }, 400);26 if (method === "POST") {27 n++;28 return json({29 success: true,30 result: {31 id: "ch-" + n,32 hostname: body.hostname,33 status: "pending",34 ssl: { status: "pending_validation", method: body.ssl.method, type: body.ssl.type, validation_records: [{ http_url: `http://${body.hostname}/.well-known/pki-validation/x.txt`, http_body: "dcv-token" }] },35 ownership_verification: { type: "txt", name: "_cf-custom-hostname." + body.hostname, value: "ov-" + n },36 },37 });38 }39 const id = url.split("/").pop();40 if (method === "GET") {41 return json({ success: true, result: { id, hostname: "x", status: opts.active ? "active" : "pending", ssl: { status: opts.active ? "active" : "pending_validation" }, ownership_verification: { type: "txt", name: "_cf-custom-hostname.x", value: "ov" } } });42 }43 if (method === "DELETE") return json({ success: true, result: { id } });44 return json({ success: false, errors: [{ message: "unexpected" }] }, 400);45 };46 return { calls, fetcher, opts };47}4849// enable gives a relay the Cloudflare client, backed by the fake.50async function enable(name: string, fake: ReturnType<typeof fakeCloudflare>) {51 await runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => {52 r.hostnames = new Hostnames("token", "zone-1", fake.fetcher);53 });54}5556describe("custom domains", () => {57 it("routes a custom hostname to its relay through KV and prints URLs with it", async () => {58 const owner = generateSecretKey();59 await rpc("alice.bind.ws", owner, "claim");60 await env.HOSTS.put("relay.alice.test", "alice");61 const doc = await info("relay.alice.test");62 expect(doc.name).toBe("alice");63 expect(doc.self_url).toBe("wss://relay.alice.test");64 expect(doc.pubkey).toBe((await info("alice.bind.ws")).pubkey);65 // An unknown host falls back to the dev relay, as before.66 expect((await info("nowhere.test")).name).toBe("dev");67 });6869 it("checks hostnames strictly", () => {70 expect(checkHostname(" Relay.Example.com. ", "bind.ws")).toEqual({ host: "relay.example.com" });71 for (const bad of ["bind.ws", "x.bind.ws", "localhost", "a.localhost", "printer.local", "1.2.3.4", "::1", "single", "a..b", "-bad.example.com", "bad-.example.com", "a.123", "x".repeat(64) + ".com", ""]) {72 expect("error" in checkHostname(bad, "bind.ws"), bad).toBe(true);73 }74 });7576 it("is off without the token", async () => {77 const owner = generateSecretKey();78 await rpc("plain.bind.ws", owner, "claim");79 const r = await rpc("plain.bind.ws", owner, "adddomain", "relay.plain.test");80 expect(r.status).toBe(400);81 expect(r.error).toMatch(/^unsupported:/);82 expect((await rpc("plain.bind.ws", owner, "listdomains")).error).toMatch(/^unsupported:/);83 expect(await env.HOSTS.get("relay.plain.test")).toBeNull();84 });8586 it("adds, checks and removes a domain, mapping it only after Cloudflare accepted", async () => {87 const host = "bob.bind.ws";88 const owner = generateSecretKey();89 await rpc(host, owner, "claim");90 const fake = fakeCloudflare();91 await enable("bob", fake);9293 // Refusals before any API call: bad names, a name another relay holds.94 expect((await rpc(host, owner, "adddomain", "x.bind.ws")).error).toMatch(/^invalid:/);95 await env.HOSTS.put("relay.carol.test", "carol");96 expect((await rpc(host, owner, "adddomain", "relay.carol.test")).error).toMatch(/another relay/);97 expect(fake.calls.length).toBe(0);98 // A member may not.99 expect((await rpc(host, generateSecretKey(), "adddomain", "relay.bob.test")).status).toBe(403);100101 const added = await rpc(host, owner, "adddomain", "Relay.Bob.Test");102 expect(added.status, JSON.stringify(added)).toBe(200);103 expect(added.result.host).toBe("relay.bob.test");104 expect(added.result.ready).toBe(false);105 expect(added.result.records[0]).toMatchObject({ type: "CNAME", name: "relay.bob.test", value: "customers.bind.ws" });106 expect(added.result.records[1]).toMatchObject({ type: "TXT", name: "_cf-custom-hostname.relay.bob.test", value: "ov-1" });107 expect(fake.calls[0]).toMatchObject({ method: "POST", body: { hostname: "relay.bob.test", ssl: { method: "http", type: "dv" } } });108 expect(await env.HOSTS.get("relay.bob.test")).toBe("bob");109 expect((await rpc(host, owner, "adddomain", "relay.bob.test")).error).toMatch(/^duplicate:/);110111 const list = (await rpc(host, owner, "listdomains")).result;112 expect(list.map((d: any) => d.host)).toEqual(["relay.bob.test"]);113 // The export never carries domains: they belong to this relay instance.114 expect((await rpc(host, owner, "exportconfig")).result.policy.customHosts).toBeUndefined();115116 fake.opts.active = true;117 const checked = await rpc(host, owner, "checkdomain", "relay.bob.test");118 expect(checked.result.ready).toBe(true);119 expect(checked.result.records.length).toBe(1);120 expect(fake.calls.at(-1)).toMatchObject({ method: "GET", url: expect.stringMatching(/\/custom_hostnames\/ch-1$/) });121 expect((await rpc(host, owner, "listdomains")).result[0].status).toBe("active");122123 for (let i = 1; i < MAX_CUSTOM_HOSTS; i++) expect((await rpc(host, owner, "adddomain", `r${i}.bob.test`)).status).toBe(200);124 expect((await rpc(host, owner, "adddomain", "one-too-many.bob.test")).error).toMatch(/^restricted: at most/);125126 expect((await rpc(host, owner, "removedomain", "relay.bob.test")).result).toBe(true);127 expect(fake.calls.at(-1)).toMatchObject({ method: "DELETE", url: expect.stringMatching(/\/custom_hostnames\/ch-1$/) });128 expect(await env.HOSTS.get("relay.bob.test")).toBeNull();129 expect((await rpc(host, owner, "listdomains")).result.length).toBe(MAX_CUSTOM_HOSTS - 1);130 expect((await rpc(host, owner, "removedomain", "relay.bob.test")).error).toMatch(/^invalid:/);131 // The other relay's mapping was never touched.132 expect(await env.HOSTS.get("relay.carol.test")).toBe("carol");133 });134135 it("writes nothing when Cloudflare refuses", async () => {136 const host = "dan.bind.ws";137 const owner = generateSecretKey();138 await rpc(host, owner, "claim");139 await enable("dan", fakeCloudflare({ refuse: true }));140 const r = await rpc(host, owner, "adddomain", "relay.dan.test");141 expect(r.status).toBe(502);142 expect(r.error).toMatch(/^error: Cloudflare: Invalid custom hostname/);143 expect(await env.HOSTS.get("relay.dan.test")).toBeNull();144 expect((await rpc(host, owner, "listdomains")).result).toEqual([]);145 });146147 it("teardown removes the domains from Cloudflare and KV", async () => {148 const host = "gonehost.bind.ws";149 const owner = generateSecretKey();150 await rpc(host, owner, "claim");151 const fake = fakeCloudflare();152 await enable("gonehost", fake);153 expect((await rpc(host, owner, "adddomain", "relay.gone.test")).status).toBe(200);154 expect(await env.HOSTS.get("relay.gone.test")).toBe("gonehost");155 expect((await rpc(host, owner, "deleterelay", "gonehost")).result).toEqual({ deleted: true, name: "gonehost" });156 expect(fake.calls.some((c) => c.method === "DELETE" && c.url.endsWith("/ch-1"))).toBe(true);157 expect(await env.HOSTS.get("relay.gone.test")).toBeNull();158 });159 it("routes a custom site domain only to site content under the relay read rule", async () => {160 const name = "domain-sites", host = name + ".bind.ws", sk = generateSecretKey();161 await rpc(host, sk, "claim"); await enable(name, fakeCloudflare());162 const file = await upload(host, sk, "custom site");163 const e = ev(sk, 15128, "", [["path", "/index.html", file.sha]]);164 const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true); c.ws.close();165 const label = siteLabel(e), custom = "www.nsite-example.test";166 const added = await rpc(host, sk, "adddomain", custom, label);167 expect(added.status).toBe(200); expect(added.result.site).toBe(label);168 expect(JSON.parse((await env.HOSTS.get(custom))!)).toEqual({ name, site: label });169 const url = "https://" + custom + "/";170 const res = await SELF.fetch(url, { headers: { accept: "application/nostr+json" } });171 expect(res.status).toBe(200); expect(await res.text()).toBe("custom site");172 expect((await SELF.fetch(url, { headers: { upgrade: "websocket" } })).status).toBe(405);173 expect((await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc" }, body: '{"method":"getpolicy"}' })).status).toBe(405);174 await rpc(host, sk, "setpolicy", { reads: "members" });175 expect((await SELF.fetch(url)).status).toBe(401);176 expect((await SELF.fetch(url, { headers: { authorization: await nip98(sk, url) } })).status).toBe(200);177 expect((await rpc(host, sk, "removedomain", custom)).status).toBe(200);178 expect(await env.HOSTS.get(custom)).toBeNull();179 // A cached edge mapping cannot keep a removed custom site alive.180 expect((await SELF.fetch(url, { headers: { authorization: await nip98(sk, url) } })).status).toBe(404);181 });182183 it("changes a relay domain into a site without exposing the cached relay door", async () => {184 const name = "domain-target", host = name + ".bind.ws", sk = generateSecretKey();185 await rpc(host, sk, "claim"); await enable(name, fakeCloudflare());186 const file = await upload(host, sk, "retargeted");187 const e = ev(sk, 35128, "", [["d", "docs"], ["path", "/index.html", file.sha]]);188 const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true); c.ws.close();189 const custom = "target.nsite-example.test";190 expect((await rpc(host, sk, "adddomain", custom)).status).toBe(200);191 expect((await info(custom)).pubkey).toBe((await info(host)).pubkey);192 expect((await rpc(host, sk, "setdomainsite", custom, siteLabel(e))).status).toBe(200);193 const res = await SELF.fetch("https://" + custom + "/", { headers: { accept: "application/nostr+json" } });194 expect(res.status).toBe(200); expect(await res.text()).toBe("retargeted");195 expect((await SELF.fetch("https://" + custom + "/", { headers: { upgrade: "websocket" } })).status).toBe(405);196 expect((await rpc(host, sk, "setdomainsite", custom, "invalid")).status).toBe(400);197 expect((await rpc(host, generateSecretKey(), "setdomainsite", custom, "")).status).toBe(403);198 expect((await rpc(host, sk, "setdomainsite", custom, "")).status).toBe(200);199 expect(await env.HOSTS.get(custom)).toBe(name);200 expect((await info(custom)).pubkey).toBe((await info(host)).pubkey);201 });202203});