clone

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

config file: a schema for the editor (served at the apex), an offline check that runs the schema and the relay's parser over a JSONC file, and relay-templates/, one configuration per template, folded into the presets the console offers; a quiet template switches every costly feature off

Dami committed dc6f386
commit
dc6f3869478379e5bc35721f62d544c5a08ba655
parent
4486ac53515a5f6b55c514fc9e3d1ec027a3cad8
author
Dami <dami.etoile@gmail.com>
27 files changed, +946 -193
Mdocs/01-relay-configuration.md4+-
Mdocs/12-develop-extend.md9+-
Mpackage-lock.json59+++++++
Mpackage.json7+-
Arelay-config.schema.json122++++++++++++++
Arelay-templates/01-default.jsonc19++
Arelay-templates/02-outbox.jsonc24+++
Arelay-templates/03-inbox.jsonc39+++++
Arelay-templates/04-private.jsonc21++
Arelay-templates/05-chat.jsonc30+++
Arelay-templates/06-media.jsonc25+++
Arelay-templates/07-search.jsonc32++++
Arelay-templates/08-articles.jsonc26+++
Arelay-templates/09-dm.jsonc24+++
Arelay-templates/10-quiet.jsonc32++++
Arelay-templates/README.md18++
Ascripts/build/build-templates.mjs32++++
Mscripts/check/check-celld.mjs26+---
Ascripts/check/check-config.mjs20++
Ascripts/check/config.mjs50++++++
Ascripts/check/jsonc.mjs29+++
Asrc/gen/templates.ts259++++++++++++++++++++++++++++++
Msrc/index.ts3+
Msrc/manage.ts4+-
Msrc/presets.ts181++-------------------
Mtest/object/presets.test.ts2+-
Atest/object/templates.test.ts42+++++
M docs/01-relay-configuration.md +3 -1
@@ -71,10 +71,12 @@ The per-address limit is what stops a client from opening many sockets to multip
7171 7272 ### Presets7373 74-Above the rules form, one button per preset sets writes, reads, the directory, the kind rules and the keep-for rules together. Limits, identity, people and bans stay. **Default** is what a fresh claim has. **Outbox** is only you writing, anyone reading, private kinds refused. **Inbox** takes notes, replies, reactions, comments, reports and zaps from anyone and keeps them 90 days. **Private** is only you writing and only members reading, every kind, forever. **Chat** is the members-only group: private messages, chat and threads, directory hidden. Your own profile and lists always land, whatever the kind rules say. Names are cheap, so one name per role is the way to run all four.74+Above the rules form, one button per preset applies a template from the repository's `relay-templates/` folder: writes, reads, the directory, the features, the kind rules and the keep-for rules together. Limits, identity, people and bans stay. **Default** is what a fresh claim has. **Outbox** is only you writing, anyone reading, private kinds refused. **Inbox** takes notes, replies, reactions, comments, reports and zaps from anyone and keeps them 90 days. **Private** is only you writing and only members reading, every kind, forever. **Chat** is the members-only group: private messages, chat and threads, directory hidden. Your own profile and lists always land, whatever the kind rules say. Names are cheap, so one name per role is the way to run all four.7575 7676 Four more presets make single-purpose names: **Media** (members upload files; the only events accepted are profiles and Blossom server lists), **Search replica** (a read-only copy of another relay's searchable kinds, refreshed every six hours), **Articles** (long-form and profiles only, mirrored daily from a source if one is given) and **DM inbox** (anyone drops gift wraps, only members read). The replica presets take a source relay URL in the field next to the buttons; applying a preset again replaces the standing pull rather than adding one, and applying a preset with no source removes it. The pull appears in the Jobs table on the Data tab.7777 78+**Quiet** is a small private relay with every feature that costs or announces switched off: members write and read, and nothing else is on until you switch it on.79+7880 ## Identity7981 8082 Name, description, icon and contact appear in your relay's public information document, which clients read. The relay also keeps a profile of its own, signed with its own key, made from the name, description and icon, so it shows up like a person in clients that look one up.
M docs/12-develop-extend.md +7 -2
@@ -25,7 +25,8 @@ src/
2525 roles.ts who may do what: owner, moderator; one table for NIP-86 and NIP-292626 groups.ts NIP-29, one group per relay: joins, leaves, moderation events2727 audit.ts the moderation log: one row per change from manage.ts or groups.ts28- presets.ts rule bundles, one click each, some with a standing pull28+ presets.ts the templates as one-click presets, some with a standing pull29+ config.ts the configuration document: parse, plan, apply, export2930 manage.ts NIP-86 methods over NIP-983031 domains.ts custom hostnames: the Cloudflare client and the KV mapping3132 dumps.ts scheduled JSONL dumps to R2
@@ -46,6 +47,7 @@ src/
4647 dashboard.ts the console's page, from the three files in console/4748 console/ console.html, console.css, console.js: the console as written4849 gen/signer.ts generated: the NIP-46 client library the console loads from /signer.js50+ gen/templates.ts generated from relay-templates/4951 landing.ts the apex, rendered from config5052 ui.ts the shared look5153 test/
@@ -53,10 +55,13 @@ test/
5355 object/ Durable Object tests in workerd, one file per feature or module5456 helpers/ the socket client, the management call, media tokens, the QR reader5557 conformance/ black-box suite for any relay URL, one file per NIP, files included58+relay-templates/ one relay configuration per template, the presets the console offers59+relay-config.schema.json the configuration file's schema, served at the apex5660 scripts/5761 build/ build-signer.mjs signer-entry.js bundle nostr-tools for the console's remote signing5862 build-console.mjs fold src/console into src/gen/console.ts59- check/ check-console.mjs check-celld.mjs run by npm run typecheck63+ build-templates.mjs fold relay-templates/ into src/gen/templates.ts64+ check/ check-console.mjs check-celld.mjs check-config.mjs run by npm run typecheck; check-config also checks any file6065 dev/ dev-signer.mjs seed.mjs stage.mjs junk.mjs shot.mjs zaptest.mjs for a dev relay6166 ops/ margin.mjs fuel prices against Cloudflare's rates, weekly in CI6267 wrangler.jsonc the Worker on Cloudflare
M package-lock.json +59 -0
@@ -14,6 +14,7 @@
1414 "@cloudflare/vitest-pool-workers": "^0.22.0",1515 "@cloudflare/workers-types": "^5.20260902.1",1616 "@types/ws": "^8.18.0",17+ "ajv": "^8.20.0",1718 "jsqr": "^1.4.0",1819 "typescript": "^7.0.2",1920 "vitest": "^4.1.0",
@@ -2198,6 +2199,23 @@
21982199 "url": "https://opencollective.com/vitest"21992200 }22002201 },2202+ "node_modules/ajv": {2203+ "version": "8.20.0",2204+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",2205+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",2206+ "dev": true,2207+ "license": "MIT",2208+ "dependencies": {2209+ "fast-deep-equal": "^3.1.3",2210+ "fast-uri": "^3.0.1",2211+ "json-schema-traverse": "^1.0.0",2212+ "require-from-string": "^2.0.2"2213+ },2214+ "funding": {2215+ "type": "github",2216+ "url": "https://github.com/sponsors/epoberezkin"2217+ }2218+ },22012219 "node_modules/assertion-error": {22022220 "version": "2.0.1",22032221 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2342,6 +2360,30 @@
23422360 "node": ">=12.0.0"23432361 }23442362 },2363+ "node_modules/fast-deep-equal": {2364+ "version": "3.1.3",2365+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",2366+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",2367+ "dev": true,2368+ "license": "MIT"2369+ },2370+ "node_modules/fast-uri": {2371+ "version": "3.1.7",2372+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",2373+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",2374+ "dev": true,2375+ "funding": [2376+ {2377+ "type": "github",2378+ "url": "https://github.com/sponsors/fastify"2379+ },2380+ {2381+ "type": "opencollective",2382+ "url": "https://opencollective.com/fastify"2383+ }2384+ ],2385+ "license": "BSD-3-Clause"2386+ },23452387 "node_modules/fdir": {23462388 "version": "6.5.0",23472389 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2375,6 +2417,13 @@
23752417 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"23762418 }23772419 },2420+ "node_modules/json-schema-traverse": {2421+ "version": "1.0.0",2422+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",2423+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",2424+ "dev": true,2425+ "license": "MIT"2426+ },23782427 "node_modules/jsqr": {23792428 "version": "1.4.0",23802429 "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
@@ -2852,6 +2901,16 @@
28522901 "node": "^10 || ^12 || >=14"28532902 }28542903 },2904+ "node_modules/require-from-string": {2905+ "version": "2.0.2",2906+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",2907+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",2908+ "dev": true,2909+ "license": "MIT",2910+ "engines": {2911+ "node": ">=0.10.0"2912+ }2913+ },28552914 "node_modules/rolldown": {28562915 "version": "1.2.7",28572916 "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
M package.json +5 -2
A relay-config.schema.json +122 -0
@@ -0,0 +1,122 @@
1+{2+ "$schema": "http://json-schema.org/draft-07/schema#",3+ "$id": "https://bind.ws/relay-config.schema.json",4+ "title": "bind.ws relay configuration",5+ "description": "A relay's rules, identity, people and lists as one document: what exportconfig writes and importconfig reads. A section left out is left alone. The relay's parser (src/config.ts) is the last word; this schema is for the editor.",6+ "type": "object",7+ "required": ["format"],8+ "additionalProperties": false,9+ "properties": {10+ "$schema": { "type": "string" },11+ "format": { "enum": ["bind.ws/relay-config/1", "bind.ws/relay-config/2"] },12+ "exported_at": { "type": "integer", "description": "when the relay wrote it; ignored on import" },13+ "name": { "type": "string", "maxLength": 64, "description": "the relay's name at export; ignored on import" },14+ "template": {15+ "type": "object",16+ "description": "present on a template in relay-templates/: how the console presents it",17+ "required": ["title", "about"],18+ "additionalProperties": false,19+ "properties": {20+ "title": { "type": "string", "maxLength": 80 },21+ "about": { "type": "string", "maxLength": 400 },22+ "source": { "enum": ["required", "optional"], "description": "a replica pulls from a source relay: required, or optional" },23+ "every": { "type": "integer", "minimum": 1, "description": "hours between pulls" }24+ }25+ },26+ "policy": {27+ "type": "object",28+ "additionalProperties": false,29+ "properties": {30+ "name": { "type": "string", "maxLength": 2000 },31+ "description": { "type": "string", "maxLength": 2000 },32+ "icon": { "type": "string", "maxLength": 2000 },33+ "banner": { "type": "string" },34+ "contact": { "type": "string", "maxLength": 2000 },35+ "postingPolicy": { "type": "string" },36+ "privacyPolicy": { "type": "string" },37+ "tags": { "type": "array", "items": { "type": "string" } },38+ "languageTags": { "type": "array", "items": { "type": "string" } },39+ "relayCountries": { "type": "array", "items": { "type": "string" } },40+ "notify": { "type": "object", "additionalProperties": { "type": "boolean" } },41+ "writes": { "enum": ["open", "allowlist", "wot", "owner"] },42+ "openKinds": { "type": "array", "items": { "$ref": "#/definitions/kind" } },43+ "guestReplies": { "type": "boolean" },44+ "blockedWords": { "type": "array", "items": { "type": "string", "maxLength": 200 } },45+ "blockedWordsInTags": { "type": "boolean" },46+ "reportThreshold": { "type": "integer", "minimum": 0 },47+ "reads": { "enum": ["open", "auth", "members"] },48+ "joinTerms": { "type": "string", "maxLength": 20000 },49+ "directoryPublic": { "type": "boolean" },50+ "maxBlobMB": { "type": "integer", "minimum": 1, "maximum": 95 },51+ "eventsPerMinute": { "type": "integer", "minimum": 1 },52+ "reqsPerMinute": { "type": "integer", "minimum": 1 },53+ "minPow": { "type": "integer", "minimum": 0, "maximum": 64 },54+ "maxFuture": { "type": "integer", "minimum": 0 },55+ "maxLimit": { "type": "integer", "minimum": 1, "maximum": 5000 },56+ "maxSubs": { "type": "integer", "minimum": 1, "maximum": 200 },57+ "maxMessageKB": { "type": "integer", "minimum": 16, "maximum": 1024 },58+ "dumps": { "enum": ["off", "daily", "weekly"] },59+ "dumpsKeep": { "type": "integer", "minimum": 1, "maximum": 60 },60+ "memberInvites": {61+ "type": "object",62+ "additionalProperties": false,63+ "properties": { "depth": { "type": "integer", "minimum": 0 }, "quota": { "type": "integer", "minimum": 0 } }64+ },65+ "views": { "type": "object", "additionalProperties": { "type": "boolean" }, "description": "a view named here with false is off; the rest are on" },66+ "features": {67+ "type": "object",68+ "additionalProperties": false,69+ "properties": {70+ "search": { "enum": ["full", "prose", "off"] },71+ "sync": { "type": "boolean" },72+ "count": { "type": "boolean" },73+ "discovery": { "type": "boolean" },74+ "names": { "type": "boolean" },75+ "files": { "type": "boolean" },76+ "pages": { "type": "boolean" },77+ "signer": { "type": "boolean" }78+ }79+ }80+ }81+ },82+ "members": {83+ "type": "array",84+ "items": {85+ "type": "object",86+ "required": ["pubkey"],87+ "additionalProperties": false,88+ "properties": {89+ "pubkey": { "$ref": "#/definitions/hex64" },90+ "name": { "type": ["string", "null"] },91+ "note": { "type": "string", "maxLength": 2000 },92+ "role": { "enum": ["member", "moderator"] },93+ "keepDays": { "type": "integer", "minimum": 1 },94+ "maxBytes": { "type": "integer", "minimum": 1 }95+ }96+ }97+ },98+ "bans": { "type": "array", "items": { "$ref": "#/definitions/pubkeyEntry" } },99+ "banned_events": {100+ "type": "array",101+ "items": { "type": "object", "required": ["id"], "additionalProperties": false, "properties": { "id": { "$ref": "#/definitions/hex64" }, "reason": { "type": "string", "maxLength": 200 } } }102+ },103+ "addresses": {104+ "type": "array",105+ "items": { "type": "object", "required": ["ip"], "additionalProperties": false, "properties": { "ip": { "type": "string" }, "reason": { "type": "string", "maxLength": 200 } } }106+ },107+ "kinds": {108+ "type": "object",109+ "additionalProperties": false,110+ "properties": { "allow": { "type": "array", "items": { "$ref": "#/definitions/kind" } }, "block": { "type": "array", "items": { "$ref": "#/definitions/kind" } } }111+ },112+ "retention": {113+ "type": "array",114+ "items": { "type": "object", "required": ["kind", "days"], "additionalProperties": false, "properties": { "kind": { "oneOf": [{ "$ref": "#/definitions/kind" }, { "type": "null" }] }, "days": { "type": "integer", "minimum": 1, "maximum": 36500 } } }115+ }116+ },117+ "definitions": {118+ "hex64": { "type": "string", "pattern": "^[0-9a-f]{64}$" },119+ "kind": { "type": "integer", "minimum": 0, "maximum": 65535 },120+ "pubkeyEntry": { "type": "object", "required": ["pubkey"], "additionalProperties": false, "properties": { "pubkey": { "$ref": "#/definitions/hex64" }, "reason": { "type": "string", "maxLength": 200 } } }121+ }122+}
A relay-templates/01-default.jsonc +19 -0
A relay-templates/02-outbox.jsonc +24 -0
A relay-templates/03-inbox.jsonc +39 -0
A relay-templates/04-private.jsonc +21 -0
A relay-templates/05-chat.jsonc +30 -0
A relay-templates/06-media.jsonc +25 -0
A relay-templates/07-search.jsonc +32 -0
A relay-templates/08-articles.jsonc +26 -0
A relay-templates/09-dm.jsonc +24 -0
A relay-templates/10-quiet.jsonc +32 -0
A relay-templates/README.md +18 -0
A scripts/build/build-templates.mjs +32 -0
M scripts/check/check-celld.mjs +1 -25
A scripts/check/check-config.mjs +20 -0
A scripts/check/config.mjs +50 -0
@@ -0,0 +1,50 @@
1+// Checks a relay configuration file (JSONC) two ways: against the schema,2+// for the editor's sake, and with the relay's own parser (src/config.ts),3+// which is the last word. Both must pass and the parser must drop nothing,4+// since an entry a relay would quietly leave out is a mistake in a file5+// under source control. Returns the parsed configuration or throws with6+// every problem found.7+import { readFileSync } from "node:fs";8+import { buildSync } from "esbuild";9+import Ajv from "ajv";10+import { stripComments } from "./jsonc.mjs";11+12+const ROOT = new URL("../../", import.meta.url);13+const schema = JSON.parse(readFileSync(new URL("relay-config.schema.json", ROOT), "utf8"));14+const validate = new Ajv({ allErrors: true }).compile(schema);15+16+let parser = null;17+async function parseConfig() {18+ if (parser) return parser;19+ const js = buildSync({ entryPoints: [new URL("src/config.ts", ROOT).pathname], bundle: true, format: "esm", platform: "neutral", write: false, logLevel: "silent", external: ["cloudflare:*"] }).outputFiles[0].text;20+ parser = (await import("data:text/javascript;base64," + Buffer.from(js).toString("base64"))).parseConfig;21+ return parser;22+}23+24+export function readConfig(path) {25+ return JSON.parse(stripComments(readFileSync(path, "utf8")));26+}27+28+export async function checkConfig(doc, label = "configuration") {29+ const problems = [];30+ if (!validate(doc)) for (const e of validate.errors) problems.push(`${label}: ${e.instancePath || "/"} ${e.message}`);31+ const parsed = (await parseConfig())(doc);32+ if (typeof parsed === "string") problems.push(`${label}: ${parsed}`);33+ else for (const w of parsed.warnings) problems.push(`${label}: ${w}`);34+ if (problems.length) throw new Error(problems.join("\n"));35+ return parsed;36+}37+38+// describe says what a parsed configuration declares, one line per section.39+export function describe(cfg) {40+ const lines = [];41+ if (cfg.template) lines.push(`template: ${cfg.template.title}`);42+ if (cfg.sections.includes("policy")) lines.push(`policy: ${Object.keys(cfg.policy).length} settings (${Object.keys(cfg.policy).join(", ")})`);43+ if (cfg.sections.includes("members")) lines.push(`members: ${cfg.members.length}`);44+ if (cfg.sections.includes("bans")) lines.push(`bans: ${cfg.bans.length}`);45+ if (cfg.sections.includes("addresses")) lines.push(`addresses: ${cfg.addresses.length}`);46+ if (cfg.sections.includes("banned_events")) lines.push(`banned events: ${cfg.banned_events.length}`);47+ if (cfg.sections.includes("kinds")) lines.push(`kinds: allow ${cfg.kinds.allow.length ? cfg.kinds.allow.join(", ") : "every kind"}; block ${cfg.kinds.block.join(", ") || "none"}`);48+ if (cfg.sections.includes("retention")) lines.push(`retention: ${cfg.retention.map((r) => `${r.kind === null ? "everything" : "kind " + r.kind} ${r.days} days`).join(", ") || "none"}`);49+ return lines;50+}
A scripts/check/jsonc.mjs +29 -0
A src/gen/templates.ts +259 -0
@@ -0,0 +1,259 @@
1+// Generated by scripts/build/build-templates.mjs from relay-templates/. Do not edit; run npm run build:templates.2+export const TEMPLATES: { name: string; document: unknown }[] = [3+ {4+ "name": "default",5+ "document": {6+ "format": "bind.ws/relay-config/2",7+ "template": {8+ "title": "Default",9+ "about": "Anyone writes, anyone reads, every kind, kept forever. The rules a fresh claim starts with."10+ },11+ "policy": {12+ "writes": "open",13+ "reads": "open",14+ "directoryPublic": true15+ },16+ "kinds": {17+ "allow": [],18+ "block": []19+ },20+ "retention": []21+ }22+ },23+ {24+ "name": "outbox",25+ "document": {26+ "format": "bind.ws/relay-config/2",27+ "template": {28+ "title": "Outbox",29+ "about": "Only you write, anyone reads. Your public notes and articles, kept forever. Private kinds are refused."30+ },31+ "policy": {32+ "writes": "owner",33+ "reads": "open",34+ "directoryPublic": true35+ },36+ "kinds": {37+ "allow": [],38+ "block": [39+ 4,40+ 105941+ ]42+ },43+ "retention": []44+ }45+ },46+ {47+ "name": "inbox",48+ "document": {49+ "format": "bind.ws/relay-config/2",50+ "template": {51+ "title": "Inbox",52+ "about": "Anyone writes notes, replies, reactions and zaps meant for you; anyone reads. Everything is kept 90 days."53+ },54+ "policy": {55+ "writes": "open",56+ "reads": "open",57+ "directoryPublic": true58+ },59+ "kinds": {60+ "allow": [61+ 0,62+ 1,63+ 6,64+ 7,65+ 16,66+ 1111,67+ 1984,68+ 9735,69+ 980270+ ],71+ "block": []72+ },73+ "retention": [74+ {75+ "kind": null,76+ "days": 9077+ }78+ ]79+ }80+ },81+ {82+ "name": "private",83+ "document": {84+ "format": "bind.ws/relay-config/2",85+ "template": {86+ "title": "Private",87+ "about": "Only you write, only members read. Drafts, wallets and app data, every kind, kept forever."88+ },89+ "policy": {90+ "writes": "owner",91+ "reads": "members",92+ "directoryPublic": false93+ },94+ "kinds": {95+ "allow": [],96+ "block": []97+ },98+ "retention": []99+ }100+ },101+ {102+ "name": "chat",103+ "document": {104+ "format": "bind.ws/relay-config/2",105+ "template": {106+ "title": "Chat",107+ "about": "Members write, members read. Private messages and the group's chat, kept forever. The directory is hidden."108+ },109+ "policy": {110+ "writes": "allowlist",111+ "reads": "members",112+ "directoryPublic": false113+ },114+ "kinds": {115+ "allow": [116+ 0,117+ 7,118+ 9,119+ 11,120+ 1059,121+ 1111122+ ],123+ "block": []124+ },125+ "retention": []126+ }127+ },128+ {129+ "name": "media",130+ "document": {131+ "format": "bind.ws/relay-config/2",132+ "template": {133+ "title": "Media",134+ "about": "A media host. Members upload files; the only events accepted are profiles and Blossom server lists. Anyone reads."135+ },136+ "policy": {137+ "writes": "allowlist",138+ "reads": "open",139+ "directoryPublic": true140+ },141+ "kinds": {142+ "allow": [143+ 0,144+ 10063145+ ],146+ "block": []147+ },148+ "retention": []149+ }150+ },151+ {152+ "name": "search",153+ "document": {154+ "format": "bind.ws/relay-config/2",155+ "template": {156+ "title": "Search replica",157+ "about": "A read-only copy of another relay's notes, threads, comments, highlights, articles and wiki pages, refreshed every six hours. Anyone reads.",158+ "source": "required",159+ "every": 6160+ },161+ "policy": {162+ "writes": "owner",163+ "reads": "open",164+ "directoryPublic": true165+ },166+ "kinds": {167+ "allow": [168+ 0,169+ 1,170+ 11,171+ 1111,172+ 9802,173+ 30023,174+ 30818175+ ],176+ "block": []177+ },178+ "retention": []179+ }180+ },181+ {182+ "name": "articles",183+ "document": {184+ "format": "bind.ws/relay-config/2",185+ "template": {186+ "title": "Articles",187+ "about": "Long-form articles and the profiles behind them, nothing else. Only you write, anyone reads. Give a source relay to mirror its articles daily.",188+ "source": "optional",189+ "every": 24190+ },191+ "policy": {192+ "writes": "owner",193+ "reads": "open",194+ "directoryPublic": true195+ },196+ "kinds": {197+ "allow": [198+ 0,199+ 30023200+ ],201+ "block": []202+ },203+ "retention": []204+ }205+ },206+ {207+ "name": "dm",208+ "document": {209+ "format": "bind.ws/relay-config/2",210+ "template": {211+ "title": "DM inbox",212+ "about": "An inbox for private messages. Anyone drops gift wraps, only members read them. Nothing else is accepted."213+ },214+ "policy": {215+ "writes": "open",216+ "reads": "members",217+ "directoryPublic": false218+ },219+ "kinds": {220+ "allow": [221+ 0,222+ 1059223+ ],224+ "block": []225+ },226+ "retention": []227+ }228+ },229+ {230+ "name": "quiet",231+ "document": {232+ "format": "bind.ws/relay-config/2",233+ "template": {234+ "title": "Quiet",235+ "about": "A small private relay with nothing that costs or announces. Members write and read; search, sync, counts, the discovery record, names, files, pages and signer traffic are off."236+ },237+ "policy": {238+ "writes": "allowlist",239+ "reads": "members",240+ "directoryPublic": false,241+ "features": {242+ "search": "off",243+ "sync": false,244+ "count": false,245+ "discovery": false,246+ "names": false,247+ "files": false,248+ "pages": false,249+ "signer": false250+ }251+ },252+ "kinds": {253+ "allow": [],254+ "block": []255+ },256+ "retention": []257+ }258+ }259+];
M src/index.ts +3 -0
M src/manage.ts +2 -2
M src/presets.ts +21 -160
@@ -1,12 +1,13 @@
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-import { SEARCH_KINDS } from "./store.ts";1+// Presets: the templates in relay-templates/, one click each. A template is2+// a relay configuration with the rules sections only (policy, kinds and3+// retention), so applying one sets writes, reads, the directory, the4+// features, the kind rules and the keep-for rules together and leaves5+// limits, identity, people and bans alone. Haven's four relays are the6+// first four: names are cheap here, so one name per role is the way to get7+// the same split.8+import { TEMPLATES } from "./gen/templates.ts";9+import { applyConfig, parseConfig, type Config } from "./config.ts";10+import type { Policy, Settings } from "./settings.ts";1011 1112 export interface Preset {1213 name: string;
@@ -22,165 +23,25 @@ export interface Preset {
2223 // pull of its kinds. "required" presets refuse to apply without one.2324 source?: "required" | "optional";2425 every?: number; // hours between pulls26+ config: Config;2527 }2628 27-// The kinds a search replica carries: what the full-text index covers,28-// minus drafts, which have no business on a public replica.29-const SEARCHABLE = [...SEARCH_KINDS].filter((k) => k !== 30024).sort((a, b) => a - b);30-31-export const PRESETS: Preset[] = [32- {33- name: "default",34- title: "Default",35- about: "Anyone writes, anyone reads, every kind, kept forever. The rules a fresh claim starts with.",36- writes: DEFAULT_POLICY.writes,37- reads: DEFAULT_POLICY.reads,38- directoryPublic: DEFAULT_POLICY.directoryPublic,39- allow: [],40- block: [],41- retention: [],42- },43- {44- // Haven's outbox: the owner's public notes, readable by all, blasted45- // elsewhere. Private kinds are blocked so a misconfigured client cannot46- // park DMs on a public relay; everything else is fair game.47- name: "outbox",48- title: "Outbox",49- about: "Only you write, anyone reads. Your public notes and articles, kept forever. Private kinds are refused.",50- writes: "owner",51- reads: "open",52- directoryPublic: true,53- allow: [],54- block: [4, 1059],55- retention: [],56- },57- {58- // Haven's inbox: where people reach the owner, so anyone may write, but59- // only the kinds that mean "for you": notes and replies, reposts,60- // reactions, comments, highlights, reports, zap receipts, plus profiles61- // so the senders render. Haven also checks that the owner is tagged;62- // kind rules cannot, so a short keep-for rule bounds what spam costs.63- // The owner's own lists always land regardless of the allow list.64- name: "inbox",65- title: "Inbox",66- about: "Anyone writes notes, replies, reactions and zaps meant for you; anyone reads. Everything is kept 90 days.",67- writes: "open",68- reads: "open",69- directoryPublic: true,70- allow: [0, 1, 6, 7, 16, 1111, 1984, 9735, 9802],71- block: [],72- retention: [{ kind: null, days: 90 }],73- },74- {75- // Haven's private relay: drafts, ecash, app data, anything the owner76- // wants nowhere else. Only the owner writes and only members read, so77- // a second device or a trusted person can be let in without opening it.78- name: "private",79- title: "Private",80- about: "Only you write, only members read. Drafts, wallets and app data, every kind, kept forever.",81- writes: "owner",82- reads: "members",83- directoryPublic: false,84- allow: [],85- block: [],86- retention: [],87- },88- {89- // Haven's chat relay, adapted: this relay already has members and a90- // NIP-29 group, so chat is the members-only group. Members write, members91- // read, and the kinds are gift wraps for private messages, group chat92- // messages and threads, comments, reactions and profiles. Join, leave93- // and moderation events are not subject to kind rules.94- name: "chat",95- title: "Chat",96- about: "Members write, members read. Private messages and the group's chat, kept forever. The directory is hidden.",97- writes: "allowlist",98- reads: "members",99- directoryPublic: false,100- allow: [0, 7, 9, 11, 1059, 1111],101- block: [],102- retention: [],103- },104- // Kind-scoped names: cheap names plus standing pulls make single-purpose105- // relays and specialised replicas nearly free. One name per job.106- {107- // A Blossom-only name, alice-media: the files live here, the notes do108- // not. Members write, so their uploads pass the upload gate, but the109- // only events they may post are profiles and Blossom server lists, so110- // the name stays a media host and nothing else.111- name: "media",112- title: "Media",113- about: "A media host. Members upload files; the only events accepted are profiles and Blossom server lists. Anyone reads.",114- writes: "allowlist",115- reads: "open",116- directoryPublic: true,117- allow: [0, 10063],118- block: [],119- retention: [],120- },121- {122- // A search replica, alice-search: a standing pull of the searchable123- // kinds from a source relay every six hours, readable by all, written124- // only by the owner, so a client can point search at it without the125- // source relay ever being asked.126- name: "search",127- title: "Search replica",128- about: "A read-only copy of another relay's notes, threads, comments, highlights, articles and wiki pages, refreshed every six hours. Anyone reads.",129- writes: "owner",130- reads: "open",131- directoryPublic: true,132- allow: SEARCHABLE,133- block: [],134- retention: [],135- source: "required",136- every: 6,137- },138- {139- // An articles name: long-form only, with the profiles that render the140- // bylines. Pulls from a source when one is given, otherwise the owner141- // publishes here directly.142- name: "articles",143- title: "Articles",144- about: "Long-form articles and the profiles behind them, nothing else. Only you write, anyone reads. Give a source relay to mirror its articles daily.",145- writes: "owner",146- reads: "open",147- directoryPublic: true,148- allow: [0, 30023],149- block: [],150- retention: [],151- source: "optional",152- every: 24,153- },154- {155- // A private-message inbox, alice-dm: anyone may drop a gift wrap, only156- // members read them. Wraps carry no author, so an allow list of just157- // the wrap kind and profiles keeps everything else out.158- name: "dm",159- title: "DM inbox",160- about: "An inbox for private messages. Anyone drops gift wraps, only members read them. Nothing else is accepted.",161- writes: "open",162- reads: "members",163- directoryPublic: false,164- allow: [0, 1059],165- block: [],166- retention: [],167- },168-];29+export const PRESETS: Preset[] = TEMPLATES.map(({ name, document }) => {30+ const config = parseConfig(document);31+ if (typeof config === "string" || !config.template) throw new Error(`template ${name}: ${typeof config === "string" ? config : "no template block"}`);32+ const p = config.policy;33+ return { name, title: config.template.title, about: config.template.about, source: config.template.source, every: config.template.every, writes: p.writes ?? "open", reads: p.reads ?? "open", directoryPublic: p.directoryPublic ?? true, allow: config.kinds.allow, block: config.kinds.block, retention: config.retention, config };34+});16935 17036 export function findPreset(name: string): Preset | undefined {17137 return PRESETS.find((p) => p.name === name);17238 }17339 174-// applyPreset replaces the access rules, kind rules and keep-for rules with175-// the bundle. Returns "" or a reason.176-export function applyPreset(s: Settings, name: string): string {40+// applyPreset applies the template's sections: the policy fields it names,41+// the kind rules and the keep-for rules. Returns "" or a reason.42+export function applyPreset(s: Settings, name: string, now: number): string {17743 const p = findPreset(name);17844 if (!p) return "invalid: no preset named " + name;179- s.update({ writes: p.writes, reads: p.reads, directoryPublic: p.directoryPublic });180- for (const k of [...s.listKinds("allow"), ...s.listKinds("block")]) s.setKind(k, null);181- for (const k of p.allow) s.setKind(k, "allow");182- for (const k of p.block) s.setKind(k, "block");183- for (const r of s.listRetention()) s.setRetention(r.kind, 0);184- for (const r of p.retention) s.setRetention(r.kind, r.days);45+ applyConfig(s, p.config, now);18546 return "";18647 }
M test/object/presets.test.ts +1 -1
A test/object/templates.test.ts +42 -0
@@ -0,0 +1,42 @@
1+// Every template in relay-templates/ boots a relay that says what the2+// template declares: the features in the policy, the numbers in NIP-11,3+// the doors that answer, and a dry run of the template afterwards that4+// changes nothing. The schema the files reference is served at the apex.5+import { SELF } from "cloudflare:test";6+import { describe, it, expect } from "vitest";7+import { generateSecretKey } from "nostr-tools/pure";8+import { PRESETS } from "../../src/presets.ts";9+import { FEATURE_NIPS } from "../../src/nip11.ts";10+import { DEFAULT_FEATURES, FEATURE_NAMES, featureOn } from "../../src/settings.ts";11+import { rpc, info, get } from "../helpers/relay.ts";12+13+describe("templates", () => {14+ it("each boots a relay that matches its document, and a dry run of it afterwards changes nothing", async () => {15+ for (const p of PRESETS) {16+ const host = `tpl-${p.name}.bind.ws`;17+ const owner = generateSecretKey();18+ await rpc(host, owner, "claim");19+ const r = await rpc(host, owner, "applypreset", p.name, p.source ? { source: "wss://source.example" } : undefined);20+ expect(r.status, p.name + " " + JSON.stringify(r)).toBe(200);21+ const policy = (await rpc(host, owner, "getpolicy")).result;22+ const features = { ...DEFAULT_FEATURES, ...(p.config.policy.features ?? {}) };23+ expect(policy.features, p.name).toEqual(features);24+ expect(policy.writes).toBe(p.writes);25+ const doc = await info(host);26+ for (const f of FEATURE_NAMES) for (const n of FEATURE_NIPS[f]) expect(doc.supported_nips.includes(n), `${p.name}: NIP-${n}`).toBe(featureOn(policy, f));27+ expect((await get(host, "/.well-known/nostr/nip96.json")).status).toBe(features.files ? 200 : 404);28+ expect((await get(host, "/feed.xml")).status).toBe(features.pages && p.reads === "open" ? 200 : 404);29+ const dry = (await rpc(host, owner, "importconfig", p.config === undefined ? {} : { format: "bind.ws/relay-config/2", policy: p.config.policy, kinds: p.config.kinds, retention: p.config.retention }, { dryRun: true })).result;30+ expect(dry.changes.summary, p.name).toEqual([]);31+ expect(dry.warnings, p.name).toEqual([]);32+ }33+ });34+35+ it("serves the configuration schema at the apex", async () => {36+ const resp = await SELF.fetch("http://bind.ws/relay-config.schema.json");37+ expect(resp.status).toBe(200);38+ const schema: any = await resp.json();39+ expect(schema.title).toBe("bind.ws relay configuration");40+ expect(schema.properties.policy.properties.features.properties.search.enum).toEqual(["full", "prose", "off"]);41+ });42+});