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 / views.test.ts
views.test.ts 12.2K
1// Views: folds the relay signs as kind 30078 records, and presence as an2// ephemeral kind 20078 from memory.3import { SELF, env, runInDurableObject } from "cloudflare:test";4import { describe, it, expect } from "vitest";5import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6import { getToken } from "nostr-tools/nip98";7import type { Relay } from "../../src/relay.ts";8import { viewD } from "../../src/views.ts";9import { now, ev, tagsOf, rpc, info, alarm } from "../helpers/relay.ts";10import { WS } from "../helpers/ws.ts";11import { KIND_VIEW, KIND_PRESENCE } from "../../src/kinds.ts";1213async function view(host: string, name: string, sk: Uint8Array | null = null) {14 const url = `http://${host}/view/${name}`;15 const headers: Record<string, string> = {};16 if (sk) headers.authorization = await getToken(url, "GET", (e) => finalizeEvent(e, sk), true);17 const resp = await SELF.fetch(url, { headers });18 return { status: resp.status, body: (await resp.json()) as any };19}2021// A relay with an owner, a member with a profile and a relay list, a22// calendar event with an RSVP, two articles and a zap receipt.23async function seed(name: string) {24 const host = `${name}.bind.ws`;25 const owner = generateSecretKey();26 const alice = generateSecretKey();27 await rpc(host, owner, "claim");28 await rpc(host, owner, "setmember", getPublicKey(alice), { name: "alice" });29 const c = await WS.connect(host);30 await c.auth(alice, host);31 const put = async (e: Event) => expect((await c.ok(e)).msg).toBe("");32 await put(ev(alice, 0, JSON.stringify({ name: "Alice", picture: "https://x/a.png" })));33 await put(ev(alice, 10002, "", [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol/"]]));34 await put(ev(owner, 10002, "", [["r", "wss://nos.lol"]]));35 const cal = ev(alice, 31923, "", [["d", "picnic"], ["title", "Picnic"], ["start", String(now() + 3 * 86400)]]);36 await put(cal);37 await put(ev(owner, 31925, "", [["d", "r1"], ["a", `31923:${getPublicKey(alice)}:picnic`], ["status", "accepted"], ["p", getPublicKey(alice)]]));38 const a1 = ev(alice, 30023, "words", [["d", "one"], ["title", "One"], ["published_at", "1700000000"]], now() - 100);39 const a2 = ev(alice, 30023, "more words", [["d", "two"], ["title", "Two"]]);40 await put(a1);41 await put(a2);42 const payer = generateSecretKey();43 const zapReq = ev(payer, 9734, "", [["p", getPublicKey(alice)], ["e", a1.id], ["relays", "wss://" + host], ["amount", "1000000"]]);44 await put(ev(generateSecretKey(), 9735, "", [["p", getPublicKey(alice)], ["e", a1.id], ["bolt11", "lnbc10u1abc"], ["description", JSON.stringify(zapReq)]]));45 return { host, owner, alice, c, a1, a2, cal };46}4748describe("views", () => {49 it("publishes the six stored views from the alarm, signed by the relay", async () => {50 const s = await seed("views");51 await rpc(s.host, s.owner, "banpubkey", getPublicKey(generateSecretKey()), "spam");52 await alarm("views");53 const doc = await info(s.host);54 expect(doc.views.map((v: any) => v.name)).toEqual(["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"]);55 expect(doc.views.find((v: any) => v.name === "presence").kind).toBe(KIND_PRESENCE);5657 const profiles = (await view(s.host, "profiles")).body as Event;58 expect(profiles.kind).toBe(KIND_VIEW);59 expect(profiles.pubkey).toBe(doc.self);60 expect(tagsOf(profiles, "d")[0][1]).toBe(viewD("profiles"));61 const ps = tagsOf(profiles, "p");62 expect(ps[0][1]).toBe(getPublicKey(s.owner));63 expect(ps[1]).toEqual(["p", getPublicKey(s.alice), "Alice", "https://x/a.png", "alice@views.bind.ws"]);6465 const relays = (await view(s.host, "relays")).body as Event;66 expect(tagsOf(relays, "r")).toEqual([["r", "wss://nos.lol", "2"], ["r", "wss://relay.damus.io", "1"]]);6768 const calendar = (await view(s.host, "calendar")).body as Event;69 const a = tagsOf(calendar, "a");70 expect(a.length).toBe(1);71 expect(a[0][1]).toBe(`31923:${getPublicKey(s.alice)}:picnic`);72 expect(a[0][3]).toBe("Picnic");73 expect(a[0][4]).toBe("1");7475 const moderation = (await view(s.host, "moderation")).body as Event;76 const counts = JSON.parse(moderation.content);77 expect(counts.bans).toBe(1);78 expect(counts.month).toBe(new Date().toISOString().slice(0, 7));7980 const articles = (await view(s.host, "articles")).body as Event;81 expect(tagsOf(articles, "a").map((t) => t.slice(1))).toEqual([82 [`30023:${getPublicKey(s.alice)}:two`, "Two", String(s.a2.created_at)],83 [`30023:${getPublicKey(s.alice)}:one`, "One", "1700000000"],84 ]);8586 const zaps = (await view(s.host, "zaps")).body as Event;87 expect(tagsOf(zaps, "e")).toEqual([["e", s.a1.id, "1000000"]]);88 expect(tagsOf(zaps, "p")).toEqual([["p", getPublicKey(s.alice), "1000000"]]);8990 // The records are ordinary events a client can subscribe to.91 const got = await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self] });92 expect(got.map((e) => tagsOf(e, "d")[0][1]).sort()).toEqual(["articles", "calendar", "moderation", "profiles", "relays", "zaps"].map(viewD).sort());93 const runs = (await rpc(s.host, s.owner, "listviews")).result;94 expect(runs.find((v: any) => v.name === "zaps").last.rows).toBeGreaterThan(0);95 });9697 it("hourly views republish only when their inputs moved", async () => {98 const s = await seed("views-hourly");99 await alarm("views-hourly");100 const first = (await view(s.host, "zaps")).body as Event;101 const backdate = () => runInDurableObject(env.RELAY.getByName("views-hourly"), async (_r: Relay, state) => state.storage.put("view-at:zaps", now() - 4000));102 await backdate();103 await alarm("views-hourly");104 expect(((await view(s.host, "zaps")).body as Event).id).toBe(first.id);105 const payer = generateSecretKey();106 const zapReq = ev(payer, 9734, "", [["p", getPublicKey(s.alice)], ["e", s.a2.id], ["relays", "wss://" + s.host], ["amount", "2000000"]]);107 expect((await s.c.ok(ev(generateSecretKey(), 9735, "", [["p", getPublicKey(s.alice)], ["bolt11", "lnbc20u1abc"], ["description", JSON.stringify(zapReq)]]))).ok).toBe(true);108 await backdate();109 await alarm("views-hourly");110 const second = (await view(s.host, "zaps")).body as Event;111 expect(second.id).not.toBe(first.id);112 expect(tagsOf(second, "p")).toEqual([["p", getPublicKey(s.alice), "3000000"]]);113 });114115 it("a members-only view is folded on request and never stored", async () => {116 const s = await seed("views-members");117 await rpc(s.host, s.owner, "setpolicy", { directoryPublic: false });118 await alarm("views-members");119 expect((await view(s.host, "profiles")).status).toBe(401);120 expect((await view(s.host, "profiles", generateSecretKey())).status).toBe(403);121 const mine = await view(s.host, "profiles", s.alice);122 expect(mine.status).toBe(200);123 expect(tagsOf(mine.body, "p").length).toBe(2);124 const doc = await info(s.host);125 expect(doc.views.find((v: any) => v.name === "profiles").audience).toBe("members");126 const stored = await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self], "#d": [viewD("profiles")] });127 expect(stored).toEqual([]);128 // Public again: the daily tick stores it.129 await rpc(s.host, s.owner, "setpolicy", { directoryPublic: true });130 await runInDurableObject(env.RELAY.getByName("views-members"), async (_r: Relay, state) => state.storage.put("view-at:profiles", 0));131 await alarm("views-members");132 expect((await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self], "#d": [viewD("profiles")] })).length).toBe(1);133 });134135 it("presence is broadcast on auth and on a write, throttled, and read from memory", async () => {136 const s = await seed("views-presence");137 const watcher = await WS.connect(s.host);138 watcher.send("REQ", "p", { kinds: [KIND_PRESENCE] });139 await watcher.expect("EOSE");140 const bob = generateSecretKey();141 const b = await WS.connect(s.host);142 // Seeding signed alice in, which broadcast once; start the throttle over.143 await runInDurableObject(env.RELAY.getByName("views-presence"), async (r: Relay) => { (r as unknown as { presenceAt: number }).presenceAt = 0; });144 await b.auth(bob, s.host);145 const first = await watcher.recvOr(2000);146 expect(first && first[0]).toBe("EVENT");147 const pres = first![2] as Event;148 expect(pres.kind).toBe(KIND_PRESENCE);149 expect(tagsOf(pres, "p").some((t) => t[1] === getPublicKey(bob) && t[2] === "online")).toBe(true);150 // A write inside the throttle window waits.151 expect((await b.ok(ev(bob, 1, "hi", [], now()))).ok).toBe(true);152 expect(await watcher.recvOr(400)).toBeNull();153 const live = await view(s.host, "presence");154 expect(live.status).toBe(200);155 expect(tagsOf(live.body, "p").some((t) => t[1] === getPublicKey(bob) && t[2] === "online")).toBe(true);156 // Members-only reads keep it to members.157 await rpc(s.host, s.owner, "setpolicy", { reads: "members" });158 expect((await view(s.host, "presence")).status).toBe(401);159 expect((await view(s.host, "presence", s.alice)).status).toBe(200);160 });161162 it("retention leaves what the relay signed alone, and a switch takes a view down", async () => {163 const s = await seed("views-keep");164 await alarm("views-keep");165 const doc = await info(s.host);166 expect((await s.c.ok(ev(s.alice, KIND_VIEW, "", [["d", "mine"]], now() - 10 * 86400))).ok).toBe(true);167 await rpc(s.host, s.owner, "setretention", KIND_VIEW, 1);168 await runInDurableObject(env.RELAY.getByName("views-keep"), async (r: Relay) => {169 r.sql.exec(`UPDATE events SET created_at=? WHERE kind=?`, now() - 10 * 86400, KIND_VIEW);170 expect(r.sweepRetention(now())).toBe(1);171 });172 expect((await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self] })).length).toBe(6);173 expect((await s.c.req({ kinds: [KIND_VIEW], authors: [getPublicKey(s.alice)] })).length).toBe(0);174 expect((await rpc(s.host, s.owner, "purgekind", KIND_VIEW, 0)).result.deleted).toBe(0);175176 await rpc(s.host, s.owner, "setpolicy", { views: { articles: false } });177 expect((await view(s.host, "articles")).status).toBe(404);178 expect((await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self], "#d": [viewD("articles")] })).length).toBe(0);179 expect((await info(s.host)).views.map((v: any) => v.name)).not.toContain("articles");180 const exported = (await rpc(s.host, s.owner, "exportconfig")).result;181 expect(exported.policy.views).toEqual({ articles: "off" });182 await rpc(s.host, s.owner, "setpolicy", { views: { articles: true } });183 expect((await view(s.host, "articles")).status).toBe(200);184 });185186 it("runs on the trigger the owner set: a daily articles view ignores a write, presence takes no schedule, and nonsense is dropped", async () => {187 const s = await seed("views-trigger");188 const list = async () => Object.fromEntries(((await rpc(s.host, s.owner, "listviews")).result as any[]).map((v) => [v.name, v]));189 let l = await list();190 expect([l.articles.trigger, l.articles.default, l.articles.choices]).toEqual(["write", "write", ["off", "write", "hourly", "daily"]]);191 expect([l.presence.trigger, l.presence.choices]).toEqual(["live", ["off"]]);192 const dirty = () => runInDurableObject(env.RELAY.getByName("views-trigger"), async (r: Relay) => [...r.viewDirty]);193 await runInDurableObject(env.RELAY.getByName("views-trigger"), async (r: Relay) => r.viewDirty.clear());194 expect((await s.c.ok(ev(s.alice, 30023, "three", [["d", "three"], ["title", "Three"]]))).ok).toBe(true);195 expect(await dirty()).toContain("articles");196 await runInDurableObject(env.RELAY.getByName("views-trigger"), async (r: Relay) => r.viewDirty.clear());197 const r = await rpc(s.host, s.owner, "setpolicy", { views: { articles: "daily", presence: "hourly", profiles: "sideways", zaps: "hourly" } });198 expect(r.result.views).toEqual({ articles: "daily", presence: "hourly", zaps: "hourly" });199 l = await list();200 expect([l.articles.trigger, l.presence.trigger, l.profiles.trigger, l.zaps.trigger]).toEqual(["daily", "live", "daily", "hourly"]);201 expect((await s.c.ok(ev(s.alice, 30023, "four", [["d", "four"], ["title", "Four"]]))).ok).toBe(true);202 expect(await dirty()).toEqual([]);203 expect((await info(s.host)).views.find((v: any) => v.name === "articles").trigger).toBe("daily");204 await rpc(s.host, s.owner, "setpolicy", { views: { articles: "off" } });205 expect((await view(s.host, "articles")).status).toBe(404);206 });207});