clone

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

relay: per-member keep-for and storage caps

Dami committed 47e1286
Members gain keep_days and max_bytes, added with a guarded ALTER. A keep-for
refuses a member's older events on arrival and sweeps theirs in the daily
pass, with the same exceptions as the catch-all rule. A cap refuses the event
that would take an author past it; stored bytes per author are summed on
demand and cached, cleared by anything that deletes. The owner is never
limited, and only the owner sets limits.
commit
47e1286375bfd79930e5118577e7c74c6f521fcd
parent
1d5fa50155aef9308b6d17520970ac7efa6f134a
author
Dami <dami.etoile@gmail.com>
5 files changed, +164 -10
Msrc/manage.ts8+++-
Msrc/relay.ts16++++++++-
Msrc/settings.ts54+++++++++++++++++++++++++---
Msrc/store.ts38++++++++++++++++++++
Mtest/data.test.ts58+++++++++++++++++++++++++++++-
M src/manage.ts +6 -2
@@ -161,13 +161,17 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
161161 // NIP-86 allowpubkey(pubkey, reason) and the richer setmember(pubkey, {name, note}).162162 const pk = str(0);163163 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);164- const patch = method === "allowpubkey" ? { note: str(1) } : (params[1] && typeof params[1] === "object" ? (params[1] as { name?: string | null; note?: string; role?: string }) : {});164+ const patch = method === "allowpubkey" ? { note: str(1) } : (params[1] && typeof params[1] === "object" ? (params[1] as { name?: string | null; note?: string; role?: string; keepDays?: unknown; maxBytes?: unknown }) : {});165165 if (outranks(pk)) return reply({ error: "restricted: moderators cannot edit the owner or other moderators" }, 403);166+ // Per-member keep-for and cap: the owner's call, and never on the owner.167+ const keepDays = Number.isInteger(patch.keepDays) && (patch.keepDays as number) >= 0 ? (patch.keepDays as number) : undefined;168+ const maxBytes = Number.isInteger(patch.maxBytes) && (patch.maxBytes as number) >= 0 ? (patch.maxBytes as number) : undefined;169+ if ((keepDays !== undefined || maxBytes !== undefined) && role !== "owner") return reply({ error: "restricted: only the owner sets limits" }, 403);166170 const wantsRole = patch.role === "moderator" || patch.role === "member" ? patch.role : undefined;167171 if (wantsRole && role !== "owner") return reply({ error: "restricted: only the owner sets roles" }, 403);168172 if (wantsRole && s.isOwner(pk)) return reply({ error: "invalid: the owner's role changes by transferowner" }, 400);169173 if (s.isBanned(pk)) s.setBan(pk, false);170- const err = await relay.setMember(pk, { name: patch.name, note: typeof patch.note === "string" ? patch.note : undefined }, true);174+ const err = await relay.setMember(pk, { name: patch.name, note: typeof patch.note === "string" ? patch.note : undefined, ...(s.isOwner(pk) ? {} : { keepDays, maxBytes }) }, true);171175 if (err) return reply({ error: err }, 400);172176 if (wantsRole && s.roleOf(pk) !== wantsRole) {173177 s.setRole(pk, wantsRole);
M src/relay.ts +15 -1
@@ -204,7 +204,7 @@ export class Relay extends DurableObject<Env> {
204204 }205205 206206 // setMember edits or adds a person and publishes the roster if membership changed.207- async setMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string }, force = false): Promise<string> {207+ async setMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; keepDays?: number; maxBytes?: number }, force = false): Promise<string> {208208 const was = this.settings.isAllowed(pubkey);209209 const err = this.settings.upsertMember(pubkey, patch, now(), force);210210 if (err) return err;
@@ -979,6 +979,13 @@ export class Relay extends DurableObject<Env> {
979979 if (!this.settings.kindAllowed(e.kind) && !(isReplaceable(e.kind) && this.settings.isOwner(e.pubkey))) return no("blocked: this relay does not accept kind " + e.kind);980980 const keep = this.settings.retentionDays(e.kind);981981 if (keep > 0 && e.created_at < t - keep * 86400) return no(`blocked: this relay keeps kind ${e.kind} for ${keep} days and this event is older`);982+ // The owner's per-member rules: a keep-for window on top of the kind983+ // rules, and a cap on stored bytes. Neither touches the owner.984+ const lim = this.settings.limitsOf(e.pubkey);985+ if (lim) {986+ if (lim.keep > 0 && !isProtected(e.kind) && !isReplaceable(e.kind) && e.created_at < t - lim.keep * 86400) return no(`blocked: this relay keeps your events for ${lim.keep} days and this event is older`);987+ if (lim.cap > 0 && this.store.authorBytes(e.pubkey) + canonical(e).length > lim.cap) return no(`restricted: you have reached your storage cap of ${Math.max(1, Math.round(lim.cap / 1024))} KB on this relay`);988+ }982989 if (p.writes === "owner" && !this.settings.isOwner(e.pubkey)) return no("restricted: only the relay owner may publish here");983990 if (p.writes === "allowlist" && !this.settings.isAllowed(e.pubkey)) return no("restricted: this relay only accepts events from its members");984991 if (p.minPow > 0) {
@@ -997,6 +1004,7 @@ export class Relay extends DurableObject<Env> {
9971004 const err = this.store.save(e, t);9981005 if (err === ERR_DUPLICATE) return { ok: true, msg: err, stored: false };9991006 if (err) return no(err);1007+ this.store.noteSaved(e.pubkey, canonical(e).length, isReplaceable(e.kind));10001008 if (exp > 0) this.scheduleSweep(exp);10011009 else this.ensureAlarm();10021010 if (e.kind === 0 && conn) claimFromProfile(this.settings, e.content, e.pubkey, conn.host, t);
@@ -1063,6 +1071,12 @@ export class Relay extends DurableObject<Env> {
10631071 if (r.kind !== null) gone += this.store.purge(r.kind, t - r.days * 86400);10641072 else gone += this.store.purge(null, t - r.days * 86400, [...own, ...this.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k) || k === 1059)]);10651073 }1074+ // Per-member keep-for rules, same pass, same exceptions as the catch-all.1075+ const limited = this.settings.limited();1076+ if (limited.length) {1077+ const keepKinds = this.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k));1078+ for (const m of limited) gone += this.store.purgeAuthor(m.pubkey, t - m.keep * 86400, keepKinds);1079+ }10661080 return gone;10671081 }10681082
M src/settings.ts +48 -6
@@ -88,6 +88,22 @@ CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC);
8888 CREATE TABLE IF NOT EXISTS dumps (name TEXT PRIMARY KEY, bytes INTEGER NOT NULL, events INTEGER NOT NULL, at INTEGER NOT NULL);8989 `;9090 91+// Columns added after the first release. There are no PRAGMAs in this92+// SQLite, so a probing SELECT stands in for "does the column exist".93+const MEMBER_COLUMNS: [string, string][] = [94+ ["keep_days", "INTEGER NOT NULL DEFAULT 0"], // per-member keep-for; 0 = the relay's rules95+ ["max_bytes", "INTEGER NOT NULL DEFAULT 0"], // per-member storage cap; 0 = unlimited96+];97+function ensureColumns(sql: SqlStorage, table: string, cols: [string, string][]) {98+ for (const [col, decl] of cols) {99+ try {100+ sql.exec(`SELECT ${col} FROM ${table} LIMIT 1`).toArray();101+ } catch {102+ sql.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`);103+ }104+ }105+}106+91107 // A member is one person of this relay: the owner or someone let in. Their92108 // optional name is their NIP-05 handle at the relay's host.93109 export type Member = {
@@ -97,8 +113,13 @@ export type Member = {
97113 note: string;98114 joined_at: number;99115 via: string; // claimed | invite <code> | added | profile116+ keep_days: number; // 0 = the relay's rules117+ max_bytes: number; // 0 = unlimited100118 };101119 120+// Limits the write path checks per author, cached so a write costs no query.121+export type MemberLimits = { keep: number; cap: number };122+102123 // dumpFields validates the dump settings of a policy patch.103124 export function dumpFields(patch: Record<string, unknown>): Partial<Policy> {104125 const out: Partial<Policy> = {};
@@ -161,6 +182,7 @@ export class Settings {
161182 private memberSet = new Set<string>();162183 private bannedEvents = new Set<string>();163184 private blockedIPs = new Set<string>();185+ private limits = new Map<string, MemberLimits>();164186 private allowedKinds = new Set<number>();165187 private blockedKinds = new Set<number>();166188 // Days to keep events of a kind; RETENTION_ANY is the rule for kinds without one.
@@ -172,9 +194,13 @@ export class Settings {
172194 this.sql.exec(SETTINGS_SCHEMA);173195 const row = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='policy'`).toArray()[0];174196 if (row) this.policy = { ...DEFAULT_POLICY, ...JSON.parse(row.value) };197+ ensureColumns(this.sql, "members", MEMBER_COLUMNS);175198 this.migrateMembers();176199 for (const r of this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM pubkey_rules WHERE rule='ban'`)) this.banned.add(r.pubkey);177- for (const r of this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM members`)) this.memberSet.add(r.pubkey);200+ for (const r of this.sql.exec<{ pubkey: string; keep_days: number; max_bytes: number }>(`SELECT pubkey, keep_days, max_bytes FROM members`)) {201+ this.memberSet.add(r.pubkey);202+ if (r.keep_days > 0 || r.max_bytes > 0) this.limits.set(r.pubkey, { keep: r.keep_days, cap: r.max_bytes });203+ }178204 for (const r of this.sql.exec<{ id: string }>(`SELECT id FROM event_rules WHERE rule='ban'`)) this.bannedEvents.add(r.id);179205 for (const r of this.sql.exec<{ ip: string }>(`SELECT ip FROM ip_rules`)) this.blockedIPs.add(r.ip);180206 for (const r of this.sql.exec<{ kind: number; rule: string }>(`SELECT kind, rule FROM kind_rules`)) {
@@ -267,9 +293,20 @@ export class Settings {
267293 return "";268294 }269295 296+ // limitsOf is a member's keep-for and cap, or null when neither is set.297+ // The owner is never limited.298+ limitsOf(pubkey: string): MemberLimits | null {299+ if (this.isOwner(pubkey)) return null;300+ return this.limits.get(pubkey) ?? null;301+ }302+ // limited lists members with a keep-for rule, for the daily sweep.303+ limited(): { pubkey: string; keep: number }[] {304+ return [...this.limits].filter(([pk, l]) => l.keep > 0 && !this.isOwner(pk)).map(([pubkey, l]) => ({ pubkey, keep: l.keep }));305+ }306+270307 // upsertMember adds or edits a member. name "" clears the name; a name held271308 // by someone else is refused unless force (owner action). Returns "" or a reason.272- upsertMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string }, now: number, force = false): string {309+ upsertMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; keepDays?: number; maxBytes?: number }, now: number, force = false): string {273310 const cur = this.member(pubkey);274311 let name = cur?.name ?? null;275312 if (patch.name !== undefined) {
@@ -286,18 +323,23 @@ export class Settings {
286323 }287324 }288325 const note = patch.note !== undefined ? patch.note.slice(0, 200) : (cur?.note ?? "");289- if (cur) this.sql.exec(`UPDATE members SET name=?, note=? WHERE pubkey=?`, name, note, pubkey);326+ const keep = patch.keepDays !== undefined ? Math.max(0, Math.min(Math.floor(patch.keepDays), 36500)) : (cur?.keep_days ?? 0);327+ const cap = patch.maxBytes !== undefined ? Math.max(0, Math.floor(patch.maxBytes)) : (cur?.max_bytes ?? 0);328+ if (cur) this.sql.exec(`UPDATE members SET name=?, note=?, keep_days=?, max_bytes=? WHERE pubkey=?`, name, note, keep, cap, pubkey);290329 else {291330 const role = this.isOwner(pubkey) ? "owner" : "member";292- this.sql.exec(`INSERT INTO members(pubkey,role,name,note,joined_at,via) VALUES(?,?,?,?,?,?)`, pubkey, role, name, note, now, (patch.via ?? "added").slice(0, 40));331+ this.sql.exec(`INSERT INTO members(pubkey,role,name,note,joined_at,via,keep_days,max_bytes) VALUES(?,?,?,?,?,?,?,?)`, pubkey, role, name, note, now, (patch.via ?? "added").slice(0, 40), keep, cap);293332 this.memberSet.add(pubkey);294333 }334+ if (keep > 0 || cap > 0) this.limits.set(pubkey, { keep, cap });335+ else this.limits.delete(pubkey);295336 return "";296337 }297338 298339 removeMember(pubkey: string): boolean {299340 if (this.isOwner(pubkey)) return false;300341 this.memberSet.delete(pubkey);342+ this.limits.delete(pubkey);301343 return this.sql.exec(`DELETE FROM members WHERE pubkey=?`, pubkey).rowsWritten > 0;302344 }303345
@@ -311,7 +353,7 @@ export class Settings {
311353 exported_at: Math.floor(Date.now() / 1000),312354 name,313355 policy: { ...this.policy, owner: undefined, lease: undefined },314- members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note, ...(m.role === "moderator" ? { role: "moderator" } : {}) })),356+ members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note, ...(m.role === "moderator" ? { role: "moderator" } : {}), ...(m.keep_days ? { keepDays: m.keep_days } : {}), ...(m.max_bytes ? { maxBytes: m.max_bytes } : {}) })),315357 bans: this.listBans(),316358 banned_events: this.listEvents("ban"),317359 kinds: { allow: this.listKinds("allow"), block: this.listKinds("block") },
@@ -340,7 +382,7 @@ export class Settings {
340382 for (const m of Array.isArray(c.members) ? c.members : []) {341383 const r = m as Record<string, unknown>;342384 if (!hex64(r.pubkey)) continue;343- this.upsertMember(r.pubkey, { name: typeof r.name === "string" ? r.name : null, note: typeof r.note === "string" ? r.note : "", via: "import" }, now, true);385+ this.upsertMember(r.pubkey, { name: typeof r.name === "string" ? r.name : null, note: typeof r.note === "string" ? r.note : "", via: "import", keepDays: Number.isInteger(r.keepDays) ? (r.keepDays as number) : 0, maxBytes: Number.isInteger(r.maxBytes) ? (r.maxBytes as number) : 0 }, now, true);344386 if (r.role === "moderator") this.setRole(r.pubkey, "moderator");345387 }346388 for (const b of this.listBans()) this.setBan(b.pubkey, false);
M src/store.ts +38 -0
@@ -150,6 +150,7 @@ export class Store {
150150 151151 // vanish implements NIP-62.152152 vanish(pubkey: string, until: number) {153+ this.bytesByAuthor.clear();153154 this.x(`INSERT INTO vanished(pubkey,until) VALUES(?,?) ON CONFLICT(pubkey) DO UPDATE SET until=max(until,excluded.until)`, pubkey, until);154155 this.x(`DELETE FROM events WHERE pubkey=? AND created_at<=?`, pubkey, until);155156 this.x(`DELETE FROM events WHERE kind=1059 AND id IN (SELECT event_id FROM tags WHERE name='p' AND value=?)`, pubkey);
@@ -157,6 +158,7 @@ export class Store {
157158 158159 // sweepExpired deletes NIP-40 expired rows and returns the next expiry, or 0.159160 sweepExpired(now: number): number {161+ this.bytesByAuthor.clear();160162 this.x(`DELETE FROM events WHERE expires > 0 AND expires <= ?`, now);161163 const row = this.x<{ next: number | null }>(`SELECT MIN(expires) AS next FROM events WHERE expires > 0`).one();162164 return row.next ?? 0;
@@ -284,9 +286,44 @@ export class Store {
284286 }285287 286288 deleteEvent(id: string): boolean {289+ this.bytesByAuthor.clear();287290 return this.x(`DELETE FROM events WHERE id=?`, id).rowsWritten > 0;288291 }289292 293+ // authorBytes is how much raw event text one pubkey has stored, for the294+ // per-member cap. Cached per author; save adds to it, and anything that295+ // deletes clears the cache rather than guessing.296+ private bytesByAuthor = new Map<string, number>();297+ authorBytes(pubkey: string): number {298+ const cached = this.bytesByAuthor.get(pubkey);299+ if (cached !== undefined) return cached;300+ const n = this.x<{ n: number | null }>(`SELECT sum(length(raw)) AS n FROM events WHERE pubkey=?`, pubkey).one().n ?? 0;301+ this.bytesByAuthor.set(pubkey, n);302+ return n;303+ }304+ // noteSaved keeps the cache right after a save. A replaceable kind may305+ // have displaced an older row, so its author is simply recounted next time.306+ noteSaved(pubkey: string, bytes: number, replaceable: boolean) {307+ if (replaceable) {308+ this.bytesByAuthor.delete(pubkey);309+ return;310+ }311+ const cached = this.bytesByAuthor.get(pubkey);312+ if (cached !== undefined) this.bytesByAuthor.set(pubkey, cached + bytes);313+ }314+315+ // purgeAuthor deletes one pubkey's events created before `before`, except316+ // the listed kinds. Returns how many went.317+ purgeAuthor(pubkey: string, before: number, except: number[]): number {318+ const list = JSON.stringify(except);319+ const n = this.x<{ n: number }>(`SELECT count(*) AS n FROM events WHERE pubkey=? AND created_at<? AND kind NOT IN (SELECT value FROM json_each(?))`, pubkey, before, list).one().n;320+ if (n > 0) {321+ this.x(`DELETE FROM events WHERE pubkey=? AND created_at<? AND kind NOT IN (SELECT value FROM json_each(?))`, pubkey, before, list);322+ this.bytesByAuthor.delete(pubkey);323+ }324+ return n;325+ }326+290327 // dumpPage reads a page of raw events by sequence for the JSONL dump.291328 dumpPage(afterSeq: number, limit: number): { seq: number; raw: string }[] {292329 return this.x<{ seq: number; raw: string }>(`SELECT seq, raw FROM events WHERE seq>? ORDER BY seq LIMIT ?`, afterSeq, limit).toArray();
@@ -303,6 +340,7 @@ export class Store {
303340 // purge deletes events of one kind, or of every kind not in `except` when304341 // kind is null, created before `before`. Returns how many went.305342 purge(kind: number | null, before: number, except: number[] = []): number {343+ this.bytesByAuthor.clear();306344 if (kind !== null) {307345 const n = this.x<{ n: number }>(`SELECT count(*) AS n FROM events WHERE kind=? AND created_at<?`, kind, before).one().n;308346 if (n > 0) this.x(`DELETE FROM events WHERE kind=? AND created_at<?`, kind, before);
M test/data.test.ts +57 -1
@@ -1,4 +1,4 @@
1-// Dumps to R2.1+// Dumps to R2, and per-member keep-for and caps.22 import { SELF, env, runInDurableObject } from "cloudflare:test";33 import { describe, it, expect } from "vitest";44 import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";
@@ -154,3 +154,59 @@ describe("dumps", () => {
154154 });155155 });156156 157+describe("per-member keep-for and caps", () => {158+ it("a member's keep-for refuses old events and sweeps theirs, while others follow the relay's rules", async () => {159+ const host = "keepy.bind.ws";160+ const owner = generateSecretKey();161+ const a = generateSecretKey();162+ const b = generateSecretKey();163+ await rpc(host, owner, "claim");164+ expect((await rpc(host, owner, "setmember", pk(a), { keepDays: 10 })).status).toBe(200);165+ await rpc(host, owner, "setmember", pk(b), {});166+ const members = (await rpc(host, owner, "listmembers")).result.members;167+ expect(members.find((m: any) => m.pubkey === pk(a)).keep_days).toBe(10);168+169+ const c = await WS.connect(host);170+ expect((await c.ok(ev(a, 1, "too old", [], now() - 20 * 86400))).msg).toMatch(/keeps your events for 10 days/);171+ expect((await c.ok(ev(b, 1, "old but fine", [], now() - 20 * 86400))).ok).toBe(true);172+ expect((await c.ok(ev(a, 1, "recent enough", [], now() - 5 * 86400))).ok).toBe(true);173+ // Profiles are never swept by a keep-for.174+ expect((await c.ok(ev(a, 0, JSON.stringify({ name: "a" }), [], now() - 30 * 86400))).ok).toBe(true);175+176+ await rpc(host, owner, "setmember", pk(a), { keepDays: 3 });177+ const gone = await runInDurableObject(env.RELAY.getByName("keepy"), async (r: Relay) => r.sweepRetention(now()));178+ expect(gone).toBe(1);179+ expect((await c.req({ kinds: [1], authors: [pk(a)] })).length).toBe(0);180+ expect((await c.req({ kinds: [0], authors: [pk(a)] })).length).toBe(1);181+ expect((await c.req({ kinds: [1], authors: [pk(b)] })).length).toBe(1);182+183+ // Only the owner sets limits, and never on themselves.184+ const mod = generateSecretKey();185+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });186+ expect((await rpc(host, mod, "setmember", pk(b), { keepDays: 1 })).status).toBe(403);187+ await rpc(host, owner, "setmember", pk(owner), { keepDays: 1 });188+ expect((await c.ok(ev(owner, 1, "owner, old", [], now() - 20 * 86400))).ok).toBe(true);189+ });190+191+ it("a cap refuses the event that would cross it, and the owner is never capped", async () => {192+ const host = "cappy.bind.ws";193+ const owner = generateSecretKey();194+ const m = generateSecretKey();195+ await rpc(host, owner, "claim");196+ await rpc(host, owner, "setmember", pk(m), { maxBytes: 1200 });197+ const c = await WS.connect(host);198+ const big = "x".repeat(400);199+ expect((await c.ok(ev(m, 1, big))).ok).toBe(true);200+ const r = await c.ok(ev(m, 1, big + "2"));201+ expect(r.ok).toBe(false);202+ expect(r.msg).toMatch(/^restricted: you have reached your storage cap/);203+ expect((await c.ok(ev(m, 7, "+"))).ok).toBe(true); // small ones still fit204+ for (let i = 0; i < 3; i++) expect((await c.ok(ev(owner, 1, big + i))).ok).toBe(true);205+ // Raising the cap lets it through; the cache follows the store.206+ await rpc(host, owner, "setmember", pk(m), { maxBytes: 5000 });207+ expect((await c.ok(ev(m, 1, big + "3"))).ok).toBe(true);208+ const cfg = (await rpc(host, owner, "exportconfig")).result;209+ expect(cfg.members.find((x: any) => x.pubkey === pk(m)).maxBytes).toBe(5000);210+ });211+});212+