fix(desktop): guard hosted deletion across identities

Signed-off-by: Codex <noreply@openai.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Codex
2026-09-30 19:47:20 +00:00
parent 047f7c2039
commit 75c761205a
6 changed files with 151 additions and 21 deletions

View File

@@ -63,11 +63,11 @@ test("single-slot A-B-A view retains exact bytes and blocks another owner", () =
target.setItem("buzz:hosted-community-delete-pending:v1", bytes);
assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), {
owned: envelope,
blockedByAnotherAccount: false,
blockingOwnerPubkey: null,
});
assert.deepEqual(pendingCommunityDeletionForAccount("b".repeat(64), target), {
owned: null,
blockedByAnotherAccount: true,
blockingOwnerPubkey: "a".repeat(64),
});
assert.equal(
target.getItem("buzz:hosted-community-delete-pending:v1"),
@@ -82,7 +82,7 @@ test("single-slot A-B-A view retains exact bytes and blocks another owner", () =
);
assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), {
owned: envelope,
blockedByAnotherAccount: false,
blockingOwnerPubkey: null,
});
assert.deepEqual(publicDeletionRequest(envelope), {
community_id: envelope.community_id,

View File

@@ -161,11 +161,11 @@ export function pendingCommunityDeletionForAccount(
storage: StorageLike = defaultStorage(),
): {
owned: PendingCommunityDeletion | null;
blockedByAnotherAccount: boolean;
blockingOwnerPubkey: string | null;
} {
const stored = loadPendingCommunityDeletion(storage);
if (!stored || !ownerPubkey) {
return { owned: null, blockedByAnotherAccount: false };
return { owned: null, blockingOwnerPubkey: null };
}
if (
pendingCommunityDeletionMatchesAccount(
@@ -174,9 +174,9 @@ export function pendingCommunityDeletionForAccount(
BUILDERLAB_BACKEND_ORIGIN,
)
) {
return { owned: stored, blockedByAnotherAccount: false };
return { owned: stored, blockingOwnerPubkey: null };
}
return { owned: null, blockedByAnotherAccount: true };
return { owned: null, blockingOwnerPubkey: stored.bound_owner_pubkey };
}
export function publicDeletionRequest(

View File

@@ -121,6 +121,15 @@ test("shared unknown errors stay neutral on non-deletion surfaces when deletion
});
test("limit_reached uses the server quota when available and retains the fallback", () => {
assert.equal(
hostedCommunityErrorMessage(
{ code: "limit_reached" },
undefined,
"fallback",
0,
),
"You can't create more communities right now.",
);
assert.equal(
hostedCommunityErrorMessage(
{ code: "limit_reached" },

View File

@@ -100,7 +100,10 @@ export function hostedCommunityErrorMessage(
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 ${displayedLimit} hosted communities.`,
limit_reached:
quotaLimit === 0
? "You can't create more communities right now."
: `You've reached the limit of ${displayedLimit} hosted communities.`,
relay_unavailable: "Community provisioning is temporarily unavailable.",
identity_already_bound:
"This Builderlab account is connected to another Buzz identity.",

View File

@@ -89,8 +89,9 @@ export function HostedCommunitiesSettingsCard() {
} | null>(null);
const [pendingDeletion, setPendingDeletion] =
React.useState<PendingCommunityDeletion | null>(null);
const [blockedByAnotherAccount, setBlockedByAnotherAccount] =
React.useState(false);
const [blockingOwnerPubkey, setBlockingOwnerPubkey] = React.useState<
string | null
>(null);
const hiddenCommunityIds = React.useRef(new Set<string>());
const recoveryAccount = React.useRef<string | null>(null);
const deleteInFlight = React.useRef<symbol | null>(null);
@@ -144,7 +145,7 @@ export function HostedCommunitiesSettingsCard() {
adoptAccountOwner(nextOwner);
const deletionView = pendingCommunityDeletionForAccount(nextOwner);
setPendingDeletion(deletionView.owned);
setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount);
setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey);
setIdentity(identityResponse.identity ?? null);
const nextCommunities = (communitiesResponse.communities ?? []).filter(
(community) =>
@@ -524,9 +525,9 @@ export function HostedCommunitiesSettingsCard() {
const startCommunityDeletion = (community: HostedCommunity) => {
const deletionView = pendingCommunityDeletionForAccount(boundHex);
if (deletionView.owned || deletionView.blockedByAnotherAccount) {
if (deletionView.owned || deletionView.blockingOwnerPubkey) {
setPendingDeletion(deletionView.owned);
setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount);
setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey);
return;
}
if (
@@ -558,7 +559,7 @@ export function HostedCommunitiesSettingsCard() {
return;
}
setPendingDeletion(envelope);
setBlockedByAnotherAccount(false);
setBlockingOwnerPubkey(null);
void runDeletion("Starting deletion…", envelope, async (generation) => {
try {
await invokeDeletion(envelope, "initial", generation);
@@ -639,7 +640,7 @@ export function HostedCommunitiesSettingsCard() {
recoveryAccount.current = accountKey;
const deletionView = pendingCommunityDeletionForAccount(boundHex);
setPendingDeletion(deletionView.owned);
setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount);
setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey);
}, [auth, boundHex, loading]);
const normalizedName = name.trim().toLowerCase();
@@ -790,17 +791,24 @@ export function HostedCommunitiesSettingsCard() {
Check deletion status
</Button>
</div>
{!deletionCapability ? (
<p>
Community deletion is unavailable right now, so this request
can&apos;t be checked. It stays saved on this device.
</p>
) : null}
</div>
) : null}
{auth && boundHex && blockedByAnotherAccount ? (
{auth && boundHex && deletionCapability && blockingOwnerPubkey ? (
<div
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-sm"
aria-live="polite"
>
Another account has a pending deletion on this device. Sign in with
that account to check deletion status before starting another deletion
here.
A deletion request from {safeNpub(blockingOwnerPubkey)} is still
pending on this device. Switch to that Buzz identity and use Check
deletion status before starting another deletion here. If you no
longer have that identity, contact support.
</div>
) : null}
@@ -980,7 +988,7 @@ export function HostedCommunitiesSettingsCard() {
busy || pendingDeletion?.community_id === community.id
}
deletionPending={
pendingDeletion !== null || blockedByAnotherAccount
pendingDeletion !== null || blockingOwnerPubkey !== null
}
canDelete={
deletionCapability &&

View File

@@ -9,6 +9,19 @@ const OUTDIR = "test-results/hosted-communities";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
/** A second valid identity key, used only as a contradictory hosted npub. */
const OTHER_HEX = "b".repeat(64);
const PENDING_KEY = "buzz:hosted-community-delete-pending:v1";
const OTHER_PENDING = {
community_id: "44444444-4444-4444-8444-444444444444",
host: "private-other.communities.buzz.xyz",
request_id: "55555555-5555-4555-8555-555555555555",
acknowledgement_version: 1,
bound_owner_pubkey: "a".repeat(64),
backend_origin: "https://app.builderlab.xyz",
};
const OTHER_PENDING_BYTES = JSON.stringify(OTHER_PENDING);
const BLOCKED_COPY = `A deletion request from ${npubEncode(OTHER_PENDING.bound_owner_pubkey)} is still pending on this device. Switch to that Buzz identity and use Check deletion status before starting another deletion here. If you no longer have that identity, contact support.`;
const CAPABILITY_OFF_COPY =
"Community deletion is unavailable right now, so this request can't be checked. It stays saved on this device.";
const DELETION_COMMUNITIES: Array<{
id: string;
name: string;
@@ -157,6 +170,101 @@ async function deletionPayloads(page: Page) {
);
}
async function seedOtherOwnerPending(page: Page) {
await page.evaluate(
({ key, bytes }) => window.localStorage.setItem(key, bytes),
{ key: PENDING_KEY, bytes: OTHER_PENDING_BYTES },
);
}
test("another Buzz identity's pending slot disables archived Delete without revealing its target", async ({
page,
}) => {
await openDeletionFixture(page, { capability: true });
await seedOtherOwnerPending(page);
await page.getByRole("button", { name: "Refresh" }).click();
const notice = page.getByText(BLOCKED_COPY, { exact: true });
await expect(notice).toBeVisible();
await expect(notice).not.toContainText(OTHER_PENDING.host);
await expect(notice).not.toContainText(OTHER_PENDING.community_id);
await expect(notice).not.toContainText(OTHER_PENDING.request_id);
await expect(
page
.getByTestId("hosted-community-row")
.filter({ hasText: "Archived team" })
.getByRole("button", { name: "Delete", exact: true }),
).toBeDisabled();
expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES);
expect(await deletionPayloads(page)).toHaveLength(0);
});
test("an occupied slot discovered during confirmation never sends or overwrites", async ({
page,
}) => {
await openDeletionFixture(page, { capability: true });
const archived = page
.getByTestId("hosted-community-row")
.filter({ hasText: "Archived team" });
await archived.getByRole("button", { name: "Delete", exact: true }).click();
await page
.getByLabel(
"Type the exact host to continue: Exact-Host.communities.buzz.xyz",
)
.fill("Exact-Host.communities.buzz.xyz");
await page.getByRole("button", { name: "Continue" }).click();
await seedOtherOwnerPending(page);
await page
.getByRole("button", { name: "Delete community permanently" })
.click();
await expect(page.getByText(BLOCKED_COPY, { exact: true })).toBeVisible();
await expect(
page.getByText(/Could not safely save the pending deletion request/),
).toHaveCount(0);
expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES);
expect(await deletionPayloads(page)).toHaveLength(0);
});
test("another identity's blocked notice is hidden when deletion capability is off", async ({
page,
}) => {
await openDeletionFixture(page, { capability: false });
await seedOtherOwnerPending(page);
await page.getByRole("button", { name: "Refresh" }).click();
await expect(page.getByText(BLOCKED_COPY, { exact: true })).toHaveCount(0);
await expect(page.getByText(/pending deletion on this device/i)).toHaveCount(
0,
);
expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES);
expect(await deletionPayloads(page)).toHaveLength(0);
});
test("own pending request explains why Check is disabled when deletion capability is off", async ({
page,
}) => {
await openDeletionFixture(page, { capability: false });
const ownedBytes = JSON.stringify({
...OTHER_PENDING,
bound_owner_pubkey: DEFAULT_MOCK_PUBKEY,
});
await page.evaluate(
({ key, bytes }) => window.localStorage.setItem(key, bytes),
{ key: PENDING_KEY, bytes: ownedBytes },
);
await page.getByRole("button", { name: "Refresh" }).click();
await expect(
page.getByText(CAPABILITY_OFF_COPY, { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Check deletion status" }),
).toBeDisabled();
expect(await storedDeletionBytes(page)).toBe(ownedBytes);
expect(await deletionPayloads(page)).toHaveLength(0);
});
test("reopen sends nothing and Check resends the same saved request once", async ({
page,
}) => {
@@ -389,7 +497,9 @@ test("A-B-A owner switch retains exact bytes and replays only under A", async ({
page.getByRole("button", { name: "Check deletion status" }),
).toHaveCount(0);
await expect(
page.getByText(/another account has a pending deletion/i),
page.getByText(
/A deletion request from npub1.* is still pending on this device/,
),
).toBeVisible();
await expect.poll(() => storedDeletionBytes(page)).toBe(bytes);
expect(await deletionPayloads(page)).toHaveLength(1);