- commit
- dc6f3869478379e5bc35721f62d544c5a08ba655
- parent
- 4486ac53515a5f6b55c514fc9e3d1ec027a3cad8
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 4 | +- |
| M | docs/ | 9 | +- |
| M | package-lock.json | 59 | +++++++ |
| M | package.json | 7 | +- |
| A | relay-config.schema.json | 122 | ++++++++++++++ |
| A | relay-templates/ | 19 | ++ |
| A | relay-templates/ | 24 | +++ |
| A | relay-templates/ | 39 | +++++ |
| A | relay-templates/ | 21 | ++ |
| A | relay-templates/ | 30 | +++ |
| A | relay-templates/ | 25 | +++ |
| A | relay-templates/ | 32 | ++++ |
| A | relay-templates/ | 26 | +++ |
| A | relay-templates/ | 24 | +++ |
| A | relay-templates/ | 32 | ++++ |
| A | relay-templates/ | 18 | ++ |
| A | scripts/ | 32 | ++++ |
| M | scripts/ | 26 | +--- |
| A | scripts/ | 20 | ++ |
| A | scripts/ | 50 | ++++++ |
| A | scripts/ | 29 | +++ |
| A | src/ | 259 | ++++++++++++++++++++++++++++++ |
| M | src/ | 3 | + |
| M | src/ | 4 | +- |
| M | src/ | 181 | ++------------------- |
| M | test/ | 2 | +- |
| A | test/ | 42 | +++++ |
@@ -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.@@ -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@@ -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",@@ -8,9 +8,11 @@
88 "deploy": "wrangler deploy",99 "test": "vitest run",1010 "test:conformance": "vitest run --config vitest.conformance.config.ts",11- "typecheck": "tsc --noEmit && node scripts/build/build-signer.mjs --check && node scripts/build/build-console.mjs --check && node scripts/check/check-console.mjs && node scripts/check/check-celld.mjs",11+ "typecheck": "tsc --noEmit && node scripts/build/build-signer.mjs --check && node scripts/build/build-console.mjs --check && node scripts/build/build-templates.mjs --check && node scripts/check/check-console.mjs && node scripts/check/check-celld.mjs && node scripts/check/check-config.mjs relay-templates/*.jsonc",1212 "build:signer": "node scripts/build/build-signer.mjs",13- "build:console": "node scripts/build/build-console.mjs"13+ "build:console": "node scripts/build/build-console.mjs",14+ "check:config": "node scripts/check/check-config.mjs",15+ "build:templates": "node scripts/build/build-templates.mjs"1416 },1517 "dependencies": {1618 "@noble/hashes": "^2.4.0",@@ -20,6 +22,7 @@
2022 "@cloudflare/vitest-pool-workers": "^0.22.0",2123 "@cloudflare/workers-types": "^5.20260902.1",2224 "@types/ws": "^8.18.0",25+ "ajv": "^8.20.0",2326 "jsqr": "^1.4.0",2427 "typescript": "^7.0.2",2528 "vitest": "^4.1.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+}@@ -0,0 +1,19 @@
1+// The rules a fresh claim starts with.2+{3+ "$schema": "../relay-config.schema.json",4+ "format": "bind.ws/relay-config/2",5+ "template": {6+ "title": "Default",7+ "about": "Anyone writes, anyone reads, every kind, kept forever. The rules a fresh claim starts with."8+ },9+ "policy": {10+ "writes": "open",11+ "reads": "open",12+ "directoryPublic": true13+ },14+ "kinds": {15+ "allow": [],16+ "block": []17+ },18+ "retention": []19+}@@ -0,0 +1,24 @@
1+// Haven's outbox: the owner's public notes, readable by all, blasted2+// elsewhere. Private kinds are blocked so a misconfigured client cannot park3+// DMs on a public relay; everything else is fair game.4+{5+ "$schema": "../relay-config.schema.json",6+ "format": "bind.ws/relay-config/2",7+ "template": {8+ "title": "Outbox",9+ "about": "Only you write, anyone reads. Your public notes and articles, kept forever. Private kinds are refused."10+ },11+ "policy": {12+ "writes": "owner",13+ "reads": "open",14+ "directoryPublic": true15+ },16+ "kinds": {17+ "allow": [],18+ "block": [19+ 4,20+ 105921+ ]22+ },23+ "retention": []24+}@@ -0,0 +1,39 @@
1+// Haven's inbox: where people reach the owner, so anyone may write, but only2+// the kinds that mean "for you": notes and replies, reposts, reactions,3+// comments, highlights, reports, zap receipts, plus profiles so the senders4+// render. Haven also checks that the owner is tagged; kind rules cannot, so a5+// short keep-for rule bounds what spam costs. The owner's own lists always6+// land regardless of the allow list.7+{8+ "$schema": "../relay-config.schema.json",9+ "format": "bind.ws/relay-config/2",10+ "template": {11+ "title": "Inbox",12+ "about": "Anyone writes notes, replies, reactions and zaps meant for you; anyone reads. Everything is kept 90 days."13+ },14+ "policy": {15+ "writes": "open",16+ "reads": "open",17+ "directoryPublic": true18+ },19+ "kinds": {20+ "allow": [21+ 0,22+ 1,23+ 6,24+ 7,25+ 16,26+ 1111,27+ 1984,28+ 9735,29+ 980230+ ],31+ "block": []32+ },33+ "retention": [34+ {35+ "kind": null,36+ "days": 9037+ }38+ ]39+}@@ -0,0 +1,21 @@
1+// Haven's private relay: drafts, ecash, app data, anything the owner wants2+// nowhere else. Only the owner writes and only members read, so a second3+// device or a trusted person can be let in without opening it.4+{5+ "$schema": "../relay-config.schema.json",6+ "format": "bind.ws/relay-config/2",7+ "template": {8+ "title": "Private",9+ "about": "Only you write, only members read. Drafts, wallets and app data, every kind, kept forever."10+ },11+ "policy": {12+ "writes": "owner",13+ "reads": "members",14+ "directoryPublic": false15+ },16+ "kinds": {17+ "allow": [],18+ "block": []19+ },20+ "retention": []21+}@@ -0,0 +1,30 @@
1+// Haven's chat relay, adapted: this relay already has members and a NIP-292+// group, so chat is the members-only group. Members write, members read, and3+// the kinds are gift wraps for private messages, group chat messages and4+// threads, comments, reactions and profiles. Join, leave and moderation events5+// are not subject to kind rules.6+{7+ "$schema": "../relay-config.schema.json",8+ "format": "bind.ws/relay-config/2",9+ "template": {10+ "title": "Chat",11+ "about": "Members write, members read. Private messages and the group's chat, kept forever. The directory is hidden."12+ },13+ "policy": {14+ "writes": "allowlist",15+ "reads": "members",16+ "directoryPublic": false17+ },18+ "kinds": {19+ "allow": [20+ 0,21+ 7,22+ 9,23+ 11,24+ 1059,25+ 111126+ ],27+ "block": []28+ },29+ "retention": []30+}@@ -0,0 +1,25 @@
1+// A Blossom-only name, alice-media: the files live here, the notes do not.2+// Members write, so their uploads pass the upload gate, but the only events3+// they may post are profiles and Blossom server lists, so the name stays a4+// media host and nothing else.5+{6+ "$schema": "../relay-config.schema.json",7+ "format": "bind.ws/relay-config/2",8+ "template": {9+ "title": "Media",10+ "about": "A media host. Members upload files; the only events accepted are profiles and Blossom server lists. Anyone reads."11+ },12+ "policy": {13+ "writes": "allowlist",14+ "reads": "open",15+ "directoryPublic": true16+ },17+ "kinds": {18+ "allow": [19+ 0,20+ 1006321+ ],22+ "block": []23+ },24+ "retention": []25+}@@ -0,0 +1,32 @@
1+// A search replica, alice-search: a standing pull of the searchable kinds (the2+// full-text index's kinds minus drafts) from a source relay every six hours,3+// readable by all, written only by the owner, so a client can point search at4+// it without the source relay ever being asked.5+{6+ "$schema": "../relay-config.schema.json",7+ "format": "bind.ws/relay-config/2",8+ "template": {9+ "title": "Search replica",10+ "about": "A read-only copy of another relay's notes, threads, comments, highlights, articles and wiki pages, refreshed every six hours. Anyone reads.",11+ "source": "required",12+ "every": 613+ },14+ "policy": {15+ "writes": "owner",16+ "reads": "open",17+ "directoryPublic": true18+ },19+ "kinds": {20+ "allow": [21+ 0,22+ 1,23+ 11,24+ 1111,25+ 9802,26+ 30023,27+ 3081828+ ],29+ "block": []30+ },31+ "retention": []32+}@@ -0,0 +1,26 @@
1+// An articles name: long-form only, with the profiles that render the bylines.2+// Pulls from a source when one is given, otherwise the owner publishes here3+// directly.4+{5+ "$schema": "../relay-config.schema.json",6+ "format": "bind.ws/relay-config/2",7+ "template": {8+ "title": "Articles",9+ "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.",10+ "source": "optional",11+ "every": 2412+ },13+ "policy": {14+ "writes": "owner",15+ "reads": "open",16+ "directoryPublic": true17+ },18+ "kinds": {19+ "allow": [20+ 0,21+ 3002322+ ],23+ "block": []24+ },25+ "retention": []26+}@@ -0,0 +1,24 @@
1+// A private-message inbox, alice-dm: anyone may drop a gift wrap, only members2+// read them. Wraps carry no author, so an allow list of just the wrap kind and3+// profiles keeps everything else out.4+{5+ "$schema": "../relay-config.schema.json",6+ "format": "bind.ws/relay-config/2",7+ "template": {8+ "title": "DM inbox",9+ "about": "An inbox for private messages. Anyone drops gift wraps, only members read them. Nothing else is accepted."10+ },11+ "policy": {12+ "writes": "open",13+ "reads": "members",14+ "directoryPublic": false15+ },16+ "kinds": {17+ "allow": [18+ 0,19+ 105920+ ],21+ "block": []22+ },23+ "retention": []24+}@@ -0,0 +1,32 @@
1+// A quiet name: every feature that reads the whole store, writes an index,2+// opens a door or tells the world is off, so the relay costs what its events3+// cost and shows nothing but its page. Switch features back on one at a time4+// as they earn their keep.5+{6+ "$schema": "../relay-config.schema.json",7+ "format": "bind.ws/relay-config/2",8+ "template": {9+ "title": "Quiet",10+ "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."11+ },12+ "policy": {13+ "writes": "allowlist",14+ "reads": "members",15+ "directoryPublic": false,16+ "features": {17+ "search": "off",18+ "sync": false,19+ "count": false,20+ "discovery": false,21+ "names": false,22+ "files": false,23+ "pages": false,24+ "signer": false25+ }26+ },27+ "kinds": {28+ "allow": [],29+ "block": []30+ },31+ "retention": []32+}@@ -0,0 +1,18 @@
1+# Relay templates2+3+One file per template, in the order the console shows them. Each is a relay4+configuration (`relay-config.schema.json`) with a `template` block for the5+title and the blurb, and the rules sections only: policy, kinds and6+retention. People, bans and addresses are never in a template, and a section7+a template leaves out is left alone when it is applied.8+9+`npm run build:templates` folds the folder into `src/gen/templates.ts`, which10+the relay serves as its presets; `npm run typecheck` checks every file here11+against the schema and the relay's parser, and fails when the generated12+module is stale.13+14+To try one on a relay without applying it:15+16+```17+node scripts/ops/relay.mjs plan relay-templates/10-quiet.jsonc wss://<name>.bind.ws18+```@@ -0,0 +1,32 @@
1+// Folds relay-templates/*.jsonc into src/gen/templates.ts, the presets the2+// relay offers, in file order. `--check` rebuilds in memory and fails when3+// the committed file is stale, so typecheck catches a forgotten rebuild.4+import { readdirSync, readFileSync, writeFileSync } from "node:fs";5+import { stripComments } from "../check/jsonc.mjs";6+7+const DIR = new URL("../../relay-templates/", import.meta.url);8+const OUT = new URL("../../src/gen/templates.ts", import.meta.url);9+const files = readdirSync(DIR).filter((f) => f.endsWith(".jsonc")).sort();10+const templates = files.map((f) => {11+ const doc = JSON.parse(stripComments(readFileSync(new URL(f, DIR), "utf8")));12+ delete doc.$schema;13+ return { name: f.replace(/^\d+-/, "").replace(/\.jsonc$/, ""), document: doc };14+});15+const file = `// Generated by scripts/build/build-templates.mjs from relay-templates/. Do not edit; run npm run build:templates.16+export const TEMPLATES: { name: string; document: unknown }[] = ${JSON.stringify(templates, null, 2)};17+`;18+if (process.argv.includes("--check")) {19+ let cur = "";20+ try {21+ cur = readFileSync(OUT, "utf8");22+ } catch {23+ /* missing */24+ }25+ if (cur !== file) {26+ console.error("src/gen/templates.ts is stale: run npm run build:templates");27+ process.exit(1);28+ }29+} else {30+ writeFileSync(OUT, file);31+ console.log(`src/gen/templates.ts: ${templates.length} templates`);32+}@@ -4,32 +4,8 @@
44 // accepts. Runs as part of `npm run typecheck`, so a change to one file that55 // forgets the other fails the build instead of quietly dropping the host.66 import { readFileSync } from "node:fs";7+import { stripComments } from "./jsonc.mjs";78 8-// stripComments removes // and /* */ comments outside strings, and the9-// trailing commas JSONC allows, so JSON.parse takes what wrangler takes.10-function stripComments(text) {11- let out = "";12- let inString = false;13- for (let i = 0; i < text.length; i++) {14- const c = text[i];15- if (inString) {16- out += c;17- if (c === "\\") out += text[++i];18- else if (c === '"') inString = false;19- } else if (c === '"') {20- inString = true;21- out += c;22- } else if (c === "/" && text[i + 1] === "/") {23- while (i < text.length && text[i] !== "\n") i++;24- out += "\n";25- } else if (c === "/" && text[i + 1] === "*") {26- const end = text.indexOf("*/", i + 2);27- if (end === -1) throw new Error("unterminated comment");28- i = end + 1;29- } else out += c;30- }31- return out.replace(/,(\s*[}\]])/g, "$1");32-}339 const load = (path) => JSON.parse(stripComments(readFileSync(new URL("../../" + path, import.meta.url), "utf8")));3410 3511 const cf = load("wrangler.jsonc");@@ -0,0 +1,20 @@
1+// Checks relay configuration files: node scripts/check/check-config.mjs <file.jsonc>...2+// Part of npm run typecheck for every template in relay-templates/.3+import { checkConfig, describe, readConfig } from "./config.mjs";4+5+const files = process.argv.slice(2);6+if (files.length === 0) {7+ console.error("usage: node scripts/check/check-config.mjs <file.jsonc>...");8+ process.exit(2);9+}10+let failed = false;11+for (const f of files) {12+ try {13+ const cfg = await checkConfig(readConfig(f), f);14+ console.log(`${f}: ok\n ${describe(cfg).join("\n ")}`);15+ } catch (e) {16+ failed = true;17+ console.error(e.message);18+ }19+}20+process.exit(failed ? 1 : 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+}@@ -0,0 +1,29 @@
1+// JSONC as wrangler reads it: // and /* */ comments outside strings, and2+// trailing commas, removed so JSON.parse takes the rest.3+import { readFileSync } from "node:fs";4+5+export function stripComments(text) {6+ let out = "";7+ let inString = false;8+ for (let i = 0; i < text.length; i++) {9+ const c = text[i];10+ if (inString) {11+ out += c;12+ if (c === "\\") out += text[++i];13+ else if (c === '"') inString = false;14+ } else if (c === '"') {15+ inString = true;16+ out += c;17+ } else if (c === "/" && text[i + 1] === "/") {18+ while (i < text.length && text[i] !== "\n") i++;19+ out += "\n";20+ } else if (c === "/" && text[i + 1] === "*") {21+ const end = text.indexOf("*/", i + 2);22+ if (end === -1) throw new Error("unterminated comment");23+ i = end + 1;24+ } else out += c;25+ }26+ return out.replace(/,(\s*[}\]])/g, "$1");27+}28+29+export const readJSONC = (path) => JSON.parse(stripComments(readFileSync(path, "utf8")));@@ -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+];@@ -8,6 +8,7 @@ import { verifyNIP98 } from "./auth.ts";
88 import { leaseNames, leaseDays, validName } from "./names.ts";99 import { now } from "./event.ts";1010 import { clientIP, customHost, hostnameKnown, leaseAllowed } from "./edge.ts";11+import schema from "../relay-config.schema.json";1112 1213 // The entry module exports the handler and the object only: workerd1314 // refuses any other kind of export here.@@ -73,6 +74,8 @@ export default {
7374 7475 if (name === null) {7576 if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") return new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } });77+ // The configuration file's schema, for editors: relay-config.schema.json in the repository.78+ if (url.pathname === "/relay-config.schema.json") return Response.json(schema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } });7679 if (url.pathname === "/lease" && req.method === "POST") return lease(req, env, url);7780 if (url.pathname === "/lease" && req.method === "OPTIONS") {7881 return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "POST, OPTIONS" } });@@ -481,7 +481,7 @@ export const METHODS: Record<string, Method> = {
481481 },482482 applypreset: {483483 action: "rules",484- run: async ({ relay, s, params, str, reply }) => {484+ run: async ({ relay, s, t, params, str, reply }) => {485485 // (name, {source?}): writes, reads, directory, kind rules and keep-for486486 // rules in one go. A replica preset also gets a standing pull of its487487 // kinds from the source; an earlier replica job is replaced.@@ -495,7 +495,7 @@ export const METHODS: Record<string, Method> = {
495495 const bad = checkPullURL(source, relay.slug, relay.domain);496496 if (bad) return reply({ error: bad }, 400);497497 }498- const err = applyPreset(s, preset.name);498+ const err = applyPreset(s, preset.name, t);499499 if (err) return reply({ error: err }, 400);500500 relay.enforceReads();501501 for (const j of await relay.jobs()) if (j.label === "replica") await relay.removeJob(j.id);@@ -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 }@@ -11,7 +11,7 @@ describe("presets", () => {
1111 const owner = generateSecretKey();1212 await rpc(host, owner, "claim");1313 const list = (await rpc(host, owner, "listpresets")).result;14- expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm"]);14+ expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm", "quiet"]);1515 for (const p of list) expect(p.about.length).toBeGreaterThan(10);1616 expect(list.find((p: any) => p.name === "search").source).toBe("required");1717 expect(list.find((p: any) => p.name === "articles").source).toBe("optional");@@ -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+});