clone

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

relay: address blocks and a per-address rate limit

Dami committed f5eca62
The worker stamps the client's address on every forwarded request as
x-relay-ip, taken from cf-connecting-ip and never from a client header. The
object keeps it in the socket state and the HTTP doors' virtual connection.

blockip, unblockip and listblockedips (NIP-86): a blocked address gets no
socket and no write or read door, and its open sockets are closed. The page,
NIP-11 and management stay reachable so a block on one's own address can be
undone. Blocks are not part of the exported configuration; addresses churn.

Per-address token buckets sit beside the per-connection ones at four times
the allowance, so a client cannot multiply its quota by opening sockets.
They are the HTTP bridge's rate limit too, which had none.
commit
f5eca62bddaa309ebbd60d73bf339b1f2df645ce
parent
f4acbb6df774004b9a47c7e6b28a0a4936d49696
author
Dami <dami.etoile@gmail.com>
7 files changed, +306 -9
Msrc/bridge.ts5+-
Msrc/index.ts3+
Msrc/manage.ts21+++
Msrc/relay.ts64+++++++++-
Msrc/roles.ts2+-
Msrc/settings.ts20+++
Atest/manage.test.ts200++++++++++++++++++++++++++++++
M src/bridge.ts +4 -1
M src/index.ts +3 -0
M src/manage.ts +21 -0
@@ -51,12 +51,19 @@ const METHODS = [
5151 "changerelayname", "changerelaydescription", "changerelayicon",5252 "createinvite", "listinvites", "revokeinvite", "listmembers",5353 "listreports", "resolvereport", "listeventsneedingmoderation",54+ "blockip", "unblockip", "listblockedips",5455 "listblobs", "deleteblob",5556 "storagestats", "setretention", "listretention", "purgekind",5657 "deleterelay", "exportconfig", "importconfig", "resetrules",5758 "pullfrom", "pullstatus", "transferowner",5859 ];5960 61+// validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.62+export function validIP(ip: string): boolean {63+ if (/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(ip)) return true;64+ return /^[0-9a-f:]{2,39}$/.test(ip) && ip.includes(":") && !ip.includes(":::") && ip.split("::").length <= 2;65+}66+6067 export async function manage(relay: Relay, req: Request): Promise<Response> {6168 const cors = { "access-control-allow-origin": "*", "content-type": "application/json" };6269 const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors });
@@ -207,6 +214,20 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
207214 }208215 return reply({ result: out });209216 }217+ case "blockip": {218+ const ip = str(0).trim().toLowerCase();219+ if (!validIP(ip)) return reply({ error: "invalid: not an IP address" }, 400);220+ relay.blockIP(ip, str(1).slice(0, 200));221+ return reply({ result: true });222+ }223+ case "unblockip": {224+ const ip = str(0).trim().toLowerCase();225+ if (!validIP(ip)) return reply({ error: "invalid: not an IP address" }, 400);226+ s.setIPBlock(ip, false);227+ return reply({ result: true });228+ }229+ case "listblockedips":230+ return reply({ result: s.listIPBlocks() });210231 case "listreports": {211232 const status = str(0) || "open";212233 return reply({ result: relay.sql.exec(`SELECT * FROM reports WHERE status=? ORDER BY at DESC LIMIT 200`, status).toArray() });
M src/relay.ts +57 -7
@@ -64,11 +64,20 @@ const LEASE_RETENTION_DAYS = 14;
6464 interface ConnState {6565 challenge: string;6666 host: string; // Host header, for NIP-42 relay-tag checks67+ ip: string; // the client's address as the worker saw it; "unknown" outside Cloudflare6768 authed: string[];6869 subs: Record<string, Filter[]>;6970 }7071 7172 const FLUSH_BYTES = 256 * 1024;73+// An address gets this many times a connection's per-minute allowance, so a74+// few tabs from one place are fine and a swarm of sockets is not.75+export const IP_LIMIT_MULTIPLE = 4;76+77+// clientIP is the address the worker stamped on the request (index.ts).78+function clientIP(req: Request): string {79+ return req.headers.get("x-relay-ip") || "unknown";80+}7281 const LNURL_TTL = 24 * 3600;7382 // How long Cloudflare keeps an idle object awake before hibernating it.7483 const IDLE_GRACE_MS = 10_000;
@@ -82,6 +91,8 @@ export class Relay extends DurableObject<Env> {
8291 private states = new Map<WebSocket, ConnState>();8392 private syncs = new Map<WebSocket, Map<string, Negentropy>>();8493 private buckets = new Map<WebSocket, { events: Bucket; reqs: Bucket }>();94+ // Per-address buckets, in memory only: they reset when the object sleeps.95+ private ipBuckets = new Map<string, { events: Bucket; reqs: Bucket }>();8596 // Usage counters, flushed to the usage table in batches.8697 private meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0 };8798 // When the object last did work, for the active-time meter (ms). Zero after a wake.
@@ -236,8 +247,36 @@ export class Relay extends DurableObject<Env> {
236247 }237248 238249 // virtualConn represents an HTTP caller as if it had authenticated over the socket.239- virtualConn(host: string, pubkey: string): ConnState {240- return { challenge: "", host, authed: [pubkey], subs: {} };250+ virtualConn(host: string, pubkey: string, ip = "unknown"): ConnState {251+ return { challenge: "", host, authed: [pubkey], subs: {}, ip };252+ }253+254+ // ---- addresses ----255+256+ // blockIP refuses an address from now on and drops its open sockets.257+ blockIP(ip: string, reason: string) {258+ this.settings.setIPBlock(ip, true, reason, now());259+ for (const ws of this.ctx.getWebSockets()) {260+ if (this.state(ws).ip !== ip) continue;261+ try {262+ ws.close(4403, "blocked: this address is blocked from this relay");263+ } catch {264+ /* already closed */265+ }266+ }267+ }268+269+ // ipLimit spends one token from the address's bucket. "" allows.270+ ipLimit(ip: string, which: "events" | "reqs"): string {271+ if (ip === "unknown") return "";272+ let b = this.ipBuckets.get(ip);273+ if (!b) {274+ if (this.ipBuckets.size >= 10_000) this.ipBuckets.clear();275+ b = { events: new Bucket(this.settings.policy.eventsPerMinute * IP_LIMIT_MULTIPLE), reqs: new Bucket(this.settings.policy.reqsPerMinute * IP_LIMIT_MULTIPLE) };276+ this.ipBuckets.set(ip, b);277+ }278+ const p = this.settings.policy;279+ return b[which].take((which === "events" ? p.eventsPerMinute : p.reqsPerMinute) * IP_LIMIT_MULTIPLE);241280 }242281 243282 isMember(pubkey: string): boolean {
@@ -277,6 +316,7 @@ export class Relay extends DurableObject<Env> {
277316 this.states.clear();278317 this.syncs.clear();279318 this.buckets.clear();319+ this.ipBuckets.clear();280320 this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0 };281321 this.lnurl = null;282322 this.store = new Store(this.sql);
@@ -508,7 +548,16 @@ export class Relay extends DurableObject<Env> {
508548 const url = new URL(req.url);509549 // Relays claimed before identities existed get theirs on first contact.510550 if (this.settings.policy.owner && !this.identity.pubkey) await this.publishRoster();511- if (req.headers.get("upgrade")?.toLowerCase() === "websocket") return this.acceptWebSocket(req);551+ // A blocked address gets no socket and no door that writes or reads552+ // events or files. The page, NIP-11 and management stay reachable, so553+ // an owner who blocked their own address can undo it.554+ const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";555+ const door = upgrade || (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count" || url.pathname === "/api/invites/claim" || url.pathname === "/fuel/invoice")) || url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname);556+ if (door && this.settings.isIPBlocked(clientIP(req))) {557+ const msg = "blocked: this address is blocked from this relay";558+ return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } });559+ }560+ if (upgrade) return this.acceptWebSocket(req);512561 if (req.headers.get("accept")?.includes("application/nostr+json")) {513562 return Response.json(this.info(url.host), {514563 headers: { "content-type": "application/nostr+json", "access-control-allow-origin": "*" },
@@ -602,7 +651,7 @@ export class Relay extends DurableObject<Env> {
602651 private acceptWebSocket(req: Request): Response {603652 const pair = new WebSocketPair();604653 const [client, server] = [pair[0], pair[1]];605- const state: ConnState = { challenge: bytesToHex(crypto.getRandomValues(new Uint8Array(16))), host: new URL(req.url).host, authed: [], subs: {} };654+ const state: ConnState = { challenge: bytesToHex(crypto.getRandomValues(new Uint8Array(16))), host: new URL(req.url).host, authed: [], subs: {}, ip: clientIP(req) };606655 server.serializeAttachment(state);607656 this.ctx.acceptWebSocket(server);608657 this.states.set(server, state);
@@ -614,7 +663,8 @@ export class Relay extends DurableObject<Env> {
614663 private state(ws: WebSocket): ConnState {615664 let s = this.states.get(ws);616665 if (!s) {617- s = (ws.deserializeAttachment() as ConnState) ?? { challenge: "", host: "", authed: [], subs: {} };666+ s = (ws.deserializeAttachment() as ConnState) ?? { challenge: "", host: "", authed: [], subs: {}, ip: "unknown" };667+ if (!s.ip) s.ip = "unknown"; // sockets from before addresses were kept618668 this.states.set(ws, s);619669 }620670 return s;
@@ -686,7 +736,7 @@ export class Relay extends DurableObject<Env> {
686736 switch (typ) {687737 case "EVENT": {688738 if (arr.length < 2) return this.send(ws, "NOTICE", "error: EVENT needs an event");689- const limited = this.bucket(ws).events.take(p.eventsPerMinute);739+ const limited = this.bucket(ws).events.take(p.eventsPerMinute) || this.ipLimit(s.ip, "events");690740 if (limited) {691741 const id = (arr[1] as { id?: unknown })?.id;692742 return this.send(ws, "OK", typeof id === "string" ? id : "", false, limited);
@@ -696,7 +746,7 @@ export class Relay extends DurableObject<Env> {
696746 case "REQ":697747 case "COUNT": {698748 if (arr.length < 3) return this.send(ws, "NOTICE", `error: ${typ} needs an id and at least one filter`);699- const limited = this.bucket(ws).reqs.take(p.reqsPerMinute);749+ const limited = this.bucket(ws).reqs.take(p.reqsPerMinute) || this.ipLimit(s.ip, "reqs");700750 if (limited) return this.send(ws, "CLOSED", typeof arr[1] === "string" ? arr[1] : "", limited);701751 return this.handleReq(ws, s, typ, arr[1], arr.slice(2));702752 }
M src/roles.ts +1 -1
M src/settings.ts +20 -0
@@ -53,6 +53,7 @@ export const SETTINGS_SCHEMA = `
5353 CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);5454 CREATE TABLE IF NOT EXISTS pubkey_rules (pubkey TEXT PRIMARY KEY, rule TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL);5555 CREATE TABLE IF NOT EXISTS event_rules (id TEXT PRIMARY KEY, rule TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL);56+CREATE TABLE IF NOT EXISTS ip_rules (ip TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL);5657 CREATE TABLE IF NOT EXISTS kind_rules (kind INTEGER PRIMARY KEY, rule TEXT NOT NULL);5758 CREATE TABLE IF NOT EXISTS retention (kind INTEGER PRIMARY KEY, days INTEGER NOT NULL);5859 CREATE TABLE IF NOT EXISTS invites (code TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, max_uses INTEGER NOT NULL DEFAULT 0, uses INTEGER NOT NULL DEFAULT 0, note TEXT NOT NULL DEFAULT '');
@@ -82,6 +83,7 @@ export class Settings {
8283 private banned = new Set<string>();8384 private memberSet = new Set<string>();8485 private bannedEvents = new Set<string>();86+ private blockedIPs = new Set<string>();8587 private allowedKinds = new Set<number>();8688 private blockedKinds = new Set<number>();8789 // Days to keep events of a kind; RETENTION_ANY is the rule for kinds without one.
@@ -97,6 +99,7 @@ export class Settings {
9799 for (const r of this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM pubkey_rules WHERE rule='ban'`)) this.banned.add(r.pubkey);98100 for (const r of this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM members`)) this.memberSet.add(r.pubkey);99101 for (const r of this.sql.exec<{ id: string }>(`SELECT id FROM event_rules WHERE rule='ban'`)) this.bannedEvents.add(r.id);102+ for (const r of this.sql.exec<{ ip: string }>(`SELECT ip FROM ip_rules`)) this.blockedIPs.add(r.ip);100103 for (const r of this.sql.exec<{ kind: number; rule: string }>(`SELECT kind, rule FROM kind_rules`)) {101104 (r.rule === "allow" ? this.allowedKinds : this.blockedKinds).add(r.kind);102105 }
@@ -317,6 +320,23 @@ export class Settings {
317320 return this.bannedEvents.has(id);318321 }319322 323+ // Address blocks (NIP-86 blockip). Addresses churn, so these are not part324+ // of the portable configuration: they describe a moment, not the relay.325+ setIPBlock(ip: string, blocked: boolean, reason = "", now = 0) {326+ this.blockedIPs.delete(ip);327+ this.sql.exec(`DELETE FROM ip_rules WHERE ip=?`, ip);328+ if (blocked) {329+ this.blockedIPs.add(ip);330+ this.sql.exec(`INSERT INTO ip_rules(ip,reason,at) VALUES(?,?,?)`, ip, reason, now);331+ }332+ }333+ listIPBlocks() {334+ return this.sql.exec<{ ip: string; reason: string }>(`SELECT ip, reason FROM ip_rules ORDER BY at DESC`).toArray();335+ }336+ isIPBlocked(ip: string) {337+ return this.blockedIPs.has(ip);338+ }339+320340 // Kind rules: an allow list, if non-empty, is exclusive; blocks always apply.321341 setKind(kind: number, rule: "allow" | "block" | null) {322342 this.allowedKinds.delete(kind);
A test/manage.test.ts +200 -0
@@ -0,0 +1,200 @@
1+// NIP-86 gaps: the moderation list over the reports queue, address blocks2+// from the connecting address, and the per-address rate limit that stops a3+// swarm of sockets from multiplying a connection's allowance.4+import { SELF } from "cloudflare:test";5+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));75+76+describe("listeventsneedingmoderation", () => {77+ it("lists open reports by reported thing, deduplicated, for owner and moderator only", async () => {78+ const host = "modqueue.bind.ws";79+ const owner = generateSecretKey();80+ const mod = generateSecretKey();81+ const member = generateSecretKey();82+ const author = generateSecretKey();83+ const reporter = generateSecretKey();84+ await rpc(host, owner, "claim");85+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });86+ await rpc(host, owner, "setmember", pk(member), {});87+ const c = (await WS.connect(host, "10.9.0.1"))!;88+ const note = ev(author, 1, "buy my coin");89+ expect((await c.ok(note)).ok).toBe(true);90+ // Two reports on the note, one on the author alone.91+ expect((await c.ok(ev(reporter, 1984, "junk", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);92+ expect((await c.ok(ev(member, 1984, "", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);93+ expect((await c.ok(ev(reporter, 1984, "bot", [["p", pk(author), "impersonation"]]))).ok).toBe(true);94+95+ const list = await rpc(host, owner, "listeventsneedingmoderation");96+ expect(list.status).toBe(200);97+ expect(list.result).toEqual([{ id: note.id, reason: "spam: junk" }]);98+ expect((await rpc(host, mod, "listeventsneedingmoderation")).result).toEqual([{ id: note.id, reason: "spam: junk" }]);99+ expect((await rpc(host, member, "listeventsneedingmoderation")).status).toBe(403);100+ expect((await rpc(host, owner, "supportedmethods")).result).toEqual(expect.arrayContaining(["listeventsneedingmoderation", "blockip", "unblockip", "listblockedips"]));101+102+ // Resolving the reports empties the list.103+ const reports = (await rpc(host, owner, "listreports")).result as { id: string }[];104+ for (const r of reports) await rpc(host, owner, "resolvereport", r.id, "dismiss");105+ expect((await rpc(host, owner, "listeventsneedingmoderation")).result).toEqual([]);106+ });107+});108+109+describe("address blocks", () => {110+ it("refuses the socket and the doors, drops open sockets, and can be undone", async () => {111+ const host = "blocks.bind.ws";112+ const owner = generateSecretKey();113+ const mod = generateSecretKey();114+ const member = generateSecretKey();115+ const bad = "203.0.113.5";116+ await rpc(host, owner, "claim");117+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });118+ await rpc(host, owner, "setmember", pk(member), {});119+120+ const open = (await WS.connect(host, bad))!;121+ expect((await open.ok(ev(member, 1, "before"))).ok).toBe(true);122+ expect((await rpc(host, member, "blockip", bad, "scraper")).status).toBe(403);123+ expect((await rpc(host, mod, "blockip", "not an address", "x")).status).toBe(400);124+ expect((await rpc(host, mod, "blockip", bad, "scraper")).result).toBe(true);125+ expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: bad, reason: "scraper" }]);126+127+ for (let i = 0; i < 40 && !open.closed; i++) await sleep(25);128+ expect(open.closed?.code).toBe(4403);129+ expect(await WS.connect(host, bad)).toBeNull();130+ const refused = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": bad } });131+ expect(refused.status).toBe(403);132+ const door = await post(host, member, "/events", ev(member, 1, "still here?"), bad);133+ expect(door.status).toBe(403);134+ expect(door.body.error).toMatch(/^blocked: this address/);135+ expect((await post(host, member, "/count", [{ kinds: [1] }], bad)).status).toBe(403);136+ // 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();139+ expect((await rpc(host, owner, "stats")).status).toBe(200);140+ // Blocks are not part of the portable configuration.141+ expect((await rpc(host, owner, "exportconfig")).result.blocked_ips).toBeUndefined();142+143+ expect((await rpc(host, owner, "unblockip", bad)).result).toBe(true);144+ expect((await rpc(host, owner, "listblockedips")).result).toEqual([]);145+ const again = await WS.connect(host, bad);146+ expect(again).not.toBeNull();147+ expect((await again!.ok(ev(member, 1, "back"))).ok).toBe(true);148+ });149+150+ it("accepts IPv6 and rejects ranges and names", async () => {151+ const host = "blocks6.bind.ws";152+ const owner = generateSecretKey();153+ await rpc(host, owner, "claim");154+ expect((await rpc(host, owner, "blockip", "2001:DB8::1", "")).result).toBe(true);155+ expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: "2001:db8::1", reason: "" }]);156+ expect(await WS.connect(host, "2001:db8::1")).toBeNull();157+ 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);158+ });159+});160+161+describe("per-address rate limit", () => {162+ it("caps all sockets from one address at four times a connection's allowance", async () => {163+ const host = "swarm.bind.ws";164+ const owner = generateSecretKey();165+ await rpc(host, owner, "claim");166+ await rpc(host, owner, "setpolicy", { eventsPerMinute: 5 });167+ const ip = "198.51.100.7";168+ const socks: WS[] = [];169+ for (let i = 0; i < 6; i++) socks.push((await WS.connect(host, ip))!);170+ // Four per socket stays under the per-connection five; the address171+ // allowance is twenty, so the twenty-first event across them is refused.172+ let accepted = 0;173+ let refused = "";174+ for (const c of socks) {175+ for (let j = 0; j < 4 && !refused; j++) {176+ const r = await c.ok(ev(owner, 1, `n${accepted}`));177+ if (r.ok) accepted++;178+ else refused = r.msg;179+ }180+ }181+ expect(accepted).toBe(20);182+ expect(refused).toMatch(/^rate-limited:/);183+ // A lone socket from elsewhere is only bound by its own bucket.184+ const alone = (await WS.connect(host, "198.51.100.8"))!;185+ for (let j = 0; j < 4; j++) expect((await alone.ok(ev(owner, 1, `alone${j}`))).ok).toBe(true);186+ });187+188+ it("is the bridge's rate limit too", async () => {189+ const host = "bridgelimit.bind.ws";190+ const owner = generateSecretKey();191+ await rpc(host, owner, "claim");192+ await rpc(host, owner, "setpolicy", { reqsPerMinute: 1 });193+ 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);196+ expect(fifth.status).toBe(429);197+ expect(fifth.body.error).toMatch(/^rate-limited:/);198+ expect((await post(host, owner, "/count", [{ kinds: [1] }], "198.51.100.10")).status).toBe(200);199+ });200+});