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 / connections.test.ts
connections.test.ts 22.4K
1// The Connect fold's shortcuts (connections.ts): the door that resolves2// them for whoever asks, the management methods that keep the owner's3// list, and the list travelling with configurations and presets. The pure4// parts are test/unit/connections.test.ts. Templates past notes, find-me5// and group are picked by what they declare, so the library can grow6// without this file naming each one.7import { SELF } from "cloudflare:test";8import { describe, it, expect } from "vitest";9import { generateSecretKey } from "nostr-tools/pure";10import { npubEncode, nprofileEncode } from "nostr-tools/nip19";11import { CONNECTION_TEMPLATES, fill, type ConnectionTemplate, type Values } from "../../src/connections.ts";12import { DEFAULT_CONNECTIONS, type Feature } from "../../src/settings.ts";13import { PRESETS } from "../../src/presets.ts";14import { rpc, get, pk, nip98 } from "../helpers/relay.ts";1516// connect reads the door, signed when a key is given, with the cache header along.17async function connect(host: string, sk: Uint8Array | null = null) {18 const r = await get(host, "/connect.json", sk);19 return { status: r.status, cache: r.headers.get("cache-control"), cors: r.headers.get("access-control-allow-origin"), ...(await r.json<any>()) };20}2122// valuesOf is what the relay is expected to fill in, from its card and the23// keys, so a template's links can be resolved here and compared.24async function valuesOf(host: string, owner: Uint8Array | null, user: Uint8Array | null): Promise<Values> {25 const card: any = await (await get(host, "/card.json")).json();26 const url = `wss://${host}`;27 const self = card.self ?? "";28 const profile = (p: string) => (p ? nprofileEncode({ pubkey: p, relays: [url] }) : "");29 const npub = (p: string) => (p ? npubEncode(p) : "");30 const o = owner ? pk(owner) : "";31 const u = user ? pk(user) : "";32 return {33 relay: { url, host, web: `https://${host}`, name: host.split(".")[0], domain: "bind.ws", hex: self, npub: npub(self), nprofile: profile(self), naddr: card.naddr ?? "" },34 owner: { hex: o, npub: npub(o), nprofile: profile(o) },35 user: user ? { hex: u, npub: npub(u), nprofile: profile(u) } : null,36 };37}3839// resolved is a template as the fold should show it: the links that fill40// in for these values, the inputs the owner set or the template's defaults.41function resolved(t: ConnectionTemplate, values: Values, inputs: Record<string, string> = {}) {42 const filled: Record<string, string> = {};43 for (const i of t.inputs) filled[i.name] = inputs[i.name] || i.default;44 const links: { label: string; href?: string; copy?: string }[] = [];45 let needsUser = false;46 for (const l of t.links) {47 const r = fill(l.href ?? l.copy ?? "", values, filled);48 if (r.missing.length) {49 if (r.missing.some((m) => m.startsWith("user:"))) needsUser = true;50 continue;51 }52 links.push(l.href ? { label: l.label, href: r.text } : { label: l.label, copy: r.text });53 }54 const qr = t.qr ? fill(t.qr, values, filled) : { text: "", missing: [] as string[] };55 return { links, needsUser, qr: qr.missing.length ? "" : qr.text };56}5758const template = (name: string) => CONNECTION_TEMPLATES.find((t) => t.name === name)!;59const names = (answer: { connections: { template: string }[] }) => answer.connections.map((c) => c.template);60const text = (l: { href?: string; copy?: string }) => l.href ?? l.copy ?? "";61const namesUser = (l: { href?: string; copy?: string }) => text(l).includes("{user:");62// everyInput fills each of a template's inputs, so no link is left out for want of one.63const everyInput = (t: ConnectionTemplate) => Object.fromEntries(t.inputs.map((i) => [i.name, "mine"]));6465// turnOn switches a template's feature on; the GRASP extensions need GRASP66// itself, and GRASP's own doors want open reads.67async function turnOn(host: string, owner: Uint8Array, f: Feature | undefined) {68 if (!f) return;69 const features: Record<string, boolean> = { [f]: true };70 if (f.startsWith("grasp0")) features.grasp = true;71 if (f === "grasp03" || f === "grasp05") features.grasp02 = true;72 expect((await rpc(host, owner, "setpolicy", { reads: "open", features })).status).toBe(200);73}7475describe("connections", () => {76 it("answers a visitor with the defaults, every link filled in for this relay, cacheable for a minute", async () => {77 const host = "connect.bind.ws";78 const owner = generateSecretKey();79 await rpc(host, owner, "claim");80 const card: any = await (await get(host, "/card.json")).json();81 expect(card.naddr).toBeDefined();82 expect(card.nprofile).toBeDefined();83 const values = await valuesOf(host, owner, null);84 const r = await connect(host);85 expect(r.status).toBe(200);86 expect(r.cache).toBe("public, max-age=60");87 expect(r.cors).toBe("*");88 expect(r.relay).toEqual({ url: "wss://connect.bind.ws", host, web: "https://connect.bind.ws", name: "connect" });89 expect(r.viewer).toBeNull();90 expect(names(r)).toEqual(DEFAULT_CONNECTIONS.map((c) => c.template));91 expect((await rpc(host, owner, "listconnections")).result).toEqual(DEFAULT_CONNECTIONS);92 for (const c of r.connections) {93 const t = template(c.template);94 const want = resolved(t, values);95 expect(c, c.template).toEqual({ template: t.name, title: t.title, about: t.about, app: t.app, where: t.where, icon: t.icon, visibility: "public", links: want.links, qr: want.qr, needsUser: false });96 }97 // The group address and the owner's profile are the card's, |enc98 // percent-encodes the relay URL, and no placeholder is left.99 const texts = r.connections.flatMap((c: any) => c.links.map(text));100 expect(texts.some((x: string) => x.includes(card.naddr))).toBe(true);101 expect(texts.some((x: string) => x.includes(card.nprofile))).toBe(true);102 expect(texts.some((x: string) => x.includes(encodeURIComponent("wss://" + host)))).toBe(true);103 // The fold has one relay URL copy control of its own, so no template carries the plain URL as a copy text.104 expect(r.connections.flatMap((c: any) => c.links.filter((l: any) => l.copy).map((l: any) => l.copy))).not.toContain("wss://" + host);105 for (const x of texts) expect(x).not.toMatch(/[{}]/);106 expect(r.connections.find((c: any) => c.template === "find-me").qr).toBe("nostr:" + card.nprofile);107 expect(r.connections.find((c: any) => c.template === "group").qr).toBe("nostr:" + card.naddr);108 });109110 it("names a signed viewer, keeps that answer out of shared caches, and refuses a signature that does not fit", async () => {111 const host = "connect-signed.bind.ws";112 const owner = generateSecretKey();113 const viewer = generateSecretKey();114 await rpc(host, owner, "claim");115 const r = await connect(host, viewer);116 expect(r.status).toBe(200);117 expect(r.cache).toBe("no-store");118 expect(r.viewer).toBe(pk(viewer));119 expect(names(r)).toEqual(DEFAULT_CONNECTIONS.map((c) => c.template));120 const bad = await SELF.fetch(`http://${host}/connect.json`, { headers: { authorization: "Nostr nonsense" } });121 expect(bad.status).toBe(401);122 expect((await bad.json<any>()).error).toMatch(/^auth-required/);123 // A signature for another door does not count as a visitor either.124 const elsewhere = await SELF.fetch(`http://${host}/connect.json`, { headers: { authorization: await nip98(viewer, `http://${host}/card.json`) } });125 expect(elsewhere.status).toBe(401);126 });127128 it("leaves out the owner's links on a relay nobody owns yet, and a shortcut with nothing else, without asking anyone to sign in", async () => {129 const host = "connect-nobody.bind.ws";130 const r = await connect(host);131 expect(r.status).toBe(200);132 expect(r.viewer).toBeNull();133 // Every link of find-me names the owner, so the whole shortcut waits for one.134 const t = template("find-me");135 expect(t.links.every((l) => text(l).includes("{owner:"))).toBe(true);136 expect(r.connections.find((x: any) => x.template === "find-me")).toBeUndefined();137 // Notes names only the relay, so it shows whole; the group shows what does not need an identity.138 const notes = r.connections.find((x: any) => x.template === "notes");139 expect(notes).toBeDefined();140 expect(notes.needsUser).toBe(false);141 expect(notes.links.map((l: any) => l.label)).toEqual(template("notes").links.map((l) => l.label));142 for (const x of r.connections) {143 expect(x.needsUser).toBe(false);144 for (const l of x.links) expect(text(l)).not.toMatch(/[{}]/);145 }146 });147148 it("shows each shortcut to whom its visibility admits, with the owner's title and about in place of the template's", async () => {149 const host = "connect-who.bind.ws";150 const owner = generateSecretKey();151 const member = generateSecretKey();152 const outsider = generateSecretKey();153 await rpc(host, owner, "claim");154 await rpc(host, owner, "setmember", pk(member), { name: "eve" });155 const list = [156 { template: "notes", visibility: "public", title: "Our feed", about: "What we post, as one feed." },157 { template: "notes", visibility: "auth", title: "Signed in" },158 { template: "group", visibility: "members", title: "Members" },159 { template: "find-me", visibility: "owner", title: "Owner" },160 ];161 const saved = await rpc(host, owner, "setconnections", list);162 expect(saved.status, JSON.stringify(saved)).toBe(200);163 expect(saved.result).toEqual(list);164 const titles = async (sk: Uint8Array | null) => (await connect(host, sk)).connections.map((c: any) => c.title);165 expect(await titles(null)).toEqual(["Our feed"]);166 expect(await titles(outsider)).toEqual(["Our feed", "Signed in"]);167 expect(await titles(member)).toEqual(["Our feed", "Signed in", "Members"]);168 expect(await titles(owner)).toEqual(["Our feed", "Signed in", "Members", "Owner"]);169 const mine = (await connect(host, owner)).connections;170 expect(mine[0].about).toBe("What we post, as one feed.");171 expect(mine[0].app).toBe(template("notes").app);172 expect(mine[0].icon).toBe(template("notes").icon);173 expect(mine[1].about).toBe(template("notes").about);174 expect(mine.map((c: any) => c.visibility)).toEqual(["public", "auth", "members", "owner"]);175 });176177 it("shows the defaults until the owner saves a list, nothing after an empty one, and the saved order", async () => {178 const host = "connect-order.bind.ws";179 const owner = generateSecretKey();180 await rpc(host, owner, "claim");181 expect(names(await connect(host))).toEqual(["notes", "find-me", "group"]);182 let r = await rpc(host, owner, "setconnections", [{ template: "group" }, { template: "notes" }]);183 expect(r.status).toBe(200);184 expect(r.result).toEqual([{ template: "group", visibility: "public" }, { template: "notes", visibility: "public" }]);185 expect((await rpc(host, owner, "listconnections")).result).toEqual(r.result);186 expect(names(await connect(host))).toEqual(["group", "notes"]);187 r = await rpc(host, owner, "setconnections", []);188 expect(r.status).toBe(200);189 expect(r.result).toEqual([]);190 expect((await rpc(host, owner, "listconnections")).result).toEqual([]);191 expect((await connect(host)).connections).toEqual([]);192 expect((await rpc(host, owner, "exportconfig")).result.connections).toEqual([]);193 });194195 it("refuses a list that does not fit, whole, to anyone but the owner, and logs the one it saved", async () => {196 const host = "connect-refuse.bind.ws";197 const owner = generateSecretKey();198 const mod = generateSecretKey();199 await rpc(host, owner, "claim");200 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });201 const before = (await rpc(host, owner, "listconnections")).result;202 let r = await rpc(host, owner, "setconnections", [{ template: "notes" }, { template: "nope" }]);203 expect(r.status).toBe(400);204 expect(r.error).toBe("invalid: connections[1]: no connection template named nope");205 r = await rpc(host, owner, "setconnections", [{ template: "notes", visibility: "friends" }]);206 expect(r.status).toBe(400);207 expect(r.error).toBe("invalid: connections[0].visibility: must be one of public, auth, members, owner");208 r = await rpc(host, owner, "setconnections", [{ template: "notes", inputs: { repo: "x" } }]);209 expect(r.status).toBe(400);210 expect(r.error).toBe("invalid: connections[0].inputs.repo: notes has no such input");211 r = await rpc(host, owner, "setconnections", [{ template: "repos", inputs: { repo: "kid's-project" } }]);212 expect(r.status).toBe(400);213 expect(r.error).toMatch(/^invalid: connections\[0\]\.inputs\.repo: must match /);214 r = await rpc(host, owner, "setconnections", { template: "notes" });215 expect(r.status).toBe(400);216 expect(r.error).toBe("invalid: connections must be a list");217 expect((await rpc(host, owner, "setconnections")).status).toBe(400);218 expect((await rpc(host, mod, "setconnections", [])).status).toBe(403);219 expect((await rpc(host, null, "setconnections", [])).status).toBe(401);220 expect((await rpc(host, owner, "listconnections")).result).toEqual(before);221 expect((await rpc(host, mod, "listconnections")).status).toBe(200);222 expect((await rpc(host, mod, "listconnectiontemplates")).status).toBe(200);223 // The moderation log has the saved list and none of the refusals.224 const list = [{ template: "group", visibility: "members", title: "Room" }];225 expect((await rpc(host, owner, "setconnections", list)).status).toBe(200);226 const audit = (await rpc(host, owner, "listaudit")).result as { actor: string; action: string; detail: string }[];227 expect(audit[0].action).toBe("setconnections");228 expect(audit[0].actor).toBe(pk(owner));229 expect(JSON.parse(audit[0].detail)).toEqual(list);230 expect(audit.filter((row) => row.action === "setconnections").length).toBe(1);231 });232233 const featured = CONNECTION_TEMPLATES.find((t) => t.feature);234 it.skipIf(!featured)("lists the library with whether each template's feature is on, and skips a shortcut whose feature is off", async () => {235 const host = "connect-feature.bind.ws";236 const owner = generateSecretKey();237 await rpc(host, owner, "claim");238 const t = featured!;239 const f = t.feature!;240 expect((await rpc(host, owner, "setpolicy", { features: { [f]: false } })).status).toBe(200);241 let lib = (await rpc(host, owner, "listconnectiontemplates")).result as (ConnectionTemplate & { available: boolean })[];242 expect(lib.map((x) => x.name)).toEqual(CONNECTION_TEMPLATES.map((x) => x.name));243 expect(lib.find((x) => x.name === "notes")).toEqual({ ...template("notes"), available: true });244 expect(lib.find((x) => x.name === t.name)!.available).toBe(false);245 for (const x of lib) if (!x.feature) expect(x.available, x.name).toBe(true);246 expect((await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: everyInput(t) }, { template: "notes" }])).status).toBe(200);247 expect(names(await connect(host, owner))).toEqual(["notes"]);248 await turnOn(host, owner, f);249 lib = (await rpc(host, owner, "listconnectiontemplates")).result;250 expect(lib.find((x) => x.name === t.name)!.available).toBe(true);251 expect(names(await connect(host, owner))).toEqual([t.name, "notes"]);252 });253254 const personal = CONNECTION_TEMPLATES.find((t) => t.links.some((l) => text(l).includes("{user:npub")));255 it.skipIf(!personal)("fills {user:*} with whoever signed the request, and for a visitor leaves those links out and says so", async () => {256 const host = "connect-user.bind.ws";257 const owner = generateSecretKey();258 const viewer = generateSecretKey();259 await rpc(host, owner, "claim");260 const t = personal!;261 await turnOn(host, owner, t.feature);262 const inputs = everyInput(t);263 expect((await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs }])).status).toBe(200);264 const visitor = (await connect(host)).connections;265 expect(visitor.length).toBe(1);266 expect(visitor[0].needsUser).toBe(true);267 expect(visitor[0].links).toEqual(resolved(t, await valuesOf(host, owner, null), inputs).links);268 expect(visitor[0].links.map((l: any) => l.label)).toEqual(t.links.filter((l) => !namesUser(l)).map((l) => l.label));269 for (const l of visitor[0].links) expect(text(l)).not.toContain("{");270 // Signed in: the viewer's own key, not the owner's, and nothing missing.271 const mine = (await connect(host, viewer)).connections;272 expect(mine.length).toBe(1);273 expect(mine[0].needsUser).toBe(false);274 expect(mine[0].links).toEqual(resolved(t, await valuesOf(host, owner, viewer), inputs).links);275 expect(mine[0].links.map((l: any) => l.label)).toEqual(t.links.map((l) => l.label));276 const own = t.links.find((l) => text(l).includes("{user:npub"))!;277 const link = mine[0].links.find((l: any) => l.label === own.label)!;278 expect(text(link)).toContain(npubEncode(pk(viewer)));279 expect(text(link)).not.toContain(npubEncode(pk(owner)));280 });281282 const withInput = CONNECTION_TEMPLATES.find((t) => t.inputs.length > 0);283 it.skipIf(!withInput)("puts the owner's input into the link, and the template's default when the input is blank", async () => {284 const host = "connect-input.bind.ws";285 const owner = generateSecretKey();286 await rpc(host, owner, "claim");287 const t = withInput!;288 await turnOn(host, owner, t.feature);289 const i = t.inputs[0];290 const named = t.links.find((l) => new RegExp(`\\{input:${i.name}(\\|enc)?\\}`).test(text(l)))!;291 expect(named, `${t.name} has a link naming {input:${i.name}}`).toBeDefined();292 let r = await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: { [i.name]: " rocket-7 " } }]);293 expect(r.status, JSON.stringify(r)).toBe(200);294 expect(r.result[0].inputs).toEqual({ [i.name]: "rocket-7" });295 let c = (await connect(host, owner)).connections[0];296 expect(c.template).toBe(t.name);297 expect(text(c.links.find((l: any) => l.label === named.label)!)).toContain("rocket-7");298 expect(c.links).toEqual(resolved(t, await valuesOf(host, owner, owner), { [i.name]: "rocket-7" }).links);299 // Blank: not kept, and the template's default fills the link.300 r = await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: { [i.name]: " " } }]);301 expect(r.status).toBe(200);302 expect(r.result[0].inputs).toBeUndefined();303 c = (await connect(host, owner)).connections[0];304 expect(c.links).toEqual(resolved(t, await valuesOf(host, owner, owner), {}).links);305 const fallback = c.links.find((l: any) => l.label === named.label);306 if (i.default) expect([i.default, encodeURIComponent(i.default)].some((d) => text(fallback!).includes(d))).toBe(true);307 else expect(fallback).toBeUndefined();308 });309310 it("travels with the configuration: exported, planned as one summary line with warnings, applied, and left alone when the document has no section", async () => {311 const a = "connect-cfg-a.bind.ws", b = "connect-cfg-b.bind.ws";312 const owner = generateSecretKey();313 await rpc(a, owner, "claim");314 await rpc(b, owner, "claim");315 const mine = [{ template: "group", visibility: "members", title: "Room" }, { template: "notes", visibility: "public" }];316 expect((await rpc(a, owner, "setconnections", mine)).status).toBe(200);317 const cfg = (await rpc(a, owner, "exportconfig")).result;318 expect(cfg.connections).toEqual(mine);319 cfg.connections.push({ template: "nope" });320 let dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result;321 expect(dry.warnings).toEqual(["connections[2]: no connection template named nope"]);322 expect(dry.changes.connections).toEqual({ add: [mine[0]], remove: [{ template: "find-me", visibility: "public" }, { template: "group", visibility: "public" }], reordered: false });323 expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: +group (members), -find-me, -group"]);324 expect((await rpc(b, owner, "listconnections")).result).toEqual(DEFAULT_CONNECTIONS);325 expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);326 expect((await rpc(b, owner, "listconnections")).result).toEqual(mine);327 expect(names(await connect(b, owner))).toEqual(["group", "notes"]);328 // No section: the list stays. The same list in another order is a reorder. An empty list clears it.329 delete cfg.connections;330 expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);331 expect((await rpc(b, owner, "listconnections")).result).toEqual(mine);332 cfg.connections = [mine[1], mine[0]];333 dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result;334 expect(dry.changes.connections).toEqual({ add: [], remove: [], reordered: true });335 expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: reordered"]);336 expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);337 expect((await rpc(b, owner, "listconnections")).result).toEqual([mine[1], mine[0]]);338 cfg.connections = [];339 dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result;340 expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: -notes, -group (members)"]);341 expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);342 expect((await rpc(b, owner, "listconnections")).result).toEqual([]);343 expect((await rpc(b, owner, "importconfig", { format: cfg.format, connections: "notes" })).status).toBe(400);344 });345346 it("is set by a preset that has a connections section and left alone by one that has not", async () => {347 const host = "connect-presets.bind.ws";348 const owner = generateSecretKey();349 await rpc(host, owner, "claim");350 const mine = [{ template: "group", visibility: "members", title: "Room" }];351 expect((await rpc(host, owner, "setconnections", mine)).status).toBe(200);352 const plain = PRESETS.find((p) => !p.connections)!;353 expect(plain).toBeDefined();354 expect((await rpc(host, owner, "applypreset", plain.name, plain.source === "required" ? { source: "wss://elsewhere.bind.ws" } : undefined)).status).toBe(200);355 expect((await rpc(host, owner, "listconnections")).result).toEqual(mine);356 for (const p of PRESETS.filter((p) => p.connections)) {357 const r = await rpc(host, owner, "applypreset", p.name, p.source === "required" ? { source: "wss://elsewhere.bind.ws" } : undefined);358 expect(r.status, p.name + " " + JSON.stringify(r)).toBe(200);359 expect((await rpc(host, owner, "listconnections")).result, p.name).toEqual(p.connections);360 expect((await rpc(host, owner, "exportconfig")).result.connections, p.name).toEqual(p.connections);361 }362 const listed = (await rpc(host, owner, "listpresets")).result as { name: string; connections?: unknown }[];363 for (const x of listed) expect(x.connections, x.name).toEqual(PRESETS.find((p) => p.name === x.name)?.connections);364 });365});