clone

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

nip66: the relay signs a kind 30166 discovery record about itself under its primary URL, re-signed only when the rules, kinds, identity or fuel state it restates change, so directories find it without a probe

Dami committed 7ab3a86
commit
7ab3a86cdbd996ad90df4de8cc37900eb8b07304
parent
991a9800ad5cd866d7741b15e9fbf6a5db233c61
author
Dami <dami.etoile@gmail.com>
11 files changed, +184 -11
MREADME.md2+-
Mdocs/01-relay-configuration.md2+
Mdocs/10-architecture.md2+-
Mdocs/14-http-reference.md2+-
Msrc/landing.ts3+-
Msrc/manage.ts1+
Asrc/nip66.ts55++++++++++++++++++
Msrc/relay.ts26++++++++-
Mtest/adopt.test.ts8+-
Atest/nip66.test.ts92++++++++++++++++++++++++++++++
Mtest/relay.test.ts2+-
M README.md +1 -1
M docs/01-relay-configuration.md +2 -0
M docs/10-architecture.md +1 -1
M docs/14-http-reference.md +1 -1
M src/landing.ts +2 -1
@@ -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://&lt;name&gt;.${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://&lt;name&gt;.${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.`],
M src/manage.ts +1 -0
A src/nip66.ts +55 -0
@@ -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+}
M src/relay.ts +24 -2
@@ -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());
M test/adopt.test.ts +4 -4
@@ -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 });
A test/nip66.test.ts +92 -0
@@ -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+});
M test/relay.test.ts +1 -1