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 / tools.test.ts
tools.test.ts 9.1K
1// Console search, importing a file, NIP-29 pins, and the weekly digest.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 { sha256 } from "@noble/hashes/sha2.js";7import type { Relay } from "../../src/relay.ts";8import { bytesToHex } from "../../src/negentropy.ts";9import { now, ev, rpc, drive } from "../helpers/relay.ts";10import { WS } from "../helpers/ws.ts";1112// signedPut sends a raw body with a NIP-98 token whose payload tag is the body's hash.13async function signedPut(host: string, path: string, sk: Uint8Array | null, body: string) {14 const url = `http://${host}${path}`;15 const headers: Record<string, string> = { "content-type": "application/x-ndjson" };16 if (sk) {17 const token = ev(sk, 27235, "", [["u", url], ["method", "PUT"], ["payload", bytesToHex(sha256(new TextEncoder().encode(body)))]]);18 headers.authorization = "Nostr " + btoa(JSON.stringify(token));19 }20 const resp = await SELF.fetch(url, { method: "PUT", headers, body });21 return { status: resp.status, ...(await resp.json<any>()) };22}2324describe("search in the console", () => {25 it("finds notes by words and answers by role", async () => {26 const host = "seek.bind.ws";27 const owner = generateSecretKey();28 const mod = generateSecretKey();29 const member = generateSecretKey();30 await rpc(host, owner, "claim");31 await rpc(host, owner, "setmember", getPublicKey(mod), { role: "moderator" });32 await rpc(host, owner, "setmember", getPublicKey(member), {});33 const c = await WS.connect(host);34 const fox = ev(owner, 1, "the quick brown fox");35 expect((await c.ok(fox)).ok).toBe(true);36 expect((await c.ok(ev(owner, 1, "lorem ipsum dolor"))).ok).toBe(true);37 const hits = (await rpc(host, owner, "searchevents", "fox", 50)).result as Event[];38 expect(hits.map((e) => e.id)).toEqual([fox.id]);39 expect((await rpc(host, mod, "searchevents", "ipsum")).result.length).toBe(1);40 expect((await rpc(host, member, "searchevents", "fox")).status).toBe(403);41 // An empty query is the recent list.42 expect((await rpc(host, owner, "searchevents", "", 10)).result.length).toBeGreaterThanOrEqual(2);43 });44});4546describe("import a file", () => {47 it("stores what checks out from a JSONL and a JSON array, then forgets the file", async () => {48 const host = "ingest.bind.ws";49 const owner = generateSecretKey();50 const alice = generateSecretKey();51 const banned = generateSecretKey();52 await rpc(host, owner, "claim");53 await rpc(host, owner, "setpolicy", { writes: "owner" });54 await rpc(host, owner, "banpubkey", getPublicKey(banned), "spam");55 const a1 = ev(alice, 1, "first", [], now() - 300);56 const a2 = ev(alice, 1, "second", [], now() - 200);57 const b1 = ev(banned, 1, "unwanted", [], now() - 100);58 const forged = { ...ev(alice, 1, "genuine"), content: "tampered" };59 const jsonl = [a1, a2, b1, forged].map((e) => JSON.stringify(e)).join("\n") + "\nnot json at all\n";6061 expect((await signedPut(host, "/import", null, jsonl)).status).toBe(401);62 expect((await signedPut(host, "/import", alice, jsonl)).status).toBe(403);63 const r = await signedPut(host, "/import?name=old-relay.jsonl", owner, jsonl);64 expect(r.status).toBe(202);65 expect(r.bytes).toBeGreaterThan(0);66 const stub = env.RELAY.getByName("ingest");67 await runInDurableObject(stub, async (relay: Relay) => expect(relay.mediaBytes()).toBeGreaterThan(0));68 expect((await env.MEDIA.list({ prefix: "ingest/imports/" })).objects.length).toBe(1);6970 const jobs = await drive(host, owner);71 const job = jobs.find((j) => j.kind === "import")!;72 expect(job.relays).toEqual(["old-relay.jsonl"]);73 expect(job.last?.error).toBe("");74 expect(job.last?.stored).toBe(2);75 expect(job.last?.skipped).toBe(3);76 expect(job.last?.duplicates).toBe(0);77 expect((await env.MEDIA.list({ prefix: "ingest/imports/" })).objects).toEqual([]);78 await runInDurableObject(stub, async (relay: Relay) => expect(relay.mediaBytes()).toBe(0));79 const c = await WS.connect(host);80 expect((await c.req({ kinds: [1], authors: [getPublicKey(alice)] })).map((e) => e.id).sort()).toEqual([a1.id, a2.id].sort());8182 // A JSON array of the same events: all duplicates.83 const again = await signedPut(host, "/import", owner, JSON.stringify([a1, a2]));84 expect(again.status).toBe(202);85 const job2 = (await drive(host, owner)).find((j) => j.kind === "import" && j.id === again.job)!;86 expect(job2.last?.stored).toBe(0);87 expect(job2.last?.duplicates).toBe(2);88 expect((await signedPut(host, "/import", owner, " ")).status).toBe(400);89 });90});9192describe("pins", () => {93 it("a moderator's pin list becomes a signed 39005, and unpinning takes it down", async () => {94 const host = "board.bind.ws";95 const owner = generateSecretKey();96 const mod = generateSecretKey();97 const member = generateSecretKey();98 await rpc(host, owner, "claim");99 await rpc(host, owner, "setmember", getPublicKey(mod), { role: "moderator" });100 await rpc(host, owner, "setmember", getPublicKey(member), {});101 const c = await WS.connect(host);102 const note = ev(owner, 1, "read this first");103 expect((await c.ok(note)).ok).toBe(true);104 const h = ["h", "board"];105 expect((await c.ok(ev(member, 9010, "", [h, ["e", note.id]]))).msg).toMatch(/^restricted/);106 expect((await c.ok(ev(mod, 9010, "", [h, ["e", note.id], ["a", "30023:" + getPublicKey(owner) + ":welcome"]]))).ok).toBe(true);107 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();108 const [pins] = await c.req({ kinds: [39005] });109 expect(pins.pubkey).toBe(info.self);110 expect(pins.tags).toEqual(expect.arrayContaining([["d", "board"], ["e", note.id], ["a", "30023:" + getPublicKey(owner) + ":welcome"]]));111 expect((await rpc(host, mod, "listpins")).result).toEqual([["e", note.id], ["a", "30023:" + getPublicKey(owner) + ":welcome"]]);112 // A client cannot write the record itself.113 expect((await c.ok(ev(owner, 39005, "", [["d", "board"], ["e", note.id]]))).msg).toMatch(/blocked/);114 // The console's methods edit the same list.115 expect((await rpc(host, owner, "unpinevent", "30023:" + getPublicKey(owner) + ":welcome")).result).toEqual([["e", note.id]]);116 expect((await rpc(host, member, "unpinevent", note.id)).status).toBe(403);117 expect((await rpc(host, mod, "unpinevent", note.id)).result).toEqual([]);118 expect(await c.req({ kinds: [39005] })).toEqual([]);119 // The cap.120 const many = Array.from({ length: 21 }, (_, i) => ["e", i.toString(16).padStart(64, "0")]);121 expect((await c.ok(ev(mod, 9010, "", [h, ...many]))).msg).toMatch(/at most 20/);122 });123});124125describe("weekly digest", () => {126 it("writes the owner once a week with what changed, and stays quiet otherwise", async () => {127 const host = "weekly.bind.ws";128 const owner = generateSecretKey();129 const pk = getPublicKey(owner);130 const friend = generateSecretKey();131 await rpc(host, owner, "claim");132 await rpc(host, owner, "setpolicy", { notify: { digest: true } });133 const stub = env.RELAY.getByName("weekly");134 const wraps = () => runInDurableObject(stub, async (r: Relay) => r.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM events WHERE kind=1059`).one().n);135 // The first tick with the switch on starts the clock and says nothing.136 await runInDurableObject(stub, async (r: Relay) => r.alarm());137 expect(await wraps()).toBe(0);138 await rpc(host, owner, "setmember", getPublicKey(friend), { name: "friend" });139 const c = await WS.connect(host);140 expect((await c.ok(ev(owner, 1, "a week's worth"))).ok).toBe(true);141 await runInDurableObject(stub, async (_r: Relay, state) => state.storage.put("lastDigest", now() - 8 * 86400));142 await runInDurableObject(stub, async (r: Relay) => r.alarm());143 expect(await wraps()).toBe(1);144 const o = await WS.connect(host);145 await o.auth(owner, host);146 const [wrap] = await o.req({ kinds: [1059], "#p": [pk] });147 const rumor = unwrapEvent(wrap, owner);148 expect(rumor.kind).toBe(14);149 expect(rumor.content).toMatch(/People: 1 joined/);150 expect(rumor.content).toMatch(/Stored: 1 events/);151 // A day later there is nothing new to say, and it is not a week yet.152 await runInDurableObject(stub, async (r: Relay) => r.alarm());153 expect(await wraps()).toBe(1);154 // A quiet week still gets one line: push last week's changes out of the window first.155 await runInDurableObject(stub, async (r: Relay, state) => {156 r.sql.exec(`UPDATE members SET joined_at = joined_at - 30 * 86400`);157 r.sql.exec(`UPDATE events SET created_at = created_at - 30 * 86400 WHERE kind = 1`);158 await state.storage.put("lastDigest", now() - 8 * 86400);159 });160 await runInDurableObject(stub, async (r: Relay) => r.alarm());161 expect(await wraps()).toBe(2);162 const two = await o.req({ kinds: [1059], "#p": [pk] });163 const texts = two.map((w) => unwrapEvent(w, owner).content);164 expect(texts.some((t) => /Nothing changed/.test(t))).toBe(true);165 });166});