diff --git a/desktop/src-tauri/src/builderlab.rs b/desktop/src-tauri/src/builderlab.rs index 1252946c58b..f3eea63eaec 100644 --- a/desktop/src-tauri/src/builderlab.rs +++ b/desktop/src-tauri/src/builderlab.rs @@ -7,6 +7,7 @@ use axum::{ routing::get, Router, }; +use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use tauri_plugin_opener::OpenerExt; use tokio::{net::TcpListener, sync::oneshot}; @@ -20,6 +21,7 @@ const BB_SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential"; // or challenge/verify fail with `invalid_origin`. It also seeds the challenge // body's `origin` field so both agree. const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz"; +const BUILDERLAB_JSON_RESPONSE_LIMIT: usize = 64 * 1024; const AUTH_COMPLETE_HTML: &str = r#" @@ -166,6 +168,7 @@ pub(crate) struct BuilderlabAuthInfo { expires_at: String, email: Option, name: Option, + can_delete_buzz_communities: bool, } #[derive(Debug, Deserialize)] @@ -173,6 +176,17 @@ struct AuthMeResponse { email: Option, name: Option, expires_at: String, + #[serde(default)] + capabilities: serde_json::Value, +} + +impl AuthMeResponse { + fn can_delete_buzz_communities(&self) -> bool { + self.capabilities + .get("can_delete_buzz_communities") + .and_then(serde_json::Value::as_bool) + == Some(true) + } } struct CallbackState { @@ -343,10 +357,12 @@ pub(crate) async fn start_builderlab_login( if exchanged.expires_at != me.expires_at { return Err("Builderlab session expiry did not match code exchange".to_owned()); } + let can_delete_buzz_communities = me.can_delete_buzz_communities(); let info = BuilderlabAuthInfo { expires_at: me.expires_at.clone(), email: me.email, name: me.name, + can_delete_buzz_communities, }; { let mut pending = login.0.lock().map_err(|error| error.to_string())?; @@ -379,11 +395,15 @@ pub(crate) async fn get_builderlab_auth( return Ok(None); }; match authenticated_user(&app_state.http_client, &credential).await { - Ok(me) => Ok(Some(BuilderlabAuthInfo { - expires_at: me.expires_at, - email: me.email, - name: me.name, - })), + Ok(me) => { + let can_delete_buzz_communities = me.can_delete_buzz_communities(); + Ok(Some(BuilderlabAuthInfo { + expires_at: me.expires_at, + email: me.email, + name: me.name, + can_delete_buzz_communities, + })) + } Err(error) => { *session .0 @@ -421,13 +441,34 @@ struct NostrIdentityChallenge { expires_at: String, } -async fn authenticated_json( +#[derive(Debug, Serialize)] +pub(crate) struct AuthenticatedJsonResponse { + http_status: u16, + body: serde_json::Value, +} + +fn authenticated_json_response_from_parts( + status: reqwest::StatusCode, + bytes: &[u8], +) -> Result { + let body: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|error| format!("invalid Builderlab response: {error}"))?; + if !status.is_success() && body.get("error").is_none() { + return Err(format!("Builderlab request failed (HTTP {status}).")); + } + Ok(AuthenticatedJsonResponse { + http_status: status.as_u16(), + body, + }) +} + +async fn authenticated_json_with_status( client: &reqwest::Client, session: &BuilderlabSession, method: reqwest::Method, path: &str, body: serde_json::Value, -) -> Result { +) -> Result { let credential = session .0 .lock() @@ -445,22 +486,34 @@ async fn authenticated_json( .await .map_err(|error| format!("Builderlab request failed: {error}"))?; let status = response.status(); - let value: serde_json::Value = response - .json() - .await - .map_err(|error| format!("invalid Builderlab response: {error}"))?; - if !status.is_success() { - // Builderlab error responses carry a structured `{ error: { code, - // message, setup_needed, ... } }` body. Pass those through as `Ok` so the - // frontend's typed handling and friendly per-code messages apply, instead - // of surfacing a raw JSON blob. Only fall back to a plain string when the - // body isn't the expected shape. - if value.get("error").is_some() { - return Ok(value); + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("Builderlab response failed: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > BUILDERLAB_JSON_RESPONSE_LIMIT { + return Err("Builderlab response exceeded the size limit".to_owned()); } - return Err(format!("Builderlab request failed (HTTP {status}).")); + bytes.extend_from_slice(&chunk); } - Ok(value) + // Builderlab error responses carry a structured `{ error: { code, + // message, setup_needed, ... } }` body. Preserve those as a typed result so + // the deletion classifier can bind the body to reqwest's actual status. + // Other callers continue to receive only the body through authenticated_json. + authenticated_json_response_from_parts(status, &bytes) +} + +async fn authenticated_json( + client: &reqwest::Client, + session: &BuilderlabSession, + method: reqwest::Method, + path: &str, + body: serde_json::Value, +) -> Result { + Ok( + authenticated_json_with_status(client, session, method, path, body) + .await? + .body, + ) } #[tauri::command] @@ -640,6 +693,39 @@ pub(crate) async fn transfer_builderlab_community( .await } +fn community_deletion_body( + community_id: String, + host: String, + request_id: String, + acknowledgement_version: i32, +) -> serde_json::Value { + serde_json::json!({ + "community_id": community_id, + "host": host, + "request_id": request_id, + "acknowledgement_version": acknowledgement_version, + }) +} + +#[tauri::command] +pub(crate) async fn delete_builderlab_community( + community_id: String, + host: String, + request_id: String, + acknowledgement_version: i32, + app_state: tauri::State<'_, crate::app_state::AppState>, + session: tauri::State<'_, BuilderlabSession>, +) -> Result { + authenticated_json_with_status( + &app_state.http_client, + &session, + reqwest::Method::POST, + "/v1/buzz/communities/delete", + community_deletion_body(community_id, host, request_id, acknowledgement_version), + ) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -684,4 +770,79 @@ mod tests { ); assert!(!query.contains_key("screen_hint")); } + + #[test] + fn deletion_capability_requires_literal_true() { + for (capabilities, expected) in [ + (serde_json::json!({}), false), + ( + serde_json::json!({ "can_delete_buzz_communities": "true" }), + false, + ), + ( + serde_json::json!({ "can_delete_buzz_communities": false }), + false, + ), + ( + serde_json::json!({ "can_delete_buzz_communities": true }), + true, + ), + ] { + let response: AuthMeResponse = serde_json::from_value(serde_json::json!({ + "expires_at": "2099-01-01T00:00:00Z", + "capabilities": capabilities, + })) + .expect("auth-me fixture"); + assert_eq!(response.can_delete_buzz_communities(), expected); + } + } + + #[test] + fn community_deletion_commands_share_the_exact_public_tuple() { + let body = community_deletion_body( + "2f6c6a10-6513-45a6-9605-4694333d8feb".to_owned(), + "Exact-Host.communities.buzz.xyz".to_owned(), + "2e1b354d-6f7c-44e8-8928-cf743c77bbbc".to_owned(), + 1, + ); + assert_eq!( + body, + serde_json::json!({ + "community_id": "2f6c6a10-6513-45a6-9605-4694333d8feb", + "host": "Exact-Host.communities.buzz.xyz", + "request_id": "2e1b354d-6f7c-44e8-8928-cf743c77bbbc", + "acknowledgement_version": 1, + }) + ); + let path = "/v1/buzz/communities/delete"; + let url = api_url(path).expect("deletion URL"); + assert_eq!(url.origin().ascii_serialization(), BUILDERLAB_ORIGIN); + assert_eq!(url.path(), format!("/api/goose{path}")); + } + + #[test] + fn deletion_transport_uses_the_native_status_not_a_body_claim() { + let response = authenticated_json_response_from_parts( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + br#"{"http_status":202,"error":{"code":"relay_unavailable"}}"#, + ) + .expect("structured response"); + + assert_eq!(response.http_status, 503); + assert_eq!(response.body["http_status"], 202); + } + + #[test] + fn community_deletion_commands_are_registered_on_the_native_boundary() { + let lib = include_str!("lib.rs"); + let command = "delete_builderlab_community,"; + assert_eq!( + lib.matches(command).count(), + 1, + "{command} must be registered exactly once" + ); + assert!(!lib.contains("get_builderlab_community_deletion_receipt")); + let source = include_str!("builderlab.rs"); + assert!(source.contains(".header(reqwest::header::ORIGIN, BUILDERLAB_ORIGIN)")); + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a37d9c536a5..491df126a44 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -556,6 +556,7 @@ pub fn run() { archive_builderlab_community, unarchive_builderlab_community, transfer_builderlab_community, + delete_builderlab_community, title_bar_double_click, get_identity, get_nsec, diff --git a/desktop/src/features/communities/communityDeletionPending.test.mjs b/desktop/src/features/communities/communityDeletionPending.test.mjs new file mode 100644 index 00000000000..58e5e6ef358 --- /dev/null +++ b/desktop/src/features/communities/communityDeletionPending.test.mjs @@ -0,0 +1,491 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearPendingCommunityDeletion, + deletionResponseDisposition, + loadPendingCommunityDeletion, + persistPendingCommunityDeletion, + pendingCommunityDeletionMatchesAccount, + pendingCommunityDeletionMatchesPersisted, + pendingCommunityDeletionForAccount, + publicDeletionRequest, +} from "./communityDeletionPending.ts"; + +function storage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + values, + }; +} + +const envelope = { + community_id: "4efb8c89-b9cb-4a26-863d-cf2bd5f9d5c1", + host: "Exact-Host.communities.buzz.xyz", + request_id: "b2456816-eea0-4f74-9c54-531645dbaec9", + acknowledgement_version: 1, + bound_owner_pubkey: "a".repeat(64), + backend_origin: "https://app.builderlab.xyz", +}; + +function transport(http_status, body) { + return { http_status, body }; +} + +test("pending deletion round-trips exact host bytes and account binding", () => { + const target = storage(); + assert.equal(persistPendingCommunityDeletion(envelope, target), true); + assert.deepEqual(loadPendingCommunityDeletion(target), envelope); + assert.equal( + pendingCommunityDeletionMatchesAccount( + envelope, + "a".repeat(64), + "https://app.builderlab.xyz", + ), + true, + ); + assert.equal( + pendingCommunityDeletionMatchesAccount( + envelope, + "b".repeat(64), + "https://app.builderlab.xyz", + ), + false, + ); +}); + +test("single-slot A-B-A view retains exact bytes and blocks another owner", () => { + const target = storage(); + const bytes = JSON.stringify(envelope); + target.setItem("buzz:hosted-community-delete-pending:v1", bytes); + assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), { + owned: envelope, + blockingOwnerPubkey: null, + }); + assert.deepEqual(pendingCommunityDeletionForAccount("b".repeat(64), target), { + owned: null, + blockingOwnerPubkey: "a".repeat(64), + }); + assert.equal( + target.getItem("buzz:hosted-community-delete-pending:v1"), + bytes, + ); + assert.equal( + persistPendingCommunityDeletion( + { ...envelope, bound_owner_pubkey: "b".repeat(64) }, + target, + ), + false, + ); + assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), { + owned: envelope, + blockingOwnerPubkey: null, + }); + assert.deepEqual(publicDeletionRequest(envelope), { + community_id: envelope.community_id, + host: envelope.host, + request_id: envelope.request_id, + acknowledgement_version: envelope.acknowledgement_version, + }); + assert.equal( + target.getItem("buzz:hosted-community-delete-pending:v1"), + bytes, + ); +}); + +test("pending deletion rejects repaired hosts, malformed UUIDs, and unknown fields", () => { + const target = storage(); + for (const invalid of [ + { ...envelope, host: ` ${envelope.host}` }, + { ...envelope, request_id: envelope.request_id.toUpperCase() }, + { ...envelope, acknowledgement_version: 2 }, + { ...envelope, extra: true }, + ]) { + target.setItem( + "buzz:hosted-community-delete-pending:v1", + JSON.stringify(invalid), + ); + assert.equal(loadPendingCommunityDeletion(target), null); + assert.equal(target.values.size, 0, "invalid envelopes are discarded"); + } +}); + +test("persistence failure is observable and clear is bounded to the deletion key", () => { + const throwing = { + getItem: () => null, + setItem: () => { + throw new Error("denied"); + }, + removeItem: () => {}, + }; + assert.equal(persistPendingCommunityDeletion(envelope, throwing), false); + + const target = storage(); + target.setItem("unrelated", "keep"); + assert.equal(persistPendingCommunityDeletion(envelope, target), true); + clearPendingCommunityDeletion(envelope, target); + assert.equal(target.getItem("unrelated"), "keep"); +}); + +test("persistence boundary never overwrites an existing envelope", () => { + const target = storage(); + assert.equal(persistPendingCommunityDeletion(envelope, target), true); + const second = { + ...envelope, + community_id: "33333333-3333-4333-8333-333333333333", + request_id: "44444444-4444-4444-8444-444444444444", + }; + assert.equal(persistPendingCommunityDeletion(second, target), false); + assert.deepEqual(loadPendingCommunityDeletion(target), envelope); +}); + +test("persisted request must still match all tuple and owner/origin fields before dispatch", () => { + const target = storage(); + assert.equal(persistPendingCommunityDeletion(envelope, target), true); + assert.equal( + pendingCommunityDeletionMatchesPersisted(envelope, target), + true, + ); + for (const changed of [ + { ...envelope, request_id: "44444444-4444-4444-8444-444444444444" }, + { ...envelope, community_id: "33333333-3333-4333-8333-333333333333" }, + { ...envelope, host: "other.communities.buzz.xyz" }, + { ...envelope, bound_owner_pubkey: "b".repeat(64) }, + { ...envelope, backend_origin: "https://other.example" }, + ]) { + assert.equal( + pendingCommunityDeletionMatchesPersisted(changed, target), + false, + ); + } + clearPendingCommunityDeletion(envelope, target); + assert.equal( + pendingCommunityDeletionMatchesPersisted(envelope, target), + false, + ); +}); + +test("terminal clear affects only the matching request and account envelope", () => { + const target = storage(); + const second = { + ...envelope, + request_id: "44444444-4444-4444-8444-444444444444", + }; + assert.equal(persistPendingCommunityDeletion(second, target), true); + const bytes = target.getItem("buzz:hosted-community-delete-pending:v1"); + clearPendingCommunityDeletion(envelope, target); + assert.equal( + target.getItem("buzz:hosted-community-delete-pending:v1"), + bytes, + ); + clearPendingCommunityDeletion(second, target); + assert.equal(loadPendingCommunityDeletion(target), null); +}); + +test("same-UUID check settles definitive errors but retains fresh-only and wrong status", () => { + for (const [code, status, freshOnly] of [ + ["missing_mapping", 400, true], + ["invalid_request", 400, true], + ["confirmation_mismatch", 400, true], + ["unsupported_acknowledgement_version", 400, true], + ["not_owner", 404, false], + ["must_archive", 409, false], + ["protected_target", 409, false], + ["deletion_conflict", 409, false], + ]) { + for (const attempt of ["initial", "check"]) { + assert.equal( + deletionResponseDisposition( + transport(status, { error: { code } }), + envelope, + attempt, + ), + attempt === "check" && freshOnly ? "retain" : "clear", + `${attempt} ${code} exact status`, + ); + assert.equal( + deletionResponseDisposition( + transport(status + 1, { error: { code } }), + envelope, + attempt, + ), + "retain", + `${attempt} ${code} wrong status`, + ); + } + } + assert.equal( + deletionResponseDisposition( + transport(409, { error: { code: "deletion_request_conflict" } }), + envelope, + "check", + ), + "retain", + ); + assert.equal( + deletionResponseDisposition( + transport(503, { error: { code: "acceptance_unknown" } }), + envelope, + "initial", + ), + "retain", + ); +}); + +test("only tuple-bound canonical stages or abort settle same-UUID recovery", () => { + const tuple = { + request_id: envelope.request_id, + community_id: envelope.community_id, + host: envelope.host, + acknowledgement_version: envelope.acknowledgement_version, + }; + for (const status of [ + "submitted", + "inventoried", + "approved", + "fenced", + "drained", + "bindings_removed", + "postgres_purged", + "cache_purged", + "logically_verified", + "retention_pending", + ]) { + assert.equal( + deletionResponseDisposition( + transport(202, { ...tuple, status }), + envelope, + "check", + ), + "accept", + status, + ); + } + assert.equal( + deletionResponseDisposition( + transport(202, { ...tuple, status: "aborted" }), + envelope, + "check", + ), + "abort", + ); + assert.equal( + deletionResponseDisposition( + transport(409, { ...tuple, error: { code: "deletion_aborted" } }), + envelope, + "check", + ), + "abort", + ); + assert.equal( + deletionResponseDisposition( + transport(409, { error: { code: "deletion_aborted" } }), + envelope, + "check", + ), + "retain", + ); + for (const status of ["accepted", "admitted", "completed", "future_stage"]) { + assert.equal( + deletionResponseDisposition( + transport(202, { ...tuple, status }), + envelope, + "check", + ), + "retain", + status, + ); + } + for (const body of [ + { + ...tuple, + request_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + status: "approved", + }, + { ...tuple, host: "other.communities.buzz.xyz", status: "approved" }, + { ...tuple, acknowledgement_version: 2, status: "aborted" }, + ]) { + assert.equal( + deletionResponseDisposition(transport(202, body), envelope, "check"), + "retain", + ); + } + assert.equal( + deletionResponseDisposition( + transport(503, { ...tuple, status: "approved", http_status: 202 }), + envelope, + "initial", + ), + "retain", + ); + assert.equal( + deletionResponseDisposition( + transport(200, { + ...tuple, + error: { code: "deletion_aborted" }, + http_status: 409, + }), + envelope, + "check", + ), + "retain", + ); +}); + +test("a typed rejection with a partial or mismatched tuple stays uncertain", () => { + for (const attempt of ["initial", "check"]) { + assert.equal( + deletionResponseDisposition( + transport(404, { + error: { code: "not_owner" }, + request_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }), + envelope, + attempt, + ), + "retain", + ); + assert.equal( + deletionResponseDisposition( + transport(409, { + error: { code: "must_archive" }, + host: "elsewhere.example", + }), + envelope, + attempt, + ), + "retain", + ); + } + assert.equal( + deletionResponseDisposition( + transport(400, { error: { code: "invalid_request" }, request_id: 42 }), + envelope, + "initial", + ), + "retain", + ); + assert.equal( + deletionResponseDisposition(transport(409, null), envelope, "check"), + "retain", + ); +}); + +test("fresh admission clears only the established exact code and native-status pairs", () => { + const terminalPairs = [ + ["missing_mapping", 400], + ["invalid_request", 400], + ["confirmation_mismatch", 400], + ["unsupported_acknowledgement_version", 400], + ["not_owner", 404], + ["must_archive", 409], + ["protected_target", 409], + ["deletion_conflict", 409], + ]; + for (const [code, httpStatus] of terminalPairs) { + assert.equal( + deletionResponseDisposition( + transport(httpStatus, { error: { code } }), + envelope, + "initial", + ), + "clear", + `${code} requires HTTP ${httpStatus}`, + ); + assert.equal( + deletionResponseDisposition( + transport(httpStatus + 1, { error: { code } }), + envelope, + "initial", + ), + "retain", + `${code} with the wrong native status is ambiguous`, + ); + } + + for (const code of [ + "acceptance_unknown", + "unauthorized", + "relay_unavailable", + "unknown", + "future_code", + ]) { + assert.equal( + deletionResponseDisposition( + transport(400, { error: { code } }), + envelope, + "initial", + ), + "retain", + `${code} cannot clear the envelope`, + ); + } +}); + +test("native status, never a body-claimed status, binds acceptance and abort", () => { + const tuple = { + request_id: envelope.request_id, + community_id: envelope.community_id, + host: envelope.host, + acknowledgement_version: envelope.acknowledgement_version, + }; + assert.equal( + deletionResponseDisposition( + transport(202, { ...tuple, status: "approved" }), + envelope, + "initial", + ), + "accept", + ); + assert.equal( + deletionResponseDisposition( + transport(503, { + ...tuple, + status: "approved", + http_status: 202, + }), + envelope, + "initial", + ), + "retain", + ); + assert.equal( + deletionResponseDisposition( + transport(409, { ...tuple, error: { code: "deletion_aborted" } }), + envelope, + "check", + ), + "abort", + ); + assert.equal( + deletionResponseDisposition( + transport(200, { + ...tuple, + error: { code: "deletion_aborted" }, + http_status: 409, + }), + envelope, + "check", + ), + "retain", + ); +}); + +test("missing native status cannot settle a typed deletion error", () => { + for (const code of ["future_code", "not_owner", "must_archive"]) { + for (const attempt of ["initial", "check"]) { + assert.equal( + deletionResponseDisposition( + { body: { error: { code } } }, + envelope, + attempt, + ), + "retain", + `${attempt} ${code} without native status is ambiguous`, + ); + } + } +}); diff --git a/desktop/src/features/communities/communityDeletionPending.ts b/desktop/src/features/communities/communityDeletionPending.ts new file mode 100644 index 00000000000..afa6cd70ca3 --- /dev/null +++ b/desktop/src/features/communities/communityDeletionPending.ts @@ -0,0 +1,273 @@ +export const BUILDERLAB_BACKEND_ORIGIN = "https://app.builderlab.xyz"; +export const PENDING_COMMUNITY_DELETION_KEY = + "buzz:hosted-community-delete-pending:v1"; + +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const PUBKEY = /^[0-9a-f]{64}$/; +const KEYS = [ + "acknowledgement_version", + "backend_origin", + "bound_owner_pubkey", + "community_id", + "host", + "request_id", +] as const; + +export type CommunityDeletionRequest = { + community_id: string; + host: string; + request_id: string; + acknowledgement_version: 1; +}; + +export type PendingCommunityDeletion = CommunityDeletionRequest & { + bound_owner_pubkey: string; + backend_origin: string; +}; + +export type CommunityDeletionAttempt = "initial" | "check"; + +export type CommunityDeletionResponseLike = { + request_id?: string; + community_id?: string; + host?: string; + acknowledgement_version?: number; + status?: string; + error?: { code?: string }; + correlation_id?: string; +}; + +export type CommunityDeletionTransport = { + http_status?: number; + body?: CommunityDeletionResponseLike; +}; + +export type CommunityDeletionDisposition = + | "accept" + | "abort" + | "clear" + | "retain"; + +type StorageLike = Pick; + +function isPendingCommunityDeletion( + value: unknown, +): value is PendingCommunityDeletion { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + if ( + Object.keys(record).length !== KEYS.length || + !KEYS.every((key) => Object.hasOwn(record, key)) + ) + return false; + return ( + typeof record.community_id === "string" && + UUID.test(record.community_id) && + typeof record.host === "string" && + record.host.length > 0 && + record.host === record.host.trim() && + typeof record.request_id === "string" && + UUID.test(record.request_id) && + record.acknowledgement_version === 1 && + typeof record.bound_owner_pubkey === "string" && + PUBKEY.test(record.bound_owner_pubkey) && + typeof record.backend_origin === "string" && + record.backend_origin === BUILDERLAB_BACKEND_ORIGIN + ); +} + +function defaultStorage(): StorageLike { + return window.localStorage; +} + +export function loadPendingCommunityDeletion( + storage: StorageLike = defaultStorage(), +): PendingCommunityDeletion | null { + try { + const raw = storage.getItem(PENDING_COMMUNITY_DELETION_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (isPendingCommunityDeletion(parsed)) return parsed; + storage.removeItem(PENDING_COMMUNITY_DELETION_KEY); + } catch { + try { + storage.removeItem(PENDING_COMMUNITY_DELETION_KEY); + } catch { + // The caller still fails closed when storage itself is unavailable. + } + } + return null; +} + +export function persistPendingCommunityDeletion( + envelope: PendingCommunityDeletion, + storage: StorageLike = defaultStorage(), +): boolean { + if (!isPendingCommunityDeletion(envelope)) return false; + try { + // This key is the one-envelope admission boundary. Never replace an + // existing valid envelope, even if another mounted view has stale React + // state. loadPendingCommunityDeletion removes malformed values first; a + // value that remains (or cannot be removed) fails closed. + if (loadPendingCommunityDeletion(storage) !== null) return false; + if (storage.getItem(PENDING_COMMUNITY_DELETION_KEY) !== null) return false; + storage.setItem(PENDING_COMMUNITY_DELETION_KEY, JSON.stringify(envelope)); + return ( + storage.getItem(PENDING_COMMUNITY_DELETION_KEY) === + JSON.stringify(envelope) + ); + } catch { + return false; + } +} + +/** Confirm the durable request still matches before sending or settling it. */ +export function pendingCommunityDeletionMatchesPersisted( + envelope: PendingCommunityDeletion, + storage: StorageLike = defaultStorage(), +): boolean { + const stored = loadPendingCommunityDeletion(storage); + return stored !== null && KEYS.every((key) => stored[key] === envelope[key]); +} + +export function clearPendingCommunityDeletion( + envelope: PendingCommunityDeletion, + storage: StorageLike = defaultStorage(), +): void { + try { + if (pendingCommunityDeletionMatchesPersisted(envelope, storage)) { + storage.removeItem(PENDING_COMMUNITY_DELETION_KEY); + } + } catch { + // Clearing is best effort after a terminal server result. + } +} + +export function pendingCommunityDeletionMatchesAccount( + envelope: PendingCommunityDeletion, + ownerPubkey: string, + backendOrigin: string, +): boolean { + return ( + envelope.bound_owner_pubkey === ownerPubkey && + envelope.backend_origin === backendOrigin + ); +} + +/** Project the single durable envelope for the current account without clearing another owner's intent. */ +export function pendingCommunityDeletionForAccount( + ownerPubkey: string | null, + storage: StorageLike = defaultStorage(), +): { + owned: PendingCommunityDeletion | null; + blockingOwnerPubkey: string | null; +} { + const stored = loadPendingCommunityDeletion(storage); + if (!stored || !ownerPubkey) { + return { owned: null, blockingOwnerPubkey: null }; + } + if ( + pendingCommunityDeletionMatchesAccount( + stored, + ownerPubkey, + BUILDERLAB_BACKEND_ORIGIN, + ) + ) { + return { owned: stored, blockingOwnerPubkey: null }; + } + return { owned: null, blockingOwnerPubkey: stored.bound_owner_pubkey }; +} + +export function publicDeletionRequest( + envelope: PendingCommunityDeletion, +): CommunityDeletionRequest { + return { + community_id: envelope.community_id, + host: envelope.host, + request_id: envelope.request_id, + acknowledgement_version: envelope.acknowledgement_version, + }; +} + +function responseMatchesDeletionTuple( + response: CommunityDeletionResponseLike, + envelope: PendingCommunityDeletion, +): boolean { + return ( + response.request_id === envelope.request_id && + response.community_id === envelope.community_id && + response.host === envelope.host && + response.acknowledgement_version === envelope.acknowledgement_version + ); +} + +const ACCEPTED_STAGES = new Set([ + "submitted", + "inventoried", + "approved", + "fenced", + "drained", + "bindings_removed", + "postgres_purged", + "cache_purged", + "logically_verified", + "retention_pending", +]); + +const DEFINITIVE_ERRORS: Readonly> = { + missing_mapping: 400, + invalid_request: 400, + confirmation_mismatch: 400, + unsupported_acknowledgement_version: 400, + not_owner: 404, + must_archive: 409, + protected_target: 409, + deletion_conflict: 409, +}; + +const FRESH_ONLY_ERRORS = new Set([ + "missing_mapping", + "invalid_request", + "confirmation_mismatch", + "unsupported_acknowledgement_version", +]); + +/** Settle only a tuple-bound stage or a native-status/typed-code rejection. */ +export function deletionResponseDisposition( + transport: CommunityDeletionTransport, + envelope: PendingCommunityDeletion, + attempt: CommunityDeletionAttempt, +): CommunityDeletionDisposition { + const response = transport.body ?? {}; + const status = transport.http_status; + const tupleMatches = responseMatchesDeletionTuple(response, envelope); + if (status === 202 && tupleMatches) { + if (response.status && ACCEPTED_STAGES.has(response.status)) + return "accept"; + if (response.status === "aborted") return "abort"; + } + if ( + status === 409 && + response.error?.code === "deletion_aborted" && + tupleMatches + ) { + return "abort"; + } + const code = response.error?.code; + const suppliedTuple = [ + "request_id", + "community_id", + "host", + "acknowledgement_version", + ].some((field) => Object.hasOwn(response, field)); + if ( + code && + typeof status === "number" && + (!suppliedTuple || tupleMatches) && + DEFINITIVE_ERRORS[code] === status && + (attempt === "initial" || !FRESH_ONLY_ERRORS.has(code)) + ) + return "clear"; + return "retain"; +} diff --git a/desktop/src/features/communities/hostedCommunityApi.test.mjs b/desktop/src/features/communities/hostedCommunityApi.test.mjs index 0af05143dff..c3e3cafeea1 100644 --- a/desktop/src/features/communities/hostedCommunityApi.test.mjs +++ b/desktop/src/features/communities/hostedCommunityApi.test.mjs @@ -14,6 +14,8 @@ import test from "node:test"; import { npubEncode } from "nostr-tools/nip19"; import { + hostedCommunityErrorMessage, + hostedCommunityCreateAvailable, normalizedBoundKeyHex, usableBoundIdentityNpub, } from "./hostedCommunityApi.ts"; @@ -96,3 +98,64 @@ test("the string normalizer rejects the same non-key values directly", () => { assert.equal(normalizedBoundKeyHex("f".repeat(65)), null); assert.equal(normalizedBoundKeyHex(" "), null); }); + +test("shared unknown errors stay neutral on non-deletion surfaces when deletion is unavailable", () => { + for (const fallback of [ + "Could not create the community.", + "Could not archive the community.", + "Could not load communities.", + "Could not prepare hosted-community onboarding.", + ]) { + const message = hostedCommunityErrorMessage( + { code: "unknown" }, + undefined, + fallback, + ); + assert.equal( + message, + "The hosted-community service returned an invalid response.", + `fallback=${fallback}`, + ); + assert.doesNotMatch(message, /delet/i); + } +}); + +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" }, + undefined, + "fallback", + 7, + ), + "You've reached the limit of 7 hosted communities.", + ); + assert.equal( + hostedCommunityErrorMessage( + { code: "limit_reached" }, + undefined, + "fallback", + ), + "You've reached the limit of 5 hosted communities.", + ); +}); + +test("only explicit can_create false hides Create, never visible rows or absent projection", () => { + for (const response of [ + {}, + { communities: Array.from({ length: 7 }) }, + { can_create: true }, + ]) { + assert.equal(hostedCommunityCreateAvailable(response), true); + } + assert.equal(hostedCommunityCreateAvailable({ can_create: false }), false); +}); diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts index ebf5857c6e2..ae09d13ffc4 100644 --- a/desktop/src/features/communities/hostedCommunityApi.ts +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -10,6 +10,7 @@ export type BuilderlabAuth = { email?: string; name?: string; expiresAt: string; + canDeleteBuzzCommunities?: boolean; }; export type HostedCommunityApiError = { @@ -40,6 +41,19 @@ export type HostedCommunity = { export type HostedCommunitiesResponse = { communities?: HostedCommunity[]; + quota_used?: number; + quota_limit?: number; + can_create?: boolean; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityDeletionResponse = { + request_id?: string; + community_id?: string; + host?: string; + acknowledgement_version?: number; + status?: string; error?: HostedCommunityApiError; correlation_id?: string; }; @@ -60,18 +74,36 @@ export type HostedCommunityMutationResponse = { export type HostedCommunityAccount = { communities: HostedCommunity[]; identity: HostedNostrIdentity | null; + quotaUsed: number | null; + quotaLimit: number | null; + canCreate: boolean; }; +/** Only the server's explicit negative quota projection blocks creation. */ +export function hostedCommunityCreateAvailable( + response: HostedCommunitiesResponse, +): boolean { + return response.can_create !== false; +} + export function hostedCommunityErrorMessage( error: HostedCommunityApiError | undefined, correlationId: string | undefined, fallback: string, + quotaLimit?: number | null, ) { + const displayedLimit = + quotaLimit != null && Number.isInteger(quotaLimit) && quotaLimit > 0 + ? quotaLimit + : HOSTED_COMMUNITY_LIMIT; 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 ${HOSTED_COMMUNITY_LIMIT} 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.", @@ -80,6 +112,17 @@ export function hostedCommunityErrorMessage( 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.", + confirmation_mismatch: "The host acknowledgement did not match exactly.", + must_archive: "Archive this community before deleting it.", + protected_target: "This community cannot be deleted.", + deletion_conflict: + "This community already has a conflicting deletion request.", + unsupported_acknowledgement_version: + "This app version cannot confirm the current deletion terms.", + deletion_aborted: "The deletion request was stopped by an operator.", + acceptance_unknown: + "Deletion acceptance is uncertain. Check deletion status; do not start a new request.", + unknown: "The hosted-community service returned an invalid response.", }; const message = messages[error?.code ?? ""] ?? error?.message ?? fallback; return correlationId @@ -187,6 +230,13 @@ export async function loadHostedCommunityAccount(): Promise( null, ); - const [communities, setCommunities] = React.useState([]); + const [quota, setQuota] = React.useState<{ + used: number | null; + limit: number | null; + canCreate: boolean; + }>({ used: null, limit: null, canCreate: false }); const [name, setName] = React.useState(""); const [availability, setAvailability] = React.useState(null); const [checkingName, setCheckingName] = React.useState(false); @@ -60,7 +62,11 @@ export function HostedCommunityCreateFlow({ const loadAccount = React.useCallback(async () => { const account = await loadHostedCommunityAccount(); setIdentity(account.identity); - setCommunities(account.communities); + setQuota({ + used: account.quotaUsed, + limit: account.quotaLimit, + canCreate: account.canCreate, + }); }, []); React.useEffect(() => { @@ -147,7 +153,7 @@ export function HostedCommunityCreateFlow({ await clearBuilderlabAuth(); setAuth(null); setIdentity(null); - setCommunities([]); + setQuota({ used: null, limit: null, canCreate: false }); }); // Identity rows display npubs derived from the same key the mismatch gate @@ -209,7 +215,7 @@ export function HostedCommunityCreateFlow({ const validName = normalizedName.length <= 63 && VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); - const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + const atCommunityLimit = quota.canCreate !== true; const ready = Boolean(auth && usableBoundIdentity && !identityMismatch); React.useEffect(() => { @@ -268,6 +274,7 @@ export function HostedCommunityCreateFlow({ response.error, response.correlation_id, "Could not create the community.", + quota.limit, ), ); } @@ -400,7 +407,11 @@ export function HostedCommunityCreateFlow({ } const feedback = atCommunityLimit - ? `You’ve reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.` + ? quota.limit === null + ? "Community quota is unavailable. Creation stays disabled." + : quota.limit === 0 + ? "You can't create more communities right now." + : `You’ve reached the limit of ${quota.limit} hosted communities.` : name && !validName ? "Use lowercase letters, numbers, and single hyphens." : checkingName diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx index 5f9578bf131..e026576963d 100644 --- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -10,7 +10,6 @@ import { createHostedCommunity, deleteBuilderlabIdentity, getBuilderlabAuth, - HOSTED_COMMUNITY_LIMIT, HOSTED_COMMUNITY_SUFFIX, hostedCommunityErrorMessage, hostedCommunityRelayUrl, @@ -87,6 +86,11 @@ export function HostedCommunityOnboarding({ null, ); const [communities, setCommunities] = React.useState([]); + const [quota, setQuota] = React.useState<{ + used: number | null; + limit: number | null; + canCreate: boolean; + }>({ used: null, limit: null, canCreate: false }); const [showCreate, setShowCreate] = React.useState(false); const [name, setName] = React.useState(""); const [availability, setAvailability] = React.useState(null); @@ -100,6 +104,11 @@ export function HostedCommunityOnboarding({ const account = await loadHostedCommunityAccount(); setIdentity(account.identity); setCommunities(account.communities); + setQuota({ + used: account.quotaUsed, + limit: account.quotaLimit, + canCreate: account.canCreate, + }); }, []); React.useEffect(() => { @@ -170,6 +179,7 @@ export function HostedCommunityOnboarding({ setAuth(null); setIdentity(null); setCommunities([]); + setQuota({ used: null, limit: null, canCreate: false }); setShowCreate(false); setName(""); setAvailability(null); @@ -259,7 +269,7 @@ export function HostedCommunityOnboarding({ const validName = normalizedName.length <= 63 && VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); - const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + const atCommunityLimit = quota.canCreate !== true; const hasCommunities = activeCommunities.length > 0; React.useEffect(() => { @@ -345,6 +355,7 @@ export function HostedCommunityOnboarding({ response.error, response.correlation_id, "Could not create the community.", + quota.limit, ), ); } @@ -385,7 +396,11 @@ export function HostedCommunityOnboarding({ ) : null; const creationFeedback = atCommunityLimit - ? `You’ve reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.` + ? quota.limit === null + ? "Community quota is unavailable. Creation stays disabled." + : quota.limit === 0 + ? "You can't create more communities right now." + : `You’ve reached the limit of ${quota.limit} hosted communities.` : name && !validName ? "Use lowercase letters, numbers, and single hyphens." : checkingName diff --git a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx index a9097671c06..6db0fc3e668 100644 --- a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx @@ -2,9 +2,6 @@ import * as React from "react"; import { invoke } from "@tauri-apps/api/core"; import { AlertCircle, - Archive, - ArchiveRestore, - ArrowLeftRight, CheckCircle2, ExternalLink, LoaderCircle, @@ -15,9 +12,9 @@ import { import { useIdentityQuery } from "@/shared/api/hooks"; import { - HOSTED_COMMUNITY_LIMIT as MAX_COMMUNITIES, HOSTED_COMMUNITY_SUFFIX as HOST_SUFFIX, hostedCommunityErrorMessage as errorMessage, + hostedCommunityCreateAvailable, hostedCommunityRelayUrl as relayUrl, normalizedBoundKeyHex, usableBoundIdentityNpub, @@ -30,7 +27,19 @@ import { type HostedNostrIdentity as NostrIdentity, VALID_HOSTED_COMMUNITY_NAME as VALID_NAME, } from "@/features/communities/hostedCommunityApi"; -import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; +import { + BUILDERLAB_BACKEND_ORIGIN, + clearPendingCommunityDeletion, + deletionResponseDisposition, + pendingCommunityDeletionForAccount, + pendingCommunityDeletionMatchesAccount, + pendingCommunityDeletionMatchesPersisted, + persistPendingCommunityDeletion, + publicDeletionRequest, + type CommunityDeletionAttempt, + type CommunityDeletionTransport, + type PendingCommunityDeletion, +} from "@/features/communities/communityDeletionPending"; import { useCommunities } from "@/features/communities/useCommunities"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { safeNpub } from "@/shared/lib/nostrUtils"; @@ -46,16 +55,10 @@ import { AlertDialogTitle, } from "@/shared/ui/alert-dialog"; import { Button, buttonVariants } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; +import { refreshAcceptedCommunityDeletions } from "./acceptedDeletionRefresh"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import { HostedCommunityRow } from "./HostedCommunityRow"; function relayHost(url: string | null | undefined) { if (!url) return null; @@ -79,8 +82,40 @@ export function HostedCommunitiesSettingsCard() { const [loading, setLoading] = React.useState(true); const [action, setAction] = React.useState(null); const [error, setError] = React.useState(null); + const [statusMessage, setStatusMessage] = React.useState(null); + const [quota, setQuota] = React.useState<{ + used: number | null; + limit: number | null; + canCreate: boolean; + } | null>(null); + const [pendingDeletion, setPendingDeletion] = + React.useState(null); + const [blockingOwnerPubkey, setBlockingOwnerPubkey] = React.useState< + string | null + >(null); + const acceptedDeletions = React.useRef( + new Map(), + ); + const cardActive = React.useRef(false); + const recoveryAccount = React.useRef(null); + const deleteInFlight = React.useRef(null); + const accountOwner = React.useRef(null); + const accountGeneration = React.useRef(0); + + const adoptAccountOwner = React.useCallback((nextOwner: string | null) => { + if (accountOwner.current === nextOwner) return; + accountOwner.current = nextOwner; + accountGeneration.current += 1; + acceptedDeletions.current.clear(); + deleteInFlight.current = null; + setAction(null); + setError(null); + setStatusMessage(null); + }, []); - const loadAccount = React.useCallback(async () => { + const loadAccount = React.useCallback(async (): Promise< + HostedCommunity[] + > => { setError(null); const [identityResponse, communitiesResponse] = await Promise.all([ invoke("get_builderlab_nostr_identity"), @@ -111,11 +146,34 @@ export function HostedCommunitiesSettingsCard() { ), ); } + const nextOwner = normalizedBoundKeyHex( + identityResponse.identity?.pubkey_hex, + ); + adoptAccountOwner(nextOwner); + const deletionView = pendingCommunityDeletionForAccount(nextOwner); + setPendingDeletion(deletionView.owned); + setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey); setIdentity(identityResponse.identity ?? null); - setCommunities(communitiesResponse.communities ?? []); - }, []); + const listedCommunities = communitiesResponse.communities ?? []; + const nextCommunities = listedCommunities.filter( + (community) => + !community.id || !acceptedDeletions.current.has(community.id), + ); + setCommunities(nextCommunities); + setQuota({ + used: Number.isInteger(communitiesResponse.quota_used) + ? (communitiesResponse.quota_used as number) + : null, + limit: Number.isInteger(communitiesResponse.quota_limit) + ? (communitiesResponse.quota_limit as number) + : null, + canCreate: hostedCommunityCreateAvailable(communitiesResponse), + }); + return listedCommunities; + }, [adoptAccountOwner]); React.useEffect(() => { + cardActive.current = true; let active = true; void invoke("get_builderlab_auth") .then(async (nextAuth) => { @@ -131,6 +189,8 @@ export function HostedCommunitiesSettingsCard() { }); return () => { active = false; + cardActive.current = false; + accountGeneration.current += 1; }; }, [loadAccount]); @@ -150,6 +210,16 @@ export function HostedCommunitiesSettingsCard() { } }; + const clearAccountView = () => { + adoptAccountOwner(null); + setAuth(null); + setIdentity(null); + setCommunities([]); + setQuota(null); + setPendingDeletion(null); + setBlockingOwnerPubkey(null); + }; + const signIn = () => run("Signing in…", async () => { const nextAuth = await invoke("start_builderlab_login"); @@ -160,9 +230,8 @@ export function HostedCommunitiesSettingsCard() { const signOut = () => run("Signing out…", async () => { await invoke("clear_builderlab_auth"); - setAuth(null); - setIdentity(null); - setCommunities([]); + clearAccountView(); + setStatusMessage(null); setName(""); setAvailability(null); }); @@ -199,7 +268,10 @@ export function HostedCommunitiesSettingsCard() { ), ); } + adoptAccountOwner(null); setIdentity(null); + setPendingDeletion(null); + setStatusMessage(null); await loadAccount(); }); @@ -235,7 +307,6 @@ export function HostedCommunitiesSettingsCard() { (!usableBoundIdentity || (boundHex !== null && localHex !== null && boundHex !== localHex)), ); - const switchToDeviceIdentity = () => run("Switching identity…", async () => { // The account is bound to a different npub, so re-binding directly returns @@ -255,6 +326,9 @@ export function HostedCommunitiesSettingsCard() { ), ); } + adoptAccountOwner(null); + setPendingDeletion(null); + setStatusMessage(null); const bound = await invoke( "bind_builderlab_nostr_identity", ); @@ -330,12 +404,279 @@ export function HostedCommunitiesSettingsCard() { response.error, response.correlation_id, "Could not transfer ownership.", + quota?.limit, ), ); } await loadAccount(); }); + const applyDeletionResponse = async ( + transport: CommunityDeletionTransport, + envelope: PendingCommunityDeletion, + attempt: CommunityDeletionAttempt, + generation: number, + ) => { + if ( + accountGeneration.current !== generation || + !pendingCommunityDeletionMatchesAccount( + envelope, + accountOwner.current ?? "", + BUILDERLAB_BACKEND_ORIGIN, + ) || + !pendingCommunityDeletionMatchesPersisted(envelope) + ) + return; + const response = transport.body ?? {}; + const disposition = deletionResponseDisposition( + transport, + envelope, + attempt, + ); + if (disposition === "abort") { + clearPendingCommunityDeletion(envelope); + setPendingDeletion(null); + setStatusMessage( + "Deletion stopped. This community is not being deleted.", + ); + await loadAccount(); + return; + } + if (response.error) { + if (disposition === "clear") { + clearPendingCommunityDeletion(envelope); + setPendingDeletion(null); + await loadAccount(); + } + throw new Error( + errorMessage( + response.error, + response.correlation_id, + attempt === "check" + ? "Could not confirm deletion status. The existing request remains pending." + : "Could not start community deletion.", + ), + ); + } + if (disposition !== "accept") { + throw new Error( + "Deletion acceptance is uncertain. Check deletion status; do not start a new request.", + ); + } + clearPendingCommunityDeletion(envelope); + setPendingDeletion(null); + acceptedDeletions.current.set(envelope.community_id, envelope); + setCommunities((current) => + current.filter((community) => community.id !== envelope.community_id), + ); + setStatusMessage("Deletion started"); + await loadAccount(); + }; + + const deletionContextMatches = ( + envelope: PendingCommunityDeletion, + generation: number, + ) => + accountGeneration.current === generation && + pendingCommunityDeletionMatchesAccount( + envelope, + accountOwner.current ?? "", + BUILDERLAB_BACKEND_ORIGIN, + ); + + const runDeletion = async ( + label: string, + envelope: PendingCommunityDeletion, + operation: (generation: number) => Promise, + ) => { + const generation = accountGeneration.current; + setAction(label); + setError(null); + try { + await operation(generation); + return true; + } catch (cause) { + if (deletionContextMatches(envelope, generation)) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + return false; + } finally { + if (deletionContextMatches(envelope, generation)) setAction(null); + } + }; + + const invokeDeletion = async ( + envelope: PendingCommunityDeletion, + attempt: CommunityDeletionAttempt, + generation: number, + ) => { + if ( + !deletionContextMatches(envelope, generation) || + !pendingCommunityDeletionMatchesPersisted(envelope) + ) + return; + const request = publicDeletionRequest(envelope); + let response: CommunityDeletionTransport; + try { + response = await invoke( + "delete_builderlab_community", + { + communityId: request.community_id, + host: request.host, + requestId: request.request_id, + acknowledgementVersion: request.acknowledgement_version, + }, + ); + } catch (cause) { + if ( + !deletionContextMatches(envelope, generation) || + !pendingCommunityDeletionMatchesPersisted(envelope) + ) + return; + throw cause; + } + if (!deletionContextMatches(envelope, generation)) return; + await applyDeletionResponse(response, envelope, attempt, generation); + }; + + const startCommunityDeletion = (community: HostedCommunity) => { + const deletionView = pendingCommunityDeletionForAccount(boundHex); + if (deletionView.owned || deletionView.blockingOwnerPubkey) { + setPendingDeletion(deletionView.owned); + setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey); + return; + } + if ( + pendingDeletion !== null || + deleteInFlight.current !== null || + auth?.canDeleteBuzzCommunities !== true || + identityMismatch || + !community.archived_at || + !community.id || + !community.normalized_host || + !boundHex + ) + return; + const inFlight = Symbol("community-deletion"); + deleteInFlight.current = inFlight; + const envelope: PendingCommunityDeletion = { + community_id: community.id, + host: community.normalized_host, + request_id: crypto.randomUUID().toLowerCase(), + acknowledgement_version: 1, + bound_owner_pubkey: boundHex, + backend_origin: BUILDERLAB_BACKEND_ORIGIN, + }; + if (!persistPendingCommunityDeletion(envelope)) { + if (deleteInFlight.current === inFlight) deleteInFlight.current = null; + setError( + "Could not safely save the pending deletion request. Nothing was sent.", + ); + return; + } + setPendingDeletion(envelope); + setBlockingOwnerPubkey(null); + void runDeletion("Starting deletion…", envelope, async (generation) => { + try { + await invokeDeletion(envelope, "initial", generation); + } finally { + if (deleteInFlight.current === inFlight) deleteInFlight.current = null; + } + }); + }; + + const checkDeletionStatus = (envelope: PendingCommunityDeletion) => { + if ( + identityMismatch || + accountOwner.current !== envelope.bound_owner_pubkey || + !pendingCommunityDeletionMatchesPersisted(envelope) || + deleteInFlight.current !== null + ) + return; + const inFlight = Symbol("community-deletion-check"); + deleteInFlight.current = inFlight; + void runDeletion( + "Checking deletion status…", + envelope, + async (generation) => { + try { + const refreshedAuth = await invoke( + "get_builderlab_auth", + ); + if (!deletionContextMatches(envelope, generation)) return; + if (!refreshedAuth) { + clearAccountView(); + return; + } + setAuth(refreshedAuth); + if (refreshedAuth.canDeleteBuzzCommunities !== true) { + throw new Error( + "Community deletion is no longer enabled for this account. The existing request remains pending.", + ); + } + await loadAccount(); + if (!deletionContextMatches(envelope, generation)) return; + const confirmedAuth = await invoke( + "get_builderlab_auth", + ); + if (!deletionContextMatches(envelope, generation)) return; + if (!confirmedAuth) { + clearAccountView(); + return; + } + setAuth(confirmedAuth); + if (confirmedAuth.canDeleteBuzzCommunities !== true) { + throw new Error( + "Community deletion is no longer enabled for this account. The existing request remains pending.", + ); + } + await invokeDeletion(envelope, "check", generation); + } finally { + if (deleteInFlight.current === inFlight) + deleteInFlight.current = null; + } + }, + ); + }; + + const refreshCommunities = () => + run("Refreshing…", async () => { + const generation = accountGeneration.current; + await refreshAcceptedCommunityDeletions({ + accepted: acceptedDeletions.current, + isCurrent: () => + cardActive.current && accountGeneration.current === generation, + ownerPubkey: () => accountOwner.current, + identityMismatch, + loadAccount, + setAuth, + clearAccount: clearAccountView, + restoreListed: (listed) => { + setCommunities( + listed.filter( + (community) => + !community.id || !acceptedDeletions.current.has(community.id), + ), + ); + setStatusMessage( + acceptedDeletions.current.size === 0 + ? "Deletion stopped. This community is not being deleted." + : "Deletion started", + ); + }, + }); + }); + + React.useEffect(() => { + if (!auth || loading || !boundHex) return; + const accountKey = `${BUILDERLAB_BACKEND_ORIGIN}:${boundHex}`; + if (recoveryAccount.current === accountKey) return; + recoveryAccount.current = accountKey; + const deletionView = pendingCommunityDeletionForAccount(boundHex); + setPendingDeletion(deletionView.owned); + setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey); + }, [auth, boundHex, loading]); + const normalizedName = name.trim().toLowerCase(); const validName = normalizedName.length <= 63 && VALID_NAME.test(normalizedName); @@ -386,7 +727,7 @@ export function HostedCommunitiesSettingsCard() { !validName || !usableBoundIdentity || identityMismatch || - communities.length >= MAX_COMMUNITIES + quota?.canCreate === false ) return; void run("Creating community…", async () => { @@ -414,6 +755,7 @@ export function HostedCommunitiesSettingsCard() { response.error, response.correlation_id, "Could not create the community.", + quota?.limit, ), ); } @@ -438,7 +780,8 @@ export function HostedCommunitiesSettingsCard() { }; const busy = action != null; - const atCommunityLimit = communities.length >= MAX_COMMUNITIES; + const atCommunityLimit = quota?.canCreate === false; + const deletionCapability = auth?.canDeleteBuzzCommunities === true; return (
@@ -454,6 +797,56 @@ export function HostedCommunitiesSettingsCard() { ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + + {pendingDeletion ? ( +
+

+ Deletion acceptance for {pendingDeletion.host} is uncertain. Keep + request {pendingDeletion.request_id} pending until its + status is resolved. Checking deletion status resends this same + request ID and may start the original deletion if it was never + received. Do not start a different request. +

+
+ +
+ {!deletionCapability ? ( +

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

+ ) : null} +
+ ) : null} + + {auth && boundHex && deletionCapability && blockingOwnerPubkey ? ( +
+ A deletion request from{" "} + {safeNpub(blockingOwnerPubkey) ?? "another Buzz identity"} 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. +
+ ) : null} + {loading ? (
Checking sign-in… @@ -592,14 +985,16 @@ export function HostedCommunitiesSettingsCard() {

Your communities - {communities.length} of {MAX_COMMUNITIES} used + {quota?.used != null && quota.limit != null + ? `${quota.used} of ${quota.limit} used` + : "Quota unavailable"}

@@ -617,10 +1012,20 @@ export function HostedCommunitiesSettingsCard() { Number(Boolean(b.archived_at)), ) .map((community, index) => ( - void archiveCommunity(community)} onUnarchive={() => void unarchiveCommunity(community)} onTransfer={(npub) => transferCommunity(community, npub)} + onDelete={() => startCommunityDeletion(community)} /> ))} @@ -667,9 +1073,11 @@ export function HostedCommunitiesSettingsCard() {
{atCommunityLimit ? (

- You've reached the limit of {MAX_COMMUNITIES} hosted - communities. Transfer one to free up a slot before creating - another. + {quota?.limit === 0 + ? "You can't create more communities right now." + : quota?.limit != null + ? `You've reached the limit of ${quota.limit} hosted communities. A deletion frees its slot only after logical cleanup completes.` + : "You've reached the hosted-community limit. A deletion frees its slot only after logical cleanup completes."}

) : null}
@@ -712,23 +1120,24 @@ export function HostedCommunitiesSettingsCard() { That address is available.

) : null} - + {!atCommunityLimit ? ( + + ) : null} )} @@ -777,218 +1186,3 @@ function UnpairIdentityButton({ ); } - -function CommunityRow({ - community, - busy, - canConnect, - onConnect, - onArchive, - onUnarchive, - onTransfer, - showIconPicker, -}: { - community: HostedCommunity; - busy: boolean; - canConnect: boolean; - onConnect: () => void; - onArchive: () => void; - onUnarchive: () => void; - onTransfer: (npub: string) => Promise; - showIconPicker: boolean; -}) { - const [confirmArchive, setConfirmArchive] = React.useState(false); - const [confirmUnarchive, setConfirmUnarchive] = React.useState(false); - const [transferOpen, setTransferOpen] = React.useState(false); - const url = relayUrl(community); - const archived = Boolean(community.archived_at); - const displayName = community.name ?? community.slug ?? "Hosted community"; - - return ( -
  • -
    - {showIconPicker ? : null} -
    -

    {displayName}

    -

    - {community.normalized_host} - {archived ? " · Archived" : ""} -

    -
    -
    - - {archived ? ( - <> - - - - - Unarchive {displayName}? - - This address becomes connectable again. Connections that - closed during archival will not reconnect automatically. - - - - Cancel - - Unarchive - - - - - - ) : ( -
    - {url && canConnect ? ( - - ) : null} - - - - - - - Archive {displayName}? - - New and existing connections stop and the address stays - reserved. Archiving can't be undone from here without - unarchiving, and the community keeps counting toward your - quota — it isn't deleted. - - - - Cancel - - Archive - - - - - - -
    - )} -
  • - ); -} - -function TransferOwnershipDialog({ - open, - onOpenChange, - communityName, - busy, - onTransfer, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - communityName: string; - busy: boolean; - onTransfer: (npub: string) => Promise; -}) { - const [npub, setNpub] = React.useState(""); - const npubIsValid = npub.startsWith("npub1") && npub.length >= 50; - - React.useEffect(() => { - if (!open) setNpub(""); - }, [open]); - - const submit = async () => { - if (!npubIsValid) return; - const ok = await onTransfer(npub.trim()); - if (ok) onOpenChange(false); - }; - - return ( - - - - Transfer ownership - - Transfer {communityName} to another person. You become a regular - member. The recipient needs a connected Buzz identity first, and - this can't be undone. - - -
    - setNpub(event.target.value.trim())} - /> - {npub.length > 0 && !npubIsValid ? ( -

    - Enter a valid npub that starts with npub1. -

    - ) : null} -
    - - - - -
    -
    - ); -} diff --git a/desktop/src/features/settings/ui/HostedCommunityDeleteDialog.test.mjs b/desktop/src/features/settings/ui/HostedCommunityDeleteDialog.test.mjs new file mode 100644 index 00000000000..875e329d3d8 --- /dev/null +++ b/desktop/src/features/settings/ui/HostedCommunityDeleteDialog.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deletionAcknowledgementMatches } from "./HostedCommunityDeleteDialog.tsx"; + +test("deletion acknowledgement is byte-exact", () => { + const host = "Exact-Host.communities.buzz.xyz"; + assert.equal(deletionAcknowledgementMatches(host, host), true); + assert.equal(deletionAcknowledgementMatches(host.toLowerCase(), host), false); + assert.equal(deletionAcknowledgementMatches(` ${host}`, host), false); + assert.equal(deletionAcknowledgementMatches(`${host} `, host), false); +}); diff --git a/desktop/src/features/settings/ui/HostedCommunityDeleteDialog.tsx b/desktop/src/features/settings/ui/HostedCommunityDeleteDialog.tsx new file mode 100644 index 00000000000..9b612e529bf --- /dev/null +++ b/desktop/src/features/settings/ui/HostedCommunityDeleteDialog.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; + +import type { HostedCommunity } from "@/features/communities/hostedCommunityApi"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +export function deletionAcknowledgementMatches(value: string, host: string) { + return value === host; +} + +export function HostedCommunityDeleteDialog({ + community, + disabled, + onConfirm, +}: { + community: HostedCommunity; + disabled: boolean; + onConfirm: () => void; +}) { + const [open, setOpen] = React.useState(false); + const [hostAcknowledgement, setHostAcknowledgement] = React.useState(""); + const [finalStep, setFinalStep] = React.useState(false); + const host = community.normalized_host ?? ""; + + const setDialogOpen = (next: boolean) => { + setOpen(next); + if (!next) { + setHostAcknowledgement(""); + setFinalStep(false); + } + }; + + return ( + <> + + + + + + {finalStep + ? "Permanently delete this community?" + : `Delete ${host}?`} + + + This cannot be cancelled by an owner. All community content will + eventually be deleted, the host stays permanently reserved, and + your quota slot is released only after logical cleanup completes. + + + {finalStep ? ( +

    + You acknowledged {host}. This final confirmation + immediately starts the irreversible deletion workflow. +

    + ) : ( + + )} + + + {finalStep ? ( + + ) : ( + + )} + +
    +
    + + ); +} diff --git a/desktop/src/features/settings/ui/HostedCommunityRow.tsx b/desktop/src/features/settings/ui/HostedCommunityRow.tsx new file mode 100644 index 00000000000..723e04c915b --- /dev/null +++ b/desktop/src/features/settings/ui/HostedCommunityRow.tsx @@ -0,0 +1,262 @@ +import * as React from "react"; +import { + Archive, + ArchiveRestore, + ArrowLeftRight, + LoaderCircle, +} from "lucide-react"; + +import { + hostedCommunityRelayUrl as relayUrl, + type HostedCommunity, +} from "@/features/communities/hostedCommunityApi"; +import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button, buttonVariants } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { HostedCommunityDeleteDialog } from "./HostedCommunityDeleteDialog"; + +export function HostedCommunityRow({ + community, + busy, + canDelete, + canConnect, + deletionPending, + onConnect, + onArchive, + onUnarchive, + onTransfer, + onDelete, + showIconPicker, +}: { + community: HostedCommunity; + busy: boolean; + canDelete: boolean; + canConnect: boolean; + deletionPending: boolean; + onConnect: () => void; + onArchive: () => void; + onUnarchive: () => void; + onTransfer: (npub: string) => Promise; + onDelete: () => void; + showIconPicker: boolean; +}) { + const [confirmArchive, setConfirmArchive] = React.useState(false); + const [confirmUnarchive, setConfirmUnarchive] = React.useState(false); + const [transferOpen, setTransferOpen] = React.useState(false); + const url = relayUrl(community); + const archived = Boolean(community.archived_at); + const displayName = community.name ?? community.slug ?? "Hosted community"; + + return ( +
  • +
    + {showIconPicker ? : null} +
    +

    {displayName}

    +

    + {community.normalized_host} + {archived ? " · Archived" : ""} +

    +
    +
    + + {archived ? ( +
    + + + + + Unarchive {displayName}? + + This address becomes connectable again. Connections that + closed during archival will not reconnect automatically. + + + + Cancel + + Unarchive + + + + + {canDelete ? ( + + ) : null} +
    + ) : ( +
    + {url && canConnect ? ( + + ) : null} + + + + + + + Archive {displayName}? + + New and existing connections stop and the address stays + reserved. Archiving can't be undone from here without + unarchiving, and the community keeps counting toward your + quota — it isn't deleted. + + + + Cancel + + Archive + + + + + + +
    + )} +
  • + ); +} + +function TransferOwnershipDialog({ + open, + onOpenChange, + communityName, + busy, + onTransfer, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + communityName: string; + busy: boolean; + onTransfer: (npub: string) => Promise; +}) { + const [npub, setNpub] = React.useState(""); + const npubIsValid = npub.startsWith("npub1") && npub.length >= 50; + + React.useEffect(() => { + if (!open) setNpub(""); + }, [open]); + + const submit = async () => { + if (!npubIsValid) return; + const ok = await onTransfer(npub.trim()); + if (ok) onOpenChange(false); + }; + + return ( + + + + Transfer ownership + + Transfer {communityName} to another person. You become a regular + member. The recipient needs a connected Buzz identity first, and + this can't be undone. + + +
    + setNpub(event.target.value.trim())} + /> + {npub.length > 0 && !npubIsValid ? ( +

    + Enter a valid npub that starts with npub1. +

    + ) : null} +
    + + + + +
    +
    + ); +} diff --git a/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts b/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts new file mode 100644 index 00000000000..bc9b8784d4b --- /dev/null +++ b/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts @@ -0,0 +1,87 @@ +import { invoke } from "@tauri-apps/api/core"; + +import { + BUILDERLAB_BACKEND_ORIGIN, + deletionResponseDisposition, + publicDeletionRequest, + type CommunityDeletionTransport, + type PendingCommunityDeletion, +} from "@/features/communities/communityDeletionPending"; +import type { + BuilderlabAuth, + HostedCommunity, +} from "@/features/communities/hostedCommunityApi"; + +type RefreshContext = { + accepted: Map; + isCurrent: () => boolean; + ownerPubkey: () => string | null; + identityMismatch: boolean; + loadAccount: () => Promise; + setAuth: (auth: BuilderlabAuth | null) => void; + clearAccount: () => void; + restoreListed: (listed: HostedCommunity[]) => void; +}; + +/** Recheck accepted requests only during an explicit owner-list refresh. */ +export async function refreshAcceptedCommunityDeletions( + context: RefreshContext, +): Promise { + if (!context.isCurrent()) return; + const skipReplay = context.identityMismatch; + let auth = await invoke("get_builderlab_auth"); + if (!context.isCurrent()) return; + context.setAuth(auth); + if (!auth) { + context.clearAccount(); + return; + } + const listed = await context.loadAccount(); + if (!context.isCurrent() || skipReplay) return; + if (auth.canDeleteBuzzCommunities !== true) return; + + let restored = false; + try { + for (const [communityId, envelope] of [...context.accepted.entries()]) { + if (!listed.some((community) => community.id === communityId)) continue; + const stillEligible = () => + context.isCurrent() && + context.ownerPubkey() === envelope.bound_owner_pubkey && + envelope.backend_origin === BUILDERLAB_BACKEND_ORIGIN && + context.accepted.get(communityId) === envelope; + if (!stillEligible()) return; + auth = await invoke("get_builderlab_auth"); + if (!stillEligible()) return; + context.setAuth(auth); + if (!auth) { + context.clearAccount(); + return; + } + if (auth.canDeleteBuzzCommunities !== true) return; + const request = publicDeletionRequest(envelope); + const response = await invoke( + "delete_builderlab_community", + { + communityId: request.community_id, + host: request.host, + requestId: request.request_id, + acknowledgementVersion: request.acknowledgement_version, + }, + ); + if (!stillEligible()) return; + const disposition = deletionResponseDisposition( + response, + envelope, + "check", + ); + if (disposition === "abort") { + context.accepted.delete(communityId); + restored = true; + } else if (disposition !== "accept") { + throw new Error("Couldn't check deletion status."); + } + } + } finally { + if (restored && context.isCurrent()) context.restoreListed(listed); + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 201701952aa..3da04a45ad0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -231,6 +231,7 @@ type E2eConfig = { email?: string; name?: string; expiresAt: string; + canDeleteBuzzCommunities?: boolean; } | null; /** Optional policy returned by the native join-policy discovery command. */ joinPolicy?: { @@ -243,6 +244,11 @@ type E2eConfig = { builderlabLoginDelayMs?: number; /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; + /** Ordered native identity results; the final result remains sticky. */ + builderlabIdentityResponseSequence?: Array< + | { identity: { npub?: string; pubkey_hex?: string } } + | { error: { code: string; setup_needed?: boolean } } + >; /** Structured error returned when onboarding tries to bind the local identity. */ builderlabBindError?: { code?: string; message?: string }; /** Communities owned by the mocked Builderlab account. */ @@ -253,6 +259,33 @@ type E2eConfig = { normalized_host?: string; archived_at?: string | null; }>; + builderlabQuota?: { used: number; limit: number; canCreate: boolean }; + builderlabDeletionError?: { code: string; message?: string }; + builderlabDeletionErrorSequence?: Array<{ + code: string; + message?: string; + } | null>; + builderlabDeletionRejectSequence?: boolean[]; + builderlabDeletionStatusSequence?: string[]; + builderlabDeletionHttpStatusSequence?: number[]; + builderlabDeletionBodyStatus?: number; + /** Hold deletion responses until the test explicitly releases them. */ + builderlabDeferDeletion?: boolean | "initial"; + builderlabAuthSequence?: Array<{ + email?: string; + name?: string; + expiresAt: string; + canDeleteBuzzCommunities?: boolean; + } | null>; + builderlabCommunitiesSequence?: Array< + Array<{ + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }> + >; /** Override the community returned after hosted creation. */ builderlabCreatedCommunity?: { id?: string; @@ -1585,6 +1618,8 @@ declare global { * `syncAgentsToActiveHuddleDelayMs`, letting it resolve without waiting * out the delay. Returns the number of holds flushed. */ __BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?: () => number; + /** Release every held hosted-community deletion response. */ + __BUZZ_E2E_RELEASE_BUILDERLAB_DELETIONS__?: () => number; /** Hold the next channel read until released. */ __BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void; /** Disarm the latch and release the held channel read, if any. */ @@ -1761,6 +1796,7 @@ let heldManagedAgentStartReleases: Array<() => void> = []; // must outlast the mid-send mutation a spec injects, then settle on demand // rather than on a timer, so the publish never races the injection. let heldHuddleAgentSyncReleases: Array<() => void> = []; +let heldBuilderlabDeletionReleases: Array<() => void> = []; let cancelledMediaUploadIds = new Set(); let cancelledMediaFetchIds = new Set(); let mockMediaFetchControllers = new Map(); @@ -11794,6 +11830,12 @@ export function maybeInstallE2eTauriMocks() { for (const release of held) release(); return held.length; }; + heldBuilderlabDeletionReleases = []; + window.__BUZZ_E2E_RELEASE_BUILDERLAB_DELETIONS__ = () => { + const held = heldBuilderlabDeletionReleases.splice(0); + for (const release of held) release(); + return held.length; + }; deferNextChannelsRead = false; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; @@ -12533,8 +12575,13 @@ export function maybeInstallE2eTauriMocks() { registry: await handleMockCommand("list_voice_registry", null), }; } - case "get_builderlab_auth": + case "get_builderlab_auth": { + const sequence = activeConfig?.mock?.builderlabAuthSequence; + if (sequence?.length) { + return sequence.length > 1 ? sequence.shift() : sequence[0]; + } return activeConfig?.mock?.builderlabAuth ?? null; + } case "start_builderlab_login": { const delayMs = activeConfig?.mock?.builderlabLoginDelayMs ?? 0; if (delayMs > 0) @@ -12552,6 +12599,10 @@ export function maybeInstallE2eTauriMocks() { if (activeConfig?.mock) activeConfig.mock.builderlabAuth = null; return null; case "get_builderlab_nostr_identity": + if (activeConfig?.mock?.builderlabIdentityResponseSequence?.length) { + const sequence = activeConfig.mock.builderlabIdentityResponseSequence; + return sequence.length > 1 ? sequence.shift() : sequence[0]; + } return activeConfig?.mock?.builderlabIdentity ? { identity: activeConfig.mock.builderlabIdentity } : { error: { code: "missing_mapping", setup_needed: true } }; @@ -12570,10 +12621,26 @@ export function maybeInstallE2eTauriMocks() { case "delete_builderlab_nostr_identity": if (activeConfig?.mock) activeConfig.mock.builderlabIdentity = null; return {}; - case "list_builderlab_communities": + case "list_builderlab_communities": { + const sequence = activeConfig?.mock?.builderlabCommunitiesSequence; + const hostedCommunities = + (sequence?.length + ? sequence.length > 1 + ? sequence.shift() + : sequence[0] + : activeConfig?.mock?.builderlabCommunities) ?? []; + const hostedQuota = activeConfig?.mock?.builderlabQuota ?? { + used: hostedCommunities.length, + limit: 5, + canCreate: hostedCommunities.length < 5, + }; return { - communities: activeConfig?.mock?.builderlabCommunities ?? [], + communities: hostedCommunities, + quota_used: hostedQuota.used, + quota_limit: hostedQuota.limit, + can_create: hostedQuota.canCreate, }; + } case "check_builderlab_community_name": return { available: true, @@ -12589,6 +12656,86 @@ export function maybeInstallE2eTauriMocks() { }, }; } + case "delete_builderlab_community": { + if ( + activeConfig?.mock?.builderlabDeferDeletion === true || + activeConfig?.mock?.builderlabDeferDeletion === "initial" + ) { + await new Promise((resolve) => { + heldBuilderlabDeletionReleases.push(resolve); + }); + } + const rejectSequence = + activeConfig?.mock?.builderlabDeletionRejectSequence; + if ( + rejectSequence?.length && + (rejectSequence.length > 1 + ? rejectSequence.shift() + : rejectSequence[0]) + ) { + throw new Error("Mock deletion transport failure"); + } + const sequence = activeConfig?.mock?.builderlabDeletionErrorSequence; + const deletionError = sequence?.length + ? sequence.length > 1 + ? sequence.shift() + : sequence[0] + : activeConfig?.mock?.builderlabDeletionError; + if (deletionError) { + const statusSequence = + activeConfig?.mock?.builderlabDeletionHttpStatusSequence; + const configuredStatus = statusSequence?.length + ? statusSequence.length > 1 + ? statusSequence.shift() + : statusSequence[0] + : undefined; + return { + http_status: + configuredStatus ?? + (deletionError.code === "not_owner" + ? 404 + : deletionError.code === "deletion_aborted" || + deletionError.code === "must_archive" || + deletionError.code === "protected_target" || + deletionError.code === "deletion_conflict" + ? 409 + : deletionError.code === "acceptance_unknown" + ? 503 + : 400), + body: { + error: deletionError, + correlation_id: "mock-delete-correlation", + ...(activeConfig?.mock?.builderlabDeletionBodyStatus === undefined + ? {} + : { + http_status: activeConfig.mock.builderlabDeletionBodyStatus, + }), + }, + }; + } + const input = payload as { + communityId?: string; + host?: string; + requestId?: string; + acknowledgementVersion?: number; + }; + const statusSequence = + activeConfig?.mock?.builderlabDeletionStatusSequence; + return { + http_status: 202, + body: { + community_id: input.communityId, + host: input.host, + request_id: input.requestId, + acknowledgement_version: input.acknowledgementVersion, + status: statusSequence?.length + ? statusSequence.length > 1 + ? statusSequence.shift() + : statusSequence[0] + : "submitted", + }, + }; + } case "mesh_installed_models": return mockMeshState.models; case "mesh_model_catalog": diff --git a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts index 40ea5fd3703..9d9ae6f1563 100644 --- a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts @@ -9,6 +9,43 @@ 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; + normalized_host: string; + archived_at?: string | null; +}> = [ + { + id: "11111111-1111-4111-8111-111111111111", + name: "Active team", + normalized_host: "active.communities.buzz.xyz", + }, + { + id: "22222222-2222-4222-8222-222222222222", + name: "Archived team", + normalized_host: "Exact-Host.communities.buzz.xyz", + archived_at: "2026-09-28T00:00:00Z", + }, +]; +const SECOND_ARCHIVED = { + id: "33333333-3333-4333-8333-333333333333", + name: "Second archived team", + normalized_host: "second.communities.buzz.xyz", + archived_at: "2026-09-28T00:00:00Z", +}; /** * Install the default hosted-communities fixture and open its settings @@ -50,6 +87,1215 @@ test.beforeEach(async ({ page }) => { await openHostedCommunitiesSettings(page); }); +async function openDeletionFixture( + page: Page, + options: { + capability?: boolean; + mismatch?: boolean; + errorCode?: string; + errorSequence?: Array<{ code: string; message?: string } | null>; + rejectSequence?: boolean[]; + statusSequence?: string[]; + capabilitySequence?: boolean[]; + quota?: { used: number; limit: number; canCreate: boolean }; + communitiesSequence?: Array; + communities?: Array<(typeof DELETION_COMMUNITIES)[number]>; + httpStatusSequence?: number[]; + bodyStatus?: number; + identityResponseSequence?: Array< + | { identity: { npub?: string; pubkey_hex?: string } } + | { error: { code: string; setup_needed?: boolean } } + >; + deferDeletion?: boolean | "initial"; + } = {}, +) { + await installMockBridge(page, { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + canDeleteBuzzCommunities: options.capability, + }, + builderlabIdentity: { + pubkey_hex: options.mismatch ? "f".repeat(64) : DEFAULT_MOCK_PUBKEY, + }, + builderlabCommunities: options.communities ?? DELETION_COMMUNITIES, + builderlabCommunitiesSequence: options.communitiesSequence, + builderlabQuota: options.quota ?? { used: 2, limit: 5, canCreate: true }, + builderlabDeletionError: options.errorCode + ? { code: options.errorCode, message: "mock deletion error" } + : undefined, + builderlabDeletionErrorSequence: options.errorSequence, + builderlabDeletionRejectSequence: options.rejectSequence, + builderlabDeletionStatusSequence: options.statusSequence, + builderlabDeletionHttpStatusSequence: options.httpStatusSequence, + builderlabDeletionBodyStatus: options.bodyStatus, + builderlabIdentityResponseSequence: options.identityResponseSequence, + builderlabDeferDeletion: options.deferDeletion, + builderlabAuthSequence: options.capabilitySequence?.map((capability) => ({ + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + canDeleteBuzzCommunities: capability, + })), + }); + await page.goto("/"); + await openSettings(page, "hosted-communities"); +} + +async function startArchivedDeletion(page: Page) { + const exactHost = "Exact-Host.communities.buzz.xyz"; + await page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); +} + +async function startSecondArchivedDeletion(page: Page) { + await page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); + await page + .getByLabel( + `Type the exact host to continue: ${SECOND_ARCHIVED.normalized_host}`, + ) + .fill(SECOND_ARCHIVED.normalized_host); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); +} + +async function storedDeletionRequestId(page: Page) { + return page.evaluate(() => { + const raw = window.localStorage.getItem( + "buzz:hosted-community-delete-pending:v1", + ); + return raw ? JSON.parse(raw).request_id : null; + }); +} + +async function storedDeletionBytes(page: Page) { + return page.evaluate(() => + window.localStorage.getItem("buzz:hosted-community-delete-pending:v1"), + ); +} + +async function deletionPayloads(page: Page) { + return page.evaluate(() => + window.__BUZZ_E2E_COMMAND_PAYLOADS__ + ?.filter(({ command }) => command === "delete_builderlab_community") + .map(({ payload }) => payload), + ); +} + +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, +}) => { + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }, null], + }); + await startArchivedDeletion(page); + await expect(page.getByText(/Deletion acceptance for/)).toBeVisible(); + const firstId = await storedDeletionRequestId(page); + expect(firstId).not.toBeNull(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("settings-view")).toHaveCount(0); + await openSettings(page, "hosted-communities"); + await expect(page.getByText(firstId, { exact: true })).toBeVisible(); + await expect.poll(() => storedDeletionRequestId(page)).toBe(firstId); + expect( + await page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length, + ), + ).toBe(1); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length, + ), + ) + .toBe(2); + await expect.poll(() => storedDeletionRequestId(page)).toBeNull(); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); +}); + +test("deletion is default-off and identity mismatch preserves the gate", async ({ + page, +}) => { + await openDeletionFixture(page); + await expect( + page.getByRole("button", { name: "Delete", exact: true }), + ).toHaveCount(0); + + await openDeletionFixture(page, { capability: true, mismatch: true }); + await expect( + page.getByRole("button", { name: "Delete", exact: true }), + ).toHaveCount(0); +}); + +test("explicit quota false hides Create and shows the limit copy", async ({ + page, +}) => { + await openDeletionFixture(page); + await page.evaluate(() => { + if (window.__BUZZ_E2E__?.mock) + window.__BUZZ_E2E__.mock.builderlabQuota = { + used: 5, + limit: 5, + canCreate: false, + }; + }); + await page.getByRole("button", { name: "Refresh" }).click(); + await expect( + page.getByText(/reached the limit of 5 hosted communities/), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create and connect" }), + ).toHaveCount(0); +}); + +test("zero community quota does not promise a deletion will free a slot", async ({ + page, +}) => { + await openDeletionFixture(page, { + quota: { used: 2, limit: 0, canCreate: false }, + }); + await expect( + page.getByText("You can't create more communities right now.", { + exact: true, + }), + ).toBeVisible(); + await expect(page.getByText(/deletion frees its slot/i)).toHaveCount(0); +}); + +test("archived deletion requires exact host and two confirmations, then removes the row", async ({ + page, +}) => { + await openDeletionFixture(page, { capability: true }); + const active = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Active team" }); + const archived = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }); + await expect( + active.getByRole("button", { name: "Delete", exact: true }), + ).toHaveCount(0); + + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + const exactHost = "Exact-Host.communities.buzz.xyz"; + const input = page.getByLabel( + `Type the exact host to continue: ${exactHost}`, + ); + await input.fill(exactHost.toLowerCase()); + await expect(page.getByRole("button", { name: "Continue" })).toBeDisabled(); + await input.fill(` ${exactHost}`); + await expect(page.getByRole("button", { name: "Continue" })).toBeDisabled(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length ?? 0, + ), + ) + .toBe(0); + + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await expect( + page.getByRole("button", { name: "Delete community permanently" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length ?? 0, + ), + ) + .toBe(0); + + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .dblclick(); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + await expect(archived).toHaveCount(0); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length ?? 0, + ), + ) + .toBe(1); +}); + +test("ambiguous deletion keeps the same pending request and exposes manual same-UUID check", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorCode: "acceptance_unknown", + }); + const archived = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }); + const exactHost = "Exact-Host.communities.buzz.xyz"; + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toBeVisible(); + await expect( + page.getByText(/Correlation ID: mock-delete-correlation/), + ).toBeVisible(); + await expect(archived).toBeVisible(); +}); + +test("transient identity loss hides but retains an ambiguous envelope and restores it for the same owner", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorCode: "acceptance_unknown", + identityResponseSequence: [ + { identity: { pubkey_hex: DEFAULT_MOCK_PUBKEY } }, + { error: { code: "unauthorized" } }, + { identity: { pubkey_hex: DEFAULT_MOCK_PUBKEY } }, + ], + }); + await startArchivedDeletion(page); + const requestId = await storedDeletionRequestId(page); + expect(requestId).not.toBeNull(); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.getByText(requestId, { exact: true })).toHaveCount(0); + await expect.poll(() => storedDeletionRequestId(page)).toBe(requestId); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.getByText(requestId, { exact: true })).toBeVisible(); + await expect.poll(() => storedDeletionRequestId(page)).toBe(requestId); +}); + +test("A-B-A owner switch retains exact bytes and replays only under A", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }, null], + identityResponseSequence: [ + { identity: { pubkey_hex: DEFAULT_MOCK_PUBKEY } }, + { identity: { pubkey_hex: OTHER_HEX } }, + { identity: { pubkey_hex: DEFAULT_MOCK_PUBKEY } }, + ], + }); + await startArchivedDeletion(page); + const requestId = await storedDeletionRequestId(page); + const bytes = await storedDeletionBytes(page); + const firstCalls = await deletionPayloads(page); + expect(firstCalls).toHaveLength(1); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.getByText(requestId, { exact: true })).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toHaveCount(0); + await expect( + 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); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.getByText(requestId, { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toBeVisible(); + await expect.poll(() => storedDeletionBytes(page)).toBe(bytes); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect + .poll(() => deletionPayloads(page)) + .toEqual([firstCalls?.[0], firstCalls?.[0]]); +}); + +test("sign out hides but retains an ambiguous envelope for same-owner reauthentication", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorCode: "acceptance_unknown", + }); + await startArchivedDeletion(page); + const requestId = await storedDeletionRequestId(page); + + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page.getByText(requestId, { exact: true })).toHaveCount(0); + await expect.poll(() => storedDeletionRequestId(page)).toBe(requestId); + + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByText(requestId, { exact: true })).toBeVisible(); + await expect.poll(() => storedDeletionRequestId(page)).toBe(requestId); +}); + +test("late A response cannot settle after a valid A-B-A owner transition", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + deferDeletion: true, + }); + await startArchivedDeletion(page); + await expect.poll(() => storedDeletionRequestId(page)).not.toBeNull(); + const bytes = await storedDeletionBytes(page); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("settings-view")).toHaveCount(0); + await page.evaluate((pubkey) => { + if (window.__BUZZ_E2E__?.mock) { + window.__BUZZ_E2E__.mock.builderlabIdentity = { pubkey_hex: pubkey }; + } + }, OTHER_HEX); + await openSettings(page, "hosted-communities"); + await expect.poll(() => storedDeletionBytes(page)).toBe(bytes); + await expect( + page.getByText("This account is connected to a different Buzz identity"), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("settings-view")).toHaveCount(0); + await page.evaluate((pubkey) => { + if (window.__BUZZ_E2E__?.mock) { + window.__BUZZ_E2E__.mock.builderlabIdentity = { pubkey_hex: pubkey }; + } + }, DEFAULT_MOCK_PUBKEY); + await openSettings(page, "hosted-communities"); + await expect( + page.getByText("This account is connected to a different Buzz identity"), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => window.__BUZZ_E2E_RELEASE_BUILDERLAB_DELETIONS__?.()), + ) + .toBe(1); + await expect(page.getByText("Deletion started", { exact: true })).toHaveCount( + 0, + ); + await expect.poll(() => storedDeletionBytes(page)).toBe(bytes); +}); + +test("a changed persisted request fences Check before dispatch in one mounted card", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }, null], + }); + await startArchivedDeletion(page); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toBeVisible(); + const replacement = await page.evaluate(() => { + const key = "buzz:hosted-community-delete-pending:v1"; + const raw = window.localStorage.getItem(key); + if (!raw) throw new Error("missing original envelope"); + const before = + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "get_builderlab_auth", + ).length ?? 0; + const button = [...document.querySelectorAll("button")].find((candidate) => + candidate.textContent?.includes("Check deletion status"), + ); + if (!(button instanceof HTMLButtonElement)) + throw new Error("missing Check button"); + button.click(); + const after = + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "get_builderlab_auth", + ).length ?? 0; + if (after !== before + 1) + throw new Error("Check did not enter the auth preflight"); + const next = { + ...JSON.parse(raw), + request_id: "44444444-4444-4444-8444-444444444444", + }; + const bytes = JSON.stringify(next); + window.localStorage.setItem(key, bytes); + return bytes; + }); + await expect.poll(() => storedDeletionBytes(page)).toBe(replacement); + await expect.poll(async () => (await deletionPayloads(page))?.length).toBe(1); + await expect(page.getByText("Deletion started", { exact: true })).toHaveCount( + 0, + ); +}); + +test("a pre-remount acceptance cannot erase a later uncertain request", async ({ + page, +}) => { + const secondArchived = { + id: "33333333-3333-4333-8333-333333333333", + name: "Second archived team", + normalized_host: "second.communities.buzz.xyz", + archived_at: "2026-09-28T00:00:00Z", + }; + await openDeletionFixture(page, { + capability: true, + communities: [...DELETION_COMMUNITIES, secondArchived], + deferDeletion: "initial", + errorSequence: [null, { code: "acceptance_unknown" }, null], + }); + await startArchivedDeletion(page); + const firstId = await storedDeletionRequestId(page); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length ?? 0, + ), + ) + .toBe(1); + await page.evaluate(() => { + if (window.__BUZZ_E2E__?.mock) + window.__BUZZ_E2E__.mock.builderlabDeferDeletion = false; + }); + + expect(firstId).not.toBeNull(); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("settings-view")).toHaveCount(0); + await openSettings(page, "hosted-communities"); + await expect.poll(() => storedDeletionRequestId(page)).toBe(firstId); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect.poll(() => storedDeletionRequestId(page)).toBeNull(); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + + const second = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }); + await second.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel("Type the exact host to continue: second.communities.buzz.xyz") + .fill("second.communities.buzz.xyz"); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + const pendingKey = "buzz:hosted-community-delete-pending:v1"; + const secondBytes = await page.evaluate( + (key) => window.localStorage.getItem(key), + pendingKey, + ); + expect(secondBytes).not.toBeNull(); + expect(JSON.parse(secondBytes as string).request_id).not.toBe(firstId); + await expect( + page.getByText(/Deletion acceptance is uncertain/), + ).toBeVisible(); + + expect( + await page.evaluate(() => + window.__BUZZ_E2E_RELEASE_BUILDERLAB_DELETIONS__?.(), + ), + ).toBe(1); + await expect + .poll(() => + page.evaluate((key) => window.localStorage.getItem(key), pendingKey), + ) + .toBe(secondBytes); + await expect( + page.getByText(JSON.parse(secondBytes as string).request_id, { + exact: true, + }), + ).toBeVisible(); +}); + +test("one pending envelope blocks a second mounted deletion without overwriting or dispatching", async ({ + page, +}) => { + const secondArchived = { + id: "33333333-3333-4333-8333-333333333333", + name: "Second archived team", + normalized_host: "second.communities.buzz.xyz", + archived_at: "2026-09-28T00:00:00Z", + }; + await openDeletionFixture(page, { + capability: true, + errorCode: "acceptance_unknown", + communities: [...DELETION_COMMUNITIES, secondArchived], + }); + const first = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }); + await first.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 page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + + const firstRequestId = await page.evaluate(() => { + const raw = window.localStorage.getItem( + "buzz:hosted-community-delete-pending:v1", + ); + return raw ? JSON.parse(raw).request_id : null; + }); + expect(firstRequestId).not.toBeNull(); + + const secondDelete = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }) + .getByRole("button", { name: "Delete", exact: true }); + await secondDelete.evaluate((button: HTMLButtonElement) => button.click()); + const secondHostInput = page.getByLabel( + "Type the exact host to continue: second.communities.buzz.xyz", + ); + if (await secondHostInput.isVisible().catch(() => false)) { + await secondHostInput.fill("second.communities.buzz.xyz"); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + } + + await expect(secondDelete).toBeDisabled(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length ?? 0, + ), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate(() => { + const raw = window.localStorage.getItem( + "buzz:hosted-community-delete-pending:v1", + ); + return raw ? JSON.parse(raw).request_id : null; + }), + ) + .toBe(firstRequestId); +}); + +test("native HTTP status wins over a contradictory body claim in the mounted flow", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorCode: "not_owner", + httpStatusSequence: [503], + bodyStatus: 404, + }); + const archived = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }); + const exactHost = "Exact-Host.communities.buzz.xyz"; + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + + const requestId = await page.evaluate(() => { + const raw = window.localStorage.getItem( + "buzz:hosted-community-delete-pending:v1", + ); + return raw ? JSON.parse(raw).request_id : null; + }); + expect(requestId).not.toBeNull(); + await expect(page.getByText(requestId, { exact: true })).toBeVisible(); + await expect(archived).toBeVisible(); +}); + +test("ambiguous check not_owner settles the same request UUID", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }, { code: "not_owner" }], + capabilitySequence: [true, true, true], + communitiesSequence: [DELETION_COMMUNITIES, []], + }); + const archived = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }); + const exactHost = "Exact-Host.communities.buzz.xyz"; + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + const requestId = await page.evaluate(() => { + const raw = window.localStorage.getItem( + "buzz:hosted-community-delete-pending:v1", + ); + return raw ? JSON.parse(raw).request_id : null; + }); + await expect(page.getByText(requestId, { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect(page.getByText(/Only the community owner/)).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter( + ({ command }) => command === "delete_builderlab_community", + ).length, + ), + ) + .toBe(2); + await expect(page.getByText(requestId, { exact: true })).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => { + const raw = window.localStorage.getItem( + "buzz:hosted-community-delete-pending:v1", + ); + return raw ? JSON.parse(raw).request_id : null; + }), + ) + .toBeNull(); +}); + +test("check rechecks capability after the fresh owner list", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }], + capabilitySequence: [true, true, false], + communitiesSequence: [DELETION_COMMUNITIES, DELETION_COMMUNITIES], + }); + const exactHost = "Exact-Host.communities.buzz.xyz"; + await page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); + await page + .getByLabel(`Type the exact host to continue: ${exactHost}`) + .fill(exactHost); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect( + page.getByText(/Community deletion is no longer enabled/), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toBeDisabled(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "delete_builderlab_community", + ).length ?? 0, + ), + ) + .toBe(1); +}); + +test("check resends the saved UUID even when the fresh owner list omits it", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }, null], + capabilitySequence: [true, true, true], + communitiesSequence: [DELETION_COMMUNITIES, []], + }); + await startArchivedDeletion(page); + const requestId = await storedDeletionRequestId(page); + expect(requestId).not.toBeNull(); + const firstCalls = await page.evaluate(() => + window.__BUZZ_E2E_COMMAND_PAYLOADS__ + ?.filter(({ command }) => command === "delete_builderlab_community") + .map(({ payload }) => payload), + ); + expect(firstCalls).toEqual([ + { + communityId: DELETION_COMMUNITIES[1].id, + host: DELETION_COMMUNITIES[1].normalized_host, + requestId, + acknowledgementVersion: 1, + }, + ]); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_COMMAND_PAYLOADS__ + ?.filter(({ command }) => command === "delete_builderlab_community") + .map(({ payload }) => payload), + ), + ) + .toEqual([firstCalls?.[0], firstCalls?.[0]]); + await expect.poll(() => storedDeletionRequestId(page)).toBeNull(); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); +}); + +test("Refresh replays an accepted request and restores a tuple-bound aborted row", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + statusSequence: ["submitted", "aborted"], + }); + await startArchivedDeletion(page); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + const firstCalls = await deletionPayloads(page); + expect(firstCalls).toHaveLength(1); + expect(await storedDeletionBytes(page)).toBeNull(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Refresh" }).click(); + const archived = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }); + await expect(archived).toBeVisible(); + await expect( + archived.getByRole("button", { name: "Delete", exact: true }), + ).toBeEnabled(); + await expect(page.getByText("2 of 5 used", { exact: false })).toBeVisible(); + await expect(page.getByTestId("hosted-community-row")).toHaveCount(2); + await expect( + page.getByText("Deletion stopped. This community is not being deleted.", { + exact: true, + }), + ).toBeVisible(); + await expect(page.getByText("Deletion started", { exact: true })).toHaveCount( + 0, + ); + await expect + .poll(() => deletionPayloads(page)) + .toEqual([firstCalls?.[0], firstCalls?.[0]]); + expect(await storedDeletionBytes(page)).toBeNull(); +}); + +test("Refresh restores only the aborted row while another accepted deletion remains", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + communities: [...DELETION_COMMUNITIES, SECOND_ARCHIVED], + quota: { used: 3, limit: 5, canCreate: true }, + statusSequence: ["submitted", "submitted", "aborted", "approved"], + }); + await startArchivedDeletion(page); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + await startSecondArchivedDeletion(page); + await expect.poll(() => deletionPayloads(page)).toHaveLength(2); + const acceptedCalls = await deletionPayloads(page); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }), + ).toBeVisible(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + await expect + .poll(() => deletionPayloads(page)) + .toEqual([ + acceptedCalls?.[0], + acceptedCalls?.[1], + acceptedCalls?.[0], + acceptedCalls?.[1], + ]); +}); + +test("Refresh restores an earlier abort when a later replay rejects", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + communities: [...DELETION_COMMUNITIES, SECOND_ARCHIVED], + quota: { used: 3, limit: 5, canCreate: true }, + statusSequence: ["submitted", "submitted", "aborted"], + rejectSequence: [false, false, false, true], + }); + await startArchivedDeletion(page); + await startSecondArchivedDeletion(page); + await expect.poll(() => deletionPayloads(page)).toHaveLength(2); + const acceptedCalls = await deletionPayloads(page); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect( + page.getByText("Mock deletion transport failure", { exact: true }), + ).toBeVisible(); + const firstRow = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }); + await expect(firstRow).toBeVisible(); + await expect( + firstRow.getByRole("button", { name: "Delete", exact: true }), + ).toBeEnabled(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + await expect + .poll(() => deletionPayloads(page)) + .toEqual([ + acceptedCalls?.[0], + acceptedCalls?.[1], + acceptedCalls?.[0], + acceptedCalls?.[1], + ]); +}); + +test("Refresh restores an earlier abort when a later capability check fails", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + capabilitySequence: [true, true, true, false], + communities: [...DELETION_COMMUNITIES, SECOND_ARCHIVED], + quota: { used: 3, limit: 5, canCreate: true }, + statusSequence: ["submitted", "submitted", "aborted"], + }); + await startArchivedDeletion(page); + await startSecondArchivedDeletion(page); + await expect.poll(() => deletionPayloads(page)).toHaveLength(2); + const acceptedCalls = await deletionPayloads(page); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); + const firstRow = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }); + await expect(firstRow).toBeVisible(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + expect(await deletionPayloads(page)).toEqual([ + acceptedCalls?.[0], + acceptedCalls?.[1], + acceptedCalls?.[0], + ]); +}); + +for (const scenario of [ + { name: "non-aborted stage", statusSequence: ["submitted", "approved"] }, + { + name: "uncertain 503", + errorSequence: [null, { code: "acceptance_unknown" }], + expectsCheckError: true, + }, + { + name: "error-only 409", + errorSequence: [null, { code: "deletion_aborted" }], + expectsCheckError: true, + }, +]) { + test(`Refresh keeps an accepted row hidden after ${scenario.name}`, async ({ + page, + }) => { + await openDeletionFixture(page, { + capability: true, + statusSequence: scenario.statusSequence, + errorSequence: scenario.errorSequence, + }); + await startArchivedDeletion(page); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + const firstCalls = await deletionPayloads(page); + await page.getByRole("button", { name: "Refresh" }).click(); + await expect + .poll(() => deletionPayloads(page)) + .toEqual([firstCalls?.[0], firstCalls?.[0]]); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Deletion stopped. This community is not being deleted.", { + exact: true, + }), + ).toHaveCount(0); + const checkError = page.getByText("Couldn't check deletion status.", { + exact: true, + }); + if (scenario.expectsCheckError) { + await expect(checkError).toBeVisible(); + } else { + await expect(checkError).toHaveCount(0); + } + }); +} + +test("Refresh with deletion capability off keeps an accepted row hidden without replay", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + capabilitySequence: [true, false], + communities: [...DELETION_COMMUNITIES, SECOND_ARCHIVED], + }); + await startArchivedDeletion(page); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + const firstCalls = await deletionPayloads(page); + await page.getByRole("button", { name: "Refresh" }).click(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }), + ).toHaveCount(0); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }) + .getByRole("button", { name: "Delete", exact: true }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + expect(await deletionPayloads(page)).toEqual(firstCalls); +}); + +test("check settles must_archive and offers Archive after the owner row is unarchived", async ({ + page, +}) => { + const unarchived = DELETION_COMMUNITIES.map((community) => + community.id === DELETION_COMMUNITIES[1].id + ? { ...community, archived_at: null } + : community, + ); + await openDeletionFixture(page, { + capability: true, + errorSequence: [{ code: "acceptance_unknown" }, { code: "must_archive" }], + capabilitySequence: [true, true, true], + communitiesSequence: [DELETION_COMMUNITIES, unarchived], + }); + await startArchivedDeletion(page); + const requestId = await storedDeletionRequestId(page); + expect(requestId).not.toBeNull(); + await page.getByRole("button", { name: "Check deletion status" }).click(); + await expect( + page.getByText(/Archive this community before deleting it/), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter( + ({ command }) => command === "delete_builderlab_community", + ).length, + ), + ) + .toBe(2); + await expect.poll(() => storedDeletionRequestId(page)).toBeNull(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .getByRole("button", { name: "Archive", exact: true }), + ).toBeVisible(); +}); + test("identity: mismatch rows follow pubkey_hex, never the hosted npub or raw hex", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index e945508c085..29a24e609a4 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -162,7 +162,12 @@ type MockBridgeOptions = { /** Native-like huddle state seeded from authoritative role-bearing membership. */ huddle?: MockHuddleSeed; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ - builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; + builderlabAuth?: { + email?: string; + name?: string; + expiresAt: string; + canDeleteBuzzCommunities?: boolean; + } | null; /** Optional policy returned by the native join-policy discovery command. */ joinPolicy?: { terms_markdown?: string; @@ -172,6 +177,11 @@ type MockBridgeOptions = { } | null; /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; + /** Ordered native identity results; the final result remains sticky. */ + builderlabIdentityResponseSequence?: Array< + | { identity: { npub?: string; pubkey_hex?: string } } + | { error: { code: string; setup_needed?: boolean } } + >; /** Communities owned by the mocked Builderlab account. */ builderlabCommunities?: Array<{ id?: string; @@ -180,6 +190,33 @@ type MockBridgeOptions = { normalized_host?: string; archived_at?: string | null; }>; + builderlabQuota?: { used: number; limit: number; canCreate: boolean }; + builderlabDeletionError?: { code: string; message?: string }; + builderlabDeletionErrorSequence?: Array<{ + code: string; + message?: string; + } | null>; + builderlabDeletionRejectSequence?: boolean[]; + builderlabDeletionStatusSequence?: string[]; + builderlabDeletionHttpStatusSequence?: number[]; + builderlabDeletionBodyStatus?: number; + /** Hold deletion responses until the test explicitly releases them. */ + builderlabDeferDeletion?: boolean | "initial"; + builderlabAuthSequence?: Array<{ + email?: string; + name?: string; + expiresAt: string; + canDeleteBuzzCommunities?: boolean; + } | null>; + builderlabCommunitiesSequence?: Array< + Array<{ + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }> + >; acpRuntimesCatalog?: Record[]; /** Catalog returned after a successful mocked install. */ acpRuntimesCatalogAfterInstall?: Record[];