clone

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

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / test / object / nipad.test.ts
nipad.test.ts 10.7K
1// NIP-AD path discovery shares the relay's page and metadata boundaries.2import { SELF, env, runInDurableObject } from "cloudflare:test";3import { describe, it, expect } from "vitest";4import { generateSecretKey } from "nostr-tools/pure";5import { npubEncode, nprofileEncode } from "nostr-tools/nip19";6import { ev, info, nip98, now, pk, post, rpc } from "../helpers/relay.ts";7import { WS } from "../helpers/ws.ts";89const endpoint = (host: string, path: string) => `http://${host}/.well-known/nostr.json?path=${encodeURIComponent(path)}`;10const lookup = async (host: string, path: string, signer?: Uint8Array) => {11 const url = endpoint(host, path);12 const response = await SELF.fetch(url, { headers: signer ? { authorization: await nip98(signer, url) } : {} });13 return { status: response.status, body: await response.json<Record<string, { filter: Record<string, unknown>; relays: string[] }>>() };14};1516describe("NIP-AD web addresses", () => {17 it("maps the homepage to this relay's exact live group, with an explicit relay hint", async () => {18 const host = "ad-group.bind.ws", owner = generateSecretKey();19 await rpc(host, owner, "claim");20 const self = (await info(host)).self;21 const filter = { kinds: [39000], authors: [self], "#d": ["ad-group"], limit: 1 };22 const r = await lookup(host, "/");23 expect(r).toEqual({ status: 200, body: { "/": { filter, relays: [`wss://${host}`] } } });24 const queried = await post(host, owner, "/query", [filter]);25 expect(queried.body).toHaveLength(1);26 expect(queried.body[0]).toMatchObject({ kind: 39000, pubkey: self });27 await runInDurableObject(env.RELAY.getByName("ad-group"), (relay) => {28 const row = relay.sql.exec<{ id: string }>("SELECT id FROM events WHERE kind=39000").one();29 relay.store.hidden.add(row.id);30 });31 expect((await lookup(host, "/")).body).toEqual({});32 });3334 it("keeps exact event versions and addressable articles aligned with the browser pages", async () => {35 const host = "ad-pages.bind.ws", owner = generateSecretKey(), author = generateSecretKey();36 await rpc(host, owner, "claim");37 const c = await WS.connect(host);38 const note = ev(owner, 1, "A web address");39 const d = "why / Nostr? #1%";40 const article = ev(owner, 30023, "An article", [["d", d]], now() - 1);41 const other = ev(author, 30023, "Another author", [["d", d]]);42 for (const e of [note, article, other]) expect((await c.ok(e)).ok).toBe(true);43 const cases = [44 { path: `/e/${note.id}`, filter: { ids: [note.id], limit: 1 } },45 { path: `/e/${article.id}`, filter: { ids: [article.id], limit: 1 } },46 ...[`/a/${encodeURIComponent(d)}`, `/a/${pk(owner)}/${encodeURIComponent(d)}`, `/a/${npubEncode(pk(owner))}/${encodeURIComponent(d)}`, `/a/${nprofileEncode({ pubkey: pk(owner) })}/${encodeURIComponent(d)}`]47 .map((path) => ({ path, filter: { kinds: [30023], authors: [pk(owner)], "#d": [d], limit: 1 } })),48 { path: `/a/${pk(author)}/${encodeURIComponent(d)}`, filter: { kinds: [30023], authors: [pk(author)], "#d": [d], limit: 1 } },49 ];50 for (const { path, filter } of cases) {51 expect((await SELF.fetch(`http://${host}${path}`)).status, path).toBe(200);52 expect((await lookup(host, path)).body).toEqual({ [path]: { filter, relays: [`wss://${host}`] } });53 }54 const next = ev(owner, 30023, "New edition", [["d", d]]);55 expect((await c.ok(next)).ok).toBe(true);56 expect((await lookup(host, `/e/${article.id}`)).body).toEqual({});57 const address = (await lookup(host, `/a/${encodeURIComponent(d)}`)).body;58 expect(address[`/a/${encodeURIComponent(d)}`].filter).toEqual(cases[2].filter);59 expect((await post(host, owner, "/query", [cases[2].filter])).body.map((e: { id: string }) => e.id)).toEqual([next.id]);60 c.ws.close();61 });6263 it("omits missing, deleted, expired, held and private events and unsupported browser paths", async () => {64 const host = "ad-hidden.bind.ws", owner = generateSecretKey();65 await rpc(host, owner, "claim");66 const c = await WS.connect(host);67 const deleted = ev(owner, 1, "deleted"), held = ev(owner, 1, "held");68 const expiring = ev(owner, 1, "expires", [["expiration", String(now() + 60)]]);69 const dm = ev(owner, 4, "private", [["p", pk(owner)]]);70 const profile = ev(owner, 0, "{}");71 const article = ev(owner, 30023, "gone", [["d", "gone"]]);72 for (const e of [deleted, held, expiring, dm, profile, article]) expect((await c.ok(e)).ok).toBe(true);73 expect((await c.ok(ev(owner, 5, "", [["e", deleted.id], ["a", `30023:${pk(owner)}:gone`]]))).ok).toBe(true);74 await runInDurableObject(env.RELAY.getByName("ad-hidden"), (relay) => {75 relay.store.hidden.add(held.id);76 relay.sql.exec("UPDATE events SET expires=? WHERE id=?", now() - 1, expiring.id);77 });78 for (const path of [deleted, held, expiring, dm, profile].map((e) => `/e/${e.id}`).concat([`/e/${"0".repeat(64)}`, "/a/gone", "/a/missing", "/people", "/feed.xml", "/view/articles", "/card.json", "/e/nope"])) {79 expect((await lookup(host, path)).body, path).toEqual({});80 }81 c.ws.close();82 });8384 it("preserves private metadata admission and never opens public pages with authentication", async () => {85 const host = "ad-private.bind.ws", owner = generateSecretKey(), outsider = generateSecretKey();86 await rpc(host, owner, "claim");87 const c = await WS.connect(host), note = ev(owner, 1, "members only");88 expect((await c.ok(note)).ok).toBe(true);89 await rpc(host, owner, "setpolicy", { reads: "members", directoryPublic: false });90 expect(await lookup(host, "/")).toEqual({ status: 401, body: {} });91 expect(await lookup(host, "/", outsider)).toEqual({ status: 403, body: {} });92 expect((await lookup(host, "/", owner)).body["/"].filter).toMatchObject({ kinds: [39000] });93 for (const signer of [undefined, outsider, owner]) expect((await lookup(host, `/e/${note.id}`, signer)).body).toEqual({});94 await rpc(host, owner, "setpolicy", { reads: "auth" });95 expect((await lookup(host, "/")).status).toBe(401);96 expect((await lookup(host, "/", outsider)).body["/"]).toBeDefined();97 await rpc(host, owner, "setpolicy", { reads: "open" });98 expect((await lookup(host, "/")).body["/"]).toBeDefined();99 c.ws.close();100 });101102 it("preserves name lookup and directory semantics independently of path and page discovery", async () => {103 const host = "ad-names.bind.ws", owner = generateSecretKey();104 await rpc(host, owner, "claim");105 await rpc(host, owner, "setmember", pk(owner), { name: "alice" });106 const wellKnown = `http://${host}/.well-known/nostr.json`;107 const names = { names: { alice: pk(owner) }, relays: { [pk(owner)]: [`wss://${host}`] } };108 expect(await (await SELF.fetch(wellKnown)).json()).toEqual(names);109 expect(await (await SELF.fetch(wellKnown + "?name=alice&path=/")).json()).toEqual(names);110 await rpc(host, owner, "setpolicy", { directoryPublic: false });111 expect(await (await SELF.fetch(wellKnown)).json()).toEqual({ names: {}, relays: {} });112 expect(await (await SELF.fetch(wellKnown + "?name=alice")).json()).toEqual(names);113 const c = await WS.connect(host), note = ev(owner, 1, "public page");114 expect((await c.ok(note)).ok).toBe(true);115 await rpc(host, owner, "setpolicy", { features: { names: false } });116 expect((await SELF.fetch(wellKnown + "?name=alice")).status).toBe(404);117 expect((await SELF.fetch(wellKnown + "?name=alice&path=/")).status).toBe(404);118 expect((await lookup(host, "/")).body["/"]).toBeDefined();119 expect((await lookup(host, `/e/${note.id}`)).body[`/e/${note.id}`]).toBeDefined();120 await rpc(host, owner, "setpolicy", { features: { pages: false, sites: false } });121 expect((await lookup(host, `/e/${note.id}`)).body).toEqual({});122 expect((await lookup(host, "/")).body["/"]).toBeDefined();123 c.ws.close();124 });125126 it("answers explicit discovery before content negotiation with CORS, HEAD and narrow preflight", async () => {127 const host = "ad-http.bind.ws", owner = generateSecretKey();128 await rpc(host, owner, "claim");129 const url = endpoint(host, "/");130 const res = await SELF.fetch(url, { headers: { accept: "application/nostr+json" } });131 expect((await res.json<Record<string, unknown>>())["/"]).toBeDefined();132 expect(res.headers.get("cache-control")).toContain("no-store");133 expect(res.headers.get("access-control-allow-origin")).toBe("*");134 for (const method of ["HEAD", "OPTIONS"]) {135 const r = await SELF.fetch(url, { method });136 expect(r.status).toBe(200);137 expect(await r.text()).toBe("");138 expect(r.headers.get("access-control-allow-headers")).toContain("authorization");139 }140 for (const method of ["POST", "PUT", "DELETE"]) expect((await SELF.fetch(url, { method })).status).toBe(405);141 const bad = await SELF.fetch(url, { headers: { authorization: "Nostr broken" } });142 expect(bad.status).toBe(401);143 expect(await bad.json()).toEqual({});144 });145146 it("rejects malformed, oversized and external path inputs without resolving another URL", async () => {147 const host = "ad-paths.bind.ws", owner = generateSecretKey();148 await rpc(host, owner, "claim");149 for (const path of ["", "a", "https://example.com/a", "//example.com/a", "/a?x=1", "/a#one", "/a/%", "/a/%ff", "/a/../secret", "/a/%2e%2e/secret", "/a\\secret", "/a/%5csecret", "/a/%00", "/a\n", "/" + "a".repeat(4096)]) {150 expect(await lookup(host, path), path).toEqual({ status: 400, body: {} });151 }152 expect((await SELF.fetch(endpoint(host, "/") + "&path=/a/other")).status).toBe(400);153 expect((await SELF.fetch(`http://${host}/a/%`)).status).toBe(404);154 });155156 it("uses the routed custom relay host and preserves local WebSocket schemes and ports", async () => {157 const owner = generateSecretKey();158 await rpc("ad-alias.bind.ws", owner, "claim");159 await env.HOSTS.put("relay.ad-example.test", "ad-alias");160 expect((await lookup("relay.ad-example.test", "/")).body["/"].relays).toEqual(["wss://relay.ad-example.test"]);161 await rpc("ad-local.localhost:8787", owner, "claim");162 expect((await lookup("ad-local.localhost:8787", "/")).body["/"].relays).toEqual(["ws://ad-local.localhost:8787"]);163 });164165 it("omits unclaimed or expired relays and refuses blocked IP addresses", async () => {166 expect((await lookup("ad-unclaimed.bind.ws", "/")).body).toEqual({});167 const host = "ad-blocked.bind.ws", owner = generateSecretKey();168 await rpc(host, owner, "claim");169 expect((await rpc(host, owner, "blockip", "203.0.113.55", "scraper")).status).toBe(200);170 expect((await SELF.fetch(endpoint(host, "/"), { headers: { "cf-connecting-ip": "203.0.113.55" } })).status).toBe(403);171 await runInDurableObject(env.RELAY.getByName("ad-blocked"), (relay) => {172 relay.settings.update({ owner: "", lease: { until: now() - 1, holder: "" } });173 });174 expect((await lookup(host, "/")).body).toEqual({});175 });176});