clone

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

relay: succession, an heir and a dead-man's switch

Dami committed a8539b2
The owner's presence is recorded on signed actions, at most hourly. With an
heir named, silence past the chosen delay starts a month of weekly warnings
and a succession_pending date in NIP-11; silence through the month hands
the relay to the heir by the transfer path, notifies both, and logs it. Any
owner action calls the warning off. setsuccession, clearsuccession and
successionstatus; the heir may read the status.
commit
a8539b26aae7a4daf4cc34d0bdaefec30ba092ea
parent
2fa662cf91a98d6c9252c0e3a743bf22323dd743
author
Dami <dami.etoile@gmail.com>
7 files changed, +401 -14
Msrc/manage.ts23+++
Msrc/notify.ts16+-
Msrc/relay.ts116+++++++++++++++-
Msrc/roles.ts2+-
Msrc/settings.ts31++++-
Atest/succession.test.ts223++++++++++++++++++++++++++++++
Mtest/web.test.ts4+-
M src/manage.ts +23 -0
@@ -63,6 +63,7 @@ const METHODS = [
6363 "listdumps", "deletedump", "dumpnow",6464 "removesubtree",6565 "forkrelay",66+ "setsuccession", "clearsuccession", "successionstatus",6667 ];6768 6869 // validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.
@@ -101,18 +102,22 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
101102 if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);102103 s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });103104 s.upsertMember(caller, { via: "claimed" }, t);105+ await relay.ownerSeenNow();104106 await relay.publishMembership();105107 return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });106108 }107109 return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);108110 }109111 if (method === "supportedmethods") return reply({ result: METHODS });112+ // The heir may read the succession status: a member, with no console otherwise.113+ if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.successionStatus() });110114 // One permission table for the console and for NIP-29 (roles.ts).111115 const role = s.roleOf(caller);112116 const action = METHOD_ACTIONS[method];113117 // A plain member reaches their own invites when the owner opened the114118 // invite tree (memberInvites); the cases below keep them to their own.115119 const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");120+ if (role === "owner") void relay.ownerSeen(caller);116121 if (!ownInvites && (!action || !can(role, action))) {117122 const why = role === "moderator" ? "restricted: moderators cannot do that" : p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed";118123 return reply({ error: why }, 403);
@@ -472,6 +477,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
472477 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);473478 const err = s.transferOwner(pk);474479 if (err) return reply({ error: err }, 400);480+ await relay.ownerSeenNow();475481 await relay.publishMembership({ pubkey: pk }, { pubkey: caller });476482 return reply({ result: { owner: pk, previous: caller } });477483 }
@@ -484,6 +490,23 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
484490 }485491 case "dumpnow":486492 return reply({ result: await writeDump(relay, t) });493+ case "setsuccession": {494+ // ({heir, afterDays}) or (heir, afterDays): the heir must be a member.495+ const o = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : { heir: str(0), afterDays: num(1) };496+ const heir = typeof o.heir === "string" ? o.heir : "";497+ if (!hex64(heir)) return reply({ error: "invalid: heir must be a 64 hex pubkey" }, 400);498+ const err = s.setSuccession(heir, Number(o.afterDays));499+ if (err) return reply({ error: err }, 400);500+ await relay.ownerSeenNow();501+ return reply({ result: await relay.successionStatus() });502+ }503+ case "clearsuccession":504+ s.update({ succession: null });505+ await relay.ownerSeenNow();506+ return reply({ result: true });507+ case "successionstatus":508+ if (role !== "owner") return reply({ error: "restricted: only the owner and the heir may see this" }, 403);509+ return reply({ result: await relay.successionStatus() });487510 case "changerelayname":488511 s.update({ name: str(0).slice(0, 200) });489512 await relay.publishMembership();
M src/notify.ts +9 -7
@@ -8,11 +8,12 @@ import { dial } from "./pull.ts";
88 import type { Relay } from "./relay.ts";99 import type { FuelStatus } from "./fuel.ts";1010 11-export type NotifyKind = "reports" | "fuel" | "jobs" | "test";11+export type NotifyKind = "reports" | "fuel" | "jobs" | "succession" | "test";1212 export interface NotifySettings {1313 reports: boolean;1414 fuel: boolean;1515 jobs: boolean;16+ succession: boolean; // the dead-man's switch warnings and the handover1617 }1718 1819 const KIND_DM = 14;
@@ -28,19 +29,20 @@ export function notifySettings(raw: unknown, cur: NotifySettings): NotifySetting
2829 if (!raw || typeof raw !== "object") return null;2930 const r = raw as Record<string, unknown>;3031 const pick = (k: keyof NotifySettings) => (typeof r[k] === "boolean" ? (r[k] as boolean) : cur[k]);31- return { reports: pick("reports"), fuel: pick("fuel"), jobs: pick("jobs") };32+ return { reports: pick("reports"), fuel: pick("fuel"), jobs: pick("jobs"), succession: pick("succession") };3233 }3334 34-// notify sends the owner a message of the given kind if that kind is35-// switched on ("test" always sends). Returns whether a wrap was stored. A36-// leased relay has no owner to write to.37-export async function notify(relay: Relay, kind: NotifyKind, text: string, subject = "your relay"): Promise<boolean> {35+// notify sends the owner (or `to`, a member the relay may address) a message36+// of the given kind if that kind is switched on ("test" always sends).37+// Returns whether a wrap was stored. A leased relay has no owner to write to.38+export async function notify(relay: Relay, kind: NotifyKind, text: string, subject = "your relay", to = ""): Promise<boolean> {3839 const p = relay.settings.policy;3940 if (p.owner === "" || relay.settings.isLeased()) return false;4041 if (kind !== "test" && !p.notify[kind]) return false;42+ const recipient = to || p.owner;4143 await relay.identity.ensure();4244 const t = now();43- const wrap = relay.identity.wrap({ kind: KIND_DM, content: text, tags: [["p", p.owner], ["subject", subject]], created_at: t }, p.owner);45+ const wrap = relay.identity.wrap({ kind: KIND_DM, content: text, tags: [["p", recipient], ["subject", subject]], created_at: t }, recipient);4446 const err = relay.store.save(wrap, t);4547 if (err) return false;4648 relay.broadcast(wrap);
M src/relay.ts +115 -1
@@ -8,7 +8,7 @@ import { match, parseFilter, type Filter } from "./filter.ts";
88 import { hllOffset } from "./hll.ts";99 import { Negentropy, bytesToHex, hexToBytes } from "./negentropy.ts";1010 import { ERR_DUPLICATE, ERR_TOO_BIG, Store, type Access } from "./store.ts";11-import { Settings, isReplaceable, isProtected } from "./settings.ts";11+import { Settings, isReplaceable, isProtected, SUCCESSION_WARN_DAYS } from "./settings.ts";1212 import { manage } from "./manage.ts";1313 import { dashboard } from "./dashboard.ts";1414 import { Fuel, fuelConfig, fetchLnurl, requestInvoice, type Fetcher, type LnurlParams } from "./fuel.ts";
@@ -82,6 +82,17 @@ interface ConnState {
8282 subs: Record<string, Filter[]>;8383 }8484 85+// Succession bookkeeping, kept in storage next to the policy.86+interface SuccessionWarn {87+ since: number;88+ lastNotified: number;89+}90+interface SuccessionLog {91+ at: number;92+ from: string;93+ to: string;94+}95+8596 const FLUSH_BYTES = 256 * 1024;8697 // An address gets this many times a connection's per-minute allowance, so a8798 // few tabs from one place are fine and a swarm of sockets is not.
@@ -113,6 +124,10 @@ export class Relay extends DurableObject<Env> {
113124 private lnurl: LnurlParams | null = null;114125 // Outbound HTTP to the lightning provider; tests substitute a fake.115126 fetcher: Fetcher = (u, i) => fetch(u, i);127+ // Succession: a warning month in flight, mirrored from storage so NIP-11128+ // can say so without a read.129+ private successionWarn: SuccessionWarn | null = null;130+ private ownerSeenWrite = 0; // ms; the presence write happens at most hourly116131 // A job round in flight, so overlapping alarms do not run two.117132 private working = false;118133
@@ -128,6 +143,7 @@ export class Relay extends DurableObject<Env> {
128143 this.fuel.init();129144 this.slug = (await ctx.storage.get<string>("slug")) ?? "";130145 this.lnurl = (await ctx.storage.get<LnurlParams>("lnurl")) ?? null;146+ this.successionWarn = (await ctx.storage.get<SuccessionWarn>("succession_warn")) ?? null;131147 await this.identity.load();132148 });133149 }
@@ -265,6 +281,7 @@ export class Relay extends DurableObject<Env> {
265281 266282 // virtualConn represents an HTTP caller as if it had authenticated over the socket.267283 virtualConn(host: string, pubkey: string, ip = "unknown"): ConnState {284+ void this.ownerSeen(pubkey);268285 return { challenge: "", host, authed: [pubkey], subs: {}, ip };269286 }270287
@@ -356,6 +373,98 @@ export class Relay extends DurableObject<Env> {
356373 return this.sql.exec<Blob>(`SELECT * FROM blobs ORDER BY uploaded`).toArray();357374 }358375 376+ // ---- owner presence and succession ----377+378+ // ownerSeen records that the owner acted, at most once an hour, and calls379+ // off a succession warning in flight. Anyone else is ignored.380+ async ownerSeen(pubkey: string) {381+ if (!this.settings.isOwner(pubkey)) return;382+ const ms = Date.now();383+ if (ms - this.ownerSeenWrite < 3600_000) return;384+ this.ownerSeenWrite = ms;385+ await this.ctx.storage.put("ownerSeenAt", now());386+ if (this.successionWarn) {387+ this.successionWarn = null;388+ await this.ctx.storage.delete("succession_warn");389+ }390+ }391+392+ // ownerSeenNow starts the clock afresh: claim, transfer, naming an heir.393+ async ownerSeenNow() {394+ this.ownerSeenWrite = Date.now();395+ await this.ctx.storage.put("ownerSeenAt", now());396+ this.successionWarn = null;397+ await this.ctx.storage.delete("succession_warn");398+ }399+400+ async successionStatus() {401+ const t = now();402+ const seen = (await this.ctx.storage.get<number>("ownerSeenAt")) ?? 0;403+ const log = (await this.ctx.storage.get<SuccessionLog[]>("succession_log")) ?? [];404+ const sc = this.settings.policy.succession;405+ const from = seen || t;406+ const handoverAt = !sc ? 0 : this.successionWarn ? this.successionWarn.since + SUCCESSION_WARN_DAYS * 86400 : from + (sc.afterDays + SUCCESSION_WARN_DAYS) * 86400;407+ return { succession: sc, ownerSeenAt: seen, silentDays: seen ? Math.floor((t - seen) / 86400) : 0, warning: this.successionWarn, handoverAt, log };408+ }409+410+ // successionTick runs from the daily alarm. Silence past the delay starts411+ // a month of weekly warnings; silence through the month hands the relay412+ // to the heir. Any owner action in between calls it off (ownerSeen).413+ private async successionTick(t: number) {414+ const sc = this.settings.policy.succession;415+ const dropWarning = async () => {416+ if (!this.successionWarn) return;417+ this.successionWarn = null;418+ await this.ctx.storage.delete("succession_warn");419+ };420+ if (!sc || this.settings.policy.owner === "" || this.settings.isLeased()) return dropWarning();421+ const subject = "succession on " + this.slug;422+ if (!this.settings.member(sc.heir)) {423+ // The heir left. Nobody to hand to; say so and stop.424+ this.settings.update({ succession: null });425+ await dropWarning();426+ await notify(this, "succession", `Your heir ${sc.heir.slice(0, 8)} is no longer a member of ${this.slug}, so the handover plan is off. Name another heir if you still want one.`, subject);427+ return;428+ }429+ let seen = (await this.ctx.storage.get<number>("ownerSeenAt")) ?? 0;430+ if (seen === 0) {431+ // Relays from before presence was recorded start the clock today.432+ seen = t;433+ await this.ctx.storage.put("ownerSeenAt", t);434+ }435+ if (t - seen < sc.afterDays * 86400) return dropWarning();436+ const day = (at: number) => new Date(at * 1000).toISOString().slice(0, 10);437+ if (!this.successionWarn) {438+ this.successionWarn = { since: t, lastNotified: t };439+ await this.ctx.storage.put("succession_warn", this.successionWarn);440+ await notify(this, "succession", `You have not signed in to ${this.slug} for ${Math.floor((t - seen) / 86400)} days. Unless you do, it goes to your heir ${sc.heir.slice(0, 8)} on ${day(t + SUCCESSION_WARN_DAYS * 86400)}. Any signed action on the relay calls this off.`, subject);441+ return;442+ }443+ if (t - this.successionWarn.since < SUCCESSION_WARN_DAYS * 86400) {444+ if (t - this.successionWarn.lastNotified >= 7 * 86400) {445+ this.successionWarn = { ...this.successionWarn, lastNotified: t };446+ await this.ctx.storage.put("succession_warn", this.successionWarn);447+ await notify(this, "succession", `Still no sign of you on ${this.slug}. It goes to ${sc.heir.slice(0, 8)} on ${day(this.successionWarn.since + SUCCESSION_WARN_DAYS * 86400)} unless you sign in.`, subject);448+ }449+ return;450+ }451+ // The month is up: hand over.452+ const old = this.settings.policy.owner;453+ const err = this.settings.transferOwner(sc.heir);454+ if (err) {455+ this.settings.update({ succession: null });456+ await dropWarning();457+ await notify(this, "succession", `The handover of ${this.slug} to ${sc.heir.slice(0, 8)} could not happen: ${err}. The plan is off.`, subject);458+ return;459+ }460+ const log = [...((await this.ctx.storage.get<SuccessionLog[]>("succession_log")) ?? []), { at: t, from: old, to: sc.heir }].slice(-10);461+ await this.ctx.storage.put("succession_log", log);462+ await this.ownerSeenNow();463+ await this.publishMembership({ pubkey: sc.heir }, { pubkey: old });464+ await notify(this, "succession", `${this.slug} now belongs to ${sc.heir.slice(0, 8)}, as you planned. You stay on as a moderator.`, subject, old);465+ await notify(this, "succession", `${this.slug} is yours now. Its owner named you heir and has been away for ${sc.afterDays + SUCCESSION_WARN_DAYS} days. Open https://${this.slug}.${this.domain}/ and sign in.`, subject, sc.heir);466+ }467+359468 // ---- temporary leases ----360469 361470 // lease turns an unclaimed relay into a temporary one: open to everyone,
@@ -790,6 +899,7 @@ export class Relay extends DurableObject<Env> {
790899 doc.payments_url = "https://" + host + "/";791900 }792901 if (p.owner) doc.pubkey = p.owner;902+ if (p.succession && this.successionWarn) doc.succession_pending = new Date((this.successionWarn.since + SUCCESSION_WARN_DAYS * 86400) * 1000).toISOString().slice(0, 10);793903 if (this.settings.isLeased() && p.lease) doc.lease = { expires_at: p.lease.until, holder: p.lease.holder || undefined, claim_url: host ? "https://" + host + "/" : undefined };794904 if (host) doc.self_url = this.relayURL(host);795905 if (this.identity.pubkey) {
@@ -1071,6 +1181,7 @@ export class Relay extends DurableObject<Env> {
10711181 if (exp > 0) this.scheduleSweep(exp);10721182 else this.ensureAlarm();10731183 if (e.kind === 0 && conn) claimFromProfile(this.settings, e.content, e.pubkey, conn.host, t);1184+ if (conn) void this.ownerSeen(e.pubkey);10741185 return { ok: true, msg: "", stored: true };10751186 }10761187
@@ -1105,6 +1216,8 @@ export class Relay extends DurableObject<Env> {
11051216 await notify(this, "fuel", fuelText(this, this.fuelStatus()), "fuel on " + this.slug);11061217 } else if (!low && last) await this.ctx.storage.delete("fuel-low-at");11071218 }1219+ // Succession: the dead-man's switch, checked once a day.1220+ await this.successionTick(t);11081221 const next = this.store.sweepExpired(t);11091222 // ---- dumps: a scheduled JSONL of everything, into R2 (dumps.ts) ----11101223 if (dumpDue(this, t)) {
@@ -1257,6 +1370,7 @@ export class Relay extends DurableObject<Env> {
12571370 if (tagValues(e, "challenge")[0] !== s.challenge) return fail("invalid: auth challenge does not match");12581371 if (hostOf(tagValues(e, "relay")[0] ?? "") !== hostOf("ws://" + s.host)) return fail("invalid: auth relay tag does not name this relay");12591372 if (!s.authed.includes(e.pubkey)) s.authed.push(e.pubkey);1373+ void this.ownerSeen(e.pubkey);12601374 this.persist(ws, s);12611375 this.send(ws, "OK", e.id, true, "");12621376 }
M src/roles.ts +1 -1
M src/settings.ts +28 -3
@@ -10,8 +10,20 @@ export interface Lease {
1010 holder: string;1111 }1212 13+// Succession is the dead-man's switch: when the owner has not signed in for14+// `afterDays`, the relay warns them for a month, then hands itself to the15+// heir, who must be a member.16+export interface Succession {17+ heir: string;18+ afterDays: number;19+}20+export const SUCCESSION_DAYS = [90, 180, 365];21+// How long the warnings run before the handover.22+export const SUCCESSION_WARN_DAYS = 30;23+1324 export interface Policy {1425 owner: string; // pubkey; "" while unclaimed26+ succession: Succession | null; // off until the owner names an heir1527 lease: Lease | null; // set while the relay is a temporary lease1628 name: string;1729 description: string;
@@ -59,7 +71,8 @@ export const DEFAULT_POLICY: Policy = {
5971 tags: [],6072 languageTags: [],6173 relayCountries: [],62- notify: { reports: false, fuel: false, jobs: false },74+ notify: { reports: false, fuel: false, jobs: false, succession: false },75+ succession: null,6376 writes: "open",6477 reads: "open",6578 joinTerms: "",
@@ -294,11 +307,23 @@ export class Settings {
294307 }295308 // transferOwner hands the relay to a member. The old owner stays on as a296309 // moderator so nobody is locked out. Returns "" or a reason.310+ // setSuccession names an heir, who must be a member, and switches the311+ // warnings on. Returns "" or a reason.312+ setSuccession(heir: string, afterDays: number): string {313+ if (this.policy.owner === "") return "restricted: this relay has no owner";314+ if (heir === this.policy.owner) return "invalid: the heir must be someone else";315+ if (!this.member(heir)) return "invalid: the heir must be a member first";316+ if (!SUCCESSION_DAYS.includes(afterDays)) return "invalid: afterDays must be one of " + SUCCESSION_DAYS.join(", ");317+ this.update({ succession: { heir, afterDays }, notify: { ...this.policy.notify, succession: true } });318+ return "";319+ }320+297321 transferOwner(pubkey: string): string {298322 const old = this.policy.owner;299323 if (old === "" || pubkey === old) return "invalid: that is already the owner";300324 if (!this.member(pubkey)) return "invalid: the new owner must be a member first";301- this.update({ owner: pubkey });325+ // A handover, by whatever route, ends the succession set for the old owner.326+ this.update({ owner: pubkey, succession: null });302327 this.sql.exec(`UPDATE members SET role='moderator' WHERE pubkey=?`, old);303328 this.sql.exec(`UPDATE members SET role='owner' WHERE pubkey=?`, pubkey);304329 return "";
@@ -397,7 +422,7 @@ export class Settings {
397422 format: "bind.ws/relay-config/1",398423 exported_at: Math.floor(Date.now() / 1000),399424 name,400- policy: { ...this.policy, owner: undefined, lease: undefined },425+ policy: { ...this.policy, owner: undefined, lease: undefined, succession: undefined },401426 members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note, ...(m.role === "moderator" ? { role: "moderator" } : {}), ...(m.keep_days ? { keepDays: m.keep_days } : {}), ...(m.max_bytes ? { maxBytes: m.max_bytes } : {}) })),402427 bans: this.listBans(),403428 banned_events: this.listEvents("ban"),
A test/succession.test.ts +223 -0
@@ -0,0 +1,223 @@
1+// Succession: an heir and a dead-man's switch. The owner's presence is2+// recorded on signed actions; silence past the delay starts a month of3+// warnings; silence through the month hands the relay to the heir.4+import { SELF, env, runInDurableObject } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7+import { getToken } from "nostr-tools/nip98";8+import { unwrapEvent } from "nostr-tools/nip59";9+import type { Relay } from "../src/relay.ts";10+11+const now = () => Math.floor(Date.now() / 1000);12+const DAY = 86400;13+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);14+const pk = (sk: Uint8Array) => getPublicKey(sk);15+16+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {17+ const url = `http://${host}/`;18+ const payload = { method, params };19+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);20+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });21+ return { status: resp.status, ...(await resp.json<any>()) };22+}23+24+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();25+26+class WS {27+ private queue: any[][] = [];28+ private waiters: ((m: any[]) => void)[] = [];29+ challenge = "";30+ constructor(public ws: WebSocket) {31+ ws.accept();32+ ws.addEventListener("message", (e) => {33+ const m = JSON.parse(e.data as string);34+ const w = this.waiters.shift();35+ if (w) w(m);36+ else this.queue.push(m);37+ });38+ }39+ static async connect(host: string) {40+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });41+ const c = new WS(resp.webSocket!);42+ c.challenge = (await c.expect("AUTH"))[1];43+ return c;44+ }45+ send(...m: unknown[]) {46+ this.ws.send(JSON.stringify(m));47+ }48+ recv(): Promise<any[]> {49+ const m = this.queue.shift();50+ if (m) return Promise.resolve(m);51+ return new Promise((res) => this.waiters.push(res));52+ }53+ async expect(type: string) {54+ const m = await this.recv();55+ expect(m[0], JSON.stringify(m)).toBe(type);56+ return m;57+ }58+ async auth(sk: Uint8Array, host: string) {59+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));60+ const m = await this.expect("OK");61+ expect(m[2], m[3]).toBe(true);62+ }63+ private n = 0;64+ async req(filter: unknown) {65+ const id = "q" + ++this.n;66+ this.send("REQ", id, filter);67+ const events: Event[] = [];68+ for (;;) {69+ const m = await this.recv();70+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);71+ else if (m[0] === "EOSE" && m[1] === id) {72+ this.send("CLOSE", id);73+ return events;74+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);75+ }76+ }77+}78+79+// inbox reads the wraps addressed to a key and opens them with it.80+async function inbox(host: string, sk: Uint8Array): Promise<string[]> {81+ const c = await WS.connect(host);82+ await c.auth(sk, host);83+ const wraps = await c.req({ kinds: [1059], "#p": [pk(sk)] });84+ return wraps.map((w) => unwrapEvent(w, sk).content);85+}86+87+// silence backdates the owner's last sign-in and holds the hourly presence88+// write so the test's own owner calls do not refresh it.89+async function silence(name: string, days: number) {90+ await runInDurableObject(env.RELAY.getByName(name), async (r: Relay, state) => {91+ await state.storage.put("ownerSeenAt", now() - days * DAY);92+ (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = Date.now();93+ });94+}95+const alarm = (name: string) => runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => r.alarm());96+97+describe("succession", () => {98+ it("names an heir who must be a member, with an allowed delay", async () => {99+ const host = "heirs.bind.ws";100+ const owner = generateSecretKey();101+ const heir = generateSecretKey();102+ await rpc(host, owner, "claim");103+ let r = await rpc(host, owner, "setsuccession", { heir: pk(heir), afterDays: 90 });104+ expect(r.status).toBe(400);105+ expect(r.error).toMatch(/member first/);106+ await rpc(host, owner, "setmember", pk(heir), {});107+ r = await rpc(host, owner, "setsuccession", { heir: pk(heir), afterDays: 10 });108+ expect(r.error).toMatch(/afterDays/);109+ r = await rpc(host, owner, "setsuccession", { heir: pk(heir), afterDays: 90 });110+ expect(r.status).toBe(200);111+ expect(r.result.succession).toEqual({ heir: pk(heir), afterDays: 90 });112+ expect(r.result.warning).toBeNull();113+ expect(r.result.handoverAt).toBeGreaterThan(now() + 119 * DAY);114+ expect((await rpc(host, owner, "getpolicy")).result.notify.succession).toBe(true);115+ // The heir may read the status; a moderator may not; the plan stays out of exports.116+ const mod = generateSecretKey();117+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });118+ expect((await rpc(host, heir, "successionstatus")).result.succession.heir).toBe(pk(heir));119+ expect((await rpc(host, mod, "successionstatus")).status).toBe(403);120+ expect((await rpc(host, owner, "exportconfig")).result.policy.succession).toBeUndefined();121+ expect((await rpc(host, owner, "clearsuccession")).result).toBe(true);122+ expect((await rpc(host, owner, "getpolicy")).result.succession).toBeNull();123+ });124+125+ it("records the owner's presence on signed actions, not a member's", async () => {126+ const host = "seen.bind.ws";127+ const owner = generateSecretKey();128+ const member = generateSecretKey();129+ await rpc(host, owner, "claim");130+ await rpc(host, owner, "setmember", pk(member), {});131+ const seenAt = () => runInDurableObject(env.RELAY.getByName("seen"), async (_r: Relay, state) => (await state.storage.get<number>("ownerSeenAt")) ?? 0);132+ expect(await seenAt()).toBeGreaterThan(now() - 5);133+ const backdate = () => runInDurableObject(env.RELAY.getByName("seen"), async (r: Relay, state) => {134+ await state.storage.put("ownerSeenAt", now() - 5 * DAY);135+ (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = 0;136+ });137+ await backdate();138+ const m = await WS.connect(host);139+ await m.auth(member, host);140+ expect(await seenAt()).toBeLessThan(now() - 4 * DAY);141+ const o = await WS.connect(host);142+ await o.auth(owner, host);143+ expect(await seenAt()).toBeGreaterThan(now() - 5);144+ await backdate();145+ await rpc(host, owner, "stats");146+ expect(await seenAt()).toBeGreaterThan(now() - 5);147+ });148+149+ it("warns after the delay, stops when the owner shows up, and hands over after the month", async () => {150+ const host = "estate.bind.ws";151+ const name = "estate";152+ const owner = generateSecretKey();153+ const heir = generateSecretKey();154+ await rpc(host, owner, "claim");155+ await rpc(host, owner, "setmember", pk(heir), { name: "heir" });156+ await rpc(host, owner, "setsuccession", { heir: pk(heir), afterDays: 90 });157+158+ // Silent for 60 days: nothing happens.159+ await silence(name, 60);160+ await alarm(name);161+ expect((await info(host)).succession_pending).toBeUndefined();162+ // Silent for 91 days: the warning month starts, the owner is told.163+ await silence(name, 91);164+ await alarm(name);165+ let doc = await info(host);166+ expect(doc.succession_pending).toMatch(/^\d{4}-\d{2}-\d{2}$/);167+ let msgs = await inbox(host, owner);168+ expect(msgs.length).toBe(1);169+ expect(msgs[0]).toMatch(/not signed in .* 91 days/);170+ // A second alarm the same week says nothing more.171+ await alarm(name);172+ expect((await inbox(host, owner)).length).toBe(1);173+174+ // The owner acts: the warning is called off.175+ await runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => {176+ (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = 0;177+ });178+ let st = (await rpc(host, owner, "successionstatus")).result;179+ expect(st.warning).toBeNull();180+ expect(st.silentDays).toBe(0);181+ expect((await info(host)).succession_pending).toBeUndefined();182+183+ // Silent again, through the whole month: the relay changes hands.184+ await silence(name, 95);185+ await alarm(name);186+ expect((await rpc(host, heir, "successionstatus")).result.warning).not.toBeNull();187+ await runInDurableObject(env.RELAY.getByName(name), async (r: Relay, state) => {188+ const warn = { since: now() - 31 * DAY, lastNotified: now() - 31 * DAY };189+ await state.storage.put("succession_warn", warn);190+ (r as unknown as { successionWarn: unknown }).successionWarn = warn;191+ });192+ await alarm(name);193+ doc = await info(host);194+ expect(doc.pubkey).toBe(pk(heir));195+ expect(doc.succession_pending).toBeUndefined();196+ const members = (await rpc(host, heir, "listmembers")).result.members as { pubkey: string; role?: string }[];197+ expect(members.find((m) => m.pubkey === pk(owner))?.role).toBe("moderator");198+ expect((await rpc(host, heir, "getpolicy")).result.succession).toBeNull();199+ st = (await rpc(host, heir, "successionstatus")).result;200+ expect(st.log).toEqual([{ at: expect.any(Number), from: pk(owner), to: pk(heir) }]);201+ expect(st.ownerSeenAt).toBeGreaterThan(now() - 5);202+ msgs = await inbox(host, owner);203+ expect(msgs.some((m) => /now belongs to/.test(m))).toBe(true);204+ expect((await inbox(host, heir)).some((m) => /is yours now/.test(m))).toBe(true);205+ // The old owner is a moderator now: no more than that.206+ expect((await rpc(host, owner, "setpolicy", { name: "mine again" })).status).toBe(403);207+ });208+209+ it("drops the plan when the heir is no longer a member", async () => {210+ const host = "orphan.bind.ws";211+ const owner = generateSecretKey();212+ const heir = generateSecretKey();213+ await rpc(host, owner, "claim");214+ await rpc(host, owner, "setmember", pk(heir), {});215+ await rpc(host, owner, "setsuccession", { heir: pk(heir), afterDays: 90 });216+ await rpc(host, owner, "removemember", pk(heir));217+ await silence("orphan", 100);218+ await alarm("orphan");219+ expect((await rpc(host, owner, "getpolicy")).result.succession).toBeNull();220+ expect((await inbox(host, owner)).some((m) => /no longer a member/.test(m))).toBe(true);221+ expect((await info(host)).pubkey).toBe(pk(owner));222+ });223+});
M test/web.test.ts +2 -2