- commit
- 7ab3a86cdbd996ad90df4de8cc37900eb8b07304
- parent
- 991a9800ad5cd866d7741b15e9fbf6a5db233c61
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | +- |
| M | docs/ | 2 | + |
| M | docs/ | 2 | +- |
| M | docs/ | 2 | +- |
| M | src/ | 3 | +- |
| M | src/ | 1 | + |
| A | src/ | 55 | ++++++++++++++++++ |
| M | src/ | 26 | ++++++++- |
| M | test/ | 8 | +- |
| A | test/ | 92 | ++++++++++++++++++++++++++++++ |
| M | test/ | 2 | +- |
@@ -62,7 +62,7 @@ npm run deploy
6262 6363 ## Protocol surface6464 65-NIP-01, 05, 09, 11, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 57, 59, 62, 67, 70, 77, 86, 94, 96, 98, and Blossom BUD-01, 02, 04, 06, 08, 09.65+NIP-01, 05, 09, 11, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 57, 59, 62, 66, 67, 70, 77, 86, 94, 96, 98, and Blossom BUD-01, 02, 04, 06, 08, 09.6666 6767 ## License6868 @@ -77,6 +77,8 @@ Name, description, icon and contact appear in your relay's public information do
7777 7878 Banner is a wide image for the same document. Posting policy and privacy policy are links to pages you host elsewhere; both must be https. Tags, languages and countries are short lists that tell relay directories what the relay is about: topic words, language codes such as `en` or `pt-BR`, and two-letter country codes.7979 80+The relay also publishes a discovery record for relay directories (NIP-66, kind 30166), signed with its own key under its `wss://<name>.bind.ws/` address: the NIPs it supports, whether reads need a signature, whether writes are restricted, proof of work and payment, the tags and languages above, and the kind allow and block lists, with the information document as its content. It is re-signed only when one of those changes. Anyone can read it with `{"kinds":[30166],"authors":["<the relay's own key>"]}`; the key is `self` in the information document.81+8082 **Your own domain** puts the relay under a hostname you control: add it, create the CNAME shown, **Check** until it reads live, **Remove** to take it down. At most three per relay, never part of the exported configuration. See [Your relay on the web](05-your-relay-on-the-web.md#your-own-domain).8183 8284 ### Your relay lists@@ -147,4 +147,4 @@ The daily alarm runs daily views once a day and hourly views once an hour. Write
147147 148148 ## Protocol surface149149 150-NIP-01, 05, 09, 11, 13, 17 and 59 private kinds, 29, 40, 42, 43, 45 with HLL counts, 46 as transport, 50, 56, 57, 62, 67, 70, 77, 86, 94, 96, 98, and Blossom BUD-01, 02, 03 by way of the lists, 04, 06, 08 and 09. `test/conformance` is a black-box suite that runs against any relay URL.150+NIP-01, 05, 09, 11, 13, 17 and 59 private kinds, 29, 40, 42, 43, 45 with HLL counts, 46 as transport, 50, 56, 57, 62, 66 as a record the relay signs about itself, 67, 70, 77, 86, 94, 96, 98, and Blossom BUD-01, 02, 03 by way of the lists, 04, 06, 08 and 09. `test/conformance` is a black-box suite that runs against any relay URL.@@ -116,4 +116,4 @@ Names: `profiles`, `relays`, `calendar`, `moderation`, `articles`, `zaps`, `pres
116116 117117 ## Websocket118118 119-The relay itself: `wss://<name>.bind.ws`. NIP-01 with 09, 13, 17 and 59 as private kinds, 29 and 43 for the group, 40, 42, 45, 50, 56, 62, 70 and 77, an `AUTH` challenge on connect, and `EOSE` hints per NIP-67. Kind 24133 passes the write and read rules so a NIP-46 signer or client can use the relay as transport: it is delivered to a socket that has proved a party's key with `AUTH`, or to a subscription that names one in `#p` or `authors`, and never to a bare subscription to the kind.119+The relay itself: `wss://<name>.bind.ws`. NIP-01 with 09, 13, 17 and 59 as private kinds, 29 and 43 for the group, 40, 42, 45, 50, 56, 62, 66 as a kind 30166 record signed by the relay about itself, 70 and 77, an `AUTH` challenge on connect, and `EOSE` hints per NIP-67. Kind 24133 passes the write and read rules so a NIP-46 signer or client can use the relay as transport: it is delivered to a socket that has proved a party's key with `AUTH`, or to a subscription that names one in `#p` or `authors`, and never to a bare subscription to the kind.@@ -92,6 +92,7 @@ const PROTOCOL: [string, string][] = [
9292 ["05", "Members with a name are you@name.bind.ws."],9393 ["43", "The relay signs and publishes its member list."],9494 ["57", "Zaps to the relay add fuel. The receipt is stored on the relay."],95+ ["66", "The relay signs a discovery record about itself, so directories find it without a probe."],9596 ["Blossom", "Upload and fetch files by hash on the same host. Check an upload before sending it, mirror a file from a URL, report one by hash."],9697 ["94, 96", "The same files through the NIP-96 door, with NIP-94 metadata in every answer."],9798 ];@@ -110,7 +111,7 @@ export function landing(req: Request, env: Env): Response {
110111 const bad = new URL(req.url).searchParams.get("bad");111112 const days = leaseDays(env);112113 const interfaces = table(["Endpoint", "Protocol", "Auth", "Notes"], [113- [`wss://<name>.${domain}`, "NIP-01, 09, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 59, 62, 67, 70, 77", "optional NIP-42", "Sockets hibernate; a negentropy session carries on after a wake."],114+ [`wss://<name>.${domain}`, "NIP-01, 09, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 59, 62, 66, 67, 70, 77", "optional NIP-42", "Sockets hibernate; a negentropy session carries on after a wake."],114115 ["GET / (accept nostr+json)", "NIP-11", "none", "limitation, retention, payments_url, self"],115116 ["POST /", "NIP-86", "NIP-98, owner", "claim and the management methods"],116117 ["POST /lease", "lease", "optional NIP-98", `A temporary relay for ${leaseDays(env)} days. A signature reserves the claim for that key.`],@@ -363,6 +363,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
363363 const k = num(0);364364 if (!Number.isInteger(k) || k < 0 || k > 65535) return reply({ error: "invalid: kind out of range" }, 400);365365 s.setKind(k, method === "allowkind" ? "allow" : method === "disallowkind" ? "block" : null);366+ await relay.publishDiscovery();366367 return reply({ result: true });367368 }368369 case "storagestats": {@@ -0,0 +1,55 @@
1+// NIP-66 relay discovery. Monitors publish kind 30166 records about the2+// relays they probe; a relay may say the same things about itself, signed3+// with its own key, so a directory finds it without a probe. The d tag is4+// the relay's primary URL, the tags restate what NIP-11 says, and the5+// content is the NIP-11 document. Nothing here is measured, so there are6+// no rtt or geohash tags: those are a monitor's to add.7+import type { Relay } from "./relay.ts";8+9+export const KIND_RELAY_DISCOVERY = 30166;10+11+export interface Discovery {12+ url: string;13+ tags: string[][];14+ content: string;15+}16+17+// relayTypes names the relay in the vocabulary of nostr-protocol/nips#1282,18+// from the read and write rules. Every relay here is one NIP-29 group, so19+// one that is not open on both sides is a community manager. Every relay20+// has NIP-50 search.21+export function relayTypes(writes: string, reads: string): string[] {22+ const out: string[] = [];23+ if (writes === "open" && reads === "open") out.push("PublicInbox");24+ else if (writes === "owner" && reads === "open") out.push("PublicOutbox");25+ else if (writes === "owner" && reads === "members") out.push("PrivateStorage");26+ else out.push("CommunityManagerRelays");27+ out.push("SearchRelays");28+ return out;29+}30+31+// discovery builds the record's tags and content for the relay at `host`.32+export function discovery(relay: Relay, host: string): Discovery {33+ const p = relay.settings.policy;34+ const doc = relay.info(host);35+ const lim = (doc.limitation ?? {}) as Record<string, unknown>;36+ const url = new URL(relay.relayURL(host)).toString();37+ const tags: string[][] = [["-"], ["d", url], ["n", "clearnet"]];38+ for (const t of relayTypes(p.writes, p.reads)) tags.push(["T", t]);39+ for (const n of doc.supported_nips as number[]) tags.push(["N", String(n)]);40+ const req = (key: string, on: boolean) => tags.push(["R", (on ? "" : "!") + key]);41+ req("auth", lim.auth_required === true);42+ req("writes", lim.restricted_writes === true);43+ req("pow", typeof lim.min_pow_difficulty === "number" && lim.min_pow_difficulty > 0);44+ req("payment", lim.payment_required === true);45+ for (const t of p.tags) tags.push(["t", t]);46+ const langs = new Set<string>();47+ for (const l of p.languageTags) {48+ const primary = l.split("-")[0].toLowerCase();49+ if (primary.length === 2) langs.add(primary);50+ }51+ for (const l of langs) tags.push(["l", l, "ISO-639-1"]);52+ for (const k of relay.settings.listKinds("allow")) tags.push(["k", String(k)]);53+ for (const k of relay.settings.listKinds("block")) tags.push(["k", "!" + k]);54+ return { url, tags, content: JSON.stringify(doc) };55+}@@ -7,6 +7,7 @@ import { KIND_AUTH, KIND_VANISH, difficulty, expiration, hasTag, isPrivate, now,
77 import { match, parseFilter, type Filter } from "./filter.ts";88 import { hllOffset } from "./hll.ts";99 import { Negentropy, bytesToHex, hexToBytes } from "./negentropy.ts";10+import { KIND_RELAY_DISCOVERY, discovery } from "./nip66.ts";1011 import { ERR_DUPLICATE, ERR_TOO_BIG, Store, type Access } from "./store.ts";1112 import { Settings, isReplaceable, isProtected, SUCCESSION_WARN_DAYS } from "./settings.ts";1213 import { manage } from "./manage.ts";@@ -71,8 +72,9 @@ export interface Env {
7172 // The less obvious numbers: 43 is added by info() once the relay has an7273 // identity; 62 is request-to-vanish (store.vanish); 67 is the EOSE hint7374 // array at the end of the subscription handler; 70 is the "-" tag check in74-// gate(); 77 is negentropy sync (handleSync).75-export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 46, 50, 56, 59, 62, 67, 70, 77, 86, 98];75+// gate(); 66 is the discovery record the relay signs about itself76+// (publishDiscovery); 77 is negentropy sync (handleSync).77+export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 46, 50, 56, 59, 62, 66, 67, 70, 77, 86, 98];7678 export const KIND_REPORT = 1984;7779 // NIP-46 remote signing traffic: ephemeral, encrypted end to end, never stored.7880 export const KIND_NOSTR_CONNECT = 24133;@@ -250,6 +252,25 @@ export class Relay extends DurableObject<Env> {
250252 events.push(...this.identity.group(f, t));251253 this.emit(events, t);252254 this.markView("profiles");255+ await this.publishDiscovery();256+ }257+258+ // publishDiscovery signs the NIP-66 record the relay keeps about itself259+ // (nip66.ts), under its primary name, and only when what it would say has260+ // changed: the rules, the kind rules, the identity fields and the fuel261+ // state all show in it. Called with the membership records and daily262+ // from the alarm, so a change made by a method that publishes nothing263+ // else still reaches the record within a day.264+ async publishDiscovery() {265+ if (this.settings.policy.owner === "") return;266+ await this.identity.ensure();267+ const d = discovery(this, this.slug + "." + this.domain);268+ const fp = JSON.stringify([d.tags, d.content]);269+ const row = this.sql.exec<{ created_at: number }>(`SELECT created_at FROM events WHERE kind=? AND pubkey=? ORDER BY created_at DESC LIMIT 1`, KIND_RELAY_DISCOVERY, this.identity.pubkey).toArray()[0];270+ if (row && fp === (await this.ctx.storage.get<string>("nip66-fp"))) return;271+ const t = now();272+ this.emit([this.identity.sign(KIND_RELAY_DISCOVERY, d.tags, d.content, Math.max(t, (row?.created_at ?? 0) + 1))], t);273+ await this.ctx.storage.put("nip66-fp", fp);253274 }254275 255276 // ---- views (views.ts) ----@@ -1458,6 +1479,7 @@ export class Relay extends DurableObject<Env> {
14581479 this.store.drain();14591480 this.sweepRetention(t);14601481 await this.viewsTick(t);1482+ await this.publishDiscovery();14611483 // Fuel notice: once when it turns low, then once a day while it stays low.14621484 if (this.settings.policy.notify.fuel) {14631485 const low = fuelLow(this.fuelStatus());@@ -518,8 +518,8 @@ describe("storage: retention and purge", () => {
518518 await runInDurableObject(stub, async (r: Relay) => expect(r.sweepRetention(now())).toBe(1));519519 let st = (await rpc(host, owner, "storagestats")).result;520520 const byKind = Object.fromEntries(st.kinds.map((k: any) => [k.kind, k.n]));521- expect(byKind).toEqual({ 1: 2, 7: 1, 0: 2, 13534: 1, 33534: 2, 39000: 1, 39001: 1, 39002: 1, 39003: 1 }); // the roster, profile, role definitions and group state are the relay's own522- expect(st.events).toBe(12);521+ expect(byKind).toEqual({ 1: 2, 7: 1, 0: 2, 13534: 1, 30166: 1, 33534: 2, 39000: 1, 39001: 1, 39002: 1, 39003: 1 }); // the roster, profile, discovery record, role definitions and group state are the relay's own522+ expect(st.events).toBe(13);523523 expect(st.eventBytes).toBeGreaterThan(0);524524 expect(st.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);525525 @@ -527,7 +527,7 @@ describe("storage: retention and purge", () => {
527527 expect((await rpc(host, owner, "purgekind", 1, 10)).result).toEqual({ deleted: 1 });528528 expect((await rpc(host, owner, "purgekind", 7, 0)).result).toEqual({ deleted: 1 });529529 st = (await rpc(host, owner, "storagestats")).result;530- expect(st.events).toBe(10);530+ expect(st.events).toBe(11);531531 532532 // Rules travel with the configuration; removing one is days 0.533533 const cfg = (await rpc(host, owner, "exportconfig")).result;@@ -540,6 +540,6 @@ describe("storage: retention and purge", () => {
540540 expect((await rpc(host, owner, "purgekind", 13534, 0)).status).toBe(400);541541 await rpc(host, owner, "setretention", null, 1);542542 expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile, roster and group state stay543- expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort((a: number, b: number) => a - b)).toEqual([0, 13534, 33534, 39000, 39001, 39002, 39003]);543+ expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort((a: number, b: number) => a - b)).toEqual([0, 13534, 30166, 33534, 39000, 39001, 39002, 39003]);544544 });545545 });@@ -0,0 +1,92 @@
1+// NIP-66: the relay signs a kind 30166 discovery record about itself, under2+// its primary URL, restating the NIP-11 document as tags, and re-signs it3+// only when what it says would change.4+import { SELF } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { finalizeEvent, generateSecretKey, type Event } from "nostr-tools/pure";7+import { getToken } from "nostr-tools/nip98";8+9+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {10+ const url = `http://${host}/`;11+ const payload = { method, params };12+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);13+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });14+ return { status: resp.status, ...(await resp.json<any>()) };15+}16+17+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();18+19+// query reads through the HTTP bridge as the owner, so the read rule never gets in the way.20+async function query(host: string, sk: Uint8Array, filter: unknown): Promise<Event[]> {21+ const url = `http://${host}/query`;22+ const body = [filter];23+ const authorization = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as any);24+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization }, body: JSON.stringify(body) });25+ expect(resp.status).toBe(200);26+ return resp.json<Event[]>();27+}28+29+const vals = (e: Event, name: string) => e.tags.filter((t) => t[0] === name).map((t) => t[1]);30+31+describe("NIP-66 discovery record", () => {32+ it("is signed by the relay under its primary URL, restates NIP-11 and follows the rules", async () => {33+ const host = "sixtysix.bind.ws";34+ const owner = generateSecretKey();35+ await rpc(host, owner, "claim");36+ await rpc(host, owner, "setpolicy", { tags: ["bitcoin", "cats"], languageTags: ["en", "pt-BR", "pt"], reads: "auth" });37+ await rpc(host, owner, "allowkind", 1);38+ await rpc(host, owner, "disallowkind", 4);39+ const doc = await info(host);40+ expect(doc.supported_nips).toContain(66);41+ const self = doc.self as string;42+ expect(self).toMatch(/^[0-9a-f]{64}$/);43+44+ const found = await query(host, owner, { kinds: [30166], authors: [self] });45+ expect(found.length).toBe(1);46+ const e = found[0];47+ expect(vals(e, "d")).toEqual([`wss://${host}/`]);48+ expect(vals(e, "n")).toEqual(["clearnet"]);49+ expect(vals(e, "T")).toEqual(["CommunityManagerRelays", "SearchRelays"]);50+ expect(vals(e, "N")).toContain("1");51+ expect(vals(e, "N")).toContain("66");52+ expect(vals(e, "N")).toContain("43");53+ expect(vals(e, "R").sort()).toEqual(["!payment", "!pow", "!writes", "auth"]);54+ expect(vals(e, "t")).toEqual(["bitcoin", "cats"]);55+ expect(e.tags.filter((t) => t[0] === "l")).toEqual([["l", "en", "ISO-639-1"], ["l", "pt", "ISO-639-1"]]);56+ expect(vals(e, "k")).toEqual(["1", "!4"]);57+ expect(e.tags[0]).toEqual(["-"]);58+ const content = JSON.parse(e.content);59+ expect(content.self).toBe(self);60+ expect(content.tags).toEqual(["bitcoin", "cats"]);61+ expect(content.limitation.auth_required).toBe(true);62+ });63+64+ it("is re-signed when the rules change and left alone when they do not", async () => {65+ const host = "sixtysix-again.bind.ws";66+ const owner = generateSecretKey();67+ await rpc(host, owner, "claim");68+ const self = (await info(host)).self as string;69+ const first = (await query(host, owner, { kinds: [30166], authors: [self] }))[0];70+ expect(first).toBeDefined();71+ expect(vals(first, "T")).toEqual(["PublicInbox", "SearchRelays"]);72+ expect(vals(first, "R")).toContain("!auth");73+ expect(vals(first, "R")).toContain("!pow");74+75+ // The same policy again: nothing to say, so nothing is signed.76+ await rpc(host, owner, "setpolicy", { reads: "open" });77+ const same = await query(host, owner, { kinds: [30166], authors: [self] });78+ expect(same.length).toBe(1);79+ expect(same[0].id).toBe(first.id);80+81+ // Proof of work and owner-only writes change the requirements and the type.82+ await rpc(host, owner, "setpolicy", { minPow: 8, writes: "owner" });83+ const next = await query(host, owner, { kinds: [30166], authors: [self] });84+ expect(next.length).toBe(1);85+ expect(next[0].id).not.toBe(first.id);86+ expect(next[0].created_at).toBeGreaterThan(first.created_at);87+ expect(vals(next[0], "R")).toContain("pow");88+ expect(vals(next[0], "R")).toContain("writes");89+ expect(vals(next[0], "T")).toEqual(["PublicOutbox", "SearchRelays"]);90+ expect(JSON.parse(next[0].content).limitation.min_pow_difficulty).toBe(8);91+ });92+});@@ -100,7 +100,7 @@ describe("claiming", () => {
100100 expect((await rpc(host, sk, "claim")).result.claimed).toBe(true);101101 102102 expect((await c.ok(ev(sk, 1, "hi"))).ok).toBe(true);103- expect((await rpc(host, sk, "stats")).result.events).toBe(9); // the note, the relay-signed roster, profile, two role definitions and the four NIP-29 state events103+ expect((await rpc(host, sk, "stats")).result.events).toBe(10); // the note, the relay-signed roster, profile, discovery record, two role definitions and the four NIP-29 state events104104 const info2 = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();105105 expect(info2.pubkey).toBe(getPublicKey(sk));106106 expect(info2.limitation.restricted_writes).toBe(false);