clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / test / object / lease.test.ts
lease.test.ts 9.8K
1// Temporary leases: a memorable name anyone can use for a while, a claim2// that converts it in place, an expiry that wipes it, and a pull that3// copies one relay into another.4import { SELF, env, runInDurableObject } from "cloudflare:test";5import { describe, it, expect } from "vitest";6import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";7import { getToken } from "nostr-tools/nip98";8import type { Relay } from "../../src/relay.ts";9import { ADJECTIVES, ANIMALS } from "../../src/names.ts";10import { now, ev, rpc, info } from "../helpers/relay.ts";11import { WS } from "../helpers/ws.ts";12import { upload } from "../helpers/media.ts";1314const APEX = "http://bind.ws";1516// Each caller gets its own address so the per-address limit is tested on its own.17let callers = 0;18async function lease(sk: Uint8Array | null = null, ip = "10.0.0." + ++callers) {19 const headers: Record<string, string> = { "cf-connecting-ip": ip };20 if (sk) headers.authorization = await getToken(APEX + "/lease", "POST", (e) => finalizeEvent(e, sk), true);21 const resp = await SELF.fetch(APEX + "/lease", { method: "POST", headers });22 return { status: resp.status, ...(await resp.json<any>()) };23}2425// pull drives the alarm until the job is done; the runtime may fire it too.26async function pull(host: string, owner: Uint8Array, from: string) {27 const started = await rpc(host, owner, "pullfrom", from);28 expect(started.status, JSON.stringify(started)).toBe(200);29 const stub = env.RELAY.getByName(host.split(".")[0]);30 for (let i = 0; i < 40; i++) {31 await runInDurableObject(stub, async (r: Relay) => r.alarm());32 const st = (await rpc(host, owner, "pullstatus")).result;33 if (!st.running) return st.last;34 await new Promise((r) => setTimeout(r, 25));35 }36 throw new Error("pull did not finish");37}3839describe("temporary leases", () => {40 it("hands out a memorable name that anyone can write to, and a claim converts it in place", async () => {41 const l = await lease();42 expect(l.status).toBe(201);43 const [adj, animal] = l.name.split("-");44 if (animal) expect(ADJECTIVES.includes(adj) && ANIMALS.includes(animal)).toBe(true);45 else expect(/^[a-z]+\d\d$/.test(l.name) && ANIMALS.includes(l.name.slice(0, -2))).toBe(true);46 expect(l.url).toBe(`wss://${l.name}.bind.ws`);47 expect(l.holder).toBeUndefined();48 expect(l.expires_at).toBeGreaterThan(now() + 13 * 86400);49 const host = `${l.name}.bind.ws`;5051 let doc = await info(host);52 expect(doc.lease.expires_at).toBe(l.expires_at);53 expect(doc.lease.claim_url).toBe(`https://${host}/`);54 expect(doc.limitation.restricted_writes).toBe(false);55 expect(doc.pubkey).toBeUndefined();56 expect(doc.description).toMatch(/Temporary relay/);57 expect(doc.retention).toEqual([{ time: 14 * 86400 }]);5859 const stranger = generateSecretKey();60 const c = await WS.connect(host);61 expect((await c.ok(ev(stranger, 1, "hello from a lease"))).ok).toBe(true);62 const denied = await rpc(host, stranger, "stats");63 expect(denied.status).toBe(403);64 expect(denied.error).toMatch(/temporary relay/);6566 const owner = generateSecretKey();67 const claimed = await rpc(host, owner, "claim");68 expect(claimed.result).toEqual({ owner: getPublicKey(owner), claimed: true, converted: true });69 doc = await info(host);70 expect(doc.lease).toBeUndefined();71 expect(doc.pubkey).toBe(getPublicKey(owner));72 expect(doc.description).toBe("");73 expect(doc.self).toBeDefined();74 // The events stay; the lease's keep-for rule stays until the owner resets.75 expect((await c.req({ kinds: [1] })).length).toBe(1);76 expect((await rpc(host, owner, "listretention")).result).toEqual([{ kind: null, days: 14 }]);77 const reset = await rpc(host, owner, "resetrules");78 expect(reset.result.writes).toBe("open");79 expect((await rpc(host, owner, "listretention")).result).toEqual([]);80 expect((await rpc(host, owner, "claim")).result.converted).toBeUndefined();81 });8283 it("a signed lease is reserved for its key", async () => {84 const holder = generateSecretKey();85 const l = await lease(holder);86 expect(l.status).toBe(201);87 expect(l.holder).toBe(getPublicKey(holder));88 const host = `${l.name}.bind.ws`;89 expect((await info(host)).lease.holder).toBe(getPublicKey(holder));90 const other = generateSecretKey();91 const denied = await rpc(host, other, "claim");92 expect(denied.status).toBe(403);93 expect(denied.error).toMatch(/reserved/);94 expect((await rpc(host, holder, "claim")).result.claimed).toBe(true);95 });9697 it("caps leases per address", async () => {98 const ip = "203.0.113.7";99 for (let i = 0; i < 5; i++) expect((await lease(null, ip)).status).toBe(201);100 const sixth = await lease(null, ip);101 expect(sixth.status).toBe(429);102 expect(sixth.error).toMatch(/^rate-limited/);103 expect((await lease(null, "203.0.113.8")).status).toBe(201);104 });105106 it("refuses a bad signature on the lease request", async () => {107 const sk = generateSecretKey();108 const token = await getToken("http://elsewhere.bind.ws/lease", "POST", (e) => finalizeEvent(e, sk), true);109 const resp = await SELF.fetch(APEX + "/lease", { method: "POST", headers: { authorization: token, "cf-connecting-ip": "10.1.1.1" } });110 expect(resp.status).toBe(401);111 });112113 it("expires: the alarm wipes everything and frees the name", async () => {114 const l = await lease();115 const host = `${l.name}.bind.ws`;116 const sk = generateSecretKey();117 const c = await WS.connect(host);118 expect((await c.ok(ev(sk, 1, "soon gone"))).ok).toBe(true);119 expect((await upload(host, sk, "a file on a lease")).status).toBe(200);120 const stub = env.RELAY.getByName(l.name);121 await runInDurableObject(stub, async (r: Relay, state) => {122 expect(await state.storage.getAlarm()).toBeLessThanOrEqual(l.expires_at * 1000);123 r.settings.update({ lease: { until: now() - 1, holder: "" } });124 await r.alarm();125 });126 const doc = await info(host);127 expect(doc.lease).toBeUndefined();128 expect(doc.pubkey).toBeUndefined();129 expect(doc.limitation.restricted_writes).toBe(true);130 expect((await env.MEDIA.list({ prefix: `${l.name}/` })).objects).toEqual([]);131 const fresh = await WS.connect(host);132 expect(await fresh.req({ kinds: [1] })).toEqual([]);133 expect((await fresh.ok(ev(sk, 1, "again"))).msg).toMatch(/unclaimed/);134 // The freed name can be leased again.135 await runInDurableObject(stub, async (r: Relay) => expect(await r.lease(l.name, host, now() + 60, "")).toBe(""));136 expect((await info(host)).lease).toBeDefined();137 });138139 it("an expired lease refuses writes even before the alarm runs", async () => {140 const l = await lease();141 const host = `${l.name}.bind.ws`;142 await runInDurableObject(env.RELAY.getByName(l.name), async (r: Relay) => r.settings.update({ lease: { until: now() - 1, holder: "" } }));143 const c = await WS.connect(host);144 expect((await c.ok(ev(generateSecretKey(), 1, "too late"))).msg).toMatch(/expired/);145 expect((await rpc(host, generateSecretKey(), "claim")).status).toBe(403);146 });147});148149describe("pull from another relay", () => {150 it("copies events and files from a lease into a claimed relay, and again only what is new", async () => {151 const l = await lease();152 const src = `${l.name}.bind.ws`;153 const a = generateSecretKey();154 const b = generateSecretKey();155 const c = await WS.connect(src);156 const e1 = ev(a, 1, "one", [], now() - 30);157 const e2 = ev(b, 1, "two", [], now() - 20);158 const e3 = ev(a, 7, "+", [["e", e1.id]], now() - 10);159 for (const e of [e1, e2, e3]) expect((await c.ok(e)).ok).toBe(true);160 const { sha } = await upload(src, a, "a picture, allegedly");161162 const owner = generateSecretKey();163 const host = "fresh.bind.ws";164 await rpc(host, owner, "claim");165 await rpc(host, owner, "setpolicy", { writes: "owner" });166 expect((await rpc(host, owner, "pullfrom", "https://" + src)).status).toBe(400);167 expect((await rpc(host, owner, "pullfrom", "wss://" + host)).error).toMatch(/itself/);168 expect((await rpc(host, generateSecretKey(), "pullfrom", "wss://" + src)).status).toBe(403);169170 let last = await pull(host, owner, "wss://" + src);171 expect(last.error).toBe("");172 expect(last.stored).toBe(3);173 expect(last.blobs).toBe(1);174 const mine = await WS.connect(host);175 expect((await mine.req({ kinds: [1, 7] })).map((e) => e.id).sort()).toEqual([e1.id, e2.id, e3.id].sort());176 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);177 expect((await rpc(host, owner, "listblobs")).result.map((x: any) => x.sha256)).toEqual([sha]);178179 // A second pull after one more event on the source fetches only that one.180 const e4 = ev(b, 1, "four");181 expect((await c.ok(e4)).ok).toBe(true);182 last = await pull(host, owner, "wss://" + src);183 expect(last.stored).toBe(1);184 expect(last.blobs).toBe(0);185 expect((await mine.req({ ids: [e4.id] })).length).toBe(1);186 // Kind rules on the puller apply; the write policy does not.187 await rpc(host, owner, "disallowkind", 7);188 const e5 = ev(a, 7, "-", [["e", e2.id]]);189 expect((await c.ok(e5)).ok).toBe(true);190 last = await pull(host, owner, "wss://" + src);191 expect(last.stored).toBe(0);192 expect(last.skipped).toBe(1);193 });194195 it("falls back from sync and records a source that also refuses ordinary queries", async () => {196 const owner = generateSecretKey();197 const closedHost = "closed.bind.ws";198 await rpc(closedHost, owner, "claim");199 await rpc(closedHost, owner, "setpolicy", { reads: "members" });200 const host = "puller.bind.ws";201 await rpc(host, owner, "claim");202 const last = await pull(host, owner, "wss://" + closedHost);203 expect(last.error).toMatch(/import source.*incomplete/);204 expect(last.rounds).toBe(2);205 const jobs = (await rpc(host, owner, "listjobs")).result;206 expect(jobs[0].last.sources[0]).toMatchObject({ mode: "query", status: "refused", error: expect.stringContaining("auth-required") });207 });208});