clone

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

main
bindws / src / settings.ts
settings.ts 45.5K
1// Per-relay settings and policy, kept in the same SQLite database.2import { DEFAULT_GIT_LIMITS, gitLimitPatch, type GitLimits } from "./git-limits.ts";3import { callbackOrigins } from "./push-policy.ts";4import { notifySettings, type NotifySettings } from "./notify.ts";5import type { CustomHost } from "./domains.ts";6import type { Role } from "./roles.ts";78// A lease makes an unclaimed relay usable for a while: open to everyone,9// then wiped at `until` unless somebody claims it first. holder "" means10// anyone may claim; a pubkey means only that key.11export interface Lease {12 until: number;13 holder: string;14}1516// Succession is the dead-man's switch: when the owner has not signed in for17// `afterDays`, the relay warns them for a month, then hands itself to the18// heir, who must be a member.19export interface Succession {20 heir: string;21 afterDays: number;22}23export const SUCCESSION_DAYS = [90, 180, 365];24// How long the warnings run before the handover.25export const SUCCESSION_WARN_DAYS = 30;2627export interface Policy {28 owner: string; // pubkey; "" while unclaimed29 succession: Succession | null; // off until the owner names an heir30 lease: Lease | null; // set while the relay is a temporary lease31 customHosts: CustomHost[]; // this relay's own domains (see domains.ts)32 name: string;33 description: string;34 icon: string;35 banner: string; // NIP-11 banner image URL36 contact: string;37 // NIP-11 extras: links to written policies, and what the relay is about.38 postingPolicy: string; // URL39 privacyPolicy: string; // URL40 tags: string[]; // short topic words41 languageTags: string[]; // BCP-47, such as en or pt-BR42 relayCountries: string[]; // ISO 3166-1 alpha-2, such as US43 notify: NotifySettings; // relay-signed NIP-17 messages to the owner44 writes: "open" | "allowlist" | "wot" | "owner"; // who may publish; wot is members and their follows45 // Guests: kinds anyone may write whatever the write rule says, and46 // whether anyone may reply to a member's note or comment thread.47 openKinds: number[];48 guestReplies: boolean;49 // Content containing one of these, case-insensitive, is refused unless the50 // author is the owner or a moderator. An entry written /like this/ is a51 // regular expression; blockedWordsInTags searches tag values as well.52 blockedWords: string[];53 blockedWordsInTags: boolean;54 // Open reports from this many distinct reporters hide an event until a55 // moderator resolves them; 0 turns it off.56 reportThreshold: number;57 reads: "open" | "auth" | "members"; // whether REQ/COUNT need NIP-42, or membership58 joinTerms: string; // shown before an invite is accepted (markdown-ish plain text)59 directoryPublic: boolean; // whether the people directory is shown to visitors60 maxBlobMB: number; // Blossom upload size cap61 git: GitLimits; // per-relay Git storage and transfer capacity62 eventsPerMinute: number; // per-connection write rate63 reqsPerMinute: number; // per-connection query rate64 minPow: number;65 maxFuture: number; // seconds; 0 disables66 maxLimit: number;67 maxSubs: number;68 maxMessageKB: number; // largest socket message, in KB; MAX_MESSAGE in relay.ts is the ceiling69 // Nightly dumps: a JSONL of every event, written to R2 by the alarm and70 // kept for `dumpsKeep` runs. Counted as media for fuel.71 dumps: "off" | "daily" | "weekly";72 dumpsKeep: number;73 // Members inviting members: a member whose distance from the owner along74 // invited_by is below `depth` may hold up to `quota` live invites. depth 075 // turns it off, which leaves inviting to the owner and moderators.76 memberInvites: { depth: number; quota: number };77 // Views (views.ts): a name maps to false when the owner switched it off.78 views: Record<string, ViewSetting>;79 features: Features;80 pushCallbacks: string[]; // HTTPS origins approved by the owner and the host operator81 letteredNips: boolean; // opt into mixed NIP-11 identifiers; push also requires them82 // Opt-in NIP-65 routing of locally-authored public events to their relays.83 delivery: { enabled: boolean; maxTargets: number };84}8586export const VIEW_NAMES = ["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"];87// A view's setting: off, or the trigger it runs on. A view not named here88// runs on its own trigger (views.ts); a setting a view cannot take (a89// schedule for the live presence view) is ignored there.90export const VIEW_SETTINGS = ["off", "write", "hourly", "daily"] as const;91export type ViewSetting = (typeof VIEW_SETTINGS)[number];9293// viewFields applies a `views` patch of name to boolean; unknown names are dropped.94// Features an operator may switch off, each a door or a cost. The bridge95// is not one: the console runs on it. What is off leaves supported_nips96// (nip11.ts), answers 404 at its door (routes.ts) and is refused at the97// socket (gates.ts, relay.ts).98export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp", "grasp02", "grasp03", "grasp05", "grasp06", "push"] as const;99export type Feature = (typeof FEATURE_NAMES)[number];100export type SearchMode = "full" | "prose" | "off";101export interface Features {102 search: SearchMode; // NIP-50: full indexes every public kind with content, prose the kinds that carry prose103 sync: boolean; // NIP-77 negentropy104 count: boolean; // NIP-45 COUNT, with HLL105 discovery: boolean; // NIP-66: the record the relay signs about itself106 names: boolean; // NIP-05 under the relay's domain107 files: boolean; // Blossom and NIP-96108 pages: boolean; // notes and articles as pages, the feed109 sites: { enabled: boolean; mirror: boolean }; // NIP-5A hosting and automatic blob copies110 grasp: boolean; // GRASP Git repositories and accepted repository state111 grasp02: boolean; // GRASP-02 proactive repository synchronization112 grasp03: boolean; // GRASP-03 outbox discussion synchronization113 grasp05: boolean; // GRASP-05 archive admission114 grasp06: boolean; // GRASP-06 alternative PR hosting115 signer: boolean; // NIP-46 traffic carried for anyone116 push: boolean; // NIP-9a relay-to-callback delivery117 marmot: boolean; // Marmot's opaque MLS transport events118}119export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, grasp02: false, grasp03: false, grasp05: false, grasp06: false, push: false };120121// A connection is one app shortcut on the Connect fold (connections.ts): a122// template from connection-templates/, who may see it, the owner's own123// title and about when they changed the template's, and the template's124// inputs as the owner filled them. The list is kept in the order shown.125export const VISIBILITIES = ["public", "auth", "members", "owner"] as const;126export type Visibility = (typeof VISIBILITIES)[number];127export interface Connection {128 template: string;129 visibility: Visibility;130 title?: string;131 about?: string;132 inputs?: Record<string, string>;133}134// What a relay shows until its owner picks: the feed, the profile handoff135// for feed apps, and the group. A saved list, even an empty one, replaces it.136export const DEFAULT_CONNECTIONS: Connection[] = [137 { template: "notes", visibility: "public" },138 { template: "find-me", visibility: "public" },139 { template: "group", visibility: "public" },140];141export const featureOn = (p: Policy, f: Feature): boolean => {142 if (f === "sites") return p.features.sites.enabled;143 if (["grasp02", "grasp03", "grasp05", "grasp06"].includes(f) && p.features[f] !== true) return false;144 if (p.features[f] === false || p.features[f] === "off") return false;145 if (f === "grasp02") return featureOn(p, "grasp");146 if (f === "grasp03" || f === "grasp05") return featureOn(p, "grasp02");147 if (f === "grasp06") return featureOn(p, "grasp");148 return true;149};150151export function featureFields(patch: Record<string, unknown>, cur: Features): Partial<Policy> {152 if (!patch.features || typeof patch.features !== "object") return {};153 const out: Features = { ...cur };154 for (const [k, v] of Object.entries(patch.features as Record<string, unknown>)) {155 if (k === "sites") {156 if (typeof v === "boolean") out.sites = { ...cur.sites, enabled: v };157 else if (v && typeof v === "object") {158 const patch = v as Record<string, unknown>;159 out.sites = { enabled: typeof patch.enabled === "boolean" ? patch.enabled : cur.sites.enabled, mirror: typeof patch.mirror === "boolean" ? patch.mirror : cur.sites.mirror };160 }161 } else if (k === "search") {162 if (v === "full" || v === "prose" || v === "off") out.search = v;163 } else if ((FEATURE_NAMES as readonly string[]).includes(k) && typeof v === "boolean") (out as unknown as Record<string, boolean>)[k] = v;164 }165 return { features: out };166}167168// policyPatch is what setpolicy and a configuration document accept: every169// field checked, the rest dropped.170export function policyPatch(patch: Record<string, unknown>, cur: Policy): Partial<Policy> {171 const clean: Record<string, unknown> = {};172 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);173 Object.assign(clean, publicFields(patch), dumpFields(patch), gateFields(patch), viewFields(patch, cur.views), featureFields(patch, cur.features));174 if (patch.delivery && typeof patch.delivery === "object") {175 const d = patch.delivery as Record<string, unknown>;176 clean.delivery = { enabled: typeof d.enabled === "boolean" ? d.enabled : cur.delivery.enabled, maxTargets: Number.isInteger(d.maxTargets) ? Math.max(1, Math.min(16, d.maxTargets as number)) : cur.delivery.maxTargets };177 }178 if (isWriteRule(patch.writes)) clean.writes = patch.writes;179 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;180 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);181 if (typeof patch.directoryPublic === "boolean") clean.directoryPublic = patch.directoryPublic;182 if (typeof patch.letteredNips === "boolean") clean.letteredNips = patch.letteredNips;183 const callbacks = callbackOrigins(patch.pushCallbacks);184 if (callbacks) clean.pushCallbacks = callbacks;185 const notifyPatch = notifySettings(patch.notify, cur.notify);186 if (notifyPatch) clean.notify = notifyPatch;187 Object.assign(clean, limitFields(patch));188 if (patch.git !== undefined) {189 const git = gitLimitPatch(patch.git, cur.git);190 if (git) clean.git = git;191 }192 return clean as Partial<Policy>;193}194195// viewFields takes a view's setting; true and false still mean the default196// trigger and off, as they did before settings were triggers.197export function viewFields(patch: Record<string, unknown>, cur: Record<string, ViewSetting>): Partial<Policy> {198 if (!patch.views || typeof patch.views !== "object") return {};199 const out: Record<string, ViewSetting> = { ...cur };200 for (const [k, v] of Object.entries(patch.views as Record<string, unknown>)) {201 if (!VIEW_NAMES.includes(k)) continue;202 if (v === true) delete out[k];203 else if (v === false) out[k] = "off";204 else if ((VIEW_SETTINGS as readonly unknown[]).includes(v)) out[k] = v as ViewSetting;205 }206 return { views: out };207}208209export const DEFAULT_POLICY: Policy = {210 owner: "",211 lease: null,212 customHosts: [],213 name: "",214 description: "",215 icon: "",216 banner: "",217 contact: "",218 postingPolicy: "",219 privacyPolicy: "",220 tags: [],221 languageTags: [],222 relayCountries: [],223 notify: { reports: false, fuel: false, jobs: false, succession: false, digest: false },224 succession: null,225 writes: "open",226 openKinds: [],227 guestReplies: false,228 blockedWords: [],229 blockedWordsInTags: false,230 reportThreshold: 0,231 reads: "open",232 joinTerms: "",233 directoryPublic: true,234 maxBlobMB: 25,235 git: { ...DEFAULT_GIT_LIMITS },236 eventsPerMinute: 120,237 reqsPerMinute: 240,238 minPow: 0,239 maxFuture: 900,240 maxLimit: 500,241 maxSubs: 20,242 maxMessageKB: 128,243 dumps: "off",244 dumpsKeep: 7,245 memberInvites: { depth: 0, quota: 0 },246 views: {},247 features: { ...DEFAULT_FEATURES },248 pushCallbacks: [],249 letteredNips: false,250 delivery: { enabled: false, maxTargets: 8 },251};252253export const SETTINGS_SCHEMA = `254CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);255CREATE TABLE IF NOT EXISTS pubkey_rules (pubkey TEXT PRIMARY KEY, rule TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL);256CREATE TABLE IF NOT EXISTS event_rules (id TEXT PRIMARY KEY, rule TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL);257CREATE TABLE IF NOT EXISTS ip_rules (ip TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL);258CREATE TABLE IF NOT EXISTS kind_rules (kind INTEGER PRIMARY KEY, rule TEXT NOT NULL);259CREATE TABLE IF NOT EXISTS retention (kind INTEGER PRIMARY KEY, days INTEGER NOT NULL);260CREATE 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 '');261CREATE TABLE IF NOT EXISTS members (pubkey TEXT PRIMARY KEY, role TEXT NOT NULL DEFAULT 'member', name TEXT, note TEXT NOT NULL DEFAULT '', joined_at INTEGER NOT NULL, via TEXT NOT NULL DEFAULT '');262CREATE UNIQUE INDEX IF NOT EXISTS members_name ON members(name) WHERE name IS NOT NULL;263CREATE TABLE IF NOT EXISTS nip05 (name TEXT PRIMARY KEY, pubkey TEXT NOT NULL, at INTEGER NOT NULL);264CREATE TABLE IF NOT EXISTS imports (id TEXT PRIMARY KEY, name TEXT NOT NULL, bytes INTEGER NOT NULL, at INTEGER NOT NULL);265CREATE TABLE IF NOT EXISTS reports (id TEXT PRIMARY KEY, reporter TEXT NOT NULL, target_pubkey TEXT NOT NULL, target_event TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_by TEXT NOT NULL DEFAULT '', resolved_at INTEGER NOT NULL DEFAULT 0, action TEXT NOT NULL DEFAULT '');266CREATE TABLE IF NOT EXISTS blobs (sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, type TEXT NOT NULL, uploader TEXT NOT NULL, uploaded INTEGER NOT NULL);267CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC);268CREATE TABLE IF NOT EXISTS dumps (name TEXT PRIMARY KEY, bytes INTEGER NOT NULL, events INTEGER NOT NULL, at INTEGER NOT NULL);269`;270271// Columns added after the first release. There are no PRAGMAs in this272// SQLite, so a probing SELECT stands in for "does the column exist".273const MEMBER_COLUMNS: [string, string][] = [274 ["keep_days", "INTEGER NOT NULL DEFAULT 0"], // per-member keep-for; 0 = the relay's rules275 ["max_bytes", "INTEGER NOT NULL DEFAULT 0"], // per-member storage cap; 0 = unlimited276 ["invited_by", "TEXT NOT NULL DEFAULT ''"], // who minted the invite they joined with277];278function ensureColumns(sql: SqlStorage, table: string, cols: [string, string][]) {279 for (const [col, decl] of cols) {280 try {281 sql.exec(`SELECT ${col} FROM ${table} LIMIT 1`).toArray();282 } catch {283 sql.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`);284 }285 }286}287288// A member is one person of this relay: the owner or someone let in. Their289// optional name is their NIP-05 handle at the relay's host.290export type Member = {291 pubkey: string;292 role: Role;293 name: string | null;294 note: string;295 joined_at: number;296 via: string; // claimed | invite <code> | added | profile297 keep_days: number; // 0 = the relay's rules298 max_bytes: number; // 0 = unlimited299 invited_by: string; // pubkey of the inviter, "" when added by the owner or joined open300};301302// Limits the write path checks per author, cached so a write costs no query.303export type MemberLimits = { keep: number; cap: number };304305// dumpFields validates the dump and member-invite settings of a policy patch.306export function dumpFields(patch: Record<string, unknown>): Partial<Policy> {307 const out: Partial<Policy> = {};308 if (patch.dumps === "off" || patch.dumps === "daily" || patch.dumps === "weekly") out.dumps = patch.dumps;309 if (Number.isInteger(patch.dumpsKeep) && (patch.dumpsKeep as number) >= 1 && (patch.dumpsKeep as number) <= 60) out.dumpsKeep = patch.dumpsKeep as number;310 const mi = patch.memberInvites as Record<string, unknown> | undefined;311 if (mi && typeof mi === "object" && Number.isInteger(mi.depth) && Number.isInteger(mi.quota) && (mi.depth as number) >= 0 && (mi.depth as number) <= 10 && (mi.quota as number) >= 0 && (mi.quota as number) <= 100) {312 out.memberInvites = { depth: mi.depth as number, quota: mi.quota as number };313 }314 return out;315}316317export const NAME_RE = /^[a-z0-9._-]{1,64}$/;318319export const WRITE_RULES = ["open", "allowlist", "wot", "owner"] as const;320export type WriteRule = (typeof WRITE_RULES)[number];321export const isWriteRule = (v: unknown): v is WriteRule => typeof v === "string" && (WRITE_RULES as readonly string[]).includes(v);322323const MAX_OPEN_KINDS = 50;324const MAX_BLOCKED_WORDS = 200;325// How many pubkeys the web of trust may hold; past it the newest lists are dropped.326export const MAX_WOT = 50_000;327328const MAX_PATTERN = 200;329330// wordPattern says whether a blocked-word entry is a regular expression,331// written /like this/, and gives its source.332export function wordPattern(w: string): string | null {333 return w.length >= 3 && w.startsWith("/") && w.endsWith("/") ? w.slice(1, -1) : null;334}335336// badBlockedWord is the reason an entry cannot be used, or "" when it can:337// a pattern that does not compile or is too long. Plain words are never bad,338// merely dropped when too short or too long.339export function badBlockedWord(x: unknown): string {340 if (typeof x !== "string") return "";341 const src = wordPattern(x.trim());342 if (src === null) return "";343 if (x.trim().length > MAX_PATTERN) return `invalid: pattern is longer than ${MAX_PATTERN} characters: ${x.trim().slice(0, 40)}`;344 try {345 new RegExp(src, "i");346 } catch (err) {347 return "invalid: " + (err instanceof Error ? err.message : "bad pattern");348 }349 return "";350}351352// blockedWords cleans a list: trimmed, unique, capped. A plain word is353// lowercased and 2 to 64 characters; a /pattern/ keeps its case, since it is354// compiled case-insensitive, and must compile.355export function blockedWords(v: unknown): string[] | undefined {356 if (!Array.isArray(v)) return undefined;357 const seen = new Set<string>();358 for (const x of v) {359 if (typeof x !== "string") continue;360 const t = x.trim();361 if (wordPattern(t) !== null) {362 if (badBlockedWord(t) === "") seen.add(t);363 } else {364 const w = t.toLowerCase().replace(/\s+/g, " ");365 if (w.length >= 2 && w.length <= 64) seen.add(w);366 }367 if (seen.size === MAX_BLOCKED_WORDS) break;368 }369 return [...seen];370}371372// gateFields validates the guest, blocked-word and report-threshold parts of373// a policy patch. Anything that does not fit is dropped.374export function gateFields(patch: Record<string, unknown>): Partial<Policy> {375 const out: Partial<Policy> = {};376 if (Array.isArray(patch.openKinds)) {377 const seen = new Set<number>();378 for (const k of patch.openKinds) if (Number.isInteger(k) && (k as number) >= 0 && (k as number) <= 65535) seen.add(k as number);379 out.openKinds = [...seen].sort((a, b) => a - b).slice(0, MAX_OPEN_KINDS);380 }381 if (typeof patch.guestReplies === "boolean") out.guestReplies = patch.guestReplies;382 const words = blockedWords(patch.blockedWords);383 if (words) out.blockedWords = words;384 if (typeof patch.blockedWordsInTags === "boolean") out.blockedWordsInTags = patch.blockedWordsInTags;385 if (Number.isInteger(patch.reportThreshold) && (patch.reportThreshold as number) >= 0 && (patch.reportThreshold as number) <= 100) out.reportThreshold = patch.reportThreshold as number;386 return out;387}388389// limitFields validates the numeric limits of a policy patch, clamped to390// what the relay can honour. Anything that is not a whole number is dropped.391export function limitFields(patch: Record<string, unknown>): Partial<Policy> {392 const out: Partial<Policy> = {};393 const clamp = (k: keyof Policy, lo: number, hi: number) => {394 const v = patch[k];395 if (Number.isInteger(v) && (v as number) >= 0) (out as Record<string, unknown>)[k] = Math.min(Math.max(v as number, lo), hi);396 };397 clamp("minPow", 0, 256);398 clamp("maxFuture", 0, Number.MAX_SAFE_INTEGER);399 clamp("maxLimit", 1, 5000);400 clamp("maxSubs", 1, 200);401 clamp("maxMessageKB", 16, 1024);402 clamp("maxBlobMB", 1, 95);403 clamp("eventsPerMinute", 1, Number.MAX_SAFE_INTEGER);404 clamp("reqsPerMinute", 1, Number.MAX_SAFE_INTEGER);405 return out;406}407408// validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.409export function validIP(ip: string): boolean {410 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;411 return /^[0-9a-f:]{2,39}$/.test(ip) && ip.includes(":") && !ip.includes(":::") && ip.split("::").length <= 2;412}413414const TAG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;415const LANG_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;416const COUNTRY_RE = /^[A-Z]{2}$/;417const MAX_LIST = 20;418419// publicFields validates the NIP-11 extras of a policy patch: three https420// links (an empty string clears one) and three short lists. Anything that421// does not fit is dropped, never stored half-right.422export function publicFields(patch: Record<string, unknown>): Partial<Policy> {423 const out: Partial<Policy> = {};424 const link = (v: unknown): string | undefined => {425 if (typeof v !== "string") return undefined;426 const s = v.trim().slice(0, 2000);427 if (s === "") return "";428 try {429 const u = new URL(s);430 return u.protocol === "https:" ? u.href : undefined;431 } catch {432 return undefined;433 }434 };435 const list = (v: unknown, re: RegExp, map: (s: string) => string): string[] | undefined => {436 if (!Array.isArray(v)) return undefined;437 const seen = new Set<string>();438 for (const x of v) {439 if (typeof x !== "string") continue;440 const s = map(x.trim());441 if (s !== "" && re.test(s)) seen.add(s);442 }443 return [...seen].slice(0, MAX_LIST);444 };445 const banner = link(patch.banner);446 if (banner !== undefined) out.banner = banner;447 const posting = link(patch.postingPolicy);448 if (posting !== undefined) out.postingPolicy = posting;449 const privacy = link(patch.privacyPolicy);450 if (privacy !== undefined) out.privacyPolicy = privacy;451 const tags = list(patch.tags, TAG_RE, (s) => s.toLowerCase());452 if (tags) out.tags = tags;453 const langs = list(patch.languageTags, LANG_RE, (s) => s);454 if (langs) out.languageTags = langs;455 const countries = list(patch.relayCountries, COUNTRY_RE, (s) => s.toUpperCase());456 if (countries) out.relayCountries = countries;457 return out;458}459460export class Settings {461 policy: Policy = { ...DEFAULT_POLICY };462 private banned = new Set<string>();463 private memberSet = new Set<string>();464 private bannedEvents = new Set<string>();465 // Events hidden by reports until a moderator looks. The store holds the466 // same Set and leaves them out of every read.467 readonly hiddenEvents = new Set<string>();468 // The web of trust: members and the owner, plus everyone in their newest469 // contact lists. Only consulted when the write rule is wot.470 private wot = new Set<string>();471 private blockedIPs = new Set<string>();472 private limits = new Map<string, MemberLimits>();473 private allowedKinds = new Set<number>();474 private blockedKinds = new Set<number>();475 // Days to keep events of a kind; RETENTION_ANY is the rule for kinds without one.476 private retention = new Map<number, number>();477 // The Connect fold's shortcuts; null until the owner saves a list, when478 // the defaults show.479 private connectionList: Connection[] | null = null;480481 constructor(private sql: SqlStorage) {}482483 load() {484 this.sql.exec(SETTINGS_SCHEMA);485 const row = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='policy'`).toArray()[0];486 if (row) {487 const stored = JSON.parse(row.value) as Partial<Policy>;488 this.policy = { ...DEFAULT_POLICY, ...stored, git: gitLimitPatch(stored.git) ?? { ...DEFAULT_GIT_LIMITS }, features: { ...DEFAULT_FEATURES, ...(stored.features ?? {}) } };489 // Views were on or off before they had triggers.490 const views: Record<string, ViewSetting> = {};491 for (const [k, v] of Object.entries((stored.views ?? {}) as Record<string, unknown>)) {492 if (v === false) views[k] = "off";493 else if ((VIEW_SETTINGS as readonly unknown[]).includes(v)) views[k] = v as ViewSetting;494 }495 this.policy.views = views;496 }497 ensureColumns(this.sql, "members", MEMBER_COLUMNS);498 this.migrateMembers();499 for (const r of this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM pubkey_rules WHERE rule='ban'`)) this.banned.add(r.pubkey);500 for (const r of this.sql.exec<{ pubkey: string; keep_days: number; max_bytes: number }>(`SELECT pubkey, keep_days, max_bytes FROM members`)) {501 this.memberSet.add(r.pubkey);502 if (r.keep_days > 0 || r.max_bytes > 0) this.limits.set(r.pubkey, { keep: r.keep_days, cap: r.max_bytes });503 }504 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);505 for (const r of this.sql.exec<{ ip: string }>(`SELECT ip FROM ip_rules`)) this.blockedIPs.add(r.ip);506 for (const r of this.sql.exec<{ kind: number; rule: string }>(`SELECT kind, rule FROM kind_rules`)) {507 (r.rule === "allow" ? this.allowedKinds : this.blockedKinds).add(r.kind);508 }509 for (const r of this.sql.exec<{ kind: number; days: number }>(`SELECT kind, days FROM retention`)) this.retention.set(r.kind, r.days);510 const saved = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='connections'`).toArray()[0];511 if (saved) {512 try {513 const list = JSON.parse(saved.value) as unknown;514 this.connectionList = Array.isArray(list) ? (list as Connection[]) : [];515 } catch {516 this.connectionList = [];517 }518 }519 if (this.policy.writes === "wot") this.rebuildWot();520 }521522 // ---- the write rule ----523524 // mayWrite applies the write rule to a pubkey: "" allows, else the reason.525 mayWrite(pubkey: string): string {526 switch (this.policy.writes) {527 case "open":528 return "";529 case "owner":530 return this.isOwner(pubkey) ? "" : "restricted: only the relay owner may publish here";531 case "allowlist":532 return this.isAllowed(pubkey) ? "" : "restricted: this relay only accepts events from its members";533 case "wot":534 return this.isAllowed(pubkey) || this.wot.has(pubkey) ? "" : "restricted: this relay only accepts events from its members and the people they follow";535 }536 }537 isTrusted(pubkey: string) {538 return this.wot.has(pubkey);539 }540 get wotSize() {541 return this.wot.size;542 }543 // rebuildWot reads the newest kind 3 of the owner and every member, one hop.544 rebuildWot() {545 const next = new Set<string>();546 const people = [this.policy.owner, ...this.memberSet].filter(Boolean);547 for (const pk of people) {548 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];549 if (!row) continue;550 let tags: unknown;551 try {552 tags = (JSON.parse(row.raw) as { tags?: unknown }).tags;553 } catch {554 continue;555 }556 if (!Array.isArray(tags)) continue;557 for (const t of tags) {558 if (Array.isArray(t) && t[0] === "p" && typeof t[1] === "string" && /^[0-9a-f]{64}$/.test(t[1])) next.add(t[1]);559 if (next.size >= MAX_WOT) break;560 }561 if (next.size >= MAX_WOT) break;562 }563 this.wot = next;564 }565 // noteContacts is called when a kind 3 lands: a member's list changes the web.566 noteContacts(pubkey: string) {567 if (this.policy.writes === "wot" && this.isAllowed(pubkey)) this.rebuildWot();568 }569 private membersChanged() {570 if (this.policy.writes === "wot") this.rebuildWot();571 }572573 // The blocked words as they are matched: a lowercased substring or a574 // compiled pattern. Rebuilt when the list changes, by identity.575 private wordsFor: string[] | null = null;576 private words: (string | RegExp)[] = [];577578 // hasBlockedWord says where an event carries a blocked word: "content",579 // "tags" when the rule reaches into tag values, or "" when it is clean.580 hasBlockedWord(text: string, tags: string[][] = []): "" | "content" | "tags" {581 const list = this.policy.blockedWords;582 if (list.length === 0) return "";583 if (this.wordsFor !== list) {584 this.wordsFor = list;585 this.words = list.map((w) => {586 const src = wordPattern(w);587 return src === null ? w : new RegExp(src, "i");588 });589 }590 const hit = (s: string) => {591 if (s === "") return false;592 const lower = s.toLowerCase();593 return this.words.some((w) => (typeof w === "string" ? lower.includes(w) : w.test(s)));594 };595 if (hit(text)) return "content";596 if (this.policy.blockedWordsInTags && tags.length && hit(tags.map((t) => t.slice(1).join(" ")).join(" "))) return "tags";597 return "";598 }599600 // migrateMembers folds the earlier allow list and names table into members,601 // and makes sure the owner has a row.602 private migrateMembers() {603 const now = Math.floor(Date.now() / 1000);604 for (const r of this.sql.exec<{ pubkey: string; reason: string; at: number }>(`SELECT pubkey, reason, at FROM pubkey_rules WHERE rule='allow'`)) {605 this.sql.exec(`INSERT OR IGNORE INTO members(pubkey,role,note,joined_at,via) VALUES(?,'member',?,?,'added')`, r.pubkey, r.reason, r.at);606 }607 this.sql.exec(`DELETE FROM pubkey_rules WHERE rule='allow'`);608 for (const r of this.sql.exec<{ name: string; pubkey: string; at: number }>(`SELECT name, pubkey, at FROM nip05`)) {609 this.sql.exec(`INSERT OR IGNORE INTO members(pubkey,role,joined_at,via) VALUES(?,'member',?,'profile')`, r.pubkey, r.at);610 this.sql.exec(`UPDATE members SET name=? WHERE pubkey=? AND name IS NULL AND NOT EXISTS (SELECT 1 FROM members WHERE name=?)`, r.name, r.pubkey, r.name);611 }612 this.sql.exec(`DELETE FROM nip05`);613 if (this.policy.owner) {614 this.sql.exec(`INSERT OR IGNORE INTO members(pubkey,role,joined_at,via) VALUES(?,'owner',?,'claimed')`, this.policy.owner, now);615 this.sql.exec(`UPDATE members SET role='owner' WHERE pubkey=?`, this.policy.owner);616 }617 }618619 update(patch: Partial<Policy>) {620 const wasWot = this.policy.writes === "wot";621 this.policy = { ...this.policy, ...patch };622 this.sql.exec(`INSERT INTO settings(key,value) VALUES('policy',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(this.policy));623 if (this.policy.writes === "wot" && !wasWot) this.rebuildWot();624 if (this.policy.writes !== "wot") this.wot.clear();625 }626627 // ---- pins: the group's pinned events, e and a tags in order ----628629 pins(): string[][] {630 const row = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='pins'`).toArray()[0];631 return row ? (JSON.parse(row.value) as string[][]) : [];632 }633 setPins(tags: string[][]) {634 this.sql.exec(`INSERT INTO settings(key,value) VALUES('pins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(tags));635 }636637 // ---- connections: the Connect fold's app shortcuts, in order ----638639 // connections is the owner's list, or the defaults while there is none.640 // Entries are checked against the library when they are written641 // (connections.ts, parseConnections) and again when the fold resolves642 // them, so a template that left the library is skipped, not shown broken.643 connections(): Connection[] {644 return (this.connectionList ?? DEFAULT_CONNECTIONS).map((c) => ({ ...c }));645 }646 setConnections(list: Connection[]) {647 this.connectionList = list.map((c) => ({ ...c }));648 this.sql.exec(`INSERT INTO settings(key,value) VALUES('connections',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(this.connectionList));649 }650651 isOwner(pubkey: string) {652 return this.policy.owner !== "" && this.policy.owner === pubkey;653 }654655 // Three states: unclaimed (nobody, nothing), leased (nobody yet, open656 // until a date), claimed (an owner). Only the first refuses writes.657 isUnclaimed() {658 return this.policy.owner === "" && this.policy.lease === null;659 }660 isLeased() {661 return this.policy.owner === "" && this.policy.lease !== null;662 }663 leaseExpired(now: number) {664 return this.isLeased() && (this.policy.lease as Lease).until <= now;665 }666667 // resetRules puts the access rules, limits, kind rules and retention back668 // to the defaults. Identity, people and bans stay.669 resetRules() {670 const d = DEFAULT_POLICY;671 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 });672 for (const k of [...this.listKinds("allow"), ...this.listKinds("block")]) this.setKind(k, null);673 for (const r of this.listRetention()) this.setRetention(r.kind, 0);674 }675676 // ---- people ----677678 members(): Member[] {679 return this.sql.exec<Member>(`SELECT * FROM members ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'moderator' THEN 1 ELSE 2 END, joined_at, pubkey`).toArray();680 }681 member(pubkey: string): Member | null {682 return this.sql.exec<Member>(`SELECT * FROM members WHERE pubkey=?`, pubkey).toArray()[0] ?? null;683 }684 memberByName(name: string): Member | null {685 return this.sql.exec<Member>(`SELECT * FROM members WHERE name=?`, name.toLowerCase()).toArray()[0] ?? null;686 }687 isAllowed(pubkey: string) {688 return this.memberSet.has(pubkey) || this.isOwner(pubkey);689 }690691 // ---- the read rule ----692693 // mayRead applies the read rule to whoever is asking, given the pubkeys694 // they have proved: "" admits, else the reason. This is the one gate every695 // door that shows events, files, names or presence goes through, on the696 // socket and over HTTP alike.697 mayRead(pubkeys: string[]): string {698 switch (this.policy.reads) {699 case "open":700 return "";701 case "auth":702 return pubkeys.length ? "" : "auth-required: this relay requires authentication";703 case "members":704 if (pubkeys.some((pk) => this.isAllowed(pk))) return "";705 return pubkeys.length ? "restricted: this relay only serves its members" : "auth-required: this relay requires authentication";706 }707 }708709 // mayList says whether the member directory, names and counts, may be710 // shown to this caller: to anyone when the directory is public, else only711 // to members, whatever the read rule says.712 mayList(pubkeys: string[]): string {713 if (this.policy.directoryPublic) return "";714 if (pubkeys.some((pk) => this.isAllowed(pk))) return "";715 return pubkeys.length ? "restricted: the directory is for members" : "auth-required: the directory is for members; sign the request";716 }717 // roleOf is the pubkey's role, or null for a stranger.718 roleOf(pubkey: string): Role | null {719 if (this.isOwner(pubkey)) return "owner";720 return this.member(pubkey)?.role ?? null;721 }722 // setRole promotes or demotes a member; the owner's role only changes by transfer.723 setRole(pubkey: string, role: "moderator" | "member"): boolean {724 return this.sql.exec(`UPDATE members SET role=? WHERE pubkey=? AND role!='owner'`, role, pubkey).rowsWritten > 0;725 }726 // transferOwner hands the relay to a member. The old owner stays on as a727 // moderator so nobody is locked out. Returns "" or a reason.728 // setSuccession names an heir, who must be a member, and switches the729 // warnings on. Returns "" or a reason.730 setSuccession(heir: string, afterDays: number): string {731 if (this.policy.owner === "") return "restricted: this relay has no owner";732 if (heir === this.policy.owner) return "invalid: the heir must be someone else";733 if (!this.member(heir)) return "invalid: the heir must be a member first";734 if (!SUCCESSION_DAYS.includes(afterDays)) return "invalid: afterDays must be one of " + SUCCESSION_DAYS.join(", ");735 this.update({ succession: { heir, afterDays }, notify: { ...this.policy.notify, succession: true } });736 return "";737 }738739 transferOwner(pubkey: string): string {740 const old = this.policy.owner;741 if (old === "" || pubkey === old) return "invalid: that is already the owner";742 if (!this.member(pubkey)) return "invalid: the new owner must be a member first";743 // A handover, by whatever route, ends the succession set for the old owner.744 this.update({ owner: pubkey, succession: null });745 this.sql.exec(`UPDATE members SET role='moderator' WHERE pubkey=?`, old);746 this.sql.exec(`UPDATE members SET role='owner' WHERE pubkey=?`, pubkey);747 this.membersChanged();748 return "";749 }750751 // limitsOf is a member's keep-for and cap, or null when neither is set.752 // The owner is never limited.753 limitsOf(pubkey: string): MemberLimits | null {754 if (this.isOwner(pubkey)) return null;755 return this.limits.get(pubkey) ?? null;756 }757 // limited lists members with a keep-for rule, for the daily sweep.758 limited(): { pubkey: string; keep: number }[] {759 return [...this.limits].filter(([pk, l]) => l.keep > 0 && !this.isOwner(pk)).map(([pubkey, l]) => ({ pubkey, keep: l.keep }));760 }761762 // inviteDepth is a member's distance from the owner along invited_by:763 // the owner is 0, someone the owner added or invited is 1, and so on. A764 // chain that ends at a departed member counts the hops it has.765 inviteDepth(pubkey: string): number {766 if (this.isOwner(pubkey)) return 0;767 let depth = 0;768 let cur = pubkey;769 const seen = new Set<string>();770 while (depth < 50 && !seen.has(cur)) {771 seen.add(cur);772 depth++;773 const m = this.member(cur);774 if (!m || m.invited_by === "" || this.isOwner(m.invited_by)) break;775 cur = m.invited_by;776 }777 return depth;778 }779 // subtree lists a member and everyone under them along invited_by, plain780 // members only: a moderator in the tree, and everyone below them, stays.781 subtree(pubkey: string): string[] {782 const root = this.member(pubkey);783 if (!root || root.role !== "member") return [];784 const out = [pubkey];785 const seen = new Set(out);786 for (let i = 0; i < out.length; i++) {787 for (const r of this.sql.exec<{ pubkey: string; role: string }>(`SELECT pubkey, role FROM members WHERE invited_by=?`, out[i])) {788 if (seen.has(r.pubkey) || r.role !== "member") continue;789 seen.add(r.pubkey);790 out.push(r.pubkey);791 }792 }793 return out;794 }795796 // upsertMember adds or edits a member. name "" clears the name; a name held797 // by someone else is refused unless force (owner action). Returns "" or a reason.798 upsertMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; invitedBy?: string; keepDays?: number; maxBytes?: number }, now: number, force = false): string {799 const cur = this.member(pubkey);800 let name = cur?.name ?? null;801 if (patch.name !== undefined) {802 const n = (patch.name ?? "").trim().toLowerCase();803 if (n === "") name = null;804 else {805 if (!NAME_RE.test(n)) return "invalid: name may use lowercase letters, digits, dot, dash and underscore";806 const holder = this.memberByName(n);807 if (holder && holder.pubkey !== pubkey) {808 if (!force) return "restricted: that name is taken";809 this.sql.exec(`UPDATE members SET name=NULL WHERE pubkey=?`, holder.pubkey);810 }811 name = n;812 }813 }814 const note = patch.note !== undefined ? patch.note.slice(0, 200) : (cur?.note ?? "");815 const keep = patch.keepDays !== undefined ? Math.max(0, Math.min(Math.floor(patch.keepDays), 36500)) : (cur?.keep_days ?? 0);816 const cap = patch.maxBytes !== undefined ? Math.max(0, Math.floor(patch.maxBytes)) : (cur?.max_bytes ?? 0);817 if (cur) this.sql.exec(`UPDATE members SET name=?, note=?, keep_days=?, max_bytes=? WHERE pubkey=?`, name, note, keep, cap, pubkey);818 else {819 const role = this.isOwner(pubkey) ? "owner" : "member";820 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 ?? "");821 this.memberSet.add(pubkey);822 this.membersChanged();823 }824 if (keep > 0 || cap > 0) this.limits.set(pubkey, { keep, cap });825 else this.limits.delete(pubkey);826 return "";827 }828829 removeMember(pubkey: string): boolean {830 if (this.isOwner(pubkey)) return false;831 this.memberSet.delete(pubkey);832 this.limits.delete(pubkey);833 const gone = this.sql.exec(`DELETE FROM members WHERE pubkey=?`, pubkey).rowsWritten > 0;834 if (gone) this.membersChanged();835 return gone;836 }837838 // ---- portable configuration ----839840 // exportConfig is everything that makes this relay itself, minus its841 // data: policy, people, bans, address blocks and kind rules. Enough to842 // rebuild it.843 // ---- bans ----844845 setBan(pubkey: string, banned: boolean, reason = "", now = 0) {846 this.banned.delete(pubkey);847 this.sql.exec(`DELETE FROM pubkey_rules WHERE pubkey=?`, pubkey);848 if (banned) {849 this.banned.add(pubkey);850 this.sql.exec(`INSERT INTO pubkey_rules(pubkey,rule,reason,at) VALUES(?,'ban',?,?)`, pubkey, reason, now);851 this.removeMember(pubkey);852 }853 }854 listBans() {855 return this.sql.exec<{ pubkey: string; reason: string }>(`SELECT pubkey, reason FROM pubkey_rules WHERE rule='ban' ORDER BY at DESC`).toArray();856 }857 isBanned(pubkey: string) {858 return this.banned.has(pubkey);859 }860861 // Event rules: ban refuses an id for good; hide keeps it out of every read862 // until a moderator resolves the reports on it.863 setEvent(id: string, rule: "ban" | "hide" | null, reason = "", now = 0) {864 this.bannedEvents.delete(id);865 this.hiddenEvents.delete(id);866 this.sql.exec(`DELETE FROM event_rules WHERE id=?`, id);867 if (rule) {868 (rule === "ban" ? this.bannedEvents : this.hiddenEvents).add(id);869 this.sql.exec(`INSERT INTO event_rules(id,rule,reason,at) VALUES(?,?,?,?)`, id, rule, reason, now);870 }871 }872 listEvents(rule: "ban" | "hide") {873 return this.sql.exec<{ id: string; reason: string }>(`SELECT id, reason FROM event_rules WHERE rule=? ORDER BY at DESC`, rule).toArray();874 }875 isEventBanned(id: string) {876 return this.bannedEvents.has(id);877 }878 isEventHidden(id: string) {879 return this.hiddenEvents.has(id);880 }881882 // Address blocks (NIP-86 blockip). Addresses churn, so these are not part883 // of the portable configuration: they describe a moment, not the relay.884 setIPBlock(ip: string, blocked: boolean, reason = "", now = 0) {885 this.blockedIPs.delete(ip);886 this.sql.exec(`DELETE FROM ip_rules WHERE ip=?`, ip);887 if (blocked) {888 this.blockedIPs.add(ip);889 this.sql.exec(`INSERT INTO ip_rules(ip,reason,at) VALUES(?,?,?)`, ip, reason, now);890 }891 }892 listIPBlocks() {893 return this.sql.exec<{ ip: string; reason: string }>(`SELECT ip, reason FROM ip_rules ORDER BY at DESC`).toArray();894 }895 isIPBlocked(ip: string) {896 return this.blockedIPs.has(ip);897 }898899 // Kind rules: an allow list, if non-empty, is exclusive; blocks always apply.900 setKind(kind: number, rule: "allow" | "block" | null) {901 this.allowedKinds.delete(kind);902 this.blockedKinds.delete(kind);903 this.sql.exec(`DELETE FROM kind_rules WHERE kind=?`, kind);904 if (rule) {905 (rule === "allow" ? this.allowedKinds : this.blockedKinds).add(kind);906 this.sql.exec(`INSERT INTO kind_rules(kind,rule) VALUES(?,?)`, kind, rule);907 }908 }909 listKinds(rule: "allow" | "block") {910 return [...(rule === "allow" ? this.allowedKinds : this.blockedKinds)].sort((a, b) => a - b);911 }912 kindAllowed(kind: number) {913 if (this.blockedKinds.has(kind)) return false;914 return this.allowedKinds.size === 0 || this.allowedKinds.has(kind);915 }916917 // ---- retention ----918919 // setRetention keeps a kind's events for `days`; 0 removes the rule. A null920 // kind is the rule for everything without one. Returns a reason if refused.921 setRetention(kind: number | null, days: number): string {922 if (kind !== null && isProtected(kind)) return `invalid: kind ${kind} is part of how the relay works and is always kept`;923 const k = kind === null ? RETENTION_ANY : kind;924 this.retention.delete(k);925 this.sql.exec(`DELETE FROM retention WHERE kind=?`, k);926 if (days > 0) {927 this.retention.set(k, days);928 this.sql.exec(`INSERT INTO retention(kind,days) VALUES(?,?)`, k, days);929 }930 return "";931 }932 listRetention(): { kind: number | null; days: number }[] {933 return [...this.retention].map(([k, days]) => ({ kind: k === RETENTION_ANY ? null : k, days })).sort((a, b) => (a.kind ?? 1e9) - (b.kind ?? 1e9));934 }935 // retentionDays is how long a kind is kept, or 0 for forever. The catch-all936 // rule skips replaceable kinds (profiles, contact lists, relay lists...):937 // clients re-send those with their original timestamps, and losing the938 // latest one is never what "keep everything else N days" meant.939 retentionDays(kind: number): number {940 if (isProtected(kind)) return 0;941 const own = this.retention.get(kind);942 if (own !== undefined) return own;943 if (isReplaceable(kind)) return 0;944 // Gift wraps are mail. "Keep everything else N days" never meant the945 // owner's inbox; an explicit rule for 1059 still applies.946 if (kind === 1059) return 0;947 return this.retention.get(RETENTION_ANY) ?? 0;948 }949}950951const RETENTION_ANY = -1;952953// Kinds the relay itself depends on: who people are (profiles, contact and954// relay lists), what was paid (zap receipts), and who belongs (the roster and955// its deltas, the NIP-29 put-user and remove-user records, and the group's956// metadata, admins, members and roles). Never expired, never purged, only957// deleted one at a time.958export const PROTECTED_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 33534, 39000, 39001, 39002, 39003, 39005]);959export function isProtected(kind: number): boolean {960 return PROTECTED_KINDS.has(kind);961}962export function isReplaceable(kind: number): boolean {963 return kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000) || (kind >= 30000 && kind < 40000);964}