invited_by records who minted the invite someone joined with, through the HTTP door and the NIP-29 join alike. memberInvites in policy sets how many hops from the owner the tree reaches and how many live invites a member may hold; off by default. Members under the rule mint, list and revoke their own invites through the same NIP-86 methods and the NIP-29 create-invite event. removesubtree takes a member and everyone they invited, plain members only, and publishes membership once.
- commit
- a1d26f4783d47c65f6e16ae31cee3f8f81f442fe
- parent
- 47e1286375bfd79930e5118577e7c74c6f521fcd
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 15 | +++++-- |
| M | src/ | 22 | ++++++++++ |
| M | src/ | 39 | +++++++++++++++--- |
| M | src/ | 20 | ++++++++- |
| M | src/ | 1 | + |
| M | src/ | 51 | ++++++++++++++++++++++- |
| M | test/ | 66 | ++++++++++++++++++++++++++++++- |
@@ -8,7 +8,7 @@
88 // the write policy: a join request comes from a stranger by definition, and99 // a moderator moderates whatever the write rule says.1010 import { now, tagValues, type Event } from "./event.ts";11-import { claimInvite, mintInvite } from "./invites.ts";11+import { claimInvite, inviteCreator, memberInviteGate, mintInvite } from "./invites.ts";1212 import { KIND_PROFILE, KIND_PUT_USER, KIND_REMOVE_USER, type GroupFacts } from "./identity.ts";1313 import { can, ROLES, type Action } from "./roles.ts";1414 import type { Relay } from "./relay.ts";@@ -77,9 +77,13 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
7777 if (s.isAllowed(e.pubkey)) return no("duplicate: already a member");7878 const code = tagValues(e, "code")[0] ?? "";7979 let via = "";80+ let invitedBy = "";8081 if (code) {8182 const r = claimInvite(relay.sql, code, t);82- if (r === "ok") via = "invite " + code.slice(0, 8);83+ if (r === "ok") {84+ via = "invite " + code.slice(0, 8);85+ invitedBy = inviteCreator(relay.sql, code);86+ }8387 else if (s.policy.writes !== "open") {8488 return no("restricted: " + { invite_invalid: "that invite code is not valid", invite_expired: "that invite has expired", invite_exhausted: "that invite has been used up" }[r]);8589 }@@ -88,7 +92,7 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
8892 if (s.policy.writes !== "open") return no("restricted: this group is closed; join with an invite");8993 via = "join";9094 }91- const err = await relay.setMember(e.pubkey, { via });95+ const err = await relay.setMember(e.pubkey, { via, invitedBy });9296 return err ? no(err) : OK;9397 }9498 case KIND_LEAVE: {@@ -103,7 +107,7 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
103107 if (!claim) return no("restricted: a join request needs a claim tag with an invite code.");104108 const r = claimInvite(relay.sql, claim, t);105109 if (r !== "ok") return no({ invite_invalid: "restricted: that is an invalid invite code.", invite_expired: "restricted: that invite code is expired.", invite_exhausted: "restricted: that invite code has been used up." }[r]);106- const err = await relay.setMember(e.pubkey, { via: "invite " + claim.slice(0, 8) });110+ const err = await relay.setMember(e.pubkey, { via: "invite " + claim.slice(0, 8), invitedBy: inviteCreator(relay.sql, claim) });107111 return err ? no(err) : { ok: true, msg: "info: welcome to " + relay.slug + "!", stored: false };108112 }109113 case KIND_NIP43_LEAVE: {@@ -166,7 +170,8 @@ export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result>
166170 return OK;167171 }168172 case KIND_CREATE_INVITE: {169- const gate = need("invites");173+ // Plain members may mint too when the owner opened the invite tree.174+ const gate = role === "member" ? memberInviteGate(s, relay.sql, e.pubkey, t) : need("invites");170175 if (gate) return no(gate);171176 const r = mintInvite(relay.sql, e.pubkey, 0, 0, e.content, t, tagValues(e, "code")[0] ?? "");172177 return typeof r === "string" ? no(r) : OK;@@ -3,6 +3,7 @@
33 // open to non-members: that is the whole point of an invite.44 import { bytesToHex } from "./negentropy.ts";55 import { page, escapeHTML } from "./ui.ts";6+import type { Settings } from "./settings.ts";67 78 export type Invite = {89 code: string;@@ -39,6 +40,27 @@ export function revokeInvite(sql: SqlStorage, code: string): boolean {
3940 return sql.exec(`DELETE FROM invites WHERE code=?`, code).rowsWritten > 0;4041 }4142 43+// inviteCreator is who minted a code, or "" for an unknown one.44+export function inviteCreator(sql: SqlStorage, code: string): string {45+ return sql.exec<{ created_by: string }>(`SELECT created_by FROM invites WHERE code=?`, code).toArray()[0]?.created_by ?? "";46+}47+48+// liveInvites counts a person's invites that can still be used.49+export function liveInvites(sql: SqlStorage, by: string, now: number): number {50+ return sql.exec<{ n: number }>(`SELECT count(*) AS n FROM invites WHERE created_by=? AND expires_at>=? AND (max_uses=0 OR uses<max_uses)`, by, now).one().n;51+}52+53+// memberInviteGate says whether a plain member may mint an invite under the54+// owner's memberInvites rule: the tree only reaches `depth` hops from the55+// owner, and each member holds at most `quota` live invites. "" allows.56+export function memberInviteGate(s: Settings, sql: SqlStorage, pubkey: string, now: number): string {57+ const mi = s.policy.memberInvites;58+ if (mi.depth <= 0 || mi.quota <= 0) return "restricted: only the owner and moderators mint invites here";59+ if (s.inviteDepth(pubkey) >= mi.depth) return "restricted: invites do not reach this far down the tree";60+ if (liveInvites(sql, pubkey, now) >= mi.quota) return `restricted: you already hold ${mi.quota} live invite${mi.quota === 1 ? "" : "s"}`;61+ return "";62+}63+4264 export type ClaimResult = "ok" | "invite_invalid" | "invite_expired" | "invite_exhausted";4365 4466 // checkInvite validates without consuming; claimInvite consumes one use.@@ -5,7 +5,7 @@ import { sha256 } from "@noble/hashes/sha2.js";
55 import { now, tagValues, validate, type Event } from "./event.ts";66 import { bytesToHex } from "./negentropy.ts";77 import type { Relay } from "./relay.ts";8-import { listInvites, mintInvite, revokeInvite } from "./invites.ts";8+import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";99 import { descriptor, type Blob } from "./blossom.ts";1010 import { isReplaceable, isProtected, publicFields, dumpFields } from "./settings.ts";1111 import { checkPullURL } from "./pull.ts";@@ -61,6 +61,7 @@ const METHODS = [
6161 "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",6262 "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner", "notifytest",6363 "listdumps", "deletedump", "dumpnow",64+ "removesubtree",6465 ];6566 6667 // validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.@@ -108,7 +109,10 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
108109 // One permission table for the console and for NIP-29 (roles.ts).109110 const role = s.roleOf(caller);110111 const action = METHOD_ACTIONS[method];111- if (!action || !can(role, action)) {112+ // A plain member reaches their own invites when the owner opened the113+ // invite tree (memberInvites); the cases below keep them to their own.114+ const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");115+ if (!ownInvites && (!action || !can(role, action))) {112116 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";113117 return reply({ error: why }, 403);114118 }@@ -194,16 +198,37 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
194198 case "listallowedpubkeys":195199 return reply({ result: s.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, reason: m.note })) });196200 case "listmembers":197- case "listpeople":198- return reply({ result: { self: relay.identity.pubkey, members: s.members() } });201+ case "listpeople": {202+ // Each member carries how many live invites they hold, for the tree.203+ const minted = new Map<string, number>();204+ for (const r of relay.sql.exec<{ created_by: string; n: number }>(`SELECT created_by, count(*) AS n FROM invites WHERE expires_at>=? AND (max_uses=0 OR uses<max_uses) GROUP BY created_by`, t)) minted.set(r.created_by, r.n);205+ return reply({ result: { self: relay.identity.pubkey, members: s.members().map((m) => ({ ...m, invites: minted.get(m.pubkey) ?? 0 })) } });206+ }199207 case "createinvite": {208+ if (role === "member") {209+ const gate = memberInviteGate(s, relay.sql, caller, t);210+ if (gate) return reply({ error: gate }, 403);211+ }200212 const inv = mintInvite(relay.sql, caller, num(0) || 0, num(1) || 0, str(2), t);201213 return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: inv });202214 }203- case "listinvites":204- return reply({ result: listInvites(relay.sql, t) });205- case "revokeinvite":215+ case "listinvites": {216+ const all = listInvites(relay.sql, t);217+ return reply({ result: role === "member" ? all.filter((i) => i.created_by === caller) : all });218+ }219+ case "revokeinvite": {220+ if (role === "member" && inviteCreator(relay.sql, str(0)) !== caller) return reply({ error: "restricted: not your invite" }, 403);206221 return reply({ result: revokeInvite(relay.sql, str(0)) });222+ }223+ case "removesubtree": {224+ // (pubkey): the member and everyone they invited, plain members only.225+ const pk = str(0);226+ if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);227+ if (s.isOwner(pk)) return reply({ error: "invalid: cannot remove the owner" }, 400);228+ if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);229+ const removed = await relay.removeSubtree(pk);230+ return reply({ result: { removed } });231+ }207232 case "banevent": {208233 const id = str(0);209234 if (!hex64(id)) return reply({ error: "invalid: id must be 64 hex chars" }, 400);@@ -19,7 +19,7 @@ import { blossom, blobBytes, isBlobPath } from "./blossom.ts";
1919 import { nip96 } from "./nip96.ts";2020 import { dumpBytes, dumpDue, dumpDownload, writeDump } from "./dumps.ts";2121 import { claimFromProfile, nip05Document } from "./nip05.ts";22-import { checkInvite, claimInvite, invitePage, termsPage } from "./invites.ts";22+import { checkInvite, claimInvite, inviteCreator, invitePage, termsPage } from "./invites.ts";2323 import { verifyNIP98 } from "./manage.ts";2424 import { FAVICON_SVG } from "./ui.ts";2525 import type { PullJob, PullResult } from "./pull.ts";@@ -204,7 +204,7 @@ export class Relay extends DurableObject<Env> {
204204 }205205 206206 // setMember edits or adds a person and publishes the roster if membership changed.207- async setMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; keepDays?: number; maxBytes?: number }, force = false): Promise<string> {207+ async setMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; invitedBy?: string; keepDays?: number; maxBytes?: number }, force = false): Promise<string> {208208 const was = this.settings.isAllowed(pubkey);209209 const err = this.settings.upsertMember(pubkey, patch, now(), force);210210 if (err) return err;@@ -219,6 +219,20 @@ export class Relay extends DurableObject<Env> {
219219 return true;220220 }221221 222+ // removeSubtree removes a member and everyone they invited, plain members223+ // only (see Settings.subtree), and publishes membership once for all of224+ // them. Returns who went.225+ async removeSubtree(pubkey: string): Promise<string[]> {226+ const gone: string[] = [];227+ for (const pk of this.settings.subtree(pubkey)) {228+ if (!this.settings.removeMember(pk)) continue;229+ if (this.settings.policy.reads === "members") this.evict(pk, "restricted: this relay only serves its members", false);230+ gone.push(pk);231+ }232+ if (gone.length) await this.publishMembership(...gone.map((pk) => ({ pubkey: pk, added: false })));233+ return gone;234+ }235+222236 async ban(pubkey: string, reason: string): Promise<void> {223237 const was = this.settings.isAllowed(pubkey);224238 this.settings.setBan(pubkey, true, reason, now());@@ -572,7 +586,7 @@ export class Relay extends DurableObject<Env> {
572586 if (this.settings.isAllowed(auth.pubkey)) return json({ status: "already_member", role: this.settings.isOwner(auth.pubkey) ? "owner" : "member" });573587 const r = claimInvite(this.sql, code, now());574588 if (r !== "ok") return json({ error: r }, 403);575- this.settings.upsertMember(auth.pubkey, { via: "invite " + code.slice(0, 8) }, now());589+ this.settings.upsertMember(auth.pubkey, { via: "invite " + code.slice(0, 8), invitedBy: inviteCreator(this.sql, code) }, now());576590 await this.publishMembership({ pubkey: auth.pubkey, added: true });577591 return json({ status: "joined", role: "member" });578592 }@@ -47,6 +47,7 @@ export const METHOD_ACTIONS: Record<string, Action> = {
4747 setpolicy: "rules", allowkind: "rules", disallowkind: "rules", unrulekind: "rules", setretention: "rules", purgekind: "rules", resetrules: "rules", applypreset: "rules",4848 changerelayname: "identity", changerelaydescription: "identity", changerelayicon: "identity",4949 storagestats: "storage", deleteblob: "storage", listdumps: "storage", deletedump: "storage", dumpnow: "storage",50+ removesubtree: "members",5051 exportconfig: "config", importconfig: "config",5152 pullfrom: "jobs", pullstatus: "jobs", listjobs: "jobs", addjob: "jobs", removejob: "jobs", runjob: "jobs", backfill: "jobs",5253 transferowner: "transfer",@@ -40,6 +40,10 @@ export interface Policy {
4040 // kept for `dumpsKeep` runs. Counted as media for fuel.4141 dumps: "off" | "daily" | "weekly";4242 dumpsKeep: number;43+ // Members inviting members: a member whose distance from the owner along44+ // invited_by is below `depth` may hold up to `quota` live invites. depth 045+ // turns it off, which leaves inviting to the owner and moderators.46+ memberInvites: { depth: number; quota: number };4347 }4448 4549 export const DEFAULT_POLICY: Policy = {@@ -69,6 +73,7 @@ export const DEFAULT_POLICY: Policy = {
6973 maxSubs: 20,7074 dumps: "off",7175 dumpsKeep: 7,76+ memberInvites: { depth: 0, quota: 0 },7277 };7378 7479 export const SETTINGS_SCHEMA = `@@ -93,6 +98,7 @@ CREATE TABLE IF NOT EXISTS dumps (name TEXT PRIMARY KEY, bytes INTEGER NOT NULL,
9398 const MEMBER_COLUMNS: [string, string][] = [9499 ["keep_days", "INTEGER NOT NULL DEFAULT 0"], // per-member keep-for; 0 = the relay's rules95100 ["max_bytes", "INTEGER NOT NULL DEFAULT 0"], // per-member storage cap; 0 = unlimited101+ ["invited_by", "TEXT NOT NULL DEFAULT ''"], // who minted the invite they joined with96102 ];97103 function ensureColumns(sql: SqlStorage, table: string, cols: [string, string][]) {98104 for (const [col, decl] of cols) {@@ -115,16 +121,21 @@ export type Member = {
115121 via: string; // claimed | invite <code> | added | profile116122 keep_days: number; // 0 = the relay's rules117123 max_bytes: number; // 0 = unlimited124+ invited_by: string; // pubkey of the inviter, "" when added by the owner or joined open118125 };119126 120127 // Limits the write path checks per author, cached so a write costs no query.121128 export type MemberLimits = { keep: number; cap: number };122129 123-// dumpFields validates the dump settings of a policy patch.130+// dumpFields validates the dump and member-invite settings of a policy patch.124131 export function dumpFields(patch: Record<string, unknown>): Partial<Policy> {125132 const out: Partial<Policy> = {};126133 if (patch.dumps === "off" || patch.dumps === "daily" || patch.dumps === "weekly") out.dumps = patch.dumps;127134 if (Number.isInteger(patch.dumpsKeep) && (patch.dumpsKeep as number) >= 1 && (patch.dumpsKeep as number) <= 60) out.dumpsKeep = patch.dumpsKeep as number;135+ const mi = patch.memberInvites as Record<string, unknown> | undefined;136+ if (mi && typeof mi === "object" && Number.isInteger(mi.depth) && Number.isInteger(mi.quota) && (mi.depth as number) >= 0 && (mi.depth as number) <= 10 && (mi.quota as number) >= 0 && (mi.quota as number) <= 100) {137+ out.memberInvites = { depth: mi.depth as number, quota: mi.quota as number };138+ }128139 return out;129140 }130141 @@ -304,9 +315,43 @@ export class Settings {
304315 return [...this.limits].filter(([pk, l]) => l.keep > 0 && !this.isOwner(pk)).map(([pubkey, l]) => ({ pubkey, keep: l.keep }));305316 }306317 318+ // inviteDepth is a member's distance from the owner along invited_by:319+ // the owner is 0, someone the owner added or invited is 1, and so on. A320+ // chain that ends at a departed member counts the hops it has.321+ inviteDepth(pubkey: string): number {322+ if (this.isOwner(pubkey)) return 0;323+ let depth = 0;324+ let cur = pubkey;325+ const seen = new Set<string>();326+ while (depth < 50 && !seen.has(cur)) {327+ seen.add(cur);328+ depth++;329+ const m = this.member(cur);330+ if (!m || m.invited_by === "" || this.isOwner(m.invited_by)) break;331+ cur = m.invited_by;332+ }333+ return depth;334+ }335+ // subtree lists a member and everyone under them along invited_by, plain336+ // members only: a moderator in the tree, and everyone below them, stays.337+ subtree(pubkey: string): string[] {338+ const root = this.member(pubkey);339+ if (!root || root.role !== "member") return [];340+ const out = [pubkey];341+ const seen = new Set(out);342+ for (let i = 0; i < out.length; i++) {343+ for (const r of this.sql.exec<{ pubkey: string; role: string }>(`SELECT pubkey, role FROM members WHERE invited_by=?`, out[i])) {344+ if (seen.has(r.pubkey) || r.role !== "member") continue;345+ seen.add(r.pubkey);346+ out.push(r.pubkey);347+ }348+ }349+ return out;350+ }351+307352 // upsertMember adds or edits a member. name "" clears the name; a name held308353 // by someone else is refused unless force (owner action). Returns "" or a reason.309- upsertMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; keepDays?: number; maxBytes?: number }, now: number, force = false): string {354+ upsertMember(pubkey: string, patch: { name?: string | null; note?: string; via?: string; invitedBy?: string; keepDays?: number; maxBytes?: number }, now: number, force = false): string {310355 const cur = this.member(pubkey);311356 let name = cur?.name ?? null;312357 if (patch.name !== undefined) {@@ -328,7 +373,7 @@ export class Settings {
328373 if (cur) this.sql.exec(`UPDATE members SET name=?, note=?, keep_days=?, max_bytes=? WHERE pubkey=?`, name, note, keep, cap, pubkey);329374 else {330375 const role = this.isOwner(pubkey) ? "owner" : "member";331- this.sql.exec(`INSERT INTO members(pubkey,role,name,note,joined_at,via,keep_days,max_bytes) VALUES(?,?,?,?,?,?,?,?)`, pubkey, role, name, note, now, (patch.via ?? "added").slice(0, 40), keep, cap);376+ this.sql.exec(`INSERT INTO members(pubkey,role,name,note,joined_at,via,keep_days,max_bytes,invited_by) VALUES(?,?,?,?,?,?,?,?,?)`, pubkey, role, name, note, now, (patch.via ?? "added").slice(0, 40), keep, cap, patch.invitedBy ?? "");332377 this.memberSet.add(pubkey);333378 }334379 if (keep > 0 || cap > 0) this.limits.set(pubkey, { keep, cap });@@ -1,4 +1,4 @@
1-// Dumps to R2, and per-member keep-for and caps.1+// Dumps to R2, per-member keep-for and caps, and members inviting members.22 import { SELF, env, runInDurableObject } from "cloudflare:test";33 import { describe, it, expect } from "vitest";44 import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";@@ -210,3 +210,67 @@ describe("per-member keep-for and caps", () => {
210210 });211211 });212212 213+describe("members invite members", () => {214+ it("depth and quota bound the tree, both join paths record the inviter, and a subtree goes in one publish", async () => {215+ const host = "treey.bind.ws";216+ const owner = generateSecretKey();217+ const a = generateSecretKey();218+ const b = generateSecretKey();219+ const c2 = generateSecretKey();220+ const e = generateSecretKey();221+ await rpc(host, owner, "claim");222+ await rpc(host, owner, "setpolicy", { writes: "allowlist", memberInvites: { depth: 2, quota: 1 } });223+ expect((await rpc(host, owner, "getpolicy")).result.memberInvites).toEqual({ depth: 2, quota: 1 });224+225+ // The owner invites A through the HTTP door.226+ const inv = (await rpc(host, owner, "createinvite", 86400, 0, "for a")).result;227+ expect((await post(host, a, "/api/invites/claim", { code: inv.code })).status).toBe("joined");228+ let members = (await rpc(host, owner, "listmembers")).result.members;229+ expect(members.find((m: any) => m.pubkey === pk(a)).invited_by).toBe(pk(owner));230+231+ // A is one hop out and may hold one live invite.232+ const invA = await rpc(host, a, "createinvite", 86400, 1, "for b");233+ expect(invA.status, JSON.stringify(invA)).toBe(200);234+ expect((await rpc(host, a, "createinvite")).error).toMatch(/already hold 1 live invite/);235+ expect((await rpc(host, a, "listinvites")).result.map((i: any) => i.code)).toEqual([invA.result.code]);236+ expect((await rpc(host, a, "revokeinvite", inv.code)).status).toBe(403);237+ expect((await rpc(host, a, "listmembers")).status).toBe(403);238+239+ // B joins with A's code over the NIP-29 door; two hops out, B cannot invite.240+ const ws = await WS.connect(host);241+ expect((await ws.ok(ev(b, 9021, "", [["h", "treey"], ["code", invA.result.code]]))).ok).toBe(true);242+ members = (await rpc(host, owner, "listmembers")).result.members;243+ expect(members.find((m: any) => m.pubkey === pk(b)).invited_by).toBe(pk(a));244+ expect((await rpc(host, b, "createinvite")).error).toMatch(/do not reach/);245+ // A's invite is used up, so A may mint again; C joins, then E, whom the owner makes a moderator.246+ const invA2 = (await rpc(host, a, "createinvite", 86400, 2, "more")).result;247+ expect((await ws.ok(ev(c2, 9021, "", [["h", "treey"], ["code", invA2.code]]))).ok).toBe(true);248+ expect((await ws.ok(ev(e, 9021, "", [["h", "treey"], ["code", invA2.code]]))).ok).toBe(true);249+ await rpc(host, owner, "setmember", pk(e), { role: "moderator" });250+ members = (await rpc(host, owner, "listmembers")).result.members;251+ expect(members.find((m: any) => m.pubkey === pk(a)).invites).toBe(0);252+253+ // Switched off, members are back to asking the owner.254+ await rpc(host, owner, "setpolicy", { memberInvites: { depth: 0, quota: 0 } });255+ expect((await rpc(host, a, "createinvite")).status).toBe(403);256+257+ // Removing A takes B and C along, leaves the moderator E, and publishes once.258+ const stub = env.RELAY.getByName("treey");259+ await runInDurableObject(stub, async (r: Relay) => {260+ const orig = r.publishMembership.bind(r);261+ (r as any).__pub = 0;262+ (r as any).publishMembership = async (...changes: { pubkey: string; added?: boolean }[]) => {263+ (r as any).__pub++;264+ return orig(...changes);265+ };266+ });267+ // The moderator may do it: A is a plain member, and E's own branch stays.268+ const removed = (await rpc(host, e, "removesubtree", pk(a))).result.removed;269+ expect(removed.sort()).toEqual([pk(a), pk(b), pk(c2)].sort());270+ members = (await rpc(host, owner, "listmembers")).result.members;271+ expect(members.map((m: any) => m.pubkey).sort()).toEqual([pk(owner), pk(e)].sort());272+ await runInDurableObject(stub, async (r: Relay) => expect((r as any).__pub).toBe(1));273+ expect((await ws.req({ kinds: [9001] })).length).toBe(3);274+ expect((await rpc(host, owner, "removesubtree", pk(owner))).status).toBe(400);275+ });276+});