From 18b278f06e6ffac4d46b7298cd9dd7ec05d15180 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 28 Sep 2026 22:16:35 +0000 Subject: [PATCH 01/13] feat: add recoverable hosted community deletion Signed-off-by: OpenAI Codex --- dev/builderlab.mjs | 50 +- dev/builderlab.test.mjs | 79 ++++ dev/relay-broker.mjs | 4 +- docs/plugin-architecture.md | 8 +- .../HostedCommunities.test.tsx | 341 +++++++++++++- .../hosted-communities/HostedCommunities.tsx | 428 ++++++++++++++++-- src/bundled/hosted-communities/api.test.ts | 123 +++++ src/bundled/hosted-communities/api.ts | 324 +++++++++++-- tests/browser/settings.spec.mjs | 78 ++++ 9 files changed, 1353 insertions(+), 82 deletions(-) create mode 100644 src/bundled/hosted-communities/api.test.ts diff --git a/dev/builderlab.mjs b/dev/builderlab.mjs index 61dbe00a8..0bba51ccc 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,14 @@ 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"], + ], + "delete-receipt": [ + "/v1/buzz/communities/delete/receipt", + ["community_id", "host", "request_id", "acknowledgement_version"], + ], // Builderlab's transfer endpoint takes camelCase keys. transfer: [ "/v1/buzz/communities/transfer", @@ -29,6 +39,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 +175,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 +204,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 +306,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..0606f2c59 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,83 @@ it("forwards only allowlisted fields and ignores unknown actions", async () => { expect(await h.builderlab.call("../auth/me", {})).toBeUndefined(); }); +it("forwards the exact deletion tuple to admission and read-only receipt", async () => { + const h = account({ + "/v1/buzz/communities/delete": () => + Response.json({ status: "accepted" }, { status: 202 }), + "/v1/buzz/communities/delete/receipt": () => + Response.json({ error: { code: "acceptance_unknown" } }, { status: 503 }), + }); + 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 receipt = await h.builderlab.call("delete-receipt", request); + expect(builderlabResponseStatus(receipt)).toBe(503); + expect(h.requests.at(-1).path).toBe("/v1/buzz/communities/delete/receipt"); + 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, + }); +}); + +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.mjs b/dev/relay-broker.mjs index 9909a302b..ad0c40e20 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -61,7 +61,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, @@ -818,7 +818,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 0f88818e3..8d4ba21c6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -128,8 +128,12 @@ 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, uses the read-only +receipt route for uncertain recovery, and consumes server-authoritative quota; +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..6bd1a9173 100644 --- a/src/bundled/hosted-communities/HostedCommunities.test.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.test.tsx @@ -13,22 +13,34 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { StrictMode } 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,31 @@ 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; + if ( + url === "/api/builderlab/list" && + result && + typeof result === "object" && + "communities" in result && + !("quota_used" in result) + ) { + const communities = (result as { communities: unknown[] }).communities; + return Response.json({ + ...result, + quota_used: communities.length, + quota_limit: 5, + can_create: communities.length < 5, + }); + } + return Response.json(result); }), ); }); afterEach(() => { cleanup(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -789,3 +818,301 @@ 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: "accepted", + 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" }), + ); +} + +it("uses authoritative quota and fails closed when the projection is missing", async () => { + routes["/api/builderlab/list"] = () => Response.json({ communities: [] }); + renderCard(); + expect(await screen.findByText("Community quota unavailable")).toBeVisible(); + expect(screen.getByPlaceholderText("north-star")).toBeDisabled(); + expect(screen.queryByText(/0 of 5 used/)).not.toBeInTheDocument(); +}); + +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("preserves one UUID across an ambiguous response, manual receipt check, and acceptance", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + new Response("{", { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + routes["/api/builderlab/delete-receipt"] = () => + Response.json( + { error: { code: "acceptance_unknown" }, correlation_id: "corr-unknown" }, + { status: 503 }, + ); + 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); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete-receipt"), + ).toHaveLength(1); + + routes["/api/builderlab/delete-receipt"] = (request) => + Response.json(accepted(request), { 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(); + expect( + calls + .filter(([url]) => url === "/api/builderlab/delete-receipt") + .at(-1)?.[1].request_id, + ).toBe(requestId); +}); + +it("performs one read-only recovery on reopen even when admission capability is off", 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-receipt"] = (body) => + Response.json(accepted(body), { status: 202 }); + renderCard(); + expect(await screen.findByText("Deletion started")).toBeVisible(); + expect(calls.map(([url]) => url)).not.toContain("/api/builderlab/delete"); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete-receipt"), + ).toHaveLength(1); +}); + +it("discards a recovery envelope bound to another owner without contacting receipt", 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, + }, + }), + ); + renderCard(); + await screen.findByText(npubEncode(local)); + await waitFor(() => + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(), + ); + expect(calls.map(([url]) => url)).not.toContain( + "/api/builderlab/delete-receipt", + ); +}); + +it("manually resubmits the same UUID only after a fresh capable archived-owner list", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "acceptance_unknown" }, correlation_id: "corr-unknown" }, + { status: 503 }, + ); + renderCard(); + await confirmDeletion(); + expect(await screen.findByText("Deletion status is unknown")).toBeVisible(); + const first = calls.find(([url]) => url === "/api/builderlab/delete")?.[1]; + routes["/api/builderlab/delete"] = (request) => + Response.json(accepted(request), { status: 202 }); + fireEvent.click( + screen.getByRole("button", { name: "Retry same deletion request" }), + ); + 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(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 owner/archive check before retry. +}); + +it("terminates pending recovery on a bound aborted receipt", 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-receipt"] = () => + Response.json( + { error: { code: "deletion_aborted" }, correlation_id: "corr-aborted" }, + { status: 409 }, + ); + renderCard(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "This deletion was aborted", + ); + expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); + expect( + screen.queryByText("Deletion status is unknown"), + ).not.toBeInTheDocument(); +}); + +it("does not let a late admission response clear another account's envelope", async () => { + 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 next = { + ...old, + owner_pubkey: other, + request: { + ...old.request, + request_id: "55555555-5555-4555-8555-555555555555", + }, + }; + localStorage.setItem(DELETION_PENDING_KEY, JSON.stringify(next)); + await act(async () => + admission.release( + Response.json(accepted(old.request), { + status: 202, + }), + ), + ); + 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..ebe7d351c 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, + checkDeletionStatus, + clearPendingDeletion, getAuth, HOST_SUFFIX, - LIMIT, + isAcceptanceUnknown, login, + makePendingDeletion, + persistPendingDeletion, + quota, + 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"; @@ -57,6 +69,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { const [auth, setAuth] = useState(); const [identity, setIdentity] = useState(null); 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 +77,10 @@ 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 [deletionNotice, setDeletionNotice] = useState(""); // The last address handed off for joining, and whether the clipboard took it. const [handoff, setHandoff] = useState<{ url: string; @@ -73,19 +90,102 @@ 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 acceptedDeletionIds = useRef(new Set()); + const loadedOwner = useRef(undefined); + const recoveryAttempted = useRef(""); const load = useCallback(async () => { const at = generation.current; const [current, list] = await Promise.all([call("identity"), call("list")]); - if (at !== generation.current) return; + if (at !== generation.current) return null; // An account without a linked identity is the connect state, not a failure. if (current.error?.code !== "unauthorized" && !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 ?? []); + const nextIdentity = current.identity ?? null; + const nextOwner = boundKey(nextIdentity); + if ( + loadedOwner.current !== undefined && + loadedOwner.current !== nextOwner + ) { + acceptedDeletionIds.current.clear(); + setDeletionNotice(""); + } + loadedOwner.current = nextOwner; + const stored = readPendingDeletion(); + if ( + stored && + (stored.owner_pubkey !== nextOwner || + stored.backend_origin !== window.location.origin) + ) { + clearPendingDeletion(stored); + setPendingDeletion(null); + } + const listed = list.communities ?? []; + const nextCommunities = listed.filter( + (community) => + !community.id || !acceptedDeletionIds.current.has(community.id), + ); + // One authoritative omission establishes that the accepted fence reached + // the list projection. A later privileged abort may then restore the row. + for (const id of acceptedDeletionIds.current) + if (!listed.some((community) => community.id === id)) + acceptedDeletionIds.current.delete(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 || !active()) return false; + clearPendingDeletion(pending); + acceptedDeletionIds.current.add(pending.request.community_id); + setPendingDeletion(null); + setCommunities((list) => + list.filter((item) => item.id !== pending.request.community_id), + ); + setDeletionNotice("Deletion started"); + setError(""); + return true; + }, + [active], + ); + + const recoverDeletion = useCallback( + async (owner: string | null, at: number) => { + const stored = readPendingDeletion(); + if (!stored) return; + if ( + stored.owner_pubkey !== owner || + stored.backend_origin !== window.location.origin + ) { + clearPendingDeletion(stored); + return; + } + setPendingDeletion(stored); + if (recoveryAttempted.current === stored.request.request_id) return; + recoveryAttempted.current = stored.request.request_id; + try { + await checkDeletionStatus(stored.request); + markDeletionAccepted(stored, at); + } catch (reason) { + if (at !== generation.current || !active()) return; + if ( + reason instanceof ApiFailure && + reason.code === "deletion_aborted" + ) { + clearPendingDeletion(stored); + setPendingDeletion(null); + } + setError(message(reason)); + } + }, + [active, markDeletionAccepted], + ); + const localRead = useRef(0); const loadLocal = useCallback(() => { const at = ++localRead.current; @@ -108,9 +208,15 @@ export function HostedCommunities({ active }: { active(): boolean }) { if (at !== generation.current) return; setAuth(next); if (next) - return load().catch( - (reason) => at === generation.current && setError(message(reason)), - ); + return load() + .then((snapshot) => { + if (snapshot && at === generation.current) + return recoverDeletion(boundKey(snapshot.identity), at); + }) + .catch( + (reason) => + at === generation.current && setError(message(reason)), + ); }) .catch((reason) => { if (at !== generation.current) return; @@ -122,10 +228,10 @@ export function HostedCommunities({ active }: { active(): boolean }) { generation.current++; loginAbort.current?.abort(); }; - }, [load, loadLocal]); + }, [load, loadLocal, recoverDeletion]); /** Runs one account operation at a time; resolves whether it succeeded. */ - async function run(label: string, operation: () => Promise) { + async function run(label: string, operation: () => Promise) { if (!active()) return false; const at = ++generation.current; setAction(label); @@ -163,6 +269,79 @@ export function HostedCommunities({ active }: { active(): boolean }) { ), ); }; + const discardPendingDeletion = () => { + const stored = readPendingDeletion(); + if (stored) clearPendingDeletion(stored); + setPendingDeletion(null); + }; + const attemptDeletion = async ( + pending: PendingDeletion, + preserveNotOwner: boolean, + ) => { + const at = generation.current; + try { + await admitDeletion(pending.request); + if (!markDeletionAccepted(pending, at)) return; + await settle(); + } catch (reason) { + if (at !== generation.current || !active()) return; + if ( + isAcceptanceUnknown(reason) || + (preserveNotOwner && + reason instanceof ApiFailure && + reason.code === "not_owner") + ) { + setPendingDeletion(pending); + setError(message(reason)); + return; + } + clearPendingDeletion(pending); + setPendingDeletion(null); + throw reason; + } + }; + const checkPendingDeletion = (pending: PendingDeletion) => + run("receipt", async () => { + const at = generation.current; + try { + await checkDeletionStatus(pending.request); + if (!markDeletionAccepted(pending, at)) return; + await settle(); + } catch (reason) { + if (at !== generation.current || !active()) return; + if ( + reason instanceof ApiFailure && + reason.code === "deletion_aborted" + ) { + clearPendingDeletion(pending); + setPendingDeletion(null); + } + throw reason; + } + }); + const retryPendingDeletion = (pending: PendingDeletion) => + run("delete", async () => { + const currentAuth = await getAuth(); + if (currentAuth?.capabilities?.can_delete_buzz_communities !== true) + throw new Error("Community deletion is no longer available."); + setAuth(currentAuth); + const snapshot = await load(); + const row = snapshot?.communities.find( + (community) => + community.id === pending.request.community_id && + community.normalized_host === pending.request.host && + Boolean(community.archived_at), + ); + if ( + !snapshot || + boundKey(snapshot.identity) !== pending.owner_pubkey || + !row + ) + throw new Error( + "The exact archived community is not currently available for a safe retry. Check deletion status instead.", + ); + await attemptDeletion(pending, true); + }); const busy = action !== null; // Repeated inside open dialogs, whose modal backdrop hides the page copy. const failure = error && ( @@ -181,6 +360,18 @@ 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; + const retryableDeletion = + pendingDeletion && + deletionEnabled && + ready && + communities.some( + (community) => + community.id === pendingDeletion.request.community_id && + community.normalized_host === pendingDeletion.request.host && + Boolean(community.archived_at), + ); // 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 +497,11 @@ export function HostedCommunities({ active }: { active(): boolean }) { onClick={() => void run("sign-out", async () => { await signOut(); + discardPendingDeletion(); setAuth(null); setIdentity(null); setCommunities([]); + setQuotaState(null); }) } > @@ -376,6 +569,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { await call("unbind"), "Could not release the previously connected Buzz identity.", ); + discardPendingDeletion(); // Unbound is a valid resting state; Connect recovers it. setIdentity(null); if (active()) await bind(); @@ -411,6 +605,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { await call("unbind"), "Could not unpair the Buzz identity.", ); + discardPendingDeletion(); setIdentity(null); await settle(); }, @@ -425,7 +620,9 @@ export function HostedCommunities({ active }: { active(): boolean }) {

Your communities{" "} - {communities.length} of {LIMIT} used + {quotaState + ? `${quotaState.used} of ${quotaState.limit} used` + : "Community quota unavailable"}

+ {deletionNotice && ( +

+ {deletionNotice} +

+ )} + {pendingDeletion && ( +
+

Deletion status is unknown

+

+ Keep this request while Buzz checks its durable receipt. A + missing receipt does not prove the deletion was never accepted. +

+

+ {pendingDeletion.request.host} +

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

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

  • {archived ? ( - + <> + + {deletionEnabled && + ready && + community.id && + community.normalized_host && ( + + )} + ) : ( <> {url && ready && ( @@ -565,8 +818,14 @@ export function HostedCommunities({ active }: { active(): boolean }) {
    )} = LIMIT} + enabled={ready && quotaState?.canCreate === true} + atLimit={Boolean( + quotaState && + !quotaState.canCreate && + quotaState.used >= quotaState.limit, + )} + quotaUnavailable={!quotaState} + {...(quotaState ? { limit: quotaState.limit } : {})} busy={busy} creating={action === "create"} onCreate={(name) => @@ -584,6 +843,30 @@ export function HostedCommunities({ active }: { active(): boolean }) { /> )} + {deleteTarget && bound && ( + setDeleteTarget(null)} + onDelete={() => { + let pending: PendingDeletion; + try { + pending = makePendingDeletion(bound, deleteTarget); + persistPendingDeletion(pending); + } catch { + setError( + "Deletion was not sent because its recovery record could not be saved.", + ); + return; + } + setPendingDeletion(pending); + void run("delete", () => attemptDeletion(pending, false)).then( + (ok) => ok && setDeleteTarget(null), + ); + }} + /> + )} {confirm && ( !open && close()} + title={`Permanently delete ${name}?`} + description="This starts an irreversible deletion." + preventClose={pending} + actions={ + <> + + + + } + > +
    +

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

    + + + +

    {expected}

    + setAcknowledged(checked === true)} + label="I understand this cannot be canceled and deletion continues after acceptance." + /> + {failure} +
    + + ); +} + function CreateCommunity({ enabled, atLimit, + quotaUnavailable, + limit, busy, creating, onCreate, }: { enabled: boolean; atLimit: boolean; + quotaUnavailable: boolean; + limit?: number; busy: boolean; creating: boolean; onCreate(name: string): Promise; @@ -694,10 +1048,16 @@ function CreateCommunity({

    {atLimit && (

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

    )} + {quotaUnavailable && ( +

    + Community quota unavailable. Creating is paused until the server + returns authoritative usage. +

    + )} localStorage.clear()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +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("reconciles a mismatched admission response and validates the entire receipt tuple", async () => { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + return url.endsWith("/delete") + ? Response.json( + { ...request, community_id: "wrong", status: "accepted" }, + { status: 202 }, + ) + : Response.json({ ...request, status: "accepted" }, { status: 202 }); + }), + ); + await expect(admitDeletion(request)).resolves.toMatchObject({ + request_id: request.request_id, + community_id: request.community_id, + }); + expect(calls).toEqual([ + "/api/builderlab/delete", + "/api/builderlab/delete-receipt", + ]); +}); + +it("turns a missing receipt after ambiguous dispatch into acceptance_unknown", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockRejectedValueOnce(new TypeError("EOF")) + .mockResolvedValueOnce( + Response.json({ error: { code: "not_owner" } }, { status: 404 }), + ), + ); + await expect(admitDeletion(request)).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); +}); + +it.each([ + ["request_id", "33333333-3333-4333-8333-333333333333"], + ["community_id", "wrong-community"], + ["host", "north.communities.buzz.xyz"], + ["acknowledgement_version", 2], + ["status", "aborted"], +])("rejects a receipt with mismatched %s", async (field, value) => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockRejectedValueOnce(new TypeError("EOF")) + .mockResolvedValueOnce( + Response.json( + { ...request, status: "accepted", [field]: value }, + { status: 202 }, + ), + ), + ); + await expect(admitDeletion(request)).rejects.toMatchObject({ + code: "acceptance_unknown", + }); +}); diff --git a/src/bundled/hosted-communities/api.ts b/src/bundled/hosted-communities/api.ts index 4fa1fd705..0750cd857 100644 --- a/src/bundled/hosted-communities/api.ts +++ b/src/bundled/hosted-communities/api.ts @@ -2,8 +2,19 @@ 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 +28,19 @@ 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 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,64 @@ 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; + +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.", + 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.", + deletion_aborted: + "This deletion was aborted. Refresh before starting a new request.", + 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.", }; -async function send( +export class ApiFailure extends Error { + constructor( + readonly code: string, + message: string, + readonly correlationId?: string, + ) { + super( + correlationId ? `${message} Correlation ID: ${correlationId}` : message, + ); + this.name = "ApiFailure"; + } +} + +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 +116,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 +150,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 +159,22 @@ 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) { 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, + 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 +184,189 @@ 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) { + localStorage.setItem(DELETION_PENDING_KEY, JSON.stringify(pending)); +} + +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 accepted receipt is safe to reconcile again on reopen. + } + } +} + +function accepted(reply: Reply, request: DeletionRequest) { + return ( + reply.status === "accepted" && + reply.request_id === request.request_id && + reply.community_id === request.community_id && + reply.host === request.host && + reply.acknowledgement_version === request.acknowledgement_version + ); +} + +/** Read-only status check. A missing, malformed, or mismatched receipt stays uncertain. */ +export async function checkDeletionStatus(request: DeletionRequest) { + let response: { status: number; value: Reply }; + try { + response = await send("delete-receipt", request); + } catch { + throw new ApiFailure( + "acceptance_unknown", + messages.acceptance_unknown as string, + ); + } + if (response.value.error?.code === "not_owner") + throw new ApiFailure( + "acceptance_unknown", + messages.acceptance_unknown as string, + response.value.correlation_id, + ); + check(response.value, "Could not check deletion status."); + if (response.status === 202 && accepted(response.value, request)) + return response.value; + throw new ApiFailure( + "acceptance_unknown", + messages.acceptance_unknown as string, + response.value.correlation_id, + ); +} + +/** Sends one admission; ambiguous browser responses reconcile through the read-only route. */ +export async function admitDeletion(request: DeletionRequest) { + try { + const response = await send("delete", request); + if (response.value.error) { + if (response.value.error.code !== "unknown") + check(response.value, "Could not start deletion."); + } else if (response.status === 202 && accepted(response.value, request)) { + return response.value; + } + } catch (reason) { + if (reason instanceof ApiFailure) throw reason; + // Browser-to-broker response loss is ambiguous; reconcile below. + } + return checkDeletionStatus(request); +} + +export const isAcceptanceUnknown = (reason: unknown) => + reason instanceof ApiFailure && reason.code === "acceptance_unknown"; diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index 5076caccd..290d63a75 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -362,6 +362,84 @@ test("avatar Settings access dismisses cleanly and exposes Profile and Plugins", } }); +test("hosted deletion reload recovery uses only the read-only receipt while capability is off", 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 = []; + 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: false }, + }, + }, + }); + 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-receipt") + return route.fulfill({ + status: 202, + json: { ...request, status: "accepted" }, + }); + 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 started", { exact: true }), + ).toBeVisible(); + expect(calls.filter((action) => action === "delete-receipt")).toHaveLength(1); + expect(calls.filter((action) => action === "delete")).toHaveLength(0); + 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, From 94d6487782037f130c4e1904ce3060b52e0a87f0 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 28 Sep 2026 22:35:07 +0000 Subject: [PATCH 02/13] fix: preserve uncertain community deletion requests Signed-off-by: OpenAI Codex --- docs/plugin-architecture.md | 5 +- .../HostedCommunities.test.tsx | 275 ++++++++++++++++-- .../hosted-communities/HostedCommunities.tsx | 147 ++++------ src/bundled/hosted-communities/api.test.ts | 136 +++++++++ src/bundled/hosted-communities/api.ts | 74 +++-- tests/browser/settings.spec.mjs | 16 +- 6 files changed, 503 insertions(+), 150 deletions(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 8d4ba21c6..a40712efa 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -131,7 +131,10 @@ account: browser sign-in, binding the local Buzz identity (a locally signed kind 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, uses the read-only -receipt route for uncertain recovery, and consumes server-authoritative quota; +receipt route only after an explicit status check for uncertain recovery, and +consumes server-authoritative quota. 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`), diff --git a/src/bundled/hosted-communities/HostedCommunities.test.tsx b/src/bundled/hosted-communities/HostedCommunities.test.tsx index 6bd1a9173..120695325 100644 --- a/src/bundled/hosted-communities/HostedCommunities.test.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.test.tsx @@ -971,7 +971,7 @@ it("preserves one UUID across an ambiguous response, manual receipt check, and a ).toBe(requestId); }); -it("performs one read-only recovery on reopen even when admission capability is off", async () => { +it("shows a stored request for explicit manual checking without background recovery", async () => { const request = { community_id: archived.id, host: archived.normalized_host, @@ -994,8 +994,18 @@ it("performs one read-only recovery on reopen even when admission capability is routes["/api/builderlab/delete-receipt"] = (body) => Response.json(accepted(body), { status: 202 }); renderCard(); - expect(await screen.findByText("Deletion started")).toBeVisible(); + 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(calls.map(([url]) => url)).not.toContain("/api/builderlab/delete"); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete-receipt"), + ).toHaveLength(0); + fireEvent.click( + screen.getByRole("button", { name: "Check deletion status" }), + ); + expect(await screen.findByText("Deletion started")).toBeVisible(); expect( calls.filter(([url]) => url === "/api/builderlab/delete-receipt"), ).toHaveLength(1); @@ -1072,10 +1082,19 @@ it("terminates pending recovery on a bound aborted receipt", async () => { ); routes["/api/builderlab/delete-receipt"] = () => Response.json( - { error: { code: "deletion_aborted" }, correlation_id: "corr-aborted" }, + { + ...request, + error: { code: "deletion_aborted" }, + status: "aborted", + correlation_id: "corr-aborted", + }, { status: 409 }, ); 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( "This deletion was aborted", ); @@ -1085,34 +1104,236 @@ it("terminates pending recovery on a bound aborted receipt", async () => { ).not.toBeInTheDocument(); }); -it("does not let a late admission response clear another account's envelope", async () => { +it("keeps an unbound aborted result pending", async () => { routes["/api/builderlab/list"] = () => ({ communities: [archived] }); - const admission = hold(); - routes["/api/builderlab/delete"] = admission.answer as Handler; - const view = render( true} />); + routes["/api/builderlab/delete"] = () => + Response.json( + { + error: { code: "deletion_aborted" }, + status: "aborted", + correlation_id: "corr-unbound-abort", + }, + { status: 409 }, + ); + routes["/api/builderlab/delete-receipt"] = routes["/api/builderlab/delete"]; + renderCard(); await confirmDeletion(); - await waitFor(() => - expect(calls.map(([url]) => url)).toContain("/api/builderlab/delete"), + 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", ); - const old = JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? ""); - view.unmount(); - const next = { - ...old, - owner_pubkey: other, - request: { - ...old.request, - request_id: "55555555-5555-4555-8555-555555555555", - }, +}); + +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", }; - localStorage.setItem(DELETION_PENDING_KEY, JSON.stringify(next)); - await act(async () => - admission.release( - Response.json(accepted(old.request), { - status: 202, - }), - ), + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "relay_unavailable" }, correlation_id: "corr-slot" }, + { status: 503 }, + ); + routes["/api/builderlab/delete-receipt"] = routes["/api/builderlab/delete"]; + 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.each([ + ["capability is revoked", "capability"], + ["the archived owner row disappears", "row"], +])("does not retry when %s on the fresh check", async (_label, condition) => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "relay_unavailable" }, correlation_id: "corr-first" }, + { status: 503 }, + ); + routes["/api/builderlab/delete-receipt"] = routes["/api/builderlab/delete"]; + renderCard(); + await confirmDeletion(); + await screen.findByText("Deletion status is unknown"); + if (condition === "capability") + routes["/api/builderlab/auth"] = () => ({ + auth: { email: "a@example.com", expiresAt: "2030", capabilities: {} }, + }); + else routes["/api/builderlab/list"] = () => ({ communities: [] }); + fireEvent.click( + screen.getByRole("button", { name: "Retry same deletion request" }), ); - expect(JSON.parse(localStorage.getItem(DELETION_PENDING_KEY) ?? "")).toEqual( - next, + await screen.findByRole("alert"); + expect( + calls.filter(([url]) => url === "/api/builderlab/delete"), + ).toHaveLength(1); + expect(localStorage.getItem(DELETION_PENDING_KEY)).not.toBeNull(); +}); + +it("rechecks capability after the fresh owner list resolves", async () => { + routes["/api/builderlab/list"] = () => ({ communities: [archived] }); + routes["/api/builderlab/delete"] = () => + Response.json( + { error: { code: "relay_unavailable" }, correlation_id: "corr-first" }, + { status: 503 }, + ); + routes["/api/builderlab/delete-receipt"] = routes["/api/builderlab/delete"]; + 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: "Retry same deletion request", + }); + 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(); +}); + +it.each(["accepted", "structured error"])( + "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 }) + : Response.json( + { + error: { code: "relay_unavailable" }, + correlation_id: "corr-late-error", + }, + { status: 503 }, + ), + ), + ); + 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 ebe7d351c..caa9ba025 100644 --- a/src/bundled/hosted-communities/HostedCommunities.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.tsx @@ -37,7 +37,6 @@ import { clearPendingDeletion, getAuth, HOST_SUFFIX, - isAcceptanceUnknown, login, makePendingDeletion, persistPendingDeletion, @@ -92,7 +91,6 @@ export function HostedCommunities({ active }: { active(): boolean }) { const generation = useRef(0); const acceptedDeletionIds = useRef(new Set()); const loadedOwner = useRef(undefined); - const recoveryAttempted = useRef(""); const load = useCallback(async () => { const at = generation.current; @@ -120,17 +118,14 @@ export function HostedCommunities({ active }: { active(): boolean }) { ) { clearPendingDeletion(stored); setPendingDeletion(null); + } else if (stored) { + setPendingDeletion(stored); } const listed = list.communities ?? []; const nextCommunities = listed.filter( (community) => !community.id || !acceptedDeletionIds.current.has(community.id), ); - // One authoritative omission establishes that the accepted fence reached - // the list projection. A later privileged abort may then restore the row. - for (const id of acceptedDeletionIds.current) - if (!listed.some((community) => community.id === id)) - acceptedDeletionIds.current.delete(id); const nextQuota = quota(list); setIdentity(nextIdentity); setCommunities(nextCommunities); @@ -154,38 +149,6 @@ export function HostedCommunities({ active }: { active(): boolean }) { [active], ); - const recoverDeletion = useCallback( - async (owner: string | null, at: number) => { - const stored = readPendingDeletion(); - if (!stored) return; - if ( - stored.owner_pubkey !== owner || - stored.backend_origin !== window.location.origin - ) { - clearPendingDeletion(stored); - return; - } - setPendingDeletion(stored); - if (recoveryAttempted.current === stored.request.request_id) return; - recoveryAttempted.current = stored.request.request_id; - try { - await checkDeletionStatus(stored.request); - markDeletionAccepted(stored, at); - } catch (reason) { - if (at !== generation.current || !active()) return; - if ( - reason instanceof ApiFailure && - reason.code === "deletion_aborted" - ) { - clearPendingDeletion(stored); - setPendingDeletion(null); - } - setError(message(reason)); - } - }, - [active, markDeletionAccepted], - ); - const localRead = useRef(0); const loadLocal = useCallback(() => { const at = ++localRead.current; @@ -208,15 +171,9 @@ export function HostedCommunities({ active }: { active(): boolean }) { if (at !== generation.current) return; setAuth(next); if (next) - return load() - .then((snapshot) => { - if (snapshot && at === generation.current) - return recoverDeletion(boundKey(snapshot.identity), at); - }) - .catch( - (reason) => - at === generation.current && setError(message(reason)), - ); + return load().catch( + (reason) => at === generation.current && setError(message(reason)), + ); }) .catch((reason) => { if (at !== generation.current) return; @@ -228,7 +185,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { generation.current++; loginAbort.current?.abort(); }; - }, [load, loadLocal, recoverDeletion]); + }, [load, loadLocal]); /** Runs one account operation at a time; resolves whether it succeeded. */ async function run(label: string, operation: () => Promise) { @@ -274,58 +231,40 @@ export function HostedCommunities({ active }: { active(): boolean }) { if (stored) clearPendingDeletion(stored); setPendingDeletion(null); }; - const attemptDeletion = async ( + const settleDeletion = async ( pending: PendingDeletion, - preserveNotOwner: boolean, + at: number, + operation: () => Promise, ) => { - const at = generation.current; try { - await admitDeletion(pending.request); - if (!markDeletionAccepted(pending, at)) return; + await operation(); + if (!markDeletionAccepted(pending, at)) return false; await settle(); + return true; } catch (reason) { - if (at !== generation.current || !active()) return; - if ( - isAcceptanceUnknown(reason) || - (preserveNotOwner && - reason instanceof ApiFailure && - reason.code === "not_owner") - ) { - setPendingDeletion(pending); - setError(message(reason)); - return; - } - clearPendingDeletion(pending); - setPendingDeletion(null); + if (at !== generation.current || !active()) return false; + if (reason instanceof ApiFailure && reason.code === "deletion_aborted") { + clearPendingDeletion(pending); + setPendingDeletion(null); + } else setPendingDeletion(pending); throw reason; } }; const checkPendingDeletion = (pending: PendingDeletion) => run("receipt", async () => { const at = generation.current; - try { - await checkDeletionStatus(pending.request); - if (!markDeletionAccepted(pending, at)) return; - await settle(); - } catch (reason) { - if (at !== generation.current || !active()) return; - if ( - reason instanceof ApiFailure && - reason.code === "deletion_aborted" - ) { - clearPendingDeletion(pending); - setPendingDeletion(null); - } - throw reason; - } + await settleDeletion(pending, at, () => + checkDeletionStatus(pending.request), + ); }); const retryPendingDeletion = (pending: PendingDeletion) => run("delete", async () => { - const currentAuth = await getAuth(); - if (currentAuth?.capabilities?.can_delete_buzz_communities !== true) - throw new Error("Community deletion is no longer available."); - setAuth(currentAuth); + const at = generation.current; const snapshot = await load(); + if (at !== generation.current || !active()) return; + const currentAuth = await getAuth(); + if (at !== generation.current || !active()) return; + const stored = readPendingDeletion(); const row = snapshot?.communities.find( (community) => community.id === pending.request.community_id && @@ -334,13 +273,19 @@ export function HostedCommunities({ active }: { active(): boolean }) { ); if ( !snapshot || + !stored || + JSON.stringify(stored) !== JSON.stringify(pending) || + stored.backend_origin !== window.location.origin || boundKey(snapshot.identity) !== pending.owner_pubkey || !row ) throw new Error( "The exact archived community is not currently available for a safe retry. Check deletion status instead.", ); - await attemptDeletion(pending, true); + if (currentAuth?.capabilities?.can_delete_buzz_communities !== true) + throw new Error("Community deletion is no longer available."); + setAuth(currentAuth); + await settleDeletion(pending, at, () => admitDeletion(pending.request)); }); const busy = action !== null; // Repeated inside open dialogs, whose modal backdrop hides the page copy. @@ -643,12 +588,20 @@ export function HostedCommunities({ active }: { active(): boolean }) {

    Deletion status is unknown

    - Keep this request while Buzz checks its durable receipt. A + Buzz will not check automatically. Use Check deletion status + when you are ready; uncertainty can continue indefinitely. A missing receipt does not prove the deletion was never accepted.

    {pendingDeletion.request.host}

    +

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

    +

    + {pendingDeletion.request.request_id} +

    - {retryableDeletion && ( - - )}
    )} @@ -781,12 +746,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { )} = quotaState.limit, - )} - {...(quotaState ? { limit: quotaState.limit } : {})} + atLimit={quotaState?.canCreate === false} busy={busy} creating={action === "create"} onCreate={(name) => @@ -831,7 +791,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { setDeleteTarget(null); void run("delete", async () => { const at = generation.current; - await settleDeletion(pending, at, "fresh", () => + await settleDeletion(pending, at, () => admitDeletion(pending.request, "fresh"), ); }); @@ -963,14 +923,12 @@ function DeleteCommunityDialog({ function CreateCommunity({ enabled, atLimit, - limit, busy, creating, onCreate, }: { enabled: boolean; atLimit: boolean; - limit?: number; busy: boolean; creating: boolean; onCreate(name: string): Promise; @@ -1017,8 +975,7 @@ function CreateCommunity({

    {atLimit && (

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

    )} { 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_request_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", "{"], [ @@ -69,43 +144,35 @@ it.each([ expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(); }); -it("reconciles a mismatched admission response and validates the entire receipt tuple", async () => { +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 url.endsWith("/delete") - ? Response.json( - { ...request, community_id: "wrong", status: "accepted" }, - { status: 202 }, - ) - : Response.json({ ...request, status: "accepted" }, { status: 202 }); + return Response.json( + { ...request, community_id: "wrong", status: "submitted" }, + { status: 202 }, + ); }), ); - await expect(admitDeletion(request, "fresh")).resolves.toMatchObject({ - request_id: request.request_id, - community_id: request.community_id, - }); - expect(calls).toEqual([ - "/api/builderlab/delete", - "/api/builderlab/delete-receipt", - ]); + await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ + code: "acceptance_unknown", + } satisfies Partial); + expect(calls).toEqual(["/api/builderlab/delete"]); }); -it("turns a missing receipt after ambiguous dispatch into acceptance_unknown", async () => { +it("does not infer noncommit after a lost dispatch response", async () => { vi.stubGlobal( "fetch", - vi - .fn() - .mockRejectedValueOnce(new TypeError("EOF")) - .mockResolvedValueOnce( - Response.json({ error: { code: "not_owner" } }, { status: 404 }), - ), + 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([ @@ -198,22 +265,11 @@ it.each([ ], ["network EOF", () => Promise.reject(new TypeError("EOF"))], ])("keeps a fresh %s ambiguous", async (_label, firstResponse) => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockImplementationOnce(firstResponse) - .mockResolvedValueOnce( - Response.json( - { error: { code: "acceptance_unknown" } }, - { status: 503 }, - ), - ), - ); + vi.stubGlobal("fetch", vi.fn(firstResponse)); await expect(admitDeletion(request, "fresh")).rejects.toMatchObject({ code: "acceptance_unknown", } satisfies Partial); - expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(1); }); it("keeps the same structured rejection uncertain during recovery", async () => { @@ -230,29 +286,27 @@ it("keeps the same structured rejection uncertain during recovery", async () => code: "acceptance_unknown", correlationId: "corr-recovery", } satisfies Partial); - expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(1); }); -it("preserves receipt correlation when EOF is followed by relay_unavailable", async () => { +it("preserves the original ambiguous response correlation", async () => { vi.stubGlobal( "fetch", - vi - .fn() - .mockRejectedValueOnce(new TypeError("EOF")) - .mockResolvedValueOnce( - Response.json( - { - error: { code: "relay_unavailable" }, - correlation_id: "corr-receipt-relay", - }, - { status: 503 }, - ), + 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-receipt-relay", + correlationId: "corr-admission", } satisfies Partial); + expect(fetch).toHaveBeenCalledTimes(1); }); it.each(["relay_unavailable", "not_owner"])( @@ -260,67 +314,55 @@ it.each(["relay_unavailable", "not_owner"])( async (code) => { vi.stubGlobal( "fetch", - vi - .fn() - .mockResolvedValueOnce( - Response.json( - { error: { code }, correlation_id: `corr-admission-${code}` }, - { status: code === "not_owner" ? 403 : 503 }, - ), - ) - .mockResolvedValueOnce( - Response.json( - { - error: { code: "relay_unavailable" }, - correlation_id: `corr-receipt-${code}`, - }, - { status: 503 }, - ), + 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-receipt-${code}`, + correlationId: `corr-admission-${code}`, } satisfies Partial); - expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(1); }, ); -it("does not accept an unbound aborted receipt as terminal", async () => { +it("does not accept an unbound aborted 202 as terminal", async () => { vi.stubGlobal( "fetch", vi.fn(async () => Response.json( { - error: { code: "deletion_aborted" }, status: "aborted", correlation_id: "corr-unbound-abort", }, - { status: 409 }, + { status: 202 }, ), ), ); - await expect(checkDeletionStatus(request)).rejects.toMatchObject({ + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ code: "acceptance_unknown", correlationId: "corr-unbound-abort", } satisfies Partial); }); -it("accepts only a full tuple-bound aborted receipt as terminal", async () => { +it("accepts only a full tuple-bound aborted 202 as terminal", async () => { vi.stubGlobal( "fetch", vi.fn(async () => Response.json( { ...request, - error: { code: "deletion_aborted" }, + status: "aborted", correlation_id: "corr-bound-abort", }, - { status: 409 }, + { status: 202 }, ), ), ); - await expect(checkDeletionStatus(request)).rejects.toMatchObject({ + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ code: "deletion_aborted", correlationId: "corr-bound-abort", } satisfies Partial); @@ -333,7 +375,7 @@ it("requires HTTP 202 for a tuple-bound accepted result", async () => { Response.json({ ...request, status: "accepted" }, { status: 200 }), ), ); - await expect(checkDeletionStatus(request)).rejects.toMatchObject({ + await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ code: "acceptance_unknown", } satisfies Partial); }); @@ -376,21 +418,19 @@ it.each([ ["community_id", "wrong-community"], ["host", "north.communities.buzz.xyz"], ["acknowledgement_version", 2], - ["status", "aborted"], -])("rejects a receipt with mismatched %s", async (field, value) => { + ["status", "unknown_stage"], +])("rejects an admission response with mismatched %s", async (field, value) => { vi.stubGlobal( "fetch", - vi - .fn() - .mockRejectedValueOnce(new TypeError("EOF")) - .mockResolvedValueOnce( - Response.json( - { ...request, status: "accepted", [field]: value }, - { status: 202 }, - ), + 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 bd2efcac2..499969533 100644 --- a/src/bundled/hosted-communities/api.ts +++ b/src/bundled/hosted-communities/api.ts @@ -63,7 +63,7 @@ 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 current community quota.", + limit_reached: "You've reached your community limit.", relay_unavailable: "Community provisioning is temporarily unavailable.", identity_already_bound: "This Builderlab account is connected to another Buzz identity.", @@ -315,7 +315,7 @@ export function clearPendingDeletion(expected: PendingDeletion) { try { localStorage.removeItem(DELETION_PENDING_KEY); } catch { - // A retained accepted receipt is safe to reconcile again on reopen. + // A retained request can be checked again on reopen. } } } @@ -339,6 +339,19 @@ const DEFINITIVE_DELETION_REJECTIONS = new Map([ ["protected_target", 409], ["deletion_conflict", 409], ]); +const DELETION_PROGRESS_STAGES = new Set([ + "accepted", + "submitted", + "inventoried", + "approved", + "fenced", + "drained", + "bindings_removed", + "postgres_purged", + "cache_purged", + "logically_verified", + "retention_pending", +]); class DefinitiveDeletionRejection extends ApiFailure {} @@ -346,7 +359,7 @@ export function isDefinitiveDeletionRejection(reason: unknown) { return reason instanceof DefinitiveDeletionRejection; } -/** A possible dispatch terminates only on a tuple-bound acceptance or abort. */ +/** A possible dispatch terminates only on a tuple-bound 202 stage or abort. */ function deletionResult( response: { status: number; value: Reply }, request: DeletionRequest, @@ -355,15 +368,21 @@ function deletionResult( if ( status === 202 && !value.error && - value.status === "accepted" && + DELETION_PROGRESS_STAGES.has(value.status ?? "") && matchesDeletion(value, request) ) return value; if ( - value.error?.code === "deletion_aborted" && + status === 202 && + !value.error && + value.status === "aborted" && matchesDeletion(value, request) ) - check(value, "Could not check deletion status."); + throw new ApiFailure( + "deletion_aborted", + messages.deletion_aborted as string, + value.correlation_id, + ); throw new ApiFailure( "acceptance_unknown", messages.acceptance_unknown as string, @@ -371,21 +390,7 @@ function deletionResult( ); } -/** Read-only status check. A missing, malformed, or mismatched receipt stays uncertain. */ -export async function checkDeletionStatus(request: DeletionRequest) { - let response: { status: number; value: Reply }; - try { - response = await send("delete-receipt", request); - } catch { - throw new ApiFailure( - "acceptance_unknown", - messages.acceptance_unknown as string, - ); - } - return deletionResult(response, request); -} - -/** Sends one admission; only a fresh call trusts known pre-admission rejections. */ +/** One same-UUID POST per explicit attempt; only fresh known rejections are definitive. */ export async function admitDeletion( request: DeletionRequest, attempt: DeletionAttempt, @@ -394,10 +399,18 @@ export async function admitDeletion( try { response = await send("delete", request); } catch { - // Browser-to-broker response loss is ambiguous; reconcile below. - return checkDeletionStatus(request); + throw new ApiFailure( + "acceptance_unknown", + messages.acceptance_unknown as string, + ); } const code = response.value.error?.code ?? ""; + if (response.status === 409 && code === "deletion_request_conflict") + throw new DefinitiveDeletionRejection( + "deletion_conflict", + messages.deletion_conflict as string, + response.value.correlation_id, + ); if ( attempt === "fresh" && DEFINITIVE_DELETION_REJECTIONS.get(code) === response.status @@ -409,11 +422,5 @@ export async function admitDeletion( "Could not start deletion.", response.value.correlation_id, ); - try { - return deletionResult(response, request); - } catch (reason) { - if (reason instanceof ApiFailure && reason.code === "deletion_aborted") - throw reason; - } - return checkDeletionStatus(request); + return deletionResult(response, request); } diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index 5e2d3aabd..a8173cc72 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -362,7 +362,7 @@ test("avatar Settings access dismisses cleanly and exposes Profile and Plugins", } }); -test("hosted deletion reload waits for a manual receipt check while capability is off", async ({ +test("hosted deletion reload keeps the UUID until an enabled manual replay", async ({ page, app, }) => { @@ -374,6 +374,8 @@ test("hosted deletion reload waits for a manual receipt check while capability i acknowledgement_version: 1, }; const calls = []; + const deletionBodies = []; + let canDelete = false; await page.route("**/api/relay/identity", (route) => route.fulfill({ json: { viewer: owner } }), ); @@ -386,7 +388,7 @@ test("hosted deletion reload waits for a manual receipt check while capability i auth: { email: "owner@example.com", expiresAt: "2030", - capabilities: { can_delete_buzz_communities: false }, + capabilities: { can_delete_buzz_communities: canDelete }, }, }, }); @@ -401,11 +403,13 @@ test("hosted deletion reload waits for a manual receipt check while capability i can_create: false, }, }); - if (action === "delete-receipt") + if (action === "delete") { + deletionBodies.push(route.request().postDataJSON()); return route.fulfill({ status: 202, - json: { ...request, status: "accepted" }, + json: { ...request, status: "submitted" }, }); + } return route.fulfill({ status: 404, json: { error: "unexpected" } }); }); @@ -434,18 +438,25 @@ test("hosted deletion reload waits for a manual receipt check while capability i page.getByText(request.request_id, { exact: true }), ).toBeVisible(); await expect(page.getByText(/will not check automatically/i)).toBeVisible(); - expect(calls.filter((action) => action === "delete-receipt")).toHaveLength(0); + 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-receipt")).toHaveLength(1); - expect(calls.filter((action) => action === "delete")).toHaveLength(0); + expect(calls.filter((action) => action === "delete")).toHaveLength(1); + expect(deletionBodies).toEqual([request]); await expect(button(page, "Delete")).toHaveCount(0); expect( await page.evaluate(() => From fd7f9d31342499f62411e661bbe99d153f1f645b Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Tue, 29 Sep 2026 17:59:18 -0400 Subject: [PATCH 10/13] fix(hosted-communities): settle recovery on relay verdicts KGoose maps the relay's deletion_request_conflict and deletion_lifecycle_conflict to the client code deletion_conflict/409. The recovery branch keyed on the pre-mapping code and never fired, so a conflicted replay stayed pending forever. The same held for an owner who unarchived after an ambiguous submit (must_archive). Recovery now settles on every known rejection that proves the saved UUID has no relay reservation. KGoose preflights and the acknowledgement version, which the relay checks before its UUID lookup, stay fresh-only. Drop the unused 'accepted' progress stage. Signed-off-by: OpenAI Codex --- .../HostedCommunities.test.tsx | 45 ++++++------ src/bundled/hosted-communities/api.test.ts | 69 +++++++++++++------ src/bundled/hosted-communities/api.ts | 26 ++++--- 3 files changed, 85 insertions(+), 55 deletions(-) diff --git a/src/bundled/hosted-communities/HostedCommunities.test.tsx b/src/bundled/hosted-communities/HostedCommunities.test.tsx index f0ccfdf14..edf1cedb6 100644 --- a/src/bundled/hosted-communities/HostedCommunities.test.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.test.tsx @@ -848,7 +848,7 @@ const archived = { }; const accepted = (request: Record) => ({ ...request, - status: "accepted", + status: "submitted", correlation_id: "corr-delete", }); @@ -1186,7 +1186,7 @@ it("keeps and retries the same UUID after a wrong-status pre-admission rejection ).toBeGreaterThan(2); // Startup and the fresh bound-owner check before replay. }); -it("keeps the original UUID when recovery receives must_archive", async () => { +it("ends pending recovery on a definitive UUID retarget conflict", async () => { routes["/api/builderlab/list"] = () => ({ communities: [archived] }); routes["/api/builderlab/delete"] = () => new Response("{", { @@ -1195,29 +1195,25 @@ it("keeps the original UUID when recovery receives must_archive", async () => { }); renderCard(); await confirmDeletion(); - await screen.findByText("Deletion status is unknown"); - const original = localStorage.getItem(DELETION_PENDING_KEY); - const requestId = JSON.parse(original ?? "").request.request_id; - - const mustArchive = () => - Response.json( - { error: { code: "must_archive" }, correlation_id: "corr-recovery" }, - { status: 409 }, - ); - routes["/api/builderlab/delete"] = mustArchive; + 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( - "Deletion status is unknown", + "This deletion conflicts with another community lifecycle change", ); - expect(localStorage.getItem(DELETION_PENDING_KEY)).toBe(original); - const admissions = calls.filter(([url]) => url === "/api/builderlab/delete"); - expect(admissions).toHaveLength(2); - expect(admissions[1]?.[1].request_id).toBe(requestId); + 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 on a definitive UUID retarget conflict", async () => { +it("ends pending recovery when the owner unarchived before the replay", async () => { routes["/api/builderlab/list"] = () => ({ communities: [archived] }); routes["/api/builderlab/delete"] = () => new Response("{", { @@ -1228,22 +1224,25 @@ it("ends pending recovery on a definitive UUID retarget conflict", async () => { 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: "deletion_request_conflict" } }, - { status: 409 }, - ); + Response.json({ error: { code: "must_archive" } }, { 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", + "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(); }); diff --git a/src/bundled/hosted-communities/api.test.ts b/src/bundled/hosted-communities/api.test.ts index 1ebdcce6c..42697b8a7 100644 --- a/src/bundled/hosted-communities/api.test.ts +++ b/src/bundled/hosted-communities/api.test.ts @@ -5,6 +5,7 @@ import { type ApiFailure, clearPendingDeletion, DELETION_PENDING_KEY, + isDefinitiveDeletionRejection, persistPendingDeletion, readPendingDeletion, type DeletionRequest, @@ -74,10 +75,7 @@ it("treats a 409 UUID retarget conflict as definitive on replay", async () => { vi.stubGlobal( "fetch", vi.fn(async () => - Response.json( - { error: { code: "deletion_request_conflict" } }, - { status: 409 }, - ), + Response.json({ error: { code: "deletion_conflict" } }, { status: 409 }), ), ); await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ @@ -207,6 +205,50 @@ it.each([ }, ); +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", @@ -272,23 +314,6 @@ it.each([ expect(fetch).toHaveBeenCalledTimes(1); }); -it("keeps the same structured rejection uncertain during recovery", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - Response.json( - { error: { code: "must_archive" }, correlation_id: "corr-recovery" }, - { status: 409 }, - ), - ), - ); - await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ - code: "acceptance_unknown", - correlationId: "corr-recovery", - } satisfies Partial); - expect(fetch).toHaveBeenCalledTimes(1); -}); - it("preserves the original ambiguous response correlation", async () => { vi.stubGlobal( "fetch", @@ -372,7 +397,7 @@ it("requires HTTP 202 for a tuple-bound accepted result", async () => { vi.stubGlobal( "fetch", vi.fn(async () => - Response.json({ ...request, status: "accepted" }, { status: 200 }), + Response.json({ ...request, status: "submitted" }, { status: 200 }), ), ); await expect(admitDeletion(request, "recovery")).rejects.toMatchObject({ diff --git a/src/bundled/hosted-communities/api.ts b/src/bundled/hosted-communities/api.ts index 499969533..33b4be708 100644 --- a/src/bundled/hosted-communities/api.ts +++ b/src/bundled/hosted-communities/api.ts @@ -339,8 +339,20 @@ const DEFINITIVE_DELETION_REJECTIONS = new Map([ ["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([ - "accepted", "submitted", "inventoried", "approved", @@ -390,7 +402,7 @@ function deletionResult( ); } -/** One same-UUID POST per explicit attempt; only fresh known rejections are definitive. */ +/** One same-UUID POST per explicit attempt; relay verdicts that prove no reservation also settle recovery. */ export async function admitDeletion( request: DeletionRequest, attempt: DeletionAttempt, @@ -405,15 +417,9 @@ export async function admitDeletion( ); } const code = response.value.error?.code ?? ""; - if (response.status === 409 && code === "deletion_request_conflict") - throw new DefinitiveDeletionRejection( - "deletion_conflict", - messages.deletion_conflict as string, - response.value.correlation_id, - ); if ( - attempt === "fresh" && - DEFINITIVE_DELETION_REJECTIONS.get(code) === response.status + DEFINITIVE_DELETION_REJECTIONS.get(code) === response.status && + (attempt === "fresh" || !FRESH_ONLY_DELETION_REJECTIONS.has(code)) ) throw new DefinitiveDeletionRejection( code, From f579908773532e32790e39c93fb85bc536364cfb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 30 Sep 2026 19:16:52 +0000 Subject: [PATCH 11/13] fix(hosted-communities): preserve deletion recovery across account changes Signed-off-by: Codex Co-authored-by: Codex --- dev/relay-broker-api.test.mjs | 72 ++++++- .../HostedCommunities.test.tsx | 202 +++++++++++++++++- .../hosted-communities/HostedCommunities.tsx | 66 ++++-- 3 files changed, 312 insertions(+), 28 deletions(-) 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/src/bundled/hosted-communities/HostedCommunities.test.tsx b/src/bundled/hosted-communities/HostedCommunities.test.tsx index edf1cedb6..92b46fdc6 100644 --- a/src/bundled/hosted-communities/HostedCommunities.test.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.test.tsx @@ -872,6 +872,195 @@ async function confirmDeletion(host = archived.normalized_host) { ); } +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" })); + await screen.findByRole("button", { name: "Connect Buzz identity" }); + 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("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(); +}); + it.each([ ["absent", {}], ["incomplete", { quota_used: 5, quota_limit: 5 }], @@ -1131,7 +1320,7 @@ it("shows a stored request for explicit manual checking without background recov ]); }); -it("discards a recovery envelope bound to another owner without dispatch", async () => { +it("retains another owner's recovery envelope without showing or dispatching it", async () => { localStorage.setItem( DELETION_PENDING_KEY, JSON.stringify({ @@ -1146,11 +1335,16 @@ it("discards a recovery envelope bound to another owner without dispatch", async }, }), ); + const original = localStorage.getItem(DELETION_PENDING_KEY); renderCard(); await screen.findByText(npubEncode(local)); await waitFor(() => - expect(localStorage.getItem(DELETION_PENDING_KEY)).toBeNull(), + 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"); }); @@ -1524,10 +1718,10 @@ it.each(["accepted", "bound abort", "definitive rejection"])( ? Response.json( { ...old.request, - error: { code: "deletion_aborted" }, + status: "aborted", correlation_id: "corr-late-abort", }, - { status: 409 }, + { status: 202 }, ) : Response.json( { diff --git a/src/bundled/hosted-communities/HostedCommunities.tsx b/src/bundled/hosted-communities/HostedCommunities.tsx index c9324bb77..4e68cedc6 100644 --- a/src/bundled/hosted-communities/HostedCommunities.tsx +++ b/src/bundled/hosted-communities/HostedCommunities.tsx @@ -89,42 +89,67 @@ 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 acceptedDeletionIds = useRef(new Set()); + 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 null; // An account without a linked identity is the connect state, not a failure. if (current.error?.code !== "unauthorized" && !current.error?.setup_needed) check(current, "Could not load the connected Buzz identity."); - if (!list.error?.setup_needed) check(list, "Could not load communities."); + if (list.error?.code !== "unauthorized" && !list.error?.setup_needed) + check(list, "Could not load communities."); const nextIdentity = current.identity ?? null; const nextOwner = boundKey(nextIdentity); if ( loadedOwner.current !== undefined && loadedOwner.current !== nextOwner ) { - acceptedDeletionIds.current.clear(); + acceptedDeletions.current.clear(); setDeletionNotice(""); } loadedOwner.current = nextOwner; const stored = readPendingDeletion(); + setPendingDeletion( + stored?.owner_pubkey === nextOwner && + stored.backend_origin === window.location.origin + ? stored + : null, + ); + const listed = list.communities ?? []; if ( - stored && - (stored.owner_pubkey !== nextOwner || - stored.backend_origin !== window.location.origin) + reconcileAccepted && + nextOwner && + listed.some((community) => + acceptedDeletions.current.has(community.id ?? ""), + ) ) { - clearPendingDeletion(stored); - setPendingDeletion(null); - } else if (stored) { - setPendingDeletion(stored); + const currentAuth = await getAuth().catch(() => null); + if (at !== generation.current) return null; + if (currentAuth?.capabilities?.can_delete_buzz_communities === true) + for (const community of listed) { + 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 && + acceptedDeletions.current.get(accepted.request.community_id) === + accepted && + reason instanceof ApiFailure && + reason.code === "deletion_aborted" + ) + acceptedDeletions.current.delete(accepted.request.community_id); + } + if (at !== generation.current) return null; + } } - const listed = list.communities ?? []; const nextCommunities = listed.filter( (community) => - !community.id || !acceptedDeletionIds.current.has(community.id), + !community.id || !acceptedDeletions.current.has(community.id), ); const nextQuota = quota(list); setIdentity(nextIdentity); @@ -137,7 +162,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { (pending: PendingDeletion, at: number) => { if (at !== generation.current || !active()) return false; clearPendingDeletion(pending); - acceptedDeletionIds.current.add(pending.request.community_id); + acceptedDeletions.current.set(pending.request.community_id, pending); setPendingDeletion(null); setCommunities((list) => list.filter((item) => item.id !== pending.request.community_id), @@ -226,11 +251,6 @@ export function HostedCommunities({ active }: { active(): boolean }) { ), ); }; - const discardPendingDeletion = () => { - const stored = readPendingDeletion(); - if (stored) clearPendingDeletion(stored); - setPendingDeletion(null); - }; const settleDeletion = async ( pending: PendingDeletion, at: number, @@ -425,7 +445,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { onClick={() => void run("sign-out", async () => { await signOut(); - discardPendingDeletion(); + setPendingDeletion(null); setAuth(null); setIdentity(null); setCommunities([]); @@ -497,7 +517,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { await call("unbind"), "Could not release the previously connected Buzz identity.", ); - discardPendingDeletion(); + setPendingDeletion(null); // Unbound is a valid resting state; Connect recovers it. setIdentity(null); if (active()) await bind(); @@ -533,7 +553,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { await call("unbind"), "Could not unpair the Buzz identity.", ); - discardPendingDeletion(); + setPendingDeletion(null); setIdentity(null); await settle(); }, @@ -557,7 +577,7 @@ export function HostedCommunities({ active }: { active(): boolean }) { variant="ghost" size="sm" disabled={busy} - onClick={() => void run("refresh", load)} + onClick={() => void run("refresh", () => load(true))} >