The join terms become terms_of_service at GET /terms. Links must be https; lists are validated and capped. setpolicy, export and import carry them. A comment next to SUPPORTED_NIPS names the less obvious numbers, since 67 is implemented (the EOSE hint array) but nothing in the code says so.
- commit
- 5a898d6f9b671926f1981571b5b0a5b1ef245691
- parent
- 604ff423880009fa0ae68ceac9fbeb6b1f084422
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 17 | ++++++ |
| M | src/ | 3 | +- |
| M | src/ | 23 | ++++++++- |
| M | src/ | 60 | ++++++++++++++++++++++ |
| A | test/ | 82 | ++++++++++++++++++++++++++++++ |
@@ -57,6 +57,23 @@ export function claimInvite(sql: SqlStorage, code: string, now: number): ClaimRe
5757 }5858 5959 // invitePage is the join page. It signs the claim with a NIP-07 extension.60+// termsPage is the relay's terms of service as a page: the join terms the61+// owner wrote, at a stable URL that NIP-11 can point to.62+export function termsPage(relayName: string, host: string, terms: string): string {63+ const css = `64+main { max-width: 44rem; padding-top: 10vh; }65+h1 { font-size: clamp(2.6rem, 8vw, 4.2rem); }66+.terms { white-space: pre-wrap; background: var(--butter); border: 2px solid var(--ink); border-radius: 14px; box-shadow: 4px 4px 0 var(--ink); padding: 1rem 1.1rem; margin: 1.2rem 0 1.5rem; }67+`;68+ const body = `<main>69+ <h1><em>${escapeHTML(relayName)}</em> terms</h1>70+ <p class="lead">What the people who run <code class="pill">wss://${escapeHTML(host)}</code> ask of everyone who uses it.</p>71+ <div class="terms">${escapeHTML(terms)}</div>72+ <footer class="pg"><p><a href="/">back to the relay</a></p></footer>73+</main>`;74+ return page(relayName + " terms", body, css);75+}76+6077 export function invitePage(relayName: string, host: string, code: string, status: ClaimResult, terms: string): string {6178 const problem = { invite_invalid: "This invite link isn't valid.", invite_expired: "This invite has expired.", invite_exhausted: "This invite has been used up.", ok: "" }[status];6279 const css = `@@ -7,7 +7,7 @@ import { bytesToHex } from "./negentropy.ts";
77 import type { Relay } from "./relay.ts";88 import { listInvites, mintInvite, revokeInvite } from "./invites.ts";99 import { descriptor, type Blob } from "./blossom.ts";10-import { isReplaceable, isProtected } from "./settings.ts";10+import { isReplaceable, isProtected, publicFields } from "./settings.ts";1111 import { checkPullURL } from "./pull.ts";1212 import { can, METHOD_ACTIONS } from "./roles.ts";1313 @@ -126,6 +126,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
126126 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};127127 const clean: Record<string, unknown> = {};128128 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);129+ Object.assign(clean, publicFields(patch));129130 if (patch.writes === "open" || patch.writes === "allowlist" || patch.writes === "owner") clean.writes = patch.writes;130131 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;131132 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);@@ -17,7 +17,7 @@ import { Bucket } from "./ratelimit.ts";
1717 import { bridge } from "./bridge.ts";1818 import { blossom, blobBytes, isBlobPath } from "./blossom.ts";1919 import { claimFromProfile, nip05Document } from "./nip05.ts";20-import { checkInvite, claimInvite, invitePage } from "./invites.ts";20+import { checkInvite, claimInvite, invitePage, termsPage } from "./invites.ts";2121 import { verifyNIP98 } from "./manage.ts";2222 import { FAVICON_SVG } from "./ui.ts";2323 import { runPullRound, type PullJob, type PullResult } from "./pull.ts";@@ -47,6 +47,10 @@ export interface Env {
4747 SATS_PER_MILLION_ROWS?: string;4848 }4949 50+// The less obvious numbers: 43 is added by info() once the relay has an51+// identity; 62 is request-to-vanish (store.vanish); 67 is the EOSE hint52+// array at the end of the subscription handler; 70 is the "-" tag check in53+// gate(); 77 is negentropy sync (handleSync).5054 export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 50, 56, 59, 62, 67, 70, 77, 86, 98];5155 export const KIND_REPORT = 1984;5256 // NIP-46 remote signing traffic: ephemeral, encrypted end to end, never stored.@@ -573,6 +577,11 @@ export class Relay extends DurableObject<Env> {
573577 const people = p.owner && p.directoryPublic ? this.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];574578 return Response.json({ public: p.directoryPublic, self: this.identity.pubkey, host: url.host, people }, { headers: { "access-control-allow-origin": "*" } });575579 }580+ if (url.pathname === "/terms" && req.method === "GET") {581+ const terms = this.settings.policy.joinTerms;582+ if (!terms) return new Response("this relay has no terms", { status: 404 });583+ return new Response(termsPage(this.settings.policy.name || this.slug, url.host, terms), { headers: { "content-type": "text/html; charset=utf-8" } });584+ }576585 if (url.pathname.startsWith("/invite/") && req.method === "GET") {577586 const code = url.pathname.slice(8);578587 const status = this.settings.policy.owner === "" ? "invite_invalid" : checkInvite(this.sql, code, now());@@ -606,6 +615,11 @@ export class Relay extends DurableObject<Env> {
606615 return (secure ? "wss://" : "ws://") + host;607616 }608617 618+ // webURL is the same address for a browser.619+ webURL(host: string): string {620+ return this.relayURL(host).replace(/^ws/, "http");621+ }622+609623 info(host: string) {610624 const p = this.settings.policy;611625 const doc: Record<string, unknown> = {@@ -628,6 +642,13 @@ export class Relay extends DurableObject<Env> {
628642 },629643 };630644 if (p.icon) doc.icon = p.icon;645+ if (p.banner) doc.banner = p.banner;646+ if (p.joinTerms && host) doc.terms_of_service = this.webURL(host) + "/terms";647+ if (p.postingPolicy) doc.posting_policy = p.postingPolicy;648+ if (p.privacyPolicy) doc.privacy_policy = p.privacyPolicy;649+ if (p.tags.length) doc.tags = p.tags;650+ if (p.languageTags.length) doc.language_tags = p.languageTags;651+ if (p.relayCountries.length) doc.relay_countries = p.relayCountries;631652 if (p.contact) doc.contact = p.contact;632653 const retention = this.settings.listRetention().map((r) => (r.kind === null ? { time: r.days * 86400 } : { kinds: [r.kind], time: r.days * 86400 }));633654 if (retention.length) doc.retention = retention;@@ -15,7 +15,14 @@ export interface Policy {
1515 name: string;1616 description: string;1717 icon: string;18+ banner: string; // NIP-11 banner image URL1819 contact: string;20+ // NIP-11 extras: links to written policies, and what the relay is about.21+ postingPolicy: string; // URL22+ privacyPolicy: string; // URL23+ tags: string[]; // short topic words24+ languageTags: string[]; // BCP-47, such as en or pt-BR25+ relayCountries: string[]; // ISO 3166-1 alpha-2, such as US1926 writes: "open" | "allowlist" | "owner"; // who may publish2027 reads: "open" | "auth" | "members"; // whether REQ/COUNT need NIP-42, or membership2128 joinTerms: string; // shown before an invite is accepted (markdown-ish plain text)@@ -35,7 +42,13 @@ export const DEFAULT_POLICY: Policy = {
3542 name: "",3643 description: "",3744 icon: "",45+ banner: "",3846 contact: "",47+ postingPolicy: "",48+ privacyPolicy: "",49+ tags: [],50+ languageTags: [],51+ relayCountries: [],3952 writes: "open",4053 reads: "open",4154 joinTerms: "",@@ -78,6 +91,52 @@ export type Member = {
7891 7992 export const NAME_RE = /^[a-z0-9._-]{1,64}$/;8093 94+const TAG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;95+const LANG_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;96+const COUNTRY_RE = /^[A-Z]{2}$/;97+const MAX_LIST = 20;98+99+// publicFields validates the NIP-11 extras of a policy patch: three https100+// links (an empty string clears one) and three short lists. Anything that101+// does not fit is dropped, never stored half-right.102+export function publicFields(patch: Record<string, unknown>): Partial<Policy> {103+ const out: Partial<Policy> = {};104+ const link = (v: unknown): string | undefined => {105+ if (typeof v !== "string") return undefined;106+ const s = v.trim().slice(0, 2000);107+ if (s === "") return "";108+ try {109+ const u = new URL(s);110+ return u.protocol === "https:" ? u.href : undefined;111+ } catch {112+ return undefined;113+ }114+ };115+ const list = (v: unknown, re: RegExp, map: (s: string) => string): string[] | undefined => {116+ if (!Array.isArray(v)) return undefined;117+ const seen = new Set<string>();118+ for (const x of v) {119+ if (typeof x !== "string") continue;120+ const s = map(x.trim());121+ if (s !== "" && re.test(s)) seen.add(s);122+ }123+ return [...seen].slice(0, MAX_LIST);124+ };125+ const banner = link(patch.banner);126+ if (banner !== undefined) out.banner = banner;127+ const posting = link(patch.postingPolicy);128+ if (posting !== undefined) out.postingPolicy = posting;129+ const privacy = link(patch.privacyPolicy);130+ if (privacy !== undefined) out.privacyPolicy = privacy;131+ const tags = list(patch.tags, TAG_RE, (s) => s.toLowerCase());132+ if (tags) out.tags = tags;133+ const langs = list(patch.languageTags, LANG_RE, (s) => s);134+ if (langs) out.languageTags = langs;135+ const countries = list(patch.relayCountries, COUNTRY_RE, (s) => s.toUpperCase());136+ if (countries) out.relayCountries = countries;137+ return out;138+}139+81140 export class Settings {82141 policy: Policy = { ...DEFAULT_POLICY };83142 private banned = new Set<string>();@@ -251,6 +310,7 @@ export class Settings {
251310 const policy = (c.policy && typeof c.policy === "object" ? c.policy : {}) as Record<string, unknown>;252311 const clean: Partial<Policy> = {};253312 for (const k of ["name", "description", "icon", "contact", "joinTerms"] as const) if (typeof policy[k] === "string") clean[k] = (policy[k] as string).slice(0, 20000);313+ Object.assign(clean, publicFields(policy));254314 if (policy.writes === "open" || policy.writes === "allowlist" || policy.writes === "owner") clean.writes = policy.writes;255315 if (policy.reads === "open" || policy.reads === "auth" || policy.reads === "members") clean.reads = policy.reads;256316 if (typeof policy.directoryPublic === "boolean") clean.directoryPublic = policy.directoryPublic;@@ -0,0 +1,82 @@
1+// NIP-11 extras: the policy links and lists the owner can set, the terms of2+// service page, and what gets into the information document.3+import { SELF } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { finalizeEvent, generateSecretKey } from "nostr-tools/pure";6+import { getToken } from "nostr-tools/nip98";7+8+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {9+ const url = `http://${host}/`;10+ const payload = { method, params };11+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);12+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });13+ return { status: resp.status, ...(await resp.json<any>()) };14+}15+16+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();17+18+describe("NIP-11 extras", () => {19+ it("publishes the policy links and lists once set, validated", async () => {20+ const host = "eleven.bind.ws";21+ const owner = generateSecretKey();22+ await rpc(host, owner, "claim");23+ let doc = await info(host);24+ for (const k of ["banner", "terms_of_service", "posting_policy", "privacy_policy", "tags", "language_tags", "relay_countries"]) expect(doc[k], k).toBeUndefined();25+26+ const r = await rpc(host, owner, "setpolicy", {27+ banner: "https://img.example/banner.png",28+ postingPolicy: "https://example.com/posting",29+ privacyPolicy: "http://example.com/privacy", // not https: dropped30+ tags: ["Bitcoin", "cats", "cats", "Not Valid!", "x".repeat(40)],31+ languageTags: ["en", "pt-BR", "english", "zh-Hant-TW"],32+ relayCountries: ["us", "DE", "USA", "fr"],33+ });34+ expect(r.status).toBe(200);35+ expect(r.result.privacyPolicy).toBe("");36+ doc = await info(host);37+ expect(doc.banner).toBe("https://img.example/banner.png");38+ expect(doc.posting_policy).toBe("https://example.com/posting");39+ expect(doc.privacy_policy).toBeUndefined();40+ expect(doc.tags).toEqual(["bitcoin", "cats"]);41+ expect(doc.language_tags).toEqual(["en", "pt-BR", "zh-Hant-TW"]);42+ expect(doc.relay_countries).toEqual(["US", "DE", "FR"]);43+44+ // Clearing a link removes it; a list capped at twenty.45+ await rpc(host, owner, "setpolicy", { postingPolicy: "", tags: Array.from({ length: 30 }, (_, i) => "t" + i) });46+ doc = await info(host);47+ expect(doc.posting_policy).toBeUndefined();48+ expect(doc.tags.length).toBe(20);49+ });50+51+ it("serves the join terms as the terms of service page", async () => {52+ const host = "terms.bind.ws";53+ const owner = generateSecretKey();54+ await rpc(host, owner, "claim");55+ expect((await SELF.fetch(`http://${host}/terms`)).status).toBe(404);56+ expect((await info(host)).terms_of_service).toBeUndefined();57+ await rpc(host, owner, "setpolicy", { joinTerms: "Be kind.\nNo spam <ever>." });58+ expect((await info(host)).terms_of_service).toBe(`https://${host}/terms`);59+ const page = await SELF.fetch(`http://${host}/terms`);60+ expect(page.status).toBe(200);61+ const html = await page.text();62+ expect(html).toContain("Be kind.");63+ expect(html).toContain("No spam <ever>.");64+ });65+66+ it("carries the extras through export and import", async () => {67+ const host = "carry.bind.ws";68+ const owner = generateSecretKey();69+ await rpc(host, owner, "claim");70+ await rpc(host, owner, "setpolicy", { privacyPolicy: "https://example.com/privacy", tags: ["art"], languageTags: ["fr"], relayCountries: ["FR"] });71+ const cfg = (await rpc(host, owner, "exportconfig")).result;72+ expect(cfg.policy.tags).toEqual(["art"]);73+ const other = "carried.bind.ws";74+ await rpc(other, owner, "claim");75+ expect((await rpc(other, owner, "importconfig", cfg)).status).toBe(200);76+ const doc = await info(other);77+ expect(doc.privacy_policy).toBe("https://example.com/privacy");78+ expect(doc.tags).toEqual(["art"]);79+ expect(doc.language_tags).toEqual(["fr"]);80+ expect(doc.relay_countries).toEqual(["FR"]);81+ });82+});