- commit
- d8ac77518ee0d4946ed1c304d7656f4a2ac8fb82
- parent
- acfd8be284d71c6d99a6e71793a30a27793c54cc
- author
- Dami <dami.etoile@gmail.com>
@@ -8,102 +8,8 @@ import { getToken } from "nostr-tools/nip98";
88 import { sha256 } from "@noble/hashes/sha2.js";99 import type { Relay } from "../src/relay.ts";1010 import { bytesToHex } from "../src/negentropy.ts";11-12-const now = () => Math.floor(Date.now() / 1000);13-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);14-15-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {16- const url = `http://${host}/`;17- const payload = { method, params };18- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);19- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });20- return { status: resp.status, ...(await resp.json<any>()) };21-}22-23-// post signs a NIP-98 request to any path with a raw JSON body.24-async function post(host: string, sk: Uint8Array | null, path: string, body: unknown) {25- const url = `http://${host}${path}`;26- const headers: Record<string, string> = { "content-type": "application/json" };27- if (sk) headers.authorization = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>);28- const resp = await SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(body) });29- const text = await resp.text();30- return { status: resp.status, body: text ? JSON.parse(text) : null };31-}32-33-class WS {34- private queue: any[][] = [];35- private waiters: ((m: any[]) => void)[] = [];36- challenge = "";37- closed: { code: number; reason: string } | null = null;38- constructor(public ws: WebSocket) {39- ws.accept();40- ws.addEventListener("message", (e) => {41- const m = JSON.parse(e.data as string);42- const w = this.waiters.shift();43- if (w) w(m);44- else this.queue.push(m);45- });46- ws.addEventListener("close", (e) => {47- this.closed = { code: e.code, reason: e.reason };48- });49- }50- static async connect(host: string) {51- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });52- const c = new WS(resp.webSocket!);53- c.challenge = (await c.expect("AUTH"))[1];54- return c;55- }56- send(...m: unknown[]) {57- this.ws.send(JSON.stringify(m));58- }59- recv(): Promise<any[]> {60- const m = this.queue.shift();61- if (m) return Promise.resolve(m);62- return new Promise((res) => this.waiters.push(res));63- }64- async expect(type: string) {65- const m = await this.recv();66- expect(m[0], JSON.stringify(m)).toBe(type);67- return m;68- }69- async ok(e: Event) {70- this.send("EVENT", e);71- const m = await this.expect("OK");72- return { ok: m[2] as boolean, msg: m[3] as string };73- }74- async auth(sk: Uint8Array, host: string) {75- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));76- const m = await this.expect("OK");77- expect(m[2], m[3]).toBe(true);78- }79- // live opens a subscription that stays open, draining stored results first.80- async live(filter: unknown, id: string) {81- this.send("REQ", id, filter);82- for (;;) {83- const m = await this.recv();84- if (m[0] === "EOSE" && m[1] === id) return;85- if (m[0] !== "EVENT") throw new Error(JSON.stringify(m));86- }87- }88-89- private n = 0;90- // req is a one-shot query: unique id, closed after EOSE so it never leaks91- // live pushes into a later query.92- async req(filter: unknown, id = "q" + ++this.n) {93- this.send("REQ", id, filter);94- const events: Event[] = [];95- for (;;) {96- const m = await this.recv();97- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);98- else if (m[0] === "EOSE" && m[1] === id) {99- this.send("CLOSE", id);100- return { events, closed: "" };101- } else if (m[0] === "CLOSED" && m[1] === id) return { events, closed: m[2] as string };102- else if (m[0] === "EVENT" || m[0] === "CLOSED") this.queue.push(m);103- else throw new Error(JSON.stringify(m));104- }105- }106-}11+import { now, ev, rpc, post } from "./helpers/relay.ts";12+import { WS } from "./helpers/ws.ts";10713 10814 describe("HTTP bridge", () => {10915 it("accepts events, answers queries and counts with NIP-98, applying the same gates", async () => {@@ -133,7 +39,7 @@ describe("HTTP bridge", () => {
13339 13440 // A subscriber on the socket sees bridge writes live.13541 const c = await WS.connect(host);136- await c.live({ kinds: [1] }, "live");42+ await c.open("live", { kinds: [1] });13743 const e2 = ev(owner, 1, "pushed");13844 await post(host, owner, "/events", e2);13945 expect((await c.expect("EVENT"))[2].id).toBe(e2.id);@@ -242,23 +148,23 @@ describe("relay identity and NIP-43 roster", () => {
242148 c.send("REQ", "live", { kinds: [8000, 8001] });243149 await c.expect("EOSE");244150 let r = await c.req({ kinds: [13534] });245- expect(r.events.length).toBe(1);246- expect(r.events[0].pubkey).toBe(info.self);247- expect(r.events[0].tags).toEqual([["-"], ["member", getPublicKey(owner), "owner"]]);151+ expect(r.length).toBe(1);152+ expect(r[0].pubkey).toBe(info.self);153+ expect(r[0].tags).toEqual([["-"], ["member", getPublicKey(owner), "owner"]]);248154 249155 await rpc(host, owner, "allowpubkey", getPublicKey(bob), "friend");250156 const added = await c.expect("EVENT");251157 expect(added[2].kind).toBe(8000);252158 expect(added[2].tags).toContainEqual(["p", getPublicKey(bob)]);253159 r = await c.req({ kinds: [13534] });254- expect(r.events[0].tags).toContainEqual(["member", getPublicKey(bob)]);160+ expect(r[0].tags).toContainEqual(["member", getPublicKey(bob)]);255161 await rpc(host, owner, "unrulepubkey", getPublicKey(bob));256162 expect((await c.expect("EVENT"))[2].kind).toBe(8001);257163 // Nobody can forge relay-signed kinds: a client-signed 13534 is just another replaceable event by another author.258164 const forged = await c.ok(ev(bob, 13534, "", [["member", getPublicKey(bob), "owner"]]));259165 expect(forged.ok).toBe(true);260166 r = await c.req({ kinds: [13534], authors: [info.self] });261- expect(r.events.map((x) => [x.pubkey, x.tags]), JSON.stringify(r.events)).toEqual([[info.self, [["-"], ["member", getPublicKey(owner), "owner"]]]]);167+ expect(r.map((x) => [x.pubkey, x.tags]), JSON.stringify(r)).toEqual([[info.self, [["-"], ["member", getPublicKey(owner), "owner"]]]]);262168 });263169 });264170 @@ -272,12 +178,12 @@ describe("members-only reads and eviction", () => {
272178 await rpc(host, owner, "allowpubkey", getPublicKey(member));273179 await rpc(host, owner, "setpolicy", { reads: "members" });274180 const s = await WS.connect(host);275- expect((await s.req({ kinds: [1] })).closed).toMatch(/^auth-required:/);181+ expect((await s.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);276182 await s.auth(stranger, host);277- expect((await s.req({ kinds: [1] })).closed).toMatch(/^restricted: .*members/);183+ expect((await s.query({ kinds: [1] })).closed).toMatch(/^restricted: .*members/);278184 const m = await WS.connect(host);279185 await m.auth(member, host);280- expect((await m.req({ kinds: [1] })).closed).toBe("");186+ expect((await m.query({ kinds: [1] })).closed).toBe("");281187 m.send("REQ", "live", { kinds: [1] });282188 await m.expect("EOSE");283189 @@ -309,9 +215,9 @@ describe("rate limits", () => {
309215 const third = await c.ok(ev(owner, 1, "c"));310216 expect(third.ok).toBe(false);311217 expect(third.msg).toMatch(/^rate-limited: quota exceeded; retry in \d+s$/);312- await c.req({ kinds: [1] }, "r1");313- await c.req({ kinds: [1] }, "r2");314- expect((await c.req({ kinds: [1] }, "r3")).closed).toMatch(/^rate-limited:/);218+ await c.open("r1", { kinds: [1] });219+ await c.open("r2", { kinds: [1] });220+ expect((await c.open("r3", { kinds: [1] })).closed).toMatch(/^rate-limited:/);315221 });316222 });317223 @@ -327,12 +233,12 @@ describe("moderation queue", () => {
327233 await c.ok(bad);328234 const report = ev(reporter, 1984, "please look", [["e", bad.id, "spam"], ["p", getPublicKey(troll)]]);329235 expect((await c.ok(report)).msg).toBe("info: report received");330- expect((await c.req({ kinds: [1984] })).events).toEqual([]); // never served236+ expect(await c.req({ kinds: [1984] })).toEqual([]); // never served331237 let q = (await rpc(host, owner, "listreports")).result;332238 expect(q.length).toBe(1);333239 expect(q[0]).toMatchObject({ id: report.id, target_event: bad.id, target_pubkey: getPublicKey(troll), type: "spam", status: "open" });334240 expect((await rpc(host, owner, "resolvereport", report.id, "ban")).result).toBe(true);335- expect((await c.req({ ids: [bad.id] })).events).toEqual([]);241+ expect(await c.req({ ids: [bad.id] })).toEqual([]);336242 expect((await c.ok(ev(troll, 1, "back"))).msg).toMatch(/^blocked:/);337243 expect((await rpc(host, owner, "listreports")).result).toEqual([]);338244 expect((await rpc(host, owner, "listreports", "resolved")).result[0].action).toBe("ban");@@ -420,7 +326,7 @@ describe("teardown", () => {
420326 await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });421327 const c = await WS.connect(host);422328 await c.ok(ev(owner, 1, "to be deleted"));423- await c.live({ kinds: [1] }, "live");329+ await c.open("live", { kinds: [1] });424330 const body = new TextEncoder().encode("bytes");425331 const sha = bytesToHex(sha256(body));426332 const token = "Nostr " + btoa(JSON.stringify(ev(owner, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));@@ -440,7 +346,7 @@ describe("teardown", () => {
440346 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);441347 expect((await env.MEDIA.list({ prefix: "gone/" })).objects).toEqual([]);442348 const fresh = await WS.connect(host);443- expect((await fresh.req({ kinds: [1] })).events).toEqual([]);349+ expect(await fresh.req({ kinds: [1] })).toEqual([]);444350 expect((await fresh.ok(ev(owner, 1, "again"))).msg).toMatch(/unclaimed/);445351 const doc: any = await (await SELF.fetch(`http://${host}/.well-known/nostr.json?name=bob`)).json();446352 expect(doc.names).toEqual({});@@ -485,7 +391,7 @@ describe("configuration export and import", () => {
485391 const rc = await WS.connect(b);486392 await rc.auth(ownerB, b);487393 const r = await rc.req({ kinds: [13534] });488- expect(r.events[0].tags).toContainEqual(["member", m1]);394+ expect(r[0].tags).toContainEqual(["member", m1]);489395 });490396 });491397 @@ -3,20 +3,8 @@
33 // can read back, newest first, with paging; reads leave nothing.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";8-9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);11-const pk = (sk: Uint8Array) => getPublicKey(sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}6+import { generateSecretKey, type Event } from "nostr-tools/pure";7+import { now, ev, pk, rpc } from "./helpers/relay.ts";208 219 async function publish(host: string, e: Event): Promise<{ ok: boolean; msg: string }> {2210 const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });@@ -2,31 +2,11 @@
22 // accepted, BUD-04 copies a blob from a URL, ours or anyone's.33 import { SELF } from "cloudflare:test";44 import { describe, it, expect } from "vitest";5-import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";6-import { getToken } from "nostr-tools/nip98";5+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";76 import { sha256 } from "@noble/hashes/sha2.js";87 import { bytesToHex } from "../src/negentropy.ts";9-10-const now = () => Math.floor(Date.now() / 1000);11-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}20-21-const token = (sk: Uint8Array, sha?: string) =>22- "Nostr " + btoa(JSON.stringify(ev(sk, 24242, "upload", [["t", "upload"], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]])));23-24-async function upload(host: string, sk: Uint8Array, text: string) {25- const body = new TextEncoder().encode(text);26- const sha = bytesToHex(sha256(body));27- const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token(sk, sha), "content-type": "text/plain" }, body });28- return { status: resp.status, sha, body: await resp.json<any>() };29-}8+import { rpc } from "./helpers/relay.ts";9+import { blossomToken, upload } from "./helpers/media.ts";3010 3111 async function head(host: string, headers: Record<string, string>) {3212 const resp = await SELF.fetch(`http://${host}/upload`, { method: "HEAD", headers });@@ -34,7 +14,7 @@ async function head(host: string, headers: Record<string, string>) {
3414 }3515 3616 async function mirror(host: string, sk: Uint8Array, url: unknown, sha?: string, raw?: string) {37- const resp = await SELF.fetch(`http://${host}/mirror`, { method: "PUT", headers: { authorization: token(sk, sha), "content-type": "application/json" }, body: raw ?? JSON.stringify({ url }) });17+ const resp = await SELF.fetch(`http://${host}/mirror`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", sha), "content-type": "application/json" }, body: raw ?? JSON.stringify({ url }) });3818 const text = await resp.text();3919 return { status: resp.status, body: text ? JSON.parse(text) : null, reason: resp.headers.get("x-reason") ?? "" };4020 }@@ -47,7 +27,7 @@ describe("BUD-06 upload requirements", () => {
4727 await rpc(host, owner, "claim");4828 const bytes = new TextEncoder().encode("a small file");4929 const sha = bytesToHex(sha256(bytes));50- const ok = { "x-sha-256": sha, "x-content-type": "text/plain", "x-content-length": String(bytes.length), authorization: token(owner, sha) };30+ const ok = { "x-sha-256": sha, "x-content-type": "text/plain", "x-content-length": String(bytes.length), authorization: blossomToken(owner, "upload", sha) };5131 5232 let r = await head(host, ok);5333 expect([r.status, r.reason, r.text]).toEqual([200, "", ""]);@@ -65,11 +45,11 @@ describe("BUD-06 upload requirements", () => {
6545 r = await head(host, { ...ok, "x-content-length": String(26 * 1024 * 1024) });6646 expect(r.status).toBe(413);6747 expect(r.reason).toMatch(/25 MB/);68- r = await head(host, { ...ok, authorization: token(owner, "ab".repeat(32)) });48+ r = await head(host, { ...ok, authorization: blossomToken(owner, "upload", "ab".repeat(32)) });6949 expect(r.status).toBe(400);7050 7151 await rpc(host, owner, "setpolicy", { writes: "owner" });72- r = await head(host, { ...ok, authorization: token(stranger, sha) });52+ r = await head(host, { ...ok, authorization: blossomToken(stranger, "upload", sha) });7353 expect(r.status).toBe(403);7454 expect(r.reason).toMatch(/^restricted/);7555 @@ -138,7 +118,7 @@ describe("BUD-04 mirror", () => {
138118 await rpc(b, bob, "setpolicy", { writes: "open", maxBlobMB: 1 });139119 // The origin declares its size, so an oversized blob is refused before any bytes move.140120 await rpc(a, alice, "setpolicy", { maxBlobMB: 2 });141- const big = await SELF.fetch(`http://${a}/upload`, { method: "PUT", headers: { authorization: token(alice), "content-type": "application/octet-stream" }, body: new Uint8Array(1024 * 1024 + 1) });121+ const big = await SELF.fetch(`http://${a}/upload`, { method: "PUT", headers: { authorization: blossomToken(alice, "upload"), "content-type": "application/octet-stream" }, body: new Uint8Array(1024 * 1024 + 1) });142122 expect(big.status).toBe(200);143123 r = await mirror(b, bob, (await big.json<any>()).url);144124 expect(r.status).toBe(413);@@ -3,20 +3,11 @@
33 // syndromes, and the bytes themselves.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";6+import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";77 import { decode } from "nostr-tools/nip19";8-import { getToken } from "nostr-tools/nip98";98 import jsQR from "jsqr";109 import { EC_M, MASKS, dataCodewords, dataOrder, encode, formatBits, gfMul, gfPow, layout, capacity } from "../src/qr.ts";11-12-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {13- const url = `http://${host}/`;14- const payload = { method, params };15- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);16- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });17- return { status: resp.status, ...(await resp.json<any>()) };18-}19-const get = (host: string, path: string) => SELF.fetch(`http://${host}${path}`);10+import { rpc, get } from "./helpers/relay.ts";2011 2112 // scan rasterizes a module grid and reads it with jsQR, a decoder that2213 // shares nothing with the encoder, so it proves a phone can read it.@@ -1,88 +1,11 @@
11 // Dumps to R2, per-member keep-for and caps, and members inviting members.2-import { SELF, env, runInDurableObject } from "cloudflare:test";2+import { env, runInDurableObject } from "cloudflare:test";33 import { describe, it, expect } from "vitest";4-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";5-import { getToken } from "nostr-tools/nip98";4+import { generateSecretKey } from "nostr-tools/pure";65 import type { Relay } from "../src/relay.ts";76 import { writeDump } from "../src/dumps.ts";8-9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);11-const pk = (sk: Uint8Array) => getPublicKey(sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}20-21-async function get(host: string, sk: Uint8Array | null, path: string) {22- const url = `http://${host}${path}`;23- const headers: Record<string, string> = {};24- if (sk) headers.authorization = await getToken(url, "GET", (e) => finalizeEvent(e, sk), true);25- return SELF.fetch(url, { headers });26-}27-28-async function post(host: string, sk: Uint8Array, path: string, body: unknown) {29- const url = `http://${host}${path}`;30- const headers: Record<string, string> = { "content-type": "application/json", authorization: await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>) };31- const resp = await SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(body) });32- return { status: resp.status, ...(await resp.json<any>()) };33-}34-35-class WS {36- private queue: any[][] = [];37- private waiters: ((m: any[]) => void)[] = [];38- constructor(public ws: WebSocket) {39- ws.accept();40- ws.addEventListener("message", (e) => {41- const m = JSON.parse(e.data as string);42- const w = this.waiters.shift();43- if (w) w(m);44- else this.queue.push(m);45- });46- }47- static async connect(host: string) {48- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });49- const c = new WS(resp.webSocket!);50- await c.expect("AUTH");51- return c;52- }53- send(...m: unknown[]) {54- this.ws.send(JSON.stringify(m));55- }56- recv(): Promise<any[]> {57- const m = this.queue.shift();58- if (m) return Promise.resolve(m);59- return new Promise((res) => this.waiters.push(res));60- }61- async expect(type: string) {62- const m = await this.recv();63- expect(m[0], JSON.stringify(m)).toBe(type);64- return m;65- }66- async ok(e: Event) {67- this.send("EVENT", e);68- const m = await this.expect("OK");69- return { ok: m[2] as boolean, msg: m[3] as string };70- }71- private n = 0;72- async req(filter: unknown) {73- const id = "q" + ++this.n;74- this.send("REQ", id, filter);75- const events: Event[] = [];76- for (;;) {77- const m = await this.recv();78- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);79- else if (m[0] === "EOSE" && m[1] === id) {80- this.send("CLOSE", id);81- return events;82- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);83- }84- }85-}7+import { now, ev, pk, rpc, get, post } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";869 8710 describe("dumps", () => {8811 it("writes a JSONL of every event to R2, lists it, serves it to a signature, rotates and counts as media", async () => {@@ -112,14 +35,14 @@ describe("dumps", () => {
11235 expect(list[0].url).toBe("/dumps/" + d.name);11336 11437 // Download needs a signature from someone with the storage action.115- const signed = await get(host, owner, "/dumps/" + d.name);38+ const signed = await get(host, "/dumps/" + d.name, owner);11639 expect(signed.status).toBe(200);11740 expect(signed.headers.get("content-disposition")).toContain(d.name);11841 expect(await signed.text()).toBe(text);119- expect((await get(host, null, "/dumps/" + d.name)).status).toBe(401);120- expect((await get(host, writer, "/dumps/" + d.name)).status).toBe(403);121- expect((await get(host, owner, "/dumps/nope.jsonl")).status).toBe(400);122- expect((await get(host, owner, "/dumps/1999-01-01.jsonl")).status).toBe(404);42+ expect((await get(host, "/dumps/" + d.name, null)).status).toBe(401);43+ expect((await get(host, "/dumps/" + d.name, writer)).status).toBe(403);44+ expect((await get(host, "/dumps/nope.jsonl", owner)).status).toBe(400);45+ expect((await get(host, "/dumps/1999-01-01.jsonl", owner)).status).toBe(404);12346 12447 const stub = env.RELAY.getByName("dumpy");12548 await runInDurableObject(stub, async (r: Relay) => {@@ -224,7 +147,7 @@ describe("members invite members", () => {
224147 225148 // The owner invites A through the HTTP door.226149 const inv = (await rpc(host, owner, "createinvite", 86400, 0, "for a")).result;227- expect((await post(host, a, "/api/invites/claim", { code: inv.code })).status).toBe("joined");150+ expect((await post(host, a, "/api/invites/claim", { code: inv.code })).body.status).toBe("joined");228151 let members = (await rpc(host, owner, "listmembers")).result.members;229152 expect(members.find((m: any) => m.pubkey === pk(a)).invited_by).toBe(pk(owner));230153 @@ -1,22 +1,12 @@
11 // Custom domains: a hostname the owner controls, mapped to the relay through22 // KV and registered with Cloudflare for SaaS. The Cloudflare API is a fake33 // fetch here; routing goes through the real worker and the KV binding.4-import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";6+import { generateSecretKey } from "nostr-tools/pure";87 import type { Relay } from "../src/relay.ts";98 import { Hostnames, checkHostname, MAX_CUSTOM_HOSTS } from "../src/domains.ts";10-11-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {12- const url = `http://${host}/`;13- const payload = { method, params };14- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);15- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });16- return { status: resp.status, ...(await resp.json<any>()) };17-}18-19-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();9+import { rpc, info } from "./helpers/relay.ts";2010 2111 // fakeCloudflare answers the custom hostnames API and records every call.2212 function fakeCloudflare(opts: { active?: boolean; refuse?: boolean } = {}) {@@ -6,100 +6,14 @@
66 // door honest: add a path here when you add one to the relay.77 import { SELF } from "cloudflare:test";88 import { describe, it, expect } from "vitest";9-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";10-import { getToken } from "nostr-tools/nip98";9+import { generateSecretKey } from "nostr-tools/pure";1110 import { npubEncode } from "nostr-tools/nip19";1211 import { sha256 } from "@noble/hashes/sha2.js";1312 import { bytesToHex } from "../src/negentropy.ts";1413 import { VIEWS } from "../src/views.ts";15-16-const now = () => Math.floor(Date.now() / 1000);17-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);18-const pk = (sk: Uint8Array) => getPublicKey(sk);19-20-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {21- const url = `http://${host}/`;22- const payload = { method, params };23- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);24- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });25- return { status: resp.status, ...(await resp.json<any>()) };26-}27-28-// nip98 signs a GET or a body-less request for the exact URL.29-const nip98 = (sk: Uint8Array, url: string, method = "GET") => getToken(url, method, (e) => finalizeEvent(e, sk), true);30-// nip98Body signs a request with a body, for the bridge.31-const nip98Body = (sk: Uint8Array, url: string, method: string, body: unknown) => getToken(url, method, (e) => finalizeEvent(e, sk), true, body as object);32-// blossom mints a kind 24242 token for an action, optionally naming a hash.33-const blossom = (sk: Uint8Array, action: string, sha?: string) =>34- "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]])));35-36-class WS {37- private queue: any[][] = [];38- private waiters: ((m: any[]) => void)[] = [];39- challenge = "";40- frames: string[] = []; // every frame received, for the leak check41- constructor(public ws: WebSocket) {42- ws.accept();43- ws.addEventListener("message", (e) => {44- this.frames.push(e.data as string);45- const m = JSON.parse(e.data as string);46- const w = this.waiters.shift();47- if (w) w(m);48- else this.queue.push(m);49- });50- }51- static async connect(host: string) {52- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });53- const c = new WS(resp.webSocket!);54- c.challenge = (await c.expect("AUTH"))[1];55- return c;56- }57- send(...m: unknown[]) {58- this.ws.send(JSON.stringify(m));59- }60- recv(): Promise<any[]> {61- const m = this.queue.shift();62- if (m) return Promise.resolve(m);63- return new Promise((res) => this.waiters.push(res));64- }65- async expect(type: string) {66- const m = await this.recv();67- expect(m[0], JSON.stringify(m)).toBe(type);68- return m;69- }70- async auth(sk: Uint8Array, host: string) {71- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));72- const m = await this.expect("OK");73- expect(m[2], m[3]).toBe(true);74- }75- async ok(e: Event) {76- this.send("EVENT", e);77- const m = await this.expect("OK");78- return { ok: m[2] as boolean, msg: m[3] as string };79- }80- // open subscribes and returns the events plus "" on EOSE, or the CLOSED reason.81- async open(id: string, ...filters: unknown[]) {82- this.send("REQ", id, ...filters);83- const events: Event[] = [];84- for (;;) {85- const m = await this.recv();86- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);87- else if (m[0] === "EOSE" && m[1] === id) return { events, closed: "" };88- else if (m[0] === "CLOSED" && m[1] === id) return { events, closed: m[2] as string };89- else throw new Error(JSON.stringify(m));90- }91- }92- async count(id: string, filter: unknown) {93- this.send("COUNT", id, filter);94- const m = await this.recv();95- return m[0] === "COUNT" ? { count: m[2].count as number, closed: "" } : { count: -1, closed: m[2] as string };96- }97- async sync(id: string, filter: unknown) {98- this.send("NEG-OPEN", id, filter, "61");99- const m = await this.recv();100- return m[0] === "NEG-ERR" ? (m[2] as string) : "";101- }102-}14+import { ev, pk, nip98, rpc } from "./helpers/relay.ts";15+import { WS } from "./helpers/ws.ts";16+import { blossomToken } from "./helpers/media.ts";10317 10418 interface Fixture {10519 host: string;@@ -119,7 +33,7 @@ async function seed(host: string): Promise<Fixture> {
11933 expect((await rpc(host, owner, "setmember", pk(eve), { name: "evelynq7" })).status).toBe(200);12034 const body = new TextEncoder().encode("eve's quarterly numbers");12135 const sha = bytesToHex(sha256(body));122- const up = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossom(eve, "upload", sha), "content-type": "text/plain" }, body });36+ const up = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(eve, "upload", sha), "content-type": "text/plain" }, body });12337 expect([200, 201]).toContain(up.status);12438 const c = await WS.connect(host);12539 const note = ev(eve, 1, "the numbers are in");@@ -193,7 +107,7 @@ describe("the read rule at every door", () => {
193107 // payload is ciphertext, and NIP-46 clients and signers never AUTH.194108 const c = await WS.connect(f.host);195109 expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);196- expect((await c.count("n", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);110+ expect((await c.count({ authors: [pk(f.eve)] }, "n")).closed).toMatch(/^auth-required/);197111 expect(await c.sync("s", { authors: [pk(f.eve)] })).toMatch(/^auth-required/);198112 expect((await c.open("nc", { kinds: [24133] })).closed).toBe("");199113 const sender = await WS.connect(f.host);@@ -213,7 +127,7 @@ describe("the read rule at every door", () => {
213127 expect(leaks(await resp.text(), f.secrets), `${method} ${d.path} -> ${resp.status}`).toEqual([]);214128 if (d.gated) expect(resp.status, d.path).toBe(403);215129 if (d.blossom) {216- const withToken = await SELF.fetch(url, { method, headers: { authorization: blossom(f.stranger, d.blossom, d.blossom === "get" ? f.sha : undefined) } });130+ const withToken = await SELF.fetch(url, { method, headers: { authorization: blossomToken(f.stranger, d.blossom, d.blossom === "get" ? f.sha : undefined) } });217131 expect(withToken.status, `${d.path} with a Blossom ${d.blossom} token`).toBe(403);218132 expect(leaks(await withToken.text(), f.secrets)).toEqual([]);219133 }@@ -221,14 +135,14 @@ describe("the read rule at every door", () => {
221135 for (const path of ["/query", "/count"]) {222136 const url = `http://${f.host}${path}`;223137 const body = [{ authors: [pk(f.eve)] }];224- const resp = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98Body(f.stranger, url, "POST", body) }, body: JSON.stringify(body) });138+ const resp = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98(f.stranger, url, "POST", body) }, body: JSON.stringify(body) });225139 expect(resp.status, path).toBe(403);226140 expect(leaks(await resp.text(), f.secrets)).toEqual([]);227141 }228142 const c = await WS.connect(f.host);229143 await c.auth(f.stranger, f.host);230144 expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^restricted/);231- expect((await c.count("n", { authors: [pk(f.eve)] })).closed).toMatch(/^restricted/);145+ expect((await c.count({ authors: [pk(f.eve)] }, "n")).closed).toMatch(/^restricted/);232146 expect(await c.sync("s", { authors: [pk(f.eve)] })).toMatch(/^restricted/);233147 expect(leaks(c.frames.join("\n"), f.secrets.filter((s) => s !== pk(f.eve)))).toEqual([]);234148 });@@ -241,14 +155,14 @@ describe("the read rule at every door", () => {
241155 };242156 const url = (path: string) => `http://${f.host}${path}`;243157 // Her file, with a Blossom get token and with NIP-98.244- let r = await get(`/${f.sha}`, blossom(f.eve, "get", f.sha));158+ let r = await get(`/${f.sha}`, blossomToken(f.eve, "get", f.sha));245159 expect(r.status).toBe(200);246160 expect(r.text).toBe("eve's quarterly numbers");247161 r = await get(`/${f.sha}`, await nip98(f.eve, url(`/${f.sha}`)));248162 expect(r.status).toBe(200);249163 // A get token naming another blob does not open this one.250- expect((await get(`/${f.sha}`, blossom(f.eve, "get", "ab".repeat(32)))).status).toBe(401);251- r = await get(`/list/${pk(f.eve)}`, blossom(f.eve, "list"));164+ expect((await get(`/${f.sha}`, blossomToken(f.eve, "get", "ab".repeat(32)))).status).toBe(401);165+ r = await get(`/list/${pk(f.eve)}`, blossomToken(f.eve, "list"));252166 expect(r.status).toBe(200);253167 expect(r.text).toContain(f.sha);254168 r = await get(`/.well-known/nostr.json`, await nip98(f.eve, url(`/.well-known/nostr.json`)));@@ -296,7 +210,7 @@ describe("the read rule at every door", () => {
296210 await rpc(host, owner, "claim");297211 const body = new TextEncoder().encode("a public file");298212 const sha = bytesToHex(sha256(body));299- await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossom(owner, "upload", sha), "content-type": "text/plain" }, body });213+ await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(owner, "upload", sha), "content-type": "text/plain" }, body });300214 // Anyone: a public link by hash, cacheable.301215 let resp = await SELF.fetch(`http://${host}/${sha}`);302216 expect(resp.status).toBe(200);@@ -305,12 +219,12 @@ describe("the read rule at every door", () => {
305219 // Signed in: any valid signature, member or not; no signature is 401; not for shared caches.306220 await rpc(host, owner, "setpolicy", { reads: "auth" });307221 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(401);308- resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossom(stranger, "get", sha) } });222+ resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossomToken(stranger, "get", sha) } });309223 expect(resp.status).toBe(200);310224 expect(resp.headers.get("cache-control")).toContain("no-store");311- expect((await SELF.fetch(`http://${host}/list/${pk(owner)}`, { headers: { authorization: blossom(stranger, "list") } })).status).toBe(200);225+ expect((await SELF.fetch(`http://${host}/list/${pk(owner)}`, { headers: { authorization: blossomToken(stranger, "list") } })).status).toBe(200);312226 // A token for the wrong action, or a malformed one, is refused with the reason rather than served as a stranger.313- resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossom(stranger, "upload", sha) } });227+ resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossomToken(stranger, "upload", sha) } });314228 expect(resp.status).toBe(401);315229 expect(resp.headers.get("x-reason")).toMatch(/not for get/);316230 expect((await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: "Nostr nonsense" } })).status).toBe(401);@@ -1,31 +1,11 @@
11 // Fork a name: lease a new name reserved for a key, pull this relay into22 // it, hand over the claim. Built from lease and jobs.3-import { SELF, env, runInDurableObject } from "cloudflare:test";3+import { env, runInDurableObject } from "cloudflare:test";44 import { describe, it, expect } from "vitest";5-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6-import { getToken } from "nostr-tools/nip98";5+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";76 import type { Relay } from "../src/relay.ts";87 import { ADJECTIVES, ANIMALS } from "../src/names.ts";9-10-const now = () => Math.floor(Date.now() / 1000);11-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}20-21-async function post(host: string, sk: Uint8Array, path: string, body: unknown) {22- const url = `http://${host}${path}`;23- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>);24- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: token }, body: JSON.stringify(body) });25- return { status: resp.status, body: await resp.json<any>() };26-}27-28-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();8+import { now, ev, rpc, info, post } from "./helpers/relay.ts";299 3010 // settle drives the new object's alarm until its pull has finished.3111 async function settle(name: string) {@@ -4,12 +4,12 @@ import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nost
44 import { getToken } from "nostr-tools/nip98";55 import type { Relay } from "../src/relay.ts";66 import { bolt11Msats } from "../src/fuel.ts";7+import { now, ev, rpc } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";79 810 const SERVICE = "ab".repeat(32);911 const provider = generateSecretKey();1012 const providerPk = getPublicKey(provider);11-const now = () => Math.floor(Date.now() / 1000);12-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);1313 1414 // A fake lightning provider, injected into the relay instance: the LNURL1515 // document names our provider key, and the callback returns a fixed invoice.@@ -37,37 +37,6 @@ async function claim(host: string, sk: Uint8Array) {
3737 expect(r.status).toBe(200);3838 }3939 40-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {41- const url = `http://${host}/`;42- const payload = { method, params };43- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);44- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });45- return { status: resp.status, ...(await resp.json<any>()) };46-}47-48-async function ws(host: string) {49- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });50- const sock = resp.webSocket!;51- sock.accept();52- const queue: any[][] = [];53- const waiters: ((m: any[]) => void)[] = [];54- sock.addEventListener("message", (e) => {55- const m = JSON.parse(e.data as string);56- const w = waiters.shift();57- if (w) w(m);58- else queue.push(m);59- });60- const recv = () => (queue.length ? Promise.resolve(queue.shift()!) : new Promise<any[]>((res) => waiters.push(res)));61- await recv(); // AUTH62- return {63- async ok(e: Event) {64- sock.send(JSON.stringify(["EVENT", e]));65- const m = await recv();66- return { ok: m[2] as boolean, msg: m[3] as string };67- },68- };69-}70-7140 function zapRequest(payer: Uint8Array, host: string, msats: number) {7241 return ev(payer, 9734, "", [["p", SERVICE], ["amount", String(msats)], ["relays", "wss://" + host]]);7342 }@@ -111,7 +80,7 @@ describe("fuel", () => {
11180 status = await (await SELF.fetch(`http://${host}/fuel`)).json();11281 expect(status.outOfFuel).toBe(true);11382 expect(status.chargedMsats).toBeGreaterThan(0);114- const c = await ws(host);83+ const c = await WS.connect(host);11584 expect((await c.ok(ev(owner, 1, "no fuel"))).msg).toMatch(/^restricted: .*out of fuel/);11685 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();11786 expect(info.limitation.payment_required).toBe(true);@@ -169,7 +138,7 @@ describe("fuel", () => {
169138 const payload = { method: "setpolicy", params: [{ writes: "owner" }] };170139 const token = await getToken(url, "POST", (e) => finalizeEvent(e, owner), true, payload);171140 await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });172- const c = await ws(host);141+ const c = await WS.connect(host);173142 expect((await c.ok(ev(generateSecretKey(), 1, "stranger"))).msg).toMatch(/^restricted:/);174143 const req = zapRequest(generateSecretKey(), host, 21_000);175144 expect((await c.ok(receipt(req, "lnbc210n1x"))).msg).toBe("fuel: credited 21 sats");@@ -192,7 +161,7 @@ describe("fuel", () => {
192161 const host = "fuel-c.bind.ws";193162 const owner = generateSecretKey();194163 await claim(host, owner);195- const c = await ws(host);164+ const c = await WS.connect(host);196165 for (let i = 0; i < 5; i++) await c.ok(ev(owner, 1, "metered " + i));197166 const stub = env.RELAY.getByName("fuel-c");198167 await runInDurableObject(stub, async (r: Relay) => {@@ -3,88 +3,10 @@
33 // that hide an event until a moderator looks.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";8-import { sha256 } from "@noble/hashes/sha2.js";9-import { bytesToHex } from "../src/negentropy.ts";10-11-const now = () => Math.floor(Date.now() / 1000);12-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);13-const pk = (sk: Uint8Array) => getPublicKey(sk);14-15-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {16- const url = `http://${host}/`;17- const payload = { method, params };18- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);19- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });20- return { status: resp.status, ...(await resp.json<any>()) };21-}22-23-class WS {24- private queue: any[][] = [];25- private waiters: ((m: any[]) => void)[] = [];26- constructor(public ws: WebSocket) {27- ws.accept();28- ws.addEventListener("message", (e) => {29- const m = JSON.parse(e.data as string);30- const w = this.waiters.shift();31- if (w) w(m);32- else this.queue.push(m);33- });34- }35- static async connect(host: string) {36- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });37- const c = new WS(resp.webSocket!);38- await c.expect("AUTH");39- return c;40- }41- send(...m: unknown[]) {42- this.ws.send(JSON.stringify(m));43- }44- recv(): Promise<any[]> {45- const m = this.queue.shift();46- if (m) return Promise.resolve(m);47- return new Promise((res) => this.waiters.push(res));48- }49- async expect(type: string) {50- const m = await this.recv();51- expect(m[0], JSON.stringify(m)).toBe(type);52- return m;53- }54- async ok(e: Event) {55- this.send("EVENT", e);56- const m = await this.expect("OK");57- return { ok: m[2] as boolean, msg: m[3] as string };58- }59- private n = 0;60- async req(filter: unknown) {61- const id = "q" + ++this.n;62- this.send("REQ", id, filter);63- const events: Event[] = [];64- for (;;) {65- const m = await this.recv();66- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);67- else if (m[0] === "EOSE" && m[1] === id) {68- this.send("CLOSE", id);69- return events;70- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);71- }72- }73- async count(filter: unknown) {74- const id = "c" + ++this.n;75- this.send("COUNT", id, filter);76- const m = await this.expect("COUNT");77- return m[2].count as number;78- }79-}80-81-async function upload(host: string, sk: Uint8Array, text: string) {82- const body = new TextEncoder().encode(text);83- const sha = bytesToHex(sha256(body));84- const token = "Nostr " + btoa(JSON.stringify(ev(sk, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));85- const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body });86- return { status: resp.status, sha };87-}6+import { generateSecretKey } from "nostr-tools/pure";7+import { now, ev, pk, rpc } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";9+import { upload } from "./helpers/media.ts";8810 8911 describe("guests", () => {9012 it("lets a stranger through a members-only rule for open kinds and for replies to members", async () => {@@ -244,7 +166,7 @@ describe("report thresholds", () => {
244166 expect((await c.ok(report(r2))).ok).toBe(true);245167 // Hidden everywhere stored events are read.246168 expect(await c.req({ ids: [note.id] })).toEqual([]);247- expect(await c.count({ authors: [pk(author)] })).toBe(0);169+ expect((await c.count({ authors: [pk(author)] })).count).toBe(0);248170 expect((await SELF.fetch(`http://${host}/e/${note.id}`)).status).toBe(404);249171 const feed = await (await SELF.fetch(`http://${host}/feed.xml`)).text();250172 expect(feed.includes(note.id)).toBe(false);@@ -3,85 +3,10 @@
33 // and ownership transfer.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";6+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7+import { ev, tagsOf, rpc, info } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";89 9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);11-12-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {13- const url = `http://${host}/`;14- const payload = { method, params };15- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);16- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });17- return { status: resp.status, ...(await resp.json<any>()) };18-}19-20-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();21-22-class WS {23- private queue: any[][] = [];24- private waiters: ((m: any[]) => void)[] = [];25- constructor(public ws: WebSocket) {26- ws.accept();27- ws.addEventListener("message", (e) => {28- const m = JSON.parse(e.data as string);29- const w = this.waiters.shift();30- if (w) w(m);31- else this.queue.push(m);32- });33- }34- static async connect(host: string) {35- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });36- const c = new WS(resp.webSocket!);37- await c.expect("AUTH");38- return c;39- }40- send(...m: unknown[]) {41- this.ws.send(JSON.stringify(m));42- }43- recv(): Promise<any[]> {44- const m = this.queue.shift();45- if (m) return Promise.resolve(m);46- return new Promise((res) => this.waiters.push(res));47- }48- async expect(type: string) {49- const m = await this.recv();50- expect(m[0], JSON.stringify(m)).toBe(type);51- return m;52- }53- // ok publishes and waits for the OK. Live pushes the relay fans out while54- // handling the event (its own put-user, say) arrive first; they are kept55- // in order for a later expect.56- async ok(e: Event) {57- this.send("EVENT", e);58- const held: any[][] = [];59- for (;;) {60- const m = await this.recv();61- if (m[0] === "OK" && m[1] === e.id) {62- this.queue.unshift(...held);63- return { ok: m[2] as boolean, msg: m[3] as string };64- }65- held.push(m);66- }67- }68- private n = 0;69- async req(filter: unknown) {70- const id = "q" + ++this.n;71- this.send("REQ", id, filter);72- const events: Event[] = [];73- for (;;) {74- const m = await this.recv();75- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);76- else if (m[0] === "EOSE" && m[1] === id) {77- this.send("CLOSE", id);78- return events;79- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);80- }81- }82-}83-84-const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);8510 const has = (e: Event, name: string) => e.tags.some((t) => t[0] === name);8611 8712 // state fetches the four relay-signed group events, keyed by kind.@@ -0,0 +1,18 @@
1+// Blossom from the outside: the kind 24242 token and an upload.2+import { SELF } from "cloudflare:test";3+import { sha256 } from "@noble/hashes/sha2.js";4+import { bytesToHex } from "../../src/negentropy.ts";5+import { ev, now } from "./relay.ts";6+7+// blossomToken mints a kind 24242 token for an action, naming a hash when given.8+export const blossomToken = (sk: Uint8Array, action: string, sha?: string) =>9+ "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]])));10+11+// upload puts a text file and returns its hash with the descriptor, when there is one.12+export async function upload(host: string, sk: Uint8Array, text: string) {13+ const body = new TextEncoder().encode(text);14+ const sha = bytesToHex(sha256(body));15+ const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", sha), "content-type": "text/plain" }, body });16+ const raw = await resp.text();17+ return { status: resp.status, sha, body: raw ? JSON.parse(raw) : null };18+}@@ -0,0 +1,63 @@
1+// Shared by the Durable Object tests: keys and events, the NIP-86 management2+// call, the information document, the HTTP bridge, and the alarm.3+import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { finalizeEvent, getPublicKey, type Event } from "nostr-tools/pure";5+import { getToken } from "nostr-tools/nip98";6+import type { Relay } from "../../src/relay.ts";7+import type { Job } from "../../src/jobs.ts";8+9+export const now = () => Math.floor(Date.now() / 1000);10+export const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()): Event => finalizeEvent({ kind, content, tags, created_at }, sk);11+export const pk = (sk: Uint8Array) => getPublicKey(sk);12+export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));13+export const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);14+15+// nip98 signs a request for the exact URL, with the body when there is one.16+export const nip98 = (sk: Uint8Array, url: string, method = "GET", body?: unknown) => getToken(url, method, (e) => finalizeEvent(e, sk), true, body as object | undefined);17+18+// rpc calls the NIP-86 management API as the given key, or unsigned.19+export async function rpc(host: string, sk: Uint8Array | null, method: string, ...params: unknown[]) {20+ const url = `http://${host}/`;21+ const payload = { method, params };22+ const headers: Record<string, string> = { "content-type": "application/nostr+json+rpc" };23+ if (sk) headers.authorization = await nip98(sk, url, "POST", payload);24+ const resp = await SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });25+ return { status: resp.status, ...(await resp.json<any>()) };26+}27+28+// info reads the NIP-11 document.29+export const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();30+31+// get fetches a path, signed when a key is given.32+export async function get(host: string, path: string, sk: Uint8Array | null = null) {33+ const url = `http://${host}${path}`;34+ const headers: Record<string, string> = {};35+ if (sk) headers.authorization = await nip98(sk, url);36+ return SELF.fetch(url, { headers });37+}38+39+// post sends a JSON body to a bridge path, signed when a key is given. The40+// answer is parsed when there is one.41+export async function post(host: string, sk: Uint8Array | null, path: string, body: unknown, headers: Record<string, string> = {}) {42+ const url = `http://${host}${path}`;43+ headers = { "content-type": "application/json", ...headers };44+ if (sk) headers.authorization = await nip98(sk, url, "POST", body);45+ const resp = await SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(body) });46+ const text = await resp.text();47+ return { status: resp.status, body: text ? JSON.parse(text) : null };48+}49+50+// alarm fires the relay's alarm by hand.51+export const alarm = (name: string) => runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => r.alarm());52+53+// drive fires the alarm until no job is running or due, and returns the list.54+export async function drive(host: string, owner: Uint8Array): Promise<Job[]> {55+ const name = host.split(".")[0];56+ for (let i = 0; i < 80; i++) {57+ await alarm(name);58+ const jobs = (await rpc(host, owner, "listjobs")).result as Job[];59+ if (!jobs.some((j) => j.running || (j.nextRun > 0 && j.nextRun <= now()))) return jobs;60+ await sleep(25);61+ }62+ throw new Error("jobs did not settle");63+}@@ -0,0 +1,143 @@
1+// A websocket client for the Durable Object tests: NIP-01 over an upgrade2+// through the Worker, with the frames queued so a test reads them in order.3+import { SELF } from "cloudflare:test";4+import { expect } from "vitest";5+import type { Event } from "nostr-tools/pure";6+import { ev } from "./relay.ts";7+8+export class WS {9+ private queue: any[][] = [];10+ private waiters: ((m: any[]) => void)[] = [];11+ private n = 0;12+ challenge = "";13+ closed: { code: number; reason: string } | null = null;14+ frames: string[] = []; // every frame received, for leak checks15+ constructor(public ws: WebSocket) {16+ ws.accept();17+ ws.addEventListener("message", (e) => {18+ this.frames.push(e.data as string);19+ const m = JSON.parse(e.data as string);20+ const w = this.waiters.shift();21+ if (w) w(m);22+ else this.queue.push(m);23+ });24+ ws.addEventListener("close", (e) => {25+ this.closed = { code: e.code, reason: e.reason };26+ });27+ }28+ // connect opens a socket, from an address when one is given, and reads the29+ // AUTH challenge.30+ static async connect(host: string, ip?: string): Promise<WS> {31+ const c = await WS.tryConnect(host, ip);32+ if (!c) throw new Error(`${host} refused the upgrade`);33+ return c;34+ }35+ // tryConnect is connect for a socket the relay may refuse: null then.36+ static async tryConnect(host: string, ip?: string): Promise<WS | null> {37+ const headers: Record<string, string> = { upgrade: "websocket" };38+ if (ip) headers["cf-connecting-ip"] = ip;39+ const resp = await SELF.fetch(`http://${host}/`, { headers });40+ if (!resp.webSocket) return null;41+ const c = new WS(resp.webSocket);42+ c.challenge = (await c.expect("AUTH"))[1];43+ return c;44+ }45+ send(...m: unknown[]) {46+ this.ws.send(JSON.stringify(m));47+ }48+ raw(s: string) {49+ this.ws.send(s);50+ }51+ recv(): Promise<any[]> {52+ const m = this.queue.shift();53+ if (m) return Promise.resolve(m);54+ return new Promise((res) => this.waiters.push(res));55+ }56+ // recvOr resolves null when nothing arrives within ms.57+ recvOr(ms: number): Promise<any[] | null> {58+ const m = this.queue.shift();59+ if (m) return Promise.resolve(m);60+ return new Promise((res) => {61+ this.waiters.push(res);62+ setTimeout(() => {63+ const i = this.waiters.indexOf(res);64+ if (i >= 0) {65+ this.waiters.splice(i, 1);66+ res(null);67+ }68+ }, ms);69+ });70+ }71+ async expect(type: string) {72+ const m = await this.recv();73+ expect(m[0], JSON.stringify(m)).toBe(type);74+ return m;75+ }76+ // ok publishes and waits for the OK. Live pushes the relay fans out while77+ // handling the event (its own put-user, say) arrive first; they are kept78+ // in order for a later expect.79+ async ok(e: Event) {80+ this.send("EVENT", e);81+ const held: any[][] = [];82+ for (;;) {83+ const m = await this.recv();84+ if (m[0] === "OK" && m[1] === e.id) {85+ this.queue.unshift(...held);86+ return { ok: m[2] as boolean, msg: m[3] as string };87+ }88+ held.push(m);89+ }90+ }91+ // auth proves a key over the socket.92+ async auth(sk: Uint8Array, host: string) {93+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));94+ const m = await this.expect("OK");95+ expect(m[2], m[3]).toBe(true);96+ }97+ // query is a one-shot subscription: unique id, closed after EOSE so it98+ // never leaks live pushes into a later query. The events and "" on EOSE,99+ // or the CLOSED reason.100+ async query(filter: unknown) {101+ const id = "q" + ++this.n;102+ const r = await this.open(id, filter);103+ if (!r.closed) this.send("CLOSE", id);104+ return r;105+ }106+ // req is query for a subscription that must be accepted: the events.107+ async req(filter: unknown): Promise<Event[]> {108+ const r = await this.query(filter);109+ if (r.closed) throw new Error(r.closed);110+ return r.events;111+ }112+ // open subscribes under a given id and stays subscribed: the stored events113+ // and "" on EOSE, or the CLOSED reason.114+ async open(id: string, ...filters: unknown[]) {115+ this.send("REQ", id, ...filters);116+ const events: Event[] = [];117+ const held: any[][] = []; // frames for other subscriptions, kept in order118+ const done = (closed: string) => {119+ this.queue.unshift(...held);120+ return { events, closed };121+ };122+ for (;;) {123+ const m = await this.recv();124+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);125+ else if (m[0] === "EOSE" && m[1] === id) return done("");126+ else if (m[0] === "CLOSED" && m[1] === id) return done(m[2] as string);127+ else if (m[0] === "EVENT" || m[0] === "CLOSED") held.push(m);128+ else throw new Error(JSON.stringify(m));129+ }130+ }131+ // count asks for a COUNT: the number and "" or the CLOSED reason.132+ async count(filter: unknown, id = "c" + ++this.n) {133+ this.send("COUNT", id, filter);134+ const m = await this.recv();135+ return m[0] === "COUNT" ? { count: m[2].count as number, closed: "" } : { count: -1, closed: m[2] as string };136+ }137+ // sync opens a NIP-77 reconciliation and returns the NEG-ERR reason, or "".138+ async sync(id: string, filter: unknown) {139+ this.send("NEG-OPEN", id, filter, "61");140+ const m = await this.recv();141+ return m[0] === "NEG-ERR" ? (m[2] as string) : "";142+ }143+}@@ -1,76 +1,13 @@
11 // Jobs: owner backfill from the relays in a kind 10002, rebroadcast to22 // other relays with a cursor, standing jobs on an interval, caps and33 // removal. Relays on this host are dialled object to object.4-import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";6+import { generateSecretKey, type Event } from "nostr-tools/pure";87 import type { Relay } from "../src/relay.ts";98 import type { Job } from "../src/jobs.ts";10-11-const now = () => Math.floor(Date.now() / 1000);12-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);13-const pk = (sk: Uint8Array) => getPublicKey(sk);14-15-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {16- const url = `http://${host}/`;17- const payload = { method, params };18- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);19- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });20- return { status: resp.status, ...(await resp.json<any>()) };21-}22-23-class WS {24- private queue: any[][] = [];25- private waiters: ((m: any[]) => void)[] = [];26- constructor(public ws: WebSocket) {27- ws.accept();28- ws.addEventListener("message", (e) => {29- const m = JSON.parse(e.data as string);30- const w = this.waiters.shift();31- if (w) w(m);32- else this.queue.push(m);33- });34- }35- static async connect(host: string) {36- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });37- const c = new WS(resp.webSocket!);38- await c.expect("AUTH");39- return c;40- }41- send(...m: unknown[]) {42- this.ws.send(JSON.stringify(m));43- }44- recv(): Promise<any[]> {45- const m = this.queue.shift();46- if (m) return Promise.resolve(m);47- return new Promise((res) => this.waiters.push(res));48- }49- async expect(type: string) {50- const m = await this.recv();51- expect(m[0], JSON.stringify(m)).toBe(type);52- return m;53- }54- async ok(e: Event) {55- this.send("EVENT", e);56- const m = await this.expect("OK");57- return { ok: m[2] as boolean, msg: m[3] as string };58- }59- private n = 0;60- async req(filter: unknown) {61- const id = "q" + ++this.n;62- this.send("REQ", id, filter);63- const events: Event[] = [];64- for (;;) {65- const m = await this.recv();66- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);67- else if (m[0] === "EOSE" && m[1] === id) {68- this.send("CLOSE", id);69- return events;70- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);71- }72- }73-}9+import { now, ev, pk, rpc, drive } from "./helpers/relay.ts";10+import { WS } from "./helpers/ws.ts";7411 7512 // relay claims a name and posts the given events to it.7613 async function relay(host: string, owner: Uint8Array, events: Event[] = []) {@@ -80,20 +17,6 @@ async function relay(host: string, owner: Uint8Array, events: Event[] = []) {
8017 return c;8118 }8219 83-const due = (j: Job) => j.running || (j.nextRun > 0 && j.nextRun <= now());84-85-// drive fires the alarm until no job is running or due, and returns the list.86-async function drive(host: string, owner: Uint8Array): Promise<Job[]> {87- const stub = env.RELAY.getByName(host.split(".")[0]);88- for (let i = 0; i < 80; i++) {89- await runInDurableObject(stub, async (r: Relay) => r.alarm());90- const jobs = (await rpc(host, owner, "listjobs")).result as Job[];91- if (!jobs.some(due)) return jobs;92- await new Promise((r) => setTimeout(r, 25));93- }94- throw new Error("jobs did not settle");95-}96-9720 describe("owner backfill", () => {9821 it("fetches the owner's own history from the relays in their kind 10002, and nothing else", async () => {9922 const me = generateSecretKey();@@ -3,25 +3,16 @@
33 // copies one relay into another.44 import { SELF, env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";77 import { getToken } from "nostr-tools/nip98";8-import { sha256 } from "@noble/hashes/sha2.js";98 import type { Relay } from "../src/relay.ts";10-import { bytesToHex } from "../src/negentropy.ts";119 import { ADJECTIVES, ANIMALS } from "../src/names.ts";10+import { now, ev, rpc, info } from "./helpers/relay.ts";11+import { WS } from "./helpers/ws.ts";12+import { upload } from "./helpers/media.ts";1213 13-const now = () => Math.floor(Date.now() / 1000);14-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);1514 const APEX = "http://bind.ws";1615 17-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {18- const url = `http://${host}/`;19- const payload = { method, params };20- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);21- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });22- return { status: resp.status, ...(await resp.json<any>()) };23-}24-2516 // Each caller gets its own address so the per-address limit is tested on its own.2617 let callers = 0;2718 async function lease(sk: Uint8Array | null = null, ip = "10.0.0." + ++callers) {@@ -31,68 +22,6 @@ async function lease(sk: Uint8Array | null = null, ip = "10.0.0." + ++callers) {
3122 return { status: resp.status, ...(await resp.json<any>()) };3223 }3324 34-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();35-36-class WS {37- private queue: any[][] = [];38- private waiters: ((m: any[]) => void)[] = [];39- constructor(public ws: WebSocket) {40- ws.accept();41- ws.addEventListener("message", (e) => {42- const m = JSON.parse(e.data as string);43- const w = this.waiters.shift();44- if (w) w(m);45- else this.queue.push(m);46- });47- }48- static async connect(host: string) {49- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });50- const c = new WS(resp.webSocket!);51- await c.expect("AUTH");52- return c;53- }54- send(...m: unknown[]) {55- this.ws.send(JSON.stringify(m));56- }57- recv(): Promise<any[]> {58- const m = this.queue.shift();59- if (m) return Promise.resolve(m);60- return new Promise((res) => this.waiters.push(res));61- }62- async expect(type: string) {63- const m = await this.recv();64- expect(m[0], JSON.stringify(m)).toBe(type);65- return m;66- }67- async ok(e: Event) {68- this.send("EVENT", e);69- const m = await this.expect("OK");70- return { ok: m[2] as boolean, msg: m[3] as string };71- }72- private n = 0;73- async req(filter: unknown) {74- const id = "q" + ++this.n;75- this.send("REQ", id, filter);76- const events: Event[] = [];77- for (;;) {78- const m = await this.recv();79- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);80- else if (m[0] === "EOSE" && m[1] === id) {81- this.send("CLOSE", id);82- return events;83- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);84- }85- }86-}87-88-async function upload(host: string, sk: Uint8Array, text: string) {89- const body = new TextEncoder().encode(text);90- const sha = bytesToHex(sha256(body));91- const token = "Nostr " + btoa(JSON.stringify(ev(sk, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));92- const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body });93- return { status: resp.status, sha };94-}95-9625 // pull drives the alarm until the job is done; the runtime may fire it too.9726 async function pull(host: string, owner: Uint8Array, from: string) {9827 const started = await rpc(host, owner, "pullfrom", from);@@ -3,75 +3,9 @@
33 // swarm of sockets from multiplying a connection's allowance.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";8-9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);11-const pk = (sk: Uint8Array) => getPublicKey(sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}20-21-// post signs a NIP-98 request to a bridge path from a given address.22-async function post(host: string, sk: Uint8Array, path: string, body: unknown, ip: string) {23- const url = `http://${host}${path}`;24- const authorization = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>);25- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization, "cf-connecting-ip": ip }, body: JSON.stringify(body) });26- const text = await resp.text();27- return { status: resp.status, body: text ? JSON.parse(text) : null };28-}29-30-class WS {31- private queue: any[][] = [];32- private waiters: ((m: any[]) => void)[] = [];33- closed: { code: number; reason: string } | null = null;34- constructor(public ws: WebSocket) {35- ws.accept();36- ws.addEventListener("message", (e) => {37- const m = JSON.parse(e.data as string);38- const w = this.waiters.shift();39- if (w) w(m);40- else this.queue.push(m);41- });42- ws.addEventListener("close", (e) => {43- this.closed = { code: e.code, reason: e.reason };44- });45- }46- // connect opens a socket from an address; null when the relay refused the upgrade.47- static async connect(host: string, ip: string): Promise<WS | null> {48- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": ip } });49- if (!resp.webSocket) return null;50- const c = new WS(resp.webSocket);51- await c.expect("AUTH");52- return c;53- }54- send(...m: unknown[]) {55- this.ws.send(JSON.stringify(m));56- }57- recv(): Promise<any[]> {58- const m = this.queue.shift();59- if (m) return Promise.resolve(m);60- return new Promise((res) => this.waiters.push(res));61- }62- async expect(type: string) {63- const m = await this.recv();64- expect(m[0], JSON.stringify(m)).toBe(type);65- return m;66- }67- async ok(e: Event) {68- this.send("EVENT", e);69- const m = await this.expect("OK");70- return { ok: m[2] as boolean, msg: m[3] as string };71- }72-}73-74-const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));6+import { generateSecretKey } from "nostr-tools/pure";7+import { ev, pk, sleep, rpc, post } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";759 7610 describe("listeventsneedingmoderation", () => {7711 it("lists open reports by reported thing, deduplicated, for owner and moderator only", async () => {@@ -84,7 +18,7 @@ describe("listeventsneedingmoderation", () => {
8418 await rpc(host, owner, "claim");8519 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });8620 await rpc(host, owner, "setmember", pk(member), {});87- const c = (await WS.connect(host, "10.9.0.1"))!;21+ const c = (await WS.tryConnect(host, "10.9.0.1"))!;8822 const note = ev(author, 1, "buy my coin");8923 expect((await c.ok(note)).ok).toBe(true);9024 // Two reports on the note, one on the author alone.@@ -117,7 +51,7 @@ describe("address blocks", () => {
11751 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });11852 await rpc(host, owner, "setmember", pk(member), {});11953 120- const open = (await WS.connect(host, bad))!;54+ const open = (await WS.tryConnect(host, bad))!;12155 expect((await open.ok(ev(member, 1, "before"))).ok).toBe(true);12256 expect((await rpc(host, member, "blockip", bad, "scraper")).status).toBe(403);12357 expect((await rpc(host, mod, "blockip", "not an address", "x")).status).toBe(400);@@ -126,23 +60,23 @@ describe("address blocks", () => {
12660 12761 for (let i = 0; i < 40 && !open.closed; i++) await sleep(25);12862 expect(open.closed?.code).toBe(4403);129- expect(await WS.connect(host, bad)).toBeNull();63+ expect(await WS.tryConnect(host, bad)).toBeNull();13064 const refused = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": bad } });13165 expect(refused.status).toBe(403);132- const door = await post(host, member, "/events", ev(member, 1, "still here?"), bad);66+ const door = await post(host, member, "/events", ev(member, 1, "still here?"), { "cf-connecting-ip": bad });13367 expect(door.status).toBe(403);13468 expect(door.body.error).toMatch(/^blocked: this address/);135- expect((await post(host, member, "/count", [{ kinds: [1] }], bad)).status).toBe(403);69+ expect((await post(host, member, "/count", [{ kinds: [1] }], { "cf-connecting-ip": bad })).status).toBe(403);13670 // Another address is unaffected, and so is management from the blocked one.137- expect((await post(host, member, "/events", ev(member, 1, "elsewhere"), "203.0.113.6")).status).toBe(200);138- expect(await WS.connect(host, "203.0.113.6")).not.toBeNull();71+ expect((await post(host, member, "/events", ev(member, 1, "elsewhere"), { "cf-connecting-ip": "203.0.113.6" })).status).toBe(200);72+ expect(await WS.tryConnect(host, "203.0.113.6")).not.toBeNull();13973 expect((await rpc(host, owner, "stats")).status).toBe(200);14074 // Blocks are part of the portable configuration.14175 expect((await rpc(host, owner, "exportconfig")).result.addresses).toEqual([{ ip: bad, reason: "scraper" }]);14276 14377 expect((await rpc(host, owner, "unblockip", bad)).result).toBe(true);14478 expect((await rpc(host, owner, "listblockedips")).result).toEqual([]);145- const again = await WS.connect(host, bad);79+ const again = await WS.tryConnect(host, bad);14680 expect(again).not.toBeNull();14781 expect((await again!.ok(ev(member, 1, "back"))).ok).toBe(true);14882 });@@ -153,7 +87,7 @@ describe("address blocks", () => {
15387 await rpc(host, owner, "claim");15488 expect((await rpc(host, owner, "blockip", "2001:DB8::1", "")).result).toBe(true);15589 expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: "2001:db8::1", reason: "" }]);156- expect(await WS.connect(host, "2001:db8::1")).toBeNull();90+ expect(await WS.tryConnect(host, "2001:db8::1")).toBeNull();15791 for (const bad of ["10.0.0.0/8", "relay.example", "1.2.3", "::::", "999.1.1.1"]) expect((await rpc(host, owner, "blockip", bad, "")).status).toBe(400);15892 });15993 });@@ -166,7 +100,7 @@ describe("per-address rate limit", () => {
166100 await rpc(host, owner, "setpolicy", { eventsPerMinute: 5 });167101 const ip = "198.51.100.7";168102 const socks: WS[] = [];169- for (let i = 0; i < 6; i++) socks.push((await WS.connect(host, ip))!);103+ for (let i = 0; i < 6; i++) socks.push((await WS.tryConnect(host, ip))!);170104 // Four per socket stays under the per-connection five; the address171105 // allowance is twenty, so the twenty-first event across them is refused.172106 let accepted = 0;@@ -181,7 +115,7 @@ describe("per-address rate limit", () => {
181115 expect(accepted).toBe(20);182116 expect(refused).toMatch(/^rate-limited:/);183117 // A lone socket from elsewhere is only bound by its own bucket.184- const alone = (await WS.connect(host, "198.51.100.8"))!;118+ const alone = (await WS.tryConnect(host, "198.51.100.8"))!;185119 for (let j = 0; j < 4; j++) expect((await alone.ok(ev(owner, 1, `alone${j}`))).ok).toBe(true);186120 });187121 @@ -191,10 +125,10 @@ describe("per-address rate limit", () => {
191125 await rpc(host, owner, "claim");192126 await rpc(host, owner, "setpolicy", { reqsPerMinute: 1 });193127 const ip = "198.51.100.9";194- for (let i = 0; i < 4; i++) expect((await post(host, owner, "/count", [{ kinds: [1] }], ip)).status).toBe(200);195- const fifth = await post(host, owner, "/count", [{ kinds: [1] }], ip);128+ for (let i = 0; i < 4; i++) expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip })).status).toBe(200);129+ const fifth = await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip });196130 expect(fifth.status).toBe(429);197131 expect(fifth.body.error).toMatch(/^rate-limited:/);198- expect((await post(host, owner, "/count", [{ kinds: [1] }], "198.51.100.10")).status).toBe(200);132+ expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": "198.51.100.10" })).status).toBe(200);199133 });200134 });@@ -3,31 +3,11 @@
33 // same bucket and table.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";6+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";87 import { sha256 } from "@noble/hashes/sha2.js";98 import { bytesToHex } from "../src/negentropy.ts";10-11-const now = () => Math.floor(Date.now() / 1000);12-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);13-14-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {15- const url = `http://${host}/`;16- const payload = { method, params };17- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);18- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });19- return { status: resp.status, ...(await resp.json<any>()) };20-}21-22-const blossomToken = (sk: Uint8Array, sha: string, action = "upload") =>23- "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ["x", sha], ["expiration", String(now() + 300)]])));24-25-async function upload(host: string, sk: Uint8Array, text: string) {26- const body = new TextEncoder().encode(text);27- const sha = bytesToHex(sha256(body));28- const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(sk, sha), "content-type": "text/plain" }, body });29- return { status: resp.status, sha, body: await resp.json<any>() };30-}9+import { ev, rpc } from "./helpers/relay.ts";10+import { upload } from "./helpers/media.ts";3111 3212 // nip98 signs a token by hand so the payload tag can be the file hash, as3313 // NIP-96 wants, rather than the hash of a JSON body.@@ -2,18 +2,8 @@
22 // service page, and what gets into the information document.33 import { SELF } from "cloudflare:test";44 import { describe, it, expect } from "vitest";5-import { finalizeEvent, generateSecretKey } from "nostr-tools/pure";6-import { getToken } from "nostr-tools/nip98";7-8-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {9- const url = `http://${host}/`;10- const payload = { method, params };11- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);12- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });13- return { status: resp.status, ...(await resp.json<any>()) };14-}15-16-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();5+import { generateSecretKey } from "nostr-tools/pure";6+import { rpc, info } from "./helpers/relay.ts";177 188 describe("NIP-11 extras", () => {199 it("publishes the policy links and lists once set, validated", async () => {@@ -2,84 +2,10 @@
22 // in the roster, the NIP-43 join and leave requests, and the promise that33 // the roster and the NIP-29 group never disagree, whichever path changed44 // the members.5-import { SELF } from "cloudflare:test";65 import { describe, it, expect } from "vitest";7-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";8-import { getToken } from "nostr-tools/nip98";9-10-const now = () => Math.floor(Date.now() / 1000);11-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12-const pk = (sk: Uint8Array) => getPublicKey(sk);13-14-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {15- const url = `http://${host}/`;16- const payload = { method, params };17- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);18- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });19- return { status: resp.status, ...(await resp.json<any>()) };20-}21-22-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();23-24-class WS {25- private queue: any[][] = [];26- private waiters: ((m: any[]) => void)[] = [];27- challenge = "";28- constructor(public ws: WebSocket) {29- ws.accept();30- ws.addEventListener("message", (e) => {31- const m = JSON.parse(e.data as string);32- const w = this.waiters.shift();33- if (w) w(m);34- else this.queue.push(m);35- });36- }37- static async connect(host: string) {38- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });39- const c = new WS(resp.webSocket!);40- c.challenge = (await c.expect("AUTH"))[1];41- return c;42- }43- send(...m: unknown[]) {44- this.ws.send(JSON.stringify(m));45- }46- recv(): Promise<any[]> {47- const m = this.queue.shift();48- if (m) return Promise.resolve(m);49- return new Promise((res) => this.waiters.push(res));50- }51- async expect(type: string) {52- const m = await this.recv();53- expect(m[0], JSON.stringify(m)).toBe(type);54- return m;55- }56- async ok(e: Event) {57- this.send("EVENT", e);58- const m = await this.expect("OK");59- return { ok: m[2] as boolean, msg: m[3] as string };60- }61- async auth(sk: Uint8Array, host: string) {62- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));63- const m = await this.expect("OK");64- expect(m[2], m[3]).toBe(true);65- }66- private n = 0;67- async req(filter: unknown) {68- const id = "q" + ++this.n;69- this.send("REQ", id, filter);70- const events: Event[] = [];71- for (;;) {72- const m = await this.recv();73- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);74- else if (m[0] === "EOSE" && m[1] === id) {75- this.send("CLOSE", id);76- return events;77- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);78- }79- }80-}81-82-const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);6+import { generateSecretKey } from "nostr-tools/pure";7+import { ev, pk, tagsOf, rpc, info } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";839 8410 describe("relay profile and roles", () => {8511 it("signs a kind 0 for the self key, role definitions, and roles in the roster", async () => {@@ -3,75 +3,9 @@
33 // remote signer's session for anyone, including someone about to claim it.44 import { SELF } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";8-9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);11-12-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {13- const url = `http://${host}/`;14- const payload = { method, params };15- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);16- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });17- return { status: resp.status, ...(await resp.json<any>()) };18-}19-20-class WS {21- private queue: any[][] = [];22- private waiters: ((m: any[]) => void)[] = [];23- constructor(public ws: WebSocket) {24- ws.accept();25- ws.addEventListener("message", (e) => {26- const m = JSON.parse(e.data as string);27- const w = this.waiters.shift();28- if (w) w(m);29- else this.queue.push(m);30- });31- }32- challenge = "";33- static async connect(host: string) {34- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });35- const c = new WS(resp.webSocket!);36- c.challenge = (await c.expect("AUTH"))[1];37- return c;38- }39- // auth proves a key over the socket: 24133 is a private kind, delivered40- // only to a socket that has proved it is the sender or the recipient.41- async auth(sk: Uint8Array, host: string) {42- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));43- const m = await this.expect("OK");44- expect(m[2], m[3]).toBe(true);45- }46- send(...m: unknown[]) {47- this.ws.send(JSON.stringify(m));48- }49- recv(): Promise<any[]> {50- const m = this.queue.shift();51- if (m) return Promise.resolve(m);52- return new Promise((res) => this.waiters.push(res));53- }54- async expect(type: string) {55- const m = await this.recv();56- expect(m[0], JSON.stringify(m)).toBe(type);57- return m;58- }59- async ok(e: Event) {60- this.send("EVENT", e);61- const m = await this.expect("OK");62- return { ok: m[2] as boolean, msg: m[3] as string };63- }64- // open subscribes and returns "" on EOSE or the CLOSED reason.65- async open(id: string, ...filters: unknown[]): Promise<string> {66- this.send("REQ", id, ...filters);67- for (;;) {68- const m = await this.recv();69- if (m[0] === "EOSE" && m[1] === id) return "";70- if (m[0] === "CLOSED" && m[1] === id) return m[2];71- if (m[0] !== "EVENT") throw new Error(JSON.stringify(m));72- }73- }74-}6+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";7+import { ev, rpc } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";759 7610 describe("NIP-46 transport", () => {7711 it("carries kind 24133 on an unclaimed relay, live only, never stored", async () => {@@ -81,10 +15,10 @@ describe("NIP-46 transport", () => {
8115 // A signer that names its key in the filter and never AUTHs, the way8216 // Amethyst's bunker listens under a transport key.8317 const listener = await WS.connect(host);84- expect(await listener.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");18+ expect((await listener.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).closed).toBe("");8519 // A bystander subscribed to the kind alone, naming no key.8620 const bystander = await WS.connect(host);87- expect(await bystander.open("nc", { kinds: [24133] })).toBe("");21+ expect((await bystander.open("nc", { kinds: [24133] })).closed).toBe("");8822 8923 const sender = await WS.connect(host);9024 const req = ev(client, 24133, "ciphertext", [["p", getPublicKey(signerKey)]]);@@ -95,20 +29,20 @@ describe("NIP-46 transport", () => {
9529 // The reply, addressed to the client, reaches a socket that AUTHed as the9630 // client, and one that asks by the signer's key as author.9731 const clientSock = await WS.connect(host);98- expect(await clientSock.open("nc", { kinds: [24133] })).toBe("");32+ expect((await clientSock.open("nc", { kinds: [24133] })).closed).toBe("");9933 await clientSock.auth(client, host);10034 const byAuthor = await WS.connect(host);101- expect(await byAuthor.open("nc", { kinds: [24133], authors: [getPublicKey(signerKey)] })).toBe("");35+ expect((await byAuthor.open("nc", { kinds: [24133], authors: [getPublicKey(signerKey)] })).closed).toBe("");10236 const reply = ev(signerKey, 24133, "ciphertext", [["p", getPublicKey(client)]]);10337 expect(await listener.ok(reply)).toEqual({ ok: true, msg: "" });10438 expect((await clientSock.expect("EVENT"))[2].id).toBe(reply.id);10539 expect((await byAuthor.expect("EVENT"))[2].id).toBe(reply.id);10640 // The bystander saw neither; a later frame proves the queue is empty.107- expect(await bystander.open("probe", { kinds: [24133] })).toBe("");41+ expect((await bystander.open("probe", { kinds: [24133] })).closed).toBe("");10842 // Anything else is still refused while unclaimed, and the request left no trace.10943 expect((await sender.ok(ev(client, 1, "hello"))).msg).toMatch(/unclaimed/);11044 const later = await WS.connect(host);111- expect(await later.open("q", { kinds: [24133] })).toBe("");45+ expect((await later.open("q", { kinds: [24133] })).closed).toBe("");11246 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();11347 expect(info.pubkey).toBeUndefined();11448 });@@ -134,11 +68,11 @@ describe("NIP-46 transport", () => {
13468 await rpc(host, owner, "claim");13569 await rpc(host, owner, "setpolicy", { reads: "members" });13670 const c = await WS.connect(host);137- expect(await c.open("a", { kinds: [24133] })).toBe("");138- expect(await c.open("b", { kinds: [24133], "#p": ["ab".repeat(32)] }, { kinds: [24133] })).toBe("");139- expect(await c.open("c", { kinds: [24133, 1] })).toMatch(/^auth-required/);140- expect(await c.open("d", { kinds: [24133] }, { kinds: [1] })).toMatch(/^auth-required/);141- expect(await c.open("e", {})).toMatch(/^auth-required/);71+ expect((await c.open("a", { kinds: [24133] })).closed).toBe("");72+ expect((await c.open("b", { kinds: [24133], "#p": ["ab".repeat(32)] }, { kinds: [24133] })).closed).toBe("");73+ expect((await c.open("c", { kinds: [24133, 1] })).closed).toMatch(/^auth-required/);74+ expect((await c.open("d", { kinds: [24133] }, { kinds: [1] })).closed).toMatch(/^auth-required/);75+ expect((await c.open("e", {})).closed).toMatch(/^auth-required/);14276 // NIP-11 says so, and advertises the transport.14377 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();14478 expect(info.limitation.auth_required).toBe(true);@@ -146,14 +80,14 @@ describe("NIP-46 transport", () => {
14680 // Amber's probe before it lists a relay: a throwaway key, no AUTH, a14781 // subscription naming the key, and its own event echoed back.14882 const probe = generateSecretKey();149- expect(await c.open("nc", { kinds: [24133], "#p": [getPublicKey(probe)] })).toBe("");83+ expect((await c.open("nc", { kinds: [24133], "#p": [getPublicKey(probe)] })).closed).toBe("");15084 const self = ev(probe, 24133, "Test bunker event", [["p", getPublicKey(probe)]]);15185 expect(await c.ok(self)).toEqual({ ok: true, msg: "" });15286 expect((await c.expect("EVENT"))[2].id).toBe(self.id);15387 // A signer's reply reaches the client that asked for it by key, unauthed.15488 const client = generateSecretKey();15589 const d = await WS.connect(host);156- expect(await d.open("nc", { kinds: [24133], "#p": [getPublicKey(client)] })).toBe("");90+ expect((await d.open("nc", { kinds: [24133], "#p": [getPublicKey(client)] })).closed).toBe("");15791 const reply = ev(owner, 24133, "ciphertext", [["p", getPublicKey(client)]]);15892 expect(await c.ok(reply)).toEqual({ ok: true, msg: "" });15993 expect((await d.expect("EVENT"))[2].id).toBe(reply.id);@@ -5,16 +5,7 @@ import { SELF } from "cloudflare:test";
55 import { describe, it, expect } from "vitest";66 import { finalizeEvent, generateSecretKey, type Event } from "nostr-tools/pure";77 import { getToken } from "nostr-tools/nip98";8-9-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {10- const url = `http://${host}/`;11- const payload = { method, params };12- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);13- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });14- return { status: resp.status, ...(await resp.json<any>()) };15-}16-17-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();8+import { rpc, info } from "./helpers/relay.ts";189 1910 // query reads through the HTTP bridge as the owner, so the read rule never gets in the way.2011 async function query(host: string, sk: Uint8Array, filter: unknown): Promise<Event[]> {@@ -1,70 +1,10 @@
11 // Rules an evaluator asked for: the socket message cap per relay, blocked22 // words that reach into tags and take a regular expression, and address33 // blocks that travel with the exported configuration.4-import { SELF } from "cloudflare:test";54 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";8-9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);11-const pk = (sk: Uint8Array) => getPublicKey(sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}20-21-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();22-23-class WS {24- private queue: any[][] = [];25- private waiters: ((m: any[]) => void)[] = [];26- closed: { code: number; reason: string } | null = null;27- constructor(public ws: WebSocket) {28- ws.accept();29- ws.addEventListener("message", (e) => {30- const m = JSON.parse(e.data as string);31- const w = this.waiters.shift();32- if (w) w(m);33- else this.queue.push(m);34- });35- ws.addEventListener("close", (e) => {36- this.closed = { code: e.code, reason: e.reason };37- });38- }39- static async connect(host: string, ip = "198.51.100.7"): Promise<WS | null> {40- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": ip } });41- if (!resp.webSocket) return null;42- const c = new WS(resp.webSocket);43- await c.expect("AUTH");44- return c;45- }46- send(...m: unknown[]) {47- this.ws.send(JSON.stringify(m));48- }49- raw(s: string) {50- this.ws.send(s);51- }52- recv(): Promise<any[]> {53- const m = this.queue.shift();54- if (m) return Promise.resolve(m);55- return new Promise((res) => this.waiters.push(res));56- }57- async expect(type: string) {58- const m = await this.recv();59- expect(m[0], JSON.stringify(m)).toBe(type);60- return m;61- }62- async ok(e: Event) {63- this.send("EVENT", e);64- const m = await this.expect("OK");65- return { ok: m[2] as boolean, msg: m[3] as string };66- }67-}5+import { generateSecretKey } from "nostr-tools/pure";6+import { ev, rpc, info } from "./helpers/relay.ts";7+import { WS } from "./helpers/ws.ts";688 699 describe("message size", () => {7010 it("is the owner's rule, clamped, and NIP-11 says so", async () => {@@ -79,7 +19,7 @@ describe("message size", () => {
7919 expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 16 })).result.maxMessageKB).toBe(16);8020 expect((await info(host)).limitation.max_message_length).toBe(16 * 1024);8121 82- const ws = (await WS.connect(host))!;22+ const ws = await WS.connect(host);8323 // A note of 20 KB is over the 16 KB cap: refused with a NOTICE, not stored.8424 const big = ev(owner, 1, "x".repeat(20 * 1024));8525 ws.raw(JSON.stringify(["EVENT", big]));@@ -114,7 +54,7 @@ describe("blocked words", () => {
11454 const kept = (await rpc(host, owner, "setblockedwords", ["Casino", "/free\\s+money/", "/\\bwin\\d{3,}/"])).result;11555 expect(kept).toEqual(["casino", "/free\\s+money/", "/\\bwin\\d{3,}/"]);11656 117- const ws = (await WS.connect(host))!;57+ const ws = await WS.connect(host);11858 expect((await ws.ok(ev(guest, 1, "FREE MONEY here"))).msg).toBe("blocked: content contains a blocked word");11959 expect((await ws.ok(ev(guest, 1, "you Win1234 now"))).msg).toBe("blocked: content contains a blocked word");12060 expect((await ws.ok(ev(guest, 1, "winner takes all"))).ok).toBe(true);@@ -152,7 +92,7 @@ describe("address blocks in the configuration", () => {
15292 15393 // The second relay has a stale block of its own and an open socket from the address about to be refused.15494 await rpc(b, owner, "blockip", "198.51.100.200", "old");155- const open = (await WS.connect(b, bad))!;95+ const open = (await WS.tryConnect(b, bad))!;15696 expect((await open.ok(ev(owner, 1, "before"))).ok).toBe(true);15797 cfg.addresses.push({ ip: "not an address", reason: "x" }, { reason: "no ip" });15898 const imported = await rpc(b, owner, "importconfig", cfg);@@ -161,12 +101,12 @@ describe("address blocks in the configuration", () => {
161101 expect((await rpc(b, owner, "listblockedips")).result.map((x: { ip: string }) => x.ip).sort()).toEqual(["2001:db8::9", bad]);162102 for (let i = 0; i < 40 && !open.closed; i++) await new Promise((r) => setTimeout(r, 25));163103 expect(open.closed?.code).toBe(4403);164- expect(await WS.connect(b, bad)).toBeNull();165- expect(await WS.connect(b, "198.51.100.200")).not.toBeNull();104+ expect(await WS.tryConnect(b, bad)).toBeNull();105+ expect(await WS.tryConnect(b, "198.51.100.200")).not.toBeNull();166106 // A document without the list clears the blocks, like the other lists.167107 delete cfg.addresses;168108 await rpc(b, owner, "importconfig", cfg);169109 expect((await rpc(b, owner, "listblockedips")).result).toEqual([]);170- expect(await WS.connect(b, bad)).not.toBeNull();110+ expect(await WS.tryConnect(b, bad)).not.toBeNull();171111 });172112 });@@ -1,28 +1,9 @@
11 // Presets: Haven's four relays as one-click rule bundles, and the owner's22 // own lists landing whatever the kind rules say.3-import { SELF } from "cloudflare:test";43 import { describe, it, expect } from "vitest";5-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6-import { getToken } from "nostr-tools/nip98";4+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";75 import { PRESETS } from "../src/presets.ts";8-9-const now = () => Math.floor(Date.now() / 1000);10-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);11-12-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {13- const url = `http://${host}/`;14- const payload = { method, params };15- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);16- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });17- return { status: resp.status, ...(await resp.json<any>()) };18-}19-20-async function post(host: string, sk: Uint8Array, path: string, body: unknown) {21- const url = `http://${host}${path}`;22- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>);23- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: token }, body: JSON.stringify(body) });24- return { status: resp.status, body: await resp.json<any>() };25-}6+import { ev, rpc, post } from "./helpers/relay.ts";267 278 describe("presets", () => {289 it("each preset sets writes, reads, directory, kind rules and retention as its bundle says", async () => {@@ -1,87 +1,18 @@
11 import { SELF, env, runInDurableObject } from "cloudflare:test";22 import { describe, it, expect } from "vitest";3-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";3+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";44 import { getToken } from "nostr-tools/nip98";55 import type { Relay } from "../src/relay.ts";66 import { difficulty } from "../src/event.ts";7-8-const now = () => Math.floor(Date.now() / 1000);9-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);10-11-// rpc calls the NIP-86 management API as the given key.12-async function rpc(host: string, sk: Uint8Array | null, method: string, ...params: unknown[]) {13- const url = `http://${host}/`;14- const payload = { method, params };15- const headers: Record<string, string> = { "content-type": "application/nostr+json+rpc" };16- if (sk) headers.authorization = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}20-21-class WS {22- private queue: any[][] = [];23- private waiters: ((m: any[]) => void)[] = [];24- challenge = "";25- constructor(public ws: WebSocket) {26- ws.accept();27- ws.addEventListener("message", (e) => {28- const m = JSON.parse(e.data as string);29- const w = this.waiters.shift();30- if (w) w(m);31- else this.queue.push(m);32- });33- }34- static async connect(host: string) {35- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });36- expect(resp.status).toBe(101);37- const c = new WS(resp.webSocket!);38- c.challenge = (await c.expect("AUTH"))[1];39- return c;40- }41- send(...m: unknown[]) {42- this.ws.send(JSON.stringify(m));43- }44- recv(): Promise<any[]> {45- const m = this.queue.shift();46- if (m) return Promise.resolve(m);47- return new Promise((res) => this.waiters.push(res));48- }49- async expect(type: string) {50- const m = await this.recv();51- expect(m[0], JSON.stringify(m)).toBe(type);52- return m;53- }54- async ok(e: Event) {55- this.send("EVENT", e);56- const m = await this.expect("OK");57- expect(m[1]).toBe(e.id);58- return { ok: m[2] as boolean, msg: m[3] as string };59- }60- async auth(sk: Uint8Array, host: string) {61- const e = ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]);62- this.send("AUTH", e);63- const m = await this.expect("OK");64- expect(m[2], m[3]).toBe(true);65- }66- async req(filter: unknown) {67- this.send("REQ", "q", filter);68- const events: Event[] = [];69- for (;;) {70- const m = await this.recv();71- if (m[0] === "EVENT") events.push(m[2]);72- else if (m[0] === "EOSE") return { events, closed: "" };73- else if (m[0] === "CLOSED") return { events, closed: m[2] as string };74- else throw new Error(JSON.stringify(m));75- }76- }77-}7+import { now, ev, rpc } from "./helpers/relay.ts";8+import { WS } from "./helpers/ws.ts";789 7910 describe("claiming", () => {8011 it("starts unclaimed: reads work, writes are refused, management needs a claim", async () => {8112 const host = "alpha.bind.ws";8213 const sk = generateSecretKey();8314 const c = await WS.connect(host);84- expect((await c.req({ kinds: [1] })).events).toEqual([]);15+ expect(await c.req({ kinds: [1] })).toEqual([]);8516 const r = await c.ok(ev(sk, 1, "hi"));8617 expect(r.ok).toBe(false);8718 expect(r.msg).toMatch(/^restricted: .*unclaimed/);@@ -157,7 +88,8 @@ describe("policy", () => {
15788 const e = ev(stranger, 1, "offensive");15889 expect((await c.ok(e)).ok).toBe(true);15990 await rpc(host, owner, "banevent", e.id, "nope");160- expect((await c.req({ ids: [e.id] })).events).toEqual([]);91+ // Left open on purpose: the read rule below has to close it.92+ expect((await c.open("q", { ids: [e.id] })).events).toEqual([]);16193 expect((await c.ok(e)).msg).toMatch(/^blocked:/);16294 expect((await rpc(host, owner, "listbannedevents")).result).toEqual([{ id: e.id, reason: "nope" }]);16395 @@ -182,9 +114,9 @@ describe("policy", () => {
182114 // reads need auth: tightening the rule closes the subscription still open183115 await rpc(host, owner, "setpolicy", { reads: "auth" });184116 expect((await c.expect("CLOSED"))[2]).toMatch(/^auth-required:/);185- expect((await c.req({ kinds: [1] })).closed).toMatch(/^auth-required:/);117+ expect((await c.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);186118 await c.auth(stranger, host);187- expect((await c.req({ kinds: [1] })).events.length).toBeGreaterThan(0);119+ expect((await c.req({ kinds: [1] })).length).toBeGreaterThan(0);188120 189121 // NIP-11 reflects it all190122 await rpc(host, owner, "changerelayname", "Gamma Club");@@ -236,7 +168,7 @@ describe("worker routing", () => {
236168 const a = await WS.connect("one.bind.ws");237169 expect((await a.ok(ev(sk, 1, "only here"))).ok).toBe(true);238170 const b = await WS.connect("two.bind.ws");239- expect((await b.req({ kinds: [1] })).events).toEqual([]);171+ expect(await b.req({ kinds: [1] })).toEqual([]);240172 const page = await SELF.fetch("http://one.bind.ws/");241173 expect(page.headers.get("content-type")).toContain("text/html");242174 });@@ -1,80 +1,15 @@
11 // Succession: an heir and a dead-man's switch. The owner's presence is22 // recorded on signed actions; silence past the delay starts a month of33 // warnings; silence through the month hands the relay to the heir.4-import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";6-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";6+import { generateSecretKey } from "nostr-tools/pure";87 import { unwrapEvent } from "nostr-tools/nip59";98 import type { Relay } from "../src/relay.ts";9+import { now, pk, rpc, info, alarm } from "./helpers/relay.ts";10+import { WS } from "./helpers/ws.ts";1011 11-const now = () => Math.floor(Date.now() / 1000);1212 const DAY = 86400;13-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);14-const pk = (sk: Uint8Array) => getPublicKey(sk);15-16-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {17- const url = `http://${host}/`;18- const payload = { method, params };19- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);20- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });21- return { status: resp.status, ...(await resp.json<any>()) };22-}23-24-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();25-26-class WS {27- private queue: any[][] = [];28- private waiters: ((m: any[]) => void)[] = [];29- challenge = "";30- constructor(public ws: WebSocket) {31- ws.accept();32- ws.addEventListener("message", (e) => {33- const m = JSON.parse(e.data as string);34- const w = this.waiters.shift();35- if (w) w(m);36- else this.queue.push(m);37- });38- }39- static async connect(host: string) {40- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });41- const c = new WS(resp.webSocket!);42- c.challenge = (await c.expect("AUTH"))[1];43- return c;44- }45- send(...m: unknown[]) {46- this.ws.send(JSON.stringify(m));47- }48- recv(): Promise<any[]> {49- const m = this.queue.shift();50- if (m) return Promise.resolve(m);51- return new Promise((res) => this.waiters.push(res));52- }53- async expect(type: string) {54- const m = await this.recv();55- expect(m[0], JSON.stringify(m)).toBe(type);56- return m;57- }58- async auth(sk: Uint8Array, host: string) {59- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));60- const m = await this.expect("OK");61- expect(m[2], m[3]).toBe(true);62- }63- private n = 0;64- async req(filter: unknown) {65- const id = "q" + ++this.n;66- this.send("REQ", id, filter);67- const events: Event[] = [];68- for (;;) {69- const m = await this.recv();70- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);71- else if (m[0] === "EOSE" && m[1] === id) {72- this.send("CLOSE", id);73- return events;74- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);75- }76- }77-}7813 7914 // inbox reads the wraps addressed to a key and opens them with it.8015 async function inbox(host: string, sk: Uint8Array): Promise<string[]> {@@ -92,7 +27,6 @@ async function silence(name: string, days: number) {
9227 (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = Date.now();9328 });9429 }95-const alarm = (name: string) => runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => r.alarm());9630 9731 describe("succession", () => {9832 it("names an heir who must be a member, with an allowed delay", async () => {@@ -1,64 +1,18 @@
11 // NIP-77 across a wake: the filter of an open sync rides on the socket's22 // attachment, so a session whose Negentropy object left with the object's33 // memory carries on from the store instead of ending with closed:.4-import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";66 import { finalizeEvent, generateSecretKey, type Event } from "nostr-tools/pure";7-import { getToken } from "nostr-tools/nip98";87 import { sha256 } from "@noble/hashes/sha2.js";98 import type { Relay } from "../src/relay.ts";109 import { Negentropy, bytesToHex, hexToBytes, type SyncItem } from "../src/negentropy.ts";10+import { now, rpc } from "./helpers/relay.ts";11+import { WS } from "./helpers/ws.ts";1112 12-const now = () => Math.floor(Date.now() / 1000);13-const ev = (sk: Uint8Array, content: string, created_at: number) => finalizeEvent({ kind: 1, content, tags: [], created_at }, sk);13+const note = (sk: Uint8Array, content: string, created_at: number) => finalizeEvent({ kind: 1, content, tags: [], created_at }, sk);1414 const item = (e: Event): SyncItem => ({ timestamp: e.created_at, id: hexToBytes(e.id) });1515 16-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {17- const url = `http://${host}/`;18- const payload = { method, params };19- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);20- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });21- return { status: resp.status, ...(await resp.json<any>()) };22-}23-24-class WS {25- private queue: any[][] = [];26- private waiters: ((m: any[]) => void)[] = [];27- constructor(public ws: WebSocket) {28- ws.accept();29- ws.addEventListener("message", (e) => {30- const m = JSON.parse(e.data as string);31- const w = this.waiters.shift();32- if (w) w(m);33- else this.queue.push(m);34- });35- }36- static async connect(host: string): Promise<WS> {37- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });38- const c = new WS(resp.webSocket!);39- await c.expect("AUTH");40- return c;41- }42- send(...m: unknown[]) {43- this.ws.send(JSON.stringify(m));44- }45- recv(): Promise<any[]> {46- const m = this.queue.shift();47- if (m) return Promise.resolve(m);48- return new Promise((res) => this.waiters.push(res));49- }50- async expect(type: string) {51- const m = await this.recv();52- expect(m[0], JSON.stringify(m)).toBe(type);53- return m;54- }55- async ok(e: Event) {56- this.send("EVENT", e);57- const m = await this.expect("OK");58- return { ok: m[2] as boolean, msg: m[3] as string };59- }60-}61-6216 // wake empties the relay's in-memory sync sessions, which is what a6317 // hibernation does to them; the sockets and their attachments stay.6418 const wake = (name: string) => runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => (r as unknown as { syncs: Map<unknown, unknown> }).syncs.clear());@@ -74,9 +28,9 @@ async function seed(name: string) {
7428 await rpc(host, owner, "claim");7529 const c = await WS.connect(host);7630 const t = now() - 100;77- const notes = [ev(author, "one", t), ev(author, "two", t + 1), ev(author, "three", t + 2)];31+ const notes = [note(author, "one", t), note(author, "two", t + 1), note(author, "three", t + 2)];7832 for (const n of notes) expect((await c.ok(n)).ok).toBe(true);79- const stray = ev(author, "stray", t + 3);33+ const stray = note(author, "stray", t + 3);8034 const mine = [notes[0], notes[1], stray];8135 return { host, owner, c, notes, stray, mine };8236 }@@ -1,24 +1,13 @@
11 // Console search, importing a file, NIP-29 pins, and the weekly digest.22 import { SELF, env, runInDurableObject } from "cloudflare:test";33 import { describe, it, expect } from "vitest";4-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";5-import { getToken } from "nostr-tools/nip98";4+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";65 import { unwrapEvent } from "nostr-tools/nip59";76 import { sha256 } from "@noble/hashes/sha2.js";87 import type { Relay } from "../src/relay.ts";98 import { bytesToHex } from "../src/negentropy.ts";10-import type { Job } from "../src/jobs.ts";11-12-const now = () => Math.floor(Date.now() / 1000);13-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);14-15-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {16- const url = `http://${host}/`;17- const payload = { method, params };18- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);19- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });20- return { status: resp.status, ...(await resp.json<any>()) };21-}9+import { now, ev, rpc, drive } from "./helpers/relay.ts";10+import { WS } from "./helpers/ws.ts";2211 2312 // signedPut sends a raw body with a NIP-98 token whose payload tag is the body's hash.2413 async function signedPut(host: string, path: string, sk: Uint8Array | null, body: string) {@@ -32,78 +21,6 @@ async function signedPut(host: string, path: string, sk: Uint8Array | null, body
3221 return { status: resp.status, ...(await resp.json<any>()) };3322 }3423 35-class WS {36- private queue: any[][] = [];37- private waiters: ((m: any[]) => void)[] = [];38- challenge = "";39- constructor(public ws: WebSocket) {40- ws.accept();41- ws.addEventListener("message", (e) => {42- const m = JSON.parse(e.data as string);43- const w = this.waiters.shift();44- if (w) w(m);45- else this.queue.push(m);46- });47- }48- static async connect(host: string) {49- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });50- const c = new WS(resp.webSocket!);51- const m = await c.expect("AUTH");52- c.challenge = m[1];53- return c;54- }55- send(...m: unknown[]) {56- this.ws.send(JSON.stringify(m));57- }58- recv(): Promise<any[]> {59- const m = this.queue.shift();60- if (m) return Promise.resolve(m);61- return new Promise((res) => this.waiters.push(res));62- }63- async expect(type: string) {64- const m = await this.recv();65- expect(m[0], JSON.stringify(m)).toBe(type);66- return m;67- }68- async ok(e: Event) {69- this.send("EVENT", e);70- const m = await this.expect("OK");71- return { ok: m[2] as boolean, msg: m[3] as string };72- }73- async auth(sk: Uint8Array, host: string) {74- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));75- const m = await this.expect("OK");76- expect(m[2], m[3]).toBe(true);77- }78- private n = 0;79- async req(filter: unknown) {80- const id = "q" + ++this.n;81- this.send("REQ", id, filter);82- const got: Event[] = [];83- for (;;) {84- const m = await this.recv();85- if (m[0] === "EOSE" && m[1] === id) {86- this.send("CLOSE", id);87- return got;88- }89- if (m[0] === "EVENT" && m[1] === id) got.push(m[2]);90- else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);91- }92- }93-}94-95-// drive fires the alarm until no job is running or due.96-async function drive(host: string, owner: Uint8Array): Promise<Job[]> {97- const stub = env.RELAY.getByName(host.split(".")[0]);98- for (let i = 0; i < 80; i++) {99- await runInDurableObject(stub, async (r: Relay) => r.alarm());100- const jobs = (await rpc(host, owner, "listjobs")).result as Job[];101- if (!jobs.some((j) => j.running || (j.nextRun > 0 && j.nextRun <= now()))) return jobs;102- await new Promise((r) => setTimeout(r, 25));103- }104- throw new Error("jobs did not settle");105-}106-10724 describe("search in the console", () => {10825 it("finds notes by words and answers by role", async () => {10926 const host = "seek.bind.ws";@@ -6,17 +6,8 @@ import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nost
66 import { getToken } from "nostr-tools/nip98";77 import type { Relay } from "../src/relay.ts";88 import { KIND_PRESENCE, KIND_VIEW, viewD } from "../src/views.ts";9-10-const now = () => Math.floor(Date.now() / 1000);11-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}9+import { now, ev, tagsOf, rpc, info, alarm } from "./helpers/relay.ts";10+import { WS } from "./helpers/ws.ts";2011 2112 async function view(host: string, name: string, sk: Uint8Array | null = null) {2213 const url = `http://${host}/view/${name}`;@@ -26,77 +17,6 @@ async function view(host: string, name: string, sk: Uint8Array | null = null) {
2617 return { status: resp.status, body: (await resp.json()) as any };2718 }2819 29-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();30-31-class WS {32- private queue: any[][] = [];33- private waiters: ((m: any[]) => void)[] = [];34- constructor(public ws: WebSocket) {35- ws.accept();36- ws.addEventListener("message", (e) => {37- const m = JSON.parse(e.data as string);38- const w = this.waiters.shift();39- if (w) w(m);40- else this.queue.push(m);41- });42- }43- challenge = "";44- static async connect(host: string) {45- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });46- const c = new WS(resp.webSocket!);47- const m = await c.expect("AUTH");48- c.challenge = m[1];49- return c;50- }51- send(...m: unknown[]) {52- this.ws.send(JSON.stringify(m));53- }54- recv(ms = 0): Promise<any[] | null> {55- const m = this.queue.shift();56- if (m) return Promise.resolve(m);57- return new Promise((res) => {58- this.waiters.push(res);59- if (ms) setTimeout(() => {60- const i = this.waiters.indexOf(res);61- if (i >= 0) { this.waiters.splice(i, 1); res(null); }62- }, ms);63- });64- }65- async expect(type: string) {66- const m = await this.recv();67- expect(m![0], JSON.stringify(m)).toBe(type);68- return m!;69- }70- async ok(e: Event) {71- this.send("EVENT", e);72- const m = await this.expect("OK");73- return { ok: m[2] as boolean, msg: m[3] as string };74- }75- async auth(sk: Uint8Array, host: string) {76- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));77- const m = await this.expect("OK");78- expect(m[2], m[3]).toBe(true);79- }80- private n = 0;81- async req(filter: unknown) {82- const id = "q" + ++this.n;83- this.send("REQ", id, filter);84- const events: Event[] = [];85- for (;;) {86- const m = await this.recv();87- if (!m) throw new Error("no answer");88- if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);89- else if (m[0] === "EOSE" && m[1] === id) {90- this.send("CLOSE", id);91- return events;92- } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);93- }94- }95-}96-97-const alarm = (name: string) => runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => r.alarm());98-const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);99-10020 // A relay with an owner, a member with a profile and a relay list, a10121 // calendar event with an RSVP, two articles and a zap receipt.10222 async function seed(name: string) {@@ -221,14 +141,14 @@ describe("views", () => {
221141 // Seeding signed alice in, which broadcast once; start the throttle over.222142 await runInDurableObject(env.RELAY.getByName("views-presence"), async (r: Relay) => { (r as unknown as { presenceAt: number }).presenceAt = 0; });223143 await b.auth(bob, s.host);224- const first = await watcher.recv(2000);144+ const first = await watcher.recvOr(2000);225145 expect(first && first[0]).toBe("EVENT");226146 const pres = first![2] as Event;227147 expect(pres.kind).toBe(KIND_PRESENCE);228148 expect(tagsOf(pres, "p").some((t) => t[1] === getPublicKey(bob) && t[2] === "online")).toBe(true);229149 // A write inside the throttle window waits.230150 expect((await b.ok(ev(bob, 1, "hi", [], now()))).ok).toBe(true);231- expect(await watcher.recv(400)).toBeNull();151+ expect(await watcher.recvOr(400)).toBeNull();232152 const live = await view(s.host, "presence");233153 expect(live.status).toBe(200);234154 expect(tagsOf(live.body, "p").some((t) => t[1] === getPublicKey(bob) && t[2] === "online")).toBe(true);@@ -1,87 +1,17 @@
11 // Pages, the feed, and relay-signed notifications.22 import { SELF, env, runInDurableObject } from "cloudflare:test";33 import { describe, it, expect } from "vitest";4-import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";5-import { getToken } from "nostr-tools/nip98";4+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";65 import { unwrapEvent } from "nostr-tools/nip59";76 import * as nip19 from "nostr-tools/nip19";87 import type { Relay } from "../src/relay.ts";9-10-const now = () => Math.floor(Date.now() / 1000);11-const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12-13-async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14- const url = `http://${host}/`;15- const payload = { method, params };16- const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17- const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18- return { status: resp.status, ...(await resp.json<any>()) };19-}8+import { now, ev, rpc, info } from "./helpers/relay.ts";9+import { WS } from "./helpers/ws.ts";2010 2111 const get = async (host: string, path: string) => {2212 const r = await SELF.fetch(`http://${host}${path}`);2313 return { status: r.status, type: r.headers.get("content-type") ?? "", text: await r.text() };2414 };25-const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();26-27-class WS {28- private queue: any[][] = [];29- private waiters: ((m: any[]) => void)[] = [];30- challenge = "";31- constructor(public ws: WebSocket) {32- ws.accept();33- ws.addEventListener("message", (e) => {34- const m = JSON.parse(e.data as string);35- const w = this.waiters.shift();36- if (w) w(m);37- else this.queue.push(m);38- });39- }40- static async connect(host: string) {41- const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });42- const c = new WS(resp.webSocket!);43- const m = await c.expect("AUTH");44- c.challenge = m[1];45- return c;46- }47- send(...m: unknown[]) {48- this.ws.send(JSON.stringify(m));49- }50- recv(): Promise<any[]> {51- const m = this.queue.shift();52- if (m) return Promise.resolve(m);53- return new Promise((res) => this.waiters.push(res));54- }55- // recvOr resolves null when nothing arrives within ms.56- recvOr(ms: number): Promise<any[] | null> {57- return Promise.race([this.recv(), new Promise<null>((res) => setTimeout(() => res(null), ms))]);58- }59- async expect(type: string) {60- const m = await this.recv();61- expect(m[0], JSON.stringify(m)).toBe(type);62- return m;63- }64- async ok(e: Event) {65- this.send("EVENT", e);66- const m = await this.expect("OK");67- return { ok: m[2] as boolean, msg: m[3] as string };68- }69- async auth(sk: Uint8Array, host: string) {70- this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));71- const m = await this.expect("OK");72- expect(m[2], m[3]).toBe(true);73- }74- async live(filter: unknown, id: string) {75- this.send("REQ", id, filter);76- const got: Event[] = [];77- for (;;) {78- const m = await this.recv();79- if (m[0] === "EOSE" && m[1] === id) return got;80- if (m[0] === "EVENT") got.push(m[2]);81- else throw new Error(JSON.stringify(m));82- }83- }84-}8515 8616 describe("pages and feed", () => {8717 it("renders notes and articles with open graph tags, hides what must stay hidden", async () => {@@ -181,13 +111,13 @@ describe("relay-signed notifications", () => {
181111 expect((await s.ok(ev(stranger, 1984, "spam", [["p", getPublicKey(member), "spam"]]))).ok).toBe(true);182112 const o = await WS.connect(host);183113 await o.auth(owner, host);184- expect(await o.live({ kinds: [1059], "#p": [ownerPk] }, "inbox")).toEqual([]);114+ expect((await o.open("inbox", { kinds: [1059], "#p": [ownerPk] })).events).toEqual([]);185115 186116 const set = await rpc(host, owner, "setpolicy", { notify: { reports: true } });187117 expect(set.result.notify).toEqual({ reports: true, fuel: false, jobs: false, succession: false, digest: false });188118 const m = await WS.connect(host);189119 await m.auth(member, host);190- expect(await m.live({ kinds: [1059] }, "mine")).toEqual([]);120+ expect((await m.open("mine", { kinds: [1059] })).events).toEqual([]);191121 192122 expect((await s.ok(ev(stranger, 1984, "still spam", [["p", getPublicKey(member), "spam"]]))).ok).toBe(true);193123 const got = await o.expect("EVENT");@@ -205,7 +135,7 @@ describe("relay-signed notifications", () => {
205135 // Stored: the owner can fetch it later; nobody else can see it.206136 const o2 = await WS.connect(host);207137 await o2.auth(owner, host);208- expect((await o2.live({ kinds: [1059] }, "again")).map((e) => e.id)).toEqual([wrap.id]);138+ expect((await o2.open("again", { kinds: [1059] })).events.map((e) => e.id)).toEqual([wrap.id]);209139 const anon = await WS.connect(host);210140 anon.send("REQ", "anon", { kinds: [1059] });211141 const closed = await anon.expect("CLOSED");@@ -243,7 +173,7 @@ describe("relay-signed notifications", () => {
243173 });244174 const o = await WS.connect(host);245175 await o.auth(owner, host);246- const [wrap] = await o.live({ kinds: [1059] }, "fuel");176+ const [wrap] = (await o.open("fuel", { kinds: [1059] })).events;247177 expect(unwrapEvent(wrap, owner).content).toMatch(/out of fuel/);248178 });249179 });