diff --git a/dev/builderlab.mjs b/dev/builderlab.mjs index 61dbe00a8..74c385306 100644 --- a/dev/builderlab.mjs +++ b/dev/builderlab.mjs @@ -10,6 +10,8 @@ const API = "https://app.builderlab.xyz/api/goose"; // Builderlab checks Origin on identity binding; it also seeds the challenge origin. export const BUILDERLAB_ORIGIN = "https://app.builderlab.xyz"; const LOGIN_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_RESPONSE_BYTES = 64 * 1024; +const RESPONSE_STATUS = Symbol("builderlabResponseStatus"); const COMPLETE_HTML = "Buzz authentication complete

You're signed in. You can close this window and return to Buzz."; @@ -22,6 +24,10 @@ const ROUTES = { create: ["/v1/buzz/communities", ["name"]], archive: ["/v1/buzz/communities/archive", ["community_id"]], unarchive: ["/v1/buzz/communities/unarchive", ["community_id"]], + delete: [ + "/v1/buzz/communities/delete", + ["community_id", "host", "request_id", "acknowledgement_version"], + ], // Builderlab's transfer endpoint takes camelCase keys. transfer: [ "/v1/buzz/communities/transfer", @@ -29,6 +35,10 @@ const ROUTES = { ], }; +/** Upstream status is metadata, not part of the public JSON body. */ +export const builderlabResponseStatus = (value) => + value?.[RESPONSE_STATUS] ?? 200; + /** Signs the kind 24243 challenge exactly as block/buzz desktop does, after the same checks. */ export function bindingEvent(key, challenge, now = Date.now()) { const { challenge_id, nonce, verification_code, origin, expires_at } = @@ -161,10 +171,23 @@ export function createBuilderlab({ redirect: "error", signal: AbortSignal.timeout(60000), }); - const value = await response.json().catch(() => undefined); + const text = await response.text(); + if (Buffer.byteLength(text) > MAX_RESPONSE_BYTES) + throw new Error("Builderlab response was too large"); + let value; + try { + value = JSON.parse(text); + } catch { + throw new Error("Builderlab returned an invalid response"); + } // Structured `{ error: { code, ... } }` bodies pass through for friendly UI messages. - if (value && typeof value === "object" && (response.ok || value.error)) + if (value && typeof value === "object" && (response.ok || value.error)) { + Object.defineProperty(value, RESPONSE_STATUS, { + value: response.status, + enumerable: false, + }); return value; + } throw new Error(`Builderlab request failed (HTTP ${response.status}).`); }; const me = async (session, signal) => { @@ -177,8 +200,16 @@ export function createBuilderlab({ throw new Error( `Builderlab session check failed with HTTP ${response.status}`, ); - const { email, name, expires_at } = await response.json(); - return { email, name, expiresAt: expires_at }; + const { email, name, expires_at, capabilities } = await response.json(); + return { + email, + name, + expiresAt: expires_at, + capabilities: { + can_delete_buzz_communities: + capabilities?.can_delete_buzz_communities === true, + }, + }; }; // Sign-in and sign-out bump the generation; late results from an older one never // write, clear or describe the current session. @@ -271,7 +302,12 @@ export function createBuilderlab({ const body = {}; for (const field of fields) { const value = input?.[field]; - if (typeof value !== "string" || !value || value.length > 200) + if (field === "acknowledgement_version") { + if (!Number.isInteger(value)) throw new Error(`Missing ${field}`); + body[field] = value; + continue; + } + if (typeof value !== "string" || !value || value.length > 253) throw new Error(`Missing ${field}`); body[field] = value; } diff --git a/dev/builderlab.test.mjs b/dev/builderlab.test.mjs index 4ec02aabe..3cf198e9f 100644 --- a/dev/builderlab.test.mjs +++ b/dev/builderlab.test.mjs @@ -3,6 +3,7 @@ import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { BUILDERLAB_ORIGIN, bindingEvent, + builderlabResponseStatus, createBuilderlab, } from "./builderlab.mjs"; @@ -107,6 +108,7 @@ it("completes browser sign-in through a loopback callback without exposing the c email: "a@example.com", name: "A", expiresAt: "2030", + capabilities: { can_delete_buzz_communities: false }, }); expect(JSON.stringify(auth)).not.toContain("secret"); expect(h.opened().pathname).toBe("/api/goose/v1/auth/login"); @@ -153,6 +155,90 @@ it("forwards only allowlisted fields and ignores unknown actions", async () => { expect(await h.builderlab.call("../auth/me", {})).toBeUndefined(); }); +it("forwards the exact deletion tuple for both admission and same-UUID replay", async () => { + const h = account({ + "/v1/buzz/communities/delete": () => + Response.json({ status: "submitted" }, { status: 202 }), + }); + await signIn(h); + const request = { + community_id: "community", + host: "North.communities.buzz.xyz", + request_id: "11111111-1111-4111-8111-111111111111", + acknowledgement_version: 1, + owner_pubkey: "must-not-pass", + extra: "dropped", + }; + const admitted = await h.builderlab.call("delete", request); + expect(builderlabResponseStatus(admitted)).toBe(202); + expect(h.requests.at(-1).path).toBe("/v1/buzz/communities/delete"); + expect(JSON.parse(h.requests.at(-1).init.body)).toEqual({ + community_id: request.community_id, + host: request.host, + request_id: request.request_id, + acknowledgement_version: 1, + }); + const replay = await h.builderlab.call("delete", request); + expect(builderlabResponseStatus(replay)).toBe(202); + expect(h.requests.at(-1).path).toBe("/v1/buzz/communities/delete"); + expect(JSON.parse(h.requests.at(-1).init.body)).toEqual({ + community_id: request.community_id, + host: request.host, + request_id: request.request_id, + acknowledgement_version: 1, + }); + expect( + h.requests.filter((item) => item.path === "/v1/buzz/communities/delete"), + ).toHaveLength(2); +}); + +it("does not expose a removed deletion receipt action", async () => { + const h = account({}); + expect(await h.builderlab.call("delete-receipt", {})).toBeUndefined(); + expect(h.requests).toHaveLength(0); +}); + +it.each([ + [true, true], + ["true", false], + [1, false], + [undefined, false], +])("maps delete capability %j to literal true=%s", async (value, expected) => { + const h = account({ + "/v1/auth/me": () => + Response.json({ + email: "a@example.com", + expires_at: "2030", + capabilities: { can_delete_buzz_communities: value }, + }), + }); + expect((await signIn(h)).capabilities).toEqual({ + can_delete_buzz_communities: expected, + }); +}); + +it.each([ + ["malformed", "{"], + ["oversize", JSON.stringify({ value: "x".repeat(70_000) })], +])("rejects a %s downstream response after dispatch", async (_label, body) => { + const h = account({ + "/v1/buzz/communities/delete": () => + new Response(body, { + status: 202, + headers: { "Content-Type": "application/json" }, + }), + }); + await signIn(h); + await expect( + h.builderlab.call("delete", { + community_id: "community", + host: "north.communities.buzz.xyz", + request_id: "11111111-1111-4111-8111-111111111111", + acknowledgement_version: 1, + }), + ).rejects.toThrow(/invalid response|too large/); +}); + it("binds the local key by verifying a signed challenge and passes structured errors through", async () => { const h = account({ "/v1/buzz/nostr-identities/challenge": () => diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index fd8116c2c..ab1dcefac 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -33,7 +33,12 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); // Real browser HTTP -> production broker. Ephemeral key; upstream I/O is entirely local. -async function harness(respond, capabilities = {}, relayUrl = fixtureRelayUrl) { +async function harness( + respond, + capabilities = {}, + relayUrl = fixtureRelayUrl, + builderlab = {}, +) { const key = new Uint8Array(32); key[31] = 7; const viewer = getPublicKey(key); @@ -51,6 +56,7 @@ async function harness(respond, capabilities = {}, relayUrl = fixtureRelayUrl) { }); const plugin = relayBrokerPlugin({ relayUrl, + builderlab, communityAliases: fixtureAliases, identity: () => key, socketFactory: socket.factory, @@ -125,6 +131,70 @@ async function harness(respond, capabilities = {}, relayUrl = fixtureRelayUrl) { }; } const filters = [{ kinds: [0], limit: 1 }]; + +test.each([ + [202, { status: "aborted" }], + [409, { error: { code: "must_archive" } }], +])( + "Builderlab HTTP forwards structured deletion status %s", + async (status, result) => { + let openLogin; + const loginOpened = new Promise((resolve) => { + openLogin = resolve; + }); + const request = { + community_id: "11111111-1111-4111-8111-111111111111", + host: "north.communities.buzz.xyz", + request_id: "22222222-2222-4222-8222-222222222222", + acknowledgement_version: 1, + }; + const upstream = []; + const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { + open: async (url) => openLogin(url), + fetch: async (url, init) => { + const path = new URL(url).pathname; + if (path.endsWith("/v1/auth/login/exchange")) + return Response.json({ + session_credential: "fixture-only", + expires_at: "2030", + }); + if (path.endsWith("/v1/auth/me")) + return Response.json({ + email: "fixture@example.com", + expires_at: "2030", + }); + if (path.endsWith("/v1/buzz/communities/delete")) { + upstream.push(JSON.parse(init.body)); + return Response.json({ ...request, ...result }, { status }); + } + throw new Error(`Unexpected fixture request: ${path}`); + }, + }); + try { + const login = fetch(`${h.base}/api/builderlab/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const opened = new URL(await loginOpened); + const callback = opened.searchParams.get("returnTo"); + expect(callback).toBeTruthy(); + expect((await fetch(`${callback}?code=fixture`)).status).toBe(200); + expect((await login).status).toBe(200); + const response = await fetch(`${h.base}/api/builderlab/delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ ...request, ...result }); + expect(upstream).toEqual([request]); + } finally { + await h.close(); + } + }, +); + const success = (call) => Response.json( call.url.endsWith("/events") diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 067ec3d12..6d51c2fea 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -66,7 +66,7 @@ import { readSnapshotCommunity, } from "../src/features/relay/read-state-snapshot.ts"; import { readAgentLibrary } from "./agent-library.mjs"; -import { createBuilderlab } from "./builderlab.mjs"; +import { builderlabResponseStatus, createBuilderlab } from "./builderlab.mjs"; import { decodeSidebarPreferences, assertSidebarAssignmentIntent, @@ -824,7 +824,7 @@ export function relayBrokerPlugin({ raw ? JSON.parse(raw) : {}, ); return result - ? json(res, 200, result) + ? json(res, builderlabResponseStatus(result), result) : json(res, 404, { error: "Unknown Builderlab route" }); } catch (error) { return json(res, 502, { diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index edc373349..95bc3b1c3 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -131,8 +131,20 @@ personal groups and the existing + creation buttons, independently of this plugi Hosted communities (`block.hosted-communities`) is a Block-specific bundled plugin under Settings → Communities. It manages Block-hosted relays through a Builderlab account: browser sign-in, binding the local Buzz identity (a locally signed kind -24243 challenge), and create/archive/unarchive/transfer. Joining stays in the -existing Add a community dialog; the card only copies the new relay address. Its +24243 challenge), and create/archive/unarchive/transfer. A server-declared, +default-off capability also exposes owner deletion for archived communities. The +card persists the bound four-field request before admission. A fresh request can +terminate on a known structured pre-admission code and HTTP status pair; +ambiguous first responses stay pending until an explicit same-UUID delete replay. +Only a tuple-bound non-aborted 202 confirms progress; an aborted 202 ends recovery +without claiming deletion. The card displays valid server quota when available; +without it, Create remains available and the server enforces its owner limit. +`can_create: false` alone disables Create; usage is informational and is never +estimated from visible rows. One origin-wide pending slot is +re-read and verified before dispatch; browser local storage has no atomic compare-and-set, +so exactly simultaneous contexts remain a documented client-side race; +it never signs deletion or infers acceptance from a missing list row. Joining +stays in the existing Add a community dialog; the card only copies the new relay address. Its `/api/builderlab/*` routes live in the development broker (`dev/builderlab.mjs`), which keeps the session credential and signing key in Node. Packaged builds ship no broker, so this plugin cannot sign in or manage communities there until a native diff --git a/src/bundled/hosted-communities/HostedCommunities.test.tsx b/src/bundled/hosted-communities/HostedCommunities.test.tsx index 699201e82..d68eabb38 100644 --- a/src/bundled/hosted-communities/HostedCommunities.test.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.test.tsx @@ -10,25 +10,37 @@ import { within, } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { StrictMode } from "react"; +import { StrictMode, useState } from "react"; import { npubEncode } from "nostr-tools/nip19"; import { HostedCommunities } from "./HostedCommunities"; +import { DELETION_PENDING_KEY } from "./api"; const local = "a".repeat(64); const other = "b".repeat(64); -type Handler = (body: Record) => unknown; +type Handler = (body: Record) => unknown; let routes: Record; -let calls: [string, Record][]; +let calls: [string, Record][]; beforeEach(() => { + localStorage.clear(); calls = []; routes = { "/api/relay/identity": () => ({ viewer: local }), "/api/builderlab/auth": () => ({ - auth: { email: "a@example.com", name: "Ada", expiresAt: "2030" }, + auth: { + email: "a@example.com", + name: "Ada", + expiresAt: "2030", + capabilities: { can_delete_buzz_communities: true }, + }, }), "/api/builderlab/identity": () => ({ identity: { pubkey_hex: local } }), - "/api/builderlab/list": () => ({ communities: [] }), + "/api/builderlab/list": () => ({ + communities: [], + quota_used: 0, + quota_limit: 5, + can_create: true, + }), }; vi.stubGlobal( "fetch", @@ -36,14 +48,16 @@ beforeEach(() => { const body = init?.body ? JSON.parse(String(init.body)) : {}; calls.push([url, body]); const handler = routes[url]; - return handler - ? Response.json(await handler(body)) - : Response.json({ error: "missing" }, { status: 404 }); + if (!handler) return Response.json({ error: "missing" }, { status: 404 }); + const result = await handler(body); + if (result instanceof Response) return result; + return Response.json(result); }), ); }); afterEach(() => { cleanup(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -55,6 +69,83 @@ const renderCard = () => , ); +function RerenderingParent() { + const [, rerender] = useState(0); + return ( + <> + + true} /> + + ); +} + +it("does not reload identity and list when the parent supplies new active closures", async () => { + render(); + await screen.findByText(npubEncode(local)); + const identityReads = calls.filter( + ([url]) => url === "/api/builderlab/identity", + ).length; + const listReads = calls.filter( + ([url]) => url === "/api/builderlab/list", + ).length; + fireEvent.click(screen.getByRole("button", { name: "Parent update" })); + fireEvent.click(screen.getByRole("button", { name: "Parent update" })); + await act(async () => {}); + expect( + calls.filter(([url]) => url === "/api/builderlab/identity"), + ).toHaveLength(identityReads); + expect(calls.filter(([url]) => url === "/api/builderlab/list")).toHaveLength( + listReads, + ); +}); + +it("accepts a held deletion across parent rerenders and releases the busy state", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + const admission = hold(); + routes["/api/builderlab/delete"] = admission.answer as Handler; + render(); + await confirmDeletion(); + await waitFor(() => expect(deletionPosts()).toHaveLength(1)); + const pending = deletionPosts()[0]?.[1]; + if (!pending) throw new Error("Expected the held deletion request"); + fireEvent.click(screen.getByRole("button", { name: "Parent update" })); + fireEvent.click(screen.getByRole("button", { name: "Parent update" })); + await act(async () => + admission.release(Response.json(accepted(pending), { status: 202 })), + ); + expect(await screen.findByText("Deletion started")).toBeVisible(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(); +}); + +it("does not abort a held sign-in on a parent rerender", async () => { + routes["/api/builderlab/auth"] = () => ({ auth: null }); + const signIn = hold(); + routes["/api/builderlab/login"] = signIn.answer as Handler; + const abort = vi.spyOn(AbortController.prototype, "abort"); + render(); + fireEvent.click( + await screen.findByRole("button", { name: /Sign in with Builderlab/ }), + ); + await waitFor(() => + expect(calls.map(([url]) => url)).toContain("/api/builderlab/login"), + ); + fireEvent.click(screen.getByRole("button", { name: "Parent update" })); + expect(abort).not.toHaveBeenCalled(); + await act(async () => + signIn.release({ + auth: { + expiresAt: "2030", + capabilities: { can_delete_buzz_communities: true }, + }, + }), + ); + await screen.findByText(npubEncode(local)); + expect(abort).not.toHaveBeenCalled(); +}); + it("offers browser sign-in when no Builderlab session exists", async () => { routes["/api/builderlab/auth"] = () => ({ auth: null }); renderCard(); @@ -155,11 +246,32 @@ it("rejects invalid names and blocks create at the community limit", async () => name: `c${index}`, normalized_host: `c${index}.communities.buzz.xyz`, })), + quota_used: 5, + quota_limit: 5, + can_create: false, }); renderCard(); expect(await screen.findByText("5 of 5 used")).toBeInTheDocument(); - expect(screen.getByText(/reached the limit of 5/)).toBeInTheDocument(); + expect( + screen.getByText("You've reached your limit of 5 communities."), + ).toBeVisible(); + expect(screen.getByPlaceholderText("north-star")).toBeDisabled(); +}); + +it("honors can_create false independently of informational quota usage", async () => { + routes["/api/builderlab/list"] = () => ({ + communities: [], + quota_used: 0, + quota_limit: 5, + can_create: false, + }); + renderCard(); + expect(await screen.findByText("0 of 5 used")).toBeVisible(); + expect( + screen.getByText("You've reached your limit of 5 communities."), + ).toBeVisible(); expect(screen.getByPlaceholderText("north-star")).toBeDisabled(); + expect(calls.map(([url]) => url)).not.toContain("/api/builderlab/create"); }); it("archives and transfers after confirmation, surfacing friendly errors", async () => { @@ -205,6 +317,49 @@ it("archives and transfers after confirmation, surfacing friendly errors", async "/api/builderlab/transfer", { communityId: "c1", transfereeNpub: recipient }, ]); + routes["/api/builderlab/transfer"] = () => ({ + error: { code: "limit_reached" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer ownership" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "The recipient has reached their community limit.", + ); +}); + +it("attributes transfer limit_reached to the recipient, not this owner's quota", async () => { + routes["/api/builderlab/list"] = () => ({ + communities: [{ ...archived, archived_at: null }], + quota_used: 1, + quota_limit: 7, + can_create: true, + }); + routes["/api/builderlab/transfer"] = () => + Response.json({ error: { code: "limit_reached" } }, { status: 409 }); + renderCard(); + fireEvent.click(await screen.findByRole("button", { name: "Transfer" })); + fireEvent.change(screen.getByPlaceholderText("npub1…"), { + target: { value: npubEncode(other) }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer ownership" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "The recipient has reached their community limit.", + ); + expect(screen.getByRole("alert")).not.toHaveTextContent("your limit of 7"); +}); + +it("shows the zero-limit copy without guessing a reason from usage", async () => { + routes["/api/builderlab/list"] = () => ({ + communities: [], + quota_used: 0, + quota_limit: 0, + can_create: false, + }); + renderCard(); + expect(await screen.findByText("0 of 0 used")).toBeVisible(); + expect( + screen.getByText("You can't create more communities right now."), + ).toBeVisible(); + expect(screen.getByPlaceholderText("north-star")).toBeDisabled(); }); /** A route answer the test releases by hand. */ @@ -401,6 +556,9 @@ const listed = { communities: [ { id: "c1", name: "north", normalized_host: "north.communities.buzz.xyz" }, ], + quota_used: 1, + quota_limit: 5, + can_create: true, }; it("drops a failed copy handoff when the identity is unpaired", async () => { @@ -548,7 +706,12 @@ it("completes a transfer when the following refresh fails", async () => { expect( screen.queryByRole("button", { name: "Transfer" }), ).not.toBeInTheDocument(); - routes["/api/builderlab/list"] = () => ({ communities: [] }); + routes["/api/builderlab/list"] = () => ({ + communities: [], + quota_used: 0, + quota_limit: 5, + can_create: true, + }); fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(await screen.findByText("0 of 5 used")).toBeInTheDocument(); expect( @@ -789,3 +952,1332 @@ it("keeps a failed copy handoff for another address when a community is archived screen.getByRole("button", { name: "Try copying again" }), ).toBeEnabled(); }); + +const archived = { + id: "11111111-1111-4111-8111-111111111111", + name: "north", + normalized_host: "North.communities.buzz.xyz", + archived_at: "2026-09-24", +}; +const accepted = (request: Record) => ({ + ...request, + status: "submitted", + correlation_id: "corr-delete", +}); + +async function openDeletion() { + fireEvent.click(await screen.findByRole("button", { name: "Delete" })); + return screen.findByRole("dialog", { name: /Permanently delete north/ }); +} + +async function confirmDeletion(host = archived.normalized_host) { + const dialog = await openDeletion(); + fireEvent.change(within(dialog).getByLabelText("Type the exact host"), { + target: { value: host }, + }); + fireEvent.click( + within(dialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + fireEvent.click( + within(dialog).getByRole("button", { name: "Start deletion" }), + ); +} + +const deletionPosts = () => + calls.filter(([url]) => url === "/api/builderlab/delete"); + +async function startUncertainDeletion() { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json({ error: { code: "acceptance_unknown" } }, { status: 503 }); + const view = renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion status is unknown"); + const saved = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); + expect(deletionPosts()).toEqual([["/api/builderlab/delete", saved.request]]); + return { view, saved }; +} + +async function expectSameRequestRecovery(saved: { + request: Record; +}) { + expect(JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? "")).toEqual( + saved, + ); + expect(deletionPosts()).toEqual([["/api/builderlab/delete", saved.request]]); + routes["/api/builderlab/delete"] = (request) => + Response.json(accepted(request), { status: 202 }); + fireEvent.click( + await screen.findByRole("button", { name: "Check deletion status" }), + ); + await screen.findByText("Deletion started"); + expect(deletionPosts()).toEqual([ + ["/api/builderlab/delete", saved.request], + ["/api/builderlab/delete", saved.request], + ]); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); +} + +it("keeps the uncertain UUID through sign-out and same-owner sign-in", async () => { + const { saved } = await startUncertainDeletion(); + routes["/api/builderlab/sign-out"] = () => ({}); + fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + await screen.findByRole("button", { name: /Sign in with Builderlab/ }); + expect(JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? "")).toEqual( + saved, + ); + expect(deletionPosts()).toHaveLength(1); + routes["/api/builderlab/login"] = () => ({ + auth: { + email: "a@example.com", + expiresAt: "2030", + capabilities: { can_delete_buzz_communities: true }, + }, + }); + fireEvent.click( + screen.getByRole("button", { name: /Sign in with Builderlab/ }), + ); + await screen.findByText(saved.request.request_id); + await expectSameRequestRecovery(saved); +}); + +it("hides but retains an uncertain UUID across A to B to A", async () => { + const { saved } = await startUncertainDeletion(); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: other }, + }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByRole("region", { name: "Identity mismatch" }); + expect(screen.queryByText(saved.request.request_id)).not.toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? "")).toEqual( + saved, + ); + expect(deletionPosts()).toHaveLength(1); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: local }, + }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText(saved.request.request_id); + await expectSameRequestRecovery(saved); +}); + +it("retains the uncertain UUID through unpair and same-owner rebind", async () => { + const { saved } = await startUncertainDeletion(); + routes["/api/builderlab/unbind"] = () => ({}); + routes["/api/builderlab/identity"] = () => ({ + error: { code: "missing_mapping", setup_needed: true }, + }); + routes["/api/builderlab/list"] = () => ({ + error: { code: "missing_mapping", setup_needed: true }, + }); + fireEvent.click(screen.getByRole("button", { name: "Unpair identity" })); + const dialog = await screen.findByRole("alertdialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: "Unpair identity" }), + ); + await screen.findByRole("button", { name: "Connect Buzz identity" }); + expect(deletionPosts()).toHaveLength(1); + expect(JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? "")).toEqual( + saved, + ); + routes["/api/builderlab/bind"] = () => ({ identity: { pubkey_hex: local } }); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: local }, + }); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + fireEvent.click( + screen.getByRole("button", { name: "Connect Buzz identity" }), + ); + await screen.findByText(saved.request.request_id); + await expectSameRequestRecovery(saved); +}); + +it.each(["setup-needed", "unauthorized"])( + "retains the uncertain UUID through %s and recovery", + async (failure) => { + const { saved } = await startUncertainDeletion(); + const error = + failure === "setup-needed" + ? { code: "missing_mapping", setup_needed: true } + : { code: "unauthorized" }; + routes["/api/builderlab/identity"] = () => ({ + error, + }); + routes["/api/builderlab/list"] = () => ({ + error, + }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + if (failure === "setup-needed") + await screen.findByRole("button", { name: "Connect Buzz identity" }); + else { + expect(await screen.findByRole("alert")).toHaveTextContent( + failure === "unauthorized" + ? "Your Builderlab session ended. Sign out, then sign in again." + : "Could not load the connected Buzz identity.", + ); + expect( + screen.queryByRole("button", { name: "Connect Buzz identity" }), + ).not.toBeInTheDocument(); + } + expect( + screen.queryByText(saved.request.request_id), + ).not.toBeInTheDocument(); + expect( + JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""), + ).toEqual(saved); + expect(deletionPosts()).toHaveLength(1); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: local }, + }); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText(saved.request.request_id); + await expectSameRequestRecovery(saved); + }, +); + +it("does not offer Connect or stale owner actions on an initial unauthorized load", async () => { + const bytes = JSON.stringify({ + version: 1, + owner_pubkey: local, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: archived.normalized_host, + request_id: "88888888-8888-4888-8888-888888888888", + acknowledgement_version: 1, + }, + }); + localStorage.setItem(DELETION_PENDING_KEY, bytes); + routes["/api/builderlab/identity"] = () => ({ + error: { code: "unauthorized" }, + }); + routes["/api/builderlab/list"] = () => ({ error: { code: "unauthorized" } }); + renderCard(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your Builderlab session ended. Sign out, then sign in again.", + ); + expect( + screen.queryByRole("button", { name: "Connect Buzz identity" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(bytes); + expect(deletionPosts()).toHaveLength(0); +}); + +it("clears stale blocked-owner and deletion notices on unauthorized Refresh", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (request) => + Response.json(accepted(request), { status: 202 }); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion started"); + localStorage.setItem( + DELETION_PENDING_KEY, + JSON.stringify({ + version: 1, + owner_pubkey: other, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: archived.normalized_host, + request_id: "77777777-7777-4777-8777-777777777777", + acknowledgement_version: 1, + }, + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText(/A deletion request from/); + routes["/api/builderlab/list"] = () => ({ error: { code: "unauthorized" } }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your Builderlab session ended. Sign out, then sign in again.", + ); + expect(screen.queryByText("Deletion started")).not.toBeInTheDocument(); + expect(screen.queryByText(/A deletion request from/)).not.toBeInTheDocument(); + expect( + JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? "").owner_pubkey, + ).toBe(other); +}); + +it("retains the uncertain UUID through Switch back to its owner", async () => { + const { saved } = await startUncertainDeletion(); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: other }, + }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByRole("region", { name: "Identity mismatch" }); + routes["/api/builderlab/unbind"] = () => ({}); + routes["/api/builderlab/bind"] = () => ({ identity: { pubkey_hex: local } }); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: local }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Switch to this device’s identity" }), + ); + await screen.findByText(saved.request.request_id); + await expectSameRequestRecovery(saved); +}); + +it("restores an accepted archived row only after its bound abort on Refresh", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (request) => + Response.json(accepted(request), { status: 202 }); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion started"); + const original = deletionPosts()[0]?.[1]; + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect(deletionPosts()).toHaveLength(1); + routes["/api/builderlab/list"] = () => ({ communities: [] }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + expect(deletionPosts()).toHaveLength(1); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (request) => + Response.json({ ...request, status: "aborted" }, { status: 202 }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(deletionPosts()).toHaveLength(2)); + expect(deletionPosts()[1]?.[1]).toEqual(original); + expect(await screen.findByRole("button", { name: "Delete" })).toBeEnabled(); + expect( + screen.getByText(/North\.communities\.buzz\.xyz · Archived/), + ).toBeVisible(); + expect( + screen.getByText( + `Deletion of ${archived.normalized_host} stopped. This community is not being deleted.`, + ), + ).toBeVisible(); + expect(screen.queryByText("Deletion started")).not.toBeInTheDocument(); +}); + +it.each([ + ["absent", {}], + ["incomplete", { quota_used: 5, quota_limit: 5 }], + ["malformed", { quota_used: "5", quota_limit: 5, can_create: false }], +])( + "keeps Create available when the quota projection is %s", + async (_label, projection) => { + routes["/api/builderlab/list"] = () => ({ + communities: Array.from({ length: 5 }, (_, index) => ({ + id: `c${index}`, + name: `c${index}`, + normalized_host: `c${index}.communities.buzz.xyz`, + })), + ...projection, + }); + renderCard(); + const input = await screen.findByPlaceholderText("north-star"); + await waitFor(() => expect(input).toBeEnabled()); + expect(screen.queryByText(/quota unavailable/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/\d+ of \d+ used/)).not.toBeInTheDocument(); + expect(screen.queryByText(/reached the limit/)).not.toBeInTheDocument(); + }, +); + +it("shows the server's limit_reached message when quota is absent", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [] }); + routes["/api/builderlab/availability"] = () => ({ available: true }); + routes["/api/builderlab/create"] = () => + Response.json({ error: { code: "limit_reached" } }, { status: 409 }); + renderCard(); + const input = await screen.findByPlaceholderText("north-star"); + await waitFor(() => expect(input).toBeEnabled()); + vi.useFakeTimers(); + fireEvent.change(input, { target: { value: "north" } }); + await act(() => vi.advanceTimersByTimeAsync(500)); + vi.useRealTimers(); + expect(await screen.findByText("That address is available.")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Create community" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("You've reached your community limit."); + expect(alert).not.toHaveTextContent(/\d/); + expect(alert).not.toHaveTextContent("limit of 5"); + expect(calls).toContainEqual(["/api/builderlab/create", { name: "north" }]); +}); + +it("uses valid projected limit for a server limit_reached create error", async () => { + routes["/api/builderlab/list"] = () => ({ + communities: [], + quota_used: 0, + quota_limit: 7, + can_create: true, + }); + routes["/api/builderlab/availability"] = () => ({ available: true }); + routes["/api/builderlab/create"] = () => + Response.json({ error: { code: "limit_reached" } }, { status: 409 }); + renderCard(); + const input = await screen.findByPlaceholderText("north-star"); + await waitFor(() => expect(input).toBeEnabled()); + vi.useFakeTimers(); + fireEvent.change(input, { target: { value: "north" } }); + await act(() => vi.advanceTimersByTimeAsync(500)); + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: "Create community" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "You've reached your limit of 7 communities.", + ); +}); + +it("shows deletion only for literal capability true and requires the byte-exact host plus explicit confirmation", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + let storedBeforeDispatch = false; + routes["/api/builderlab/delete"] = (request) => { + storedBeforeDispatch = localStorage.getItem(DELETION_PENDING_KEY) !== null; + return Response.json(accepted(request), { status: 202 }); + }; + renderCard(); + const dialog = await openDeletion(); + expect(dialog).toHaveTextContent("cannot be canceled by an owner"); + expect(dialog).toHaveTextContent("All community content will be deleted"); + expect(dialog).toHaveTextContent("permanently reserved"); + expect(dialog).toHaveTextContent("logical cleanup finishes"); + const input = within(dialog).getByLabelText("Type the exact host"); + const submit = within(dialog).getByRole("button", { + name: "Start deletion", + }); + fireEvent.change(input, { + target: { value: archived.normalized_host.toLowerCase() }, + }); + fireEvent.click( + within(dialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + expect(submit).toBeDisabled(); + fireEvent.change(input, { + target: { value: ` ${archived.normalized_host}` }, + }); + expect(submit).toBeDisabled(); + fireEvent.change(input, { target: { value: archived.normalized_host } }); + expect(submit).toBeEnabled(); + fireEvent.click(submit); + expect(await screen.findByText("Deletion started")).toBeVisible(); + expect(storedBeforeDispatch).toBe(true); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + const request = calls.find(([url]) => url === "/api/builderlab/delete")?.[1]; + expect(request).toEqual({ + community_id: archived.id, + host: archived.normalized_host, + request_id: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), + acknowledgement_version: 1, + }); +}); + +it("keeps deletion hidden when the capability is absent", async () => { + routes["/api/builderlab/auth"] = () => ({ + auth: { email: "a@example.com", expiresAt: "2030", capabilities: {} }, + }); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + renderCard(); + expect( + await screen.findByRole("button", { name: "Unarchive" }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); +}); + +it("does not dispatch when the pending envelope cannot be persisted", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage full"); + }); + renderCard(); + await confirmDeletion(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Deletion was not sent because its recovery record could not be saved", + ); + expect(calls.map(([url]) => url)).not.toContain("/api/builderlab/delete"); +}); + +it("replays an aborted 202 with the stored UUID and restores the archived row", async () => { + const request = { + community_id: archived.id, + host: archived.normalized_host, + request_id: "77777777-7777-4777-8777-777777777777", + acknowledgement_version: 1, + }; + localStorage.setItem( + DELETION_PENDING_KEY, + JSON.stringify({ + version: 1, + owner_pubkey: local, + backend_origin: window.location.origin, + request, + }), + ); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (body) => + Response.json({ ...body, status: "aborted" }, { status: 202 }); + renderCard(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + `Deletion of ${archived.normalized_host} stopped. This community is not being deleted.`, + ); + expect(calls.filter(([url]) => url === "/api/builderlab/delete")).toEqual([ + ["/api/builderlab/delete", request], + ]); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect(screen.queryByText("Deletion started")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Delete" })).toBeEnabled(), + ); +}); + +it("clears a fresh must_archive rejection and restores deletion after remount", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { + error: { code: "must_archive" }, + correlation_id: "corr-must-archive", + }, + { status: 409 }, + ); + const view = renderCard(); + await confirmDeletion(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Archive the community before deleting it", + ); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect(screen.getByRole("button", { name: "Delete" })).toBeEnabled(); + + view.unmount(); + renderCard(); + expect(await screen.findByRole("button", { name: "Delete" })).toBeEnabled(); + expect( + screen.queryByText("Deletion status is unknown"), + ).not.toBeInTheDocument(); +}); + +it("preserves one UUID across an ambiguous response and manual same-UUID replay", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + new Response("{", { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + const saved = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); + const requestId = saved.request.request_id; + expect( + calls.filter(([url]) => url === "/api/builderlab/delete"), + ).toHaveLength(1); + routes["/api/builderlab/delete"] = (request) => + Response.json({ ...request, status: "retention_pending" }, { status: 202 }); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByText("Deletion started")).toBeVisible(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + const admissions = calls.filter(([url]) => url === "/api/builderlab/delete"); + expect(admissions).toHaveLength(2); + expect(admissions[1]?.[1]).toEqual(saved.request); + expect(admissions[1]?.[1].request_id).toBe(requestId); +}); + +it("shows a stored request for explicit manual checking without background recovery", async () => { + const request = { + community_id: archived.id, + host: archived.normalized_host, + request_id: "22222222-2222-4222-8222-222222222222", + acknowledgement_version: 1, + }; + localStorage.setItem( + DELETION_PENDING_KEY, + JSON.stringify({ + version: 1, + owner_pubkey: local, + backend_origin: window.location.origin, + request, + }), + ); + routes["/api/builderlab/auth"] = () => ({ + auth: { email: "a@example.com", expiresAt: "2030", capabilities: {} }, + }); + routes["/api/builderlab/list"] = () => ({ communities: [] }); + routes["/api/builderlab/delete"] = (body) => + Response.json({ ...body, status: "submitted" }, { status: 202 }); + const view = renderCard(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + expect(screen.getByText(request.request_id)).toHaveClass("select-all"); + expect(screen.getByText(/will not check automatically/i)).toBeVisible(); + expect(screen.getByText(/contact support/i)).toBeVisible(); + expect( + screen.getByText( + "Community deletion is unavailable right now, so this request can't be checked. It stays saved on this device.", + ), + ).toBeVisible(); + expect(calls.map(([url]) => url)).not.toContain("/api/builderlab/delete"); + expect( + screen.getByRole("button", { name: "Check deletion status" }), + ).toBeDisabled(); + view.unmount(); + routes["/api/builderlab/auth"] = () => ({ + auth: { + email: "a@example.com", + expiresAt: "2030", + capabilities: { can_delete_buzz_communities: true }, + }, + }); + renderCard(); + fireEvent.click( + await screen.findByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByText("Deletion started")).toBeVisible(); + expect(calls.filter(([url]) => url === "/api/builderlab/delete")).toEqual([ + ["/api/builderlab/delete", request], + ]); +}); + +it("retains another owner's recovery envelope without showing or dispatching it", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + localStorage.setItem( + DELETION_PENDING_KEY, + JSON.stringify({ + version: 1, + owner_pubkey: other, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: "Private.communities.buzz.xyz", + request_id: "33333333-3333-4333-8333-333333333333", + acknowledgement_version: 1, + }, + }), + ); + const original = localStorage.getItem(DELETION_PENDING_KEY); + renderCard(); + await screen.findByText(npubEncode(local)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(original); + expect( + screen.queryByText("33333333-3333-4333-8333-333333333333"), + ).not.toBeInTheDocument(); + expect(calls.map(([url]) => url)).not.toContain("/api/builderlab/delete"); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + expect( + screen.getByText( + `A deletion request from ${npubEncode(other)} is still pending on this device. Switch to that Buzz identity and use Check deletion status before starting another deletion here. If you no longer have that identity, contact support.`, + ), + ).toBeVisible(); + expect( + screen.queryByText("33333333-3333-4333-8333-333333333333"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Private.communities.buzz.xyz"), + ).not.toBeInTheDocument(); +}); + +it("does not reveal another owner's blocked notice when deletion is unavailable", async () => { + localStorage.setItem( + DELETION_PENDING_KEY, + JSON.stringify({ + version: 1, + owner_pubkey: other, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: archived.normalized_host, + request_id: "33333333-3333-4333-8333-333333333333", + acknowledgement_version: 1, + }, + }), + ); + const original = localStorage.getItem(DELETION_PENDING_KEY); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/auth"] = () => ({ + auth: { expiresAt: "2030", capabilities: {} }, + }); + renderCard(); + await screen.findByText(npubEncode(local)); + expect(screen.queryByText(/A deletion request from/)).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(original); + expect(deletionPosts()).toHaveLength(0); +}); + +it("rejects a newly occupied slot at final confirmation without claiming a storage failure", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + renderCard(); + const dialog = await openDeletion(); + fireEvent.change(within(dialog).getByLabelText("Type the exact host"), { + target: { value: archived.normalized_host }, + }); + fireEvent.click( + within(dialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + const original = JSON.stringify({ + version: 1, + owner_pubkey: other, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: archived.normalized_host, + request_id: "33333333-3333-4333-8333-333333333333", + acknowledgement_version: 1, + }, + }); + localStorage.setItem(DELETION_PENDING_KEY, original); + fireEvent.click( + within(dialog).getByRole("button", { name: "Start deletion" }), + ); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(original); + expect(deletionPosts()).toHaveLength(0); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + `Deletion was not sent. A deletion request from ${npubEncode(other)} is already pending on this device.`, + ); + expect( + screen.queryByText(/recovery record could not be saved/), + ).not.toBeInTheDocument(); +}); + +it("explains a same-owner slot occupied at final confirmation", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + renderCard(); + const dialog = await openDeletion(); + fireEvent.change(within(dialog).getByLabelText("Type the exact host"), { + target: { value: archived.normalized_host }, + }); + fireEvent.click( + within(dialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + const bytes = JSON.stringify({ + version: 1, + owner_pubkey: local, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: archived.normalized_host, + request_id: "33333333-3333-4333-8333-333333333333", + acknowledgement_version: 1, + }, + }); + localStorage.setItem(DELETION_PENDING_KEY, bytes); + fireEvent.click( + within(dialog).getByRole("button", { name: "Start deletion" }), + ); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Deletion was not sent. This identity already has a pending deletion request. Use Check deletion status.", + ); + expect(deletionPosts()).toHaveLength(0); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(bytes); +}); + +it("explains when another context cleared the blocked slot before final confirmation", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + renderCard(); + const dialog = await openDeletion(); + fireEvent.change(within(dialog).getByLabelText("Type the exact host"), { + target: { value: archived.normalized_host }, + }); + fireEvent.click( + within(dialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + const bytes = JSON.stringify({ + version: 1, + owner_pubkey: other, + backend_origin: window.location.origin, + request: { + community_id: archived.id, + host: archived.normalized_host, + request_id: "33333333-3333-4333-8333-333333333333", + acknowledgement_version: 1, + }, + }); + localStorage.setItem(DELETION_PENDING_KEY, bytes); + fireEvent.click( + within(dialog).getByRole("button", { name: "Start deletion" }), + ); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + `A deletion request from ${npubEncode(other)}`, + ); + localStorage.removeItem(DELETION_PENDING_KEY); + fireEvent.click( + within(dialog).getByRole("button", { name: "Start deletion" }), + ); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Refresh and try again.", + ); + expect(deletionPosts()).toHaveLength(0); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); +}); + +it("keeps and retries the same UUID after a wrong-status pre-admission rejection", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "must_archive" }, correlation_id: "corr-wrong-status" }, + { status: 503 }, + ); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + const original = localStorage.getItem(DELETION_PENDING_KEY); + expect(original).not.toBeNull(); + const first = calls.find(([url]) => url === "/api/builderlab/delete")?.[1]; + expect(first?.request_id).toBe(JSON.parse(original ?? "").request.request_id); + routes["/api/builderlab/delete"] = (request) => + Response.json(accepted(request), { status: 202 }); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByText("Deletion started")).toBeVisible(); + const admissions = calls.filter(([url]) => url === "/api/builderlab/delete"); + expect(admissions).toHaveLength(2); + expect(admissions[1]?.[1]).toEqual(first); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect(calls.filter(([url]) => url === "/api/builderlab/auth")).toHaveLength( + 3, + ); // StrictMode startup twice, then the fresh capability check. + expect( + calls.filter(([url]) => url === "/api/builderlab/list").length, + ).toBeGreaterThan(2); // Startup and the fresh bound-owner check before replay. +}); + +it("ends pending recovery on a definitive UUID retarget conflict", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + new Response("{", { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + const saved = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); + routes["/api/builderlab/delete"] = () => + Response.json({ error: { code: "deletion_conflict" } }, { status: 409 }); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "This deletion conflicts with another community lifecycle change", + ); + expect(calls.filter(([url]) => url === "/api/builderlab/delete")).toEqual([ + ["/api/builderlab/delete", saved.request], + ["/api/builderlab/delete", saved.request], + ]); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect(screen.queryByText("Deletion started")).not.toBeInTheDocument(); +}); + +it("ends pending recovery when the owner unarchived before the replay", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + new Response("{", { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + const saved = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); + routes["/api/builderlab/list"] = () => ({ + communities: [{ ...archived, archived_at: null }], + }); + routes["/api/builderlab/delete"] = () => + Response.json({ error: { code: "must_archive" } }, { status: 409 }); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Archive the community before deleting it.", + ); + expect(calls.filter(([url]) => url === "/api/builderlab/delete")).toEqual([ + ["/api/builderlab/delete", saved.request], + ["/api/builderlab/delete", saved.request], + ]); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect( + screen.queryByRole("button", { name: "Check deletion status" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Deletion started")).not.toBeInTheDocument(); +}); + +it("terminates pending recovery on a bound aborted 202", async () => { + const request = { + community_id: archived.id, + host: archived.normalized_host, + request_id: "44444444-4444-4444-8444-444444444444", + acknowledgement_version: 1, + }; + localStorage.setItem( + DELETION_PENDING_KEY, + JSON.stringify({ + version: 1, + owner_pubkey: local, + backend_origin: window.location.origin, + request, + }), + ); + routes["/api/builderlab/delete"] = () => + Response.json( + { + ...request, + status: "aborted", + correlation_id: "corr-aborted", + }, + { status: 202 }, + ); + renderCard(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + `Deletion of ${archived.normalized_host} stopped. This community is not being deleted. Correlation ID: corr-aborted`, + ); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect( + screen.queryByText("Deletion status is unknown"), + ).not.toBeInTheDocument(); +}); + +it("keeps an unbound aborted result pending", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { + status: "aborted", + correlation_id: "corr-unbound-abort", + }, + { status: 202 }, + ); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).not.toBeNull(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Correlation ID: corr-unbound-abort", + ); +}); + +it("disables deletion for every row when a pending slot exists in another mounted card", async () => { + const south = { + ...archived, + id: "22222222-2222-4222-8222-222222222222", + name: "south", + normalized_host: "South.communities.buzz.xyz", + }; + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "acceptance_unknown" }, correlation_id: "corr-slot" }, + { status: 503 }, + ); + render( true} />); + fireEvent.click( + ( + await screen.findAllByRole("button", { name: "Delete" }) + )[0] as HTMLElement, + ); + const dialog = await screen.findByRole("dialog", { + name: /Permanently delete north/, + }); + fireEvent.change(within(dialog).getByLabelText("Type the exact host"), { + target: { value: archived.normalized_host }, + }); + fireEvent.click( + within(dialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + fireEvent.click( + within(dialog).getByRole("button", { name: "Start deletion" }), + ); + await screen.findByText("Deletion status is unknown"); + const original = localStorage.getItem(DELETION_PENDING_KEY); + routes["/api/builderlab/list"] = () => ({ + communities: [archived, south], + }); + render( true} />); + await waitFor(() => + expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(3), + ); + for (const button of screen.getAllByRole("button", { name: "Delete" })) + expect(button).toBeDisabled(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(original); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete"), + ).toHaveLength(1); +}); + +it("does not replay when capability is revoked on the fresh check", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "acceptance_unknown" }, correlation_id: "corr-first" }, + { status: 503 }, + ); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion status is unknown"); + routes["/api/builderlab/auth"] = () => ({ + auth: { email: "a@example.com", expiresAt: "2030", capabilities: {} }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Community deletion is no longer available", + ); + expect( + screen.getByRole("button", { name: "Check deletion status" }), + ).toBeDisabled(); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete"), + ).toHaveLength(1); + expect(localStorage.getItem(DELETION_PENDING_KEY)).not.toBeNull(); +}); + +it("replays the same UUID when the archived owner row is no longer listed", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json({ error: { code: "acceptance_unknown" } }, { status: 503 }); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion status is unknown"); + const original = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); + routes["/api/builderlab/list"] = () => ({ communities: [] }); + routes["/api/builderlab/delete"] = (body) => + Response.json({ ...body, status: "postgres_purged" }, { status: 202 }); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByText("Deletion started")).toBeVisible(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + const admissions = calls.filter(([url]) => url === "/api/builderlab/delete"); + expect(admissions).toHaveLength(2); + expect(admissions[0]?.[1]).toEqual(original.request); + expect(admissions[1]?.[1]).toEqual(original.request); +}); + +it("rechecks capability after the fresh owner list resolves", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "acceptance_unknown" }, correlation_id: "corr-first" }, + { status: 503 }, + ); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion status is unknown"); + const list = hold(); + routes["/api/builderlab/list"] = list.answer as Handler; + const listCalls = calls.filter( + ([url]) => url === "/api/builderlab/list", + ).length; + const retry = screen.getByRole("button", { + name: "Check deletion status", + }); + await waitFor(() => expect(retry).toBeEnabled()); + fireEvent.click(retry); + await waitFor(() => + expect( + calls.filter(([url]) => url === "/api/builderlab/list").length, + ).toBeGreaterThan(listCalls), + ); + routes["/api/builderlab/auth"] = () => ({ + auth: { email: "a@example.com", expiresAt: "2030", capabilities: {} }, + }); + await act(async () => list.release({ communities: [archived] })); + await screen.findByRole("alert"); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete"), + ).toHaveLength(1); + expect(localStorage.getItem(DELETION_PENDING_KEY)).not.toBeNull(); +}); + +it("keeps an accepted row hidden if a stale list returns after an omission", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (body) => + Response.json(accepted(body), { status: 202 }); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion started")).toBeVisible(); + const refresh = screen.getByRole("button", { name: "Refresh" }); + await waitFor(() => expect(refresh).toBeEnabled()); + routes["/api/builderlab/list"] = () => ({ communities: [] }); + let listCalls = calls.filter( + ([url]) => url === "/api/builderlab/list", + ).length; + fireEvent.click(refresh); + await waitFor(() => + expect( + calls.filter(([url]) => url === "/api/builderlab/list").length, + ).toBeGreaterThan(listCalls), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + listCalls = calls.filter(([url]) => url === "/api/builderlab/list").length; + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => + expect( + calls.filter(([url]) => url === "/api/builderlab/list").length, + ).toBeGreaterThan(listCalls), + ); + expect( + screen.queryByText("North.communities.buzz.xyz"), + ).not.toBeInTheDocument(); + const original = deletionPosts()[0]?.[1]; + expect(deletionPosts()).toEqual([ + ["/api/builderlab/delete", original], + ["/api/builderlab/delete", original], + ]); +}); + +it.each([ + [ + "uncertain", + () => + Response.json({ error: { code: "acceptance_unknown" } }, { status: 503 }), + ], + [ + "in progress", + (request: Record) => + Response.json({ ...request, status: "cache_purged" }, { status: 202 }), + ], +])( + "retains an accepted row on a stale-list %s replay", + async (_label, replay) => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (body) => + Response.json(accepted(body), { status: 202 }); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion started")).toBeVisible(); + const original = deletionPosts()[0]?.[1]; + routes["/api/builderlab/delete"] = replay; + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(deletionPosts()).toHaveLength(2)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + expect(deletionPosts().map(([, body]) => body)).toEqual([ + original, + original, + ]); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(deletionPosts()).toHaveLength(3)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + expect(deletionPosts().map(([, body]) => body)).toEqual([ + original, + original, + original, + ]); + expect( + screen.queryByText(/North\.communities\.buzz\.xyz · Archived/), + ).not.toBeInTheDocument(); + expect(screen.getByText("Deletion started")).toBeVisible(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + }, +); + +it("does not replay an accepted row during Refresh when capability is off", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (body) => + Response.json(accepted(body), { status: 202 }); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion started"); + routes["/api/builderlab/auth"] = () => ({ + auth: { expiresAt: "2030", capabilities: {} }, + }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + expect(deletionPosts()).toHaveLength(1); + expect( + screen.queryByText(/North\.communities\.buzz\.xyz · Archived/), + ).not.toBeInTheDocument(); +}); + +it("stops accepted-row replay when the card retires mid-Refresh", async () => { + const second = { + ...archived, + id: "22222222-2222-4222-8222-222222222222", + name: "south", + normalized_host: "South.communities.buzz.xyz", + }; + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (body) => + Response.json(accepted(body), { status: 202 }); + let live = true; + render( live} />); + await confirmDeletion(); + await screen.findByText("Deletion started"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + routes["/api/builderlab/list"] = () => ({ communities: [archived, second] }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + fireEvent.click(await screen.findByRole("button", { name: "Delete" })); + const secondDialog = await screen.findByRole("dialog", { + name: /Permanently delete south/, + }); + fireEvent.change(within(secondDialog).getByLabelText("Type the exact host"), { + target: { value: second.normalized_host }, + }); + fireEvent.click( + within(secondDialog).getByRole("checkbox", { + name: /I understand this cannot be canceled/, + }), + ); + fireEvent.click( + within(secondDialog).getByRole("button", { name: "Start deletion" }), + ); + await waitFor(() => expect(deletionPosts()).toHaveLength(3)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(), + ); + const beforeReplay = deletionPosts().length; + const firstReplay = hold(); + routes["/api/builderlab/delete"] = firstReplay.answer as Handler; + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(deletionPosts()).toHaveLength(beforeReplay + 1)); + live = false; + await act(async () => + firstReplay.release( + Response.json( + { ...deletionPosts()[beforeReplay]?.[1], status: "aborted" }, + { status: 202 }, + ), + ), + ); + expect(deletionPosts()).toHaveLength(beforeReplay + 1); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Deletion started")).toBeVisible(); + expect( + screen.queryByText( + `Deletion of ${archived.normalized_host} stopped. This community is not being deleted.`, + ), + ).not.toBeInTheDocument(); +}); + +it("shows a Refresh replay error without uncovering an accepted row", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = (body) => + Response.json(accepted(body), { status: 202 }); + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion started"); + const replay = hold(); + routes["/api/builderlab/delete"] = replay.answer as Handler; + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(deletionPosts()).toHaveLength(2)); + await act(async () => + replay.release( + Response.json({ error: { code: "relay_unavailable" } }, { status: 503 }), + ), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Couldn't check deletion status.", + ); + expect(screen.getByText("Deletion started")).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); +}); + +it.each(["accepted", "bound abort", "definitive rejection"])( + "generation-fences a late %s after an A to B to A account sequence", + async (outcome) => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + const admission = hold(); + routes["/api/builderlab/delete"] = admission.answer as Handler; + const view = render( true} />); + await confirmDeletion(); + await waitFor(() => + expect(calls.map(([url]) => url)).toContain("/api/builderlab/delete"), + ); + const old = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); + view.unmount(); + const middle = { + ...old, + owner_pubkey: other, + request: { + ...old.request, + request_id: "55555555-5555-4555-8555-555555555555", + }, + }; + localStorage.setItem(DELETION_PENDING_KEY, JSON.stringify(middle)); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: other }, + }); + const middleView = render( true} />); + await screen.findByText(middle.request.request_id); + middleView.unmount(); + const next = { + ...old, + request: { + ...old.request, + request_id: "66666666-6666-4666-8666-666666666666", + }, + }; + localStorage.setItem(DELETION_PENDING_KEY, JSON.stringify(next)); + routes["/api/builderlab/identity"] = () => ({ + identity: { pubkey_hex: local }, + }); + render( true} />); + await screen.findByText(next.request.request_id); + await act(async () => + admission.release( + outcome === "accepted" + ? Response.json(accepted(old.request), { status: 202 }) + : outcome === "bound abort" + ? Response.json( + { + ...old.request, + status: "aborted", + correlation_id: "corr-late-abort", + }, + { status: 202 }, + ) + : Response.json( + { + error: { code: "must_archive" }, + correlation_id: "corr-late-rejection", + }, + { status: 409 }, + ), + ), + ); + await act(async () => Promise.resolve()); + expect( + JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""), + ).toEqual(next); + }, +); diff --git a/src/bundled/hosted-communities/HostedCommunities.tsx b/src/bundled/hosted-communities/HostedCommunities.tsx index ffe23aa45..bf13d7eed 100644 --- a/src/bundled/hosted-communities/HostedCommunities.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.tsx @@ -10,6 +10,7 @@ import { import { npubEncode } from "nostr-tools/nip19"; import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; import { Button } from "../../shared/design-system/ui/Button"; +import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { Dialog } from "../../shared/design-system/ui/Dialog"; import { Field } from "../../shared/design-system/ui/Field"; import { Input } from "../../shared/design-system/ui/Input"; @@ -23,16 +24,25 @@ import { CircleNotchIcon, LinkBreakIcon, SignOutIcon, + TrashIcon, WarningCircleIcon, } from "../../shared/design-system/icons"; import { + admitDeletion, + ApiFailure, boundKey, call, check, + clearPendingDeletion, getAuth, HOST_SUFFIX, - LIMIT, + isDefinitiveDeletionRejection, login, + makePendingDeletion, + persistPendingDeletion, + quota, + quotaLimitMessage, + readPendingDeletion, relayUrl, signOut, Unsupported, @@ -40,6 +50,8 @@ import { type Account, type Community, type Identity, + type PendingDeletion, + type Quota, } from "./api"; const card = "mt-6 rounded-xl border border-default p-5"; @@ -54,9 +66,13 @@ type Confirm = { }; export function HostedCommunities({ active }: { active(): boolean }) { + const activeRef = useRef(active); + activeRef.current = active; const [auth, setAuth] = useState(); const [identity, setIdentity] = useState(null); + const [identityLoadFailed, setIdentityLoadFailed] = useState(false); const [communities, setCommunities] = useState([]); + const [quotaState, setQuotaState] = useState(null); // This device's key: undefined while loading, null when it could not be read. const [local, setLocal] = useState(); const [unsupported, setUnsupported] = useState(""); @@ -64,6 +80,11 @@ export function HostedCommunities({ active }: { active(): boolean }) { const [error, setError] = useState(""); const [confirm, setConfirm] = useState(null); const [transfer, setTransfer] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [pendingDeletion, setPendingDeletion] = + useState(null); + const [blockedOwner, setBlockedOwner] = useState(null); + const [deletionNotice, setDeletionNotice] = useState(""); // The last address handed off for joining, and whether the clipboard took it. const [handoff, setHandoff] = useState<{ url: string; @@ -73,19 +94,122 @@ export function HostedCommunities({ active }: { active(): boolean }) { const loginAbort = useRef(null); // Bumped by every operation and unmount; a read applies only if none happened since it began. const generation = useRef(0); + const actionOwner = useRef(0); + const acceptedDeletions = useRef(new Map()); + const loadedOwner = useRef(undefined); - const load = useCallback(async () => { + const load = useCallback(async (reconcileAccepted = false) => { const at = generation.current; const [current, list] = await Promise.all([call("identity"), call("list")]); - if (at !== generation.current) return; - // An account without a linked identity is the connect state, not a failure. - if (current.error?.code !== "unauthorized" && !current.error?.setup_needed) + if (at !== generation.current) return null; + // A setup-needed mapping is the connect state. An upstream unauthorized + // response may also be an expired session (dev/builderlab.mjs forwards it). + if ( + current.error?.code === "unauthorized" || + list.error?.code === "unauthorized" + ) { + setIdentityLoadFailed(true); + setIdentity(null); + setCommunities([]); + setQuotaState(null); + setPendingDeletion(null); + setBlockedOwner(null); + setDeletionNotice(""); + } + if (!current.error?.setup_needed) check(current, "Could not load the connected Buzz identity."); if (!list.error?.setup_needed) check(list, "Could not load communities."); - setIdentity(current.identity ?? null); - setCommunities(list.communities ?? []); + setIdentityLoadFailed(false); + const nextIdentity = current.identity ?? null; + const nextOwner = boundKey(nextIdentity); + if ( + loadedOwner.current !== undefined && + loadedOwner.current !== nextOwner + ) { + acceptedDeletions.current.clear(); + setDeletionNotice(""); + } + loadedOwner.current = nextOwner; + const stored = readPendingDeletion(); + setBlockedOwner( + stored && + (stored.owner_pubkey !== nextOwner || + stored.backend_origin !== window.location.origin) + ? stored.owner_pubkey + : null, + ); + setPendingDeletion( + stored?.owner_pubkey === nextOwner && + stored.backend_origin === window.location.origin + ? stored + : null, + ); + const listed = list.communities ?? []; + if ( + reconcileAccepted && + nextOwner && + listed.some((community) => + acceptedDeletions.current.has(community.id ?? ""), + ) + ) { + const currentAuth = await getAuth().catch(() => null); + if (at !== generation.current || !activeRef.current()) return null; + if (currentAuth?.capabilities?.can_delete_buzz_communities === true) + for (const community of listed) { + if (at !== generation.current || !activeRef.current()) return null; + const accepted = acceptedDeletions.current.get(community.id ?? ""); + if (!accepted || accepted.owner_pubkey !== nextOwner) continue; + try { + await admitDeletion(accepted.request, "recovery"); + } catch (reason) { + if ( + at === generation.current && + activeRef.current() && + acceptedDeletions.current.get(accepted.request.community_id) === + accepted + ) { + if ( + reason instanceof ApiFailure && + reason.code === "deletion_aborted" + ) { + acceptedDeletions.current.delete(accepted.request.community_id); + if (acceptedDeletions.current.size === 0) setDeletionNotice(""); + setError( + `Deletion of ${accepted.request.host} stopped. This community is not being deleted.`, + ); + } else setError("Couldn't check deletion status."); + } + } + if (at !== generation.current || !activeRef.current()) return null; + } + } + const nextCommunities = listed.filter( + (community) => + !community.id || !acceptedDeletions.current.has(community.id), + ); + const nextQuota = quota(list); + setIdentity(nextIdentity); + setCommunities(nextCommunities); + setQuotaState(nextQuota); + return { identity: nextIdentity, communities: nextCommunities }; }, []); + const markDeletionAccepted = useCallback( + (pending: PendingDeletion, at: number) => { + if (at !== generation.current || !activeRef.current()) return false; + clearPendingDeletion(pending); + acceptedDeletions.current.set(pending.request.community_id, pending); + setPendingDeletion(null); + setCommunities((list) => + list.filter((item) => item.id !== pending.request.community_id), + ); + setDeletionNotice("Deletion started"); + setError(""); + return true; + }, + [], + ); + const localRead = useRef(0); const loadLocal = useCallback(() => { const at = ++localRead.current; @@ -125,9 +249,10 @@ export function HostedCommunities({ active }: { active(): boolean }) { }, [load, loadLocal]); /** Runs one account operation at a time; resolves whether it succeeded. */ - async function run(label: string, operation: () => Promise) { - if (!active()) return false; + async function run(label: string, operation: () => Promise) { + if (!activeRef.current()) return false; const at = ++generation.current; + const owner = ++actionOwner.current; setAction(label); setError(""); try { @@ -137,12 +262,12 @@ export function HostedCommunities({ active }: { active(): boolean }) { if (at === generation.current) setError(message(reason)); return false; } finally { - if (at === generation.current) setAction(null); + if (owner === actionOwner.current) setAction(null); } } const copy = async (url: string) => { // A retired card must not start a clipboard write. - if (!active()) return; + if (!activeRef.current()) return; const at = handoffOwner.current; const copied = await Promise.resolve() .then(() => navigator.clipboard.writeText(url)) @@ -163,6 +288,55 @@ export function HostedCommunities({ active }: { active(): boolean }) { ), ); }; + const settleDeletion = async ( + pending: PendingDeletion, + at: number, + operation: () => Promise, + ) => { + try { + await operation(); + if (!markDeletionAccepted(pending, at)) return false; + await settle(); + return true; + } catch (reason) { + if (at !== generation.current || !activeRef.current()) return false; + if ( + (reason instanceof ApiFailure && reason.code === "deletion_aborted") || + isDefinitiveDeletionRejection(reason) + ) { + clearPendingDeletion(pending); + setPendingDeletion(null); + if (reason instanceof ApiFailure && reason.code === "deletion_aborted") + await load().catch(() => undefined); + } else setPendingDeletion(pending); + throw reason; + } + }; + const checkPendingDeletion = (pending: PendingDeletion) => + run("delete", async () => { + const at = generation.current; + const snapshot = await load(); + if (at !== generation.current || !activeRef.current()) return; + const currentAuth = await getAuth(); + if (at !== generation.current || !activeRef.current()) return; + const stored = readPendingDeletion(); + if ( + !snapshot || + !stored || + JSON.stringify(stored) !== JSON.stringify(pending) || + stored.backend_origin !== window.location.origin || + boundKey(snapshot.identity) !== pending.owner_pubkey + ) + throw new Error( + "This deletion request no longer matches the current account. Sign in with the original account to check its status.", + ); + setAuth(currentAuth); + if (currentAuth?.capabilities?.can_delete_buzz_communities !== true) + throw new Error("Community deletion is no longer available."); + await settleDeletion(pending, at, () => + admitDeletion(pending.request, "recovery"), + ); + }); const busy = action !== null; // Repeated inside open dialogs, whose modal backdrop hides the page copy. const failure = error && ( @@ -181,6 +355,8 @@ export function HostedCommunities({ active }: { active(): boolean }) { Boolean(identity) && (!bound || (Boolean(local) && bound !== local)); // Acting requires this device's key to be known and to match the account's. const ready = bound !== null && bound === local; + const deletionEnabled = + auth?.capabilities?.can_delete_buzz_communities === true; // A handoff belongs to the bound identity: whenever it changes, by a local // action or a refresh, drop the handoff and any clipboard result in flight. // Layout effect, so a stale handoff is never painted beside the new identity. @@ -306,9 +482,11 @@ export function HostedCommunities({ active }: { active(): boolean }) { onClick={() => void run("sign-out", async () => { await signOut(); + setPendingDeletion(null); setAuth(null); setIdentity(null); setCommunities([]); + setQuotaState(null); }) } > @@ -316,25 +494,27 @@ export function HostedCommunities({ active }: { active(): boolean }) { {!identity ? ( -

-

- Link this account to your Buzz identity -

-

- This Builderlab account isn’t linked to a Buzz identity yet. - Connect this device’s key to create and own communities under it - — Buzz signs a one-time challenge locally, so your private key - never leaves this computer. -

- -
+ identityLoadFailed ? null : ( +
+

+ Link this account to your Buzz identity +

+

+ This Builderlab account isn’t linked to a Buzz identity yet. + Connect this device’s key to create and own communities under + it — Buzz signs a one-time challenge locally, so your private + key never leaves this computer. +

+ +
+ ) ) : local === undefined && bound ? (

Checking this device’s Buzz identity… @@ -376,9 +556,10 @@ export function HostedCommunities({ active }: { active(): boolean }) { await call("unbind"), "Could not release the previously connected Buzz identity.", ); + setPendingDeletion(null); // Unbound is a valid resting state; Connect recovers it. setIdentity(null); - if (active()) await bind(); + if (activeRef.current()) await bind(); }) } > @@ -411,6 +592,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { await call("unbind"), "Could not unpair the Buzz identity.", ); + setPendingDeletion(null); setIdentity(null); await settle(); }, @@ -424,19 +606,71 @@ export function HostedCommunities({ active }: { active(): boolean }) {

Your communities{" "} - - {communities.length} of {LIMIT} used - + {quotaState && ( + + {quotaState.used} of {quotaState.limit} used + + )}

+ {deletionNotice && ( +

+ {deletionNotice} +

+ )} + {pendingDeletion && ( +
+

Deletion status is unknown

+

+ Buzz will not check automatically. Use Check deletion status + when deletion is available. This resends the same request UUID, + which may admit the original intent; a failed check does not + prove the earlier request was never accepted. +

+

+ {pendingDeletion.request.host} +

+

+ If this remains uncertain, contact support and include this + Request UUID: +

+

+ {pendingDeletion.request.request_id} +

+
+ +
+ {!deletionEnabled && ( +

+ Community deletion is unavailable right now, so this request + can't be checked. It stays saved on this device. +

+ )} +
+ )} + {deletionEnabled && blockedOwner && ( +

+ A deletion request from {npub(blockedOwner)} is still pending on + this device. Switch to that Buzz identity and use Check deletion + status before starting another deletion here. If you no longer + have that identity, contact support. +

+ )} {communities.length === 0 ? (

No hosted communities yet. @@ -454,6 +688,8 @@ export function HostedCommunities({ active }: { active(): boolean }) { community.name ?? community.slug ?? "Hosted community"; const url = relayUrl(community); const archived = Boolean(community.archived_at); + const deletionPending = + pendingDeletion?.request.community_id === community.id; return (

  • {archived ? ( - + <> + + {deletionEnabled && + ready && + community.id && + community.normalized_host && ( + + )} + ) : ( <> {url && ready && ( @@ -565,8 +822,12 @@ export function HostedCommunities({ active }: { active(): boolean }) {
    )} = LIMIT} + enabled={ready && quotaState?.canCreate !== false} + atLimit={ + quotaState?.canCreate === false + ? quotaLimitMessage(quotaState.limit) + : null + } busy={busy} creating={action === "create"} onCreate={(name) => @@ -574,6 +835,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { const reply = check( await call("create", { name }), "Could not create the community.", + quotaState?.limit, ); // Hand off the address before refreshing, so a failed refresh cannot lose it. const url = reply.community && relayUrl(reply.community); @@ -584,6 +846,63 @@ export function HostedCommunities({ active }: { active(): boolean }) { /> )} + {deleteTarget && bound && ( + setDeleteTarget(null)} + onDelete={() => { + const occupied = readPendingDeletion(); + if (blockedOwner && !occupied) { + setBlockedOwner(null); + setError("Refresh and try again."); + return; + } + if (blockedOwner || occupied) { + if ( + occupied?.owner_pubkey === bound && + occupied.backend_origin === window.location.origin + ) { + setPendingDeletion(occupied); + setError( + "Deletion was not sent. This identity already has a pending deletion request. Use Check deletion status.", + ); + } else if (occupied) { + setBlockedOwner(occupied.owner_pubkey); + setError( + `Deletion was not sent. A deletion request from ${npub(occupied.owner_pubkey)} is already pending on this device.`, + ); + } + return; + } + let pending: PendingDeletion; + try { + pending = makePendingDeletion(bound, deleteTarget); + persistPendingDeletion(pending); + } catch { + const stored = readPendingDeletion(); + if ( + stored?.owner_pubkey === bound && + stored.backend_origin === window.location.origin + ) + setPendingDeletion(stored); + setError( + "Deletion was not sent because its recovery record could not be saved.", + ); + return; + } + setPendingDeletion(pending); + setDeleteTarget(null); + void run("delete", async () => { + const at = generation.current; + await settleDeletion(pending, at, () => + admitDeletion(pending.request, "fresh"), + ); + }); + }} + /> + )} {confirm && ( setTransfer(null)} onTransfer={(recipient) => run("transfer", async () => { - check( - await call("transfer", { - communityId: transfer.id ?? "", - transfereeNpub: recipient, - }), - "Could not transfer ownership.", - ); + const reply = await call("transfer", { + communityId: transfer.id ?? "", + transfereeNpub: recipient, + }); + if (reply.error?.code === "limit_reached") + throw new ApiFailure( + "limit_reached", + "The recipient has reached their community limit.", + reply.correlation_id, + ); + check(reply, "Could not transfer ownership."); // The community is no longer owned; drop it before refreshing. setCommunities((list) => list.filter((item) => item.id !== transfer.id), @@ -639,6 +962,73 @@ export function HostedCommunities({ active }: { active(): boolean }) { ); } +function DeleteCommunityDialog({ + community, + pending, + failure, + close, + onDelete, +}: { + community: Community; + pending: boolean; + failure: ReactNode; + close(): void; + onDelete(): void; +}) { + const [host, setHost] = useState(""); + const [acknowledged, setAcknowledged] = useState(false); + const expected = community.normalized_host ?? ""; + const name = community.name ?? community.slug ?? "this community"; + const confirmed = host === expected && acknowledged; + return ( + !open && close()} + title={`Permanently delete ${name}?`} + description="This starts an irreversible deletion." + preventClose={pending} + actions={ + <> + + + + } + > +
    +

    + This request cannot be canceled by an owner. All community content + will be deleted eventually, the host stays permanently reserved, and + your quota slot is released only after logical cleanup finishes. +

    + + + +

    {expected}

    + setAcknowledged(checked === true)} + label="I understand this cannot be canceled and deletion continues after acceptance." + /> + {failure} +
    +
    + ); +} + function CreateCommunity({ enabled, atLimit, @@ -647,7 +1037,7 @@ function CreateCommunity({ onCreate, }: { enabled: boolean; - atLimit: boolean; + atLimit: string | null; busy: boolean; creating: boolean; onCreate(name: string): Promise; @@ -692,12 +1082,7 @@ function CreateCommunity({

    Choose the address your team will use to connect.

    - {atLimit && ( -

    - You’ve reached the limit of {LIMIT} hosted communities. Transfer one - to free up a slot before creating another. -

    - )} + {atLimit &&

    {atLimit}

    } localStorage.clear()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("replays the same deletion tuple once after an ambiguous dispatch", async () => { + const requests: [string, DeletionRequest][] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + requests.push([url, JSON.parse(String(init.body))]); + if (requests.length === 1) throw new TypeError("EOF"); + return Response.json( + { ...request, status: "retention_pending" }, + { status: 202 }, + ); + }), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); + expect(requests).toEqual([["/api/builderlab/delete", request]]); + await expect(admitDeletion(request, "recovery")).resolves.toMatchObject({ + ...request, + status: "retention_pending", + }); + expect(requests).toEqual([ + ["/api/builderlab/delete", request], + ["/api/builderlab/delete", request], + ]); +}); + +it("treats a tuple-bound aborted 202 replay as terminal, not progress", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ ...request, status: "aborted" }, { status: 202 }), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "deletion_aborted", + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it("treats a 409 UUID retarget conflict as definitive on replay", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ error: { code: "deletion_conflict" } }, { status: 409 }), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "deletion_conflict", + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it.each(["EOF", "relay 503"])( + "keeps %s ambiguous without automatically replaying", + async (failure) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + if (failure === "EOF") throw new TypeError("EOF"); + return Response.json( + { error: { code: "relay_unavailable" } }, + { status: 503 }, + ); + }), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); + }, +); + +it.each([ + ["invalid JSON", "{"], + [ + "extra public field", + JSON.stringify({ + version: 1, + owner_pubkey: "a".repeat(64), + backend_origin: window.location.origin, + request: { ...request, owner_pubkey: "a".repeat(64) }, + }), + ], + [ + "noncanonical UUID", + JSON.stringify({ + version: 1, + owner_pubkey: "a".repeat(64), + backend_origin: window.location.origin, + request: { + ...request, + request_id: "abcdefab-cdef-4abc-8def-abcdefabcdef".toUpperCase(), + }, + }), + ], + [ + "different acknowledgement", + JSON.stringify({ + version: 1, + owner_pubkey: "a".repeat(64), + backend_origin: window.location.origin, + request: { ...request, acknowledgement_version: 2 }, + }), + ], +])("discards a stored envelope with %s", (_label, raw) => { + localStorage.setItem(DELETION_PENDING_KEY, raw); + expect(readPendingDeletion()).toBeNull(); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); +}); + +it("rejects a mismatched admission response without a second request", async () => { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + return Response.json( + { ...request, community_id: "wrong", status: "submitted" }, + { status: 202 }, + ); + }), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); + expect(calls).toEqual(["/api/builderlab/delete"]); +}); + +it("does not infer noncommit after a lost dispatch response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("EOF"); + }), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it.each([ + ["missing_mapping", 400], + ["invalid_request", 400], + ["confirmation_mismatch", 400], + ["unsupported_acknowledgement_version", 400], + ["not_owner", 404], + ["must_archive", 409], + ["protected_target", 409], + ["deletion_conflict", 409], +])( + "terminates a fresh trustworthy structured %s/%i rejection", + async (code, status) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + error: { code }, + correlation_id: `corr-${code}`, + }, + { status }, + ), + ), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code, + correlationId: `corr-${code}`, + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); + }, +); + +it.each([ + ["must_archive", 409], + ["not_owner", 404], + ["protected_target", 409], + ["deletion_conflict", 409], +])("settles recovery on relay verdict %s/%i", async (code, status) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ error: { code } }, { status })), + ); + const reason = await admitDeletion(request, "recovery").catch((e) => e); + expect(reason).toMatchObject({ code } satisfies Partial); + expect(isDefinitiveDeletionRejection(reason)).toBe(true); +}); + +it.each([ + ["missing_mapping", 400], + ["invalid_request", 400], + ["confirmation_mismatch", 400], + ["unsupported_acknowledgement_version", 400], + ["must_archive", 400], + ["not_owner", 409], +])("keeps a recovery %s/%i rejection ambiguous", async (code, status) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ error: { code } }, { status })), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); +}); + +it("does not treat an unknown relay stage as progress", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ ...request, status: "accepted" }, { status: 202 }), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); +}); + +it.each([ + [ + "broker string error", + () => Response.json({ error: "upstream failed" }, { status: 502 }), + ], + ["malformed response", () => new Response("{", { status: 502 })], + [ + "unknown structured rejection", + () => + Response.json({ error: { code: "future_rejection" } }, { status: 409 }), + ], + [ + "wrong-status missing_mapping", + () => + Response.json({ error: { code: "missing_mapping" } }, { status: 409 }), + ], + [ + "wrong-status not_owner", + () => Response.json({ error: { code: "not_owner" } }, { status: 400 }), + ], + [ + "wrong-status deletion_conflict", + () => + Response.json({ error: { code: "deletion_conflict" } }, { status: 400 }), + ], + [ + "wrong-status must_archive", + () => Response.json({ error: { code: "must_archive" } }, { status: 503 }), + ], + [ + "relay_unavailable/409", + () => + Response.json({ error: { code: "relay_unavailable" } }, { status: 409 }), + ], + [ + "relay_unavailable/502", + () => + Response.json({ error: { code: "relay_unavailable" } }, { status: 502 }), + ], + [ + "relay_unavailable/503", + () => + Response.json({ error: { code: "relay_unavailable" } }, { status: 503 }), + ], + [ + "unauthorized/401", + () => Response.json({ error: { code: "unauthorized" } }, { status: 401 }), + ], + [ + "unauthorized/403", + () => Response.json({ error: { code: "unauthorized" } }, { status: 403 }), + ], + [ + "timeout", + () => Promise.reject(new DOMException("timed out", "TimeoutError")), + ], + ["network EOF", () => Promise.reject(new TypeError("EOF"))], +])("keeps a fresh %s ambiguous", async (_label, firstResponse) => { + vi.stubGlobal("fetch", vi.fn(firstResponse)); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it("preserves the original ambiguous response correlation", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + error: { code: "relay_unavailable" }, + correlation_id: "corr-admission", + }, + { status: 503 }, + ), + ), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + correlationId: "corr-admission", + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it.each(["relay_unavailable", "not_owner"])( + "treats retry admission %s as uncertain instead of proof of noncommit", + async (code) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { error: { code }, correlation_id: `corr-admission-${code}` }, + { status: code === "not_owner" ? 403 : 503 }, + ), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "acceptance_unknown", + correlationId: `corr-admission-${code}`, + } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); + }, +); + +it("does not accept an unbound aborted 202 as terminal", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + status: "aborted", + correlation_id: "corr-unbound-abort", + }, + { status: 202 }, + ), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "acceptance_unknown", + correlationId: "corr-unbound-abort", + } satisfies Partial); +}); + +it("accepts only a full tuple-bound aborted 202 as terminal", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + ...request, + status: "aborted", + correlation_id: "corr-bound-abort", + }, + { status: 202 }, + ), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "deletion_aborted", + correlationId: "corr-bound-abort", + } satisfies Partial); +}); + +it("requires HTTP 202 for a tuple-bound accepted result", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ ...request, status: "submitted" }, { status: 200 }), + ), + ); + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); +}); + +it("refuses to replace a different pending deletion", () => { + persistPendingDeletion(pending); + const replacement = { + ...pending, + request: { + ...request, + request_id: "33333333-3333-4333-8333-333333333333", + }, + }; + expect(() => persistPendingDeletion(replacement)).toThrow(/already pending/i); + expect(readPendingDeletion()).toEqual(pending); +}); + +it("does not clear a different pending envelope", () => { + persistPendingDeletion(pending); + clearPendingDeletion({ + ...pending, + request: { + ...pending.request, + request_id: "33333333-3333-4333-8333-333333333333", + }, + }); + expect(readPendingDeletion()).toEqual(pending); +}); + +it("requires the exact pending tuple to be readable after persistence", () => { + const getItem = vi.spyOn(Storage.prototype, "getItem"); + getItem.mockReturnValueOnce(null).mockReturnValueOnce(null); + expect(() => persistPendingDeletion(pending)).toThrow( + /could not be verified/i, + ); +}); + +it.each([ + ["request_id", "33333333-3333-4333-8333-333333333333"], + ["community_id", "wrong-community"], + ["host", "north.communities.buzz.xyz"], + ["acknowledgement_version", 2], + ["status", "unknown_stage"], +])("rejects an admission response with mismatched %s", async (field, value) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { ...request, status: "submitted", [field]: value }, + { status: 202 }, + ), + ), + ); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + }); + expect(fetch).toHaveBeenCalledTimes(1); +}); diff --git a/src/bundled/hosted-communities/api.ts b/src/bundled/hosted-communities/api.ts index 4fa1fd705..6f23d2a9d 100644 --- a/src/bundled/hosted-communities/api.ts +++ b/src/bundled/hosted-communities/api.ts @@ -1,9 +1,19 @@ // Block-hosted community accounts through the development broker's /api/builderlab routes. export const HOST_SUFFIX = "communities.buzz.xyz"; -export const LIMIT = 5; export const VALID_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +export const ACKNOWLEDGEMENT_VERSION = 1; +export const DELETION_PENDING_KEY = "buzz.hosted-community-deletion.v1"; -export type Account = { email?: string; name?: string; expiresAt: string }; +const MAX_RESPONSE_BYTES = 64 * 1024; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export type Account = { + email?: string; + name?: string; + expiresAt: string; + capabilities?: { can_delete_buzz_communities?: boolean }; +}; export type ApiError = { code?: string; message?: string; @@ -17,6 +27,20 @@ export type Community = { normalized_host?: string; archived_at?: string | null; }; +export type DeletionRequest = { + community_id: string; + host: string; + request_id: string; + acknowledgement_version: 1; +}; +export type DeletionAttempt = "fresh" | "recovery"; +export type PendingDeletion = { + version: 1; + owner_pubkey: string; + backend_origin: string; + request: DeletionRequest; +}; +export type Quota = { used: number; limit: number; canCreate: boolean }; export type Reply = { error?: ApiError; correlation_id?: string; @@ -24,13 +48,70 @@ export type Reply = { communities?: Community[]; community?: Community; available?: boolean; + quota_used?: number; + quota_limit?: number; + can_create?: boolean; + request_id?: string; + community_id?: string; + host?: string; + acknowledgement_version?: number; + status?: string; }; +type Body = Record; -async function send( +const messages: Record = { + missing_mapping: "Connect your Buzz identity before creating a community.", + invalid_name: "Use lowercase letters, numbers, and hyphens.", + taken: "That Buzz address is already taken.", + limit_reached: "You've reached your community limit.", + unauthorized: "Your Builderlab session ended. Sign out, then sign in again.", + relay_unavailable: "Community provisioning is temporarily unavailable.", + identity_already_bound: + "This Builderlab account is connected to another Buzz identity.", + pubkey_already_bound: + "This Buzz identity is connected to another Builderlab account.", + not_owner: "Only the community owner can do that.", + transferee_not_registered: + "That person needs a connected Buzz identity before you can transfer ownership to them.", + invalid_request: "The deletion request is invalid.", + confirmation_mismatch: "The host confirmation does not match exactly.", + must_archive: "Archive the community before deleting it.", + protected_target: "This community cannot be deleted.", + deletion_conflict: + "This deletion conflicts with another community lifecycle change.", + unsupported_acknowledgement_version: + "This deletion confirmation version is not supported.", + acceptance_unknown: + "Deletion status is unknown. Keep this request and check its status; do not start a new deletion.", + unknown: + "The deletion service returned an invalid response. Check deletion status before trying anything else.", +}; + +export class ApiFailure extends Error { + constructor( + readonly code: string, + message: string, + readonly correlationId?: string, + ) { + super( + correlationId ? `${message} Correlation ID: ${correlationId}` : message, + ); + this.name = "ApiFailure"; + } +} + +export function quotaLimitMessage(limit?: number | null) { + if (limit === 0) return "You can't create more communities right now."; + return limit && Number.isSafeInteger(limit) && limit > 0 + ? `You've reached your limit of ${limit} communities.` + : "You've reached your community limit."; +} + +async function send( action: string, - body?: Record, + body?: Body, signal?: AbortSignal, -): Promise { +): Promise<{ status: number; value: T }> { const response = await fetch(`/api/builderlab/${action}`, { method: action === "auth" ? "GET" : "POST", ...(action === "auth" @@ -41,13 +122,27 @@ async function send( }), ...(signal ? { signal } : {}), }); - const value = await response.json().catch(() => ({})); - if (!response.ok) + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BYTES) + throw new Error("Builderlab response was too large"); + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error("Builderlab returned an invalid response"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Builderlab returned an invalid response"); + const error = (value as { error?: unknown }).error; + if (!response.ok && (!error || typeof error !== "object")) throw new Error( - value.error ?? `Builderlab request failed (${response.status})`, + typeof error === "string" + ? error + : `Builderlab request failed (${response.status})`, ); - return value; + return { status: response.status, value: value as T }; } + /** The host has no development broker, so Builderlab sign-in cannot work here. */ export class Unsupported extends Error {} export async function getAuth(): Promise { @@ -61,7 +156,6 @@ export async function getAuth(): Promise { (auth === null || typeof auth?.expiresAt === "string") ) return auth; - // A missing route or an app-shell page means no broker answered. if (response.ok || response.status === 404) throw new Unsupported( "Hosted communities need the Buzz development broker and are unavailable in this build.", @@ -71,36 +165,24 @@ export async function getAuth(): Promise { ); } export const login = (signal: AbortSignal) => - send<{ auth: Account }>("login", {}, signal).then((value) => value.auth); + send<{ auth: Account }>("login", {}, signal).then(({ value }) => value.auth); export const signOut = () => send("sign-out"); -export const call = (action: string, body?: Record) => - send(action, body); +export const call = (action: string, body?: Body) => + send(action, body).then(({ value }) => value); -const messages: Record = { - missing_mapping: "Connect your Buzz identity before creating a community.", - invalid_name: "Use lowercase letters, numbers, and hyphens.", - taken: "That Buzz address is already taken.", - limit_reached: `You've reached the limit of ${LIMIT} hosted communities.`, - relay_unavailable: "Community provisioning is temporarily unavailable.", - identity_already_bound: - "This Builderlab account is connected to another Buzz identity.", - pubkey_already_bound: - "This Buzz identity is connected to another Builderlab account.", - not_owner: "Only the community owner can do that.", - transferee_not_registered: - "That person needs a connected Buzz identity before you can transfer ownership to them.", -}; /** Throws a friendly message for a structured Builderlab error. */ -export function check(reply: Reply, fallback: string) { +export function check(reply: Reply, fallback: string, quotaLimit?: number) { if (!reply.error) return reply; - const message = - messages[reply.error.code ?? ""] ?? reply.error.message ?? fallback; - throw new Error( - reply.correlation_id - ? `${message} Correlation ID: ${reply.correlation_id}` - : message, + const code = reply.error.code ?? ""; + throw new ApiFailure( + code, + code === "limit_reached" + ? quotaLimitMessage(quotaLimit) + : (messages[code] ?? reply.error.message ?? fallback), + reply.correlation_id, ); } + /** The hex key the account is bound to, or null when the server sent no usable key. */ export function boundKey(identity: Identity | null | undefined) { const hex = identity?.pubkey_hex?.trim().toLowerCase(); @@ -110,3 +192,249 @@ export function relayUrl(community: Community) { const host = community.normalized_host?.trim(); return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null; } + +/** Missing or malformed authoritative quota is intentionally not reconstructed. */ +export function quota(reply: Reply): Quota | null { + const { quota_used: used, quota_limit: limit, can_create: canCreate } = reply; + return Number.isInteger(used) && + Number.isInteger(limit) && + (used as number) >= 0 && + (limit as number) >= 0 && + (used as number) <= 2_147_483_647 && + (limit as number) <= 2_147_483_647 && + typeof canCreate === "boolean" + ? { used: used as number, limit: limit as number, canCreate } + : null; +} + +function exactKeys(value: object, expected: string[]) { + const keys = Object.keys(value).sort(); + const sorted = [...expected].sort(); + return ( + keys.length === sorted.length && + keys.every((key, index) => key === sorted[index]) + ); +} + +function validDeletionRequest(value: unknown): value is DeletionRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const request = value as Record; + return ( + exactKeys(request, [ + "community_id", + "host", + "request_id", + "acknowledgement_version", + ]) && + typeof request.community_id === "string" && + request.community_id.length > 0 && + request.community_id.length <= 200 && + typeof request.host === "string" && + request.host.length > 0 && + request.host.length <= 253 && + request.host === request.host.trim() && + typeof request.request_id === "string" && + UUID.test(request.request_id) && + request.acknowledgement_version === ACKNOWLEDGEMENT_VERSION + ); +} + +export function makePendingDeletion( + ownerPubkey: string, + community: Community, +): PendingDeletion { + if ( + !/^[0-9a-f]{64}$/.test(ownerPubkey) || + !community.id || + !community.normalized_host + ) + throw new Error("The community deletion target is incomplete"); + return { + version: 1, + owner_pubkey: ownerPubkey, + backend_origin: window.location.origin, + request: { + community_id: community.id, + host: community.normalized_host, + request_id: crypto.randomUUID(), + acknowledgement_version: ACKNOWLEDGEMENT_VERSION, + }, + }; +} + +function validPendingDeletion(value: unknown): value is PendingDeletion { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const pending = value as Record; + try { + return ( + exactKeys(pending, [ + "version", + "owner_pubkey", + "backend_origin", + "request", + ]) && + pending.version === 1 && + typeof pending.owner_pubkey === "string" && + /^[0-9a-f]{64}$/.test(pending.owner_pubkey) && + typeof pending.backend_origin === "string" && + pending.backend_origin === new URL(pending.backend_origin).origin && + validDeletionRequest(pending.request) + ); + } catch { + return false; + } +} + +export function readPendingDeletion(): PendingDeletion | null { + let raw: string | null; + try { + raw = localStorage.getItem(DELETION_PENDING_KEY); + } catch { + return null; + } + if (!raw) return null; + try { + const value: unknown = JSON.parse(raw); + if (validPendingDeletion(value)) return value; + } catch { + // Invalid local data is not authority and is discarded below. + } + try { + localStorage.removeItem(DELETION_PENDING_KEY); + } catch { + // Invalid local data is never authority; an unavailable store is harmless here. + } + return null; +} + +export function persistPendingDeletion(pending: PendingDeletion) { + const serialized = JSON.stringify(pending); + const current = readPendingDeletion(); + if (current && JSON.stringify(current) !== serialized) + throw new Error("Another community deletion is already pending"); + localStorage.setItem(DELETION_PENDING_KEY, serialized); + if (JSON.stringify(readPendingDeletion()) !== serialized) + throw new Error("The pending deletion could not be verified"); +} + +export function clearPendingDeletion(expected: PendingDeletion) { + const current = readPendingDeletion(); + if (current && JSON.stringify(current) === JSON.stringify(expected)) { + try { + localStorage.removeItem(DELETION_PENDING_KEY); + } catch { + // A retained request can be checked again on reopen. + } + } +} + +function matchesDeletion(reply: Reply, request: DeletionRequest) { + return ( + reply.request_id === request.request_id && + reply.community_id === request.community_id && + reply.host === request.host && + reply.acknowledgement_version === request.acknowledgement_version + ); +} + +const DEFINITIVE_DELETION_REJECTIONS = new Map([ + ["missing_mapping", 400], + ["invalid_request", 400], + ["confirmation_mismatch", 400], + ["unsupported_acknowledgement_version", 400], + ["not_owner", 404], + ["must_archive", 409], + ["protected_target", 409], + ["deletion_conflict", 409], +]); +/** + * Rejections that do not prove the saved UUID lacks a relay reservation: KGoose + * preflights and the acknowledgement version, which the relay checks before its + * UUID lookup. The rest are relay verdicts reached only after a known UUID would + * have returned its stage (or, for protected_target, can never admit the host), + * so they settle recovery as well. + */ +const FRESH_ONLY_DELETION_REJECTIONS = new Set([ + "missing_mapping", + "invalid_request", + "confirmation_mismatch", + "unsupported_acknowledgement_version", +]); +const DELETION_PROGRESS_STAGES = new Set([ + "submitted", + "inventoried", + "approved", + "fenced", + "drained", + "bindings_removed", + "postgres_purged", + "cache_purged", + "logically_verified", + "retention_pending", +]); + +class DefinitiveDeletionRejection extends ApiFailure {} + +export function isDefinitiveDeletionRejection(reason: unknown) { + return reason instanceof DefinitiveDeletionRejection; +} + +/** A possible dispatch terminates only on a tuple-bound 202 stage or abort. */ +function deletionResult( + response: { status: number; value: Reply }, + request: DeletionRequest, +) { + const { status, value } = response; + if ( + status === 202 && + !value.error && + DELETION_PROGRESS_STAGES.has(value.status ?? "") && + matchesDeletion(value, request) + ) + return value; + if ( + status === 202 && + !value.error && + value.status === "aborted" && + matchesDeletion(value, request) + ) + throw new ApiFailure( + "deletion_aborted", + `Deletion of ${request.host} stopped. This community is not being deleted.`, + value.correlation_id, + ); + throw new ApiFailure( + "acceptance_unknown", + messages.acceptance_unknown as string, + value.correlation_id, + ); +} + +/** One same-UUID POST per explicit attempt; relay verdicts that prove no reservation also settle recovery. */ +export async function admitDeletion( + request: DeletionRequest, + attempt: DeletionAttempt, +) { + let response: { status: number; value: Reply }; + try { + response = await send("delete", request); + } catch { + throw new ApiFailure( + "acceptance_unknown", + messages.acceptance_unknown as string, + ); + } + const code = response.value.error?.code ?? ""; + if ( + DEFINITIVE_DELETION_REJECTIONS.get(code) === response.status && + (attempt === "fresh" || !FRESH_ONLY_DELETION_REJECTIONS.has(code)) + ) + throw new DefinitiveDeletionRejection( + code, + messages[code] ?? + response.value.error?.message ?? + "Could not start deletion.", + response.value.correlation_id, + ); + return deletionResult(response, request); +} diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index 40267f402..04d75fa67 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -497,6 +497,109 @@ confirmedPresence( }, ); +test("hosted deletion reload keeps the UUID until an enabled manual replay", async ({ + page, + app, +}) => { + const owner = "a".repeat(64); + const request = { + community_id: "11111111-1111-4111-8111-111111111111", + host: "North.communities.buzz.xyz", + request_id: "22222222-2222-4222-8222-222222222222", + acknowledgement_version: 1, + }; + const calls = []; + const deletionBodies = []; + let canDelete = false; + await page.route("**/api/relay/identity", (route) => + route.fulfill({ json: { viewer: owner } }), + ); + await page.route("**/api/builderlab/**", async (route) => { + const action = new URL(route.request().url()).pathname.split("/").at(-1); + calls.push(action); + if (action === "auth") + return route.fulfill({ + json: { + auth: { + email: "owner@example.com", + expiresAt: "2030", + capabilities: { can_delete_buzz_communities: canDelete }, + }, + }, + }); + if (action === "identity") + return route.fulfill({ json: { identity: { pubkey_hex: owner } } }); + if (action === "list") + return route.fulfill({ + json: { + communities: [], + quota_used: 1, + quota_limit: 5, + can_create: false, + }, + }); + if (action === "delete") { + deletionBodies.push(route.request().postDataJSON()); + return route.fulfill({ + status: 202, + json: { ...request, status: "submitted" }, + }); + } + return route.fulfill({ status: 404, json: { error: "unexpected" } }); + }); + + await page.goto(app.origin); + await page.evaluate( + ({ owner, request }) => + localStorage.setItem( + "buzz.hosted-community-deletion.v1", + JSON.stringify({ + version: 1, + owner_pubkey: owner, + backend_origin: window.location.origin, + request, + }), + ), + { owner, request }, + ); + await page.reload(); + await button(page, "Your profile").click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); + await button(page, "Hosted communities").click(); + await expect( + page.getByText("Deletion status is unknown", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(request.request_id, { exact: true }), + ).toBeVisible(); + await expect(page.getByText(/will not check automatically/i)).toBeVisible(); + expect(calls.filter((action) => action === "delete")).toHaveLength(0); + expect( + await page.evaluate(() => + localStorage.getItem("buzz.hosted-community-deletion.v1"), + ), + ).not.toBeNull(); + await expect(button(page, "Check deletion status")).toBeDisabled(); + canDelete = true; + await page.reload(); + await button(page, "Your profile").click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); + await button(page, "Hosted communities").click(); + await expect(button(page, "Check deletion status")).toBeEnabled(); + await button(page, "Check deletion status").click(); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + expect(calls.filter((action) => action === "delete")).toHaveLength(1); + expect(deletionBodies).toEqual([request]); + await expect(button(page, "Delete")).toHaveCount(0); + expect( + await page.evaluate(() => + localStorage.getItem("buzz.hosted-community-deletion.v1"), + ), + ).toBeNull(); +}); + test("Settings loads and publishes the selected community profile", async ({ page, app,