From e7b1be8efb4a21c37a77c9d54bf9c82053904d67 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 25 Oct 2026 22:11:14 -0500 Subject: [PATCH 1/8] Delete Organization Brains through one confirmed store transaction A Brain admin previews the deletion, signs the preview's state digest into a delete-brain admin access-change event, and the store rechecks admin standing, Brain kind, active Mounts, confirmation age and the digest in the same write transaction that removes every Brain-owned row. A permanent deletion record outside the Brain foreign-key graph retires the id (a database trigger refuses reinsertion), answers exact retries with the same receipt, and lets only former readers see 410 Gone. Co-Authored-By: Claude Opus 5.5 --- .../crates/finite-brain-core/src/lib.rs | 5 + .../finite-brain-server/src/contracts.rs | 91 ++ .../crates/finite-brain-server/src/lib.rs | 25 + .../src/protected_routes.rs | 18 + .../src/routes/brain_deletion.rs | 229 +++++ .../finite-brain-server/src/routes/mod.rs | 2 + .../src/tests/brain_deletion_tests.rs | 578 +++++++++++++ .../finite-brain-store/src/brain_deletion.rs | 736 ++++++++++++++++ .../crates/finite-brain-store/src/lib.rs | 8 +- .../crates/finite-brain-store/src/schema.rs | 47 +- .../src/tests/brain_deletion_tests.rs | 798 ++++++++++++++++++ 11 files changed, 2535 insertions(+), 2 deletions(-) create mode 100644 finite-brain/crates/finite-brain-server/src/routes/brain_deletion.rs create mode 100644 finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs create mode 100644 finite-brain/crates/finite-brain-store/src/brain_deletion.rs create mode 100644 finite-brain/crates/finite-brain-store/src/tests/brain_deletion_tests.rs diff --git a/finite-brain/crates/finite-brain-core/src/lib.rs b/finite-brain/crates/finite-brain-core/src/lib.rs index c4a9aa2e1..3e8ac2ac1 100644 --- a/finite-brain/crates/finite-brain-core/src/lib.rs +++ b/finite-brain/crates/finite-brain-core/src/lib.rs @@ -1686,6 +1686,9 @@ pub enum AdminAccessAction { SetFolderAccessMode, /// Permanently delete a Folder subtree. DeleteFolder, + /// Permanently delete an Organization Brain. The signed note carries the + /// server's deletion preview state digest the admin confirmed. + DeleteBrain, } impl AdminAccessAction { @@ -1702,6 +1705,7 @@ impl AdminAccessAction { Self::RotateFolderKey => "rotate-folder-key", Self::SetFolderAccessMode => "set-folder-access-mode", Self::DeleteFolder => "delete-folder", + Self::DeleteBrain => "delete-brain", } } } @@ -1721,6 +1725,7 @@ impl TryFrom<&str> for AdminAccessAction { "rotate-folder-key" => Ok(Self::RotateFolderKey), "set-folder-access-mode" => Ok(Self::SetFolderAccessMode), "delete-folder" => Ok(Self::DeleteFolder), + "delete-brain" => Ok(Self::DeleteBrain), _ => Err(CryptoRecordError::BadOperation { operation: value.to_owned(), }), diff --git a/finite-brain/crates/finite-brain-server/src/contracts.rs b/finite-brain/crates/finite-brain-server/src/contracts.rs index 8f6f0ed0d..f2b581d95 100644 --- a/finite-brain/crates/finite-brain-server/src/contracts.rs +++ b/finite-brain/crates/finite-brain-server/src/contracts.rs @@ -248,6 +248,97 @@ pub struct FolderDeleteResponse { pub deleted_folder_ids: Vec, } +/// Server-computed scope of permanently deleting an Organization Brain. The +/// confirming admin signs `stateDigest` into the `delete-brain` event note; +/// the deletion transaction recomputes it and refuses any change, including +/// one that leaves every count the same. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrainDeletionPreviewResponse { + /// This Brain server's public identity. + pub server: String, + pub brain_id: String, + pub name: String, + pub kind: String, + pub folder_count: usize, + /// Live Folder objects: pages, plus any legacy assets. + pub page_count: usize, + pub member_count: usize, + pub admin_count: usize, + pub guest_count: usize, + /// Pending Brain and Folder invitations that deletion revokes. + pub pending_invitation_count: usize, + /// Invite Tokens neither redeemed nor revoked that deletion invalidates. + pub open_invite_token_count: usize, + /// Active cross-Brain Mounts to revoke first; deletion refuses while any remain. + pub blocking_mounts: Vec, + /// Pending Mount Offers and revoked Mount records naming this Brain in + /// either direction, removed with it. + pub retired_mount_record_count: usize, + pub deletable: bool, + pub state_digest: String, + /// How long a signed confirmation of this preview stays acceptable. + pub confirmation_max_age_seconds: u64, +} + +/// One active cross-Brain Mount that blocks deletion. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockingMountResponse { + pub connection_id: String, + /// `outgoing` when this Brain shares the Folder, `incoming` when it mounts one. + pub direction: String, + pub source_brain_id: String, + pub source_folder_id: String, + pub destination_brain_id: String, +} + +/// Request body for `DELETE /v1/brains/{brain_id}`: a signed `delete-brain` +/// admin access-change event whose note is the confirmed preview `stateDigest`. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrainDeleteRequest { + pub deletion_event: serde_json::Value, +} + +/// Permanent receipt of an accepted Brain deletion. An exact retry of the +/// same signed deletion answers the same receipt with `duplicate: true`. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrainDeleteResponse { + pub receipt: BrainDeletionReceiptResponse, + pub duplicate: bool, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrainDeletionReceiptResponse { + pub brain_id: String, + pub deletion_event_id: String, + pub actor_npub: String, + pub deleted_at: String, + pub state_digest: String, + pub folder_count: usize, + pub page_count: usize, +} + +/// `410 Gone` body for a principal that held access to a deleted Brain. +/// Anyone else gets the unknown-Brain answer. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeletedBrainResponse { + pub error: String, + pub code: String, + pub brain_deleted: DeletedBrainNoticeResponse, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeletedBrainNoticeResponse { + pub brain_id: String, + pub deleted_at: String, +} + /// Object write response. #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/finite-brain/crates/finite-brain-server/src/lib.rs b/finite-brain/crates/finite-brain-server/src/lib.rs index 3a307ca50..2a8b15a97 100644 --- a/finite-brain/crates/finite-brain-server/src/lib.rs +++ b/finite-brain/crates/finite-brain-server/src/lib.rs @@ -94,6 +94,8 @@ pub struct HealthStatus { pub enum BrainUpdateReason { ContentUpdated, AccessUpdated, + /// The Brain was permanently deleted; sent only to its former readers. + BrainDeleted, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] @@ -257,6 +259,15 @@ impl ServerState { }); } + fn publish_brain_deleted(&self, brain_id: &BrainId, audience: &[UserId]) { + let _ = self.brain_updates.send(BrainUpdateNotification { + brain_id: brain_id.to_string(), + latest_sequence: 0, + reason: BrainUpdateReason::BrainDeleted, + notify_npubs: audience.iter().map(ToString::to_string).collect(), + }); + } + fn publish_access_update_for(&self, brain_id: &BrainId, target_npub: &str) { let Ok(store) = self.store.lock() else { return }; let Ok(latest_sequence) = store.latest_sequence(brain_id) else { @@ -535,11 +546,16 @@ pub fn router_with_state(state: ServerState) -> Router { let normal_routes = normal_signed_api_router(&state); let low_level_routes = low_level_signed_api_router(&state); let signed_routes = Router::new().nest("/v1", normal_routes.nest("/admin", low_level_routes)); + let deletion_state = state.clone(); Router::new() .route("/", get(root_handler)) .route("/health", get(health_handler)) .merge(signed_routes) .fallback(api_route_not_found_handler) + .layer(middleware::from_fn_with_state( + deletion_state, + deleted_brain_notice_middleware, + )) .layer(middleware::from_fn_with_state( cors_state, cors_allowlist_middleware, @@ -564,6 +580,14 @@ fn normal_signed_api_router(state: &ServerState) -> Router { ) .route("/identities/resolve", post(resolve_identity_handler)) .route("/my-invitations", get(list_my_invitations_handler)) + .route( + "/brains/{brain_id}", + axum::routing::delete(delete_brain_handler), + ) + .route( + "/brains/{brain_id}/deletion-preview", + get(brain_deletion_preview_handler), + ) .route("/brains/{brain_id}/metadata", get(brain_metadata_handler)) .route("/brains/{brain_id}/rename", post(rename_brain_handler)) .route("/brains/{brain_id}/access", get(brain_metadata_handler)) @@ -10535,6 +10559,7 @@ mod tests { mod access_report_tests; mod admin_demotion_tests; + mod brain_deletion_tests; mod folder_key_rotation_tests; mod member_removal_tests; } diff --git a/finite-brain/crates/finite-brain-server/src/protected_routes.rs b/finite-brain/crates/finite-brain-server/src/protected_routes.rs index 6b5caf4ea..b9c363978 100644 --- a/finite-brain/crates/finite-brain-server/src/protected_routes.rs +++ b/finite-brain/crates/finite-brain-server/src/protected_routes.rs @@ -136,6 +136,24 @@ pub(crate) fn authenticate_request_header_at( signer.to_npub().map_err(auth_error) } +/// The signer of an already-answered request, for deciding what its error +/// response may reveal: signature, method, URL and time are checked, but the +/// replay cache and rate limit are not touched again, since the handler +/// already ran full validation (or refused the request) exactly once. +pub(crate) fn answered_request_signer( + state: &ServerState, + headers: &HeaderMap, + method: &Method, + uri: &Uri, +) -> Option { + let (event, expected) = + signed_request_header(state, headers, method, uri, state.auth_now_unix_seconds()).ok()?; + finite_nostr::validate_http_auth_event_before_body(&event, &expected) + .ok()? + .to_npub() + .ok() +} + fn signed_request_header( state: &ServerState, headers: &HeaderMap, diff --git a/finite-brain/crates/finite-brain-server/src/routes/brain_deletion.rs b/finite-brain/crates/finite-brain-server/src/routes/brain_deletion.rs new file mode 100644 index 000000000..5c3fcfecb --- /dev/null +++ b/finite-brain/crates/finite-brain-server/src/routes/brain_deletion.rs @@ -0,0 +1,229 @@ +//! Permanent Organization Brain deletion over the signed API: a preview the +//! admin confirms, the deletion that rechecks it atomically, and the `410` +//! answer that tells former readers (and only them) the Brain is gone. + +use axum::extract::Request; +use axum::middleware::Next; +use finite_brain_store::{ + BRAIN_DELETION_DIGEST_PREFIX, BrainDeletionOutcome, BrainDeletionReceipt, BrainDeletionRequest, +}; + +use crate::protected_routes::answered_request_signer; +use crate::*; + +/// How long a signed `delete-brain` confirmation stays acceptable. An exact +/// retry of an accepted deletion is answered at any age. +pub(crate) const BRAIN_DELETION_CONFIRMATION_MAX_AGE_SECONDS: u64 = 10 * 60; + +pub(crate) async fn brain_deletion_preview_handler( + State(state): State, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, + AxumPath(brain_id): AxumPath, +) -> Result, ApiError> { + let actor = validate_request_auth(&state, &headers, &method, &uri, None)?; + spawn_store(state, move |state| { + let brain_id = BrainId::new(brain_id)?; + let server = server_identity(&state); + let store = state.store.lock().map_err(lock_error)?; + let stored = store.load_brain(&brain_id)?; + ensure_metadata_visible(&stored, &actor)?; + ensure_direct_delete_authority(&stored, &actor)?; + let preview = store.brain_deletion_preview(&brain_id, &UserId::new(actor)?, &server)?; + Ok(Json(BrainDeletionPreviewResponse { + server, + brain_id: preview.brain_id.to_string(), + name: preview.name, + kind: "organization".to_owned(), + folder_count: preview.folder_count, + page_count: preview.object_count, + member_count: preview.member_count, + admin_count: preview.admin_count, + guest_count: preview.guest_count, + pending_invitation_count: preview.pending_invitation_count, + open_invite_token_count: preview.open_invite_token_count, + deletable: preview.blocking_mounts.is_empty(), + blocking_mounts: preview + .blocking_mounts + .into_iter() + .map(|mount| BlockingMountResponse { + connection_id: mount.connection_id, + direction: match mount.direction { + SharedFolderDirection::Source => "outgoing", + SharedFolderDirection::Destination => "incoming", + } + .to_owned(), + source_brain_id: mount.source_brain_id.to_string(), + source_folder_id: mount.source_folder_id.to_string(), + destination_brain_id: mount.destination_brain_id.to_string(), + }) + .collect(), + retired_mount_record_count: preview.retired_mount_record_count, + state_digest: preview.state_digest, + confirmation_max_age_seconds: BRAIN_DELETION_CONFIRMATION_MAX_AGE_SECONDS, + })) + }) + .await +} + +pub(crate) async fn delete_brain_handler( + State(state): State, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, + AxumPath(brain_id): AxumPath, + body: Bytes, +) -> Result, ApiError> { + let actor = validate_request_auth(&state, &headers, &method, &uri, Some(&body))?; + let request: BrainDeleteRequest = serde_json::from_slice(&body) + .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid JSON request body"))?; + let brain_id = BrainId::new(brain_id)?; + // The signed event is validated against the request alone, so a refusal + // here never depends on whether the Brain exists. + let (event, payload) = validate_admin_access_change_value( + request.deletion_event, + &brain_id, + &actor, + AdminAccessAction::DeleteBrain, + None, + None, + None, + )?; + let state_digest = payload + .note + .filter(|note| is_state_digest(note)) + .ok_or_else(|| { + ApiError::new( + StatusCode::BAD_REQUEST, + "delete-brain must sign the confirmed deletion preview stateDigest as its note", + ) + })?; + let now = state.auth_now_unix_seconds(); + let signed_at = event.created_at.as_secs(); + let confirmation_current = + signed_at.saturating_add(BRAIN_DELETION_CONFIRMATION_MAX_AGE_SECONDS) >= now + && signed_at <= now.saturating_add(state.max_auth_skew_seconds); + let deletion_event_id = event.id.to_hex(); + let deletion_event_json = event.as_json(); + let deleted_at = server_timestamp(&state); + let outcome = spawn_store(state.clone(), move |state| { + let actor_id = UserId::new(actor.clone())?; + let server = server_identity(&state); + let mut store = state.store.lock().map_err(lock_error)?; + if let Some(receipt) = + store.brain_deletion_replay(&brain_id, &deletion_event_id, &actor_id)? + { + return Ok(BrainDeletionOutcome::Replayed { receipt }); + } + let stored = store.load_brain(&brain_id)?; + ensure_metadata_visible(&stored, &actor)?; + ensure_direct_delete_authority(&stored, &actor)?; + Ok(store.delete_organization_brain(&BrainDeletionRequest { + brain_id: &brain_id, + actor_npub: &actor_id, + deletion_event_id: &deletion_event_id, + deletion_event_json: &deletion_event_json, + expected_state_digest: &state_digest, + server_identity: &server, + deleted_at: &deleted_at, + confirmation_current, + })?) + }) + .await?; + let (receipt, duplicate) = match outcome { + BrainDeletionOutcome::Deleted { receipt, audience } => { + state.publish_brain_deleted(&receipt.brain_id, &audience); + (receipt, false) + } + BrainDeletionOutcome::Replayed { receipt } => (receipt, true), + }; + Ok(Json(BrainDeleteResponse { + receipt: receipt_response(receipt), + duplicate, + })) +} + +/// Turn the unknown-Brain `404` of any Brain-scoped request into a `410 Gone` +/// deletion notice when, and only when, the signer held access to that Brain +/// when it was deleted. Everyone else keeps the unknown-Brain answer, so a +/// deleted Brain id reveals nothing to unrelated principals. +pub(crate) async fn deleted_brain_notice_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + let Some(brain_id) = brain_id_in_path(request.uri().path()) else { + return next.run(request).await; + }; + let method = request.method().clone(); + let uri = request.uri().clone(); + let headers = request.headers().clone(); + let response = next.run(request).await; + if response.status() != StatusCode::NOT_FOUND { + return response; + } + let Some(signer) = answered_request_signer(&state, &headers, &method, &uri) else { + return response; + }; + let notice = UserId::new(signer).ok().and_then(|signer| { + let store = state.store.lock().ok()?; + store + .deleted_brain_notice(&brain_id, &signer) + .ok() + .flatten() + }); + match notice { + Some(notice) => ( + StatusCode::GONE, + Json(DeletedBrainResponse { + error: format!( + "Brain {} was permanently deleted at {}", + notice.brain_id, notice.deleted_at + ), + code: "brain_deleted".to_owned(), + brain_deleted: DeletedBrainNoticeResponse { + brain_id: notice.brain_id.to_string(), + deleted_at: notice.deleted_at, + }, + }), + ) + .into_response(), + None => response, + } +} + +/// The Brain id of `/v1/brains/{id}[/...]` and `/v1/admin/brains/{id}/...`. +fn brain_id_in_path(path: &str) -> Option { + let rest = path + .strip_prefix("/v1/brains/") + .or_else(|| path.strip_prefix("/v1/admin/brains/"))?; + let id = rest.split('/').next()?; + BrainId::new(id).ok() +} + +fn is_state_digest(note: &str) -> bool { + note.strip_prefix(BRAIN_DELETION_DIGEST_PREFIX) + .is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +fn server_identity(state: &ServerState) -> String { + state.public_base_url.trim_end_matches('/').to_owned() +} + +fn receipt_response(receipt: BrainDeletionReceipt) -> BrainDeletionReceiptResponse { + BrainDeletionReceiptResponse { + brain_id: receipt.brain_id.to_string(), + deletion_event_id: receipt.deletion_event_id, + actor_npub: receipt.actor_npub.to_string(), + deleted_at: receipt.deleted_at, + state_digest: receipt.state_digest, + folder_count: receipt.folder_count, + page_count: receipt.object_count, + } +} diff --git a/finite-brain/crates/finite-brain-server/src/routes/mod.rs b/finite-brain/crates/finite-brain-server/src/routes/mod.rs index a8d8034aa..b67e072f0 100644 --- a/finite-brain/crates/finite-brain-server/src/routes/mod.rs +++ b/finite-brain/crates/finite-brain-server/src/routes/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod admin_demotion; pub(crate) mod approvals; +pub(crate) mod brain_deletion; pub(crate) mod brains; pub(crate) mod collaboration; pub(crate) mod folder_keys; @@ -13,6 +14,7 @@ pub(crate) mod sharing; pub(crate) use admin_demotion::*; pub(crate) use approvals::*; +pub(crate) use brain_deletion::*; pub(crate) use brains::*; pub(crate) use collaboration::*; pub(crate) use folder_keys::*; diff --git a/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs b/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs new file mode 100644 index 000000000..008918edb --- /dev/null +++ b/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs @@ -0,0 +1,578 @@ +//! Organization Brain deletion through the signed router: preview authority, +//! atomic refusal of stale or expired confirmations, exact-retry receipts, and +//! a deleted Brain that former readers learn about while everyone else sees +//! an unknown Brain id. + +use super::*; + +fn delete_event(keys: &Keys, brain_id: &str, change_id: &str, note: Option<&str>) -> Event { + let expected = AdminAccessChangeValidation { + brain_id: BrainId::new(brain_id).unwrap(), + change_id: change_id.to_owned(), + action: AdminAccessAction::DeleteBrain, + admin_npub: npub(keys), + folder_id: None, + target_npub: None, + key_version: None, + note: note.map(ToOwned::to_owned), + created_at: test_rfc3339(), + }; + let payload = AdminAccessChangePayload::new(&expected); + sign_app_event( + keys, + payload.canonical_json(), + admin_access_change_tags(&expected), + ) +} + +fn delete_body(event: &Event) -> String { + serde_json::json!({ "deletionEvent": event }).to_string() +} + +async fn preview( + router: &Router, + keys: &Keys, + brain_id: &str, + at: u64, +) -> axum::response::Response { + authed_request( + router.clone(), + keys, + "GET", + &format!("/v1/brains/{brain_id}/deletion-preview"), + None, + at, + ) + .await +} + +async fn delete( + router: &Router, + keys: &Keys, + brain_id: &str, + body: &str, + at: u64, +) -> axum::response::Response { + authed_request( + router.clone(), + keys, + "DELETE", + &format!("/v1/brains/{brain_id}"), + Some(body.to_owned()), + at, + ) + .await +} + +async fn put_page( + router: &Router, + keys: &Keys, + brain_id: &str, + revision: u64, + at: u64, +) -> axum::response::Response { + let body = object_write_body( + keys, + RevisionFixture { + brain_id, + folder_id: "getting-started", + object_id: "obj_000000000401", + operation: if revision == 1 { + FolderObjectOperation::Create + } else { + FolderObjectOperation::Update + }, + revision, + base_revision: (revision > 1).then(|| revision - 1), + key_version: 1, + content: "deletion page", + nonce: 40 + revision as u8, + record_type: false, + }, + ); + authed_request( + router.clone(), + keys, + "PUT", + &format!("/v1/brains/{brain_id}/folders/getting-started/objects/obj_000000000401"), + Some(body), + at, + ) + .await +} + +/// `acme` with the two test Folders, a Member and one page; a second +/// Organization Brain `content-brain` the same admin controls. +async fn deletion_router(admin: &Keys, member: &Keys) -> (ServerState, Router) { + let state = ServerState::new(BrainStore::open_in_memory().unwrap(), TEST_BASE_URL) + .with_auth_clock(TEST_NOW, 60); + let router = router_with_state(state.clone()); + for brain_id in ["acme", "content-brain"] { + let created = post_brain( + router.clone(), + admin, + &create_brain_body(brain_id, "organization"), + TEST_NOW - 30, + None, + None, + None, + ) + .await; + assert_eq!( + created.status(), + StatusCode::OK, + "{}", + read_text(created).await + ); + } + add_test_org_folders(&router, admin).await; + let target = npub(member); + let added = authed_request( + router.clone(), + admin, + "PUT", + &format!("/v1/admin/brains/acme/members/{target}"), + Some( + serde_json::json!({"accessChangeEvent": admin_event(admin, "acme", "deletion-add-member", + AdminAccessAction::AddMember, None, Some(&target), None)}) + .to_string(), + ), + TEST_NOW - 20, + ) + .await; + assert_eq!(added.status(), StatusCode::OK); + assert_eq!( + put_page(&router, admin, "acme", 1, TEST_NOW - 10) + .await + .status(), + StatusCode::OK + ); + (state, router) +} + +#[tokio::test] +async fn admins_delete_through_the_signed_api_and_former_readers_see_gone() { + let admin = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let (state, router) = deletion_router(&admin, &member).await; + let mut updates = state.brain_updates.subscribe(); + + assert_error( + preview(&router, &member, "acme", TEST_NOW).await, + StatusCode::FORBIDDEN, + "permanent deletion requires brain destructive authority", + ) + .await; + assert_error( + preview(&router, &outsider, "acme", TEST_NOW).await, + StatusCode::FORBIDDEN, + "brain access required", + ) + .await; + let first = preview(&router, &admin, "acme", TEST_NOW + 1).await; + assert_eq!(first.status(), StatusCode::OK); + let first: BrainDeletionPreviewResponse = read_json(first).await; + assert_eq!(first.server, TEST_BASE_URL); + assert_eq!(first.name, "Acme"); + assert_eq!((first.folder_count, first.page_count), (2, 1)); + assert_eq!((first.member_count, first.admin_count), (2, 1)); + assert!(first.deletable); + assert_eq!(first.confirmation_max_age_seconds, 600); + + // The signed note must carry a well-formed preview digest. + for note in [None, Some("not-a-digest"), Some("sha256:XYZ")] { + let event = delete_event(&admin, "acme", "delete-bad-note", note); + assert_error( + delete(&router, &admin, "acme", &delete_body(&event), TEST_NOW + 2).await, + StatusCode::BAD_REQUEST, + "stateDigest", + ) + .await; + } + // A Member cannot delete even with the admin's digest. + let member_event = delete_event(&member, "acme", "delete-member", Some(&first.state_digest)); + assert_error( + delete( + &router, + &member, + "acme", + &delete_body(&member_event), + TEST_NOW + 3, + ) + .await, + StatusCode::FORBIDDEN, + "permanent deletion requires brain destructive authority", + ) + .await; + + // An edit after the preview keeps every count but invalidates it. + assert_eq!( + put_page(&router, &admin, "acme", 2, TEST_NOW + 4) + .await + .status(), + StatusCode::OK + ); + let stale = delete_event(&admin, "acme", "delete-stale", Some(&first.state_digest)); + assert_error( + delete(&router, &admin, "acme", &delete_body(&stale), TEST_NOW + 5).await, + StatusCode::CONFLICT, + "Brain changed after its deletion preview", + ) + .await; + let fresh: BrainDeletionPreviewResponse = + read_json(preview(&router, &admin, "acme", TEST_NOW + 6).await).await; + assert_eq!( + (fresh.folder_count, fresh.page_count), + (first.folder_count, first.page_count) + ); + assert_ne!(fresh.state_digest, first.state_digest); + + let event = delete_event(&admin, "acme", "delete-acme", Some(&fresh.state_digest)); + let deleted = delete(&router, &admin, "acme", &delete_body(&event), TEST_NOW + 7).await; + assert_eq!(deleted.status(), StatusCode::OK); + let deleted: BrainDeleteResponse = read_json(deleted).await; + assert!(!deleted.duplicate); + assert_eq!(deleted.receipt.brain_id, "acme"); + assert_eq!(deleted.receipt.deletion_event_id, event.id.to_hex()); + assert_eq!(deleted.receipt.actor_npub, npub(&admin)); + assert_eq!(deleted.receipt.state_digest, fresh.state_digest); + assert_eq!( + (deleted.receipt.folder_count, deleted.receipt.page_count), + (2, 1) + ); + let notice = loop { + let update = updates.recv().await.unwrap(); + if update.reason == BrainUpdateReason::BrainDeleted { + break update; + } + }; + assert_eq!(notice.brain_id, "acme"); + assert_eq!(notice.reason, BrainUpdateReason::BrainDeleted); + assert_eq!( + notice.notify_npubs.iter().cloned().collect::>(), + BTreeSet::from([npub(&admin), npub(&member)]) + ); + + // An exact retry (same signed deletion, fresh request signature) answers + // the same receipt and changes nothing. + let retry = delete(&router, &admin, "acme", &delete_body(&event), TEST_NOW + 8).await; + assert_eq!(retry.status(), StatusCode::OK); + let retry: BrainDeleteResponse = read_json(retry).await; + assert!(retry.duplicate); + assert_eq!(retry.receipt, deleted.receipt); + assert!(updates.try_recv().is_err()); + + // Former readers learn the Brain is gone; a new deletion attempt too. + let deleted_at = deleted.receipt.deleted_at.as_str(); + let again = delete_event( + &admin, + "acme", + "delete-acme-again", + Some(&fresh.state_digest), + ); + assert_gone( + deleted_at, + delete(&router, &admin, "acme", &delete_body(&again), TEST_NOW + 9).await, + ) + .await; + assert_gone( + deleted_at, + get_metadata(router.clone(), &member, "acme", TEST_NOW + 10).await, + ) + .await; + assert_gone( + deleted_at, + authed_request( + router.clone(), + &member, + "GET", + "/v1/brains/acme/sync/records?after=0", + None, + TEST_NOW + 11, + ) + .await, + ) + .await; + assert_gone( + deleted_at, + put_page(&router, &admin, "acme", 3, TEST_NOW + 12).await, + ) + .await; + + // Everyone else gets exactly the unknown-Brain answer. + let unknown = get_metadata(router.clone(), &outsider, "never-existed", TEST_NOW + 13).await; + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + let unknown = read_text(unknown).await; + let deleted_for_outsider = get_metadata(router.clone(), &outsider, "acme", TEST_NOW + 14).await; + assert_eq!(deleted_for_outsider.status(), StatusCode::NOT_FOUND); + assert_eq!( + read_text(deleted_for_outsider) + .await + .replace("acme", "never-existed"), + unknown + ); + let outsider_event = delete_event( + &outsider, + "acme", + "delete-outsider", + Some(&fresh.state_digest), + ); + assert_eq!( + delete( + &router, + &outsider, + "acme", + &delete_body(&outsider_event), + TEST_NOW + 15 + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + + // The Brain left every list; its id cannot be created again, and the + // refusal matches creating over a live Brain. + let listed = authed_request( + router.clone(), + &admin, + "GET", + "/v1/brains", + None, + TEST_NOW + 16, + ) + .await; + let listed: VisibleBrainsResponse = read_json(listed).await; + assert_eq!( + listed + .brains + .iter() + .map(|brain| brain.brain_id.as_str()) + .collect::>(), + vec!["content-brain"] + ); + for (brain_id, at) in [("acme", TEST_NOW + 17), ("content-brain", TEST_NOW + 18)] { + let recreate = post_brain( + router.clone(), + &outsider, + &create_brain_body(brain_id, "organization"), + at, + None, + None, + None, + ) + .await; + assert_error( + recreate, + StatusCode::CONFLICT, + &format!("duplicate id for brain_id: {brain_id}"), + ) + .await; + } +} + +#[tokio::test] +async fn active_mounts_in_either_direction_block_deletion() { + let admin = Keys::generate(); + let member = Keys::generate(); + let (state, router) = deletion_router(&admin, &member).await; + let admin_id = UserId::new(npub(&admin)).unwrap(); + { + let mut store = state.store.lock().unwrap(); + store + .create_folder( + &BrainId::new("content-brain").unwrap(), + &Folder { + id: FolderId::new("shared-in").unwrap(), + name: DisplayName::new("folder_name", "shared-in").unwrap(), + role: FolderRole::Folder, + access: FolderAccessMode::AdminOnly, + parent_folder_id: None, + path: SafeRelativePath::new("folder_path", "shared-in").unwrap(), + current_key_version: 1, + }, + &BTreeSet::new(), + &[grant_metadata( + "grant-shared-in-admin", + "shared-in", + &admin_id, + )], + ) + .unwrap(); + for (offer, source, folder, destination) in [ + ("offer-out", "acme", "restricted", "content-brain"), + ("offer-in", "content-brain", "shared-in", "acme"), + ] { + let offer_grant = grant_metadata(&format!("grant-{offer}"), folder, &admin_id); + store + .create_shared_folder_invitation( + &BrainId::new(source).unwrap(), + &FolderId::new(folder).unwrap(), + &BrainId::new(destination).unwrap(), + offer, + &admin_id, + &admin_id, + &format!("/v1/mount-offers/{offer}/accept"), + &offer_grant, + &format_unix_timestamp(TEST_NOW + 86_400).unwrap(), + &test_rfc3339(), + ) + .unwrap(); + let record = folder_key_grant_sync_record(&offer_grant).unwrap(); + store + .accept_shared_folder_invitation( + offer, + &admin_id, + &format!("mount-{offer}"), + &format!("projection-{offer}"), + &[], + &[record], + &test_rfc3339(), + ) + .unwrap(); + } + } + let blocked: BrainDeletionPreviewResponse = + read_json(preview(&router, &admin, "acme", TEST_NOW).await).await; + assert!(!blocked.deletable); + assert_eq!( + blocked + .blocking_mounts + .iter() + .map(|mount| ( + mount.connection_id.as_str(), + mount.direction.as_str(), + mount.source_brain_id.as_str(), + mount.destination_brain_id.as_str() + )) + .collect::>(), + vec![ + ("mount-offer-in", "incoming", "content-brain", "acme"), + ("mount-offer-out", "outgoing", "acme", "content-brain"), + ] + ); + let event = delete_event( + &admin, + "acme", + "delete-blocked", + Some(&blocked.state_digest), + ); + assert_error( + delete(&router, &admin, "acme", &delete_body(&event), TEST_NOW + 1).await, + StatusCode::CONFLICT, + "active cross-Brain Mount(s) (mount-offer-in, mount-offer-out)", + ) + .await; + assert_eq!( + get_metadata(router.clone(), &admin, "acme", TEST_NOW + 2) + .await + .status(), + StatusCode::OK + ); +} + +#[tokio::test] +async fn a_confirmation_signed_too_long_ago_deletes_nothing() { + let admin = Keys::generate(); + let member = Keys::generate(); + let (state, router) = deletion_router(&admin, &member).await; + // The event is signed at TEST_NOW; the server clock moves past its window. + let event_digest: BrainDeletionPreviewResponse = + read_json(preview(&router, &admin, "acme", TEST_NOW).await).await; + let signed = delete_event( + &admin, + "acme", + "delete-expired", + Some(&event_digest.state_digest), + ); + state.advance_auth_clock(BRAIN_DELETION_CONFIRMATION_MAX_AGE_SECONDS + 5); + let later = TEST_NOW + BRAIN_DELETION_CONFIRMATION_MAX_AGE_SECONDS + 5; + let current: BrainDeletionPreviewResponse = + read_json(preview(&router, &admin, "acme", later).await).await; + assert_eq!(current.state_digest, event_digest.state_digest); + assert_error( + delete(&router, &admin, "acme", &delete_body(&signed), later + 1).await, + StatusCode::CONFLICT, + "Brain deletion confirmation expired", + ) + .await; + assert_eq!( + get_metadata(router.clone(), &admin, "acme", later + 2) + .await + .status(), + StatusCode::OK + ); +} + +#[tokio::test] +async fn personal_brains_cannot_be_deleted_by_their_owner_or_agent() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let state = ServerState::new(BrainStore::open_in_memory().unwrap(), TEST_BASE_URL) + .with_auth_clock(TEST_NOW, 60); + let personal = bootstrap_personal_brain("personal-owner", "Personal", &npub(&owner)).unwrap(); + state + .store + .lock() + .unwrap() + .create_personal_brain_bootstrap( + &personal, + &[], + &UserId::new(npub(&agent)).unwrap(), + &UserId::new(npub(&owner)).unwrap(), + &test_rfc3339(), + ) + .unwrap(); + let router = router_with_state(state); + for (keys, at) in [(&owner, TEST_NOW), (&agent, TEST_NOW + 1)] { + assert_error( + preview(&router, keys, "personal-owner", at).await, + StatusCode::BAD_REQUEST, + "Personal Brains cannot be deleted", + ) + .await; + let digest = format!("sha256:{}", "0".repeat(64)); + let event = delete_event(keys, "personal-owner", "delete-personal", Some(&digest)); + assert_error( + delete( + &router, + keys, + "personal-owner", + &delete_body(&event), + at + 10, + ) + .await, + StatusCode::BAD_REQUEST, + "Personal Brains cannot be deleted", + ) + .await; + } + assert_eq!( + get_metadata(router.clone(), &owner, "personal-owner", TEST_NOW + 20) + .await + .status(), + StatusCode::OK + ); +} + +async fn assert_gone(deleted_at: &str, response: axum::response::Response) { + assert_eq!(response.status(), StatusCode::GONE); + let body: DeletedBrainResponse = read_json(response).await; + assert_eq!(body.code, "brain_deleted"); + assert_eq!(body.brain_deleted.brain_id, "acme"); + assert_eq!(body.brain_deleted.deleted_at, deleted_at); +} + +fn grant_metadata(id: &str, folder_id: &str, recipient: &UserId) -> FolderKeyGrantMetadata { + FolderKeyGrantMetadata { + id: id.to_owned(), + folder_id: FolderId::new(folder_id).unwrap(), + key_version: 1, + issuer_npub: recipient.clone(), + recipient_npub: recipient.clone(), + format: "NIP-59".to_owned(), + wrapped_event_json: gift_wrap_event_json(recipient.as_str()), + access_change_event_json: Some(r#"{"kind":30078}"#.to_owned()), + created_at: test_rfc3339(), + } +} diff --git a/finite-brain/crates/finite-brain-store/src/brain_deletion.rs b/finite-brain/crates/finite-brain-store/src/brain_deletion.rs new file mode 100644 index 000000000..f144a659a --- /dev/null +++ b/finite-brain/crates/finite-brain-store/src/brain_deletion.rs @@ -0,0 +1,736 @@ +//! Permanent Organization Brain deletion: one store transaction that rechecks +//! the confirmed preview, removes everything the Brain owns, and leaves a +//! small permanent deletion record outside the Brain's foreign-key graph. + +use rusqlite::types::ValueRef; +use sha2::{Digest, Sha256}; + +use crate::folder_key_rotation::begin_authoritative_write; +use crate::*; + +/// Prefix of a Brain deletion state digest. +pub const BRAIN_DELETION_DIGEST_PREFIX: &str = "sha256:"; +const BRAIN_DELETION_DIGEST_DOMAIN: &str = "finite-brain-deletion-state-v1"; +/// Rows any one table may contribute to a deletion preview. Every scoped +/// table is already bounded by the Brain capacity envelope; this refuses a +/// preview over a Brain that somehow exceeds it instead of hashing without end. +const MAX_DIGEST_ROWS_PER_TABLE: usize = 200_000; + +/// One set of rows a Brain deletion removes and its preview digest covers. +struct ScopedRows { + table: &'static str, + /// SQL predicate selecting this Brain's rows; `?1` is the Brain id. + predicate: &'static str, + /// Large ciphertext columns left out of the digest. The row's id, + /// revision or event id in the same row already pins their contents. + digest_excludes: &'static [&'static str], +} + +/// Everything a Brain owns, plus the cross-Brain Mount artifacts that name +/// it. The preview digest hashes these rows and deletion must leave none of +/// them. A schema test requires every table to be listed here or in +/// [`BRAIN_DELETION_UNSCOPED_TABLES`]. +const BRAIN_DELETION_SCOPE: &[ScopedRows] = &[ + scoped("brains", "id = ?1"), + scoped("brain_members", "brain_id = ?1"), + scoped("brain_admins", "brain_id = ?1"), + scoped("folders", "brain_id = ?1"), + scoped("folder_access", "brain_id = ?1"), + scoped("folder_access_sources", "brain_id = ?1"), + ScopedRows { + table: "folder_key_grants", + predicate: "brain_id = ?1", + digest_excludes: &["wrapped_event_json", "access_change_event_json"], + }, + scoped("brain_pending_grant_wraps", "brain_id = ?1"), + ScopedRows { + table: "current_encrypted_brain_objects", + predicate: "brain_id = ?1", + digest_excludes: &["payload_json"], + }, + ScopedRows { + table: "brain_record_index", + predicate: "brain_id = ?1", + digest_excludes: &["payload_json"], + }, + scoped("brain_sync_retention", "brain_id = ?1"), + scoped("brain_capacity_counts", "brain_id = ?1"), + ScopedRows { + table: "deleted_folder_identities", + predicate: "brain_id = ?1", + digest_excludes: &["payload_json"], + }, + scoped("deleted_object_identities", "brain_id = ?1"), + scoped("folder_deletion_audience", "brain_id = ?1"), + scoped("brain_invitations", "brain_id = ?1"), + scoped("brain_invite_tokens", "brain_id = ?1"), + scoped("brain_invitation_plans", "brain_id = ?1"), + scoped("brain_approval_requests", "brain_id = ?1"), + scoped("brain_approval_nonces", "brain_id = ?1"), + scoped("brain_principal_revocations", "brain_id = ?1"), + scoped("brain_departure_pending_rotations", "brain_id = ?1"), + scoped("share_links", "brain_id = ?1"), + scoped("personal_agents", "brain_id = ?1"), + scoped("personal_agent_audit", "brain_id = ?1"), + scoped("brain_email_access_delegations", "vault_id = ?1"), + scoped( + "brain_email_access_delegation_audit", + "delegation_id IN (SELECT id FROM brain_email_access_delegations WHERE vault_id = ?1)", + ), + scoped("personal_vault_bootstrap_authorizations", "vault_id = ?1"), + scoped( + "shared_folder_invitations", + "source_brain_id = ?1 OR destination_brain_id = ?1", + ), + scoped( + "shared_folder_connections", + "source_brain_id = ?1 OR destination_brain_id = ?1", + ), + scoped( + "shared_folder_connection_members", + concat!( + "connection_id IN (SELECT id FROM shared_folder_connections ", + "WHERE source_brain_id = ?1 OR destination_brain_id = ?1)" + ), + ), + scoped( + "folder_mounts", + "source_brain_id = ?1 OR destination_brain_id = ?1", + ), + scoped("personal_folder_mounts", "source_brain_id = ?1"), + scoped( + "legacy_folder_access_source_repairs", + concat!( + "brain_id = ?1 OR connection_id IN (SELECT id FROM shared_folder_connections ", + "WHERE source_brain_id = ?1 OR destination_brain_id = ?1)" + ), + ), + scoped( + "legacy_personal_mount_migrations", + "destination_brain_id = ?1", + ), +]; + +/// Tables that hold no Brain-owned rows: global state, and the permanent +/// deletion record itself. +#[cfg(test)] +pub(crate) const BRAIN_DELETION_UNSCOPED_TABLES: &[&str] = &[ + "schema_migrations", + "identity_aliases", + "brain_departure_fact_cursor", + "deleted_brains", + "deleted_brain_audience", +]; + +const fn scoped(table: &'static str, predicate: &'static str) -> ScopedRows { + ScopedRows { + table, + predicate, + digest_excludes: &[], + } +} + +/// Server-computed scope of a permanent Organization Brain deletion. The +/// admin confirms `state_digest`; the deletion transaction recomputes it and +/// refuses any change, including changes that leave every count the same. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BrainDeletionPreview { + pub brain_id: BrainId, + pub name: String, + pub folder_count: usize, + /// Live Folder objects (pages, plus legacy assets). + pub object_count: usize, + pub member_count: usize, + pub admin_count: usize, + pub guest_count: usize, + /// Pending Brain and Folder invitations. + pub pending_invitation_count: usize, + /// Invite Tokens that are neither redeemed nor revoked. + pub open_invite_token_count: usize, + /// Active cross-Brain Mounts. Deletion is refused while any remain. + pub blocking_mounts: Vec, + /// Pending Mount Offers and revoked Mount records naming this Brain in + /// either direction. Deletion removes them so none can restore access. + pub retired_mount_record_count: usize, + pub state_digest: String, +} + +/// An active Mount that must be revoked before the Brain can be deleted. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BlockingMount { + pub connection_id: String, + pub direction: SharedFolderDirection, + pub source_brain_id: BrainId, + pub source_folder_id: FolderId, + pub destination_brain_id: BrainId, +} + +/// One confirmed, signed request to delete an Organization Brain. +#[derive(Debug, Clone)] +pub struct BrainDeletionRequest<'a> { + pub brain_id: &'a BrainId, + pub actor_npub: &'a UserId, + pub deletion_event_id: &'a str, + pub deletion_event_json: &'a str, + /// The digest the admin confirmed, as signed in the deletion event. + pub expected_state_digest: &'a str, + /// Identity of this Brain server, bound into the digest. + pub server_identity: &'a str, + pub deleted_at: &'a str, + /// False when the signed confirmation is older than the server accepts. + /// Only a new deletion is refused for it; an exact retry still answers. + pub confirmation_current: bool, +} + +/// Permanent receipt for one accepted Brain deletion. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct BrainDeletionReceipt { + pub brain_id: BrainId, + pub deletion_event_id: String, + pub actor_npub: UserId, + pub deleted_at: String, + pub state_digest: String, + pub folder_count: usize, + pub object_count: usize, +} + +/// Result of a Brain deletion request. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum BrainDeletionOutcome { + /// The Brain was deleted now. `audience` held access at deletion. + Deleted { + receipt: BrainDeletionReceipt, + audience: Vec, + }, + /// An exact retry of the accepted deletion; nothing changed. + Replayed { receipt: BrainDeletionReceipt }, +} + +/// What a principal that held access learns about a deleted Brain. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct DeletedBrainNotice { + pub brain_id: BrainId, + pub deleted_at: String, +} + +impl BrainStore { + /// Scope and state digest of deleting `brain_id`, read from one + /// snapshot without loading object or record ciphertext. + pub fn brain_deletion_preview( + &self, + brain_id: &BrainId, + actor_npub: &UserId, + server_identity: &str, + ) -> Result { + let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Deferred)?; + let stored = self.load_brain(brain_id)?; + refuse_unless_deletable_by(&stored, actor_npub)?; + let folder_count = stored.brain.folders.len(); + let object_count = count( + &tx, + "SELECT COUNT(*) FROM current_encrypted_brain_objects WHERE brain_id = ?1 AND deleted = 0", + brain_id, + )?; + let pending_invitation_count = count( + &tx, + "SELECT COUNT(*) FROM brain_invitations WHERE brain_id = ?1 AND status = 'pending'", + brain_id, + )? + count( + &tx, + "SELECT COUNT(*) FROM share_links WHERE brain_id = ?1 AND status = 'pending'", + brain_id, + )?; + let open_invite_token_count = count( + &tx, + "SELECT COUNT(*) FROM brain_invite_tokens WHERE brain_id = ?1 AND redeemed_at IS NULL AND revoked_at IS NULL", + brain_id, + )?; + let retired_mount_record_count = count( + &tx, + "SELECT COUNT(*) FROM shared_folder_connections WHERE (source_brain_id = ?1 OR destination_brain_id = ?1) AND status <> 'active'", + brain_id, + )? + count( + &tx, + "SELECT COUNT(*) FROM shared_folder_invitations WHERE (source_brain_id = ?1 OR destination_brain_id = ?1) AND status = 'pending'", + brain_id, + )?; + let preview = BrainDeletionPreview { + brain_id: brain_id.clone(), + name: stored.brain.name.as_str().to_owned(), + folder_count, + object_count, + member_count: stored.brain.members.len(), + admin_count: stored.brain.admins.len(), + guest_count: stored.guest_user_ids().len(), + pending_invitation_count, + open_invite_token_count, + blocking_mounts: active_mounts(&tx, brain_id)?, + retired_mount_record_count, + state_digest: brain_state_digest(&tx, brain_id, server_identity)?, + }; + tx.commit()?; + Ok(preview) + } + + /// Delete an Organization Brain, or answer an exact retry of an accepted + /// deletion with its receipt. Authority, Brain kind, active Mounts, + /// confirmation age and the confirmed state digest are all rechecked + /// inside the one write transaction that deletes; any failure changes + /// nothing. + pub fn delete_organization_brain( + &mut self, + request: &BrainDeletionRequest<'_>, + ) -> Result { + let brain_id = request.brain_id; + let tx = begin_authoritative_write(&self.conn)?; + if let Some(receipt) = load_deletion_receipt(&tx, brain_id)? { + return if receipt.deletion_event_id == request.deletion_event_id + && receipt.actor_npub == *request.actor_npub + { + Ok(BrainDeletionOutcome::Replayed { receipt }) + } else { + Err(StoreError::MissingBrain { + brain_id: brain_id.to_string(), + }) + }; + } + let stored = self.load_brain(brain_id)?; + refuse_unless_deletable_by(&stored, request.actor_npub)?; + let blocking = active_mounts(&tx, brain_id)?; + if !blocking.is_empty() { + return Err(StoreError::Conflict { + reason: active_mounts_reason(&blocking), + current_revision: None, + }); + } + if brain_state_digest(&tx, brain_id, request.server_identity)? + != request.expected_state_digest + { + return Err(StoreError::Conflict { + reason: "Brain changed after its deletion preview; nothing was deleted. Review a fresh preview and confirm again".to_owned(), + current_revision: None, + }); + } + if !request.confirmation_current { + return Err(StoreError::Conflict { + reason: "Brain deletion confirmation expired; nothing was deleted. Review a fresh preview and confirm again".to_owned(), + current_revision: None, + }); + } + + let folder_count = stored.brain.folders.len(); + let object_count = count( + &tx, + "SELECT COUNT(*) FROM current_encrypted_brain_objects WHERE brain_id = ?1 AND deleted = 0", + brain_id, + )?; + let audience = deletion_audience(&stored); + tx.execute( + r#"INSERT INTO deleted_brains ( + brain_id, deletion_event_id, actor_npub, deleted_at, state_digest, + folder_count, object_count, deletion_event_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#, + params![ + brain_id.as_str(), + request.deletion_event_id, + request.actor_npub.as_str(), + request.deleted_at, + request.expected_state_digest, + folder_count, + object_count, + request.deletion_event_json, + ], + )?; + for reader in &audience { + tx.execute( + "INSERT INTO deleted_brain_audience (brain_id, actor_npub) VALUES (?1, ?2)", + params![brain_id.as_str(), reader.as_str()], + )?; + } + remove_brain_rows(&tx, brain_id)?; + tx.commit()?; + let receipt = load_deletion_receipt(&self.conn, brain_id)?.ok_or_else(|| { + StoreError::BrokenInvariant { + reason: "Brain deletion record was not written".to_owned(), + } + })?; + Ok(BrainDeletionOutcome::Deleted { + receipt, + audience: audience.into_iter().collect(), + }) + } + + /// The receipt of an accepted deletion, when `deletion_event_id` by + /// `actor_npub` is exactly the event that deleted `brain_id`. + pub fn brain_deletion_replay( + &self, + brain_id: &BrainId, + deletion_event_id: &str, + actor_npub: &UserId, + ) -> Result, StoreError> { + Ok( + load_deletion_receipt(&self.conn, brain_id)?.filter(|receipt| { + receipt.deletion_event_id == deletion_event_id && receipt.actor_npub == *actor_npub + }), + ) + } + + /// The deletion notice for `actor_npub`, only when it held access to + /// `brain_id` when the Brain was deleted. + pub fn deleted_brain_notice( + &self, + brain_id: &BrainId, + actor_npub: &UserId, + ) -> Result, StoreError> { + let deleted_at = self + .conn + .query_row( + r#"SELECT d.deleted_at + FROM deleted_brains d + JOIN deleted_brain_audience a ON a.brain_id = d.brain_id + WHERE d.brain_id = ?1 AND a.actor_npub = ?2"#, + params![brain_id.as_str(), actor_npub.as_str()], + |row| row.get::<_, String>(0), + ) + .optional()?; + Ok(deleted_at.map(|deleted_at| DeletedBrainNotice { + brain_id: brain_id.clone(), + deleted_at, + })) + } +} + +/// Only a current admin of an Organization Brain may delete it. A Personal +/// Brain is refused for everyone, its owner and Personal Agent included. +fn refuse_unless_deletable_by(stored: &StoredBrain, actor_npub: &UserId) -> Result<(), StoreError> { + if stored.brain.kind != BrainKind::Organization { + return Err(StoreError::BrokenInvariant { + reason: "Personal Brains cannot be deleted; only Organization Brains can".to_owned(), + }); + } + if !stored.brain.admins.contains(actor_npub) { + return Err(StoreError::Conflict { + reason: "Brain deletion requires current Brain admin standing; nothing was deleted" + .to_owned(), + current_revision: None, + }); + } + Ok(()) +} + +/// Every principal with access when the Brain is deleted: Members (admins +/// are Members) and Folder-limited guests. +fn deletion_audience(stored: &StoredBrain) -> BTreeSet { + let mut audience = stored + .brain + .members + .iter() + .map(|member| member.user_id.clone()) + .collect::>(); + audience.extend(stored.brain.admins.iter().cloned()); + audience.extend(stored.folder_access.values().flatten().cloned()); + audience +} + +fn active_mounts(conn: &Connection, brain_id: &BrainId) -> Result, StoreError> { + let mut stmt = conn.prepare( + r#"SELECT id, source_brain_id, source_folder_id, destination_brain_id + FROM shared_folder_connections + WHERE (source_brain_id = ?1 OR destination_brain_id = ?1) AND status = 'active' + ORDER BY id"#, + )?; + let rows = stmt.query_map(params![brain_id.as_str()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })?; + let mut mounts = Vec::new(); + for row in rows { + let (connection_id, source, folder, destination) = row?; + let source_brain_id = BrainId::new(source)?; + mounts.push(BlockingMount { + connection_id, + direction: if source_brain_id == *brain_id { + SharedFolderDirection::Source + } else { + SharedFolderDirection::Destination + }, + source_brain_id, + source_folder_id: FolderId::new(folder)?, + destination_brain_id: BrainId::new(destination)?, + }); + } + Ok(mounts) +} + +fn active_mounts_reason(mounts: &[BlockingMount]) -> String { + let ids = mounts + .iter() + .map(|mount| mount.connection_id.as_str()) + .collect::>() + .join(", "); + format!( + "Brain has {} active cross-Brain Mount(s) ({ids}); revoke them before deleting the Brain. Nothing was deleted", + mounts.len() + ) +} + +fn count(conn: &Connection, sql: &str, brain_id: &BrainId) -> Result { + Ok(conn.query_row(sql, params![brain_id.as_str()], |row| { + row.get::<_, usize>(0) + })?) +} + +fn load_deletion_receipt( + conn: &Connection, + brain_id: &BrainId, +) -> Result, StoreError> { + let row = conn + .query_row( + r#"SELECT deletion_event_id, actor_npub, deleted_at, state_digest, + folder_count, object_count + FROM deleted_brains WHERE brain_id = ?1"#, + params![brain_id.as_str()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, usize>(4)?, + row.get::<_, usize>(5)?, + )) + }, + ) + .optional()?; + row.map( + |(deletion_event_id, actor, deleted_at, state_digest, folder_count, object_count)| { + Ok(BrainDeletionReceipt { + brain_id: brain_id.clone(), + deletion_event_id, + actor_npub: UserId::new(actor)?, + deleted_at, + state_digest, + folder_count, + object_count, + }) + }, + ) + .transpose() +} + +/// SHA-256 over every scoped row of the Brain, bound to this server and +/// Brain id. Rows are hashed column by column with type tags and lengths, in +/// a fixed table order and sorted by their own values, so the digest changes +/// whenever any scoped row is added, removed or altered. +fn brain_state_digest( + conn: &Connection, + brain_id: &BrainId, + server_identity: &str, +) -> Result { + let mut hasher = Sha256::new(); + for part in [ + BRAIN_DELETION_DIGEST_DOMAIN, + server_identity, + brain_id.as_str(), + ] { + hash_bytes(&mut hasher, part.as_bytes()); + } + for scope in BRAIN_DELETION_SCOPE { + let columns = digest_columns(conn, scope)?; + hash_bytes(&mut hasher, scope.table.as_bytes()); + let order = (1..=columns.len()) + .map(|index| index.to_string()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {} FROM \"{}\" WHERE {} ORDER BY {order} LIMIT {}", + columns + .iter() + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(", "), + scope.table, + scope.predicate, + MAX_DIGEST_ROWS_PER_TABLE + 1, + ); + let mut stmt = conn.prepare(&sql)?; + let mut rows = stmt.query(params![brain_id.as_str()])?; + let mut row_count = 0_usize; + while let Some(row) = rows.next()? { + row_count += 1; + if row_count > MAX_DIGEST_ROWS_PER_TABLE { + return Err(StoreError::CapacityExceeded { + limit: format!("brain_deletion_preview:{}", scope.table), + max: MAX_DIGEST_ROWS_PER_TABLE, + current: row_count, + }); + } + hasher.update([0x1e]); + for index in 0..columns.len() { + hash_value(&mut hasher, row.get_ref(index)?); + } + } + hasher.update([0x1d]); + hasher.update((row_count as u64).to_be_bytes()); + } + let digest = hasher.finalize(); + Ok(format!( + "{BRAIN_DELETION_DIGEST_PREFIX}{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn digest_columns(conn: &Connection, scope: &ScopedRows) -> Result, StoreError> { + let mut stmt = conn.prepare("SELECT name FROM pragma_table_info(?1) ORDER BY cid")?; + let rows = stmt.query_map(params![scope.table], |row| row.get::<_, String>(0))?; + let mut columns = Vec::new(); + for row in rows { + let column = row?; + if !scope.digest_excludes.contains(&column.as_str()) { + columns.push(column); + } + } + if columns.is_empty() { + return Err(StoreError::BrokenInvariant { + reason: format!("Brain deletion scope table {} has no columns", scope.table), + }); + } + Ok(columns) +} + +fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn hash_value(hasher: &mut Sha256, value: ValueRef<'_>) { + match value { + ValueRef::Null => hasher.update(b"n"), + ValueRef::Integer(value) => { + hasher.update(b"i"); + hasher.update(value.to_be_bytes()); + } + ValueRef::Real(value) => { + hasher.update(b"r"); + hasher.update(value.to_bits().to_be_bytes()); + } + ValueRef::Text(bytes) => { + hasher.update(b"t"); + hash_bytes(hasher, bytes); + } + ValueRef::Blob(bytes) => { + hasher.update(b"b"); + hash_bytes(hasher, bytes); + } + } +} + +/// Delete every scoped row in an order the RESTRICT foreign keys accept, +/// then prove none remain. Runs inside the caller's write transaction. +fn remove_brain_rows(tx: &Connection, brain_id: &BrainId) -> Result<(), StoreError> { + let id = params![brain_id.as_str()]; + // Capacity counters first: every later counter trigger for this Brain + // becomes a no-op instead of failing on counter drift. + tx.execute("DELETE FROM brain_capacity_counts WHERE brain_id = ?1", id)?; + tx.execute( + "DELETE FROM folder_mounts WHERE source_brain_id = ?1 OR destination_brain_id = ?1", + id, + )?; + tx.execute( + "DELETE FROM shared_folder_connections WHERE source_brain_id = ?1 OR destination_brain_id = ?1", + id, + )?; + tx.execute( + "DELETE FROM shared_folder_invitations WHERE source_brain_id = ?1 OR destination_brain_id = ?1", + id, + )?; + tx.execute( + "DELETE FROM personal_folder_mounts WHERE source_brain_id = ?1", + id, + )?; + tx.execute( + "DELETE FROM brain_email_access_delegations WHERE vault_id = ?1", + id, + )?; + tx.execute( + "DELETE FROM personal_vault_bootstrap_authorizations WHERE vault_id = ?1", + id, + )?; + tx.execute("DELETE FROM brain_record_index WHERE brain_id = ?1", id)?; + // Folders reference their parent with ON DELETE RESTRICT: remove leaves + // first, one bounded pass per hierarchy level. + for _ in 0..=BRAIN_CAPACITY_ENVELOPE.folder_depth { + let removed = tx.execute( + r#"DELETE FROM folders + WHERE brain_id = ?1 + AND id NOT IN ( + SELECT parent_folder_id FROM folders + WHERE brain_id = ?1 AND parent_folder_id IS NOT NULL + )"#, + id, + )?; + if removed == 0 { + break; + } + } + tx.execute("DELETE FROM brains WHERE id = ?1", id)?; + for scope in BRAIN_DELETION_SCOPE { + let remaining = count( + tx, + &format!( + "SELECT COUNT(*) FROM \"{}\" WHERE {}", + scope.table, scope.predicate + ), + brain_id, + )?; + if remaining > 0 { + return Err(StoreError::BrokenInvariant { + reason: format!( + "Brain deletion left {remaining} row(s) in {}; nothing was deleted", + scope.table + ), + }); + } + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn brain_deletion_scope_tables() -> Vec<&'static str> { + BRAIN_DELETION_SCOPE + .iter() + .map(|scope| scope.table) + .collect() +} + +/// Every scoped row of `brain_id`, every column included, as sorted text. +/// Tests compare these dumps to prove other Brains are left byte-identical. +#[cfg(test)] +pub(crate) fn brain_scoped_rows(conn: &Connection, brain_id: &BrainId) -> Vec { + let mut dump = Vec::new(); + for scope in BRAIN_DELETION_SCOPE { + let sql = format!( + "SELECT * FROM \"{}\" WHERE {}", + scope.table, scope.predicate + ); + let mut stmt = conn.prepare(&sql).unwrap(); + let columns = stmt.column_count(); + let mut rows = stmt.query(params![brain_id.as_str()]).unwrap(); + while let Some(row) = rows.next().unwrap() { + let values = (0..columns) + .map(|index| format!("{:?}", row.get_ref(index).unwrap())) + .collect::>() + .join("|"); + dump.push(format!("{}|{values}", scope.table)); + } + } + dump.sort(); + dump +} diff --git a/finite-brain/crates/finite-brain-store/src/lib.rs b/finite-brain/crates/finite-brain-store/src/lib.rs index 19a70a544..1a0f65023 100644 --- a/finite-brain/crates/finite-brain-store/src/lib.rs +++ b/finite-brain/crates/finite-brain-store/src/lib.rs @@ -20,6 +20,7 @@ use time::format_description::well_known::Rfc3339; mod access_report; mod admin_demotion; mod approvals; +mod brain_deletion; mod brains; mod folder_access; mod folder_deletion; @@ -40,6 +41,10 @@ pub use access_report::{ ParticipationKind, }; pub use admin_demotion::{AdminDemotionRequest, admin_demotion_rotation_folders}; +pub use brain_deletion::{ + BRAIN_DELETION_DIGEST_PREFIX, BlockingMount, BrainDeletionOutcome, BrainDeletionPreview, + BrainDeletionReceipt, BrainDeletionRequest, DeletedBrainNotice, +}; pub use folder_key_rotation::{ FolderKeyRotationOutcome, FolderKeyRotationRequest, FolderKeyRotationTarget, folder_key_required_recipients, stale_folder_key_holders, @@ -3463,6 +3468,7 @@ mod tests { use tempfile::TempDir; mod admin_demotion_tests; + mod brain_deletion_tests; mod folder_key_rotation_tests; mod member_removal_tests; @@ -7313,7 +7319,7 @@ mod tests { row.get(0) }) .unwrap(); - assert_eq!(version, 30); + assert_eq!(version, 31); for (status, timestamp, eligible) in [ ("accepted", Some(now), true), ("revoked", Some(now), true), diff --git a/finite-brain/crates/finite-brain-store/src/schema.rs b/finite-brain/crates/finite-brain-store/src/schema.rs index 145e45594..8b149975f 100644 --- a/finite-brain/crates/finite-brain-store/src/schema.rs +++ b/finite-brain/crates/finite-brain-store/src/schema.rs @@ -120,6 +120,18 @@ impl BrainStore { params![30, MIGRATION_TIMESTAMP], )?; } + + // V31 is additive only: the permanent Brain deletion record, its + // audience, and a guard that keeps a deleted Brain id retired. A V30 + // binary reopening a V31 database never looks for version 31, and + // the guard trigger still refuses to recreate a deleted Brain id. + if !migration_applied(&tx, 31)? { + tx.execute_batch(SCHEMA_V31)?; + tx.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?2)", + params![31, MIGRATION_TIMESTAMP], + )?; + } tx.commit()?; Ok(()) } @@ -394,6 +406,39 @@ CREATE INDEX brain_invite_tokens_by_brain ON brain_invite_tokens(brain_id, created_at); "#; +const SCHEMA_V31: &str = r#" +-- Permanent Organization Brain deletion record. Deliberately outside the +-- brains foreign-key graph: deleting the Brain row and everything it owns +-- never removes this row, so the Brain id stays retired and an exact retry of +-- the accepted signed deletion returns the same receipt. +CREATE TABLE deleted_brains ( + brain_id TEXT PRIMARY KEY NOT NULL, + deletion_event_id TEXT NOT NULL UNIQUE, + actor_npub TEXT NOT NULL, + deleted_at TEXT NOT NULL, + state_digest TEXT NOT NULL, + folder_count INTEGER NOT NULL CHECK (folder_count >= 0), + object_count INTEGER NOT NULL CHECK (object_count >= 0), + deletion_event_json TEXT NOT NULL +); + +-- Principals that held access when the Brain was deleted. Only they learn +-- that the Brain was deleted; everyone else sees an unknown Brain id. +CREATE TABLE deleted_brain_audience ( + brain_id TEXT NOT NULL, + actor_npub TEXT NOT NULL, + PRIMARY KEY (brain_id, actor_npub) +); + +-- Every writer, including older binaries, is refused a deleted Brain id. +CREATE TRIGGER deleted_brain_ids_stay_retired +BEFORE INSERT ON brains +WHEN EXISTS (SELECT 1 FROM deleted_brains WHERE brain_id = NEW.id) +BEGIN + SELECT RAISE(ABORT, 'finite_deleted_brain_id'); +END; +"#; + const SCHEMA_V30: &str = r#" -- Access report participation evidence: one indexed point read per report -- key ("did this exact key act on this Brain itself?") instead of grouping a @@ -2831,7 +2876,7 @@ mod tests { row.get(0) }) .unwrap(); - assert_eq!(latest_version, 30); + assert_eq!(latest_version, 31); assert_eq!(capacity_count(&store, "legacy-organization", "folders"), 1); assert_eq!(capacity_count(&store, "legacy-organization", "members"), 1); assert_eq!( diff --git a/finite-brain/crates/finite-brain-store/src/tests/brain_deletion_tests.rs b/finite-brain/crates/finite-brain-store/src/tests/brain_deletion_tests.rs new file mode 100644 index 000000000..e6375ba6a --- /dev/null +++ b/finite-brain/crates/finite-brain-store/src/tests/brain_deletion_tests.rs @@ -0,0 +1,798 @@ +//! Organization Brain deletion: the confirmed preview digest is rechecked in +//! the deleting transaction, every owned row goes, other Brains stay +//! byte-identical, and the permanent deletion record retires the Brain id. + +use super::*; +use crate::brain_deletion::{ + BRAIN_DELETION_UNSCOPED_TABLES, brain_deletion_scope_tables, brain_scoped_rows, +}; + +const ADMIN: &str = "npub-admin"; +const MEMBER: &str = "npub-member"; +const GUEST: &str = "npub-guest"; +const INVITEE: &str = "npub-invitee"; +const SERVER: &str = "https://brain.example.test"; +const NOW: &str = "2026-06-23T00:00:00.000Z"; +const EXPIRES: &str = "2026-06-30T00:00:00.000Z"; + +fn v1() -> BrainId { + BrainId::new("acme").unwrap() +} + +fn content() -> BrainId { + BrainId::new("content-brain").unwrap() +} + +fn user(npub: &str) -> UserId { + UserId::new(npub).unwrap() +} + +fn folder_spec(id: &str, access: FolderAccessMode) -> Folder { + Folder { + id: FolderId::new(id).unwrap(), + name: DisplayName::new("folder_name", id).unwrap(), + role: FolderRole::Folder, + access, + parent_folder_id: None, + path: SafeRelativePath::new("folder_path", id).unwrap(), + current_key_version: 1, + } +} + +fn create_folder_in(store: &mut BrainStore, brain: &BrainId, id: &str, access: FolderAccessMode) { + store + .create_folder( + brain, + &folder_spec(id, access), + &BTreeSet::new(), + &[grant(&format!("grant-{brain}-{id}"), id, 1, ADMIN, ADMIN)], + ) + .unwrap(); +} + +/// `acme` stands in for V1: three Folders (one nested), two live pages, a +/// Member, a Folder-limited guest, a pending invitation, an open Invite Token +/// and the history of an earlier Folder deletion. `content-brain` shares the +/// admin and Member and holds its own page. +fn populate(store: &mut BrainStore) { + bootstrap_org_and_strategy_folder(store); + store.add_member(&v1(), &user(MEMBER)).unwrap(); + store + .grant_folder_access( + &v1(), + &FolderId::new("private-project").unwrap(), + &user(GUEST), + &grant("grant-private-guest", "private-project", 1, ADMIN, GUEST), + ) + .unwrap(); + for (event, object) in [ + ("event-v1-page-1", "obj_000000000001"), + ("event-v1-page-2", "obj_000000000002"), + ] { + store + .submit_sync_record(&v1(), &revision_record(event, object, 1, None, "v1 page")) + .unwrap(); + } + store + .create_brain_invitation( + &v1(), + "invitation-v1", + &user(INVITEE), + "code-v1", + "/accept", + &[], + &user(ADMIN), + EXPIRES, + NOW, + ) + .unwrap(); + store + .create_brain_invite_token( + &v1(), + &"a".repeat(64), + BrainInviteTokenRole::Member, + &user(ADMIN), + EXPIRES, + NOW, + ) + .unwrap(); + create_folder_in(store, &v1(), "archive", FolderAccessMode::AdminOnly); + store + .delete_folder_subtree( + &v1(), + &FolderId::new("archive").unwrap(), + &user(ADMIN), + 1, + "event-delete-archive", + r#"{"recordType":"folder_subtree_tombstone"}"#, + NOW, + APP_SPECIFIC_KIND, + None, + ) + .unwrap(); + + bootstrap_org_named(store, content().as_str(), "Content", ADMIN); + create_folder_in( + store, + &content(), + "content-notes", + FolderAccessMode::AllMembers, + ); + store.add_member(&content(), &user(MEMBER)).unwrap(); + store + .submit_sync_record( + &content(), + &revision_record_for( + "content-notes", + "event-content-page", + "obj_000000000101", + 1, + None, + "content page", + ), + ) + .unwrap(); +} + +fn request<'a>( + brain_id: &'a BrainId, + actor: &'a UserId, + event_id: &'a str, + digest: &'a str, +) -> BrainDeletionRequest<'a> { + BrainDeletionRequest { + brain_id, + actor_npub: actor, + deletion_event_id: event_id, + deletion_event_json: r#"{"kind":30078}"#, + expected_state_digest: digest, + server_identity: SERVER, + deleted_at: "2026-06-24T00:00:00Z", + confirmation_current: true, + } +} + +fn preview(store: &BrainStore, brain_id: &BrainId) -> BrainDeletionPreview { + store + .brain_deletion_preview(brain_id, &user(ADMIN), SERVER) + .unwrap() +} + +#[test] +fn deletion_removes_owned_rows_retires_the_id_and_leaves_other_brains_identical() { + let temp = TempDir::new().unwrap(); + let db = temp.path().join("deletion.sqlite3"); + let mut store = BrainStore::open(&db).unwrap(); + populate(&mut store); + let admin = user(ADMIN); + let content_before = brain_scoped_rows(&store.conn, &content()); + let aliases_before = store + .conn + .query_row("SELECT COUNT(*) FROM identity_aliases", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + + let scope = preview(&store, &v1()); + assert_eq!(scope.name, "Acme"); + assert_eq!(scope.folder_count, 3); + assert_eq!(scope.object_count, 2); + assert_eq!(scope.member_count, 2); + assert_eq!(scope.admin_count, 1); + assert_eq!(scope.guest_count, 1); + assert_eq!(scope.pending_invitation_count, 1); + assert_eq!(scope.open_invite_token_count, 1); + assert!(scope.blocking_mounts.is_empty()); + assert_eq!(scope.retired_mount_record_count, 0); + assert!(scope.state_digest.starts_with(BRAIN_DELETION_DIGEST_PREFIX)); + assert_eq!( + scope.state_digest.len(), + BRAIN_DELETION_DIGEST_PREFIX.len() + 64 + ); + // The digest binds the server identity. + assert_ne!( + store + .brain_deletion_preview(&v1(), &admin, "https://other.example.test") + .unwrap() + .state_digest, + scope.state_digest + ); + + let outcome = store + .delete_organization_brain(&request( + &v1(), + &admin, + "event-delete-v1", + &scope.state_digest, + )) + .unwrap(); + let BrainDeletionOutcome::Deleted { receipt, audience } = outcome else { + panic!("first deletion must delete"); + }; + assert_eq!( + audience.into_iter().collect::>(), + BTreeSet::from([user(ADMIN), user(MEMBER), user(GUEST)]) + ); + assert_eq!(receipt.brain_id, v1()); + assert_eq!(receipt.deletion_event_id, "event-delete-v1"); + assert_eq!(receipt.actor_npub, admin); + assert_eq!(receipt.state_digest, scope.state_digest); + assert_eq!((receipt.folder_count, receipt.object_count), (3, 2)); + + assert!(brain_scoped_rows(&store.conn, &v1()).is_empty()); + assert_eq!(brain_scoped_rows(&store.conn, &content()), content_before); + assert_eq!( + store + .conn + .query_row("SELECT COUNT(*) FROM identity_aliases", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + aliases_before + ); + assert_eq!( + store.load_brain(&v1()).unwrap_err(), + StoreError::MissingBrain { + brain_id: "acme".to_owned() + } + ); + let visible = |store: &BrainStore, npub: &str| { + store + .list_visible_brains(&user(npub)) + .unwrap() + .into_iter() + .map(|brain| brain.id.to_string()) + .collect::>() + }; + assert_eq!(visible(&store, ADMIN), vec!["content-brain"]); + assert_eq!(visible(&store, MEMBER), vec!["content-brain"]); + assert!(visible(&store, GUEST).is_empty()); + assert!(visible(&store, INVITEE).is_empty()); + // Invitation and Invite Token answer exactly like unknown ones. + let unknown_token = store + .redeem_brain_invite_token(&"b".repeat(64), &user(INVITEE), NOW) + .unwrap_err(); + assert_eq!( + store + .redeem_brain_invite_token(&"a".repeat(64), &user(INVITEE), NOW) + .unwrap_err(), + unknown_token + ); + assert!(store.load_brain_invitation_by_code("code-v1").is_err()); + // A stale client write cannot recreate content. + assert_eq!( + store + .submit_sync_record( + &v1(), + &revision_record("event-stale-write", "obj_000000000003", 1, None, "stale"), + ) + .unwrap_err(), + StoreError::MissingBrain { + brain_id: "acme".to_owned() + } + ); + // Recreating the id fails exactly like creating over a live Brain. + let recreate = bootstrap_organization_brain("acme", "Acme", ADMIN).unwrap(); + let live_duplicate = bootstrap_organization_brain("content-brain", "Different", ADMIN).unwrap(); + assert_eq!( + store + .create_brain_bootstrap( + &recreate, + &grants_for_required(&recreate.required_key_grants, ADMIN) + ) + .unwrap_err(), + StoreError::DuplicateId { + field: "brain_id", + value: "acme".to_owned() + } + ); + assert!(matches!( + store + .create_brain_bootstrap( + &live_duplicate, + &grants_for_required(&live_duplicate.required_key_grants, ADMIN) + ) + .unwrap_err(), + StoreError::DuplicateId { + field: "brain_id", + .. + } + )); + let personal = bootstrap_personal_brain("acme", "Personal", "npub-owner").unwrap(); + assert!(matches!( + store + .create_personal_brain_bootstrap( + &personal, + &[], + &user("npub-agent"), + &user("npub-owner"), + NOW, + ) + .unwrap_err(), + StoreError::DuplicateId { + field: "brain_id", + .. + } + )); + drop(store); + + // The deletion record survives reopen: exact retries replay the same + // receipt and change nothing; anything else sees an unknown Brain. + let mut store = BrainStore::open(&db).unwrap(); + let digest = scope.state_digest.as_str(); + assert_eq!( + store + .delete_organization_brain(&request(&v1(), &admin, "event-delete-v1", digest)) + .unwrap(), + BrainDeletionOutcome::Replayed { + receipt: receipt.clone() + } + ); + let acme = v1(); + let mut expired = request(&acme, &admin, "event-delete-v1", digest); + expired.confirmation_current = false; + assert_eq!( + store.delete_organization_brain(&expired).unwrap(), + BrainDeletionOutcome::Replayed { + receipt: receipt.clone() + } + ); + for (actor, event_id) in [ + (ADMIN, "event-delete-v1-again"), + (MEMBER, "event-delete-v1"), + ] { + assert_eq!( + store + .delete_organization_brain(&request(&v1(), &user(actor), event_id, digest)) + .unwrap_err(), + StoreError::MissingBrain { + brain_id: "acme".to_owned() + } + ); + } + assert_eq!( + store + .brain_deletion_replay(&v1(), "event-delete-v1", &admin) + .unwrap(), + Some(receipt) + ); + assert_eq!( + store + .brain_deletion_replay(&v1(), "event-delete-v1", &user(MEMBER)) + .unwrap(), + None + ); + for npub in [ADMIN, MEMBER, GUEST] { + assert_eq!( + store.deleted_brain_notice(&v1(), &user(npub)).unwrap(), + Some(DeletedBrainNotice { + brain_id: v1(), + deleted_at: "2026-06-24T00:00:00Z".to_owned() + }) + ); + } + for npub in [INVITEE, "npub-outsider"] { + assert_eq!( + store.deleted_brain_notice(&v1(), &user(npub)).unwrap(), + None + ); + } + assert_eq!( + store + .deleted_brain_notice(&BrainId::new("never-existed").unwrap(), &admin) + .unwrap(), + None + ); +} + +#[test] +fn a_stale_preview_is_refused_even_when_every_count_is_unchanged() { + let mut store = BrainStore::open_in_memory().unwrap(); + populate(&mut store); + let admin = user(ADMIN); + let first = preview(&store, &v1()); + + // Edit an existing page: same Folders, same page count, new revision. + store + .submit_sync_record( + &v1(), + &revision_record( + "event-v1-page-1-edit", + "obj_000000000001", + 2, + Some(1), + "edited", + ), + ) + .unwrap(); + let edited = preview(&store, &v1()); + assert_eq!( + ( + edited.folder_count, + edited.object_count, + edited.member_count + ), + (first.folder_count, first.object_count, first.member_count) + ); + assert_ne!(edited.state_digest, first.state_digest); + let before = brain_scoped_rows(&store.conn, &v1()); + assert_eq!( + store + .delete_organization_brain(&request(&v1(), &admin, "event-stale", &first.state_digest)) + .unwrap_err(), + StoreError::Conflict { + reason: "Brain changed after its deletion preview; nothing was deleted. Review a fresh preview and confirm again".to_owned(), + current_revision: None, + } + ); + assert_eq!(brain_scoped_rows(&store.conn, &v1()), before); + assert!( + store + .brain_deletion_replay(&v1(), "event-stale", &admin) + .unwrap() + .is_none() + ); + + // Role, token and invitation changes each move the digest with no + // change to the Folder and page counts. + let mut digests = BTreeSet::from([edited.state_digest.clone()]); + store.add_admin(&v1(), &user(MEMBER)).unwrap(); + assert!(digests.insert(preview(&store, &v1()).state_digest)); + store + .revoke_brain_invite_token(&v1(), &"a".repeat(64), &admin, NOW) + .unwrap(); + assert!(digests.insert(preview(&store, &v1()).state_digest)); + store + .revoke_brain_invitation(&v1(), "invitation-v1", &admin, NOW) + .unwrap(); + assert!(digests.insert(preview(&store, &v1()).state_digest)); + + let fresh = preview(&store, &v1()); + assert!(matches!( + store + .delete_organization_brain(&request(&v1(), &admin, "event-fresh", &fresh.state_digest)) + .unwrap(), + BrainDeletionOutcome::Deleted { .. } + )); +} + +#[test] +fn members_outsiders_personal_brains_and_lost_admins_cannot_delete() { + let mut store = BrainStore::open_in_memory().unwrap(); + populate(&mut store); + bootstrap_personal_named( + &mut store, + "personal-owner", + "npub-owner", + "npub-agent", + NOW, + ); + let admin_scope = preview(&store, &v1()); + let v1_before = brain_scoped_rows(&store.conn, &v1()); + let personal = BrainId::new("personal-owner").unwrap(); + let personal_before = brain_scoped_rows(&store.conn, &personal); + + for actor in [MEMBER, GUEST, "npub-outsider"] { + let refused = StoreError::Conflict { + reason: "Brain deletion requires current Brain admin standing; nothing was deleted" + .to_owned(), + current_revision: None, + }; + assert_eq!( + store + .brain_deletion_preview(&v1(), &user(actor), SERVER) + .unwrap_err(), + refused + ); + assert_eq!( + store + .delete_organization_brain(&request( + &v1(), + &user(actor), + "event-not-admin", + &admin_scope.state_digest + )) + .unwrap_err(), + refused + ); + } + let personal_refused = StoreError::BrokenInvariant { + reason: "Personal Brains cannot be deleted; only Organization Brains can".to_owned(), + }; + for actor in ["npub-owner", "npub-agent"] { + assert_eq!( + store + .brain_deletion_preview(&personal, &user(actor), SERVER) + .unwrap_err(), + personal_refused + ); + assert_eq!( + store + .delete_organization_brain(&request( + &personal, + &user(actor), + "event-personal", + &admin_scope.state_digest + )) + .unwrap_err(), + personal_refused + ); + } + assert_eq!(brain_scoped_rows(&store.conn, &personal), personal_before); + + // A signed confirmation older than the server accepts deletes nothing. + let (acme, admin) = (v1(), user(ADMIN)); + let mut expired = request(&acme, &admin, "event-expired", &admin_scope.state_digest); + expired.confirmation_current = false; + assert_eq!( + store.delete_organization_brain(&expired).unwrap_err(), + StoreError::Conflict { + reason: "Brain deletion confirmation expired; nothing was deleted. Review a fresh preview and confirm again".to_owned(), + current_revision: None, + } + ); + assert_eq!(brain_scoped_rows(&store.conn, &v1()), v1_before); + + // A second admin previews, then loses the role before confirming. + store.add_admin(&v1(), &user(MEMBER)).unwrap(); + let member_scope = store + .brain_deletion_preview(&v1(), &user(MEMBER), SERVER) + .unwrap(); + store.remove_admin_keeping_wraps(&v1(), &user(MEMBER)); + assert!(matches!( + store + .delete_organization_brain(&request( + &v1(), + &user(MEMBER), + "event-demoted", + &member_scope.state_digest + )) + .unwrap_err(), + StoreError::Conflict { .. } + )); + assert!(store.load_brain(&v1()).is_ok()); +} + +#[test] +fn a_write_through_another_connection_invalidates_the_confirmation() { + let temp = TempDir::new().unwrap(); + let db = temp.path().join("concurrent.sqlite3"); + let mut deleting = BrainStore::open(&db).unwrap(); + populate(&mut deleting); + let mut writer = BrainStore::open(&db).unwrap(); + let scope = preview(&deleting, &v1()); + writer + .submit_sync_record( + &v1(), + &revision_record( + "event-concurrent", + "obj_000000000002", + 2, + Some(1), + "concurrent", + ), + ) + .unwrap(); + assert!(matches!( + deleting + .delete_organization_brain(&request( + &v1(), + &user(ADMIN), + "event-after-concurrent", + &scope.state_digest + )) + .unwrap_err(), + StoreError::Conflict { .. } + )); + assert!(deleting.load_brain(&v1()).is_ok()); +} + +#[test] +fn active_mounts_block_both_directions_and_revoked_artifacts_are_retired() { + let mut store = BrainStore::open_in_memory().unwrap(); + populate(&mut store); + let marketing_admin = user("npub-marketing"); + bootstrap_org_named( + &mut store, + "marketing-comms-brain", + "Marketing", + marketing_admin.as_str(), + ); + let marketing = BrainId::new("marketing-comms-brain").unwrap(); + let admin = user(ADMIN); + // acme's second admin controls the incoming Mounts; it is only a Member + // of content-brain, so the Mount alone gives it the source Folder. + let controller = user(MEMBER); + create_folder_in(&mut store, &v1(), "outbound", FolderAccessMode::AdminOnly); + store.add_admin(&v1(), &controller).unwrap(); + create_folder_in( + &mut store, + &content(), + "shared-in", + FolderAccessMode::AdminOnly, + ); + create_folder_in( + &mut store, + &content(), + "offered-in", + FolderAccessMode::AdminOnly, + ); + + // Outgoing: acme/outbound mounted into marketing. + store + .create_shared_folder_invitation( + &v1(), + &FolderId::new("outbound").unwrap(), + &marketing, + "offer-out", + &marketing_admin, + &admin, + "/v1/mount-offers/offer-out/accept", + &grant( + "grant-offer-out", + "outbound", + 1, + ADMIN, + marketing_admin.as_str(), + ), + EXPIRES, + NOW, + ) + .unwrap(); + accept_mount_for_test( + &mut store, + "offer-out", + &marketing_admin, + "mount-out", + "projection-out", + &[], + NOW, + ) + .unwrap(); + // Incoming: content-brain/shared-in mounted into acme, plus a pending + // offer of content-brain/offered-in to acme. + for (offer, folder) in [("offer-in", "shared-in"), ("offer-pending", "offered-in")] { + store + .create_shared_folder_invitation( + &content(), + &FolderId::new(folder).unwrap(), + &v1(), + offer, + &controller, + &admin, + &format!("/v1/mount-offers/{offer}/accept"), + &grant(&format!("grant-{offer}"), folder, 1, ADMIN, MEMBER), + EXPIRES, + NOW, + ) + .unwrap(); + } + accept_mount_for_test( + &mut store, + "offer-in", + &controller, + "mount-in", + "projection-in", + &[], + NOW, + ) + .unwrap(); + + let blocked = store.brain_deletion_preview(&v1(), &admin, SERVER).unwrap(); + assert_eq!( + blocked + .blocking_mounts + .iter() + .map(|mount| (mount.connection_id.as_str(), mount.direction)) + .collect::>(), + vec![ + ("mount-in", SharedFolderDirection::Destination), + ("mount-out", SharedFolderDirection::Source), + ] + ); + let before = brain_scoped_rows(&store.conn, &v1()); + assert_eq!( + store + .delete_organization_brain(&request(&v1(), &admin, "event-blocked", &blocked.state_digest)) + .unwrap_err(), + StoreError::Conflict { + reason: "Brain has 2 active cross-Brain Mount(s) (mount-in, mount-out); revoke them before deleting the Brain. Nothing was deleted".to_owned(), + current_revision: None, + } + ); + assert_eq!(brain_scoped_rows(&store.conn, &v1()), before); + + // Each source admin revokes its Mount, rotating the source Folder Key + // to everyone still entitled. + for connection in ["mount-in", "mount-out"] { + let mount = store.load_shared_folder_connection(connection).unwrap(); + let source = store.load_brain(&mount.source_brain_id).unwrap(); + let folder = source + .brain + .folders + .iter() + .find(|folder| folder.id == mount.source_folder_id) + .unwrap(); + let grants = folder_key_required_recipients(&source, folder) + .unwrap() + .difference(&mount.managed_access_npubs) + .map(|recipient| { + grant( + &format!("grant-{}-v2-{recipient}", folder.id), + folder.id.as_str(), + 2, + ADMIN, + recipient.as_str(), + ) + }) + .collect::>(); + revoke_mount_for_test(&mut store, connection, &admin, 2, &grants, &[], NOW).unwrap(); + } + let semantic = |store: &BrainStore, brain: &BrainId| { + brain_scoped_rows(&store.conn, brain) + .into_iter() + .filter(|row| { + !row.starts_with("shared_folder_") + && !row.starts_with("folder_mounts|") + && !row.starts_with("brain_capacity_counts|") + }) + .collect::>() + }; + let content_before = semantic(&store, &content()); + let marketing_before = semantic(&store, &marketing); + let ready = store.brain_deletion_preview(&v1(), &admin, SERVER).unwrap(); + assert!(ready.blocking_mounts.is_empty()); + // Two revoked Mounts plus the pending incoming offer. + assert_eq!(ready.retired_mount_record_count, 3); + assert!(matches!( + store + .delete_organization_brain(&request( + &v1(), + &admin, + "event-after-revoke", + &ready.state_digest + )) + .unwrap(), + BrainDeletionOutcome::Deleted { .. } + )); + // Other Brains keep every Folder, page, key grant and role; only the + // retired Mount artifacts naming acme are gone, so no stale offer can + // restore access. + assert_eq!(semantic(&store, &content()), content_before); + assert_eq!(semantic(&store, &marketing), marketing_before); + for offer in ["offer-out", "offer-in", "offer-pending"] { + assert!(store.load_shared_folder_invitation(offer).is_err()); + } + for connection in ["mount-in", "mount-out"] { + assert!(store.load_shared_folder_connection(connection).is_err()); + } + assert!(brain_scoped_rows(&store.conn, &v1()).is_empty()); +} + +#[test] +fn deletion_scope_names_every_table_in_the_schema() { + let store = BrainStore::open_in_memory().unwrap(); + let mut stmt = store + .conn + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .unwrap(); + let tables = stmt + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + let scoped = brain_deletion_scope_tables(); + let unclassified = tables + .iter() + .filter(|table| { + !scoped.contains(&table.as_str()) + && !BRAIN_DELETION_UNSCOPED_TABLES.contains(&table.as_str()) + }) + .collect::>(); + assert!( + unclassified.is_empty(), + "decide whether Brain deletion owns these tables: {unclassified:?}" + ); + for table in scoped.iter().chain(BRAIN_DELETION_UNSCOPED_TABLES) { + assert!(tables.contains(*table), "{table} is not in the schema"); + } +} From f0b6946f50194588f24e7f69c141849e15962607 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 25 Oct 2026 22:36:06 -0500 Subject: [PATCH 2/8] Add fbrain brain delete and stop Working Trees of deleted Brains fbrain brain delete previews the server scope and digest; --confirm signs and deletes exactly that state, with one exact retry after a transport failure. There is no flag that skips the preview. When the server reports a Brain deleted, its Working Tree records the terminal state with the unsynced paths it kept in place, and sync, daemon watch and the supervisor stop contacting the server for it. The process acceptance test drives the built fbrain against a real file-backed server with synthetic V1, content-brain and marketing-comms-brain: authority, stale previews with unchanged counts, mounts in both directions, deletion by an Agent admin, invitation and token failure, recreate refusal, a dirty stale client, restart durability, and a restore of the pre-deletion backup onto an empty target. The FiniteBrain skill routes Brain deletion through an exact-scope user confirmation. Co-Authored-By: Claude Opus 5.5 --- .../finite-brain-cli/src/brain_deletion.rs | 360 ++++++ .../crates/finite-brain-cli/src/error.rs | 35 + .../crates/finite-brain-cli/src/http.rs | 21 +- .../crates/finite-brain-cli/src/lib.rs | 18 +- .../crates/finite-brain-cli/src/models.rs | 18 + .../crates/finite-brain-cli/src/state.rs | 9 +- .../src/tests/brain_deletion_tests.rs | 94 ++ .../brain_deletion_process_acceptance.rs | 1089 +++++++++++++++++ .../src/tests/brain_deletion_tests.rs | 2 +- .../docs/runbooks/brain-restore-drill.md | 11 + .../software-development/finitebrain/SKILL.md | 7 +- .../references/destructive-operations.md | 44 +- .../finitebrain/references/fbrain-cli.md | 15 +- .../finitebrain/references/recovery.md | 6 + 14 files changed, 1713 insertions(+), 16 deletions(-) create mode 100644 finite-brain/crates/finite-brain-cli/src/brain_deletion.rs create mode 100644 finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs create mode 100644 finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs diff --git a/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs b/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs new file mode 100644 index 000000000..e337cd99a --- /dev/null +++ b/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs @@ -0,0 +1,360 @@ +//! `fbrain brain delete`: preview an Organization Brain deletion, then confirm +//! exactly the previewed state. Also the client side of a deleted Brain: a +//! Working Tree whose Brain is gone stops syncing for good and leaves every +//! file, including unsynced local changes, where it is. + +use std::io::Write; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::*; + +/// Unsynced paths named in the terminal message; the full list stays in +/// Agent State. +const LISTED_UNSYNCED_PATHS: usize = 20; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrainDeletionPreviewView { + pub(crate) server: String, + pub(crate) brain_id: String, + pub(crate) name: String, + pub(crate) kind: String, + pub(crate) folder_count: usize, + pub(crate) page_count: usize, + pub(crate) member_count: usize, + pub(crate) admin_count: usize, + pub(crate) guest_count: usize, + pub(crate) pending_invitation_count: usize, + pub(crate) open_invite_token_count: usize, + pub(crate) blocking_mounts: Vec, + pub(crate) retired_mount_record_count: usize, + pub(crate) deletable: bool, + pub(crate) state_digest: String, + pub(crate) confirmation_max_age_seconds: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlockingMountView { + pub(crate) connection_id: String, + pub(crate) direction: String, + pub(crate) source_brain_id: String, + pub(crate) source_folder_id: String, + pub(crate) destination_brain_id: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrainDeleteResponseView { + receipt: BrainDeletionReceiptView, + duplicate: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrainDeletionReceiptView { + brain_id: String, + deletion_event_id: String, + actor_npub: String, + deleted_at: String, + state_digest: String, + folder_count: usize, + page_count: usize, +} + +/// `fbrain brain delete [--confirm ]`. Without +/// `--confirm` it only previews. There is deliberately no flag that skips +/// the preview: the confirmation names one exact server-computed state. +pub(crate) fn brain_delete( + args: &[String], + env: &CliEnvironment, + json: bool, + output: &mut W, +) -> Result<(), CliError> { + for flag in ["--force", "--yes", "-y"] { + if args.iter().any(|arg| arg == flag) { + return Err(CliError::InvalidInput(format!( + "`fbrain brain delete` has no {flag}; preview with `fbrain brain delete `, then confirm that exact state with --confirm " + ))); + } + } + let brain_id = positional_values(args) + .get(1) + .cloned() + .ok_or(CliError::MissingArgument("brain-id"))?; + BrainId::new(brain_id.clone()).map_err(|error| CliError::InvalidInput(error.to_string()))?; + let confirmed = option_value(args, "--confirm"); + let preview = fetch_brain_deletion_preview(env, args, &brain_id)?; + let Some(confirmed) = confirmed else { + return write_preview(output, json, &preview); + }; + if preview.state_digest != confirmed { + return Err(CliError::InvalidInput(format!( + "Brain {brain_id} changed since the preview you confirmed; nothing was deleted. Run `fbrain brain delete {brain_id}` to review the current preview, then confirm it" + ))); + } + if !preview.deletable { + return Err(CliError::InvalidInput(format!( + "Brain {brain_id} still has active cross-Brain Mounts ({}); revoke them with `fbrain mount revoke ` first. Nothing was deleted", + mount_ids(&preview) + ))); + } + let event = admin_access_change_event_with_note( + env, + &brain_id, + AdminAccessAction::DeleteBrain, + None, + None, + None, + Some(&confirmed), + )?; + let body = serde_json::json!({ "deletionEvent": event }); + let path = format!("/v1/brains/{brain_id}"); + // A transport failure leaves the outcome unknown. One exact retry of the + // same signed deletion either deletes or answers the accepted receipt. + let response = match signed_json_request(env, args, "DELETE", &path, Some(body.clone())) { + Err(CliError::Http(_)) => signed_json_request(env, args, "DELETE", &path, Some(body)), + other => other, + } + .map_err(|error| deletion_request_error(error, &brain_id))?; + let response: BrainDeleteResponseView = serde_json::from_value(response)?; + if let Some(root) = find_agent_state(&env.cwd)? + && read_agent_state(&root)?.brain_id == brain_id + { + record_brain_deleted(env, &root, &brain_id, &response.receipt.deleted_at)?; + } + if json { + write_json( + output, + &serde_json::json!({ + "state": "deleted", + "duplicate": response.duplicate, + "server": preview.server, + "name": preview.name, + "receipt": response.receipt, + }), + ) + } else { + let receipt = &response.receipt; + writeln!( + output, + "deleted Organization Brain \"{}\" ({}) on {} at {}: {} folders, {} pages", + preview.name, + receipt.brain_id, + preview.server, + receipt.deleted_at, + receipt.folder_count, + receipt.page_count + )?; + writeln!( + output, + "receipt deletionEvent={} actor={} stateDigest={}{}", + receipt.deletion_event_id, + receipt.actor_npub, + receipt.state_digest, + if response.duplicate { + " (already accepted)" + } else { + "" + } + )?; + writeln!( + output, + "Local Working Trees, exports and other offline copies are not erased; remove them when you no longer need them" + )?; + Ok(()) + } +} + +fn fetch_brain_deletion_preview( + env: &CliEnvironment, + args: &[String], + brain_id: &str, +) -> Result { + let response = signed_json_request( + env, + args, + "GET", + &format!("/v1/brains/{brain_id}/deletion-preview"), + None, + ) + .map_err(|error| deletion_request_error(error, brain_id))?; + Ok(serde_json::from_value(response)?) +} + +fn deletion_request_error(error: CliError, brain_id: &str) -> CliError { + if let Some((_, deleted_at)) = brain_deleted_notice(&error) { + return CliError::InvalidInput(format!( + "Brain {brain_id} was already permanently deleted at {deleted_at}" + )); + } + unsupported_on_older_server(error, || { + CliError::Unsupported( + "this Brain server cannot delete Brains; upgrade the Brain server".to_owned(), + ) + }) +} + +fn mount_ids(preview: &BrainDeletionPreviewView) -> String { + preview + .blocking_mounts + .iter() + .map(|mount| mount.connection_id.as_str()) + .collect::>() + .join(", ") +} + +fn write_preview( + output: &mut W, + json: bool, + preview: &BrainDeletionPreviewView, +) -> Result<(), CliError> { + let confirm_command = format!( + "fbrain brain delete {} --confirm {}", + preview.brain_id, preview.state_digest + ); + if json { + return write_json( + output, + &serde_json::json!({ + "state": if preview.deletable { "confirmation-required" } else { "blocked" }, + "preview": preview, + "confirmCommand": preview.deletable.then_some(confirm_command), + }), + ); + } + writeln!( + output, + "Delete Organization Brain \"{}\" ({}) on {}?", + preview.name, preview.brain_id, preview.server + )?; + writeln!( + output, + "- {} folders and {} pages, with their server-side history", + preview.folder_count, preview.page_count + )?; + writeln!( + output, + "- access for {} members ({} admins) and {} guests", + preview.member_count, preview.admin_count, preview.guest_count + )?; + writeln!( + output, + "- {} pending invitations and {} open invite tokens stop working", + preview.pending_invitation_count, preview.open_invite_token_count + )?; + if preview.retired_mount_record_count > 0 { + writeln!( + output, + "- {} pending or revoked mount records naming this Brain are removed", + preview.retired_mount_record_count + )?; + } + if !preview.deletable { + writeln!( + output, + "Blocked: revoke these active mounts first, then preview again:" + )?; + for mount in &preview.blocking_mounts { + writeln!( + output, + "- {} {} {}/{} -> {}: fbrain mount revoke {}", + mount.connection_id, + mount.direction, + mount.source_brain_id, + mount.source_folder_id, + mount.destination_brain_id, + mount.connection_id + )?; + } + writeln!(output, "Nothing was deleted.")?; + return Ok(()); + } + writeln!( + output, + "This deletes the Brain for everyone and cannot be undone here; only an operator restore from backup can recover it. Local Working Trees and exported copies are not erased." + )?; + writeln!( + output, + "To delete exactly this state within {} minutes, run:\n {confirm_command}", + preview.confirmation_max_age_seconds / 60 + )?; + writeln!(output, "Nothing was deleted.")?; + Ok(()) +} + +/// The `(brain id, deletedAt)` of a Brain server's deleted-Brain answer. +pub(crate) fn brain_deleted_notice(error: &CliError) -> Option<(String, String)> { + match error { + CliError::SyncStage { source, .. } => brain_deleted_notice(source), + CliError::HttpStatus { status: 410, body } => { + let body = serde_json::from_str::(body).ok()?; + if body["code"] != "brain_deleted" { + return None; + } + Some(( + body["brainDeleted"]["brainId"].as_str()?.to_owned(), + body["brainDeleted"]["deletedAt"].as_str()?.to_owned(), + )) + } + _ => None, + } +} + +/// Mark the Working Tree at `root` as belonging to a deleted Brain: it never +/// syncs again, and its files, including unsynced changes, stay in place. +/// Returns the terminal error every later sync answers with. +pub(crate) fn record_brain_deleted( + env: &CliEnvironment, + root: &Path, + brain_id: &str, + deleted_at: &str, +) -> Result { + let unsynced_paths = pending_working_tree_change_paths(root)?; + let mut tree_env = env.clone(); + tree_env.cwd = root.to_path_buf(); + let mut recorded = None; + mutate_agent_state(&tree_env, |state, now| { + let deleted = state + .brain_deleted + .get_or_insert_with(|| BrainDeletedState { + deleted_at: deleted_at.to_owned(), + detected_at: now.clone(), + unsynced_paths, + }) + .clone(); + let error = brain_deleted_error(brain_id, &deleted); + state.sync.status = AgentSyncStatus::Blocked { + reason: error.to_string(), + }; + state.daemon.last_error = Some(error.to_string()); + state.add_activity( + now, + "brain.deleted", + format!( + "Brain was permanently deleted on the server at {}; sync stopped and local files were left in place", + deleted.deleted_at + ), + ); + recorded = Some(error); + Ok(()) + })?; + recorded.ok_or_else(|| CliError::InvalidInput("Agent State was not updated".to_owned())) +} + +/// The terminal error for a Working Tree whose Brain was deleted. +pub(crate) fn brain_deleted_error(brain_id: &str, deleted: &BrainDeletedState) -> CliError { + CliError::BrainDeleted { + brain_id: brain_id.to_owned(), + deleted_at: deleted.deleted_at.clone(), + unsynced_paths: deleted + .unsynced_paths + .iter() + .take(LISTED_UNSYNCED_PATHS) + .cloned() + .collect(), + unsynced_count: deleted.unsynced_paths.len(), + } +} diff --git a/finite-brain/crates/finite-brain-cli/src/error.rs b/finite-brain/crates/finite-brain-cli/src/error.rs index 3cfb4b18e..c8371fc8e 100644 --- a/finite-brain/crates/finite-brain-cli/src/error.rs +++ b/finite-brain/crates/finite-brain-cli/src/error.rs @@ -58,6 +58,14 @@ pub enum CliError { MissingArgument(&'static str), NotFound(String), Unsupported(String), + /// The Working Tree's Brain was permanently deleted on the server. + BrainDeleted { + brain_id: String, + deleted_at: String, + /// The first unsynced local paths, kept in place. + unsynced_paths: Vec, + unsynced_count: usize, + }, } impl fmt::Display for CliError { @@ -122,6 +130,33 @@ impl fmt::Display for CliError { Self::MissingArgument(argument) => write!(f, "missing required argument: {argument}"), Self::NotFound(item) => write!(f, "not found: {item}"), Self::Unsupported(reason) => write!(f, "unsupported: {reason}"), + Self::BrainDeleted { + brain_id, + deleted_at, + unsynced_paths, + unsynced_count, + } => { + write!( + f, + "Brain {brain_id} was permanently deleted on the server at {deleted_at}; this Working Tree no longer syncs and its files were left in place" + )?; + if *unsynced_count > 0 { + write!( + f, + ". {unsynced_count} unsynced local change(s) were kept here and not uploaded anywhere: {}{}", + unsynced_paths.join(", "), + if *unsynced_count > unsynced_paths.len() { + ", ..." + } else { + "" + } + )?; + } + write!( + f, + ". Copy anything you still need, then remove the Working Tree" + ) + } } } } diff --git a/finite-brain/crates/finite-brain-cli/src/http.rs b/finite-brain/crates/finite-brain-cli/src/http.rs index b119803c3..863905044 100644 --- a/finite-brain/crates/finite-brain-cli/src/http.rs +++ b/finite-brain/crates/finite-brain-cli/src/http.rs @@ -7,9 +7,10 @@ use std::time::Duration; use crate::{ AgentSyncStatus, CliEnvironment, CliError, HealthCheck, HttpResponse, SyncOnceReport, - find_agent_state, load_signer, mutate_agent_state, option_value, - pending_working_tree_change_paths, read_agent_state, reconcile_local_search_paths, - reconcile_search_changes, run_working_tree_sync, signed_http_auth_header, + brain_deleted_error, brain_deleted_notice, find_agent_state, load_signer, mutate_agent_state, + option_value, pending_working_tree_change_paths, read_agent_state, + reconcile_local_search_paths, reconcile_search_changes, record_brain_deleted, + run_working_tree_sync, signed_http_auth_header, }; pub(crate) const FINITE_BRAIN_DEVELOPMENT_HTTP_HOST_ENV: &str = @@ -175,6 +176,14 @@ fn sync_once_with_local_paths_holding_lock( discovered_local_paths: Option>, root: Option, ) -> Result { + // A deleted Brain is terminal: never contact the server again from this + // Working Tree, and never move or upload its files. + if let Some(root) = root.as_deref() { + let state = read_agent_state(root)?; + if let Some(deleted) = state.brain_deleted.as_ref() { + return Err(brain_deleted_error(&state.brain_id, deleted)); + } + } let local_paths = discovered_local_paths.map_or_else( || { root.as_deref() @@ -184,6 +193,12 @@ fn sync_once_with_local_paths_holding_lock( |paths| Ok(Some(paths)), ); let report = run_working_tree_sync(env, args, activity_kind); + if let (Err(error), Some(root)) = (&report, root.as_deref()) + && let Some((brain_id, deleted_at)) = brain_deleted_notice(error) + && read_agent_state(root)?.brain_id == brain_id + { + return Err(record_brain_deleted(env, root, &brain_id, &deleted_at)?); + } if report.as_ref().is_err_and(is_brain_access_loss) { let _ = mutate_agent_state(env, |state, now| { state.sync.status = AgentSyncStatus::PausedAccessRevoked; diff --git a/finite-brain/crates/finite-brain-cli/src/lib.rs b/finite-brain/crates/finite-brain-cli/src/lib.rs index 5c35514b7..8d08ade90 100644 --- a/finite-brain/crates/finite-brain-cli/src/lib.rs +++ b/finite-brain/crates/finite-brain-cli/src/lib.rs @@ -4,6 +4,7 @@ mod access_report; mod admin; mod admin_demotion; mod args; +mod brain_deletion; mod clock; mod embedding_provider; mod environment; @@ -36,6 +37,7 @@ pub use models::{ActivityEntry, ConflictEntry, ConflictState}; pub(crate) use admin::*; pub(crate) use admin_demotion::*; pub(crate) use args::*; +pub(crate) use brain_deletion::*; pub(crate) use clock::*; pub(crate) use folder_key_rotation::*; pub(crate) use http::*; @@ -164,7 +166,7 @@ where fn help(output: &mut W) -> Result<(), CliError> { writeln!( output, - "fbrain [--config-dir ] doctor\nrepair\nauth status|import [--file ]|login |redeem \nsigner status|public-key|sign|encrypt|decrypt\ndaemon status|start|stop|logs|tick|watch|supervise [--working-tree-root ]\nsync status|now [--summary]\nopen personal [path]\nopen [path]\nstatus [--json]\nconflicts\nresolve \nsearch [--folder ...] [--limit <1-50>] [--lexical-only] [--json]\nsearch-index status [--folder ...]|enable --folder |disable --folder [--json]\nactivity\nwiki check\naccess explain|list|summary [--brain ]\nbrain list|create organization |rename [--brain ]|personal-agent-consent --owner |metadata|export\nfolder create |list|delete\nmount offer create|list|inspect|revoke\nmount accept|list|inspect|revoke\nmount participant add|remove\nadmin member add|remove\nadmin role grant|revoke admin\nadmin folder-access grant|revoke --target \nadmin folder-access rotate --folder \nadmin ensure-access --brain --target \ncollaborator ensure-admin --brain --target \ninvite brain create|list|inspect|accept|revoke\ninvite folder create|list|inspect|accept|revoke\ninvite-token create|list|revoke\ninvite-accept \napprovals list [--brain ] [--all]|approve --id [--brain ]|deny --id [--brain ]\n--skill print the self-contained agent guide" + "fbrain [--config-dir ] doctor\nrepair\nauth status|import [--file ]|login |redeem \nsigner status|public-key|sign|encrypt|decrypt\ndaemon status|start|stop|logs|tick|watch|supervise [--working-tree-root ]\nsync status|now [--summary]\nopen personal [path]\nopen [path]\nstatus [--json]\nconflicts\nresolve \nsearch [--folder ...] [--limit <1-50>] [--lexical-only] [--json]\nsearch-index status [--folder ...]|enable --folder |disable --folder [--json]\nactivity\nwiki check\naccess explain|list|summary [--brain ]\nbrain list|create organization |rename [--brain ]|personal-agent-consent --owner |metadata|export|delete [--confirm ]\nfolder create |list|delete\nmount offer create|list|inspect|revoke\nmount accept|list|inspect|revoke\nmount participant add|remove\nadmin member add|remove\nadmin role grant|revoke admin\nadmin folder-access grant|revoke --target \nadmin folder-access rotate --folder \nadmin ensure-access --brain --target \ncollaborator ensure-admin --brain --target \ninvite brain create|list|inspect|accept|revoke\ninvite folder create|list|inspect|accept|revoke\ninvite-token create|list|revoke\ninvite-accept \napprovals list [--brain ] [--all]|approve --id [--brain ]|deny --id [--brain ]\n--skill print the self-contained agent guide" )?; Ok(()) } @@ -1320,8 +1322,12 @@ fn run_brain_sync_worker( tree_env.cwd = path; run_coalesced_notification_queue_until_cancelled(receiver, &cancelled, |notification| { let result = retry_transient_notification_work(&cancelled, || { - let paused_for_access = read_agent_state(&tree_env.cwd)?.sync.status - == AgentSyncStatus::PausedAccessRevoked; + let state = read_agent_state(&tree_env.cwd)?; + // A deleted Brain is terminal; its Working Tree is never synced. + if state.brain_deleted.is_some() { + return Ok(()); + } + let paused_for_access = state.sync.status == AgentSyncStatus::PausedAccessRevoked; if paused_for_access && notification.reason == "local_updated" { return Ok(()); } @@ -1621,6 +1627,7 @@ fn daemon_watch( retry_backoff_millis = 0; } Err(error) => { + let brain_deleted = matches!(error, CliError::BrainDeleted { .. }); if local_changes_due && env.embedding_provider.is_some() { semantic_refresh_pending = true; mutate_agent_state(env, |state, _| { @@ -1642,6 +1649,9 @@ fn daemon_watch( ); Ok(()) })?; + if brain_deleted { + break; + } } } } else { @@ -2814,6 +2824,7 @@ fn brain( let response = brain_inventory_read(env, args, &path)?; write_command_response(output, json, &response) } + "delete" => brain_delete(args, env, json, output), "export" => { let explicit_brain_id = option_value(args, "--brain").or_else(|| positional_values(args).get(1).cloned()); @@ -4794,6 +4805,7 @@ mod tests { mod access_report_tests; mod admin_demotion_tests; + mod brain_deletion_tests; mod folder_delete_tests; mod folder_key_rotation_tests; mod member_removal_tests; diff --git a/finite-brain/crates/finite-brain-cli/src/models.rs b/finite-brain/crates/finite-brain-cli/src/models.rs index cd8212935..87905f48b 100644 --- a/finite-brain/crates/finite-brain-cli/src/models.rs +++ b/finite-brain/crates/finite-brain-cli/src/models.rs @@ -23,6 +23,20 @@ pub(crate) struct AgentState { pub(crate) activity: Vec, pub(crate) created_at: String, pub(crate) updated_at: String, + /// Set once the server reports this Brain permanently deleted. Sync + /// stops for good; older fbrain versions ignore the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) brain_deleted: Option, +} + +/// Terminal state of a Working Tree whose Brain was deleted on the server. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrainDeletedState { + pub(crate) deleted_at: String, + pub(crate) detected_at: String, + /// Local changes never uploaded before deletion; kept in place. + pub(crate) unsynced_paths: Vec, } impl AgentState { @@ -53,6 +67,7 @@ impl AgentState { activity: Vec::new(), created_at: now.to_owned(), updated_at: now.to_owned(), + brain_deleted: None, } } @@ -397,6 +412,9 @@ pub(crate) struct StatusReport { /// when the server reports the admin-only field for this identity. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) pending_wraps: Option, + /// Present once the server reported this Brain permanently deleted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) brain_deleted: Option, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] diff --git a/finite-brain/crates/finite-brain-cli/src/state.rs b/finite-brain/crates/finite-brain-cli/src/state.rs index b4d9b128f..78fbae064 100644 --- a/finite-brain/crates/finite-brain-cli/src/state.rs +++ b/finite-brain/crates/finite-brain-cli/src/state.rs @@ -7,8 +7,8 @@ use serde::Deserialize; use crate::{ AccessExplanation, AgentState, AgentSyncStatus, AuthStatus, CliEnvironment, CliError, - ConflictState, DaemonStatus, StatusReport, SyncStatus, identity_paths, load_identity_optional, - option_value, timestamp, validate_private_working_tree, + ConflictState, DaemonStatus, StatusReport, SyncStatus, brain_deleted_error, identity_paths, + load_identity_optional, option_value, timestamp, validate_private_working_tree, validate_working_tree_managed_structure, write_json_file, write_private_file_atomic_for_migration, }; @@ -84,6 +84,7 @@ pub(crate) fn status_report(env: &CliEnvironment) -> Result Result Result>, + thread: Option>, +} + +impl Server { + fn start(database: &Path, address: Option) -> Self { + let database = database.to_path_buf(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let thread = thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let bind = address.unwrap_or_else(|| "127.0.0.1:0".parse().unwrap()); + let listener = tokio::net::TcpListener::bind(bind).await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let store = finite_brain_store::BrainStore::open(&database).unwrap(); + let state = finite_brain_server::ServerState::new(store, url.clone()); + ready_tx.send((url, address)).unwrap(); + axum::serve(listener, finite_brain_server::router_with_state(state)) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + }); + let (url, address) = ready_rx.recv().unwrap(); + Self { + url, + address, + shutdown: Some(shutdown_tx), + thread: Some(thread), + } + } + + fn stop(mut self) -> SocketAddr { + self.halt(); + self.address + } + + fn halt(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.halt(); + } +} + +struct Home { + dir: PathBuf, + npub: String, +} + +fn home(scratch: &Path, name: &str, secret: &str) -> Home { + let dir = scratch.join(name); + fs::create_dir_all(&dir).unwrap(); + let secret_file = scratch.join(format!("{name}.secret")); + fs::write(&secret_file, format!("{secret}\n")).unwrap(); + let imported = command(&dir, &dir, "http://127.0.0.1:9") + .args(["auth", "import", "--file", secret_file.to_str().unwrap()]) + .output() + .unwrap(); + assert!(imported.status.success(), "{}", stderr(&imported)); + fs::remove_file(secret_file).unwrap(); + let signer = command(&dir, &dir, "http://127.0.0.1:9") + .args(["signer", "public-key", "--json"]) + .output() + .unwrap(); + let npub = serde_json::from_slice::(&signer.stdout).unwrap()["npub"] + .as_str() + .unwrap() + .to_owned(); + Home { dir, npub } +} + +fn command(home: &Path, cwd: &Path, server: &str) -> Command { + command_with(Path::new(env!("CARGO_BIN_EXE_fbrain")), home, cwd, server) +} + +fn command_with(binary: &Path, home: &Path, cwd: &Path, server: &str) -> Command { + let mut command = Command::new(binary); + command + .current_dir(cwd) + .env_clear() + .env("HOME", home) + .env("FINITE_HOME", home.join("finite-home")) + .env("FBRAIN_CONFIG_DIR", home.join("fbrain-config")) + .env( + "FBRAIN_NOW", + OffsetDateTime::now_utc().format(&Rfc3339).unwrap(), + ) + .env("FINITE_BRAIN_SERVER_URL", server) + .env("FINITE_BRAIN_PUBLIC_BASE_URL", server); + command +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// Run a command that must succeed; returns its `--json` output. +fn ok(home: &Home, cwd: &Path, server: &str, args: &[&str]) -> Value { + let output = command(&home.dir, cwd, server) + .args(args) + .arg("--json") + .output() + .unwrap(); + assert!(output.status.success(), "{args:?}: {}", stderr(&output)); + serde_json::from_slice(&output.stdout).unwrap() +} + +/// Run a command that must fail; returns its error text. +fn refused(home: &Home, cwd: &Path, server: &str, args: &[&str]) -> String { + let output = command(&home.dir, cwd, server).args(args).output().unwrap(); + assert!( + !output.status.success(), + "{args:?} unexpectedly succeeded: {}", + String::from_utf8_lossy(&output.stdout) + ); + stderr(&output) +} + +/// Every row in every table that names `brain_id`, outside the permanent +/// deletion record, as sorted text. +fn brain_rows(database: &Path, brain_id: &str) -> Vec { + let conn = rusqlite::Connection::open(database).unwrap(); + let tables = conn + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + let mut rows = Vec::new(); + for table in tables { + if table == "deleted_brains" || table == "deleted_brain_audience" { + continue; + } + let columns = conn + .prepare(&format!("SELECT name FROM pragma_table_info('{table}')")) + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + let keyed = columns + .iter() + .filter(|column| { + matches!( + column.as_str(), + "brain_id" | "source_brain_id" | "destination_brain_id" | "vault_id" + ) || (table == "brains" && column.as_str() == "id") + }) + .map(|column| format!("\"{column}\" = ?1")) + .collect::>(); + if keyed.is_empty() { + continue; + } + let mut stmt = conn + .prepare(&format!( + "SELECT * FROM \"{table}\" WHERE {}", + keyed.join(" OR ") + )) + .unwrap(); + let width = stmt.column_count(); + let mut found = stmt.query([brain_id]).unwrap(); + while let Some(row) = found.next().unwrap() { + let values = (0..width) + .map(|index| match row.get_ref(index).unwrap() { + rusqlite::types::ValueRef::Text(text) => { + String::from_utf8_lossy(text).into_owned() + } + other => format!("{other:?}"), + }) + .collect::>(); + rows.push(format!("{table}|{}", values.join("|"))); + } + } + rows.sort(); + rows +} + +/// Another Brain keeps every row except the revoked Mount records that named +/// V1 (removed so no stale offer can restore access) and its derived +/// capacity counters. Returns how many retired records were removed. +fn assert_only_retired_mount_records_changed(before: &[String], after: &[String]) -> usize { + let retired = |row: &String| { + (row.starts_with("folder_mounts|") || row.starts_with("shared_folder_")) + && row.split('|').any(|field| field == "v1") + }; + let derived = |row: &String| row.starts_with("brain_capacity_counts|"); + let kept = |rows: &[String]| { + rows.iter() + .filter(|row| !retired(row) && !derived(row)) + .cloned() + .collect::>() + }; + assert_eq!(kept(after), kept(before)); + assert!(!after.iter().any(retired), "{after:#?}"); + before.iter().filter(|row| retired(row)).count() +} + +fn copy_database(from: &Path, to: &Path) { + fs::create_dir_all(to.parent().unwrap()).unwrap(); + for suffix in ["", "-wal", "-shm"] { + let source = PathBuf::from(format!("{}{suffix}", from.display())); + if source.exists() { + fs::copy(&source, format!("{}{suffix}", to.display())).unwrap(); + } + } +} + +/// Boundaries that passed, written as JSON when the report path is set. +struct Evidence { + path: Option, + facts: serde_json::Map, +} + +impl Evidence { + fn pass(&mut self, boundary: &str, detail: Value) { + self.facts.insert(boundary.to_owned(), detail); + } +} + +impl Drop for Evidence { + fn drop(&mut self) { + if let Some(path) = &self.path { + let report = json!({ + "format": "finite.brain.organization-brain-deletion-acceptance.v1", + "status": if thread::panicking() { "failed" } else { "passed" }, + "passedBoundaries": self.facts, + "syntheticOnly": true, + }); + fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap(); + } + } +} + +#[test] +fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { + let scratch = TempDir::new().unwrap(); + let root = scratch.path(); + let mut evidence = Evidence { + path: std::env::var_os("FBRAIN_BRAIN_DELETION_REPORT").map(PathBuf::from), + facts: serde_json::Map::new(), + }; + let admin = home(root, "admin-human", ADMIN_SECRET); + let agent = home(root, "admin-agent", AGENT_SECRET); + let member = home(root, "member", MEMBER_SECRET); + let invitee = home(root, "invitee", INVITEE_SECRET); + let outsider = home(root, "outsider", OUTSIDER_SECRET); + let database = root.join("state/brain.sqlite3"); + fs::create_dir_all(database.parent().unwrap()).unwrap(); + let server = Server::start(&database, None); + let url = server.url.clone(); + let url = url.as_str(); + + // Three synthetic Organization Brains. + for (id, name) in [ + ("v1", "V1"), + ("content-brain", "Content"), + ("marketing-comms-brain", "Marketing Comms"), + ] { + ok( + &admin, + &admin.dir, + url, + &[ + "brain", + "create", + id, + "--kind", + "organization", + "--name", + name, + ], + ); + } + let v1_tree = admin.dir.join("v1"); + ok( + &admin, + &admin.dir, + url, + &["open", "v1", v1_tree.to_str().unwrap()], + ); + ok( + &admin, + &v1_tree, + url, + &["folder", "create", "Knowledge", "--access", "all_members"], + ); + ok(&admin, &v1_tree, url, &["folder", "create", "Strategy"]); + for target in [&member.npub, &agent.npub] { + ok( + &admin, + &v1_tree, + url, + &["admin", "member", "add", "--target", target], + ); + } + ok( + &admin, + &v1_tree, + url, + &["admin", "role", "grant", "admin", "--target", &agent.npub], + ); + ok(&admin, &v1_tree, url, &["sync", "now"]); + fs::write( + v1_tree.join("Knowledge/wiki/alpha.md"), + "# Alpha\n\nShared V1 page.\n", + ) + .unwrap(); + fs::write( + v1_tree.join("Strategy/wiki/plan.md"), + "# Plan\n\nRestricted V1 page.\n", + ) + .unwrap(); + ok(&admin, &v1_tree, url, &["sync", "now"]); + let mut other_trees = Vec::new(); + for (id, folder, page) in [ + ("content-brain", "Notes", "content.md"), + ("marketing-comms-brain", "Campaigns", "launch.md"), + ] { + let tree = admin.dir.join(id); + ok( + &admin, + &admin.dir, + url, + &["open", id, tree.to_str().unwrap()], + ); + ok(&admin, &tree, url, &["folder", "create", folder]); + ok(&admin, &tree, url, &["sync", "now"]); + fs::write( + tree.join(format!("{folder}/wiki/{page}")), + format!("# {id}\n\nKept.\n"), + ) + .unwrap(); + ok(&admin, &tree, url, &["sync", "now"]); + other_trees.push(tree); + } + let member_tree = member.dir.join("v1"); + ok( + &member, + &member.dir, + url, + &["open", "v1", member_tree.to_str().unwrap()], + ); + ok(&member, &member_tree, url, &["sync", "now"]); + assert_eq!( + fs::read_to_string(member_tree.join("Knowledge/wiki/alpha.md")).unwrap(), + "# Alpha\n\nShared V1 page.\n" + ); + let invitation = ok( + &admin, + &v1_tree, + url, + &["invite", "brain", "create", "--target", &invitee.npub], + ); + let invitation_id = invitation["id"].as_str().unwrap().to_owned(); + let token = ok(&admin, &v1_tree, url, &["invite-token", "create"]); + let token_url = token["url"].as_str().unwrap().to_owned(); + evidence.pass( + "fixtures", + json!({"brains": ["v1", "content-brain", "marketing-comms-brain"], "server": url}), + ); + + // Only current admins may preview or delete; there is no bypass flag. + assert!( + refused(&member, &member.dir, url, &["brain", "delete", "v1"]) + .contains("destructive authority") + ); + assert!( + refused(&outsider, &outsider.dir, url, &["brain", "delete", "v1"]) + .contains("brain access required") + ); + assert!( + refused( + &admin, + &admin.dir, + url, + &["brain", "delete", "v1", "--force"] + ) + .contains("has no --force") + ); + evidence.pass("memberAndOutsiderDenied", json!(true)); + + // The preview names the server, Brain and scope; text mode says nothing + // was deleted. + let first = ok(&admin, &admin.dir, url, &["brain", "delete", "v1"]); + assert_eq!(first["state"], "confirmation-required"); + let scope = &first["preview"]; + assert_eq!( + ( + scope["server"].as_str(), + scope["brainId"].as_str(), + scope["name"].as_str() + ), + (Some(url), Some("v1"), Some("V1")) + ); + assert_eq!( + (scope["folderCount"].as_u64(), scope["pageCount"].as_u64()), + (Some(2), Some(2)) + ); + assert_eq!( + (scope["memberCount"].as_u64(), scope["adminCount"].as_u64()), + (Some(3), Some(2)) + ); + assert_eq!( + ( + scope["pendingInvitationCount"].as_u64(), + scope["openInviteTokenCount"].as_u64() + ), + (Some(1), Some(1)) + ); + let text = command(&admin.dir, &admin.dir, url) + .args(["brain", "delete", "v1"]) + .output() + .unwrap(); + let text = String::from_utf8(text.stdout).unwrap(); + assert!( + text.contains(&format!("Delete Organization Brain \"V1\" (v1) on {url}?")), + "{text}" + ); + assert!(text.contains("Nothing was deleted."), "{text}"); + let first_digest = scope["stateDigest"].as_str().unwrap().to_owned(); + + // An edit after the preview keeps every count but invalidates it. + fs::write( + v1_tree.join("Strategy/wiki/plan.md"), + "# Plan\n\nEdited after preview.\n", + ) + .unwrap(); + ok(&admin, &v1_tree, url, &["sync", "now"]); + let edited = ok(&admin, &admin.dir, url, &["brain", "delete", "v1"]); + assert_eq!(edited["preview"]["pageCount"], scope["pageCount"]); + assert_ne!( + edited["preview"]["stateDigest"].as_str().unwrap(), + first_digest + ); + assert!( + refused( + &admin, + &admin.dir, + url, + &["brain", "delete", "v1", "--confirm", &first_digest] + ) + .contains("changed since the preview you confirmed") + ); + evidence.pass("stalePreviewWithUnchangedCountsRefused", json!(true)); + + // Active Mounts in both directions block deletion until revoked. + let marketing_tree = &other_trees[1]; + let content_tree = &other_trees[0]; + let offer_out = ok( + &admin, + &v1_tree.join("Strategy"), + url, + &[ + "mount", + "offer", + "create", + "--destination-brain", + "marketing-comms-brain", + "--destination-controller", + &admin.npub, + ], + ); + let mount_out = ok( + &admin, + marketing_tree, + url, + &["mount", "accept", offer_out["id"].as_str().unwrap()], + ); + let offer_in = ok( + &admin, + &content_tree.join("Notes"), + url, + &[ + "mount", + "offer", + "create", + "--destination-brain", + "v1", + "--destination-controller", + &admin.npub, + ], + ); + let mount_in = ok( + &admin, + &v1_tree, + url, + &["mount", "accept", offer_in["id"].as_str().unwrap()], + ); + let (mount_out, mount_in) = ( + mount_out["mountId"].as_str().unwrap().to_owned(), + mount_in["mountId"].as_str().unwrap().to_owned(), + ); + let blocked = ok(&admin, &admin.dir, url, &["brain", "delete", "v1"]); + assert_eq!(blocked["state"], "blocked"); + let mut directions = blocked["preview"]["blockingMounts"] + .as_array() + .unwrap() + .iter() + .map(|mount| { + ( + mount["connectionId"].as_str().unwrap().to_owned(), + mount["direction"].as_str().unwrap().to_owned(), + ) + }) + .collect::>(); + directions.sort(); + let mut expected = vec![ + (mount_in.clone(), "incoming".to_owned()), + (mount_out.clone(), "outgoing".to_owned()), + ]; + expected.sort(); + assert_eq!(directions, expected); + let blocked_digest = blocked["preview"]["stateDigest"].as_str().unwrap(); + assert!( + refused( + &admin, + &admin.dir, + url, + &["brain", "delete", "v1", "--confirm", blocked_digest] + ) + .contains("active cross-Brain Mounts") + ); + ok( + &admin, + marketing_tree, + url, + &["mount", "revoke", &mount_out], + ); + ok(&admin, &v1_tree, url, &["mount", "revoke", &mount_in]); + evidence.pass( + "activeMountsBothDirectionsBlocked", + json!({"outgoing": mount_out, "incoming": mount_in}), + ); + + // Backup for the restore drill: stop, copy, restart on the same address. + let address = server.stop(); + let backup = root.join("backup/brain.sqlite3"); + copy_database(&database, &backup); + let server = Server::start(&database, Some(address)); + assert_eq!(server.url, url); + + // Optional older client: a pre-deletion fbrain opens its own V1 tree. + let compat = std::env::var_os("FBRAIN_COMPAT_BINARY").map(PathBuf::from); + let compat_tree = member.dir.join("v1-older-client"); + if let Some(binary) = &compat { + let opened = command_with(binary, &member.dir, &member.dir, url) + .args(["open", "v1", compat_tree.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!(opened.status.success(), "{}", stderr(&opened)); + assert!(compat_tree.join("Knowledge/wiki/alpha.md").is_file()); + } + + // Unsynced local work in the Member's Working Tree. + fs::write( + member_tree.join("Knowledge/wiki/alpha.md"), + "# Alpha\n\nMember edit never synced.\n", + ) + .unwrap(); + fs::write( + member_tree.join("Knowledge/wiki/new-idea.md"), + "# New idea\n\nNever synced.\n", + ) + .unwrap(); + + let content_rows = brain_rows(&database, "content-brain"); + let marketing_rows = brain_rows(&database, "marketing-comms-brain"); + let content_export = ok( + &admin, + &admin.dir, + url, + &["brain", "export", "--brain", "content-brain"], + ); + let marketing_export = ok( + &admin, + &admin.dir, + url, + &["brain", "export", "--brain", "marketing-comms-brain"], + ); + + // An Agent admin confirms the exact current preview. + let current = ok(&agent, &agent.dir, url, &["brain", "delete", "v1"]); + assert_eq!(current["state"], "confirmation-required"); + let digest = current["preview"]["stateDigest"] + .as_str() + .unwrap() + .to_owned(); + let deleted = ok( + &agent, + &agent.dir, + url, + &["brain", "delete", "v1", "--confirm", &digest], + ); + assert_eq!(deleted["state"], "deleted"); + assert_eq!(deleted["duplicate"], false); + let receipt = &deleted["receipt"]; + assert_eq!(receipt["brainId"], "v1"); + assert_eq!(receipt["actorNpub"], agent.npub.as_str()); + assert_eq!(receipt["stateDigest"], digest.as_str()); + assert_eq!( + ( + receipt["folderCount"].as_u64(), + receipt["pageCount"].as_u64() + ), + (Some(2), Some(2)) + ); + evidence.pass("agentAdminDeletedV1", receipt.clone()); + + // V1 is gone from every list and from the store; the others are intact. + for person in [&admin, &agent, &member] { + let listed = ok(person, &person.dir, url, &["brain", "list"]); + assert!( + !listed["brains"] + .as_array() + .unwrap() + .iter() + .any(|brain| brain["brainId"] == "v1"), + "{listed}" + ); + } + assert!(brain_rows(&database, "v1").is_empty()); + let mut retired_records = 0; + for (brain_id, before) in [ + ("content-brain", &content_rows), + ("marketing-comms-brain", &marketing_rows), + ] { + retired_records += + assert_only_retired_mount_records_changed(before, &brain_rows(&database, brain_id)); + } + assert_eq!( + ok( + &admin, + &admin.dir, + url, + &["brain", "export", "--brain", "content-brain"] + ), + content_export + ); + assert_eq!( + ok( + &admin, + &admin.dir, + url, + &["brain", "export", "--brain", "marketing-comms-brain"] + ), + marketing_export + ); + evidence.pass( + "otherBrainsIntact", + json!({ + "encryptedExportsByteIdentical": true, + "contentRows": content_rows.len(), + "marketingRows": marketing_rows.len(), + "retiredMountRecordsNamingV1": retired_records, + }), + ); + + // Repeating the deletion reports the terminal state. + assert!( + refused( + &agent, + &agent.dir, + url, + &["brain", "delete", "v1", "--confirm", &digest] + ) + .contains("was already permanently deleted") + ); + // Invitations and Invite Tokens fail like unknown ones. + assert!( + !refused( + &invitee, + &invitee.dir, + url, + &["invite", "brain", "accept", "--id", &invitation_id] + ) + .is_empty() + ); + let token_error = refused(&invitee, &invitee.dir, url, &["invite-accept", &token_url]); + let unknown_error = refused( + &invitee, + &invitee.dir, + url, + &[ + "invite-accept", + &format!("{url}/v1/invite-tokens/redeem#fbit-unknownunknownunknownunknown00"), + ], + ); + assert_eq!(token_error, unknown_error); + // Unrelated identities see exactly an unknown Brain. + let outsider_v1 = refused(&outsider, &outsider.dir, url, &["brain", "metadata", "v1"]); + let outsider_unknown = refused( + &outsider, + &outsider.dir, + url, + &["brain", "metadata", "never-existed"], + ); + assert_eq!(outsider_v1.replace("v1", "never-existed"), outsider_unknown); + // Former readers learn it was deleted; nobody can recreate the id. + assert!( + refused(&member, &member.dir, url, &["brain", "metadata", "v1"]).contains("brain_deleted") + ); + for person in [&admin, &outsider] { + assert!( + refused( + person, + &person.dir, + url, + &[ + "brain", + "create", + "v1", + "--kind", + "organization", + "--name", + "V1" + ] + ) + .contains("duplicate id for brain_id: v1") + ); + } + assert!( + refused( + &admin, + &v1_tree, + url, + &["folder", "create", "Revived", "--brain", "v1"] + ) + .contains("brain_deleted") + ); + evidence.pass("invitationsTokensRecreateAndProbingRefused", json!(true)); + + // The Member's stale client stops for good and keeps its files. + let stopped = refused(&member, &member_tree, url, &["sync", "now"]); + assert!( + stopped.contains("was permanently deleted on the server"), + "{stopped}" + ); + assert!(stopped.contains("Knowledge/wiki/new-idea.md"), "{stopped}"); + assert_eq!( + fs::read_to_string(member_tree.join("Knowledge/wiki/alpha.md")).unwrap(), + "# Alpha\n\nMember edit never synced.\n" + ); + assert!(member_tree.join("Knowledge/wiki/new-idea.md").is_file()); + let state: Value = serde_json::from_slice( + &fs::read(member_tree.join(".finitebrain/agent-state.json")).unwrap(), + ) + .unwrap(); + let unsynced = state["brainDeleted"]["unsyncedPaths"] + .as_array() + .unwrap() + .clone(); + assert!( + unsynced.contains(&json!("Knowledge/wiki/alpha.md")) + && unsynced.contains(&json!("Knowledge/wiki/new-idea.md")), + "{state}" + ); + assert_eq!(state["brainDeleted"]["deletedAt"], receipt["deletedAt"]); + let watched = ok( + &member, + &member_tree, + url, + &["daemon", "watch", "--max-ticks", "5", "--poll-ms", "10"], + ); + assert_eq!(watched["ticks"], 1, "{watched}"); + let status = ok(&member, &member_tree, url, &["status"]); + assert_eq!(status["brainDeleted"]["deletedAt"], receipt["deletedAt"]); + evidence.pass( + "staleClientStoppedAndKeptUnsyncedWork", + json!({"unsyncedPaths": unsynced}), + ); + + // The deletion record survives a restart; the stale client never needs + // the server to stay stopped. + let address = server.stop(); + let offline = refused(&member, &member_tree, url, &["sync", "now"]); + assert!( + offline.contains("was permanently deleted on the server"), + "{offline}" + ); + let server = Server::start(&database, Some(address)); + assert!( + refused(&admin, &admin.dir, url, &["brain", "metadata", "v1"]).contains("brain_deleted") + ); + assert!( + refused( + &outsider, + &outsider.dir, + url, + &[ + "brain", + "create", + "v1", + "--kind", + "organization", + "--name", + "V1" + ] + ) + .contains("duplicate id for brain_id: v1") + ); + evidence.pass("deletionRecordDurableAcrossRestart", json!(true)); + + // Optional older-client proof: a pre-deletion fbrain neither uploads its + // dirty tree nor recreates V1, and its local files stay. + if let Some(binary) = &compat { + fs::write( + compat_tree.join("Knowledge/wiki/older.md"), + "# Older client edit\n", + ) + .unwrap(); + let synced = command_with(binary, &member.dir, &compat_tree, url) + .args(["sync", "now", "--json"]) + .output() + .unwrap(); + assert!(!synced.status.success(), "older client sync must fail"); + let created = command_with(binary, &member.dir, &member.dir, url) + .args([ + "brain", + "create", + "v1", + "--kind", + "organization", + "--name", + "V1", + "--json", + ]) + .output() + .unwrap(); + assert!( + !created.status.success(), + "older client must not recreate V1" + ); + assert!(compat_tree.join("Knowledge/wiki/older.md").is_file()); + assert!(compat_tree.join("Knowledge/wiki/alpha.md").is_file()); + assert!(brain_rows(&database, "v1").is_empty()); + evidence.pass( + "olderClientCannotResurrect", + json!({"binary": binary.to_string_lossy(), "syncError": stderr(&synced).trim(), "createError": stderr(&created).trim()}), + ); + } + drop(server); + + // Operator restore drill: the pre-deletion backup onto an empty target, + // the admin identity key restored into an empty home, and the pages + // decrypt again. A focused Brain drill, not the hosted Recovery Set. + let restored_database = root.join("restore-target/brain.sqlite3"); + copy_database(&backup, &restored_database); + let restored = Server::start(&restored_database, None); + let restored_admin = home(root, "restored-admin", ADMIN_SECRET); + assert_eq!(restored_admin.npub, admin.npub); + let restored_tree = restored_admin.dir.join("v1"); + ok( + &restored_admin, + &restored_admin.dir, + &restored.url, + &["open", "v1", restored_tree.to_str().unwrap()], + ); + ok( + &restored_admin, + &restored_tree, + &restored.url, + &["sync", "now"], + ); + assert_eq!( + fs::read_to_string(restored_tree.join("Knowledge/wiki/alpha.md")).unwrap(), + "# Alpha\n\nShared V1 page.\n" + ); + assert_eq!( + fs::read_to_string(restored_tree.join("Strategy/wiki/plan.md")).unwrap(), + "# Plan\n\nEdited after preview.\n" + ); + // The Member's deleted-Brain Working Tree stays stopped even against the + // restored server: resuming is an explicit reopen, never automatic. + assert!( + refused(&member, &member_tree, &restored.url, &["sync", "now"]) + .contains("was permanently deleted on the server") + ); + evidence.pass("restoreDrillToEmptyTarget", json!({"limits": "one Brain database and one admin identity key; not the hosted five-part Recovery Set"})); +} + +struct ChildGuard(std::process::Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn wait_until(what: &str, log: &Path, mut done: impl FnMut() -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while !done() { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {what}: {}", + fs::read_to_string(log).unwrap_or_default() + ); + thread::sleep(std::time::Duration::from_millis(100)); + } +} + +#[test] +fn supervised_working_tree_stops_on_the_live_deletion_notice() { + let scratch = TempDir::new().unwrap(); + let root = scratch.path(); + let admin = home(root, "admin", ADMIN_SECRET); + let member = home(root, "member", MEMBER_SECRET); + let database = root.join("state/brain.sqlite3"); + fs::create_dir_all(database.parent().unwrap()).unwrap(); + let server = Server::start(&database, None); + let url = server.url.as_str(); + ok( + &admin, + &admin.dir, + url, + &[ + "brain", + "create", + "v1", + "--kind", + "organization", + "--name", + "V1", + ], + ); + let admin_tree = admin.dir.join("v1"); + ok( + &admin, + &admin.dir, + url, + &["open", "v1", admin_tree.to_str().unwrap()], + ); + ok( + &admin, + &admin_tree, + url, + &["folder", "create", "Knowledge", "--access", "all_members"], + ); + ok( + &admin, + &admin_tree, + url, + &["admin", "member", "add", "--target", &member.npub], + ); + ok(&admin, &admin_tree, url, &["sync", "now"]); + fs::write(admin_tree.join("Knowledge/wiki/alpha.md"), "# Alpha\n").unwrap(); + ok(&admin, &admin_tree, url, &["sync", "now"]); + + let supervised = root.join("supervised"); + fs::create_dir_all(&supervised).unwrap(); + let member_tree = supervised.join("v1"); + ok( + &member, + &member.dir, + url, + &["open", "v1", member_tree.to_str().unwrap()], + ); + let log_path = root.join("supervisor.log"); + let log = fs::File::create(&log_path).unwrap(); + let supervisor = command(&member.dir, &member.dir, url) + .args([ + "daemon", + "supervise", + "--working-tree-root", + supervised.to_str().unwrap(), + ]) + .stdout(std::process::Stdio::from(log.try_clone().unwrap())) + .stderr(std::process::Stdio::from(log)) + .spawn() + .unwrap(); + let supervisor = ChildGuard(supervisor); + let agent_state = || -> Value { + fs::read(member_tree.join(".finitebrain/agent-state.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or(Value::Null) + }; + wait_until("the supervisor's first catch-up", &log_path, || { + member_tree.join("Knowledge/wiki/alpha.md").is_file() + && agent_state()["daemon"]["tickCount"].as_u64().unwrap_or(0) > 0 + }); + fs::write( + member_tree.join("Knowledge/wiki/draft.md"), + "# Draft never synced\n", + ) + .unwrap(); + // Let the supervisor push nothing yet: the draft is written after its + // catch-up and deletion follows at once, so it may or may not be pushed. + let preview = ok(&admin, &admin.dir, url, &["brain", "delete", "v1"]); + let digest = preview["preview"]["stateDigest"] + .as_str() + .unwrap() + .to_owned(); + let deleted = ok( + &admin, + &admin.dir, + url, + &["brain", "delete", "v1", "--confirm", &digest], + ); + let deleted_at = deleted["receipt"]["deletedAt"].clone(); + + // The live notice reaches the former Member's supervisor, which records + // the terminal state without polling Brain contents. + wait_until("the terminal deleted-Brain state", &log_path, || { + agent_state()["brainDeleted"]["deletedAt"] == deleted_at + }); + let blocked_lines = || { + fs::read_to_string(&log_path) + .unwrap_or_default() + .matches("was permanently deleted on the server") + .count() + }; + let blocked_activity = || { + agent_state()["activity"] + .as_array() + .map(|entries| { + entries + .iter() + .filter(|entry| entry["kind"] == "daemon.sync_blocked") + .count() + }) + .unwrap_or(0) + }; + // Later local edits never reach the server: the worker skips the + // deleted Brain, so the one blocked sync stays the only one. + fs::write( + member_tree.join("Knowledge/wiki/after.md"), + "# After deletion\n", + ) + .unwrap(); + thread::sleep(std::time::Duration::from_secs(2)); + fs::write( + member_tree.join("Knowledge/wiki/after.md"), + "# After deletion, again\n", + ) + .unwrap(); + thread::sleep(std::time::Duration::from_secs(2)); + assert_eq!(blocked_activity(), 1, "{}", agent_state()); + assert!(blocked_lines() <= 1); + assert!(member_tree.join("Knowledge/wiki/alpha.md").is_file()); + assert!(member_tree.join("Knowledge/wiki/after.md").is_file()); + assert!(member_tree.join("Knowledge/wiki/draft.md").is_file()); + // The supervisor holds the notification stream open; stop it first so + // the server's graceful shutdown can finish. + drop(supervisor); + drop(server); +} diff --git a/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs b/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs index 008918edb..bff869d94 100644 --- a/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs +++ b/finite-brain/crates/finite-brain-server/src/tests/brain_deletion_tests.rs @@ -510,7 +510,7 @@ async fn personal_brains_cannot_be_deleted_by_their_owner_or_agent() { let agent = Keys::generate(); let state = ServerState::new(BrainStore::open_in_memory().unwrap(), TEST_BASE_URL) .with_auth_clock(TEST_NOW, 60); - let personal = bootstrap_personal_brain("personal-owner", "Personal", &npub(&owner)).unwrap(); + let personal = bootstrap_personal_brain("personal-owner", "Personal", npub(&owner)).unwrap(); state .store .lock() diff --git a/finite-brain/docs/runbooks/brain-restore-drill.md b/finite-brain/docs/runbooks/brain-restore-drill.md index a4ca1713d..361cbdefc 100644 --- a/finite-brain/docs/runbooks/brain-restore-drill.md +++ b/finite-brain/docs/runbooks/brain-restore-drill.md @@ -22,3 +22,14 @@ or the [coordinated snapshot](../../../infra/runbooks/hosted-web-chat-recovery.m The process acceptance test `built_fbrain_process_brain_restore_drill` exercises shutdown, copy, empty-target restore and permission continuity with real CLI and server processes. A green backup timer alone is not this proof. + +## Deleted Organization Brains + +A deleted Organization Brain stays deleted on its live server: the +`deleted_brains` record retires its id, a trigger refuses to reinsert it, and +former readers get `410 Gone`. Recover it by restoring a backup taken before +the deletion onto an empty isolated target as above; never copy its rows back +into the live database. Client identity keys come from their own backups. +`built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact` +proves this for one Brain database and one admin identity key, not for the +hosted five-part Recovery Set. diff --git a/finite-skills/skills/software-development/finitebrain/SKILL.md b/finite-skills/skills/software-development/finitebrain/SKILL.md index c18e93180..5716150fb 100644 --- a/finite-skills/skills/software-development/finitebrain/SKILL.md +++ b/finite-skills/skills/software-development/finitebrain/SKILL.md @@ -74,7 +74,7 @@ Read the matching reference before acting: | the user asks to create or bootstrap a Brain | [brain-creation.md](references/brain-creation.md) | | the user asks to invite someone, find or accept an invitation, share an Org Brain with another Agent, or unlock a member's Folders | [sharing.md](references/sharing.md) | | the user asks who has access, who a key belongs to, what the dashboard roster shows, to remove or demote someone, or to clear removal pending (`revocationIncomplete`) by rotating a Folder Key | [who-has-access.md](references/who-has-access.md) | -| the user asks to delete a Folder | [destructive-operations.md](references/destructive-operations.md) | +| the user asks to delete a Folder or an Organization Brain | [destructive-operations.md](references/destructive-operations.md) | | the user asks about backup, restore, migration or whether Brain data is durable | [recovery.md](references/recovery.md) | | you run brain, daemon, search-index, folder, admin or mount commands, run `fbrain` from the Rust repo, or a command fails | [fbrain-cli.md](references/fbrain-cli.md) | | the installed CLI, a server response or a user-owned skill disagrees with this skill | [skill-freshness.md](references/skill-freshness.md) | @@ -166,8 +166,9 @@ outside the wiki; `datasets/` describes it. when useful. Earlier entries stay as written. - Prefer updating a topic to deleting it. Before deleting a Page, double-check once in ordinary language, then delete it on a clear yes rather - than archiving it. Folder deletion removes a whole - subtree: read [destructive-operations.md](references/destructive-operations.md). + than archiving it. Folder deletion removes a whole subtree and Brain deletion + removes a whole Organization Brain: read + [destructive-operations.md](references/destructive-operations.md). ### Querying diff --git a/finite-skills/skills/software-development/finitebrain/references/destructive-operations.md b/finite-skills/skills/software-development/finitebrain/references/destructive-operations.md index 792f4d407..872604a02 100644 --- a/finite-skills/skills/software-development/finitebrain/references/destructive-operations.md +++ b/finite-skills/skills/software-development/finitebrain/references/destructive-operations.md @@ -1,8 +1,9 @@ # Destructive Operations -Use this branch before permanently deleting a Folder. A Folder deletion is not -a Page deletion: it deletes the named Folder, every descendant Folder, and all -durable objects in that subtree. +Use this branch before permanently deleting a Folder or an Organization Brain. +A Folder deletion is not a Page deletion: it deletes the named Folder, every +descendant Folder, and all durable objects in that subtree. Brain deletion is +covered under [Delete An Organization Brain](#delete-an-organization-brain). Run every command in this branch from the Brain's Working Tree, so each one uses the server that tree was opened against. @@ -42,3 +43,40 @@ Folder metadata and the local Working Tree, sync reaches a named latest sequence, and conflicts are empty or explicitly reported. Report the deleted Folder IDs and never describe an unverified local disappearance as successful server deletion. + +## Delete An Organization Brain + +Brain deletion removes the Brain for everyone: its Folders, Pages and +server-side history, every member's and guest's access, and its pending +invitations and Invite Tokens. Only a current admin of an Organization Brain +can delete it; Personal Brains cannot be deleted. Only an operator restore from +a backup can bring it back, and local Working Trees and exports stay where they +are. + +1. Preview: `fbrain brain delete --json`. A preview deletes nothing. +2. If `state` is `blocked`, report each `preview.blockingMounts` entry (Mount + ID, direction, source and destination Brain) and stop. Revoking a Mount + changes another Brain's sharing, so revoke one with + `fbrain mount revoke ` only when the user asks, then preview + again. +3. Show the user the exact scope: `server`, Brain `name` and `brainId`, Folder + and Page counts, members, guests, pending invitations and open Invite + Tokens. Ask once whether to delete exactly that Brain. Only a clear yes + naming that Brain authorizes deletion; a request to delete Pages or + Folders, leave a Brain, or archive content does not. +4. On that yes, run the preview's `confirmCommand` within ten minutes: + + ```sh + fbrain brain delete --confirm --json + ``` + + If anything in the Brain changed since the preview, the command fails and + nothing is deleted: preview again, show the new scope, and ask again. + +Completion: the command returned `state: deleted` with a `receipt` naming the +Brain, and `fbrain brain list --json` no longer lists it. Report the receipt's +`deletionEventId` and `deletedAt`. + +A Working Tree of a deleted Brain stops syncing for good: `sync now` and the +daemon report the deletion and list the unsynced local changes they kept in +place. Ask the user before moving those files into another Brain. diff --git a/finite-skills/skills/software-development/finitebrain/references/fbrain-cli.md b/finite-skills/skills/software-development/finitebrain/references/fbrain-cli.md index 8ff8a6f44..0fb7e596d 100644 --- a/finite-skills/skills/software-development/finitebrain/references/fbrain-cli.md +++ b/finite-skills/skills/software-development/finitebrain/references/fbrain-cli.md @@ -43,7 +43,7 @@ fbrain search-index status [--folder ...]|enable --folder |disab fbrain activity fbrain wiki check [--json] fbrain access explain|list|summary -fbrain brain list|create|rename|personal-agent-consent|metadata|export +fbrain brain list|create|rename|personal-agent-consent|metadata|export|delete fbrain folder create|list|delete fbrain collaborator ensure-admin fbrain invite brain create|list|inspect|accept|revoke @@ -91,6 +91,13 @@ administrative sync history. Other clients receive the name on refresh or sync. An older server rejects this command without mutation; upgrade the server before promoting the matching CLI and managed skill revision. +## Delete a Brain + +`brain delete ` only previews an Organization Brain deletion; the +preview's `--confirm ` form deletes exactly the previewed state. +Follow [destructive-operations.md](destructive-operations.md) before running +it. An older server answers with an upgrade error and changes nothing. + ## Identity `fbrain` signs with the current Finite Home's Local Identity Key, at @@ -173,6 +180,12 @@ values include `caught-up`, `applied-remote-records`, `pushed-local-changes`, an Each `remoteChanges` entry produced from a signed sync record includes `actorNpub`; `--summary` renders it as `actor=`. +When the server reports the Brain permanently deleted, the Working Tree records +`brainDeleted` in Agent State and `fbrain status --json`, and every later sync, +`daemon watch` tick and supervisor notification stops without contacting the +server. Its files, including the unsynced changes listed in +`brainDeleted.unsyncedPaths`, stay in place. + ## Search Evidence `fbrain search` returns ranked Markdown Sections from every currently readable diff --git a/finite-skills/skills/software-development/finitebrain/references/recovery.md b/finite-skills/skills/software-development/finitebrain/references/recovery.md index 2e04f9099..b0597dd14 100644 --- a/finite-skills/skills/software-development/finitebrain/references/recovery.md +++ b/finite-skills/skills/software-development/finitebrain/references/recovery.md @@ -36,5 +36,11 @@ Coordinated tampering, an invalid scalar, a missing identity/database pair, an ambiguous account binding, or a partial restore fails closed. Name the backup and rollback boundary before any authorized production mutation. +A deleted Organization Brain stays deleted on its live server, which keeps a +small deletion record that retires the Brain ID. Bringing it back is the same +operator restore of a backup taken before the deletion, onto an empty target; +nothing in `fbrain` undoes a deletion. Deletion never erases backups, exports +or other offline copies. + If every completion condition has not been observed, report exactly which evidence is missing and describe recovery as unproven rather than durable. From a55fae6b966b1bff332de11edab58311bb6c0900 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 25 Oct 2026 22:49:17 -0500 Subject: [PATCH 3/8] Delete an Organization Brain from the dashboard Brain page The selected Organization Brain gains a Delete Brain panel: it shows the server's exact preview (server, name and ID, folders, pages, members, guests, invitations, tokens, blocking mounts), requires typing the Brain ID, and deletes exactly the previewed state. The route signs with the account's hosted Brain key through the hosted device, the same delete-brain event the CLI signs, and resends the identical signed deletion once if the response is lost. A stale preview is refused and must be reviewed again. Proof: unit tests pin the canonical event to finite-brain-core; a hosted device test signs it and the Brain server accepts it; an opt-in route test and an opt-in browser test drive the real hosted device and Brain server binaries (FC_BRAIN_DELETION_PROOF_BIN_DIR). The web design fixture forwards only Brain identity-provider calls to a real hosted device when asked. Co-Authored-By: Claude Opus 5.5 --- .../tests/brain_deletion.rs | 226 ++++++++++++++++++ .../browser/brain-deletion.browser.ts | 167 +++++++++++++ .../dashboard/scripts/web-design-fixture.ts | 23 ++ .../src/app/api/brain/deletion/route.ts | 83 +++++++ .../src/components/agent-brain-browser.tsx | 2 + .../src/components/brain-deletion-panel.tsx | 168 +++++++++++++ .../src/lib/brain-deletion-hosted.test.ts | 173 ++++++++++++++ .../dashboard/src/lib/brain-deletion-view.ts | 155 ++++++++++++ .../dashboard/src/lib/brain-deletion.test.ts | 175 ++++++++++++++ .../apps/dashboard/src/lib/brain-deletion.ts | 185 ++++++++++++++ 10 files changed, 1357 insertions(+) create mode 100644 finitechat/crates/finitechat-hosted-device/tests/brain_deletion.rs create mode 100644 finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/app/api/brain/deletion/route.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx create mode 100644 finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts create mode 100644 finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts diff --git a/finitechat/crates/finitechat-hosted-device/tests/brain_deletion.rs b/finitechat/crates/finitechat-hosted-device/tests/brain_deletion.rs new file mode 100644 index 000000000..462cf07b0 --- /dev/null +++ b/finitechat/crates/finitechat-hosted-device/tests/brain_deletion.rs @@ -0,0 +1,226 @@ +//! The dashboard deletes an Organization Brain with the account's hosted +//! Brain key: this hosted device signs the dashboard-shaped `delete-brain` +//! event and the protected requests, and the Brain server accepts them. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use finite_brain_core::{ + AdminAccessAction, AdminAccessChangePayload, AdminAccessChangeValidation, + BRAIN_IDENTITY_PROVIDER_VERSION, BrainId, bootstrap_organization_brain, +}; +use finitechat_hosted_device::{HostedDeviceConfig, WORKOS_USER_HEADER, app}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use sha2::Digest; +use std::time::{SystemTime, UNIX_EPOCH}; +use tempfile::TempDir; +use tower::ServiceExt; + +const TOKEN: &str = "hosted-device-test-token"; +const BRAIN_ORIGIN: &str = "https://brain.finite.test"; + +/// Shared with the dashboard test `the delete-brain template matches +/// finite-brain-core's canonical event` in `src/lib/brain-deletion.test.ts`. +const DASHBOARD_CANONICAL_DELETE_CONTENT: &str = concat!( + r#"{"version":"finite-brain-admin-access-change-v1","brainId":"v1","changeId":"delete-brain-0123456789abcdef","#, + r#""action":"delete-brain","adminNpub":"npub1example","note":"sha256:abababababababababababababababababababababababababababababababab","createdAt":"2026-05-28T20:26:40Z"}"# +); + +async fn json_body(response: axum::response::Response) -> Value { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap_or(Value::Null) +} + +async fn provider(hosted: &axum::Router, operation: &str, input: Value) -> Value { + let response = hosted + .clone() + .oneshot( + Request::post("/v1/brain/identity-provider") + .header("authorization", format!("Bearer {TOKEN}")) + .header(WORKOS_USER_HEADER, "user_admin") + .header("x-finite-brain-public-origin", BRAIN_ORIGIN) + .header("content-type", "application/json") + .body(Body::from( + json!({"version": BRAIN_IDENTITY_PROVIDER_VERSION, "operation": operation, "input": input}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{operation}"); + json_body(response).await +} + +/// One Brain request signed the way `hostedSignedBrainRequest` signs it. +async fn signed( + hosted: &axum::Router, + brain: &axum::Router, + method: &str, + path: &str, + body: &str, +) -> axum::response::Response { + let url = format!("{BRAIN_ORIGIN}{path}"); + let mut tags = vec![ + json!(["u", url]), + json!(["method", method]), + json!(["nonce", hex::encode(rand_nonce())]), + ]; + if !body.is_empty() { + tags.push(json!(["payload", hex::encode(sha2::Sha256::digest(body))])); + } + let event = provider( + hosted, + "authorizeHttpRequest", + json!({ + "method": method, + "url": url, + "bodyText": body, + "eventTemplate": {"kind": 27_235, "created_at": now(), "tags": tags, "content": ""}, + }), + ) + .await; + let authorization = + finite_nostr::encode_http_auth_header(&serde_json::from_value(event).unwrap()); + let mut request = Request::builder() + .method(method) + .uri(path) + .header("authorization", authorization); + if !body.is_empty() { + request = request.header("content-type", "application/json"); + } + brain + .clone() + .oneshot(request.body(Body::from(body.to_owned())).unwrap()) + .await + .unwrap() +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn rand_nonce() -> [u8; 16] { + let mut nonce = [0_u8; 16]; + getrandom::fill(&mut nonce).unwrap(); + nonce +} + +fn delete_content( + brain_id: &str, + change_id: &str, + admin_npub: &str, + digest: &str, + created_at: &str, +) -> String { + AdminAccessChangePayload::new(&AdminAccessChangeValidation { + brain_id: BrainId::new(brain_id).unwrap(), + change_id: change_id.to_owned(), + action: AdminAccessAction::DeleteBrain, + admin_npub: admin_npub.to_owned(), + folder_id: None, + target_npub: None, + key_version: None, + note: Some(digest.to_owned()), + created_at: created_at.to_owned(), + }) + .canonical_json() +} + +#[tokio::test] +async fn hosted_device_signs_the_dashboard_brain_deletion_the_brain_server_accepts() { + // The dashboard builds exactly the canonical content finite-brain-core + // expects. + assert_eq!( + delete_content( + "v1", + "delete-brain-0123456789abcdef", + "npub1example", + &format!("sha256:{}", "ab".repeat(32)), + "2026-05-28T20:26:40Z", + ), + DASHBOARD_CANONICAL_DELETE_CONTENT + ); + + let root = TempDir::new().unwrap(); + let hosted = app(HostedDeviceConfig { + data_root: root.path().to_path_buf(), + server_url: "http://127.0.0.1:9".to_owned(), + public_url: "https://chat.finite.computer".to_owned(), + api_token: TOKEN.to_owned(), + }); + let state = hosted + .clone() + .oneshot( + Request::get("/v1/app/state") + .header("authorization", format!("Bearer {TOKEN}")) + .header(WORKOS_USER_HEADER, "user_admin") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(state.status(), StatusCode::OK); + let npub = provider(&hosted, "identifyMember", Value::Null).await["npub"] + .as_str() + .unwrap() + .to_owned(); + + let mut store = finite_brain_store::BrainStore::open_in_memory().unwrap(); + store + .create_brain_bootstrap( + &bootstrap_organization_brain("v1", "V1", &npub).unwrap(), + &[], + ) + .unwrap(); + let brain = finite_brain_server::router_with_state(finite_brain_server::ServerState::new( + store, + BRAIN_ORIGIN, + )); + + let preview = signed(&hosted, &brain, "GET", "/v1/brains/v1/deletion-preview", "").await; + assert_eq!(preview.status(), StatusCode::OK); + let preview = json_body(preview).await; + assert_eq!(preview["server"], BRAIN_ORIGIN); + let digest = preview["stateDigest"].as_str().unwrap().to_owned(); + + let created_at = now(); + let created_at_text = time::OffsetDateTime::from_unix_timestamp(created_at as i64) + .unwrap() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + let change_id = "delete-brain-hosted-proof"; + let event = provider( + &hosted, + "authorizeBrainEvent", + json!({ + "intent": "brain-access-change", + "eventTemplate": { + "kind": 30_078, + "created_at": created_at, + "tags": [ + ["d", format!("finite-brain-admin-access-change:v1:{change_id}")], + ["brain", "v1"], + ["action", "delete-brain"], + ], + "content": delete_content("v1", change_id, &npub, &digest, &created_at_text), + }, + }), + ) + .await; + let body = json!({ "deletionEvent": event }).to_string(); + let deleted = signed(&hosted, &brain, "DELETE", "/v1/brains/v1", &body).await; + assert_eq!(deleted.status(), StatusCode::OK); + let deleted = json_body(deleted).await; + assert_eq!(deleted["duplicate"], false); + assert_eq!(deleted["receipt"]["actorNpub"], npub.as_str()); + assert_eq!(deleted["receipt"]["stateDigest"], digest.as_str()); + let retry = json_body(signed(&hosted, &brain, "DELETE", "/v1/brains/v1", &body).await).await; + assert_eq!(retry["duplicate"], true); + assert_eq!(retry["receipt"], deleted["receipt"]); + let gone = signed(&hosted, &brain, "GET", "/v1/brains/v1/metadata", "").await; + assert_eq!(gone.status(), StatusCode::GONE); +} diff --git a/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts b/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts new file mode 100644 index 000000000..26e9b9240 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts @@ -0,0 +1,167 @@ +/** Delete Brain UI against the real hosted device and Brain server binaries: + * the browser clicks through the dashboard route, which signs with the + * account's hosted Brain key. Synthetic Brains and accounts only. Build the + * binaries, then set FC_BRAIN_DELETION_PROOF_BIN_DIR=. */ +import assert from "node:assert/strict"; +import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { chromium, type Browser } from "playwright"; +import { chromiumLaunchOptions } from "../scripts/playwright-browser"; + +const BIN_DIR = process.env.FC_BRAIN_DELETION_PROOF_BIN_DIR?.trim(); +// The web design fixture's hosted-device credential and account. +const DEVICE_TOKEN = "web-design-hosted-device-token"; +const WORKOS_USER_ID = "user_web_design"; + +async function freePort() { + const server = http.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert(address && typeof address === "object"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +async function waitFor(what: string, child: ChildProcess, ready: () => Promise, output = () => "") { + const deadline = Date.now() + 120_000; + while (!(await ready().catch(() => false))) { + assert.equal(child.exitCode, null, `${what} exited: ${output()}`); + assert(Date.now() < deadline, `${what} did not become ready: ${output()}`); + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} + +test("Delete Brain previews, refuses a stale confirmation, and deletes through the hosted key", { + skip: BIN_DIR ? false : "set FC_BRAIN_DELETION_PROOF_BIN_DIR to the built binaries", + timeout: 300_000, +}, async () => { + const scratch = await mkdtemp(path.join(os.tmpdir(), "brain-deletion-browser-")); + const [brainPort, devicePort, dashboardPort] = [await freePort(), await freePort(), await freePort()]; + const brainOrigin = `http://127.0.0.1:${brainPort}`; + const deviceUrl = `http://127.0.0.1:${devicePort}`; + const base = `http://127.0.0.1:${dashboardPort}`; + const children: ChildProcess[] = []; + let browser: Browser | undefined; + try { + const brain = spawn(path.join(BIN_DIR!, "finite-brain"), [], { + env: { + NODE_ENV: "test", + FINITE_BRAIN_ADDR: `127.0.0.1:${brainPort}`, + FINITE_BRAIN_PUBLIC_BASE_URL: brainOrigin, + FINITE_BRAIN_DB: path.join(scratch, "brain.sqlite3"), + }, + stdio: "ignore", + }); + const device = spawn(path.join(BIN_DIR!, "finitechat-hosted-device"), [ + "--bind", `127.0.0.1:${devicePort}`, + "--data-root", path.join(scratch, "hosted"), + "--server-url", "http://127.0.0.1:9", + "--public-url", "http://chat.test", + ], { env: { NODE_ENV: "test", FINITECHAT_HOSTED_API_TOKEN: DEVICE_TOKEN }, stdio: "ignore" }); + children.push(brain, device); + await waitFor("Brain server", brain, async () => (await fetch(`${brainOrigin}/health`)).ok); + await waitFor("hosted device", device, async () => (await fetch(`${deviceUrl}/v1/app/state`, { + headers: { authorization: `Bearer ${DEVICE_TOKEN}`, "x-finite-workos-user-id": WORKOS_USER_ID }, + })).ok); + + // Seed synthetic Brains with the fixture account's hosted key. + process.env.FC_BRAIN_PUBLIC_ORIGIN = brainOrigin; + const { adminAccessChangeEventTemplate, hostedBrainDeletionTransport } = await import("../src/lib/brain-deletion"); + const admin = hostedBrainDeletionTransport( + { baseUrl: deviceUrl, apiToken: DEVICE_TOKEN }, + { email: "fixture-user@example.test", workosUserId: WORKOS_USER_ID, emailVerified: true, source: "dev" }, + brainOrigin, + ); + const { npub } = await admin.identify(); + for (const [brainId, name] of [["v1", "V1"], ["content-brain", "Content"], ["marketing-comms-brain", "Marketing Comms"]]) { + await admin.signedRequest("POST", "/v1/brains", JSON.stringify({ brainId, kind: "organization", name })); + } + + const reset = spawn(process.execPath, ["--import", "tsx", "scripts/web-design-fixture.ts", "reset"]); + await once(reset, "exit"); + const server = spawn(process.execPath, ["--import", "tsx", "scripts/web-design-fixture.ts", "serve"], { + env: { + ...process.env, + FC_WEB_DESIGN_PORT: String(dashboardPort), + FC_WEB_DESIGN_PREVIEWS: "0", + FC_WEB_DESIGN_BRAIN_IDENTITY_PROVIDER_URL: deviceUrl, + FC_WEB_DESIGN_BRAIN_URL: brainOrigin, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + children.push(server); + let output = ""; + server.stdout?.on("data", (chunk) => { output += chunk; }); + server.stderr?.on("data", (chunk) => { output += chunk; }); + await waitFor("dashboard", server, async () => (await fetch(`${base}/healthz`)).ok, () => output); + + browser = await chromium.launch({ headless: true, ...chromiumLaunchOptions() }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/api/chat/**", (route) => route.fulfill({ status: 503, json: {} })); + await page.route("**/api/agents/*/hermes-access", (route) => route.fulfill({ + status: 200, json: { baseUrl: "https://agent.fixture.test/", accessToken: "synthetic-test", expiresAt: 100 }, + })); + // The agent's own Brain inventory is synthetic; deletion never uses it. + await page.route("https://agent.fixture.test/**", (route) => { + const headers = { "access-control-allow-origin": base, "access-control-allow-headers": "authorization" }; + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 200, headers }); + const url = new URL(route.request().url()); + return route.fulfill({ status: 200, headers, json: url.pathname.endsWith("/overview") + ? { version: 1, brains: [ + { id: "v1", name: "V1", kind: "organization", role: "admin", folders: [] }, + { id: "content-brain", name: "Content", kind: "organization", role: "admin", folders: [] }, + ] } + : { version: 1, brainId: "v1", identities: [] } }); + }); + + await page.goto(`${base}/dashboard/machines/runtime_web_design/brain`); + await page.getByRole("button", { name: "V1", exact: true }).click(); + const panel = page.getByRole("region", { name: "Delete V1", exact: true }); + await panel.getByRole("button", { name: "Review deletion…" }).click(); + await panel.getByText("Delete V1 permanently?").waitFor(); + await panel.getByText(brainOrigin, { exact: true }).waitFor(); + const remove = panel.getByRole("button", { name: "Delete Brain permanently" }); + assert.equal(await remove.isDisabled(), true); + + // The Brain changes after the preview: the confirmation is refused. + const rename = await admin.signBrainEvent(adminAccessChangeEventTemplate({ + brainId: "v1", action: "rename-brain", adminNpub: npub, note: "V1 renamed", + changeId: "rename-during-review", createdAt: Math.floor(Date.now() / 1000), + })); + await admin.signedRequest("POST", "/v1/brains/v1/rename", JSON.stringify({ accessChangeEvent: rename })); + await panel.getByLabel("Type v1 to confirm").fill("v1"); + await remove.click(); + await panel.getByRole("alert").filter({ hasText: "changed after its deletion preview" }).waitFor(); + assert.equal(await (await admin.signedRequest("GET", "/v1/brains") as { brains: unknown[] }).brains.length, 3); + + const artifacts = process.env.FC_BROWSER_ARTIFACT_DIR; + await panel.getByRole("button", { name: "Review the current Brain again" }).click(); + await panel.getByText("V1 renamed (v1)", { exact: true }).waitFor(); + await panel.getByLabel("Type v1 to confirm").fill("v1"); + if (artifacts) { + await mkdir(artifacts, { recursive: true }); + await page.screenshot({ path: path.join(artifacts, "brain-deletion-preview.png"), fullPage: true }); + } + await panel.getByRole("button", { name: "Delete Brain permanently" }).click(); + await panel.getByRole("status").filter({ hasText: "Deleted V1 renamed (v1)." }).waitFor(); + if (artifacts) await page.screenshot({ path: path.join(artifacts, "brain-deletion-receipt.png"), fullPage: true }); + + const listed = await admin.signedRequest("GET", "/v1/brains") as { brains: Array<{ brainId: string }> }; + assert.deepEqual(listed.brains.map((entry) => entry.brainId).sort(), ["content-brain", "marketing-comms-brain"]); + await assert.rejects(admin.signedRequest("GET", "/v1/brains/v1/deletion-preview"), (error: { status?: number }) => error.status === 410); + assert.deepEqual(errors, []); + } finally { + await browser?.close(); + for (const child of children.reverse()) { + child.kill("SIGTERM"); + if (child.exitCode === null) await once(child, "exit").catch(() => undefined); + } + } +}); diff --git a/finitecomputer-v2/apps/dashboard/scripts/web-design-fixture.ts b/finitecomputer-v2/apps/dashboard/scripts/web-design-fixture.ts index 664f5fecd..ade177b3b 100644 --- a/finitecomputer-v2/apps/dashboard/scripts/web-design-fixture.ts +++ b/finitecomputer-v2/apps/dashboard/scripts/web-design-fixture.ts @@ -11,6 +11,11 @@ const DEVICE_TOKEN = "web-design-hosted-device-token"; const WORKOS_USER_ID = "user_web_design"; const FIXTURE_EMAIL = "fixture-user@example.test"; const DEFAULT_PORT = 13002; +// Opt-in real Brain signing for browser proofs: Brain identity-provider calls +// go to a real hosted device (started with DEVICE_TOKEN) and the dashboard +// talks to a real Brain server. Everything else stays synthetic. +const BRAIN_IDENTITY_PROVIDER_URL = process.env.FC_WEB_DESIGN_BRAIN_IDENTITY_PROVIDER_URL?.trim(); +const BRAIN_URL = process.env.FC_WEB_DESIGN_BRAIN_URL?.trim(); const VALID_SCENARIOS = new Set(["healthy", "unavailable", "recovering"]); type Scenario = "healthy" | "unavailable" | "recovering"; @@ -251,6 +256,7 @@ async function serve() { NEXT_DIST_DIR: ".next-web-design", NEXT_PUBLIC_FC_DESIGN_PREVIEWS: process.env.FC_WEB_DESIGN_PREVIEWS === "0" ? "0" : "1", NEXT_TELEMETRY_DISABLED: "1", + ...(BRAIN_URL ? { FC_BRAIN_UPSTREAM_URL: BRAIN_URL, FC_BRAIN_PUBLIC_ORIGIN: BRAIN_URL } : {}), NODE_OPTIONS: process.env.FC_WEB_DESIGN_NODE_OPTIONS ?? "--max-old-space-size=2048", }, @@ -282,6 +288,23 @@ async function serve() { async function handleHostedRequest(request: IncomingMessage, response: ServerResponse) { const requestPath = new URL(request.url ?? "/", "http://fixture").pathname; + if (BRAIN_IDENTITY_PROVIDER_URL && request.method === "POST" && requestPath === "/v1/brain/identity-provider") { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk as Buffer); + const headers = new Headers(); + for (const name of ["authorization", "content-type", "x-finite-workos-user-id", "x-finite-brain-public-origin"]) { + const value = request.headers[name]; + if (typeof value === "string") headers.set(name, value); + } + const forwarded = await fetch(`${BRAIN_IDENTITY_PROVIDER_URL}${requestPath}`, { + method: "POST", + headers, + body: Buffer.concat(chunks), + }); + response.writeHead(forwarded.status, { "content-type": forwarded.headers.get("content-type") ?? "application/json" }); + response.end(Buffer.from(await forwarded.arrayBuffer())); + return; + } if (request.method === "GET" && requestPath === "/runtime-status") { writeJson(response, 200, { agent_npub: "npub1webdesignfixture" }); return; diff --git a/finitecomputer-v2/apps/dashboard/src/app/api/brain/deletion/route.ts b/finitecomputer-v2/apps/dashboard/src/app/api/brain/deletion/route.ts new file mode 100644 index 000000000..7fa16846a --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/app/api/brain/deletion/route.ts @@ -0,0 +1,83 @@ +import { getAccountAuthContext } from "@/lib/dashboard-auth"; +import { brainServerOrigin } from "@/lib/brain-hosted-client"; +import { + brainDeletionFailure, + deleteBrainExactly, + hostedBrainDeletionTransport, + previewBrainDeletion, +} from "@/lib/brain-deletion"; +import { BRAIN_ID_PATTERN, STATE_DIGEST_PATTERN } from "@/lib/brain-deletion-view"; +import { hostedDeviceConfig } from "@/lib/hosted-web-device"; +import { requestOriginMatchesHost, requestOriginSameOrNone } from "@/lib/http-headers"; + +const NO_STORE = { "cache-control": "no-store" }; +const MAX_BODY_BYTES = 4 * 1024; + +async function deletionTransport() { + const account = await getAccountAuthContext(); + if (!account.workosUserId || !account.emailVerified) { + return { error: Response.json({ error: "Sign in again to manage this Brain." }, { status: 401, headers: NO_STORE }) }; + } + const config = hostedDeviceConfig(); + const brainOrigin = brainServerOrigin(); + if (!config || !brainOrigin) { + return { error: Response.json({ error: "Brain isn't available right now." }, { status: 503, headers: NO_STORE }) }; + } + return { transport: hostedBrainDeletionTransport(config, account, brainOrigin) }; +} + +/// Preview deleting an Organization Brain with the account's hosted Brain key. +export async function GET(request: Request) { + if (!requestOriginSameOrNone(request)) { + return Response.json({ error: "Brain deletion requires the dashboard." }, { status: 403, headers: NO_STORE }); + } + const brainId = new URL(request.url).searchParams.get("brainId")?.trim() ?? ""; + if (!BRAIN_ID_PATTERN.test(brainId)) { + return Response.json({ error: "Brain deletion request is invalid." }, { status: 400, headers: NO_STORE }); + } + const { transport, error } = await deletionTransport(); + if (!transport) return error; + try { + return Response.json(await previewBrainDeletion(transport, brainId), { headers: NO_STORE }); + } catch (caught) { + const failure = brainDeletionFailure(caught); + return Response.json({ error: failure.message }, { status: failure.status, headers: NO_STORE }); + } +} + +/// Delete exactly the previewed state. The account confirms by typing the +/// Brain id; the Brain server rechecks admin standing and the state digest. +export async function POST(request: Request) { + if (!requestOriginMatchesHost(request)) { + return Response.json({ error: "Brain deletion requires the dashboard." }, { status: 403, headers: NO_STORE }); + } + const bodyText = await request.text(); + if (new TextEncoder().encode(bodyText).byteLength > MAX_BODY_BYTES) { + return Response.json({ error: "Brain deletion request is too large." }, { status: 413, headers: NO_STORE }); + } + let body: { brainId?: unknown; stateDigest?: unknown; confirmation?: unknown }; + try { + body = JSON.parse(bodyText); + } catch { + return Response.json({ error: "Brain deletion request is invalid." }, { status: 400, headers: NO_STORE }); + } + const brainId = typeof body.brainId === "string" ? body.brainId : ""; + const stateDigest = typeof body.stateDigest === "string" ? body.stateDigest : ""; + if (!BRAIN_ID_PATTERN.test(brainId) || !STATE_DIGEST_PATTERN.test(stateDigest)) { + return Response.json({ error: "Brain deletion request is invalid." }, { status: 400, headers: NO_STORE }); + } + if (body.confirmation !== brainId) { + return Response.json( + { error: "Type the Brain ID exactly to confirm deletion." }, + { status: 400, headers: NO_STORE } + ); + } + const { transport, error } = await deletionTransport(); + if (!transport) return error; + try { + return Response.json(await deleteBrainExactly(transport, { brainId, stateDigest }), { headers: NO_STORE }); + } catch (caught) { + const failure = brainDeletionFailure(caught); + return Response.json({ error: failure.message }, { status: failure.status, headers: NO_STORE }); + } +} diff --git a/finitecomputer-v2/apps/dashboard/src/components/agent-brain-browser.tsx b/finitecomputer-v2/apps/dashboard/src/components/agent-brain-browser.tsx index 91b0e7b32..46ce9f89b 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/agent-brain-browser.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/agent-brain-browser.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { BrainIdentityRoster } from "@/components/brain-identity-roster"; import { BrainChatState } from "@/components/brain-chat-state"; +import { BrainDeletionPanel } from "@/components/brain-deletion-panel"; import { BrainTable } from "@/components/brain-membership-table"; import { AgentInventoryRefresh } from "@/components/agent-inventory-refresh"; import { PersonalBrainSetupRow } from "@/components/personal-brain-setup"; @@ -37,5 +38,6 @@ export function AgentBrainBrowser({ runtimeId, agentName }: { runtimeId: string; {selected && (["Admin", "Owner", "Personal agent"].includes(selected.role) ? :

Only Brain admins can see who has access to {selected.name}.

)} + {selected && } ; } diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx new file mode 100644 index 000000000..f6a75854d --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx @@ -0,0 +1,168 @@ +"use client"; +import { useState } from "react"; +import { Loader2Icon, TriangleAlertIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { BrainRow } from "@/lib/agent-product-inventory"; +import { + type BrainDeletionPreview, + type BrainDeletionReceipt, + deletionConfirmationReady, + parseBrainDeletionPreview, + parseBrainDeletionResult, + previewFacts, +} from "@/lib/brain-deletion-view"; + +/// Delete an Organization Brain with the account's own hosted Brain key: +/// review the server's exact scope, type the Brain ID, then delete exactly +/// that previewed state. The Brain server decides admin standing; this panel +/// only shows what it answers. + +type Phase = + | { kind: "closed" } + | { kind: "loading" } + | { kind: "preview"; preview: BrainDeletionPreview; typed: string; error?: string; stale?: boolean } + | { kind: "deleting"; preview: BrainDeletionPreview } + | { kind: "deleted"; preview: BrainDeletionPreview; receipt: BrainDeletionReceipt } + | { kind: "unavailable"; message: string }; + +async function readJson(response: Response) { + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const message = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`; + throw Object.assign(new Error(message), { status: response.status }); + } + return body; +} + +function failureStatus(error: unknown) { + return typeof (error as { status?: unknown })?.status === "number" ? (error as { status: number }).status : 0; +} + +export function BrainDeletionPanel({ brain, onDeleted }: { brain: BrainRow; onDeleted: () => void }) { + const [phase, setPhase] = useState({ kind: "closed" }); + + async function review() { + setPhase({ kind: "loading" }); + try { + const response = await fetch(`/api/brain/deletion?brainId=${encodeURIComponent(brain.id)}`, { cache: "no-store" }); + setPhase({ kind: "preview", preview: parseBrainDeletionPreview(await readJson(response)), typed: "" }); + } catch (error) { + const status = failureStatus(error); + setPhase({ + kind: "unavailable", + message: status === 403 + ? `Only a current admin of ${brain.name} can delete it, and your account isn't one.` + : error instanceof Error ? error.message : "Brain deletion is unavailable right now.", + }); + } + } + + async function remove(preview: BrainDeletionPreview, typed: string) { + setPhase({ kind: "deleting", preview }); + try { + const response = await fetch("/api/brain/deletion", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ brainId: preview.brainId, stateDigest: preview.stateDigest, confirmation: typed }), + }); + const { receipt } = parseBrainDeletionResult(await readJson(response)); + setPhase({ kind: "deleted", preview, receipt }); + } catch (error) { + setPhase({ + kind: "preview", + preview, + typed, + stale: failureStatus(error) === 409, + error: error instanceof Error ? error.message : "The Brain wasn't deleted.", + }); + } + } + + if (brain.kind !== "Organization" || brain.pending) return null; + return ( +
+

Delete Brain

+ {phase.kind === "closed" && <> +

+ Permanently deletes {brain.name} for everyone. You'll review exactly what goes first. +

+ + } + {phase.kind === "loading" &&

Checking what would be deleted…

} + {phase.kind === "unavailable" && <> +

{phase.message}

+ + } + {(phase.kind === "preview" || phase.kind === "deleting") && phase.kind === "preview" && !phase.stale && setPhase({ ...phase, typed, error: undefined })} + onDelete={() => phase.kind === "preview" && void remove(phase.preview, phase.typed)} + onReviewAgain={() => void review()} + onCancel={() => setPhase({ kind: "closed" })} + />} + {phase.kind === "deleted" &&
+

Deleted {phase.preview.name} ({phase.receipt.brainId}).

+

+ Deleted at · receipt{" "} + {phase.receipt.deletionEventId.slice(0, 12)}… +

+

Local copies and exports people already hold aren't erased.

+ +
} +
+ ); +} + +function DeletionReview({ phase, onType, onDelete, onReviewAgain, onCancel }: { + phase: Extract; + onType: (typed: string) => void; + onDelete: () => void; + onReviewAgain: () => void; + onCancel: () => void; +}) { + const { preview } = phase; + const typed = phase.kind === "preview" ? phase.typed : preview.brainId; + const deleting = phase.kind === "deleting"; + // A refused stale preview must be reviewed again before anything is signed. + const stale = phase.kind === "preview" && Boolean(phase.stale); + return
+

Delete {preview.name} permanently?

+
+ {previewFacts(preview).map(([label, value]) =>
+
{label}
{value}
+
)} +
+ {preview.retiredMountRecordCount > 0 &&

+ {preview.retiredMountRecordCount} pending or revoked mount records that name this Brain are removed too. +

} + {!preview.deletable ?
+

Revoke these active mounts first:

+
    {preview.blockingMounts.map(mount =>
  • + {mount.direction === "outgoing" ? "Shared out" : "Mounted in"}: {mount.sourceBrainId}/{mount.sourceFolderId} → {mount.destinationBrainId} + {" "}({mount.connectionId}) +
  • )}
+
: <> +

+ This deletes the Brain, its folders, pages and history for everyone, and stops every invitation and + invite token. It can't be undone here; only an operator restore from backup brings it back. + Local copies and exports people already hold aren't erased. +

+
+ + onType(event.target.value)} /> +
+ } + {phase.kind === "preview" && phase.error &&

{phase.error}

} +
+ {stale + ? + : preview.deletable && } + +
+
; +} diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts new file mode 100644 index 000000000..5fe818df4 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts @@ -0,0 +1,173 @@ +/** Opt-in proof: the dashboard deletion route against the real hosted device + * and Brain server binaries. Build them, then run with + * FC_BRAIN_DELETION_PROOF_BIN_DIR=; set + * FC_BRAIN_DELETION_PROOF_REPORT= to keep a JSON evidence report. + * Synthetic Brains and accounts only. */ +import assert from "node:assert/strict"; +import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const BIN_DIR = process.env.FC_BRAIN_DELETION_PROOF_BIN_DIR?.trim(); +const TOKEN = "synthetic-hosted-device-token"; +const DASHBOARD = "http://dashboard.test"; + +async function freePort() { + const server = http.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert(address && typeof address === "object"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +async function waitFor(what: string, child: ChildProcess, ready: () => Promise) { + const deadline = Date.now() + 30_000; + while (!(await ready().catch(() => false))) { + assert.equal(child.exitCode, null, `${what} exited`); + assert(Date.now() < deadline, `${what} did not become ready`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +function asUser(workosUserId: string) { + process.env.FC_DASHBOARD_DEV_WORKOS_USER_ID = workosUserId; + process.env.FC_DASHBOARD_DEV_EMAIL = `${workosUserId}@example.test`; +} + +test("dashboard Brain deletion signs through the hosted device and the Brain server enforces it", { + skip: BIN_DIR ? false : "set FC_BRAIN_DELETION_PROOF_BIN_DIR to the built binaries", + timeout: 120_000, +}, async () => { + const scratch = await mkdtemp(path.join(os.tmpdir(), "brain-deletion-proof-")); + const [brainPort, devicePort] = [await freePort(), await freePort()]; + const brainOrigin = `http://127.0.0.1:${brainPort}`; + const children: ChildProcess[] = []; + const evidence: Record = {}; + try { + const brain = spawn(path.join(BIN_DIR!, "finite-brain"), [], { + env: { + NODE_ENV: "test", + FINITE_BRAIN_ADDR: `127.0.0.1:${brainPort}`, + FINITE_BRAIN_PUBLIC_BASE_URL: brainOrigin, + FINITE_BRAIN_DB: path.join(scratch, "brain.sqlite3"), + }, + stdio: "ignore", + }); + const device = spawn(path.join(BIN_DIR!, "finitechat-hosted-device"), [ + "--bind", `127.0.0.1:${devicePort}`, + "--data-root", path.join(scratch, "hosted"), + "--server-url", "http://127.0.0.1:9", + "--public-url", "http://chat.test", + ], { env: { NODE_ENV: "test", FINITECHAT_HOSTED_API_TOKEN: TOKEN }, stdio: "ignore" }); + children.push(brain, device); + await waitFor("Brain server", brain, async () => (await fetch(`${brainOrigin}/health`)).ok); + const deviceUrl = `http://127.0.0.1:${devicePort}`; + const provision = (user: string) => fetch(`${deviceUrl}/v1/app/state`, { + headers: { authorization: `Bearer ${TOKEN}`, "x-finite-workos-user-id": user }, + }); + await waitFor("hosted device", device, async () => (await provision("user_admin")).ok); + assert.equal((await provision("user_outsider")).status, 200); + + Object.assign(process.env, { + FC_HOSTED_WEB_DEVICE_URL: deviceUrl, + FINITECHAT_HOSTED_API_TOKEN: TOKEN, + FC_BRAIN_UPSTREAM_URL: brainOrigin, + FC_BRAIN_PUBLIC_ORIGIN: brainOrigin, + FC_DASHBOARD_ALLOW_DEV_ACCOUNT_AUTH: "1", + FC_DASHBOARD_DEV_WORKOS_ACCESS_TOKEN: "synthetic-access-token", + }); + delete process.env.FC_WORKOS_AUTH_ENABLED; + asUser("user_admin"); + const route = await import("../app/api/brain/deletion/route"); + const { getAccountAuthContext } = await import("./dashboard-auth"); + const { hostedDeviceConfig } = await import("./hosted-web-device"); + const { adminAccessChangeEventTemplate, hostedBrainDeletionTransport } = await import("./brain-deletion"); + const adminTransport = hostedBrainDeletionTransport(hostedDeviceConfig()!, await getAccountAuthContext(), brainOrigin); + const { npub: adminNpub } = await adminTransport.identify(); + for (const [brainId, name] of [["v1", "V1"], ["content-brain", "Content"], ["marketing-comms-brain", "Marketing Comms"]]) { + await adminTransport.signedRequest("POST", "/v1/brains", JSON.stringify({ brainId, kind: "organization", name })); + } + + const preview = (user: string) => { + asUser(user); + return route.GET(new Request(`${DASHBOARD}/api/brain/deletion?brainId=v1`, { headers: { host: "dashboard.test" } })); + }; + const confirm = (body: Record) => { + asUser("user_admin"); + return route.POST(new Request(`${DASHBOARD}/api/brain/deletion`, { + method: "POST", + headers: { host: "dashboard.test", origin: DASHBOARD, "content-type": "application/json" }, + body: JSON.stringify(body), + })); + }; + + // Only a current admin may preview; the preview names the server. + assert.equal((await preview("user_outsider")).status, 403); + const first = await preview("user_admin"); + assert.equal(first.status, 200); + const firstScope = await first.json(); + assert.equal(firstScope.server, brainOrigin); + assert.equal(firstScope.name, "V1"); + evidence.adminPreview = { server: firstScope.server, brainId: firstScope.brainId, deletable: firstScope.deletable }; + + // A rename after the preview invalidates it; a mistyped confirmation never signs. + const renameEvent = await adminTransport.signBrainEvent(adminAccessChangeEventTemplate({ + brainId: "v1", action: "rename-brain", adminNpub, note: "V1 (renamed)", + changeId: "rename-after-preview", createdAt: Math.floor(Date.now() / 1000), + })); + await adminTransport.signedRequest("POST", "/v1/brains/v1/rename", JSON.stringify({ accessChangeEvent: renameEvent })); + const stale = await confirm({ brainId: "v1", stateDigest: firstScope.stateDigest, confirmation: "v1" }); + assert.equal(stale.status, 409); + assert.match((await stale.json()).error, /changed after its deletion preview/u); + assert.equal((await confirm({ brainId: "v1", stateDigest: firstScope.stateDigest, confirmation: "V1" })).status, 400); + + // Confirm the fresh preview while the first DELETE response is lost in + // transit: the route resends the identical signed deletion once. + const fresh = await (await preview("user_admin")).json(); + assert.notEqual(fresh.stateDigest, firstScope.stateDigest); + const realFetch = globalThis.fetch; + let dropped = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await realFetch(input, init); + if (String(input) === `${brainOrigin}/v1/brains/v1` && init?.method === "DELETE" && dropped === 0) { + dropped += 1; + await response.text(); + throw new TypeError("fetch failed: response lost in transit"); + } + return response; + }) as typeof fetch; + let deleted: Response; + try { + deleted = await confirm({ brainId: "v1", stateDigest: fresh.stateDigest, confirmation: "v1" }); + } finally { + globalThis.fetch = realFetch; + } + assert.equal(dropped, 1); + assert.equal(deleted.status, 200); + const result = await deleted.json(); + assert.equal(result.duplicate, true); + assert.equal(result.receipt.brainId, "v1"); + assert.equal(result.receipt.actorNpub, adminNpub); + assert.equal(result.receipt.stateDigest, fresh.stateDigest); + evidence.deletedThroughRoute = { receipt: result.receipt, exactRetryAfterLostResponse: result.duplicate }; + + // The former admin learns it is gone; an unrelated account cannot tell. + const gone = await preview("user_admin"); + assert.equal(gone.status, 410); + assert.match((await gone.json()).error, /permanently deleted/u); + assert.equal((await preview("user_outsider")).status, 404); + asUser("user_admin"); + const listed = await adminTransport.signedRequest("GET", "/v1/brains") as { brains: Array<{ brainId: string }> }; + assert.deepEqual(listed.brains.map((brain) => brain.brainId).sort(), ["content-brain", "marketing-comms-brain"]); + evidence.afterDeletion = { formerAdmin: 410, outsider: 404, remaining: listed.brains.map((brain) => brain.brainId).sort() }; + } finally { + for (const child of children) child.kill("SIGTERM"); + const report = process.env.FC_BRAIN_DELETION_PROOF_REPORT; + if (report) await writeFile(report, JSON.stringify({ format: "finite.dashboard.brain-deletion-hosted-proof.v1", evidence }, null, 2)); + } +}); diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts new file mode 100644 index 000000000..8e580e006 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts @@ -0,0 +1,155 @@ +/// Organization Brain deletion shapes shared by the dashboard route and the +/// deletion panel: parsing of the Brain server's preview and receipt, and the +/// confirmation rule. No server-only imports. + +export const BRAIN_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,127}$/u; +export const STATE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +export type BlockingMount = { + connectionId: string; + direction: "outgoing" | "incoming"; + sourceBrainId: string; + sourceFolderId: string; + destinationBrainId: string; +}; + +export type BrainDeletionPreview = { + server: string; + brainId: string; + name: string; + folderCount: number; + pageCount: number; + memberCount: number; + adminCount: number; + guestCount: number; + pendingInvitationCount: number; + openInviteTokenCount: number; + blockingMounts: BlockingMount[]; + retiredMountRecordCount: number; + deletable: boolean; + stateDigest: string; + confirmationMaxAgeSeconds: number; +}; + +export type BrainDeletionReceipt = { + brainId: string; + deletionEventId: string; + actorNpub: string; + deletedAt: string; + stateDigest: string; + folderCount: number; + pageCount: number; +}; + +export type BrainDeletionResult = { receipt: BrainDeletionReceipt; duplicate: boolean }; + +export class InvalidBrainResponse extends Error {} + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return value as Record; +} + +function text(value: unknown): string { + if (typeof value !== "string" || !value || value.length > 2048) { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return value; +} + +function count(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return value; +} + +function digest(value: unknown): string { + const candidate = text(value); + if (!STATE_DIGEST_PATTERN.test(candidate)) { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return candidate; +} + +export function parseBrainDeletionPreview(value: unknown): BrainDeletionPreview { + const data = record(value); + if (!Array.isArray(data.blockingMounts) || data.blockingMounts.length > 2_000) { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + const blockingMounts = data.blockingMounts.map((entry): BlockingMount => { + const mount = record(entry); + if (mount.direction !== "outgoing" && mount.direction !== "incoming") { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return { + connectionId: text(mount.connectionId), + direction: mount.direction, + sourceBrainId: text(mount.sourceBrainId), + sourceFolderId: text(mount.sourceFolderId), + destinationBrainId: text(mount.destinationBrainId), + }; + }); + if (typeof data.deletable !== "boolean" || data.deletable !== (blockingMounts.length === 0)) { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return { + server: text(data.server), + brainId: text(data.brainId), + name: text(data.name), + folderCount: count(data.folderCount), + pageCount: count(data.pageCount), + memberCount: count(data.memberCount), + adminCount: count(data.adminCount), + guestCount: count(data.guestCount), + pendingInvitationCount: count(data.pendingInvitationCount), + openInviteTokenCount: count(data.openInviteTokenCount), + blockingMounts, + retiredMountRecordCount: count(data.retiredMountRecordCount), + deletable: data.deletable, + stateDigest: digest(data.stateDigest), + confirmationMaxAgeSeconds: count(data.confirmationMaxAgeSeconds), + }; +} + +export function parseBrainDeletionResult(value: unknown): BrainDeletionResult { + const data = record(value); + const receipt = record(data.receipt); + if (typeof data.duplicate !== "boolean") { + throw new InvalidBrainResponse("Brain returned an unexpected deletion response."); + } + return { + duplicate: data.duplicate, + receipt: { + brainId: text(receipt.brainId), + deletionEventId: text(receipt.deletionEventId), + actorNpub: text(receipt.actorNpub), + deletedAt: text(receipt.deletedAt), + stateDigest: digest(receipt.stateDigest), + folderCount: count(receipt.folderCount), + pageCount: count(receipt.pageCount), + }, + }; +} + +/// Deletion is allowed only for a deletable preview whose Brain ID the +/// account typed exactly. +export function deletionConfirmationReady(preview: BrainDeletionPreview, typed: string) { + return preview.deletable && typed === preview.brainId; +} + +/// The exact scope the account confirms, in display order. +export function previewFacts(preview: BrainDeletionPreview): Array<[string, string]> { + return [ + ["Brain", `${preview.name} (${preview.brainId})`], + ["Server", preview.server], + ["Folders", String(preview.folderCount)], + ["Pages", String(preview.pageCount)], + ["Members", `${preview.memberCount} (${preview.adminCount} ${preview.adminCount === 1 ? "admin" : "admins"})`], + ["Guests", String(preview.guestCount)], + ["Pending invitations", String(preview.pendingInvitationCount)], + ["Open invite tokens", String(preview.openInviteTokenCount)], + ]; +} diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts new file mode 100644 index 000000000..7383fe9b4 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { BrainHostedClientError } from "./brain-hosted-client"; +import { + type BrainDeletionTransport, + brainDeletionEventTemplate, + brainDeletionFailure, + deleteBrainExactly, + previewBrainDeletion, +} from "./brain-deletion"; +import { deletionConfirmationReady, parseBrainDeletionPreview, previewFacts } from "./brain-deletion-view"; + +const DIGEST = `sha256:${"ab".repeat(32)}`; + +/// Shared with `finitechat-hosted-device` test +/// `hosted_device_signs_the_dashboard_brain_deletion_the_brain_server_accepts`, +/// which asserts finite-brain-core produces exactly this canonical content. +const CANONICAL_DELETE_CONTENT = + `{"version":"finite-brain-admin-access-change-v1","brainId":"v1","changeId":"delete-brain-0123456789abcdef",` + + `"action":"delete-brain","adminNpub":"npub1example","note":"${DIGEST}","createdAt":"2026-05-28T20:26:40Z"}`; + +function previewBody(overrides: Record = {}) { + return { + server: "https://brain.example", + brainId: "v1", + name: "V1", + kind: "organization", + folderCount: 3, + pageCount: 12, + memberCount: 4, + adminCount: 2, + guestCount: 1, + pendingInvitationCount: 1, + openInviteTokenCount: 2, + blockingMounts: [], + retiredMountRecordCount: 0, + deletable: true, + stateDigest: DIGEST, + confirmationMaxAgeSeconds: 600, + ...overrides, + }; +} + +function receiptBody(duplicate: boolean) { + return { + duplicate, + receipt: { + brainId: "v1", + deletionEventId: "e".repeat(64), + actorNpub: "npub1example", + deletedAt: "2026-10-25T00:00:00Z", + stateDigest: DIGEST, + folderCount: 3, + pageCount: 12, + }, + }; +} + +function fakeTransport(responses: Array<() => unknown>) { + const calls: Array<{ method: string; path: string; bodyText?: string }> = []; + const signed: unknown[] = []; + const transport: BrainDeletionTransport = { + signedRequest: async (method, path, bodyText) => { + calls.push({ method, path, bodyText }); + const next = responses.shift(); + assert(next, `unexpected ${method} ${path}`); + return next(); + }, + identify: async () => ({ npub: "npub1example" }), + signBrainEvent: async (template) => { + signed.push(template); + return { id: "f".repeat(64), ...template }; + }, + }; + return { transport, calls, signed }; +} + +test("the delete-brain template matches finite-brain-core's canonical event", () => { + const template = brainDeletionEventTemplate({ + brainId: "v1", + adminNpub: "npub1example", + stateDigest: DIGEST, + changeId: "delete-brain-0123456789abcdef", + createdAt: 1_780_000_000, + }); + assert.equal(template.kind, 30_078); + assert.equal(template.created_at, 1_780_000_000); + assert.equal(template.content, CANONICAL_DELETE_CONTENT); + assert.deepEqual(template.tags, [ + ["d", "finite-brain-admin-access-change:v1:delete-brain-0123456789abcdef"], + ["brain", "v1"], + ["action", "delete-brain"], + ]); + assert.throws(() => + brainDeletionEventTemplate({ brainId: "v1", adminNpub: "npub1x", stateDigest: "sha256:XY", changeId: "c", createdAt: 1 }) + ); +}); + +test("preview parsing keeps the exact scope and refuses inconsistent answers", async () => { + const { transport, calls } = fakeTransport([() => previewBody()]); + const preview = await previewBrainDeletion(transport, "v1"); + assert.deepEqual(calls, [{ method: "GET", path: "/v1/brains/v1/deletion-preview", bodyText: undefined }]); + assert.deepEqual(previewFacts(preview), [ + ["Brain", "V1 (v1)"], + ["Server", "https://brain.example"], + ["Folders", "3"], + ["Pages", "12"], + ["Members", "4 (2 admins)"], + ["Guests", "1"], + ["Pending invitations", "1"], + ["Open invite tokens", "2"], + ]); + assert.equal(deletionConfirmationReady(preview, "V1"), false); + assert.equal(deletionConfirmationReady(preview, "v1 "), false); + assert.equal(deletionConfirmationReady(preview, "v1"), true); + + const mount = { + connectionId: "mount-1", + direction: "incoming", + sourceBrainId: "content-brain", + sourceFolderId: "notes", + destinationBrainId: "v1", + }; + const blocked = parseBrainDeletionPreview(previewBody({ blockingMounts: [mount], deletable: false })); + assert.equal(deletionConfirmationReady(blocked, "v1"), false); + for (const bad of [ + previewBody({ blockingMounts: [mount], deletable: true }), + previewBody({ stateDigest: "sha256:short" }), + previewBody({ pageCount: -1 }), + previewBody({ blockingMounts: [{ ...mount, direction: "sideways" }], deletable: false }), + ]) { + assert.throws(() => parseBrainDeletionPreview(bad)); + } +}); + +test("deletion signs the confirmed digest and retries only a lost transport once", async () => { + const lost = fakeTransport([ + () => { throw new TypeError("fetch failed"); }, + () => receiptBody(true), + ]); + const result = await deleteBrainExactly(lost.transport, { brainId: "v1", stateDigest: DIGEST }, 1_780_000_000); + assert.equal(result.duplicate, true); + assert.equal(result.receipt.stateDigest, DIGEST); + assert.equal(lost.signed.length, 1); + assert.equal(lost.calls.length, 2); + assert.equal(lost.calls[0].method, "DELETE"); + assert.equal(lost.calls[0].path, "/v1/brains/v1"); + // The retry is the identical signed deletion, not a new one. + assert.equal(lost.calls[1].bodyText, lost.calls[0].bodyText); + const event = JSON.parse(lost.calls[0].bodyText ?? "{}").deletionEvent; + assert.equal(JSON.parse(event.content).note, DIGEST); + assert.equal(JSON.parse(event.content).action, "delete-brain"); + + // A Brain server answer is final. + const refused = fakeTransport([ + () => { throw new BrainHostedClientError("Brain changed after its deletion preview", 409); }, + ]); + await assert.rejects( + deleteBrainExactly(refused.transport, { brainId: "v1", stateDigest: DIGEST }), + (error) => error instanceof BrainHostedClientError && error.status === 409 + ); + assert.equal(refused.calls.length, 1); +}); + +test("refusals keep the Brain server's status and message", () => { + for (const status of [403, 404, 409, 410, 400]) { + assert.deepEqual(brainDeletionFailure(new BrainHostedClientError(`server says ${status}`, status)), { + status, + message: `server says ${status}`, + }); + } + assert.equal(brainDeletionFailure(new BrainHostedClientError("boom", 500)).status, 502); + assert.equal(brainDeletionFailure(new TypeError("fetch failed")).status, 502); +}); diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts new file mode 100644 index 000000000..3b219e317 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts @@ -0,0 +1,185 @@ +import { randomBytes } from "node:crypto"; + +import { type AccountAuthContext } from "@/lib/dashboard-auth"; +import { + BrainHostedClientError, + brainPublicOrigin, + hostedSignedBrainRequest, +} from "@/lib/brain-hosted-client"; +import { + BRAIN_ID_PATTERN, + type BrainDeletionPreview, + type BrainDeletionResult, + InvalidBrainResponse, + STATE_DIGEST_PATTERN, + parseBrainDeletionPreview, + parseBrainDeletionResult, +} from "@/lib/brain-deletion-view"; +import { type HostedDeviceConfig, hostedDeviceBrainIdentityProvider } from "@/lib/hosted-web-device"; + +/// Organization Brain deletion for the dashboard. Every step is signed by the +/// account's hosted Brain key through the hosted device, the same authority +/// `fbrain brain delete` uses: preview, then a `delete-brain` admin +/// access-change event whose note is the preview's state digest. The Brain +/// server rechecks admin standing and that digest in the deleting transaction. + +const ADMIN_ACCESS_CHANGE_KIND = 30_078; + +/// The signed calls deletion needs; tests substitute their own. +export type BrainDeletionTransport = { + signedRequest: (method: string, path: string, bodyText?: string) => Promise; + identify: () => Promise<{ npub: string }>; + signBrainEvent: (eventTemplate: BrainEventTemplate) => Promise; +}; + +export type BrainEventTemplate = { + kind: number; + created_at: number; + tags: string[][]; + content: string; +}; + +/// RFC 3339 at whole seconds, as the Brain server formats a signed event's +/// `created_at` (no fractional seconds). +function rfc3339Seconds(unixSeconds: number) { + return new Date(unixSeconds * 1000).toISOString().replace(/\.\d{3}Z$/u, "Z"); +} + +/// One unsigned Brain admin access-change event with a note and no Folder, +/// target or key version. Content and tags must equal +/// `AdminAccessChangePayload::canonical_json` and `admin_access_change_tags` +/// in finite-brain-core byte for byte, or the server refuses the signature. +export function adminAccessChangeEventTemplate(input: { + brainId: string; + action: "delete-brain" | "rename-brain"; + adminNpub: string; + note: string; + changeId: string; + createdAt: number; +}): BrainEventTemplate { + const content = [ + `"version":${JSON.stringify("finite-brain-admin-access-change-v1")}`, + `"brainId":${JSON.stringify(input.brainId)}`, + `"changeId":${JSON.stringify(input.changeId)}`, + `"action":${JSON.stringify(input.action)}`, + `"adminNpub":${JSON.stringify(input.adminNpub)}`, + `"note":${JSON.stringify(input.note)}`, + `"createdAt":${JSON.stringify(rfc3339Seconds(input.createdAt))}`, + ].join(","); + return { + kind: ADMIN_ACCESS_CHANGE_KIND, + created_at: input.createdAt, + tags: [ + ["d", `finite-brain-admin-access-change:${input.brainId}:${input.changeId}`], + ["brain", input.brainId], + ["action", input.action], + ], + content: `{${content}}`, + }; +} + +/// The unsigned `delete-brain` event; its note is the preview state digest. +export function brainDeletionEventTemplate(input: { + brainId: string; + adminNpub: string; + stateDigest: string; + changeId: string; + createdAt: number; +}): BrainEventTemplate { + if (!BRAIN_ID_PATTERN.test(input.brainId) || !STATE_DIGEST_PATTERN.test(input.stateDigest)) { + throw new Error("Brain deletion needs a Brain id and a preview state digest."); + } + return adminAccessChangeEventTemplate({ + brainId: input.brainId, + action: "delete-brain", + adminNpub: input.adminNpub, + note: input.stateDigest, + changeId: input.changeId, + createdAt: input.createdAt, + }); +} + +export async function previewBrainDeletion( + transport: BrainDeletionTransport, + brainId: string +): Promise { + return parseBrainDeletionPreview( + await transport.signedRequest("GET", `/v1/brains/${encodeURIComponent(brainId)}/deletion-preview`) + ); +} + +/// Sign and submit deletion of exactly the previewed state. A transport +/// failure leaves the outcome unknown, so the identical signed deletion is +/// sent once more: the server either deletes or answers the accepted receipt. +/// A Brain server answer is final and never retried. +export async function deleteBrainExactly( + transport: BrainDeletionTransport, + input: { brainId: string; stateDigest: string }, + nowUnixSeconds = Math.floor(Date.now() / 1000) +): Promise { + const { npub } = await transport.identify(); + const template = brainDeletionEventTemplate({ + brainId: input.brainId, + adminNpub: npub, + stateDigest: input.stateDigest, + changeId: `delete-brain-${randomBytes(16).toString("hex")}`, + createdAt: nowUnixSeconds, + }); + const deletionEvent = await transport.signBrainEvent(template); + const bodyText = JSON.stringify({ deletionEvent }); + const path = `/v1/brains/${encodeURIComponent(input.brainId)}`; + let response: unknown; + try { + response = await transport.signedRequest("DELETE", path, bodyText); + } catch (error) { + if (error instanceof BrainHostedClientError) throw error; + response = await transport.signedRequest("DELETE", path, bodyText); + } + return parseBrainDeletionResult(response); +} + +export function hostedBrainDeletionTransport( + config: HostedDeviceConfig, + account: AccountAuthContext, + brainOrigin: string +): BrainDeletionTransport { + const identityOrigin = brainPublicOrigin() ?? brainOrigin; + const provider = (operation: "identifyMember" | "authorizeBrainEvent", input: unknown) => + hostedDeviceBrainIdentityProvider( + config, + account, + { version: "finite-brain-identity-provider-v1", operation, input }, + identityOrigin + ); + return { + signedRequest: (method, path, bodyText) => + hostedSignedBrainRequest(config, account, brainOrigin, method, path, bodyText), + identify: async () => { + const identity = await provider("identifyMember", null); + if (typeof identity.npub !== "string" || !identity.npub.startsWith("npub1")) { + throw new InvalidBrainResponse("The hosted Brain key could not be identified."); + } + return { npub: identity.npub }; + }, + signBrainEvent: async (eventTemplate) => { + const signed = await provider("authorizeBrainEvent", { + intent: "brain-access-change", + eventTemplate, + }); + return signed.event ?? signed; + }, + }; +} + +/// What the dashboard tells the account after a refused request. +export function brainDeletionFailure(error: unknown): { status: number; message: string } { + if (error instanceof BrainHostedClientError) { + if ([403, 404, 409, 410].includes(error.status)) { + return { status: error.status, message: error.message }; + } + if (error.status === 400) return { status: 400, message: error.message }; + return { status: 502, message: "Brain couldn't complete the deletion request right now." }; + } + if (error instanceof InvalidBrainResponse) return { status: 502, message: error.message }; + return { status: 502, message: "Brain deletion couldn't be signed right now." }; +} From 653280512557d2115385775fef484954e572ebc3 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 25 Oct 2026 22:54:16 -0500 Subject: [PATCH 4/8] Keep Brain deletion within the Brain language check and stabilize its proofs The retired email-delegation tables keep their deployed column name, so their deletion predicates now live beside the exempt schema migrations. The process acceptance harness moves to its own module, and the supervisor test no longer writes a page between the admin's preview and confirmation: a concurrent push correctly invalidated the preview. Co-Authored-By: Claude Opus 5.5 --- .../tests/brain_deletion/harness.rs | 302 ++++++++++++++++++ .../brain_deletion_process_acceptance.rs | 298 +---------------- .../finite-brain-store/src/brain_deletion.rs | 30 +- .../crates/finite-brain-store/src/schema.rs | 11 + 4 files changed, 335 insertions(+), 306 deletions(-) create mode 100644 finite-brain/crates/finite-brain-cli/tests/brain_deletion/harness.rs diff --git a/finite-brain/crates/finite-brain-cli/tests/brain_deletion/harness.rs b/finite-brain/crates/finite-brain-cli/tests/brain_deletion/harness.rs new file mode 100644 index 000000000..09ae99fb6 --- /dev/null +++ b/finite-brain/crates/finite-brain-cli/tests/brain_deletion/harness.rs @@ -0,0 +1,302 @@ +//! Process harness for the Brain deletion acceptance tests: a restartable +//! file-backed Brain server, imported identities, `fbrain` runners, row +//! dumps and the evidence report. + +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::mpsc; +use std::thread; + +use serde_json::{Value, json}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +pub(super) const ADMIN_SECRET: &str = + "00000000000000000000000000000000000000000000000000000000000000a1"; +pub(super) const AGENT_SECRET: &str = + "00000000000000000000000000000000000000000000000000000000000000a2"; +pub(super) const MEMBER_SECRET: &str = + "00000000000000000000000000000000000000000000000000000000000000a3"; +pub(super) const INVITEE_SECRET: &str = + "00000000000000000000000000000000000000000000000000000000000000a4"; +pub(super) const OUTSIDER_SECRET: &str = + "00000000000000000000000000000000000000000000000000000000000000a5"; + +/// A real Brain server on a file-backed SQLite store, restartable on the +/// same address so clients keep their configured server. +pub(super) struct Server { + pub(super) url: String, + address: SocketAddr, + shutdown: Option>, + thread: Option>, +} + +impl Server { + pub(super) fn start(database: &Path, address: Option) -> Self { + let database = database.to_path_buf(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let thread = thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let bind = address.unwrap_or_else(|| "127.0.0.1:0".parse().unwrap()); + let listener = tokio::net::TcpListener::bind(bind).await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let store = finite_brain_store::BrainStore::open(&database).unwrap(); + let state = finite_brain_server::ServerState::new(store, url.clone()); + ready_tx.send((url, address)).unwrap(); + axum::serve(listener, finite_brain_server::router_with_state(state)) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + }); + let (url, address) = ready_rx.recv().unwrap(); + Self { + url, + address, + shutdown: Some(shutdown_tx), + thread: Some(thread), + } + } + + pub(super) fn stop(mut self) -> SocketAddr { + self.halt(); + self.address + } + + fn halt(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.halt(); + } +} + +pub(super) struct Home { + pub(super) dir: PathBuf, + pub(super) npub: String, +} + +pub(super) fn home(scratch: &Path, name: &str, secret: &str) -> Home { + let dir = scratch.join(name); + fs::create_dir_all(&dir).unwrap(); + let secret_file = scratch.join(format!("{name}.secret")); + fs::write(&secret_file, format!("{secret}\n")).unwrap(); + let imported = command(&dir, &dir, "http://127.0.0.1:9") + .args(["auth", "import", "--file", secret_file.to_str().unwrap()]) + .output() + .unwrap(); + assert!(imported.status.success(), "{}", stderr(&imported)); + fs::remove_file(secret_file).unwrap(); + let signer = command(&dir, &dir, "http://127.0.0.1:9") + .args(["signer", "public-key", "--json"]) + .output() + .unwrap(); + let npub = serde_json::from_slice::(&signer.stdout).unwrap()["npub"] + .as_str() + .unwrap() + .to_owned(); + Home { dir, npub } +} + +pub(super) fn command(home: &Path, cwd: &Path, server: &str) -> Command { + command_with(Path::new(env!("CARGO_BIN_EXE_fbrain")), home, cwd, server) +} + +pub(super) fn command_with(binary: &Path, home: &Path, cwd: &Path, server: &str) -> Command { + let mut command = Command::new(binary); + command + .current_dir(cwd) + .env_clear() + .env("HOME", home) + .env("FINITE_HOME", home.join("finite-home")) + .env("FBRAIN_CONFIG_DIR", home.join("fbrain-config")) + .env( + "FBRAIN_NOW", + OffsetDateTime::now_utc().format(&Rfc3339).unwrap(), + ) + .env("FINITE_BRAIN_SERVER_URL", server) + .env("FINITE_BRAIN_PUBLIC_BASE_URL", server); + command +} + +pub(super) fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// Run a command that must succeed; returns its `--json` output. +pub(super) fn ok(home: &Home, cwd: &Path, server: &str, args: &[&str]) -> Value { + let output = command(&home.dir, cwd, server) + .args(args) + .arg("--json") + .output() + .unwrap(); + assert!(output.status.success(), "{args:?}: {}", stderr(&output)); + serde_json::from_slice(&output.stdout).unwrap() +} + +/// Run a command that must fail; returns its error text. +pub(super) fn refused(home: &Home, cwd: &Path, server: &str, args: &[&str]) -> String { + let output = command(&home.dir, cwd, server).args(args).output().unwrap(); + assert!( + !output.status.success(), + "{args:?} unexpectedly succeeded: {}", + String::from_utf8_lossy(&output.stdout) + ); + stderr(&output) +} + +/// Every row in every table that names `brain_id`, outside the permanent +/// deletion record, as sorted text. The retired email-delegation tables are +/// always empty for Organization Brains; the store tests cover them. +pub(super) fn brain_rows(database: &Path, brain_id: &str) -> Vec { + let conn = rusqlite::Connection::open(database).unwrap(); + let tables = conn + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + let mut rows = Vec::new(); + for table in tables { + if table == "deleted_brains" || table == "deleted_brain_audience" { + continue; + } + let columns = conn + .prepare(&format!("SELECT name FROM pragma_table_info('{table}')")) + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + let keyed = columns + .iter() + .filter(|column| { + matches!( + column.as_str(), + "brain_id" | "source_brain_id" | "destination_brain_id" + ) || (table == "brains" && column.as_str() == "id") + }) + .map(|column| format!("\"{column}\" = ?1")) + .collect::>(); + if keyed.is_empty() { + continue; + } + let mut stmt = conn + .prepare(&format!( + "SELECT * FROM \"{table}\" WHERE {}", + keyed.join(" OR ") + )) + .unwrap(); + let width = stmt.column_count(); + let mut found = stmt.query([brain_id]).unwrap(); + while let Some(row) = found.next().unwrap() { + let values = (0..width) + .map(|index| match row.get_ref(index).unwrap() { + rusqlite::types::ValueRef::Text(text) => { + String::from_utf8_lossy(text).into_owned() + } + other => format!("{other:?}"), + }) + .collect::>(); + rows.push(format!("{table}|{}", values.join("|"))); + } + } + rows.sort(); + rows +} + +/// Another Brain keeps every row except the revoked Mount records that named +/// V1 (removed so no stale offer can restore access) and its derived +/// capacity counters. Returns how many retired records were removed. +pub(super) fn assert_only_retired_mount_records_changed( + before: &[String], + after: &[String], +) -> usize { + let retired = |row: &String| { + (row.starts_with("folder_mounts|") || row.starts_with("shared_folder_")) + && row.split('|').any(|field| field == "v1") + }; + let derived = |row: &String| row.starts_with("brain_capacity_counts|"); + let kept = |rows: &[String]| { + rows.iter() + .filter(|row| !retired(row) && !derived(row)) + .cloned() + .collect::>() + }; + assert_eq!(kept(after), kept(before)); + assert!(!after.iter().any(retired), "{after:#?}"); + before.iter().filter(|row| retired(row)).count() +} + +pub(super) fn copy_database(from: &Path, to: &Path) { + fs::create_dir_all(to.parent().unwrap()).unwrap(); + for suffix in ["", "-wal", "-shm"] { + let source = PathBuf::from(format!("{}{suffix}", from.display())); + if source.exists() { + fs::copy(&source, format!("{}{suffix}", to.display())).unwrap(); + } + } +} + +/// Boundaries that passed, written as JSON when the report path is set. +pub(super) struct Evidence { + pub(super) path: Option, + pub(super) facts: serde_json::Map, +} + +impl Evidence { + pub(super) fn pass(&mut self, boundary: &str, detail: Value) { + self.facts.insert(boundary.to_owned(), detail); + } +} + +impl Drop for Evidence { + fn drop(&mut self) { + if let Some(path) = &self.path { + let report = json!({ + "format": "finite.brain.organization-brain-deletion-acceptance.v1", + "status": if thread::panicking() { "failed" } else { "passed" }, + "passedBoundaries": self.facts, + "syntheticOnly": true, + }); + fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap(); + } + } +} + +pub(super) struct ChildGuard(pub(super) std::process::Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +pub(super) fn wait_until(what: &str, log: &Path, mut done: impl FnMut() -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while !done() { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {what}: {}", + fs::read_to_string(log).unwrap_or_default() + ); + thread::sleep(std::time::Duration::from_millis(100)); + } +} diff --git a/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs b/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs index 828813a49..32dcd5afd 100644 --- a/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs +++ b/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs @@ -4,274 +4,15 @@ //! keep a JSON evidence report of the boundaries that passed. use std::fs; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::mpsc; +use std::path::PathBuf; use std::thread; use serde_json::{Value, json}; use tempfile::TempDir; -use time::OffsetDateTime; -use time::format_description::well_known::Rfc3339; -const ADMIN_SECRET: &str = "00000000000000000000000000000000000000000000000000000000000000a1"; -const AGENT_SECRET: &str = "00000000000000000000000000000000000000000000000000000000000000a2"; -const MEMBER_SECRET: &str = "00000000000000000000000000000000000000000000000000000000000000a3"; -const INVITEE_SECRET: &str = "00000000000000000000000000000000000000000000000000000000000000a4"; -const OUTSIDER_SECRET: &str = "00000000000000000000000000000000000000000000000000000000000000a5"; - -/// A real Brain server on a file-backed SQLite store, restartable on the -/// same address so clients keep their configured server. -struct Server { - url: String, - address: SocketAddr, - shutdown: Option>, - thread: Option>, -} - -impl Server { - fn start(database: &Path, address: Option) -> Self { - let database = database.to_path_buf(); - let (ready_tx, ready_rx) = mpsc::channel(); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let thread = thread::spawn(move || { - let runtime = tokio::runtime::Runtime::new().unwrap(); - runtime.block_on(async move { - let bind = address.unwrap_or_else(|| "127.0.0.1:0".parse().unwrap()); - let listener = tokio::net::TcpListener::bind(bind).await.unwrap(); - let address = listener.local_addr().unwrap(); - let url = format!("http://{address}"); - let store = finite_brain_store::BrainStore::open(&database).unwrap(); - let state = finite_brain_server::ServerState::new(store, url.clone()); - ready_tx.send((url, address)).unwrap(); - axum::serve(listener, finite_brain_server::router_with_state(state)) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }) - .await - .unwrap(); - }); - }); - let (url, address) = ready_rx.recv().unwrap(); - Self { - url, - address, - shutdown: Some(shutdown_tx), - thread: Some(thread), - } - } - - fn stop(mut self) -> SocketAddr { - self.halt(); - self.address - } - - fn halt(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(thread) = self.thread.take() { - thread.join().unwrap(); - } - } -} - -impl Drop for Server { - fn drop(&mut self) { - self.halt(); - } -} - -struct Home { - dir: PathBuf, - npub: String, -} - -fn home(scratch: &Path, name: &str, secret: &str) -> Home { - let dir = scratch.join(name); - fs::create_dir_all(&dir).unwrap(); - let secret_file = scratch.join(format!("{name}.secret")); - fs::write(&secret_file, format!("{secret}\n")).unwrap(); - let imported = command(&dir, &dir, "http://127.0.0.1:9") - .args(["auth", "import", "--file", secret_file.to_str().unwrap()]) - .output() - .unwrap(); - assert!(imported.status.success(), "{}", stderr(&imported)); - fs::remove_file(secret_file).unwrap(); - let signer = command(&dir, &dir, "http://127.0.0.1:9") - .args(["signer", "public-key", "--json"]) - .output() - .unwrap(); - let npub = serde_json::from_slice::(&signer.stdout).unwrap()["npub"] - .as_str() - .unwrap() - .to_owned(); - Home { dir, npub } -} - -fn command(home: &Path, cwd: &Path, server: &str) -> Command { - command_with(Path::new(env!("CARGO_BIN_EXE_fbrain")), home, cwd, server) -} - -fn command_with(binary: &Path, home: &Path, cwd: &Path, server: &str) -> Command { - let mut command = Command::new(binary); - command - .current_dir(cwd) - .env_clear() - .env("HOME", home) - .env("FINITE_HOME", home.join("finite-home")) - .env("FBRAIN_CONFIG_DIR", home.join("fbrain-config")) - .env( - "FBRAIN_NOW", - OffsetDateTime::now_utc().format(&Rfc3339).unwrap(), - ) - .env("FINITE_BRAIN_SERVER_URL", server) - .env("FINITE_BRAIN_PUBLIC_BASE_URL", server); - command -} - -fn stderr(output: &Output) -> String { - String::from_utf8_lossy(&output.stderr).into_owned() -} - -/// Run a command that must succeed; returns its `--json` output. -fn ok(home: &Home, cwd: &Path, server: &str, args: &[&str]) -> Value { - let output = command(&home.dir, cwd, server) - .args(args) - .arg("--json") - .output() - .unwrap(); - assert!(output.status.success(), "{args:?}: {}", stderr(&output)); - serde_json::from_slice(&output.stdout).unwrap() -} - -/// Run a command that must fail; returns its error text. -fn refused(home: &Home, cwd: &Path, server: &str, args: &[&str]) -> String { - let output = command(&home.dir, cwd, server).args(args).output().unwrap(); - assert!( - !output.status.success(), - "{args:?} unexpectedly succeeded: {}", - String::from_utf8_lossy(&output.stdout) - ); - stderr(&output) -} - -/// Every row in every table that names `brain_id`, outside the permanent -/// deletion record, as sorted text. -fn brain_rows(database: &Path, brain_id: &str) -> Vec { - let conn = rusqlite::Connection::open(database).unwrap(); - let tables = conn - .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") - .unwrap() - .query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .collect::, _>>() - .unwrap(); - let mut rows = Vec::new(); - for table in tables { - if table == "deleted_brains" || table == "deleted_brain_audience" { - continue; - } - let columns = conn - .prepare(&format!("SELECT name FROM pragma_table_info('{table}')")) - .unwrap() - .query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .collect::, _>>() - .unwrap(); - let keyed = columns - .iter() - .filter(|column| { - matches!( - column.as_str(), - "brain_id" | "source_brain_id" | "destination_brain_id" | "vault_id" - ) || (table == "brains" && column.as_str() == "id") - }) - .map(|column| format!("\"{column}\" = ?1")) - .collect::>(); - if keyed.is_empty() { - continue; - } - let mut stmt = conn - .prepare(&format!( - "SELECT * FROM \"{table}\" WHERE {}", - keyed.join(" OR ") - )) - .unwrap(); - let width = stmt.column_count(); - let mut found = stmt.query([brain_id]).unwrap(); - while let Some(row) = found.next().unwrap() { - let values = (0..width) - .map(|index| match row.get_ref(index).unwrap() { - rusqlite::types::ValueRef::Text(text) => { - String::from_utf8_lossy(text).into_owned() - } - other => format!("{other:?}"), - }) - .collect::>(); - rows.push(format!("{table}|{}", values.join("|"))); - } - } - rows.sort(); - rows -} - -/// Another Brain keeps every row except the revoked Mount records that named -/// V1 (removed so no stale offer can restore access) and its derived -/// capacity counters. Returns how many retired records were removed. -fn assert_only_retired_mount_records_changed(before: &[String], after: &[String]) -> usize { - let retired = |row: &String| { - (row.starts_with("folder_mounts|") || row.starts_with("shared_folder_")) - && row.split('|').any(|field| field == "v1") - }; - let derived = |row: &String| row.starts_with("brain_capacity_counts|"); - let kept = |rows: &[String]| { - rows.iter() - .filter(|row| !retired(row) && !derived(row)) - .cloned() - .collect::>() - }; - assert_eq!(kept(after), kept(before)); - assert!(!after.iter().any(retired), "{after:#?}"); - before.iter().filter(|row| retired(row)).count() -} - -fn copy_database(from: &Path, to: &Path) { - fs::create_dir_all(to.parent().unwrap()).unwrap(); - for suffix in ["", "-wal", "-shm"] { - let source = PathBuf::from(format!("{}{suffix}", from.display())); - if source.exists() { - fs::copy(&source, format!("{}{suffix}", to.display())).unwrap(); - } - } -} - -/// Boundaries that passed, written as JSON when the report path is set. -struct Evidence { - path: Option, - facts: serde_json::Map, -} - -impl Evidence { - fn pass(&mut self, boundary: &str, detail: Value) { - self.facts.insert(boundary.to_owned(), detail); - } -} - -impl Drop for Evidence { - fn drop(&mut self) { - if let Some(path) = &self.path { - let report = json!({ - "format": "finite.brain.organization-brain-deletion-acceptance.v1", - "status": if thread::panicking() { "failed" } else { "passed" }, - "passedBoundaries": self.facts, - "syntheticOnly": true, - }); - fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap(); - } - } -} +#[path = "brain_deletion/harness.rs"] +mod harness; +use harness::*; #[test] fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { @@ -920,27 +661,6 @@ fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { evidence.pass("restoreDrillToEmptyTarget", json!({"limits": "one Brain database and one admin identity key; not the hosted five-part Recovery Set"})); } -struct ChildGuard(std::process::Child); - -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn wait_until(what: &str, log: &Path, mut done: impl FnMut() -> bool) { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - while !done() { - assert!( - std::time::Instant::now() < deadline, - "timed out waiting for {what}: {}", - fs::read_to_string(log).unwrap_or_default() - ); - thread::sleep(std::time::Duration::from_millis(100)); - } -} - #[test] fn supervised_working_tree_stops_on_the_live_deletion_notice() { let scratch = TempDir::new().unwrap(); @@ -1021,13 +741,8 @@ fn supervised_working_tree_stops_on_the_live_deletion_notice() { member_tree.join("Knowledge/wiki/alpha.md").is_file() && agent_state()["daemon"]["tickCount"].as_u64().unwrap_or(0) > 0 }); - fs::write( - member_tree.join("Knowledge/wiki/draft.md"), - "# Draft never synced\n", - ) - .unwrap(); - // Let the supervisor push nothing yet: the draft is written after its - // catch-up and deletion follows at once, so it may or may not be pushed. + // The supervisor is idle now; nothing changes V1 between the admin's + // preview and confirmation. let preview = ok(&admin, &admin.dir, url, &["brain", "delete", "v1"]); let digest = preview["preview"]["stateDigest"] .as_str() @@ -1081,7 +796,6 @@ fn supervised_working_tree_stops_on_the_live_deletion_notice() { assert!(blocked_lines() <= 1); assert!(member_tree.join("Knowledge/wiki/alpha.md").is_file()); assert!(member_tree.join("Knowledge/wiki/after.md").is_file()); - assert!(member_tree.join("Knowledge/wiki/draft.md").is_file()); // The supervisor holds the notification stream open; stop it first so // the server's graceful shutdown can finish. drop(supervisor); diff --git a/finite-brain/crates/finite-brain-store/src/brain_deletion.rs b/finite-brain/crates/finite-brain-store/src/brain_deletion.rs index f144a659a..585464546 100644 --- a/finite-brain/crates/finite-brain-store/src/brain_deletion.rs +++ b/finite-brain/crates/finite-brain-store/src/brain_deletion.rs @@ -6,6 +6,7 @@ use rusqlite::types::ValueRef; use sha2::{Digest, Sha256}; use crate::folder_key_rotation::begin_authoritative_write; +use crate::schema::LEGACY_DELEGATION_DELETION_SCOPE; use crate::*; /// Prefix of a Brain deletion state digest. @@ -72,12 +73,9 @@ const BRAIN_DELETION_SCOPE: &[ScopedRows] = &[ scoped("share_links", "brain_id = ?1"), scoped("personal_agents", "brain_id = ?1"), scoped("personal_agent_audit", "brain_id = ?1"), - scoped("brain_email_access_delegations", "vault_id = ?1"), - scoped( - "brain_email_access_delegation_audit", - "delegation_id IN (SELECT id FROM brain_email_access_delegations WHERE vault_id = ?1)", - ), - scoped("personal_vault_bootstrap_authorizations", "vault_id = ?1"), + legacy_delegation_scope(0), + legacy_delegation_scope(1), + legacy_delegation_scope(2), scoped( "shared_folder_invitations", "source_brain_id = ?1 OR destination_brain_id = ?1", @@ -122,6 +120,11 @@ pub(crate) const BRAIN_DELETION_UNSCOPED_TABLES: &[&str] = &[ "deleted_brain_audience", ]; +const fn legacy_delegation_scope(index: usize) -> ScopedRows { + let (table, predicate) = LEGACY_DELEGATION_DELETION_SCOPE[index]; + scoped(table, predicate) +} + const fn scoped(table: &'static str, predicate: &'static str) -> ScopedRows { ScopedRows { table, @@ -655,14 +658,13 @@ fn remove_brain_rows(tx: &Connection, brain_id: &BrainId) -> Result<(), StoreErr "DELETE FROM personal_folder_mounts WHERE source_brain_id = ?1", id, )?; - tx.execute( - "DELETE FROM brain_email_access_delegations WHERE vault_id = ?1", - id, - )?; - tx.execute( - "DELETE FROM personal_vault_bootstrap_authorizations WHERE vault_id = ?1", - id, - )?; + // The audit rows cascade with their delegations. + for (table, predicate) in [ + LEGACY_DELEGATION_DELETION_SCOPE[0], + LEGACY_DELEGATION_DELETION_SCOPE[2], + ] { + tx.execute(&format!("DELETE FROM {table} WHERE {predicate}"), id)?; + } tx.execute("DELETE FROM brain_record_index WHERE brain_id = ?1", id)?; // Folders reference their parent with ON DELETE RESTRICT: remove leaves // first, one bounded pass per hierarchy level. diff --git a/finite-brain/crates/finite-brain-store/src/schema.rs b/finite-brain/crates/finite-brain-store/src/schema.rs index 8b149975f..38aa1c64f 100644 --- a/finite-brain/crates/finite-brain-store/src/schema.rs +++ b/finite-brain/crates/finite-brain-store/src/schema.rs @@ -406,6 +406,17 @@ CREATE INDEX brain_invite_tokens_by_brain ON brain_invite_tokens(brain_id, created_at); "#; +/// Brain deletion scope of the retired email-delegation tables, which keep +/// their deployed legacy Brain column name: (table, predicate on `?1`). +pub(crate) const LEGACY_DELEGATION_DELETION_SCOPE: [(&str, &str); 3] = [ + ("brain_email_access_delegations", "vault_id = ?1"), + ( + "brain_email_access_delegation_audit", + "delegation_id IN (SELECT id FROM brain_email_access_delegations WHERE vault_id = ?1)", + ), + ("personal_vault_bootstrap_authorizations", "vault_id = ?1"), +]; + const SCHEMA_V31: &str = r#" -- Permanent Organization Brain deletion record. Deliberately outside the -- brains foreign-key graph: deleting the Brain row and everything it owns From 76406a11324bacd122c970dbcb08c058a662307f Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 25 Oct 2026 23:26:58 -0500 Subject: [PATCH 5/8] Polish Brain deletion confirmations and preserve server-bound client state --- .../finite-brain-cli/src/brain_deletion.rs | 33 +++++- .../crates/finite-brain-cli/src/error.rs | 11 +- .../crates/finite-brain-cli/src/http.rs | 2 +- .../src/tests/brain_deletion_tests.rs | 34 ++++++ .../brain_deletion_process_acceptance.rs | 105 +++++++++++++++++- .../browser/brain-deletion-fixture.ts | 29 +++++ .../browser/brain-deletion.browser.ts | 7 +- .../src/components/brain-deletion-panel.tsx | 2 +- .../src/lib/brain-deletion-hosted.test.ts | 7 +- .../dashboard/src/lib/brain-deletion-view.ts | 2 +- .../dashboard/src/lib/brain-deletion.test.ts | 17 ++- .../apps/dashboard/src/lib/brain-deletion.ts | 39 ++----- 12 files changed, 241 insertions(+), 47 deletions(-) create mode 100644 finitecomputer-v2/apps/dashboard/browser/brain-deletion-fixture.ts diff --git a/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs b/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs index e337cd99a..e81dce5e3 100644 --- a/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs +++ b/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs @@ -86,6 +86,18 @@ pub(crate) fn brain_delete( .ok_or(CliError::MissingArgument("brain-id"))?; BrainId::new(brain_id.clone()).map_err(|error| CliError::InvalidInput(error.to_string()))?; let confirmed = option_value(args, "--confirm"); + if let Some(digest) = confirmed.as_deref() + && !digest.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + { + return Err(CliError::InvalidInput( + "--confirm requires the exact lowercase sha256:<64 hex characters> digest from the deletion preview; nothing was deleted".to_owned(), + )); + } let preview = fetch_brain_deletion_preview(env, args, &brain_id)?; let Some(confirmed) = confirmed else { return write_preview(output, json, &preview); @@ -121,7 +133,7 @@ pub(crate) fn brain_delete( .map_err(|error| deletion_request_error(error, &brain_id))?; let response: BrainDeleteResponseView = serde_json::from_value(response)?; if let Some(root) = find_agent_state(&env.cwd)? - && read_agent_state(&root)?.brain_id == brain_id + && deletion_is_for_working_tree(env, args, &root, &brain_id)? { record_brain_deleted(env, &root, &brain_id, &response.receipt.deleted_at)?; } @@ -303,6 +315,25 @@ pub(crate) fn brain_deleted_notice(error: &CliError) -> Option<(String, String)> } } +/// A server override may name a different Brain with the same ID. Only the +/// Working Tree's saved server can make that tree permanently stop syncing. +pub(crate) fn deletion_is_for_working_tree( + env: &CliEnvironment, + args: &[String], + root: &Path, + brain_id: &str, +) -> Result { + let state = read_agent_state(root)?; + let Some(saved_server) = state.server_url else { + return Ok(false); + }; + Ok(state.brain_id == brain_id + && saved_server.trim().trim_end_matches('/') + == server_url_for_command(env, args)? + .trim() + .trim_end_matches('/')) +} + /// Mark the Working Tree at `root` as belonging to a deleted Brain: it never /// syncs again, and its files, including unsynced changes, stay in place. /// Returns the terminal error every later sync answers with. diff --git a/finite-brain/crates/finite-brain-cli/src/error.rs b/finite-brain/crates/finite-brain-cli/src/error.rs index c8371fc8e..aae41bf8a 100644 --- a/finite-brain/crates/finite-brain-cli/src/error.rs +++ b/finite-brain/crates/finite-brain-cli/src/error.rs @@ -89,7 +89,16 @@ impl fmt::Display for CliError { write!(f, "invalid successful HTTP response: {reason}") } Self::HttpStatus { status, body } => { - write!(f, "http request rejected with {status}: {}", body.trim()) + if let Some((brain_id, deleted_at)) = + crate::brain_deletion::brain_deleted_notice(self) + { + write!( + f, + "Brain {brain_id} was permanently deleted on the server at {deleted_at}" + ) + } else { + write!(f, "http request rejected with {status}: {}", body.trim()) + } } Self::Identity(reason) => write!(f, "finite identity error: {reason}"), Self::InsecureWorkingTree { path, reason } => write!( diff --git a/finite-brain/crates/finite-brain-cli/src/http.rs b/finite-brain/crates/finite-brain-cli/src/http.rs index 863905044..39f57fb1b 100644 --- a/finite-brain/crates/finite-brain-cli/src/http.rs +++ b/finite-brain/crates/finite-brain-cli/src/http.rs @@ -195,7 +195,7 @@ fn sync_once_with_local_paths_holding_lock( let report = run_working_tree_sync(env, args, activity_kind); if let (Err(error), Some(root)) = (&report, root.as_deref()) && let Some((brain_id, deleted_at)) = brain_deleted_notice(error) - && read_agent_state(root)?.brain_id == brain_id + && crate::brain_deletion::deletion_is_for_working_tree(env, args, root, &brain_id)? { return Err(record_brain_deleted(env, root, &brain_id, &deleted_at)?); } diff --git a/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs b/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs index 192ac8e88..d75442f8d 100644 --- a/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs +++ b/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs @@ -54,6 +54,12 @@ fn only_the_deleted_brain_answer_is_terminal() { brain_deleted_notice(&deleted), Some(("acme".to_owned(), "2026-10-25T00:00:00Z".to_owned())) ); + assert!( + deleted + .to_string() + .contains("Brain acme was permanently deleted on the server") + ); + assert!(!deleted.to_string().contains("brainDeleted")); for other in [ // An expired sync cursor is also 410, but asks for a rebootstrap. CliError::HttpStatus { @@ -71,6 +77,34 @@ fn only_the_deleted_brain_answer_is_terminal() { } } +#[test] +fn malformed_confirmation_is_reported_before_contacting_the_server() { + let tmp = TempDir::new().unwrap(); + for digest in [ + format!("sha256:{}", "AB".repeat(32)), + "sha256:short".to_owned(), + ] { + let error = run_with_env( + [ + "brain", + "delete", + "acme", + "--confirm", + &digest, + "--server", + "http://127.0.0.1:9", + ], + env_for(&tmp), + &mut Vec::new(), + ) + .unwrap_err(); + assert!( + error.to_string().contains("exact lowercase sha256:"), + "{error}" + ); + } +} + #[test] fn the_terminal_error_names_kept_unsynced_work_without_listing_everything() { let state = BrainDeletedState { diff --git a/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs b/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs index 32dcd5afd..e3a0b5ecd 100644 --- a/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs +++ b/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs @@ -5,7 +5,6 @@ use std::fs; use std::path::PathBuf; -use std::thread; use serde_json::{Value, json}; use tempfile::TempDir; @@ -480,7 +479,8 @@ fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { assert_eq!(outsider_v1.replace("v1", "never-existed"), outsider_unknown); // Former readers learn it was deleted; nobody can recreate the id. assert!( - refused(&member, &member.dir, url, &["brain", "metadata", "v1"]).contains("brain_deleted") + refused(&member, &member.dir, url, &["brain", "metadata", "v1"]) + .contains("Brain v1 was permanently deleted on the server") ); for person in [&admin, &outsider] { assert!( @@ -508,7 +508,7 @@ fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { url, &["folder", "create", "Revived", "--brain", "v1"] ) - .contains("brain_deleted") + .contains("Brain v1 was permanently deleted on the server") ); evidence.pass("invitationsTokensRecreateAndProbingRefused", json!(true)); @@ -562,7 +562,8 @@ fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { ); let server = Server::start(&database, Some(address)); assert!( - refused(&admin, &admin.dir, url, &["brain", "metadata", "v1"]).contains("brain_deleted") + refused(&admin, &admin.dir, url, &["brain", "metadata", "v1"]) + .contains("Brain v1 was permanently deleted on the server") ); assert!( refused( @@ -661,6 +662,84 @@ fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { evidence.pass("restoreDrillToEmptyTarget", json!({"limits": "one Brain database and one admin identity key; not the hosted five-part Recovery Set"})); } +#[test] +fn deletion_on_an_overridden_server_does_not_stop_the_saved_working_tree() { + let scratch = TempDir::new().unwrap(); + let admin = home(scratch.path(), "admin", ADMIN_SECRET); + let saved = Server::start(&scratch.path().join("saved.sqlite3"), None); + let other = Server::start(&scratch.path().join("other.sqlite3"), None); + for server in [&saved, &other] { + ok( + &admin, + &admin.dir, + &server.url, + &[ + "brain", + "create", + "v1", + "--kind", + "organization", + "--name", + "V1", + ], + ); + } + let tree = admin.dir.join("v1"); + ok( + &admin, + &admin.dir, + &saved.url, + &["open", "v1", tree.to_str().unwrap()], + ); + let tree_state = || -> Value { + serde_json::from_slice(&fs::read(tree.join(".finitebrain/agent-state.json")).unwrap()) + .unwrap() + }; + let preview = ok(&admin, &admin.dir, &other.url, &["brain", "delete", "v1"]); + ok( + &admin, + &tree, + &saved.url, + &[ + "brain", + "delete", + "v1", + "--confirm", + preview["preview"]["stateDigest"].as_str().unwrap(), + "--server", + &other.url, + ], + ); + assert!(tree_state()["brainDeleted"].is_null(), "{}", tree_state()); + let error = refused( + &admin, + &tree, + &saved.url, + &["sync", "now", "--server", &other.url], + ); + assert!( + error.contains("was permanently deleted on the server"), + "{error}" + ); + assert!(tree_state()["brainDeleted"].is_null(), "{}", tree_state()); + // The same ID is still live on the saved server and remains usable. + ok(&admin, &tree, &saved.url, &["sync", "now"]); + let preview = ok(&admin, &tree, &saved.url, &["brain", "delete", "v1"]); + ok( + &admin, + &tree, + &saved.url, + &[ + "brain", + "delete", + "v1", + "--confirm", + preview["preview"]["stateDigest"].as_str().unwrap(), + ], + ); + assert!(tree_state()["brainDeleted"]["deletedAt"].is_string()); +} + #[test] fn supervised_working_tree_stops_on_the_live_deletion_notice() { let scratch = TempDir::new().unwrap(); @@ -780,18 +859,32 @@ fn supervised_working_tree_stops_on_the_live_deletion_notice() { }; // Later local edits never reach the server: the worker skips the // deleted Brain, so the one blocked sync stays the only one. + wait_until("the blocked sync outcome", &log_path, || { + blocked_activity() == 1 + }); + let tick_count = || agent_state()["daemon"]["tickCount"].as_u64().unwrap_or(0); + let before_first_edit = tick_count(); fs::write( member_tree.join("Knowledge/wiki/after.md"), "# After deletion\n", ) .unwrap(); - thread::sleep(std::time::Duration::from_secs(2)); + wait_until( + "the worker observing the first post-deletion edit", + &log_path, + || tick_count() > before_first_edit, + ); + let before_second_edit = tick_count(); fs::write( member_tree.join("Knowledge/wiki/after.md"), "# After deletion, again\n", ) .unwrap(); - thread::sleep(std::time::Duration::from_secs(2)); + wait_until( + "the worker observing the second post-deletion edit", + &log_path, + || tick_count() > before_second_edit, + ); assert_eq!(blocked_activity(), 1, "{}", agent_state()); assert!(blocked_lines() <= 1); assert!(member_tree.join("Knowledge/wiki/alpha.md").is_file()); diff --git a/finitecomputer-v2/apps/dashboard/browser/brain-deletion-fixture.ts b/finitecomputer-v2/apps/dashboard/browser/brain-deletion-fixture.ts new file mode 100644 index 000000000..1e8e8a027 --- /dev/null +++ b/finitecomputer-v2/apps/dashboard/browser/brain-deletion-fixture.ts @@ -0,0 +1,29 @@ +import type { BrainEventTemplate } from "../src/lib/brain-deletion"; + +// Only used by deletion proofs to change the Brain after its preview. +export function renameBrainFixtureEvent(input: { + brainId: string; + adminNpub: string; + note: string; + changeId: string; + createdAt: number; +}): BrainEventTemplate { + return { + kind: 30_078, + created_at: input.createdAt, + tags: [ + ["d", `finite-brain-admin-access-change:${input.brainId}:${input.changeId}`], + ["brain", input.brainId], + ["action", "rename-brain"], + ], + content: JSON.stringify({ + version: "finite-brain-admin-access-change-v1", + brainId: input.brainId, + changeId: input.changeId, + action: "rename-brain", + adminNpub: input.adminNpub, + note: input.note, + createdAt: new Date(input.createdAt * 1000).toISOString().replace(/\.\d{3}Z$/u, "Z"), + }), + }; +} diff --git a/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts b/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts index 26e9b9240..13c421cc9 100644 --- a/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts +++ b/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { test } from "node:test"; import { chromium, type Browser } from "playwright"; import { chromiumLaunchOptions } from "../scripts/playwright-browser"; +import { renameBrainFixtureEvent } from "./brain-deletion-fixture"; const BIN_DIR = process.env.FC_BRAIN_DELETION_PROOF_BIN_DIR?.trim(); // The web design fixture's hosted-device credential and account. @@ -71,7 +72,7 @@ test("Delete Brain previews, refuses a stale confirmation, and deletes through t // Seed synthetic Brains with the fixture account's hosted key. process.env.FC_BRAIN_PUBLIC_ORIGIN = brainOrigin; - const { adminAccessChangeEventTemplate, hostedBrainDeletionTransport } = await import("../src/lib/brain-deletion"); + const { hostedBrainDeletionTransport } = await import("../src/lib/brain-deletion"); const admin = hostedBrainDeletionTransport( { baseUrl: deviceUrl, apiToken: DEVICE_TOKEN }, { email: "fixture-user@example.test", workosUserId: WORKOS_USER_ID, emailVerified: true, source: "dev" }, @@ -131,8 +132,8 @@ test("Delete Brain previews, refuses a stale confirmation, and deletes through t assert.equal(await remove.isDisabled(), true); // The Brain changes after the preview: the confirmation is refused. - const rename = await admin.signBrainEvent(adminAccessChangeEventTemplate({ - brainId: "v1", action: "rename-brain", adminNpub: npub, note: "V1 renamed", + const rename = await admin.signBrainEvent(renameBrainFixtureEvent({ + brainId: "v1", adminNpub: npub, note: "V1 renamed", changeId: "rename-during-review", createdAt: Math.floor(Date.now() / 1000), })); await admin.signedRequest("POST", "/v1/brains/v1/rename", JSON.stringify({ accessChangeEvent: rename })); diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx index f6a75854d..299dcd95b 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx @@ -86,7 +86,7 @@ export function BrainDeletionPanel({ brain, onDeleted }: { brain: BrainRow; onDe

Delete Brain

{phase.kind === "closed" && <>

- Permanently deletes {brain.name} for everyone. You'll review exactly what goes first. + A current admin can permanently delete {brain.name} for everyone. We'll check your account's access and show exactly what would be deleted.

} diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts index 5fe818df4..835f9e00f 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-hosted.test.ts @@ -11,6 +11,7 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { test } from "node:test"; +import { renameBrainFixtureEvent } from "../../browser/brain-deletion-fixture"; const BIN_DIR = process.env.FC_BRAIN_DELETION_PROOF_BIN_DIR?.trim(); const TOKEN = "synthetic-hosted-device-token"; @@ -86,7 +87,7 @@ test("dashboard Brain deletion signs through the hosted device and the Brain ser const route = await import("../app/api/brain/deletion/route"); const { getAccountAuthContext } = await import("./dashboard-auth"); const { hostedDeviceConfig } = await import("./hosted-web-device"); - const { adminAccessChangeEventTemplate, hostedBrainDeletionTransport } = await import("./brain-deletion"); + const { hostedBrainDeletionTransport } = await import("./brain-deletion"); const adminTransport = hostedBrainDeletionTransport(hostedDeviceConfig()!, await getAccountAuthContext(), brainOrigin); const { npub: adminNpub } = await adminTransport.identify(); for (const [brainId, name] of [["v1", "V1"], ["content-brain", "Content"], ["marketing-comms-brain", "Marketing Comms"]]) { @@ -116,8 +117,8 @@ test("dashboard Brain deletion signs through the hosted device and the Brain ser evidence.adminPreview = { server: firstScope.server, brainId: firstScope.brainId, deletable: firstScope.deletable }; // A rename after the preview invalidates it; a mistyped confirmation never signs. - const renameEvent = await adminTransport.signBrainEvent(adminAccessChangeEventTemplate({ - brainId: "v1", action: "rename-brain", adminNpub, note: "V1 (renamed)", + const renameEvent = await adminTransport.signBrainEvent(renameBrainFixtureEvent({ + brainId: "v1", adminNpub, note: "V1 (renamed)", changeId: "rename-after-preview", createdAt: Math.floor(Date.now() / 1000), })); await adminTransport.signedRequest("POST", "/v1/brains/v1/rename", JSON.stringify({ accessChangeEvent: renameEvent })); diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts index 8e580e006..8a6d5608f 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion-view.ts @@ -2,7 +2,7 @@ /// deletion panel: parsing of the Brain server's preview and receipt, and the /// confirmation rule. No server-only imports. -export const BRAIN_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,127}$/u; +export const BRAIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; export const STATE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; export type BlockingMount = { diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts index 7383fe9b4..aa00b601e 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts @@ -9,7 +9,7 @@ import { deleteBrainExactly, previewBrainDeletion, } from "./brain-deletion"; -import { deletionConfirmationReady, parseBrainDeletionPreview, previewFacts } from "./brain-deletion-view"; +import { BRAIN_ID_PATTERN, deletionConfirmationReady, parseBrainDeletionPreview, previewFacts } from "./brain-deletion-view"; const DIGEST = `sha256:${"ab".repeat(32)}`; @@ -97,6 +97,21 @@ test("the delete-brain template matches finite-brain-core's canonical event", () ); }); +test("deletion accepts case-sensitive Brain IDs without normalizing the confirmation", () => { + const brainId = "Marketing-V1_Mixed"; + assert(BRAIN_ID_PATTERN.test(brainId)); + const preview = parseBrainDeletionPreview(previewBody({ brainId })); + assert.equal(deletionConfirmationReady(preview, brainId), true); + assert.equal(deletionConfirmationReady(preview, brainId.toLowerCase()), false); + const event = brainDeletionEventTemplate({ + brainId, adminNpub: "npub1example", stateDigest: DIGEST, changeId: "c", createdAt: 1, + }); + assert.equal(JSON.parse(event.content).brainId, brainId); + for (const invalid of ["../V1", "V1/other", "V1?query", " V1"]) { + assert.equal(BRAIN_ID_PATTERN.test(invalid), false); + } +}); + test("preview parsing keeps the exact scope and refuses inconsistent answers", async () => { const { transport, calls } = fakeTransport([() => previewBody()]); const preview = await previewBrainDeletion(transport, "v1"); diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts index 3b219e317..ff2db2eaf 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts @@ -45,25 +45,27 @@ function rfc3339Seconds(unixSeconds: number) { return new Date(unixSeconds * 1000).toISOString().replace(/\.\d{3}Z$/u, "Z"); } -/// One unsigned Brain admin access-change event with a note and no Folder, -/// target or key version. Content and tags must equal +/// One unsigned delete-brain event with the preview digest as its note. +/// Content and tags must equal /// `AdminAccessChangePayload::canonical_json` and `admin_access_change_tags` /// in finite-brain-core byte for byte, or the server refuses the signature. -export function adminAccessChangeEventTemplate(input: { +export function brainDeletionEventTemplate(input: { brainId: string; - action: "delete-brain" | "rename-brain"; adminNpub: string; - note: string; + stateDigest: string; changeId: string; createdAt: number; }): BrainEventTemplate { + if (!BRAIN_ID_PATTERN.test(input.brainId) || !STATE_DIGEST_PATTERN.test(input.stateDigest)) { + throw new Error("Brain deletion needs a Brain id and a preview state digest."); + } const content = [ `"version":${JSON.stringify("finite-brain-admin-access-change-v1")}`, `"brainId":${JSON.stringify(input.brainId)}`, `"changeId":${JSON.stringify(input.changeId)}`, - `"action":${JSON.stringify(input.action)}`, + `"action":"delete-brain"`, `"adminNpub":${JSON.stringify(input.adminNpub)}`, - `"note":${JSON.stringify(input.note)}`, + `"note":${JSON.stringify(input.stateDigest)}`, `"createdAt":${JSON.stringify(rfc3339Seconds(input.createdAt))}`, ].join(","); return { @@ -72,33 +74,12 @@ export function adminAccessChangeEventTemplate(input: { tags: [ ["d", `finite-brain-admin-access-change:${input.brainId}:${input.changeId}`], ["brain", input.brainId], - ["action", input.action], + ["action", "delete-brain"], ], content: `{${content}}`, }; } -/// The unsigned `delete-brain` event; its note is the preview state digest. -export function brainDeletionEventTemplate(input: { - brainId: string; - adminNpub: string; - stateDigest: string; - changeId: string; - createdAt: number; -}): BrainEventTemplate { - if (!BRAIN_ID_PATTERN.test(input.brainId) || !STATE_DIGEST_PATTERN.test(input.stateDigest)) { - throw new Error("Brain deletion needs a Brain id and a preview state digest."); - } - return adminAccessChangeEventTemplate({ - brainId: input.brainId, - action: "delete-brain", - adminNpub: input.adminNpub, - note: input.stateDigest, - changeId: input.changeId, - createdAt: input.createdAt, - }); -} - export async function previewBrainDeletion( transport: BrainDeletionTransport, brainId: string From 084939f57dce6fac014b1f1408a246a394387396 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 26 Oct 2026 18:40:06 -0500 Subject: [PATCH 6/8] Prepare Brain 0.9.0 and document deletion rollout compatibility --- Cargo.lock | 10 +++---- .../crates/finite-brain-app/Cargo.toml | 2 +- .../crates/finite-brain-cli/Cargo.toml | 2 +- .../crates/finite-brain-core/Cargo.toml | 2 +- .../crates/finite-brain-server/Cargo.toml | 2 +- .../crates/finite-brain-store/Cargo.toml | 2 +- infra/deployment-changelog.md | 29 +++++++++++++++++++ 7 files changed, 39 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 264f19934..add93e989 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,7 +958,7 @@ dependencies = [ [[package]] name = "finite-brain-app" -version = "0.8.0" +version = "0.9.0" dependencies = [ "axum", "finite-brain-server", @@ -967,7 +967,7 @@ dependencies = [ [[package]] name = "finite-brain-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "axum", "backon", @@ -994,7 +994,7 @@ dependencies = [ [[package]] name = "finite-brain-core" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes-gcm", "base64", @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "finite-brain-server" -version = "0.8.0" +version = "0.9.0" dependencies = [ "async-stream", "axum", @@ -1032,7 +1032,7 @@ dependencies = [ [[package]] name = "finite-brain-store" -version = "0.8.0" +version = "0.9.0" dependencies = [ "finite-brain-core", "rusqlite", diff --git a/finite-brain/crates/finite-brain-app/Cargo.toml b/finite-brain/crates/finite-brain-app/Cargo.toml index cecfb2784..c8541936d 100644 --- a/finite-brain/crates/finite-brain-app/Cargo.toml +++ b/finite-brain/crates/finite-brain-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finite-brain-app" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/finite-brain/crates/finite-brain-cli/Cargo.toml b/finite-brain/crates/finite-brain-cli/Cargo.toml index fc9223292..22fed047d 100644 --- a/finite-brain/crates/finite-brain-cli/Cargo.toml +++ b/finite-brain/crates/finite-brain-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finite-brain-cli" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/finite-brain/crates/finite-brain-core/Cargo.toml b/finite-brain/crates/finite-brain-core/Cargo.toml index cd86abf07..e704ad535 100644 --- a/finite-brain/crates/finite-brain-core/Cargo.toml +++ b/finite-brain/crates/finite-brain-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finite-brain-core" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/finite-brain/crates/finite-brain-server/Cargo.toml b/finite-brain/crates/finite-brain-server/Cargo.toml index 3aeef8efe..f49544ffe 100644 --- a/finite-brain/crates/finite-brain-server/Cargo.toml +++ b/finite-brain/crates/finite-brain-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finite-brain-server" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/finite-brain/crates/finite-brain-store/Cargo.toml b/finite-brain/crates/finite-brain-store/Cargo.toml index 1eaa77b63..7a19bb6b2 100644 --- a/finite-brain/crates/finite-brain-store/Cargo.toml +++ b/finite-brain/crates/finite-brain-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finite-brain-store" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/infra/deployment-changelog.md b/infra/deployment-changelog.md index ff101dc13..cc9d3a192 100644 --- a/infra/deployment-changelog.md +++ b/infra/deployment-changelog.md @@ -122,3 +122,32 @@ The dashboard's **Set up Personal Brain** needs a Runtime whose Brain plugin and `fbrain` 0.7.1 provide `brain personal-agent-consent`; an older Runtime reports that the Agent needs an update. `fbrain open personal` selects only a Brain where the key is owner or Personal Agent from 0.7.1 on. + +### fbrain 0.9.0 and Organization Brain deletion + +Deploy the Brain server and Hosted Web Device built with the new Brain core +before exposing the dashboard Delete Brain action or publishing the CLI. +The rollout's `lat2-app-source` must include both services and the dashboard +image digest; a host revision alone does not advance them. Use the +[application release procedure](runbooks/deploy-core.md#deploy-flow--prebuilt-immutable-mono-rev). +Older Hosted Devices refuse to sign `delete-brain`, and older servers refuse +the new deletion routes without mutation. Existing Agents receive the CLI only +through a separately qualified Runtime upgrade; this dashboard action requires +no new Runtime capability. Managed skills update through `finite skills sync`. + +Brain SCHEMA_V31 adds durable deletion receipts and the prior-access audience, +plus a trigger preventing reuse of a deleted Brain ID. The previous server can +reopen the database and still blocks ID reuse, but loses the deletion API and +private `410 Gone` notices. A server rollback does not restore deleted content; +preserve the current database and subsequent accepted writes. Restoring a +pre-deletion backup is an operator-run, isolated-empty-target operation per the +[Brain restore drill](../finite-brain/docs/runbooks/brain-restore-drill.md). +Its automated proof covers a Brain database and admin identity key, not the +complete hosted Recovery Set. + +Older clients cannot recreate a deleted Brain or upload its stale content. +Updated clients additionally stop sync and keep local files and unsynced paths. +The dashboard continues to discover Brains through the selected Agent's access, +while the account's own key must be a current Brain admin to delete. The CLI +supports an admin whose Agent cannot see the Brain. Personal Brains remain +excluded; active cross-Brain mounts must be disconnected before deletion. From a94a1f1fb00254845fb6acff0483967c4bf38c13 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 26 Oct 2026 18:51:59 -0500 Subject: [PATCH 7/8] Preserve Brain deletion receipts when local bookkeeping fails --- .../finite-brain-cli/src/brain_deletion.rs | 50 ++++-- .../crates/finite-brain-cli/src/error.rs | 8 + .../crates/finite-brain-cli/src/http.rs | 3 +- .../crates/finite-brain-cli/src/models.rs | 4 + .../src/tests/brain_deletion_tests.rs | 1 + .../tests/brain_deletion/local_bookkeeping.rs | 149 ++++++++++++++++++ .../brain_deletion_process_acceptance.rs | 3 + 7 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 finite-brain/crates/finite-brain-cli/tests/brain_deletion/local_bookkeeping.rs diff --git a/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs b/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs index e81dce5e3..7151225e0 100644 --- a/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs +++ b/finite-brain/crates/finite-brain-cli/src/brain_deletion.rs @@ -132,11 +132,14 @@ pub(crate) fn brain_delete( } .map_err(|error| deletion_request_error(error, &brain_id))?; let response: BrainDeleteResponseView = serde_json::from_value(response)?; - if let Some(root) = find_agent_state(&env.cwd)? - && deletion_is_for_working_tree(env, args, &root, &brain_id)? - { - record_brain_deleted(env, &root, &brain_id, &response.receipt.deleted_at)?; - } + // The server has committed. Local discovery, scanning or persistence must + // not turn that accepted outcome into an error that hides its receipt. + let local_warning = record_confirmed_deletion(env, args, &response.receipt) + .unwrap_or_else(|error| { + Some(format!( + "Server deletion succeeded, but could not record or check the local Working Tree's deletion state: {error}. Local files were left in place; keep this receipt and inspect the Working Tree before removing it" + )) + }); if json { write_json( output, @@ -146,6 +149,7 @@ pub(crate) fn brain_delete( "server": preview.server, "name": preview.name, "receipt": response.receipt, + "warnings": local_warning.into_iter().collect::>(), }), ) } else { @@ -176,10 +180,32 @@ pub(crate) fn brain_delete( output, "Local Working Trees, exports and other offline copies are not erased; remove them when you no longer need them" )?; + if let Some(warning) = local_warning { + writeln!(output, "Warning: {warning}")?; + } Ok(()) } } +fn record_confirmed_deletion( + env: &CliEnvironment, + args: &[String], + receipt: &BrainDeletionReceiptView, +) -> Result, CliError> { + let Some(root) = find_agent_state(&env.cwd)? else { + return Ok(None); + }; + if !deletion_is_for_working_tree(env, args, &root, &receipt.brain_id)? { + return Ok(None); + } + let deleted = record_brain_deleted(env, &root, &receipt.brain_id, &receipt.deleted_at)?; + Ok(deleted.unsynced_paths_error.map(|error| { + format!( + "Sync stopped, but could not list unsynced local changes: {error}. Local files were left in place; inspect them before removing the Working Tree" + ) + })) +} + fn fetch_brain_deletion_preview( env: &CliEnvironment, args: &[String], @@ -336,14 +362,18 @@ pub(crate) fn deletion_is_for_working_tree( /// Mark the Working Tree at `root` as belonging to a deleted Brain: it never /// syncs again, and its files, including unsynced changes, stay in place. -/// Returns the terminal error every later sync answers with. +/// Returns the persisted terminal state. A failed local scan is recorded as +/// incomplete bookkeeping and must not prevent the terminal marker being saved. pub(crate) fn record_brain_deleted( env: &CliEnvironment, root: &Path, brain_id: &str, deleted_at: &str, -) -> Result { - let unsynced_paths = pending_working_tree_change_paths(root)?; +) -> Result { + let (unsynced_paths, unsynced_paths_error) = match pending_working_tree_change_paths(root) { + Ok(paths) => (paths, None), + Err(error) => (Vec::new(), Some(error.to_string())), + }; let mut tree_env = env.clone(); tree_env.cwd = root.to_path_buf(); let mut recorded = None; @@ -354,6 +384,7 @@ pub(crate) fn record_brain_deleted( deleted_at: deleted_at.to_owned(), detected_at: now.clone(), unsynced_paths, + unsynced_paths_error, }) .clone(); let error = brain_deleted_error(brain_id, &deleted); @@ -369,7 +400,7 @@ pub(crate) fn record_brain_deleted( deleted.deleted_at ), ); - recorded = Some(error); + recorded = Some(deleted); Ok(()) })?; recorded.ok_or_else(|| CliError::InvalidInput("Agent State was not updated".to_owned())) @@ -387,5 +418,6 @@ pub(crate) fn brain_deleted_error(brain_id: &str, deleted: &BrainDeletedState) - .cloned() .collect(), unsynced_count: deleted.unsynced_paths.len(), + unsynced_paths_error: deleted.unsynced_paths_error.clone(), } } diff --git a/finite-brain/crates/finite-brain-cli/src/error.rs b/finite-brain/crates/finite-brain-cli/src/error.rs index aae41bf8a..7c59b229f 100644 --- a/finite-brain/crates/finite-brain-cli/src/error.rs +++ b/finite-brain/crates/finite-brain-cli/src/error.rs @@ -65,6 +65,7 @@ pub enum CliError { /// The first unsynced local paths, kept in place. unsynced_paths: Vec, unsynced_count: usize, + unsynced_paths_error: Option, }, } @@ -144,6 +145,7 @@ impl fmt::Display for CliError { deleted_at, unsynced_paths, unsynced_count, + unsynced_paths_error, } => { write!( f, @@ -161,6 +163,12 @@ impl fmt::Display for CliError { } )?; } + if let Some(error) = unsynced_paths_error { + write!( + f, + ". Warning: could not list unsynced local changes: {error}; inspect the preserved files" + )?; + } write!( f, ". Copy anything you still need, then remove the Working Tree" diff --git a/finite-brain/crates/finite-brain-cli/src/http.rs b/finite-brain/crates/finite-brain-cli/src/http.rs index 39f57fb1b..23dadee11 100644 --- a/finite-brain/crates/finite-brain-cli/src/http.rs +++ b/finite-brain/crates/finite-brain-cli/src/http.rs @@ -197,7 +197,8 @@ fn sync_once_with_local_paths_holding_lock( && let Some((brain_id, deleted_at)) = brain_deleted_notice(error) && crate::brain_deletion::deletion_is_for_working_tree(env, args, root, &brain_id)? { - return Err(record_brain_deleted(env, root, &brain_id, &deleted_at)?); + let deleted = record_brain_deleted(env, root, &brain_id, &deleted_at)?; + return Err(brain_deleted_error(&brain_id, &deleted)); } if report.as_ref().is_err_and(is_brain_access_loss) { let _ = mutate_agent_state(env, |state, now| { diff --git a/finite-brain/crates/finite-brain-cli/src/models.rs b/finite-brain/crates/finite-brain-cli/src/models.rs index 87905f48b..6eb29ca8d 100644 --- a/finite-brain/crates/finite-brain-cli/src/models.rs +++ b/finite-brain/crates/finite-brain-cli/src/models.rs @@ -37,6 +37,10 @@ pub(crate) struct BrainDeletedState { pub(crate) detected_at: String, /// Local changes never uploaded before deletion; kept in place. pub(crate) unsynced_paths: Vec, + /// A failed scan means the paths above are incomplete, not that the tree + /// has no unsynced work. It never prevents recording terminal deletion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) unsynced_paths_error: Option, } impl AgentState { diff --git a/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs b/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs index d75442f8d..191ed972d 100644 --- a/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs +++ b/finite-brain/crates/finite-brain-cli/src/tests/brain_deletion_tests.rs @@ -113,6 +113,7 @@ fn the_terminal_error_names_kept_unsynced_work_without_listing_everything() { unsynced_paths: (0..25) .map(|index| format!("Notes/{index:02}.md")) .collect(), + unsynced_paths_error: None, }; let message = brain_deleted_error("acme", &state).to_string(); assert!( diff --git a/finite-brain/crates/finite-brain-cli/tests/brain_deletion/local_bookkeeping.rs b/finite-brain/crates/finite-brain-cli/tests/brain_deletion/local_bookkeeping.rs new file mode 100644 index 000000000..60c291d39 --- /dev/null +++ b/finite-brain/crates/finite-brain-cli/tests/brain_deletion/local_bookkeeping.rs @@ -0,0 +1,149 @@ +//! A successful server deletion remains successful when local bookkeeping fails. + +use super::*; + +#[test] +fn confirmed_deletion_keeps_receipt_and_terminal_state_when_local_scan_fails() { + for json_output in [true, false] { + let scratch = TempDir::new().unwrap(); + let admin = home(scratch.path(), "admin", ADMIN_SECRET); + let database = scratch.path().join("brain.sqlite3"); + let server = Server::start(&database, None); + let tree = create_tree(&admin, &server); + let bad_file = tree.join("Knowledge/wiki/invalid.md"); + let bytes = b"# Unsynced local work\n\xff\xfe"; + fs::write(&bad_file, bytes).unwrap(); + let preview = ok(&admin, &tree, &server.url, &["brain", "delete", "v1"]); + let digest = preview["preview"]["stateDigest"].as_str().unwrap(); + let mut delete = command(&admin.dir, &tree, &server.url); + delete.args(["brain", "delete", "v1", "--confirm", digest]); + if json_output { + delete.arg("--json"); + } + let output = delete.output().unwrap(); + + // The server has already committed, regardless of local bookkeeping. + assert!(brain_rows(&database, "v1").is_empty()); + assert_eq!(fs::read(&bad_file).unwrap(), bytes); + assert!(output.status.success(), "{}", stderr(&output)); + let state: Value = + serde_json::from_slice(&fs::read(tree.join(".finitebrain/agent-state.json")).unwrap()) + .unwrap(); + assert!(state["brainDeleted"]["deletedAt"].is_string(), "{state}"); + assert!( + state["brainDeleted"]["unsyncedPathsError"].is_string(), + "{state}" + ); + if json_output { + let deleted: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(deleted["state"], "deleted"); + assert_eq!(deleted["receipt"]["brainId"], "v1"); + assert_eq!(deleted["receipt"]["stateDigest"], digest); + assert_eq!( + deleted["receipt"]["deletedAt"], + state["brainDeleted"]["deletedAt"] + ); + assert!(deleted["receipt"]["deletionEventId"].is_string()); + assert!( + deleted["warnings"][0] + .as_str() + .unwrap() + .contains("could not list unsynced") + ); + } else { + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("deleted Organization Brain"), "{text}"); + assert!(text.contains("receipt deletionEvent="), "{text}"); + assert!(text.contains("Warning:"), "{text}"); + assert!(text.contains("could not list unsynced"), "{text}"); + } + + drop(server); + let sync = refused(&admin, &tree, "http://127.0.0.1:9", &["sync", "now"]); + assert!( + sync.contains("was permanently deleted on the server"), + "{sync}" + ); + assert!(sync.contains("could not list unsynced"), "{sync}"); + assert_eq!(fs::read(&bad_file).unwrap(), bytes); + } +} + +#[test] +fn confirmed_deletion_keeps_receipt_when_local_state_cannot_be_updated() { + let scratch = TempDir::new().unwrap(); + let admin = home(scratch.path(), "admin", ADMIN_SECRET); + let database = scratch.path().join("brain.sqlite3"); + let server = Server::start(&database, None); + let tree = create_tree(&admin, &server); + let local_file = tree.join("Knowledge/wiki/local.md"); + fs::write(&local_file, "# Keep my local work\n").unwrap(); + let preview = ok(&admin, &tree, &server.url, &["brain", "delete", "v1"]); + let state_path = tree.join(".finitebrain/agent-state.json"); + fs::write(&state_path, "{broken local state").unwrap(); + let output = command(&admin.dir, &tree, &server.url) + .args([ + "brain", + "delete", + "v1", + "--confirm", + preview["preview"]["stateDigest"].as_str().unwrap(), + "--server", + &server.url, + "--json", + ]) + .output() + .unwrap(); + assert!(brain_rows(&database, "v1").is_empty()); + assert!(output.status.success(), "{}", stderr(&output)); + let deleted: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(deleted["state"], "deleted"); + assert_eq!(deleted["receipt"]["brainId"], "v1"); + assert!(deleted["receipt"]["deletionEventId"].is_string()); + assert!( + deleted["warnings"][0] + .as_str() + .unwrap() + .contains("could not record") + ); + assert_eq!( + fs::read_to_string(state_path).unwrap(), + "{broken local state" + ); + assert_eq!( + fs::read_to_string(local_file).unwrap(), + "# Keep my local work\n" + ); +} + +fn create_tree(admin: &Home, server: &Server) -> PathBuf { + ok( + admin, + &admin.dir, + &server.url, + &[ + "brain", + "create", + "v1", + "--kind", + "organization", + "--name", + "V1", + ], + ); + let tree = admin.dir.join("v1"); + ok( + admin, + &admin.dir, + &server.url, + &["open", "v1", tree.to_str().unwrap()], + ); + ok( + admin, + &tree, + &server.url, + &["folder", "create", "Knowledge"], + ); + ok(admin, &tree, &server.url, &["sync", "now"]); + tree +} diff --git a/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs b/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs index e3a0b5ecd..642c13040 100644 --- a/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs +++ b/finite-brain/crates/finite-brain-cli/tests/brain_deletion_process_acceptance.rs @@ -13,6 +13,9 @@ use tempfile::TempDir; mod harness; use harness::*; +#[path = "brain_deletion/local_bookkeeping.rs"] +mod local_bookkeeping; + #[test] fn built_fbrain_deletes_v1_and_leaves_content_and_marketing_brains_intact() { let scratch = TempDir::new().unwrap(); From a992859ccefcead4ed43c56b16f5159082f16cce Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Tue, 27 Oct 2026 08:26:04 -0500 Subject: [PATCH 8/8] Clarify Brain deletion confirmation and stale-preview messages --- .../dashboard/browser/brain-deletion.browser.ts | 2 ++ .../src/components/brain-deletion-panel.tsx | 2 +- .../apps/dashboard/src/lib/brain-deletion.test.ts | 13 ++++++++++++- .../apps/dashboard/src/lib/brain-deletion.ts | 12 +++++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts b/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts index 13c421cc9..e7942396d 100644 --- a/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts +++ b/finitecomputer-v2/apps/dashboard/browser/brain-deletion.browser.ts @@ -125,6 +125,7 @@ test("Delete Brain previews, refuses a stale confirmation, and deletes through t await page.goto(`${base}/dashboard/machines/runtime_web_design/brain`); await page.getByRole("button", { name: "V1", exact: true }).click(); const panel = page.getByRole("region", { name: "Delete V1", exact: true }); + await panel.getByText("A current admin can permanently delete V1 for everyone.", { exact: false }).waitFor(); await panel.getByRole("button", { name: "Review deletion…" }).click(); await panel.getByText("Delete V1 permanently?").waitFor(); await panel.getByText(brainOrigin, { exact: true }).waitFor(); @@ -140,6 +141,7 @@ test("Delete Brain previews, refuses a stale confirmation, and deletes through t await panel.getByLabel("Type v1 to confirm").fill("v1"); await remove.click(); await panel.getByRole("alert").filter({ hasText: "changed after its deletion preview" }).waitFor(); + assert.doesNotMatch(await panel.getByRole("alert").innerText(), /current revision|sync conflict/u); assert.equal(await (await admin.signedRequest("GET", "/v1/brains") as { brains: unknown[] }).brains.length, 3); const artifacts = process.env.FC_BROWSER_ARTIFACT_DIR; diff --git a/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx b/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx index 299dcd95b..03584503b 100644 --- a/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx +++ b/finitecomputer-v2/apps/dashboard/src/components/brain-deletion-panel.tsx @@ -86,7 +86,7 @@ export function BrainDeletionPanel({ brain, onDeleted }: { brain: BrainRow; onDe

Delete Brain

{phase.kind === "closed" && <>

- A current admin can permanently delete {brain.name} for everyone. We'll check your account's access and show exactly what would be deleted. + A current admin can permanently delete {brain.name}{" "}for everyone. We'll check your account's access and show exactly what would be deleted.

} diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts index aa00b601e..b25ccc441 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.test.ts @@ -179,7 +179,7 @@ test("deletion signs the confirmed digest and retries only a lost transport once }); test("refusals keep the Brain server's status and message", () => { - for (const status of [403, 404, 409, 410, 400]) { + for (const status of [403, 404, 410, 400]) { assert.deepEqual(brainDeletionFailure(new BrainHostedClientError(`server says ${status}`, status)), { status, message: `server says ${status}`, @@ -188,3 +188,14 @@ test("refusals keep the Brain server's status and message", () => { assert.equal(brainDeletionFailure(new BrainHostedClientError("boom", 500)).status, 502); assert.equal(brainDeletionFailure(new TypeError("fetch failed")).status, 502); }); + +test("deletion conflicts explain the next step without exposing revision internals", () => { + for (const reason of ["Brain changed after its deletion preview", "Brain deletion confirmation expired", "other conflict"]) { + const result = brainDeletionFailure(new BrainHostedClientError(`sync conflict: ${reason}; current revision: None`, 409)); + assert.equal(result.status, 409); + assert.match(result.message, /Nothing was deleted\. Review a fresh preview/u); + assert.doesNotMatch(result.message, /sync conflict|current revision|None/u); + } + const mounts = "Brain has 1 active cross-Brain Mount(s) (connection-1); revoke them before deleting the Brain. Nothing was deleted"; + assert.deepEqual(brainDeletionFailure(new BrainHostedClientError(mounts, 409)), { status: 409, message: mounts }); +}); diff --git a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts index ff2db2eaf..300b95d90 100644 --- a/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts +++ b/finitecomputer-v2/apps/dashboard/src/lib/brain-deletion.ts @@ -155,7 +155,17 @@ export function hostedBrainDeletionTransport( /// What the dashboard tells the account after a refused request. export function brainDeletionFailure(error: unknown): { status: number; message: string } { if (error instanceof BrainHostedClientError) { - if ([403, 404, 409, 410].includes(error.status)) { + if (error.status === 409) { + const message = error.message.includes("changed after its deletion preview") + ? "This Brain changed after its deletion preview. Nothing was deleted. Review a fresh preview and confirm again." + : error.message.includes("confirmation expired") + ? "This deletion confirmation expired. Nothing was deleted. Review a fresh preview and confirm again." + : error.message.includes("active cross-Brain Mount") + ? error.message + : "This Brain can't be deleted in its current state. Nothing was deleted. Review a fresh preview before trying again."; + return { status: 409, message }; + } + if ([403, 404, 410].includes(error.status)) { return { status: error.status, message: error.message }; } if (error.status === 400) return { status: 400, message: error.message };