outbox, inbox, private and chat, plus default, each setting writes, reads, the directory, kind rules and keep-for rules together through the existing Settings methods. applypreset needs the rules action; listpresets is read. The owner's own replaceable kinds now pass the kind rules, so a relay can always hold its owner's lists.
- commit
- 682a3ac61421e807bcdc7393cbe02fdc4b735a8b
- parent
- 3363cbfe86f65173c29dab9e8cfffae31741e0f8
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 12 | +++- |
| A | src/ | 113 | ++++++++++++++++++++++++++++++ |
| M | src/ | 5 | +- |
| M | src/ | 4 | +- |
| A | test/ | 85 | +++++++++++++++++++++++ |
@@ -10,6 +10,7 @@ import { descriptor, type Blob } from "./blossom.ts";
1010 import { isReplaceable, isProtected, publicFields } from "./settings.ts";1111 import { checkPullURL } from "./pull.ts";1212 import { can, METHOD_ACTIONS } from "./roles.ts";13+import { PRESETS, applyPreset } from "./presets.ts";1314 1415 // verifyNIP98 checks an "Authorization: Nostr <base64 event>" header against1516 // the request: kind 27235, fresh, u tag naming this URL, method tag, and a@@ -54,7 +55,7 @@ const METHODS = [
5455 "blockip", "unblockip", "listblockedips",5556 "listblobs", "deleteblob",5657 "storagestats", "setretention", "listretention", "purgekind",57- "deleterelay", "exportconfig", "importconfig", "resetrules",58+ "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",5859 "pullfrom", "pullstatus", "transferowner",5960 ];6061 @@ -342,6 +343,15 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
342343 s.resetRules();343344 await relay.publishMembership();344345 return reply({ result: s.policy });346+ case "listpresets":347+ return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about })) });348+ case "applypreset": {349+ // (name): writes, reads, directory, kind rules and keep-for rules in one go.350+ const err = applyPreset(s, str(0));351+ if (err) return reply({ error: err }, 400);352+ await relay.publishMembership();353+ return reply({ result: s.policy });354+ }345355 case "pullfrom": {346356 // (url): copy what another relay has that this one lacks. Runs in347357 // the background; pullstatus reports on it.@@ -0,0 +1,113 @@
1+// Presets: Haven's four relays as rule bundles, one click each. Haven runs2+// outbox, inbox, private and chat as four relays behind one binary and one3+// config file. Here names are cheap, so one name per role is the way to get4+// the same split: lease or claim a name, pick its preset, wire it into the5+// matching list. A preset sets writes, reads, the people directory, kind6+// rules and keep-for rules together and leaves limits, identity, people and7+// bans alone.8+import { DEFAULT_POLICY, type Policy, type Settings } from "./settings.ts";9+10+export interface Preset {11+ name: string;12+ title: string;13+ about: string;14+ writes: Policy["writes"];15+ reads: Policy["reads"];16+ directoryPublic: boolean;17+ allow: number[];18+ block: number[];19+ retention: { kind: number | null; days: number }[];20+}21+22+export const PRESETS: Preset[] = [23+ {24+ name: "default",25+ title: "Default",26+ about: "Anyone writes, anyone reads, every kind, kept forever. The rules a fresh claim starts with.",27+ writes: DEFAULT_POLICY.writes,28+ reads: DEFAULT_POLICY.reads,29+ directoryPublic: DEFAULT_POLICY.directoryPublic,30+ allow: [],31+ block: [],32+ retention: [],33+ },34+ {35+ // Haven's outbox: the owner's public notes, readable by all, blasted36+ // elsewhere. Private kinds are blocked so a misconfigured client cannot37+ // park DMs on a public relay; everything else is fair game.38+ name: "outbox",39+ title: "Outbox",40+ about: "Only you write, anyone reads. Your public notes and articles, kept forever. Private kinds are refused.",41+ writes: "owner",42+ reads: "open",43+ directoryPublic: true,44+ allow: [],45+ block: [4, 1059],46+ retention: [],47+ },48+ {49+ // Haven's inbox: where people reach the owner, so anyone may write, but50+ // only the kinds that mean "for you": notes and replies, reposts,51+ // reactions, comments, highlights, reports, zap receipts, plus profiles52+ // so the senders render. Haven also checks that the owner is tagged;53+ // kind rules cannot, so a short keep-for rule bounds what spam costs.54+ // The owner's own lists always land regardless of the allow list.55+ name: "inbox",56+ title: "Inbox",57+ about: "Anyone writes notes, replies, reactions and zaps meant for you; anyone reads. Everything is kept 90 days.",58+ writes: "open",59+ reads: "open",60+ directoryPublic: true,61+ allow: [0, 1, 6, 7, 16, 1111, 1984, 9735, 9802],62+ block: [],63+ retention: [{ kind: null, days: 90 }],64+ },65+ {66+ // Haven's private relay: drafts, ecash, app data, anything the owner67+ // wants nowhere else. Only the owner writes and only members read, so68+ // a second device or a trusted person can be let in without opening it.69+ name: "private",70+ title: "Private",71+ about: "Only you write, only members read. Drafts, wallets and app data, every kind, kept forever.",72+ writes: "owner",73+ reads: "members",74+ directoryPublic: false,75+ allow: [],76+ block: [],77+ retention: [],78+ },79+ {80+ // Haven's chat relay, adapted: this relay already has members and a81+ // NIP-29 group, so chat is the members-only group. Members write, members82+ // read, and the kinds are gift wraps for private messages, group chat83+ // messages and threads, comments, reactions and profiles. Join, leave84+ // and moderation events are not subject to kind rules.85+ name: "chat",86+ title: "Chat",87+ about: "Members write, members read. Private messages and the group's chat, kept forever. The directory is hidden.",88+ writes: "allowlist",89+ reads: "members",90+ directoryPublic: false,91+ allow: [0, 7, 9, 11, 1059, 1111],92+ block: [],93+ retention: [],94+ },95+];96+97+export function findPreset(name: string): Preset | undefined {98+ return PRESETS.find((p) => p.name === name);99+}100+101+// applyPreset replaces the access rules, kind rules and keep-for rules with102+// the bundle. Returns "" or a reason.103+export function applyPreset(s: Settings, name: string): string {104+ const p = findPreset(name);105+ if (!p) return "invalid: no preset named " + name;106+ s.update({ writes: p.writes, reads: p.reads, directoryPublic: p.directoryPublic });107+ for (const k of [...s.listKinds("allow"), ...s.listKinds("block")]) s.setKind(k, null);108+ for (const k of p.allow) s.setKind(k, "allow");109+ for (const k of p.block) s.setKind(k, "block");110+ for (const r of s.listRetention()) s.setRetention(r.kind, 0);111+ for (const r of p.retention) s.setRetention(r.kind, r.days);112+ return "";113+}@@ -894,7 +894,10 @@ export class Relay extends DurableObject<Env> {
894894 if (e.kind === KIND_NOSTR_CONNECT) return { ok: true, msg: "", stored: true }; // see gate895895 const exp = expiration(e);896896 if (conn) {897- if (!this.settings.kindAllowed(e.kind)) return no("blocked: this relay does not accept kind " + e.kind);897+ // The owner's own profile and lists always land, whatever the kind898+ // rules say: a relay that refuses its owner's relay list cannot be899+ // wired into it.900+ 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);898901 const keep = this.settings.retentionDays(e.kind);899902 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`);900903 if (p.writes === "owner" && !this.settings.isOwner(e.pubkey)) return no("restricted: only the relay owner may publish here");@@ -36,7 +36,7 @@ export const ROLES: { role: Role; about: string }[] = [
3636 // Every NIP-86 method and the action it needs. claim and supportedmethods3737 // are open and not listed.3838 export const METHOD_ACTIONS: Record<string, Action> = {39- stats: "read", getpolicy: "read",39+ stats: "read", getpolicy: "read", listpresets: "read",4040 listbannedpubkeys: "read", listallowedpubkeys: "read", listmembers: "read", listpeople: "read", listinvites: "read",4141 listbannedevents: "read", listrecentevents: "read", listallowedkinds: "read", listblockedkinds: "read", listblobs: "read", listretention: "read", listreports: "read",4242 setmember: "members", allowpubkey: "members", removemember: "members", unrulepubkey: "members",@@ -44,7 +44,7 @@ export const METHOD_ACTIONS: Record<string, Action> = {
4444 deleteevent: "deleteEvent",4545 createinvite: "invites", revokeinvite: "invites",4646 resolvereport: "reports", listeventsneedingmoderation: "reports",47- setpolicy: "rules", allowkind: "rules", disallowkind: "rules", unrulekind: "rules", setretention: "rules", purgekind: "rules", resetrules: "rules",47+ setpolicy: "rules", allowkind: "rules", disallowkind: "rules", unrulekind: "rules", setretention: "rules", purgekind: "rules", resetrules: "rules", applypreset: "rules",4848 changerelayname: "identity", changerelaydescription: "identity", changerelayicon: "identity",4949 storagestats: "storage", deleteblob: "storage",5050 exportconfig: "config", importconfig: "config",@@ -0,0 +1,85 @@
1+// Presets: Haven's four relays as one-click rule bundles, and the owner's2+// own lists landing whatever the kind rules say.3+import { SELF } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6+import { getToken } from "nostr-tools/nip98";7+import { PRESETS } from "../src/presets.ts";8+9+const now = () => Math.floor(Date.now() / 1000);10+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);11+12+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {13+ const url = `http://${host}/`;14+ const payload = { method, params };15+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);16+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });17+ return { status: resp.status, ...(await resp.json<any>()) };18+}19+20+async function post(host: string, sk: Uint8Array, path: string, body: unknown) {21+ const url = `http://${host}${path}`;22+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>);23+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: token }, body: JSON.stringify(body) });24+ return { status: resp.status, body: await resp.json<any>() };25+}26+27+describe("presets", () => {28+ it("each preset sets writes, reads, directory, kind rules and retention as its bundle says", async () => {29+ const host = "presets.bind.ws";30+ const owner = generateSecretKey();31+ await rpc(host, owner, "claim");32+ const list = (await rpc(host, owner, "listpresets")).result;33+ expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat"]);34+ for (const p of list) expect(p.about.length).toBeGreaterThan(10);35+ for (const preset of PRESETS) {36+ const r = await rpc(host, owner, "applypreset", preset.name);37+ expect(r.status, preset.name).toBe(200);38+ expect(r.result.writes).toBe(preset.writes);39+ expect(r.result.reads).toBe(preset.reads);40+ expect(r.result.directoryPublic).toBe(preset.directoryPublic);41+ expect((await rpc(host, owner, "listallowedkinds")).result).toEqual([...preset.allow].sort((a, b) => a - b));42+ expect((await rpc(host, owner, "listblockedkinds")).result).toEqual([...preset.block].sort((a, b) => a - b));43+ expect((await rpc(host, owner, "listretention")).result).toEqual(preset.retention);44+ }45+ // Back to default leaves no rules behind.46+ await rpc(host, owner, "applypreset", "chat");47+ const d = await rpc(host, owner, "applypreset", "default");48+ expect(d.result.writes).toBe("open");49+ expect((await rpc(host, owner, "listallowedkinds")).result).toEqual([]);50+ expect((await rpc(host, owner, "listretention")).result).toEqual([]);51+ expect((await rpc(host, owner, "applypreset", "haven")).status).toBe(400);52+ });53+54+ it("needs the rules action: a moderator may list presets but not apply one", async () => {55+ const host = "presets-mod.bind.ws";56+ const owner = generateSecretKey();57+ const mod = generateSecretKey();58+ await rpc(host, owner, "claim");59+ await rpc(host, owner, "setmember", getPublicKey(mod), { role: "moderator" });60+ expect((await rpc(host, mod, "listpresets")).status).toBe(200);61+ const denied = await rpc(host, mod, "applypreset", "outbox");62+ expect(denied.status).toBe(403);63+ expect((await rpc(host, owner, "getpolicy")).result.writes).toBe("open");64+ });65+66+ it("the owner's own lists land under an allow list that excludes them; a stranger's do not", async () => {67+ const host = "presets-lists.bind.ws";68+ const owner = generateSecretKey();69+ const stranger = generateSecretKey();70+ await rpc(host, owner, "claim");71+ await rpc(host, owner, "applypreset", "inbox");72+ const mine = ev(owner, 10002, "", [["r", "wss://" + host]]);73+ let r = await post(host, owner, "/events", mine);74+ expect(r.body.accepted, r.body.message).toBe(true);75+ const theirs = ev(stranger, 10002, "", [["r", "wss://" + host]]);76+ r = await post(host, stranger, "/events", theirs);77+ expect(r.body.accepted).toBe(false);78+ expect(r.body.message).toMatch(/does not accept kind 10002/);79+ // A stranger's note is what the inbox is for.80+ r = await post(host, stranger, "/events", ev(stranger, 1, "hello", [["p", getPublicKey(owner)]]));81+ expect(r.body.accepted).toBe(true);82+ const q = await post(host, owner, "/query", [{ kinds: [10002], authors: [getPublicKey(owner)] }]);83+ expect(q.body.map((e: Event) => e.id)).toEqual([mine.id]);84+ });85+});