forkrelay leases a new name (chosen or memorable) reserved for a holder, copies the plain members if asked, and has the new object pull this relay with an optional authors and kinds filter, over RPC that HTTP cannot reach. One fork an hour per relay. Nothing new in the protocol: a lease, a pull job, a claim.
- commit
- d302c188a6305554c1983ee5bff493d9d0c29cd8
- parent
- 0b3adcefcdc0dd71124e3982ea58ada7f5e72e56
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 56 | +++++++++++++ |
| M | src/ | 49 | +++++++++++- |
| M | src/ | 2 | + |
| A | test/ | 127 | ++++++++++++++++++++++++++++++ |
@@ -62,6 +62,7 @@ const METHODS = [
6262 "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner", "notifytest",6363 "listdumps", "deletedump", "dumpnow",6464 "removesubtree",65+ "forkrelay",6566 ];6667 6768 // validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.@@ -410,6 +411,61 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
410411 await relay.publishMembership();411412 return reply({ result: { ...s.policy, job } });412413 }414+ case "forkrelay": {415+ // ({name?, holder?, filter?, people?}): lease a new name reserved for416+ // holder (the caller by default), copy this relay into it, hand over.417+ const o = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};418+ const name = typeof o.name === "string" ? o.name.trim().toLowerCase() : "";419+ const holder = typeof o.holder === "string" && o.holder !== "" ? o.holder : caller;420+ if (!hex64(holder)) return reply({ error: "invalid: holder must be a 64 hex pubkey" }, 400);421+ const f = o.filter && typeof o.filter === "object" ? (o.filter as Record<string, unknown>) : {};422+ const filter: { authors?: string[]; kinds?: number[] } = {};423+ if (Array.isArray(f.authors) && f.authors.length) {424+ if (!f.authors.every((a) => hex64(String(a))) || f.authors.length > 50) return reply({ error: "invalid: authors must be up to fifty hex pubkeys" }, 400);425+ filter.authors = [...new Set(f.authors as string[])];426+ }427+ if (Array.isArray(f.kinds) && f.kinds.length) {428+ if (!f.kinds.every((k) => Number.isInteger(k) && (k as number) >= 0 && (k as number) <= 65535) || f.kinds.length > 50) return reply({ error: "invalid: kinds must be up to fifty integers" }, 400);429+ filter.kinds = [...new Set(f.kinds as number[])];430+ }431+ const r = await relay.forkRelay(new URL(req.url).host, { name: name || undefined, holder, filter, people: o.people === true });432+ if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : r.startsWith("error") ? 503 : 409);433+ return reply({ result: { ...r, handover: `Your relay is ready at ${r.console}. Open it and claim it with your key before it expires; the events are copying over now.` } });434+ }435+ case "pullfrom": {436+ // (url): copy what another relay has that this one lacks. Runs in437+ // the background; pullstatus reports on it.438+ const url = str(0).trim();439+ const bad = checkPullURL(url, relay.slug, relay.domain);440+ if (bad) return reply({ error: bad }, 400);441+ const err = await relay.pullStart(url);442+ if (err) return reply({ error: err }, 409);443+ return reply({ result: { started: true, url } });444+ }445+ case "pullstatus":446+ return reply({ result: await relay.pullStatus() });447+ case "listjobs":448+ return reply({ result: await relay.jobs() });449+ case "addjob": {450+ // ({kind, relays, filter?, every?, label?}): a pull or push, once or standing.451+ const r = await relay.addJob(params[0]);452+ if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);453+ return reply({ result: r });454+ }455+ case "removejob":456+ return reply({ result: await relay.removeJob(str(0)) });457+ case "runjob":458+ return reply({ result: await relay.runJob(str(0)) });459+ case "backfill": {460+ // (relays?): fetch the owner's own history from the relays in their461+ // kind 10002 stored here, or from the given list.462+ const given = Array.isArray(params[0]) ? (params[0] as unknown[]).filter((u): u is string => typeof u === "string" && u.trim() !== "") : [];463+ const relays = given.length ? given : relaysFromList(relay, p.owner);464+ if (relays.length === 0) return reply({ error: "invalid: no relay list (kind 10002) is stored here; give relays to fetch from" }, 400);465+ const r = await relay.addJob({ kind: "pull", label: "backfill", relays, filter: { authors: [p.owner] }, every: 0 });466+ if (typeof r === "string") return reply({ error: r }, r.startsWith("invalid") ? 400 : 409);467+ return reply({ result: r });468+ }413469 case "transferowner": {414470 // (pubkey): hands the relay to a member; the old owner stays as a moderator.415471 const pk = str(0);@@ -22,7 +22,8 @@ import { claimFromProfile, nip05Document } from "./nip05.ts";
2222 import { checkInvite, claimInvite, inviteCreator, invitePage, termsPage } from "./invites.ts";2323 import { verifyNIP98 } from "./manage.ts";2424 import { FAVICON_SVG } from "./ui.ts";25-import type { PullJob, PullResult } from "./pull.ts";25+import type { PullFilter, PullJob, PullResult } from "./pull.ts";26+import { leaseDays, leaseNames, validName } from "./names.ts";2627 import { MAX_JOBS, MAX_STANDING, checkJob, finishRun, newJobID, pruneFinished, pullView, runRound, startRun, type Job, type JobSpec } from "./jobs.ts";2728 import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState, isNIP43Request } from "./groups.ts";2829 import { KIND_GROUP_MEMBERS } from "./identity.ts";@@ -69,6 +70,8 @@ const MAX_SYNC = 100_000;
6970 // How long a leased relay keeps everything, so a claim inherits a bounded7071 // window until the owner resets the rules.7172 const LEASE_RETENTION_DAYS = 14;73+// Forks are owner actions, not rate limited at the apex, so one an hour.74+const FORK_INTERVAL = 3600;7275 7376 // ConnState is everything about a websocket that must survive hibernation.7477 interface ConnState {@@ -378,6 +381,50 @@ export class Relay extends DurableObject<Env> {
378381 return "";379382 }380383 384+ // ---- forking: a new name, this relay's events pulled into it ----385+386+ // forkRelay leases a new name, reserved for `holder`, and has it pull387+ // this relay. `people` copies the plain members along. Nothing new in388+ // the protocol: a lease, a pull job, a claim. One fork an hour.389+ async forkRelay(host: string, opts: { name?: string; holder: string; filter?: PullFilter; people?: boolean }): Promise<{ name: string; url: string; console: string; holder: string; expires_at: number } | string> {390+ const t = now();391+ const last = (await this.ctx.storage.get<number>("lastFork")) ?? 0;392+ if (t - last < FORK_INTERVAL) return `restricted: one fork an hour; the last was ${Math.ceil((t - last) / 60)} minutes ago`;393+ const suffix = host.startsWith(this.slug + ".") ? host.slice(this.slug.length) : "." + this.env.DOMAIN;394+ const secure = this.relayURL(host).startsWith("wss");395+ const until = t + leaseDays(this.env) * 86400;396+ if (opts.name && (!validName(opts.name) || opts.name === this.slug)) return "invalid: that is not a usable name";397+ const candidates = opts.name ? [opts.name] : [...leaseNames()];398+ for (const name of candidates) {399+ const newHost = name + suffix;400+ const stub = this.relays.getByName(name);401+ const err = await stub.lease(name, newHost, until, opts.holder);402+ if (err) {403+ if (opts.name) return "restricted: that name is taken";404+ continue;405+ }406+ const people = opts.people ? this.settings.members().filter((m) => m.role === "member").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note })) : [];407+ const adopted = await stub.adoptFrom(this.relayURL(host), opts.filter ?? {}, people, host);408+ if (adopted) return adopted;409+ await this.ctx.storage.put("lastFork", t);410+ return { name, url: (secure ? "wss://" : "ws://") + newHost, console: (secure ? "https://" : "http://") + newHost + "/", holder: opts.holder, expires_at: until };411+ }412+ return "error: no free name found, try again";413+ }414+415+ // adoptFrom is the forked side, reached over RPC only: a fresh lease416+ // takes the people and starts pulling from the source.417+ async adoptFrom(sourceURL: string, filter: PullFilter, people: { pubkey: string; name: string | null; note: string }[], sourceHost: string): Promise<string> {418+ if (!this.settings.isLeased()) return "restricted: only a fresh lease can adopt";419+ if (this.store.stats().events > 0) return "restricted: this lease already holds events";420+ const day = new Date((this.settings.policy.lease?.until ?? 0) * 1000).toISOString().slice(0, 10);421+ this.settings.update({ description: `Forked from ${sourceHost}. Temporary until ${day} unless claimed; then everything on it is deleted. Claim it to keep it.` });422+ const t = now();423+ for (const m of people) this.settings.upsertMember(m.pubkey, { name: m.name, note: m.note, via: "forked" }, t, true);424+ const r = await this.addChecked({ kind: "pull", label: "pull", relays: [sourceURL], filter, every: 0, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null });425+ return typeof r === "string" ? r : "";426+ }427+381428 // ---- jobs: pulls, backfills, rebroadcasts, once or standing ----382429 383430 // jobs is the persisted list. A job left from before the list existed@@ -16,6 +16,7 @@ export type Action =
1616 | "storage"1717 | "config" // export and import1818 | "jobs" // pulls, backfills, rebroadcasts19+ | "fork" // lease a new name and copy this relay into it1920 | "transfer"2021 | "deleteRelay";2122 @@ -52,5 +53,6 @@ export const METHOD_ACTIONS: Record<string, Action> = {
5253 pullfrom: "jobs", pullstatus: "jobs", listjobs: "jobs", addjob: "jobs", removejob: "jobs", runjob: "jobs", backfill: "jobs",5354 transferowner: "transfer",5455 notifytest: "identity",56+ forkrelay: "fork",5557 deleterelay: "deleteRelay",5658 };@@ -0,0 +1,127 @@
1+// Fork a name: lease a new name reserved for a key, pull this relay into2+// it, hand over the claim. Built from lease and jobs.3+import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6+import { getToken } from "nostr-tools/nip98";7+import type { Relay } from "../src/relay.ts";8+import { ADJECTIVES, ANIMALS } from "../src/names.ts";9+10+const now = () => Math.floor(Date.now() / 1000);11+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12+13+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14+ const url = `http://${host}/`;15+ const payload = { method, params };16+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18+ return { status: resp.status, ...(await resp.json<any>()) };19+}20+21+async function post(host: string, sk: Uint8Array, path: string, body: unknown) {22+ const url = `http://${host}${path}`;23+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>);24+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: token }, body: JSON.stringify(body) });25+ return { status: resp.status, body: await resp.json<any>() };26+}27+28+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();29+30+// settle drives the new object's alarm until its pull has finished.31+async function settle(name: string) {32+ const stub = env.RELAY.getByName(name);33+ for (let i = 0; i < 40; i++) {34+ const done = await runInDurableObject(stub, async (r: Relay) => {35+ await r.alarm();36+ const jobs = await r.jobs();37+ return jobs.length > 0 && jobs.every((j) => !j.running && j.nextRun === 0);38+ });39+ if (done) return;40+ await new Promise((r) => setTimeout(r, 25));41+ }42+ throw new Error("the fork did not finish pulling");43+}44+45+async function seed(host: string) {46+ const owner = generateSecretKey();47+ const friend = generateSecretKey();48+ const member = generateSecretKey();49+ await rpc(host, owner, "claim");50+ await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob", note: "a friend" });51+ const e1 = ev(owner, 1, "mine", [], now() - 30);52+ const e2 = ev(friend, 1, "theirs", [], now() - 20);53+ const e3 = ev(owner, 30023, "an article", [["d", "a"]], now() - 10);54+ for (const e of [e1, e2, e3]) expect((await post(host, e.pubkey === getPublicKey(owner) ? owner : friend, "/events", e)).body.accepted).toBe(true);55+ return { owner, friend, member, e1, e2, e3 };56+}57+58+describe("fork a name", () => {59+ it("leases a chosen name for the caller, pulls everything and the people, and a claim converts it", async () => {60+ const host = "origin.bind.ws";61+ const { owner, member, e1, e2, e3 } = await seed(host);62+ const r = await rpc(host, owner, "forkrelay", { name: "offshoot", people: true });63+ expect(r.status, JSON.stringify(r)).toBe(200);64+ expect(r.result.name).toBe("offshoot");65+ expect(r.result.url).toBe("wss://offshoot.bind.ws");66+ expect(r.result.holder).toBe(getPublicKey(owner));67+ expect(r.result.expires_at).toBeGreaterThan(now() + 13 * 86400);68+ expect(r.result.handover).toMatch(/offshoot\.bind\.ws/);69+70+ const doc = await info("offshoot.bind.ws");71+ expect(doc.lease.holder).toBe(getPublicKey(owner));72+ expect(doc.description).toMatch(/^Forked from origin\.bind\.ws/);73+ await settle("offshoot");74+ const q = await post("offshoot.bind.ws", owner, "/query", [{ kinds: [1, 30023] }]);75+ expect(q.body.map((e: Event) => e.id).sort()).toEqual([e1.id, e2.id, e3.id].sort());76+77+ // Reserved: a stranger cannot claim it; the holder can, and the people came along.78+ expect((await rpc("offshoot.bind.ws", generateSecretKey(), "claim")).status).toBe(403);79+ expect((await rpc("offshoot.bind.ws", owner, "claim")).result.converted).toBe(true);80+ const people = (await rpc("offshoot.bind.ws", owner, "listmembers")).result.members.filter((m: any) => m.role !== "owner");81+ expect(people.map((m: any) => m.pubkey)).toEqual([getPublicKey(member)]);82+ expect((await info("offshoot.bind.ws")).pubkey).toBe(getPublicKey(owner));83+ });84+85+ it("picks a memorable name, hands it to another key, and respects the filter", async () => {86+ const host = "origin2.bind.ws";87+ const { owner, e1, e3, member } = await seed(host);88+ const heir = generateSecretKey();89+ const r = await rpc(host, owner, "forkrelay", { holder: getPublicKey(heir), filter: { authors: [getPublicKey(owner)] } });90+ expect(r.status, JSON.stringify(r)).toBe(200);91+ const [adj, animal] = r.result.name.split("-");92+ if (animal) expect(ADJECTIVES.includes(adj) && ANIMALS.includes(animal)).toBe(true);93+ else expect(ANIMALS.includes(r.result.name.slice(0, -2))).toBe(true);94+ expect(r.result.holder).toBe(getPublicKey(heir));95+ const h = r.result.name + ".bind.ws";96+ await settle(r.result.name);97+ const q = await post(h, heir, "/query", [{ kinds: [1, 30023] }]);98+ expect(q.body.map((e: Event) => e.id).sort()).toEqual([e1.id, e3.id].sort());99+ // No people asked for, none copied.100+ expect((await rpc(h, owner, "claim")).status).toBe(403);101+ expect((await rpc(h, heir, "claim")).result.claimed).toBe(true);102+ const lm = await rpc(h, heir, "listmembers"); expect(lm.status, JSON.stringify(lm)).toBe(200); expect(lm.result.members.map((m: any) => m.pubkey)).not.toContain(getPublicKey(member));103+ });104+105+ it("refuses a taken name, a bad name, a second fork within the hour, and anyone but the owner", async () => {106+ const host = "origin3.bind.ws";107+ const { owner, member } = await seed(host);108+ const taken = generateSecretKey();109+ await rpc("taken.bind.ws", taken, "claim");110+ let r = await rpc(host, owner, "forkrelay", { name: "taken" });111+ expect(r.status).toBe(409);112+ expect(r.error).toMatch(/taken/);113+ r = await rpc(host, owner, "forkrelay", { name: "no way" });114+ expect(r.status).toBe(400);115+ r = await rpc(host, owner, "forkrelay", { name: "origin3" });116+ expect(r.status).toBe(400);117+ // Refusals do not spend the hourly fork.118+ r = await rpc(host, owner, "forkrelay", { name: "twig" });119+ expect(r.status, JSON.stringify(r)).toBe(200);120+ r = await rpc(host, owner, "forkrelay", { name: "twig2" });121+ expect(r.status).toBe(409);122+ expect(r.error).toMatch(/one fork an hour/);123+ expect((await info("twig2.bind.ws")).lease).toBeUndefined();124+ await rpc(host, owner, "setmember", getPublicKey(member), { role: "moderator" });125+ expect((await rpc(host, member, "forkrelay", { name: "twig3" })).status).toBe(403);126+ });127+});