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>
| M | src/ | 8 | +++- |
| M | src/ | 16 | ++++++++- |
| M | src/ | 54 | +++++++++++++++++++++++++--- |
| M | src/ | 38 | ++++++++++++++++++++ |
| M | test/ | 58 | +++++++++++++++++++++++++++++- |
@@ -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);@@ -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 @@ -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);@@ -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);@@ -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+