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 (
+ <>
+ rerender((count) => count + 1)}>
+ Parent update
+
+ 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.
-
-
void run("bind", bind)}
- >
- Connect Buzz identity
-
-
+ 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.
+
+
void run("bind", bind)}
+ >
+ Connect Buzz identity
+
+
+ )
) : 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
+
+ )}
void run("refresh", load)}
+ onClick={() => void run("refresh", () => load(true))}
>
Refresh
+ {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}
+
+
+ void checkPendingDeletion(pendingDeletion)}
+ >
+ Check deletion status
+
+
+ {!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 ? (
-
- setConfirm({
- title: `Unarchive ${name}?`,
- description:
- "This address becomes connectable again. Connections that closed during archival will not reconnect automatically.",
- action: "Unarchive",
- run: () =>
- mutate(
- "unarchive",
- community,
- "Could not unarchive the community.",
- ),
- })
- }
- >
- Unarchive
-
+ <>
+
+ setConfirm({
+ title: `Unarchive ${name}?`,
+ description:
+ "This address becomes connectable again. Connections that closed during archival will not reconnect automatically.",
+ action: "Unarchive",
+ run: () =>
+ mutate(
+ "unarchive",
+ community,
+ "Could not unarchive the community.",
+ ),
+ })
+ }
+ >
+ Unarchive
+
+ {deletionEnabled &&
+ ready &&
+ community.id &&
+ community.normalized_host && (
+ setDeleteTarget(community)}
+ >
+ Delete
+
+ )}
+ >
) : (
<>
{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={
+ <>
+
+ Cancel
+
+
+ Start deletion
+
+ >
+ }
+ >
+
+
+ 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,