1// Pages, the feed, and relay-signed notifications.2import { SELF, env, runInDurableObject } from "cloudflare:test";3import { describe, it, expect } from "vitest";4import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";5import { unwrapEvent } from "nostr-tools/nip59";6import * as nip19 from "nostr-tools/nip19";7import type { Relay } from "../../src/relay.ts";8import { now, ev, rpc, info } from "../helpers/relay.ts";9import { WS } from "../helpers/ws.ts";1011const get = async (host: string, path: string) => {12 const r = await SELF.fetch(`http://${host}${path}`);13 return { status: r.status, type: r.headers.get("content-type") ?? "", text: await r.text() };14};1516describe("pages and feed", () => {17 it("renders notes and articles with open graph tags, hides what must stay hidden", async () => {18 const host = "site.bind.ws";19 const owner = generateSecretKey();20 const pk = getPublicKey(owner);21 await rpc(host, owner, "claim");22 await rpc(host, owner, "setpolicy", { name: "Alice's place" });23 const c = await WS.connect(host);24 expect((await c.ok(ev(owner, 0, JSON.stringify({ name: "alice", display_name: "Alice" })))).ok).toBe(true);25 const note = ev(owner, 1, "Hello from my own relay\nWith a picture https://pics.example/cat.png and a link https://example.com/page.");26 expect((await c.ok(note)).ok).toBe(true);27 const article = ev(owner, 30023, "First paragraph of the essay.\n\nSecond paragraph, longer, with some thought in it.", [["d", "why-relays"], ["title", "Why relays"], ["summary", "A short case for owning one."], ["image", "https://pics.example/hero.jpg"]]);28 expect((await c.ok(article)).ok).toBe(true);29 const gone = ev(owner, 1, "to be deleted");30 expect((await c.ok(gone)).ok).toBe(true);31 expect((await c.ok(ev(owner, 5, "", [["e", gone.id]]))).ok).toBe(true);32 const dm = ev(owner, 4, "secret", [["p", getPublicKey(generateSecretKey())]]);33 expect((await c.ok(dm)).ok).toBe(true);3435 let r = await get(host, `/e/${note.id}`);36 expect(r.status).toBe(200);37 expect(r.type).toMatch(/text\/html/);38 expect(r.text).toContain('<meta property="og:title" content="Hello from my own relay">');39 expect(r.text).toContain('<meta property="og:image" content="https://pics.example/cat.png">');40 expect(r.text).toContain('<meta name="twitter:card" content="summary_large_image">');41 // The host is under the service domain, so the page addresses itself over https.42 expect(r.text).toContain(`<link rel="canonical" href="https://${host}/e/${note.id}">`);43 expect(r.text).toContain('<link rel="alternate" type="application/atom+xml"');44 expect(r.text).toContain("<b>Alice</b>");45 expect(r.text).toContain('<img src="https://pics.example/cat.png"');46 expect(r.text).toContain('<a href="https://example.com/page" rel="noopener nofollow">');47 expect(r.text).toContain("nostr:nevent1");48 expect(r.text).not.toContain("<script");4950 r = await get(host, "/a/why-relays");51 expect(r.status).toBe(200);52 expect(r.text).toContain("<h1>Why relays</h1>");53 expect(r.text).toContain('<meta property="og:description" content="A short case for owning one.">');54 expect(r.text).toContain('<img class="hero" src="https://pics.example/hero.jpg"');55 expect(r.text).toContain("<p>Second paragraph, longer, with some thought in it.</p>");56 expect(r.text).toContain("nostr:naddr1");57 r = await get(host, `/a/${nip19.npubEncode(pk)}/why-relays`);58 expect(r.status).toBe(200);5960 expect((await get(host, `/e/${"0".repeat(64)}`)).status).toBe(404);61 expect((await get(host, `/e/${gone.id}`)).status).toBe(404);62 expect((await get(host, `/e/${dm.id}`)).status).toBe(404);63 expect((await get(host, "/a/nope")).status).toBe(404);6465 // A note that references the article links to it here.66 const ref = ev(owner, 1, "Read this: nostr:" + nip19.naddrEncode({ kind: 30023, pubkey: pk, identifier: "why-relays" }) + " by nostr:" + nip19.npubEncode(pk));67 expect((await c.ok(ref)).ok).toBe(true);68 r = await get(host, `/e/${ref.id}`);69 expect(r.text).toContain('<a href="/a/why-relays">Why relays</a>');70 expect(r.text).toContain("@Alice");7172 // The feed lists both, newest first, and filters by kind.73 r = await get(host, "/feed.xml");74 expect(r.status).toBe(200);75 expect(r.type).toMatch(/application\/atom\+xml/);76 expect(r.text.match(/<entry>/g)?.length).toBe(3);77 expect(r.text).toContain("<title>Why relays</title>");78 expect(r.text).toContain(`<link href="https://${host}/a/why-relays"/>`);79 expect(r.text).toContain("<name>Alice</name>");80 r = await get(host, "/feed.xml?kinds=30023");81 expect(r.text.match(/<entry>/g)?.length).toBe(1);82 r = await get(host, `/feed.xml?author=${"1".repeat(64)}`);83 expect(r.text).not.toContain("<entry>");8485 // Members-only reads: nothing renders.86 await rpc(host, owner, "setpolicy", { reads: "members" });87 expect((await get(host, `/e/${note.id}`)).status).toBe(404);88 expect((await get(host, "/a/why-relays")).status).toBe(404);89 expect((await get(host, "/feed.xml")).status).toBe(404);90 });9192 it("renders nothing on an unclaimed relay", async () => {93 expect((await get("nobody.bind.ws", "/feed.xml")).status).toBe(404);94 expect((await get("nobody.bind.ws", `/e/${"a".repeat(64)}`)).status).toBe(404);95 });96});9798describe("relay-signed notifications", () => {99 it("wraps a report for the owner, who alone can read it", async () => {100 const host = "inbox.bind.ws";101 const owner = generateSecretKey();102 const ownerPk = getPublicKey(owner);103 const member = generateSecretKey();104 const stranger = generateSecretKey();105 await rpc(host, owner, "claim");106 await rpc(host, owner, "setmember", getPublicKey(member), {});107 expect((await rpc(host, owner, "getpolicy")).result.notify).toEqual({ reports: false, fuel: false, jobs: false, succession: false, digest: false });108109 // Off by default: a report stores nothing.110 const s = await WS.connect(host);111 expect((await s.ok(ev(stranger, 1984, "spam", [["p", getPublicKey(member), "spam"]]))).ok).toBe(true);112 const o = await WS.connect(host);113 await o.auth(owner, host);114 expect((await o.open("inbox", { kinds: [1059], "#p": [ownerPk] })).events).toEqual([]);115116 const set = await rpc(host, owner, "setpolicy", { notify: { reports: true } });117 expect(set.result.notify).toEqual({ reports: true, fuel: false, jobs: false, succession: false, digest: false });118 const m = await WS.connect(host);119 await m.auth(member, host);120 expect((await m.open("mine", { kinds: [1059] })).events).toEqual([]);121122 expect((await s.ok(ev(stranger, 1984, "still spam", [["p", getPublicKey(member), "spam"]]))).ok).toBe(true);123 const got = await o.expect("EVENT");124 const wrap = got[2] as Event;125 expect(wrap.kind).toBe(1059);126 expect(wrap.tags).toContainEqual(["p", ownerPk]);127 const rumor = unwrapEvent(wrap, owner);128 expect(rumor.kind).toBe(14);129 expect(rumor.pubkey).toBe((await info(host)).self);130 expect(rumor.content).toMatch(/report/i);131 expect(rumor.content).toContain("still spam");132 expect(rumor.tags).toContainEqual(["subject", "a report on inbox"]);133 expect(await m.recvOr(300)).toBeNull();134135 // Stored: the owner can fetch it later; nobody else can see it.136 const o2 = await WS.connect(host);137 await o2.auth(owner, host);138 expect((await o2.open("again", { kinds: [1059] })).events.map((e) => e.id)).toEqual([wrap.id]);139 const anon = await WS.connect(host);140 anon.send("REQ", "anon", { kinds: [1059] });141 const closed = await anon.expect("CLOSED");142 expect(closed[2]).toMatch(/^auth-required/);143144 // The test button always sends; a member may not press it.145 expect((await rpc(host, owner, "notifytest")).result).toEqual({ sent: true });146 expect((await rpc(host, member, "notifytest")).status).toBe(403);147 expect(unwrapEvent((await o.expect("EVENT"))[2] as Event, owner).content).toMatch(/test/i);148149 // The catch-all keep-for rule leaves the inbox alone.150 await rpc(host, owner, "setretention", null, 1);151 await runInDurableObject(env.RELAY.getByName("inbox"), async (r: Relay) => {152 expect(r.settings.retentionDays(1059)).toBe(0);153 expect(r.settings.retentionDays(1)).toBe(1);154 });155 });156157 it("says fuel is low once, then not again the same day", async () => {158 const host = "lowfuel.bind.ws";159 const owner = generateSecretKey();160 await rpc(host, owner, "claim");161 await rpc(host, owner, "setpolicy", { notify: { fuel: true } });162 const stub = env.RELAY.getByName("lowfuel");163 const wraps = (r: Relay) => r.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM events WHERE kind=1059`).one().n;164 await runInDurableObject(stub, async (r: Relay) => {165 await r.alarm();166 expect(wraps(r)).toBe(0);167 r.fuel.record(now(), { rowsWritten: 1_200_000 });168 expect(r.fuelStatus().outOfFuel).toBe(true);169 await r.alarm();170 expect(wraps(r)).toBe(1);171 await r.alarm();172 expect(wraps(r)).toBe(1);173 });174 const o = await WS.connect(host);175 await o.auth(owner, host);176 const [wrap] = (await o.open("fuel", { kinds: [1059] })).events;177 expect(unwrapEvent(wrap, owner).content).toMatch(/out of fuel/);178 });179});