- commit
- a98d1114865508344943a356eef56cc661a7fb04
- parent
- d6bbcaba5a3de3bc81e3cdb952864a6c7f9b8453
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 16 | ++-- |
| M | src/ | 2 | +- |
| M | src/ | 126 | ++++-------------------------- |
| A | src/ | 122 | +++++++++++++++++++++++++++++ |
| M | test/ | 8 | +- |
@@ -136,7 +136,7 @@ export const METHODS: Record<string, Method> = {
136136 if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);137137 s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });138138 s.upsertMember(caller, { via: "claimed" }, t);139- await relay.ownerSeenNow();139+ await relay.succession.seenNow();140140 await relay.publishMembership();141141 return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });142142 }@@ -587,7 +587,7 @@ export const METHODS: Record<string, Method> = {
587587 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);588588 const err = s.transferOwner(pk);589589 if (err) return reply({ error: err }, 400);590- await relay.ownerSeenNow();590+ await relay.succession.seenNow();591591 await relay.publishMembership({ pubkey: pk }, { pubkey: caller });592592 return reply({ result: { owner: pk, previous: caller } });593593 },@@ -611,15 +611,15 @@ export const METHODS: Record<string, Method> = {
611611 if (!hex64(heir)) return reply({ error: "invalid: heir must be a 64 hex pubkey" }, 400);612612 const err = s.setSuccession(heir, Number(o.afterDays));613613 if (err) return reply({ error: err }, 400);614- await relay.ownerSeenNow();615- return reply({ result: await relay.successionStatus() });614+ await relay.succession.seenNow();615+ return reply({ result: await relay.succession.status() });616616 },617617 },618618 clearsuccession: {619619 action: "transfer",620620 run: async ({ relay, s, reply }) => {621621 s.update({ succession: null });622- await relay.ownerSeenNow();622+ await relay.succession.seenNow();623623 return reply({ result: true });624624 },625625 },@@ -627,7 +627,7 @@ export const METHODS: Record<string, Method> = {
627627 action: "read", reads: true,628628 run: async ({ relay, role, reply }) => {629629 if (role !== "owner") return reply({ error: "restricted: only the owner and the heir may see this" }, 403);630- return reply({ result: await relay.successionStatus() });630+ return reply({ result: await relay.succession.status() });631631 },632632 },633633 changerelayname: {@@ -719,11 +719,11 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
719719 const call: Call = { relay, req, s, p, t, caller, role, method, params, str, num, hex64, reply, outranks };720720 if (m.action === "open") return m.run(call);721721 // The heir may read the succession status: a member, with no console otherwise.722- if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.successionStatus() });722+ if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.succession.status() });723723 // A plain member reaches their own invites when the owner opened the724724 // invite tree (memberInvites); the invite methods keep them to their own.725725 const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");726- if (role === "owner") void relay.ownerSeen(caller);726+ if (role === "owner") void relay.succession.seen(caller);727727 if (!ownInvites && !can(role, m.action)) {728728 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";729729 return reply({ error: why }, 403);@@ -136,7 +136,7 @@ export async function digestText(relay: Relay, since: number, t: number): Promis
136136 const f = relay.fuelStatus();137137 const spent = Math.floor(f.chargedMsats / 1000);138138 const balance = Math.floor(f.balanceMsats / 1000);139- const sc = await relay.successionStatus();139+ const sc = await relay.succession.status();140140 const viewRows = await viewRowsSince(relay, since);141141 const lines: string[] = [];142142 if (joined || left) lines.push(`People: ${joined} joined${left ? `, ${left} left` : ""}.`);@@ -12,6 +12,7 @@ import { Audit } from "./audit.ts";
1212 import { ERR_DUPLICATE, ERR_TOO_BIG, Store, type Access } from "./store.ts";1313 import { Settings, isReplaceable, isProtected, SUCCESSION_WARN_DAYS } from "./settings.ts";1414 import { manage } from "./manage.ts";15+import { Succession } from "./succession.ts";1516 import { guestPass, readGate, writeGate } from "./gates.ts";1617 import { dashboard } from "./dashboard.ts";1718 import { Fuel, fuelConfig, fetchLnurl, requestInvoice, type Fetcher, type LnurlParams } from "./fuel.ts";@@ -104,17 +105,6 @@ export interface ConnState {
104105 syncs?: Record<string, Filter>;105106 }106107 107-// Succession bookkeeping, kept in storage next to the policy.108-interface SuccessionWarn {109- since: number;110- lastNotified: number;111-}112-interface SuccessionLog {113- at: number;114- from: string;115- to: string;116-}117-118108 const FLUSH_BYTES = 256 * 1024;119109 // An address gets this many times a connection's per-minute allowance, so a120110 // few tabs from one place are fine and a swarm of sockets is not.@@ -149,10 +139,8 @@ export class Relay extends DurableObject<Env> {
149139 private lnurl: LnurlParams | null = null;150140 // Outbound HTTP to the lightning provider and to Cloudflare; tests substitute a fake.151141 fetcher: Fetcher = (u, i) => fetch(u, i);152- // Succession: a warning month in flight, mirrored from storage so NIP-11153- // can say so without a read.154- private successionWarn: SuccessionWarn | null = null;155- private ownerSeenWrite = 0; // ms; the presence write happens at most hourly142+ // The heir and the dead-man's switch (succession.ts).143+ succession = new Succession(this);156144 // A job round in flight, so overlapping alarms do not run two.157145 private working = false;158146 // Presence (views.ts): who wrote lately, in memory; the sockets are asked@@ -181,7 +169,7 @@ export class Relay extends DurableObject<Env> {
181169 this.fuel.init();182170 this.slug = (await ctx.storage.get<string>("slug")) ?? "";183171 this.lnurl = (await ctx.storage.get<LnurlParams>("lnurl")) ?? null;184- this.successionWarn = (await ctx.storage.get<SuccessionWarn>("succession_warn")) ?? null;172+ await this.succession.load();185173 await this.identity.load();186174 });187175 }@@ -190,6 +178,12 @@ export class Relay extends DurableObject<Env> {
190178 return this.ctx.storage.sql;191179 }192180 181+ // storage is the object's key-value store, for the modules that keep a182+ // little state beside the tables.183+ get storage(): DurableObjectStorage {184+ return this.ctx.storage;185+ }186+193187 get media(): R2Bucket {194188 return this.env.MEDIA;195189 }@@ -494,7 +488,7 @@ export class Relay extends DurableObject<Env> {
494488 495489 // virtualConn represents an HTTP caller as if it had authenticated over the socket.496490 virtualConn(host: string, pubkey: string, ip = "unknown"): ConnState {497- void this.ownerSeen(pubkey);491+ void this.succession.seen(pubkey);498492 return { challenge: "", host, authed: [pubkey], subs: {}, ip };499493 }500494 @@ -594,96 +588,6 @@ export class Relay extends DurableObject<Env> {
594588 595589 // ---- owner presence and succession ----596590 597- // ownerSeen records that the owner acted, at most once an hour, and calls598- // off a succession warning in flight. Anyone else is ignored.599- async ownerSeen(pubkey: string) {600- if (!this.settings.isOwner(pubkey)) return;601- const ms = Date.now();602- if (ms - this.ownerSeenWrite < 3600_000) return;603- this.ownerSeenWrite = ms;604- await this.ctx.storage.put("ownerSeenAt", now());605- if (this.successionWarn) {606- this.successionWarn = null;607- await this.ctx.storage.delete("succession_warn");608- }609- }610-611- // ownerSeenNow starts the clock afresh: claim, transfer, naming an heir.612- async ownerSeenNow() {613- this.ownerSeenWrite = Date.now();614- await this.ctx.storage.put("ownerSeenAt", now());615- this.successionWarn = null;616- await this.ctx.storage.delete("succession_warn");617- }618-619- async successionStatus() {620- const t = now();621- const seen = (await this.ctx.storage.get<number>("ownerSeenAt")) ?? 0;622- const log = (await this.ctx.storage.get<SuccessionLog[]>("succession_log")) ?? [];623- const sc = this.settings.policy.succession;624- const from = seen || t;625- const handoverAt = !sc ? 0 : this.successionWarn ? this.successionWarn.since + SUCCESSION_WARN_DAYS * 86400 : from + (sc.afterDays + SUCCESSION_WARN_DAYS) * 86400;626- return { succession: sc, ownerSeenAt: seen, silentDays: seen ? Math.floor((t - seen) / 86400) : 0, warning: this.successionWarn, handoverAt, log };627- }628-629- // successionTick runs from the daily alarm. Silence past the delay starts630- // a month of weekly warnings; silence through the month hands the relay631- // to the heir. Any owner action in between calls it off (ownerSeen).632- private async successionTick(t: number) {633- const sc = this.settings.policy.succession;634- const dropWarning = async () => {635- if (!this.successionWarn) return;636- this.successionWarn = null;637- await this.ctx.storage.delete("succession_warn");638- };639- if (!sc || this.settings.policy.owner === "" || this.settings.isLeased()) return dropWarning();640- const subject = "succession on " + this.slug;641- if (!this.settings.member(sc.heir)) {642- // The heir left. Nobody to hand to; say so and stop.643- this.settings.update({ succession: null });644- await dropWarning();645- 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);646- return;647- }648- let seen = (await this.ctx.storage.get<number>("ownerSeenAt")) ?? 0;649- if (seen === 0) {650- // Relays from before presence was recorded start the clock today.651- seen = t;652- await this.ctx.storage.put("ownerSeenAt", t);653- }654- if (t - seen < sc.afterDays * 86400) return dropWarning();655- const day = (at: number) => new Date(at * 1000).toISOString().slice(0, 10);656- if (!this.successionWarn) {657- this.successionWarn = { since: t, lastNotified: t };658- await this.ctx.storage.put("succession_warn", this.successionWarn);659- 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);660- return;661- }662- if (t - this.successionWarn.since < SUCCESSION_WARN_DAYS * 86400) {663- if (t - this.successionWarn.lastNotified >= 7 * 86400) {664- this.successionWarn = { ...this.successionWarn, lastNotified: t };665- await this.ctx.storage.put("succession_warn", this.successionWarn);666- 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);667- }668- return;669- }670- // The month is up: hand over.671- const old = this.settings.policy.owner;672- const err = this.settings.transferOwner(sc.heir);673- if (err) {674- this.settings.update({ succession: null });675- await dropWarning();676- await notify(this, "succession", `The handover of ${this.slug} to ${sc.heir.slice(0, 8)} could not happen: ${err}. The plan is off.`, subject);677- return;678- }679- const log = [...((await this.ctx.storage.get<SuccessionLog[]>("succession_log")) ?? []), { at: t, from: old, to: sc.heir }].slice(-10);680- await this.ctx.storage.put("succession_log", log);681- await this.ownerSeenNow();682- await this.publishMembership({ pubkey: sc.heir }, { pubkey: old });683- 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);684- 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);685- }686-687591 // ---- temporary leases ----688592 689593 // lease turns an unclaimed relay into a temporary one: open to everyone,@@ -1147,7 +1051,7 @@ export class Relay extends DurableObject<Env> {
11471051 doc.payments_url = "https://" + host + "/";11481052 }11491053 if (p.owner) doc.pubkey = p.owner;1150- if (p.succession && this.successionWarn) doc.succession_pending = new Date((this.successionWarn.since + SUCCESSION_WARN_DAYS * 86400) * 1000).toISOString().slice(0, 10);1054+ if (p.succession && this.succession.warn) doc.succession_pending = new Date((this.succession.warn.since + SUCCESSION_WARN_DAYS * 86400) * 1000).toISOString().slice(0, 10);11511055 if (this.settings.isLeased() && p.lease) doc.lease = { expires_at: p.lease.until, holder: p.lease.holder || undefined, claim_url: host ? "https://" + host + "/" : undefined };11521056 if (host) doc.self_url = this.relayURL(host);11531057 if (this.identity.pubkey) {@@ -1411,7 +1315,7 @@ export class Relay extends DurableObject<Env> {
14111315 if (exp > 0) this.scheduleSweep(exp);14121316 else this.ensureAlarm();14131317 if (e.kind === 0 && conn) claimFromProfile(this.settings, e.content, e.pubkey, conn.host, t);1414- if (conn) void this.ownerSeen(e.pubkey);1318+ if (conn) void this.succession.seen(e.pubkey);14151319 if (conn) this.notePresence(e.pubkey);14161320 if (e.kind === 30023) this.markView("articles");14171321 return { ok: true, msg: "", stored: true };@@ -1451,7 +1355,7 @@ export class Relay extends DurableObject<Env> {
14511355 } else if (!low && last) await this.ctx.storage.delete("fuel-low-at");14521356 }14531357 // Succession: the dead-man's switch, checked once a day.1454- await this.successionTick(t);1358+ await this.succession.tick(t);14551359 // ---- digest: one message a week on how the relay is doing ----14561360 if (this.settings.policy.notify.digest && this.settings.policy.owner !== "") {14571361 const last = (await this.ctx.storage.get<number>("lastDigest")) ?? 0;@@ -1600,7 +1504,7 @@ export class Relay extends DurableObject<Env> {
16001504 if (tagValues(e, "challenge")[0] !== s.challenge) return fail("invalid: auth challenge does not match");16011505 if (hostOf(tagValues(e, "relay")[0] ?? "") !== hostOf("ws://" + s.host)) return fail("invalid: auth relay tag does not name this relay");16021506 if (!s.authed.includes(e.pubkey)) s.authed.push(e.pubkey);1603- void this.ownerSeen(e.pubkey);1507+ void this.succession.seen(e.pubkey);16041508 this.notePresence();16051509 this.persist(ws, s);16061510 this.send(ws, "OK", e.id, true, "");@@ -0,0 +1,122 @@
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+// weekly warnings; silence through the month hands the relay to the heir.4+// The bookkeeping lives in storage next to the policy; the warning in5+// flight is mirrored here so NIP-11 can say so without a read.6+import type { Relay } from "./relay.ts";7+import { now } from "./event.ts";8+import { SUCCESSION_WARN_DAYS } from "./settings.ts";9+import { notify } from "./notify.ts";10+11+export interface SuccessionWarn {12+ since: number;13+ lastNotified: number;14+}15+export interface SuccessionLog {16+ at: number;17+ from: string;18+ to: string;19+}20+21+export class Succession {22+ // A warning month in flight, or null.23+ warn: SuccessionWarn | null = null;24+ // When the presence write last happened (ms); at most hourly.25+ seenWrite = 0;26+27+ constructor(private relay: Relay) {}28+29+ async load() {30+ this.warn = (await this.relay.storage.get<SuccessionWarn>("succession_warn")) ?? null;31+ }32+33+ // seen records that the owner acted, at most once an hour, and calls34+ // off a succession warning in flight. Anyone else is ignored.35+ async seen(pubkey: string) {36+ if (!this.relay.settings.isOwner(pubkey)) return;37+ const ms = Date.now();38+ if (ms - this.seenWrite < 3600_000) return;39+ this.seenWrite = ms;40+ await this.relay.storage.put("ownerSeenAt", now());41+ if (this.warn) {42+ this.warn = null;43+ await this.relay.storage.delete("succession_warn");44+ }45+ }46+47+ // seenNow starts the clock afresh: claim, transfer, naming an heir.48+ async seenNow() {49+ this.seenWrite = Date.now();50+ await this.relay.storage.put("ownerSeenAt", now());51+ this.warn = null;52+ await this.relay.storage.delete("succession_warn");53+ }54+55+ async status() {56+ const t = now();57+ const seen = (await this.relay.storage.get<number>("ownerSeenAt")) ?? 0;58+ const log = (await this.relay.storage.get<SuccessionLog[]>("succession_log")) ?? [];59+ const sc = this.relay.settings.policy.succession;60+ const from = seen || t;61+ const handoverAt = !sc ? 0 : this.warn ? this.warn.since + SUCCESSION_WARN_DAYS * 86400 : from + (sc.afterDays + SUCCESSION_WARN_DAYS) * 86400;62+ return { succession: sc, ownerSeenAt: seen, silentDays: seen ? Math.floor((t - seen) / 86400) : 0, warning: this.warn, handoverAt, log };63+ }64+65+ // tick runs from the daily alarm. Silence past the delay starts66+ // a month of weekly warnings; silence through the month hands the relay67+ // to the heir. Any owner action in between calls it off (seen).68+ async tick(t: number) {69+ const sc = this.relay.settings.policy.succession;70+ const dropWarning = async () => {71+ if (!this.warn) return;72+ this.warn = null;73+ await this.relay.storage.delete("succession_warn");74+ };75+ if (!sc || this.relay.settings.policy.owner === "" || this.relay.settings.isLeased()) return dropWarning();76+ const subject = "succession on " + this.relay.slug;77+ if (!this.relay.settings.member(sc.heir)) {78+ // The heir left. Nobody to hand to; say so and stop.79+ this.relay.settings.update({ succession: null });80+ await dropWarning();81+ await notify(this.relay, "succession", `Your heir ${sc.heir.slice(0, 8)} is no longer a member of ${this.relay.slug}, so the handover plan is off. Name another heir if you still want one.`, subject);82+ return;83+ }84+ let seen = (await this.relay.storage.get<number>("ownerSeenAt")) ?? 0;85+ if (seen === 0) {86+ // Relays from before presence was recorded start the clock today.87+ seen = t;88+ await this.relay.storage.put("ownerSeenAt", t);89+ }90+ if (t - seen < sc.afterDays * 86400) return dropWarning();91+ const day = (at: number) => new Date(at * 1000).toISOString().slice(0, 10);92+ if (!this.warn) {93+ this.warn = { since: t, lastNotified: t };94+ await this.relay.storage.put("succession_warn", this.warn);95+ await notify(this.relay, "succession", `You have not signed in to ${this.relay.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);96+ return;97+ }98+ if (t - this.warn.since < SUCCESSION_WARN_DAYS * 86400) {99+ if (t - this.warn.lastNotified >= 7 * 86400) {100+ this.warn = { ...this.warn, lastNotified: t };101+ await this.relay.storage.put("succession_warn", this.warn);102+ await notify(this.relay, "succession", `Still no sign of you on ${this.relay.slug}. It goes to ${sc.heir.slice(0, 8)} on ${day(this.warn.since + SUCCESSION_WARN_DAYS * 86400)} unless you sign in.`, subject);103+ }104+ return;105+ }106+ // The month is up: hand over.107+ const old = this.relay.settings.policy.owner;108+ const err = this.relay.settings.transferOwner(sc.heir);109+ if (err) {110+ this.relay.settings.update({ succession: null });111+ await dropWarning();112+ await notify(this.relay, "succession", `The handover of ${this.relay.slug} to ${sc.heir.slice(0, 8)} could not happen: ${err}. The plan is off.`, subject);113+ return;114+ }115+ const log = [...((await this.relay.storage.get<SuccessionLog[]>("succession_log")) ?? []), { at: t, from: old, to: sc.heir }].slice(-10);116+ await this.relay.storage.put("succession_log", log);117+ await this.seenNow();118+ await this.relay.publishMembership({ pubkey: sc.heir }, { pubkey: old });119+ await notify(this.relay, "succession", `${this.relay.slug} now belongs to ${sc.heir.slice(0, 8)}, as you planned. You stay on as a moderator.`, subject, old);120+ await notify(this.relay, "succession", `${this.relay.slug} is yours now. Its owner named you heir and has been away for ${sc.afterDays + SUCCESSION_WARN_DAYS} days. Open https://${this.relay.slug}.${this.relay.domain}/ and sign in.`, subject, sc.heir);121+ }122+}@@ -24,7 +24,7 @@ async function inbox(host: string, sk: Uint8Array): Promise<string[]> {
2424 async function silence(name: string, days: number) {2525 await runInDurableObject(env.RELAY.getByName(name), async (r: Relay, state) => {2626 await state.storage.put("ownerSeenAt", now() - days * DAY);27- (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = Date.now();27+ r.succession.seenWrite = Date.now();2828 });2929 }3030 @@ -66,7 +66,7 @@ describe("succession", () => {
6666 expect(await seenAt()).toBeGreaterThan(now() - 5);6767 const backdate = () => runInDurableObject(env.RELAY.getByName("seen"), async (r: Relay, state) => {6868 await state.storage.put("ownerSeenAt", now() - 5 * DAY);69- (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = 0;69+ r.succession.seenWrite = 0;7070 });7171 await backdate();7272 const m = await WS.connect(host);@@ -107,7 +107,7 @@ describe("succession", () => {
107107 108108 // The owner acts: the warning is called off.109109 await runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => {110- (r as unknown as { ownerSeenWrite: number }).ownerSeenWrite = 0;110+ r.succession.seenWrite = 0;111111 });112112 let st = (await rpc(host, owner, "successionstatus")).result;113113 expect(st.warning).toBeNull();@@ -121,7 +121,7 @@ describe("succession", () => {
121121 await runInDurableObject(env.RELAY.getByName(name), async (r: Relay, state) => {122122 const warn = { since: now() - 31 * DAY, lastNotified: now() - 31 * DAY };123123 await state.storage.put("succession_warn", warn);124- (r as unknown as { successionWarn: unknown }).successionWarn = warn;124+ r.succession.warn = warn;125125 });126126 await alarm(name);127127 doc = await info(host);