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 / exposure.test.ts
exposure.test.ts 17.9K
1// The read rule, door by door. A members-only relay with the directory off2// holds a named member, her note and her file. A stranger and a signed-in3// non-member then knock on every path the relay answers, over HTTP and the4// socket, and nothing that names her, her note or her file may come back.5// Then she asks, and it all does. This is the test that keeps the next6// door honest: add a path here when you add one to the relay.7import { SELF, env, runInDurableObject } from "cloudflare:test";8import { describe, it, expect } from "vitest";9import { generateSecretKey } from "nostr-tools/pure";10import { npubEncode } from "nostr-tools/nip19";11import { sha256 } from "@noble/hashes/sha2.js";12import { bytesToHex } from "../../src/negentropy.ts";13import { siteLabel } from "../../src/sites.ts";14import { VIEWS } from "../../src/views.ts";15import { ev, pk, nip98, rpc } from "../helpers/relay.ts";16import { WS } from "../helpers/ws.ts";17import { blossomToken } from "../helpers/media.ts";1819interface Fixture {20 host: string;21 siteHost: string;22 owner: Uint8Array;23 eve: Uint8Array;24 stranger: Uint8Array;25 sha: string;26 noteId: string;27 secrets: string[];28}2930async function seed(host: string): Promise<Fixture> {31 const owner = generateSecretKey();32 const eve = generateSecretKey();33 const stranger = generateSecretKey();34 await rpc(host, owner, "claim");35 expect((await rpc(host, owner, "setmember", pk(eve), { name: "evelynq7" })).status).toBe(200);36 // Two shortcuts with the owner's own titles: one for members, one for the owner alone.37 expect((await rpc(host, owner, "setconnections", [{ template: "notes", visibility: "public" }, { template: "group", visibility: "members", title: "membersroomq7" }, { template: "find-me", visibility: "owner", title: "ownerdeskq7" }])).status).toBe(200);38 const body = new TextEncoder().encode("eve's quarterly numbers");39 const sha = bytesToHex(sha256(body));40 const up = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(eve, "upload", sha), "content-type": "text/plain" }, body });41 expect([200, 201]).toContain(up.status);42 const c = await WS.connect(host);43 const note = ev(eve, 1, "the numbers are in");44 expect((await c.ok(note)).ok).toBe(true);45 const site = ev(eve, 15128, "", [["path", "/index.html", sha]]);46 expect((await c.ok(site)).ok).toBe(true);47 await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (r) => r.syncSites());48 c.ws.close();49 await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", directoryPublic: false });50 return { host, siteHost: siteLabel(site) + ".bind.ws", owner, eve, stranger, sha, noteId: note.id, secrets: [pk(eve), npubEncode(pk(eve)), note.id, sha, "evelynq7", "membersroomq7", "ownerdeskq7"] };51}5253// Every HTTP path a relay answers that could carry something of a member's.54function doors(f: Fixture): { path: string; host?: string; method?: string; gated: boolean; blossom?: string }[] {55 return [56 { path: `/${npubEncode(pk(f.eve))}/member.git`, gated: false },57 { path: `/prs/${npubEncode(pk(f.eve))}/member.git`, gated: false },58 { path: `/prs/${npubEncode(pk(f.eve))}/member.git/info/refs?service=git-upload-pack`, gated: false },59 { path: `/prs/${npubEncode(pk(f.eve))}/member.git/git-upload-pack`, method: "POST", gated: false },60 { path: `/prs/${npubEncode(pk(f.eve))}/member.git/git-receive-pack`, method: "POST", gated: false },61 { host: f.siteHost, path: "/", gated: true },62 { host: f.siteHost, path: "/index.html", method: "HEAD", gated: true },63 { host: f.siteHost, path: "/.well-known/nsite/auth", gated: true },64 { host: f.siteHost, path: "/.well-known/nostr.json?path=%2F", gated: true },65 { host: f.siteHost, path: "/.well-known/nostr.json?path=%2Findex.html", method: "HEAD", gated: true },66 { path: "/", gated: false },67 { path: "/people", gated: false },68 { path: "/.well-known/nostr.json", gated: false },69 { path: "/.well-known/nostr.json?name=nobodyq7", gated: false },70 { path: "/.well-known/nostr.json?path=%2F", gated: true },71 { path: `/.well-known/nostr.json?path=${encodeURIComponent(`/e/${f.noteId}`)}`, gated: false },72 { path: `/.well-known/nostr.json?path=${encodeURIComponent(`/a/${pk(f.eve)}/private`)}`, gated: false },73 { path: `/list/${pk(f.eve)}`, gated: true, blossom: "list" },74 { path: `/${f.sha}`, gated: true, blossom: "get" },75 { path: `/${f.sha}`, method: "HEAD", gated: true, blossom: "get" },76 { path: `/nip96/${f.sha}`, gated: true, blossom: "get" },77 { path: "/nip96?page=0", gated: true },78 ...VIEWS.map((v) => ({ path: `/view/${v.name}`, gated: true })),79 { path: "/fuel", gated: false },80 { path: "/card.json", gated: false },81 { path: "/card.nostr", gated: false },82 { path: "/card.svg", gated: false },83 { path: "/connect.json", gated: false },84 { path: `/e/${f.noteId}`, gated: false },85 { path: `/feed.xml?author=${pk(f.eve)}`, gated: false },86 { path: "/terms", gated: false },87 { path: "/api/join-policy", gated: false },88 { path: "/.well-known/nostr/nip96.json", gated: false },89 { path: "/backups/private", gated: true },90 { path: "/backups/preview", method: "POST", gated: true },91 { path: "/backups/restore", method: "POST", gated: true },92 ];93}9495// leaks returns which secrets appear in a text.96const leaks = (text: string, secrets: string[]) => secrets.filter((s) => text.includes(s));9798describe("the read rule at every door", () => {99 it("keeps membership claim management behind authentication and invitation permission", async () => {100 const host = "exposure-claims.bind.ws";101 const owner = generateSecretKey();102 const outsider = generateSecretKey();103 await rpc(host, owner, "claim");104 await rpc(host, owner, "createclaim", "private-invite");105 for (const method of ["listclaims", "createclaim", "deleteclaim"]) {106 const params = method === "listclaims" ? [] : ["private-invite"];107 expect((await rpc(host, null, method, ...params)).status).toBe(401);108 const denied = await rpc(host, outsider, method, ...params);109 expect(denied.status).toBe(403);110 expect(JSON.stringify(denied)).not.toContain("private-invite");111 }112 expect((await rpc(host, owner, "listclaims")).result).toEqual(["private-invite"]);113 });114115 it("keeps Git storage inventory owner-only at the management door", async () => {116 const host = "exposure-git-storage.bind.ws";117 const owner = generateSecretKey();118 const stranger = generateSecretKey();119 await rpc(host, owner, "claim");120 expect((await rpc(host, null, "gitstorage", pk(owner), "missing")).status).toBe(401);121 expect((await rpc(host, stranger, "gitstorage", pk(owner), "missing")).status).toBe(403);122 const methods = (await rpc(host, owner, "supportedmethods")).result as string[];123 expect(methods).toContain("gitstorage");124 expect((await rpc(host, stranger, "storagestats")).status).toBe(403);125 expect((await rpc(host, owner, "storagestats")).result.graspSync).toMatchObject({ enabled: false, partial: false, events: { jobs: 0, failed: 0, history: 0 }, git: { jobs: 0, failed: 0 } });126 });127128 it("shows a stranger nothing of a member, her note or her file", async () => {129 const f = await seed("exposure-stranger.bind.ws");130 for (const d of doors(f)) {131 const resp = await SELF.fetch(`http://${d.host ?? f.host}${d.path}`, { method: d.method ?? "GET" });132 const text = await resp.text();133 expect(leaks(text, f.secrets), `${d.method ?? "GET"} ${d.path} -> ${resp.status}`).toEqual([]);134 if (d.gated) expect(resp.status, d.path).toBe(401);135 }136 // The information document names the owner and the relay, nothing else.137 const info = await SELF.fetch(`http://${f.host}/`, { headers: { accept: "application/nostr+json" } });138 const doc = await info.json<any>();139 expect(doc.pubkey).toBe(pk(f.owner));140 expect(leaks(JSON.stringify(doc), f.secrets)).toEqual([]);141 for (const v of doc.views) expect(v.audience, v.name).toBe("members");142 // Pages and the feed are gone, not just empty.143 expect((await SELF.fetch(`http://${f.host}/e/${f.noteId}`)).status).toBe(404);144 expect((await SELF.fetch(`http://${f.host}/feed.xml`)).status).toBe(404);145 // A report about her file from a stranger is refused before the hash is looked up.146 const report = ev(f.stranger, 1984, "", [["x", f.sha, "spam"]]);147 const filed = await SELF.fetch(`http://${f.host}/report`, { method: "PUT", body: JSON.stringify(report) });148 expect(filed.status).toBe(403);149 const other = ev(f.stranger, 1984, "", [["x", "ab".repeat(32), "spam"]]);150 expect((await SELF.fetch(`http://${f.host}/report`, { method: "PUT", body: JSON.stringify(other) })).status).toBe(403);151 // The bridge without a signature.152 for (const path of ["/query", "/count"]) {153 const resp = await SELF.fetch(`http://${f.host}${path}`, { method: "POST", body: JSON.stringify([{ authors: [pk(f.eve)] }]) });154 expect(resp.status).toBe(401);155 expect(leaks(await resp.text(), f.secrets)).toEqual([]);156 }157158 // The socket: REQ, COUNT and NEG-OPEN are refused. Signer traffic is the159 // one exception, and only to a filter that already names her key: the160 // payload is ciphertext, and NIP-46 clients and signers never AUTH.161 const c = await WS.connect(f.host);162 expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);163 expect((await c.count({ authors: [pk(f.eve)] }, "n")).closed).toMatch(/^auth-required/);164 expect(await c.sync("s", { authors: [pk(f.eve)] })).toMatch(/^auth-required/);165 expect((await c.open("nc", { kinds: [24133] })).closed).toBe("");166 const sender = await WS.connect(f.host);167 expect((await sender.ok(ev(generateSecretKey(), 24133, "ciphertext", [["p", pk(f.eve)]]))).ok).toBe(true);168 // A later round trip proves no EVENT frame was queued in between.169 expect((await c.open("probe", { kinds: [24133] })).events).toEqual([]);170 expect(leaks(c.frames.join("\n"), f.secrets.filter((s) => s !== pk(f.eve)))).toEqual([]);171 });172173 it("shows a signed-in non-member the same nothing, with 403 where the stranger got 401", async () => {174 const f = await seed("exposure-outsider.bind.ws");175 for (const d of doors(f)) {176 const url = `http://${d.host ?? f.host}${d.path}`;177 const method = d.method ?? "GET";178 const headers = { authorization: await nip98(f.stranger, url, method) };179 const resp = await SELF.fetch(url, { method, headers });180 expect(leaks(await resp.text(), f.secrets), `${method} ${d.path} -> ${resp.status}`).toEqual([]);181 if (d.gated) expect(resp.status, d.path).toBe(403);182 if (d.blossom) {183 const withToken = await SELF.fetch(url, { method, headers: { authorization: blossomToken(f.stranger, d.blossom, d.blossom === "get" ? f.sha : undefined) } });184 expect(withToken.status, `${d.path} with a Blossom ${d.blossom} token`).toBe(403);185 expect(leaks(await withToken.text(), f.secrets)).toEqual([]);186 }187 }188 for (const path of ["/query", "/count"]) {189 const url = `http://${f.host}${path}`;190 const body = [{ authors: [pk(f.eve)] }];191 const resp = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98(f.stranger, url, "POST", body) }, body: JSON.stringify(body) });192 expect(resp.status, path).toBe(403);193 expect(leaks(await resp.text(), f.secrets)).toEqual([]);194 }195 const c = await WS.connect(f.host);196 await c.auth(f.stranger, f.host);197 expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^restricted/);198 expect((await c.count({ authors: [pk(f.eve)] }, "n")).closed).toMatch(/^restricted/);199 expect(await c.sync("s", { authors: [pk(f.eve)] })).toMatch(/^restricted/);200 expect(leaks(c.frames.join("\n"), f.secrets.filter((s) => s !== pk(f.eve)))).toEqual([]);201 });202203 it("answers the member herself at every one of them", async () => {204 const f = await seed("exposure-member.bind.ws");205 const get = async (path: string, auth: string) => {206 const resp = await SELF.fetch(`http://${f.host}${path}`, { headers: { authorization: auth } });207 return { status: resp.status, text: await resp.text() };208 };209 const url = (path: string) => `http://${f.host}${path}`;210 const siteURL = `http://${f.siteHost}/`;211 const site = await SELF.fetch(siteURL, { headers: { authorization: await nip98(f.eve, siteURL) } });212 expect(site.status).toBe(200);213 expect(await site.text()).toBe("eve's quarterly numbers");214 // Her file, with a Blossom get token and with NIP-98.215 let r = await get(`/${f.sha}`, blossomToken(f.eve, "get", f.sha));216 expect(r.status).toBe(200);217 expect(r.text).toBe("eve's quarterly numbers");218 r = await get(`/${f.sha}`, await nip98(f.eve, url(`/${f.sha}`)));219 expect(r.status).toBe(200);220 // A get token naming another blob does not open this one.221 expect((await get(`/${f.sha}`, blossomToken(f.eve, "get", "ab".repeat(32)))).status).toBe(401);222 r = await get(`/list/${pk(f.eve)}`, blossomToken(f.eve, "list"));223 expect(r.status).toBe(200);224 expect(r.text).toContain(f.sha);225 r = await get(`/.well-known/nostr.json`, await nip98(f.eve, url(`/.well-known/nostr.json`)));226 expect(JSON.parse(r.text).names.evelynq7).toBe(pk(f.eve));227 // Her name resolves for anyone: she published that address herself.228 expect(JSON.parse((await get(`/.well-known/nostr.json?name=evelynq7`, "")).text).names.evelynq7).toBe(pk(f.eve));229 for (const v of VIEWS) {230 r = await get(`/view/${v.name}`, await nip98(f.eve, url(`/view/${v.name}`)));231 expect([200, 404], `${v.name} -> ${r.status} ${r.text}`).toContain(r.status);232 }233 r = await get(`/people`, await nip98(f.eve, url(`/people`)));234 expect(JSON.parse(r.text).people.map((m: any) => m.pubkey)).toContain(pk(f.eve));235 // The Connect fold shows her the members' shortcut and not the owner's.236 r = await get(`/connect.json`, await nip98(f.eve, url(`/connect.json`)));237 expect(r.status).toBe(200);238 const titles = JSON.parse(r.text).connections.map((c: any) => c.title);239 expect(titles).toContain("membersroomq7");240 expect(titles).not.toContain("ownerdeskq7");241 const c = await WS.connect(f.host);242 await c.auth(f.eve, f.host);243 expect((await c.open("r", { authors: [pk(f.eve)], kinds: [1] })).events.map((e) => e.id)).toEqual([f.noteId]);244 });245246 it("closes the subscriptions a tightened read rule no longer admits", async () => {247 const host = "exposure-tighten.bind.ws";248 const owner = generateSecretKey();249 await rpc(host, owner, "claim");250 const bystander = await WS.connect(host);251 expect((await bystander.open("feed", { kinds: [1] })).closed).toBe("");252 const signer = await WS.connect(host);253 expect((await signer.open("nc", { kinds: [24133] })).closed).toBe("");254 await rpc(host, owner, "setpolicy", { reads: "members" });255 const closed = await bystander.expect("CLOSED");256 expect(closed[1]).toBe("feed");257 expect(closed[2]).toMatch(/^auth-required/);258 // The signer's 24133-only subscription stays open, and a fresh probe shows nothing else is queued for it.259 expect((await signer.open("probe", { kinds: [24133] })).closed).toBe("");260 // A stored note published after the change never reaches the bystander.261 const member = generateSecretKey();262 await rpc(host, owner, "allowpubkey", pk(member));263 const m = await WS.connect(host);264 expect((await m.ok(ev(member, 1, "after"))).ok).toBe(true);265 expect((await bystander.open("again", { kinds: [1] })).closed).toMatch(/^auth-required/);266 expect(bystander.frames.filter((x) => x.includes('"EVENT"'))).toEqual([]);267 });268269 it("files follow the other two read rules the same way: anyone, then any signature", async () => {270 const host = "exposure-rules.bind.ws";271 const owner = generateSecretKey();272 const stranger = generateSecretKey();273 await rpc(host, owner, "claim");274 const body = new TextEncoder().encode("a public file");275 const sha = bytesToHex(sha256(body));276 await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(owner, "upload", sha), "content-type": "text/plain" }, body });277 // Anyone: a public link by hash, cacheable.278 let resp = await SELF.fetch(`http://${host}/${sha}`);279 expect(resp.status).toBe(200);280 expect(resp.headers.get("cache-control")).toContain("public");281 expect((await SELF.fetch(`http://${host}/list/${pk(owner)}`)).status).toBe(200);282 // Signed in: any valid signature, member or not; no signature is 401; not for shared caches.283 await rpc(host, owner, "setpolicy", { reads: "auth" });284 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(401);285 resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossomToken(stranger, "get", sha) } });286 expect(resp.status).toBe(200);287 expect(resp.headers.get("cache-control")).toContain("no-store");288 expect((await SELF.fetch(`http://${host}/list/${pk(owner)}`, { headers: { authorization: blossomToken(stranger, "list") } })).status).toBe(200);289 // A token for the wrong action, or a malformed one, is refused with the reason rather than served as a stranger.290 resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossomToken(stranger, "upload", sha) } });291 expect(resp.status).toBe(401);292 expect(resp.headers.get("x-reason")).toMatch(/not for get/);293 expect((await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: "Nostr nonsense" } })).status).toBe(401);294 });295296 it("copies files between names on this host only from a relay that lets anyone read", async () => {297 const host = "exposure-copy.bind.ws";298 const owner = generateSecretKey();299 await rpc(host, owner, "claim");300 const { env } = await import("cloudflare:test");301 const stub = env.RELAY.getByName("exposure-copy");302 expect(Array.isArray(await stub.listBlobs([]))).toBe(true);303 await rpc(host, owner, "setpolicy", { reads: "members" });304 expect(await stub.listBlobs([])).toMatch(/^auth-required/);305 expect(await stub.listBlobs([pk(generateSecretKey())])).toMatch(/^restricted/);306 expect(Array.isArray(await stub.listBlobs([pk(owner)]))).toBe(true);307 });308});