clone

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

relay: guests, a web of trust, blocked words, ban with erase, report thresholds

Dami committed 0c7ffc9
Guests: openKinds and guestReplies let a stranger past a limited write
rule for listed kinds, or for a note or comment that answers a member.
Web of trust: a fourth write rule, wot, admits members and everyone in
their newest contact lists, one hop, rebuilt as lists arrive and members
change, capped at 50,000. Blocked words: content containing one is refused
unless the author is the owner or a moderator; setblockedwords needs the
ban action. Ban with erase deletes everything the pubkey wrote and
uploaded. Report thresholds: open reports from that many distinct people
put an event under a hide rule the store leaves out of every read, lifted
when the last report is dismissed, made permanent by delete or ban.
commit
0c7ffc987c10a893c4ee59e4826cfeab3c40032a
parent
e53b20a502b67f3eccda887449e7812d26016de9
author
Dami <dami.etoile@gmail.com>
6 files changed, +217 -24
Msrc/card.ts4+-
Msrc/manage.ts23+++-
Msrc/relay.ts46++++++++-
Msrc/roles.ts2+-
Msrc/settings.ts149++++++++++++++++++++++++++++--
Msrc/store.ts17+++
M src/card.ts +2 -2
M src/manage.ts +17 -6
@@ -7,6 +7,7 @@ import { bytesToHex } from "./negentropy.ts";
77 import type { Relay } from "./relay.ts";88 import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";99 import { descriptor, type Blob } from "./blossom.ts";10+import { blockedWords, gateFields, isWriteRule } from "./settings.ts";1011 import { isReplaceable, isProtected, publicFields, dumpFields } from "./settings.ts";1112 import { checkPullURL } from "./pull.ts";1213 import { relaysFromList } from "./jobs.ts";
@@ -56,7 +57,7 @@ const METHODS = [
5657 "changerelayname", "changerelaydescription", "changerelayicon",5758 "createinvite", "listinvites", "revokeinvite", "listmembers",5859 "listreports", "resolvereport", "listeventsneedingmoderation",59- "blockip", "unblockip", "listblockedips",60+ "blockip", "unblockip", "listblockedips", "setblockedwords",6061 "listblobs", "deleteblob",6162 "storagestats", "setretention", "listretention", "purgekind",6263 "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",
@@ -143,8 +144,8 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
143144 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};144145 const clean: Record<string, unknown> = {};145146 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);146- Object.assign(clean, publicFields(patch), dumpFields(patch));147- if (patch.writes === "open" || patch.writes === "allowlist" || patch.writes === "owner") clean.writes = patch.writes;147+ Object.assign(clean, publicFields(patch), dumpFields(patch), gateFields(patch));148+ if (isWriteRule(patch.writes)) clean.writes = patch.writes;148149 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;149150 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);150151 if (typeof patch.directoryPublic === "boolean") clean.directoryPublic = patch.directoryPublic;
@@ -165,9 +166,17 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
165166 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);166167 if (s.isOwner(pk)) return reply({ error: "invalid: cannot ban the owner" }, 400);167168 if (outranks(pk)) return reply({ error: "restricted: moderators cannot ban other moderators" }, 403);168- await relay.ban(pk, str(1));169+ // (pubkey, reason, erase): erase deletes everything they wrote and uploaded.170+ await relay.ban(pk, str(1), params[2] === true);169171 return reply({ result: true });170172 }173+ case "setblockedwords": {174+ // (words[]): content containing one is refused; the owner and moderators are exempt.175+ const words = blockedWords(params[0]);176+ if (!words) return reply({ error: "invalid: give a list of words" }, 400);177+ s.update({ blockedWords: words });178+ return reply({ result: words });179+ }171180 case "allowpubkey":172181 case "setmember": {173182 // NIP-86 allowpubkey(pubkey, reason) and the richer setmember(pubkey, {name, note}).
@@ -276,7 +285,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
276285 case "listreports": {277286 const status = str(0) || "open";278287 // blob says whether target_event is a file this relay still holds (BUD-09).279- return reply({ result: relay.sql.exec(`SELECT r.*, EXISTS(SELECT 1 FROM blobs b WHERE b.sha256=r.target_event) AS blob FROM reports r WHERE status=? ORDER BY at DESC LIMIT 200`, status).toArray() });288+ return reply({ result: relay.sql.exec(`SELECT r.*, EXISTS(SELECT 1 FROM blobs b WHERE b.sha256=r.target_event) AS blob, EXISTS(SELECT 1 FROM event_rules e WHERE e.id=r.target_event AND e.rule='hide') AS hidden FROM reports r WHERE status=? ORDER BY at DESC LIMIT 200`, status).toArray() });280289 }281290 case "resolvereport": {282291 const id = str(0);
@@ -295,10 +304,12 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
295304 if (action === "ban") {296305 if (s.isOwner(row.target_pubkey)) return reply({ error: "invalid: cannot ban the owner" }, 400);297306 if (outranks(row.target_pubkey)) return reply({ error: "restricted: moderators cannot ban other moderators" }, 403);298- await relay.ban(row.target_pubkey, "report " + id.slice(0, 8));307+ await relay.ban(row.target_pubkey, "report " + id.slice(0, 8), params[2] === true);299308 await remove();300309 } else if (action === "delete") await remove();301310 relay.sql.exec(`UPDATE reports SET status='resolved', resolved_by=?, resolved_at=?, action=? WHERE id=?`, caller, t, action, id);311+ // A dismissal that clears the last open report lifts the hold.312+ if (action === "dismiss" && row.target_event && s.isEventHidden(row.target_event) && relay.sql.exec(`SELECT 1 FROM reports WHERE target_event=? AND status='open'`, row.target_event).toArray().length === 0) s.setEvent(row.target_event, null);302313 return reply({ result: true });303314 }304315 case "exportconfig":
M src/relay.ts +41 -5
@@ -150,6 +150,7 @@ export class Relay extends DurableObject<Env> {
150150 ctx.blockConcurrencyWhile(async () => {151151 this.store.init();152152 this.settings.load();153+ this.store.hidden = this.settings.hiddenEvents;153154 this.fuel.init();154155 this.slug = (await ctx.storage.get<string>("slug")) ?? "";155156 this.lnurl = (await ctx.storage.get<LnurlParams>("lnurl")) ?? null;
@@ -272,10 +273,16 @@ export class Relay extends DurableObject<Env> {
272273 return gone;273274 }274275 275- async ban(pubkey: string, reason: string): Promise<void> {276+ // ban refuses a pubkey from now on; with erase, everything they wrote and277+ // uploaded goes too, their profile included.278+ async ban(pubkey: string, reason: string, erase = false): Promise<void> {276279 const was = this.settings.isAllowed(pubkey);277280 this.settings.setBan(pubkey, true, reason, now());278281 this.evict(pubkey, "blocked: you are banned from this relay", true);282+ if (erase) {283+ this.store.eraseAuthor(pubkey);284+ for (const b of this.sql.exec<{ sha256: string }>(`SELECT sha256 FROM blobs WHERE uploader=?`, pubkey).toArray()) await this.deleteBlob(b.sha256);285+ }279286 if (was) await this.publishMembership({ pubkey, added: false });280287 }281288
@@ -342,8 +349,8 @@ export class Relay extends DurableObject<Env> {
342349 if (this.settings.isUnclaimed()) return "restricted: this relay is unclaimed";343350 if (this.settings.leaseExpired(now())) return "restricted: this temporary relay has expired";344351 if (this.settings.isBanned(pubkey)) return "blocked: this pubkey is banned from this relay";345- if (p.writes === "owner" && !this.settings.isOwner(pubkey)) return "restricted: only the relay owner may upload here";346- if (p.writes === "allowlist" && !this.settings.isAllowed(pubkey)) return "restricted: this relay only accepts uploads from its members";352+ const why = this.settings.mayWrite(pubkey);353+ if (why) return why.replace("publish here", "upload here").replace("accepts events", "accepts uploads");347354 if (this.fuelStatus().outOfFuel) return "restricted: this relay is out of fuel; zap it at https://" + host + "/ to top up";348355 return "";349356 }
@@ -379,6 +386,7 @@ export class Relay extends DurableObject<Env> {
379386 this.identity = new Identity(this.ctx.storage);380387 this.store.init();381388 this.settings.load();389+ this.store.hidden = this.settings.hiddenEvents;382390 this.fuel.init();383391 await this.ctx.storage.put("slug", this.slug);384392 }
@@ -1117,6 +1125,11 @@ export class Relay extends DurableObject<Env> {
11171125 if (exp > 0 && exp <= t) return "invalid: event has already expired";11181126 if (this.settings.isBanned(e.pubkey)) return "blocked: this pubkey is banned from this relay";11191127 if (this.settings.isEventBanned(e.id)) return "blocked: this event is banned from this relay";1128+ // Blocked words: the owner and moderators may say anything, nobody else may say these.1129+ if (e.content !== "" && this.settings.hasBlockedWord(e.content)) {1130+ const role = this.settings.roleOf(e.pubkey);1131+ if (role !== "owner" && role !== "moderator") return "blocked: content contains a blocked word";1132+ }11201133 // NIP-46 traffic passes the ownership, fuel and write gates: it is11211134 // ephemeral, never stored, and readable only by its two parties, and11221135 // letting it through means this relay can carry a remote signer's
@@ -1152,11 +1165,33 @@ export class Relay extends DurableObject<Env> {
11521165 `INSERT OR IGNORE INTO reports(id,reporter,target_pubkey,target_event,type,content,at) VALUES(?,?,?,?,?,?,?)`,11531166 e.id, e.pubkey, p, et?.[1] ?? "", type.slice(0, 32), e.content.slice(0, 2000), e.created_at,11541167 );1168+ // Enough distinct reporters hide an event until a moderator looks.1169+ const th = this.settings.policy.reportThreshold;1170+ const target = et?.[1] ?? "";1171+ if (th > 0 && /^[0-9a-f]{64}$/.test(target) && !this.settings.isEventHidden(target) && !this.settings.isEventBanned(target)) {1172+ const n = this.sql.exec<{ n: number }>(`SELECT count(DISTINCT reporter) AS n FROM reports WHERE target_event=? AND status='open'`, target).one().n;1173+ if (n >= th && this.sql.exec(`SELECT 1 FROM events WHERE id=?`, target).toArray().length) this.settings.setEvent(target, "hide", `${n} reports`, e.created_at);1174+ }11551175 const where = host ? `https://${host}/#people` : "the People tab";11561176 void notify(this, "reports", `New report on ${this.slug}: ${type || "report"} about ${p.slice(0, 8)}${et ? " (event " + et[1].slice(0, 8) + ")" : ""} by ${e.pubkey.slice(0, 8)}.${e.content ? " " + e.content.slice(0, 300) : ""} Review it at ${where}`, "a report on " + this.slug);11571177 return { ok: true, msg: "info: report received", stored: false };11581178 }11591179 1180+ // guestPass lets a stranger through a limited write rule: a kind the owner1181+ // opened to anyone, or, when replies are open, a note or comment that1182+ // answers something a member or the owner wrote here.1183+ private guestPass(e: Event): boolean {1184+ const p = this.settings.policy;1185+ if (p.openKinds.includes(e.kind)) return true;1186+ if (!p.guestReplies || (e.kind !== 1 && e.kind !== 1111)) return false;1187+ const parents = [...tagValues(e, "e"), ...tagValues(e, "E")].filter((id) => /^[0-9a-f]{64}$/.test(id)).slice(0, 5);1188+ for (const id of parents) {1189+ const row = this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM events WHERE id=?`, id).toArray()[0];1190+ if (row && this.settings.isAllowed(row.pubkey)) return true;1191+ }1192+ return false;1193+ }1194+11601195 // accept runs the write-side rules and stores the event. conn is null for11611196 // host-side publishes (the dashboard), which skip client policy.11621197 accept(e: Event, conn: ConnState | null): { ok: boolean; msg: string; stored: boolean } {
@@ -1181,8 +1216,8 @@ export class Relay extends DurableObject<Env> {
11811216 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`);11821217 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`);11831218 }1184- if (p.writes === "owner" && !this.settings.isOwner(e.pubkey)) return no("restricted: only the relay owner may publish here");1185- if (p.writes === "allowlist" && !this.settings.isAllowed(e.pubkey)) return no("restricted: this relay only accepts events from its members");1219+ const why = this.settings.mayWrite(e.pubkey);1220+ if (why && !this.guestPass(e)) return no(why);11861221 if (p.minPow > 0) {11871222 const d = difficulty(e);11881223 if (d.difficulty < p.minPow) return no(`pow: difficulty ${d.difficulty} is less than ${p.minPow}`);
@@ -1199,6 +1234,7 @@ export class Relay extends DurableObject<Env> {
11991234 const err = this.store.save(e, t);12001235 if (err === ERR_DUPLICATE) return { ok: true, msg: err, stored: false };12011236 if (err) return no(err);1237+ if (e.kind === 3) this.settings.noteContacts(e.pubkey);12021238 this.store.noteSaved(e.pubkey, canonical(e).length, isReplaceable(e.kind));12031239 if (exp > 0) this.scheduleSweep(exp);12041240 else this.ensureAlarm();
M src/roles.ts +1 -1
M src/settings.ts +139 -10
@@ -39,7 +39,17 @@ export interface Policy {
3939 languageTags: string[]; // BCP-47, such as en or pt-BR4040 relayCountries: string[]; // ISO 3166-1 alpha-2, such as US4141 notify: NotifySettings; // relay-signed NIP-17 messages to the owner42- writes: "open" | "allowlist" | "owner"; // who may publish42+ writes: "open" | "allowlist" | "wot" | "owner"; // who may publish; wot is members and their follows43+ // Guests: kinds anyone may write whatever the write rule says, and44+ // whether anyone may reply to a member's note or comment thread.45+ openKinds: number[];46+ guestReplies: boolean;47+ // Content containing one of these, case-insensitive, is refused unless the48+ // author is the owner or a moderator.49+ blockedWords: string[];50+ // Open reports from this many distinct reporters hide an event until a51+ // moderator resolves them; 0 turns it off.52+ reportThreshold: number;4353 reads: "open" | "auth" | "members"; // whether REQ/COUNT need NIP-42, or membership4454 joinTerms: string; // shown before an invite is accepted (markdown-ish plain text)4555 directoryPublic: boolean; // whether the people directory is shown to visitors
@@ -77,6 +87,10 @@ export const DEFAULT_POLICY: Policy = {
7787 notify: { reports: false, fuel: false, jobs: false, succession: false },7888 succession: null,7989 writes: "open",90+ openKinds: [],91+ guestReplies: false,92+ blockedWords: [],93+ reportThreshold: 0,8094 reads: "open",8195 joinTerms: "",8296 directoryPublic: true,
@@ -157,6 +171,44 @@ export function dumpFields(patch: Record<string, unknown>): Partial<Policy> {
157171 158172 export const NAME_RE = /^[a-z0-9._-]{1,64}$/;159173 174+export const WRITE_RULES = ["open", "allowlist", "wot", "owner"] as const;175+export type WriteRule = (typeof WRITE_RULES)[number];176+export const isWriteRule = (v: unknown): v is WriteRule => typeof v === "string" && (WRITE_RULES as readonly string[]).includes(v);177+178+const MAX_OPEN_KINDS = 50;179+const MAX_BLOCKED_WORDS = 200;180+// How many pubkeys the web of trust may hold; past it the newest lists are dropped.181+export const MAX_WOT = 50_000;182+183+// blockedWords cleans a list: lowercased, trimmed, 2 to 64 characters, unique, capped.184+export function blockedWords(v: unknown): string[] | undefined {185+ if (!Array.isArray(v)) return undefined;186+ const seen = new Set<string>();187+ for (const x of v) {188+ if (typeof x !== "string") continue;189+ const w = x.trim().toLowerCase().replace(/\s+/g, " ");190+ if (w.length >= 2 && w.length <= 64) seen.add(w);191+ if (seen.size === MAX_BLOCKED_WORDS) break;192+ }193+ return [...seen];194+}195+196+// gateFields validates the guest, blocked-word and report-threshold parts of197+// a policy patch. Anything that does not fit is dropped.198+export function gateFields(patch: Record<string, unknown>): Partial<Policy> {199+ const out: Partial<Policy> = {};200+ if (Array.isArray(patch.openKinds)) {201+ const seen = new Set<number>();202+ for (const k of patch.openKinds) if (Number.isInteger(k) && (k as number) >= 0 && (k as number) <= 65535) seen.add(k as number);203+ out.openKinds = [...seen].sort((a, b) => a - b).slice(0, MAX_OPEN_KINDS);204+ }205+ if (typeof patch.guestReplies === "boolean") out.guestReplies = patch.guestReplies;206+ const words = blockedWords(patch.blockedWords);207+ if (words) out.blockedWords = words;208+ if (Number.isInteger(patch.reportThreshold) && (patch.reportThreshold as number) >= 0 && (patch.reportThreshold as number) <= 100) out.reportThreshold = patch.reportThreshold as number;209+ return out;210+}211+160212 const TAG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;161213 const LANG_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;162214 const COUNTRY_RE = /^[A-Z]{2}$/;
@@ -208,6 +260,12 @@ export class Settings {
208260 private banned = new Set<string>();209261 private memberSet = new Set<string>();210262 private bannedEvents = new Set<string>();263+ // Events hidden by reports until a moderator looks. The store holds the264+ // same Set and leaves them out of every read.265+ readonly hiddenEvents = new Set<string>();266+ // The web of trust: members and the owner, plus everyone in their newest267+ // contact lists. Only consulted when the write rule is wot.268+ private wot = new Set<string>();211269 private blockedIPs = new Set<string>();212270 private limits = new Map<string, MemberLimits>();213271 private allowedKinds = new Set<number>();
@@ -228,12 +286,71 @@ export class Settings {
228286 this.memberSet.add(r.pubkey);229287 if (r.keep_days > 0 || r.max_bytes > 0) this.limits.set(r.pubkey, { keep: r.keep_days, cap: r.max_bytes });230288 }231- for (const r of this.sql.exec<{ id: string }>(`SELECT id FROM event_rules WHERE rule='ban'`)) this.bannedEvents.add(r.id);289+ for (const r of this.sql.exec<{ id: string; rule: string }>(`SELECT id, rule FROM event_rules WHERE rule IN ('ban','hide')`)) (r.rule === "ban" ? this.bannedEvents : this.hiddenEvents).add(r.id);232290 for (const r of this.sql.exec<{ ip: string }>(`SELECT ip FROM ip_rules`)) this.blockedIPs.add(r.ip);233291 for (const r of this.sql.exec<{ kind: number; rule: string }>(`SELECT kind, rule FROM kind_rules`)) {234292 (r.rule === "allow" ? this.allowedKinds : this.blockedKinds).add(r.kind);235293 }236294 for (const r of this.sql.exec<{ kind: number; days: number }>(`SELECT kind, days FROM retention`)) this.retention.set(r.kind, r.days);295+ if (this.policy.writes === "wot") this.rebuildWot();296+ }297+298+ // ---- the write rule ----299+300+ // mayWrite applies the write rule to a pubkey: "" allows, else the reason.301+ mayWrite(pubkey: string): string {302+ switch (this.policy.writes) {303+ case "open":304+ return "";305+ case "owner":306+ return this.isOwner(pubkey) ? "" : "restricted: only the relay owner may publish here";307+ case "allowlist":308+ return this.isAllowed(pubkey) ? "" : "restricted: this relay only accepts events from its members";309+ case "wot":310+ return this.isAllowed(pubkey) || this.wot.has(pubkey) ? "" : "restricted: this relay only accepts events from its members and the people they follow";311+ }312+ }313+ isTrusted(pubkey: string) {314+ return this.wot.has(pubkey);315+ }316+ get wotSize() {317+ return this.wot.size;318+ }319+ // rebuildWot reads the newest kind 3 of the owner and every member, one hop.320+ rebuildWot() {321+ const next = new Set<string>();322+ const people = [this.policy.owner, ...this.memberSet].filter(Boolean);323+ for (const pk of people) {324+ const row = this.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=3 AND pubkey=? ORDER BY created_at DESC LIMIT 1`, pk).toArray()[0];325+ if (!row) continue;326+ let tags: unknown;327+ try {328+ tags = (JSON.parse(row.raw) as { tags?: unknown }).tags;329+ } catch {330+ continue;331+ }332+ if (!Array.isArray(tags)) continue;333+ for (const t of tags) {334+ if (Array.isArray(t) && t[0] === "p" && typeof t[1] === "string" && /^[0-9a-f]{64}$/.test(t[1])) next.add(t[1]);335+ if (next.size >= MAX_WOT) break;336+ }337+ if (next.size >= MAX_WOT) break;338+ }339+ this.wot = next;340+ }341+ // noteContacts is called when a kind 3 lands: a member's list changes the web.342+ noteContacts(pubkey: string) {343+ if (this.policy.writes === "wot" && this.isAllowed(pubkey)) this.rebuildWot();344+ }345+ private membersChanged() {346+ if (this.policy.writes === "wot") this.rebuildWot();347+ }348+349+ // hasBlockedWord says whether text contains one of the blocked words.350+ hasBlockedWord(text: string): boolean {351+ if (this.policy.blockedWords.length === 0 || text === "") return false;352+ const lower = text.toLowerCase();353+ return this.policy.blockedWords.some((w) => lower.includes(w));237354 }238355 239356 // migrateMembers folds the earlier allow list and names table into members,
@@ -256,8 +373,11 @@ export class Settings {
256373 }257374 258375 update(patch: Partial<Policy>) {376+ const wasWot = this.policy.writes === "wot";259377 this.policy = { ...this.policy, ...patch };260378 this.sql.exec(`INSERT INTO settings(key,value) VALUES('policy',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(this.policy));379+ if (this.policy.writes === "wot" && !wasWot) this.rebuildWot();380+ if (this.policy.writes !== "wot") this.wot.clear();261381 }262382 263383 isOwner(pubkey: string) {
@@ -280,7 +400,7 @@ export class Settings {
280400 // to the defaults. Identity, people and bans stay.281401 resetRules() {282402 const d = DEFAULT_POLICY;283- this.update({ writes: d.writes, reads: d.reads, directoryPublic: d.directoryPublic, maxBlobMB: d.maxBlobMB, eventsPerMinute: d.eventsPerMinute, reqsPerMinute: d.reqsPerMinute, minPow: d.minPow, maxFuture: d.maxFuture, maxLimit: d.maxLimit, maxSubs: d.maxSubs });403+ this.update({ writes: d.writes, reads: d.reads, openKinds: d.openKinds, guestReplies: d.guestReplies, directoryPublic: d.directoryPublic, maxBlobMB: d.maxBlobMB, eventsPerMinute: d.eventsPerMinute, reqsPerMinute: d.reqsPerMinute, minPow: d.minPow, maxFuture: d.maxFuture, maxLimit: d.maxLimit, maxSubs: d.maxSubs });284404 for (const k of [...this.listKinds("allow"), ...this.listKinds("block")]) this.setKind(k, null);285405 for (const r of this.listRetention()) this.setRetention(r.kind, 0);286406 }
@@ -329,6 +449,7 @@ export class Settings {
329449 this.update({ owner: pubkey, succession: null });330450 this.sql.exec(`UPDATE members SET role='moderator' WHERE pubkey=?`, old);331451 this.sql.exec(`UPDATE members SET role='owner' WHERE pubkey=?`, pubkey);452+ this.membersChanged();332453 return "";333454 }334455
@@ -403,6 +524,7 @@ export class Settings {
403524 const role = this.isOwner(pubkey) ? "owner" : "member";404525 this.sql.exec(`INSERT INTO members(pubkey,role,name,note,joined_at,via,keep_days,max_bytes,invited_by) VALUES(?,?,?,?,?,?,?,?,?)`, pubkey, role, name, note, now, (patch.via ?? "added").slice(0, 40), keep, cap, patch.invitedBy ?? "");405526 this.memberSet.add(pubkey);527+ this.membersChanged();406528 }407529 if (keep > 0 || cap > 0) this.limits.set(pubkey, { keep, cap });408530 else this.limits.delete(pubkey);
@@ -413,7 +535,9 @@ export class Settings {
413535 if (this.isOwner(pubkey)) return false;414536 this.memberSet.delete(pubkey);415537 this.limits.delete(pubkey);416- return this.sql.exec(`DELETE FROM members WHERE pubkey=?`, pubkey).rowsWritten > 0;538+ const gone = this.sql.exec(`DELETE FROM members WHERE pubkey=?`, pubkey).rowsWritten > 0;539+ if (gone) this.membersChanged();540+ return gone;417541 }418542 419543 // ---- portable configuration ----
@@ -443,8 +567,8 @@ export class Settings {
443567 const policy = (c.policy && typeof c.policy === "object" ? c.policy : {}) as Record<string, unknown>;444568 const clean: Partial<Policy> = {};445569 for (const k of ["name", "description", "icon", "contact", "joinTerms"] as const) if (typeof policy[k] === "string") clean[k] = (policy[k] as string).slice(0, 20000);446- Object.assign(clean, publicFields(policy), dumpFields(policy));447- if (policy.writes === "open" || policy.writes === "allowlist" || policy.writes === "owner") clean.writes = policy.writes;570+ Object.assign(clean, publicFields(policy), dumpFields(policy), gateFields(policy));571+ if (isWriteRule(policy.writes)) clean.writes = policy.writes;448572 if (policy.reads === "open" || policy.reads === "auth" || policy.reads === "members") clean.reads = policy.reads;449573 if (typeof policy.directoryPublic === "boolean") clean.directoryPublic = policy.directoryPublic;450574 const notify = notifySettings(policy.notify, this.policy.notify);
@@ -499,21 +623,26 @@ export class Settings {
499623 return this.banned.has(pubkey);500624 }501625 502- // Event rules503- setEvent(id: string, rule: "ban" | null, reason = "", now = 0) {626+ // Event rules: ban refuses an id for good; hide keeps it out of every read627+ // until a moderator resolves the reports on it.628+ setEvent(id: string, rule: "ban" | "hide" | null, reason = "", now = 0) {504629 this.bannedEvents.delete(id);630+ this.hiddenEvents.delete(id);505631 this.sql.exec(`DELETE FROM event_rules WHERE id=?`, id);506632 if (rule) {507- this.bannedEvents.add(id);633+ (rule === "ban" ? this.bannedEvents : this.hiddenEvents).add(id);508634 this.sql.exec(`INSERT INTO event_rules(id,rule,reason,at) VALUES(?,?,?,?)`, id, rule, reason, now);509635 }510636 }511- listEvents(rule: "ban") {637+ listEvents(rule: "ban" | "hide") {512638 return this.sql.exec<{ id: string; reason: string }>(`SELECT id, reason FROM event_rules WHERE rule=? ORDER BY at DESC`, rule).toArray();513639 }514640 isEventBanned(id: string) {515641 return this.bannedEvents.has(id);516642 }643+ isEventHidden(id: string) {644+ return this.hiddenEvents.has(id);645+ }517646 518647 // Address blocks (NIP-86 blockip). Addresses churn, so these are not part519648 // of the portable configuration: they describe a moment, not the relay.
M src/store.ts +17 -0