diff --git a/Justfile b/Justfile index 29814653674..5c15a7338a1 100644 --- a/Justfile +++ b/Justfile @@ -374,6 +374,13 @@ test-unit: cargo test -p buzz-auth --doc cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli + # buzz-sdk builder/validation unit tests: pure event-builder and input + # validation (e.g. the canvas writer-discipline/skew guard and the + # canvas_write_survived predicate), no infra. `--lib` runs all unit + # tests without the rustdoc dependency-resolution flake the full-package + # invocation hits. Enumerated explicitly because nothing in CI runs + # `cargo test --workspace` — membership buys clippy/check, not tests. + cargo nextest run -p buzz-sdk --lib # buzz-acp owns the relay-to-agent trust boundary. Run its tests here so # forged relay events cannot regain a path into agent routing unnoticed. cargo nextest run -p buzz-acp diff --git a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md new file mode 100644 index 00000000000..b6442d4f6dc --- /dev/null +++ b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md @@ -0,0 +1,74 @@ +--- +title: "Replica Full-Read Routing — Caller Classification" +tags: [relay, replica-routing, consistency] +status: active +created: 2026-08-28 +--- + +# Replica Full-Read Routing — Caller Classification + +`Db::query_events` always reads the **writer** pool. `Db::query_events_routed` +(and its `_bounded` / `count` / feed siblings) opt a read into **replica +routing**: the read may be served from a read replica when +`BUZZ_REPLICA_READ_MAX_AGE_MS` is set, subject to the soundness predicate +`RoutePredicate::for_query` derives from the query shape. The seam fails closed +to the writer at every error and is a genuine no-op until the budget is +configured (`crates/buzz-db/src/lib.rs`). + +## Routing rule + +**If a read's result influences a write or a permission decision, it reads from +the writer.** A replica can lag behind the caller's own just-committed write; a +read that gates the next write against that lag would make a wrong decision +(e.g. validate a save against a stale head, or miss the caller's own accepted +event during post-write verification and report a false conflict). Every such +read stays on `query_events` / writer. + +Display reads — a page the user scrolls, a count on a badge, a history list — +tolerate bounded staleness and take the routed path. + +Adding, removing, or reclassifying a caller **requires updating the table +below**; the `query_events_routed` doc-comment points here. + +### Client-carried intent: the `consistency` extension field + +Some write-influencing reads are issued by clients (Desktop, CLI) through the +HTTP `/query` bridge, and are **indistinguishable by query shape** from display +reads — a kind-40100 `limit:1` read serves both `get_canvas` (display) and a +canvas save's head precondition. The client therefore signals intent with the +`consistency` extension field on the raw filter: + +- `"consistency": "strong"` → the bridge serves that filter from the writer + (`query_events`), never a replica. +- absent → the default routed path. +- any other value → `400 Bad Request` (fails loud, never silently degrades). + +The field only ever forces the **writer**, which is always the sound direction; +there is deliberately **no** inverse "force replica" value, so it cannot be +sprayed to bypass the replica-staleness guard on reads that should not. Parsed +in `crates/buzz-relay/src/api/bridge.rs` (`extract_consistency`). + +## Caller classification table + +Rows below are every `query_events_routed` / `query_events_routed_bounded` +call site at head, plus the writer-pinned canvas row this change adds. (The +`count` and feed routed families — `count_events_routed`, +`get_events_by_ids_routed`, `query_feed_*_routed`, and the `get_channel_window` +cursor/head reads — are all display or bounded-count surfaces on the routed +path; they carry their own soundness notes at their definitions in +`crates/buzz-db/src/lib.rs` and are out of scope for this table.) + +| Caller / path label | Pool | Justification | +|---|---|---| +| `bridge_query` (default `/query` filter) | routed | Display reads over the HTTP bridge; bounded staleness acceptable. | +| `bridge_query` + `consistency: strong` | **writer** | Client-declared write-influencing read (canvas save precondition, restore precondition, post-write ancestry verification). | +| `req_historical` (WS REQ historical page) | routed | Display backfill of a subscription; per-row re-filter absorbs a briefly-stale row. | +| `bridge_thread_aux` (`AuxReader::Routed`, thread aux page) | routed | Thread reply hydration; display, post-verified against the fence wall. | +| `bridge_count_fallback` (`query_events_routed_bounded`) | routed (bounded arm) | COUNT fallback that materializes rows; bounded arm only, never covered. | +| `count_req_fallback` (`query_events_routed_bounded`) | routed (bounded arm) | WS COUNT fallback that materializes rows; bounded arm only. | + +The **writer** row is the only write-influencing entry; every other caller is a +display or count surface that tolerates bounded staleness. Client canvas reads +that gate a write set `consistency: strong` (Desktop +`desktop/src-tauri/src/commands/canvas.rs`, CLI +`crates/buzz-cli/src/commands/channels.rs`) so they land on the writer row. diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 72168793588..f956d0f7f9d 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -267,16 +267,78 @@ pub async fn cmd_list_channel_members( Ok(()) } -pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; +/// Fetch the live canvas head (kind 40100) for a channel. +/// +/// The relay orders results `created_at DESC, id ASC`, so a `limit: 1` query +/// returns exactly the head every surface agrees on — no full-stream scan, no +/// silent page clamp. +/// +/// `writer_pinned` opts into read-your-writes: when the head read gates a write +/// (the restore precondition, or `set`'s writer-discipline stamping) it must +/// observe the caller's own recent writes, so it sets `consistency: strong` to +/// pin the relay read to the writer pool. The display path (`get`) leaves it +/// `false` and stays replica-eligible. +async fn fetch_canvas_head( + client: &BuzzClient, + channel_id: &str, + writer_pinned: bool, +) -> Result, CliError> { + let mut filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + }); + if writer_pinned { + filter["consistency"] = serde_json::json!("strong"); + } + let resp = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&resp).map_err(|e| { + CliError::Other(format!( + "malformed relay response querying canvas head: {e}" + )) + })?; + Ok(events.into_iter().next()) +} + +/// Fetch a single canvas revision by event ID, scoped to the channel and kind. +/// +/// An ID-scoped query resolves revisions of any age; scanning a capped stream +/// would report a retained-but-old revision as absent. +async fn fetch_canvas_revision( + client: &BuzzClient, + channel_id: &str, + revision: &str, +) -> Result, CliError> { let filter = serde_json::json!({ + "ids": [revision], "kinds": [40100], - "#h": [channel_id] + "#h": [channel_id], + "limit": 1, }); let resp = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - if let Some(content) = events - .first() + let events: Vec = serde_json::from_str(&resp).map_err(|e| { + CliError::Other(format!( + "malformed relay response querying canvas revision: {e}" + )) + })?; + Ok(events.into_iter().next()) +} + +pub async fn cmd_get_canvas( + client: &BuzzClient, + channel_id: &str, + revision: Option<&str>, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let selected = match revision { + Some(revision) => { + validate_hex64(revision)?; + fetch_canvas_revision(client, channel_id, revision).await? + } + None => fetch_canvas_head(client, channel_id, false).await?, + }; + if let Some(content) = selected + .as_ref() .and_then(|e| e.get("content")) .and_then(|c| c.as_str()) { @@ -287,6 +349,309 @@ pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), Ok(()) } +/// List canvas revisions newest-first as JSON `{event_id, author, created_at}`. +/// +/// Uses composite `(until, before_id)` pagination so a `limit` above one relay +/// page returns the full requested window instead of a silently truncated one. +/// The Clap layer bounds `limit` to 1–10000, so the request is never unbounded. +/// +/// Ordering is `created_at DESC, id ASC`: revisions sharing a second break the +/// tie by smallest event id, not by which write arrived last. +pub async fn cmd_canvas_history( + client: &BuzzClient, + channel_id: &str, + limit: u32, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + }); + let events = client.query_paginated(filter, limit).await?; + let revisions: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "author": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + }) + }) + .collect(); + println!("{}", serde_json::to_string(&revisions).unwrap_or_default()); + Ok(()) +} + +/// Restore the canvas to a previous revision by re-publishing its content. +/// +/// The target revision is fetched by an ID-scoped query (resolves any age) and +/// the head by a separate `limit: 1` query — neither scans the full stream. +/// Fetching the head immediately before the write is an optimistic +/// precondition check: a missing target revision or absent head fails here, and +/// restoring the current head is short-circuited so history never grows a +/// redundant revision. The republished event is built via +/// [`buzz_sdk::build_set_canvas_after_head`], which applies writer discipline +/// (`created_at = max(now, head.created_at + 1)`) so the restore sorts strictly +/// ahead of the head it read; the `expected-revision` tag it carries is +/// enforced by the relay as a CAS (compare-and-swap): the relay reads the +/// canonical live head under an advisory lock and rejects writes whose +/// precondition no longer matches. +/// +/// After publishing, a single post-write re-read classifies whether the restore +/// still holds the visible head (or a later write legitimately built on it). If +/// a concurrent write has become current, this returns [`CliError::Conflict`] +/// (exit 5) naming the persisted revision — the restore is not lost, it survives +/// in history. If the verification read itself fails, the accepted restore is +/// reported as success (exit 0) with a stderr warning that verification was +/// unavailable — a failed read must never masquerade as a failed restore. +pub async fn cmd_restore_canvas( + client: &BuzzClient, + channel_id: &str, + revision: &str, + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { + let channel_uuid = parse_uuid(channel_id)?; + validate_hex64(revision)?; + + let content = fetch_canvas_revision(client, channel_id, revision) + .await? + .as_ref() + .and_then(|e| e.get("content")) + .and_then(|c| c.as_str()) + .ok_or_else(|| { + CliError::Usage(format!( + "revision {revision} not found for channel {channel_id}" + )) + })? + .to_string(); + + let head = fetch_canvas_head(client, channel_id, true) + .await? + .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; + let head_id = head + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; + let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + + // Restoring the revision that is already the head would publish a new event + // with identical content, growing history with a redundant revision. Match + // Desktop, which hides Restore on the current revision, by short-circuiting. + if head_id.eq_ignore_ascii_case(revision) { + eprintln!("revision {revision} is already the current revision"); + let _ = writeln!( + out, + "{}", + serde_json::json!({ + "event_id": revision, + "accepted": true, + "message": "already-current", + }) + ); + return Ok(()); + } + + let builder = + buzz_sdk::build_set_canvas_after_head(channel_uuid, &content, head_id, head_created_at) + .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; + let event = client.sign_event(builder)?; + let our_id = event.id.to_hex(); + let submit_result = client.submit_event(event).await; + + // If the relay returned a conflict-shaped error the submit may still have + // persisted: a prior attempt stored A(expected=H), the response was lost, + // and the retry of identical A bytes sees RevisionMismatch — a persisted + // restore presented as a rejected write. Two concurrent-write shapes arise: + // + // - A tagged write B(expected=A) becomes head: `canvas_write_survived` + // returns true; A committed and is an accepted ancestor → success. + // - An unconditional (legacy) write B(no expected-revision) becomes head: + // `canvas_write_survived` returns false because B carries no ancestry link + // to A, yet A may still be durably in the stream. A bounded ancestry walk + // alone cannot distinguish "A is absent" from "A is present but not an + // ancestor of head." + // + // Fix: establish A's persistence via a separate writer-pinned IDs lookup, + // then feed the four-way classification: + // - survived (ancestor of head) → accepted JSON, exit 0 + // - persisted but head unrelated to A → supersession naming A + // - genuinely absent → original 409 rejection + // - either reconciliation read fails → DeliveryUnknown naming A + let resp = match submit_result { + Ok(resp) => resp, + Err(e @ CliError::Relay { status: 409, .. }) if matches!(&e, CliError::Relay { body, .. } if body.contains("canvas changed")) => + { + // Both reads use strong consistency so read-replica lag cannot + // report a just-stored event as absent. + let stream = match fetch_canvas_ancestry(client, channel_id).await { + Ok(s) => s, + Err(_) => { + return Err(CliError::DeliveryUnknown(format!( + "canvas restore {our_id}: submit returned a conflict and the post-submit ancestry read failed; outcome unknown — check `buzz canvas history` before retrying" + ))); + } + }; + if buzz_sdk::canvas_write_survived(&our_id, &stream) { + // A is an ancestor of the live head: it committed and a later + // write built on it. This is the same committed state the + // normal accepted-submit path treats as success — return + // accepted JSON so the caller is not prompted to re-restore. + // The relay's 409 response body is not an accepted response; + // synthesize one from the event ID we know committed. + let accepted_json = serde_json::json!({ + "event_id": our_id, + "accepted": true, + "message": "", + }) + .to_string(); + let _ = writeln!(out, "{accepted_json}"); + return Ok(()); + } + // A is not a reachable ancestor. Distinguish absence from presence + // with a separate writer-pinned IDs lookup (an unconditional legacy + // write can become head without linking back to A, so the bounded + // ancestry stream alone cannot confirm A exists). + match fetch_canvas_event_exists(client, channel_id, &our_id).await { + Ok(true) => { + // A committed but is no longer an ancestor of the current + // head: an unconditional write superseded it. Surface as a + // post-write supersession so the caller knows A is durable. + return Err(CliError::Conflict(format!( + "canvas restore {our_id} was superseded by a concurrent write; it is preserved in history — re-run restore against the current head if you still want it" + ))); + } + Ok(false) => { + // A is genuinely absent: return the original relay error unchanged. + return Err(e); + } + Err(_) => { + // Cannot confirm or deny whether A was stored. + return Err(CliError::DeliveryUnknown(format!( + "canvas restore {our_id}: submit returned a conflict and the post-submit existence check failed; outcome unknown — check `buzz canvas history` before retrying" + ))); + } + } + } + Err(e) => return Err(e), + }; + + // Post-write supersession check. Re-read a recent slice of the revision + // stream and classify via the SDK's shared predicate: our event is the + // head, or reachable through the head's `expected-revision` ancestry chain + // (a later write legitimately built on ours, possibly transitively) → the + // restore is live; anything else (including no head) → a concurrent write + // won the visible head. The restored revision is preserved in history, so + // surface a conflict naming it rather than reporting a hollow success. This + // only catches a competitor visible by now; the residual race past this + // read needs relay linearization (phase 2). + // + // The submit above was accepted, so the restore is durable. Only this + // verification read can still fail; when it does we must not present an + // accepted publish as a failed restore. Print the normal success response + // naming `our_id`, warn on stderr that verification was unavailable, and + // exit 0 — a failed read is not a conflict. + let live_stream = match fetch_canvas_ancestry(client, channel_id).await { + Ok(stream) => stream, + Err(_) => { + eprintln!( + "warning: canvas restore {our_id} was published but its post-write verification read failed; the restore is preserved in history — check `buzz canvas history` if a concurrent edit may have landed" + ); + let _ = writeln!(out, "{}", normalize_write_response(&resp)); + return Ok(()); + } + }; + if !buzz_sdk::canvas_write_survived(&our_id, &live_stream) { + return Err(CliError::Conflict(format!( + "canvas restore {our_id} was superseded by a concurrent write; it is preserved in history — re-run restore against the current head if you still want it" + ))); + } + + let _ = writeln!(out, "{}", normalize_write_response(&resp)); + Ok(()) +} + +/// Read a recent slice of the canvas revision stream as `(event_id, +/// expected-revision tag)` pairs, newest first, for the post-write supersession +/// check. The head is the first element; the rest let +/// [`buzz_sdk::canvas_write_survived`] walk `expected-revision` links back +/// through a descendant chain (A→B→C) so a legitimate later write layered on +/// ours is not misread as a supersession. An empty vec means no canvas exists. +async fn fetch_canvas_ancestry( + client: &BuzzClient, + channel_id: &str, +) -> Result)>, CliError> { + let filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": buzz_sdk::CANVAS_ANCESTRY_WALK_MAX, + // Read-your-writes: this post-write verification read must observe the + // restore we just published, so it pins to the writer pool. + "consistency": "strong", + }); + let resp = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&resp).map_err(|e| { + CliError::Other(format!( + "malformed relay response querying canvas head: {e}" + )) + })?; + Ok(events + .iter() + .filter_map(|event| { + let id = event.get("id").and_then(|v| v.as_str())?.to_string(); + Some((id, extract_head_expected_revision(event))) + }) + .collect()) +} + +/// Whether a canvas event with `event_id` exists in this channel's history, +/// using a writer-pinned (strong-consistency) read so the caller is not +/// reporting a truly-persisted event as absent due to read-replica lag. +/// +/// Used by the 409-reconciliation branch to establish persistence independently +/// of ancestry position: an unconditional ("legacy") write can become head +/// without building an `expected-revision` link to the event being checked, +/// so a bounded ancestry walk alone cannot confirm presence. +async fn fetch_canvas_event_exists( + client: &BuzzClient, + channel_id: &str, + event_id: &str, +) -> Result { + let filter = serde_json::json!({ + "ids": [event_id], + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + "consistency": "strong", + }); + let resp = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&resp).map_err(|e| { + CliError::Other(format!( + "malformed relay response checking canvas event existence: {e}" + )) + })?; + Ok(!events.is_empty()) +} + +/// The head's own `["expected-revision", …]` tag value (the id it built on), or +/// `None` when absent — used by the post-write supersession check to recognize +/// a later write that legitimately layered on top of ours. +fn extract_head_expected_revision(head: &serde_json::Value) -> Option { + head.get("tags") + .and_then(|t| t.as_array()) + .and_then(|tags| { + tags.iter().find(|t| { + t.as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + == Some("expected-revision") + }) + }) + .and_then(|t| t.as_array()) + .and_then(|a| a.get(1)) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + pub async fn cmd_create_channel( client: &BuzzClient, name: &str, @@ -1120,7 +1485,9 @@ pub async fn cmd_create_channel_from_template( .replace("{channel.name}", name) .replace("{template.name}", &template.name); let canvas_result: Result<(), CliError> = async { - let builder = buzz_sdk::build_set_canvas(channel_uuid, &content) + // Fresh channel: no head can exist yet, so assert the create with + // `Some("none")` to match the create-assertion convention. + let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some("none")) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; let event = client.sign_event(builder)?; client.submit_event(event).await?; @@ -1461,8 +1828,31 @@ pub async fn cmd_set_canvas( let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; - let builder = buzz_sdk::build_set_canvas(channel_uuid, &content) - .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; + // `set` is an unconditional replace — no `expected-revision` tag, so the + // relay accepts the write regardless of the current head. A moved head is + // not an error; there is no post-write supersession check. + // + // Writer discipline still applies: read the head and stamp + // `created_at = max(now, head + 1)` so the write sorts strictly ahead of a + // newer or future-dated head under `created_at DESC, id ASC`. This keeps + // an accepted append from landing behind the current head in read order, + // which would "succeed" without changing the visible canvas. + // + // The skew guard applies: a head timestamped more than CANVAS_MAX_FUTURE_SKEW_SECS + // in the future is refused with a clear error rather than extending a poisoned + // timeline. With no head yet, `build_set_canvas` with no tag stamps at `now`. + let builder = match fetch_canvas_head(client, channel_id, true).await? { + Some(head) => { + let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + buzz_sdk::build_set_canvas_unconditional_after_head( + channel_uuid, + &content, + head_created_at, + ) + } + None => buzz_sdk::build_set_canvas(channel_uuid, &content, None), + } + .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -1578,8 +1968,14 @@ pub async fn dispatch( pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::CanvasCmd; match cmd { - CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await, + CanvasCmd::Get { channel, revision } => { + cmd_get_canvas(client, &channel, revision.as_deref()).await + } CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + CanvasCmd::History { channel, limit } => cmd_canvas_history(client, &channel, limit).await, + CanvasCmd::Restore { channel, revision } => { + cmd_restore_canvas(client, &channel, &revision, &mut std::io::stdout()).await + } } } @@ -2930,3 +3326,1326 @@ mod tests { ); } } + +/// Command-level coverage for `cmd_set_canvas`'s writer discipline: it must read +/// the head, stamp `created_at` strictly ahead of a future-dated head, emit NO +/// `expected-revision` tag (unconditional replace), fall back to stamping at `now` +/// when no head exists, and refuse to submit against a structurally poisoned head. +/// These pin the fix so a revert to `created_at = now` / no head read fails a test +/// rather than silently recreating the false-success bug. +/// +/// Each test spins up a local axum relay that answers `POST /query` with a fixed +/// head and captures the event submitted to `POST /events`, then inspects the +/// signed event — behaviour at the command seam, not implementation details. +#[cfg(test)] +mod set_canvas_tests { + use std::sync::{Arc, Mutex}; + + use axum::body::Bytes; + use axum::extract::State; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use serde_json::{json, Value}; + use tokio::net::TcpListener; + + use super::cmd_set_canvas; + use crate::client::BuzzClient; + + const CHANNEL: &str = "326d56bc-c96c-4af0-86a1-5e804cd1b467"; + + #[derive(Clone)] + struct RelayState { + query_response: Arc, + submitted: Arc>>, + /// All filter arrays sent to POST /query, in call order. + query_bodies: Arc>>>, + } + + /// Spawn a relay that returns `query_response` from `POST /query` and records + /// the event posted to `POST /events`. `submitted` stays `None` until (and + /// unless) `set` actually publishes. + /// + /// Returns `(url, submitted_event, query_bodies)`. + async fn relay( + query_response: &str, + ) -> ( + String, + Arc>>, + Arc>>>, + ) { + let submitted: Arc>> = Arc::new(Mutex::new(None)); + let query_bodies: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let state = RelayState { + query_response: Arc::new(query_response.to_string()), + submitted: submitted.clone(), + query_bodies: query_bodies.clone(), + }; + let app = Router::new() + .route( + "/query", + post(|State(s): State, body: Bytes| async move { + let filters: Vec = serde_json::from_slice(&body).unwrap_or_default(); + s.query_bodies.lock().unwrap().push(filters); + (*s.query_response).clone() + }), + ) + .route( + "/events", + post(|State(s): State, body: Bytes| async move { + let event: Value = serde_json::from_slice(&body).expect("event json"); + *s.submitted.lock().unwrap() = Some(event); + r#"{"event_id":"deadbeef","accepted":true,"message":""}"#.to_string() + }), + ) + .with_state(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), submitted, query_bodies) + } + + /// Return all filter objects from all /query calls flattened into one vec. + fn all_filters(query_bodies: &Arc>>>) -> Vec { + query_bodies + .lock() + .unwrap() + .iter() + .flat_map(|batch| batch.clone()) + .collect() + } + + fn client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + fn tag_value(event: &Value, key: &str) -> Option { + event + .get("tags")? + .as_array()? + .iter() + .find(|t| { + t.as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + == Some(key) + }) + .and_then(|t| t.as_array()) + .and_then(|a| a.get(1)) + .and_then(|v| v.as_str()) + .map(str::to_string) + } + + /// A future-dated head (within the skew ceiling) forces the + /// `max(now, head + 1)` bump to resolve to `head + 1` deterministically (no + /// clock dependence, no sleeps), so the submitted event sorts strictly + /// ahead of the head under `created_at DESC, id ASC`. `set` is unconditional: + /// no `expected-revision` tag is emitted even though the head was read. + #[tokio::test] + async fn set_stamps_ahead_of_future_head_and_is_untagged() { + let head_id = "a".repeat(64); + // A head ahead of `now` but inside the 60-second ceiling: `head + 1` + // deterministically wins the `max`, and the guard accepts it. Computed + // from `now` so it tracks the wall clock without a hardcoded date. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let future = now + 30; + let head = json!([{ + "id": head_id, + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "old", + "created_at": future, + "tags": [["h", CHANNEL]], + }]); + let (url, submitted, _) = relay(&head.to_string()).await; + + cmd_set_canvas(&client(&url), CHANNEL, "new content") + .await + .expect("set succeeds"); + + let event = submitted.lock().unwrap().clone().expect("set must submit"); + assert_eq!( + event.get("created_at").and_then(|v| v.as_u64()), + Some(future + 1), + "must stamp strictly ahead of the future-dated head" + ); + // `set` is unconditional — no expected-revision tag regardless of head presence. + assert_eq!( + tag_value(&event, "expected-revision"), + None, + "set must NOT emit expected-revision — it is an unconditional replace" + ); + assert_eq!(event.get("kind").and_then(|v| v.as_u64()), Some(40100)); + } + + /// `set` inherits the SDK skew guard via `build_set_canvas_after_head`: a + /// head timestamped far beyond the future ceiling is refused with a clear + /// error and nothing is published, rather than extending a poisoned timeline. + #[tokio::test] + async fn set_refuses_a_poisoned_future_head() { + let head = json!([{ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "old", + "created_at": 4_102_444_800u64, // 2100-01-01Z — far past the ceiling + "tags": [["h", CHANNEL]], + }]); + let (url, submitted, _) = relay(&head.to_string()).await; + + let err = cmd_set_canvas(&client(&url), CHANNEL, "new content") + .await + .expect_err("a poisoned future head must be refused"); + + assert!( + err.to_string().contains("too far in the future"), + "unexpected message: {err}" + ); + assert!( + submitted.lock().unwrap().is_none(), + "no event may be published against a poisoned head" + ); + } + + /// No head yet: `set` still submits (create is not an error) and does NOT + /// emit an `expected-revision` tag — `set` is unconditional regardless of + /// head presence. + #[tokio::test] + async fn set_with_no_head_creates_without_expected_revision() { + let (url, submitted, _) = relay("[]").await; + + cmd_set_canvas(&client(&url), CHANNEL, "first content") + .await + .expect("set on empty channel succeeds"); + + let event = submitted.lock().unwrap().clone().expect("set must submit"); + assert_eq!( + tag_value(&event, "expected-revision"), + None, + "set must NOT emit expected-revision even on first create" + ); + } + + /// `set` sends `"consistency": "strong"` on its head-read (write-influencing) + /// but NOT on other queries. This is the request-filter side of the writer-pin + /// contract: removing the injection from `fetch_canvas_head(writer_pinned=true)` + /// must turn this test red. + /// + /// Mutation oracle: if `filter["consistency"] = …` is deleted from the + /// `writer_pinned` branch of `fetch_canvas_head`, the head-read filter no + /// longer carries `"consistency"`, and the assertion below fails. + #[tokio::test] + async fn set_head_read_carries_strong_consistency() { + let head_id = "a".repeat(64); + let head = json!([{ + "id": head_id, + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "old", + "created_at": 1_700_000_000u64, + "tags": [["h", CHANNEL]], + }]); + let (url, _, query_bodies) = relay(&head.to_string()).await; + cmd_set_canvas(&client(&url), CHANNEL, "new content") + .await + .expect("set succeeds"); + + let filters = all_filters(&query_bodies); + // The head read is a non-`ids` filter for kind 40100 with limit 1. + let head_reads: Vec<_> = filters + .iter() + .filter(|f| { + f.get("ids").is_none() + && f.get("kinds") + .and_then(|k| k.as_array()) + .map(|a| a.iter().any(|k| k == 40100)) + .unwrap_or(false) + && f.get("limit").and_then(|v| v.as_u64()) == Some(1) + }) + .collect(); + assert!( + !head_reads.is_empty(), + "set must issue at least one head-read filter" + ); + for f in &head_reads { + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "set head-read must carry consistency=strong: {f}" + ); + } + } + + /// `get` (display path) does NOT send `"consistency"` — it stays + /// replica-eligible. This is the inverse of the writer-pin contract: a + /// display read that silently acquired the pin would unnecessarily load + /// the writer pool. + /// + /// Mutation oracle: if `fetch_canvas_head(writer_pinned=false)` were changed + /// to always inject `consistency`, the assertion below would fail. + #[tokio::test] + async fn get_head_read_does_not_carry_consistency() { + use super::cmd_get_canvas; + + let head_id = "a".repeat(64); + let head = json!([{ + "id": head_id, + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "canvas content", + "created_at": 1_700_000_000u64, + "tags": [["h", CHANNEL]], + }]); + let (url, _, query_bodies) = relay(&head.to_string()).await; + cmd_get_canvas(&client(&url), CHANNEL, None) + .await + .expect("get succeeds"); + + let filters = all_filters(&query_bodies); + let head_reads: Vec<_> = filters + .iter() + .filter(|f| { + f.get("ids").is_none() + && f.get("kinds") + .and_then(|k| k.as_array()) + .map(|a| a.iter().any(|k| k == 40100)) + .unwrap_or(false) + && f.get("limit").and_then(|v| v.as_u64()) == Some(1) + }) + .collect(); + assert!(!head_reads.is_empty(), "get must issue a head-read filter"); + for f in &head_reads { + assert!( + f.get("consistency").is_none(), + "display head-read must NOT carry consistency: {f}" + ); + } + } +} + +/// Command-level coverage for `cmd_restore_canvas`'s post-write supersession +/// check. After publishing the restored revision, the command re-reads the head +/// once and classifies the outcome via the SDK's `canvas_write_survived` +/// predicate. These pin all three post-write outcomes deterministically — +/// controlled relay responses, no timing, no sleeps — so a revert of the +/// verification hunk fails a test rather than silently reporting hollow success. +/// +/// The relay dispatches by filter shape: an `ids` query returns the target +/// revision's content; the first plain head query returns the pre-write head, +/// and the post-write head query is synthesized from the captured submission per +/// a per-test [`PostHead`] mode — so the head can echo our own event id (which +/// we cannot predict before signing under a random key). +#[cfg(test)] +mod restore_canvas_tests { + use std::sync::{Arc, Mutex}; + + use axum::body::Bytes; + use axum::extract::State; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use serde_json::{json, Value}; + use tokio::net::TcpListener; + + use super::cmd_restore_canvas; + use crate::client::BuzzClient; + use crate::CliError; + + const CHANNEL: &str = "326d56bc-c96c-4af0-86a1-5e804cd1b467"; + const REVISION: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const HEAD: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + const STRANGER: &str = "3333333333333333333333333333333333333333333333333333333333333333"; + const THIRD: &str = "4444444444444444444444444444444444444444444444444444444444444444"; + + /// How the relay synthesizes the post-write head from the submitted event. + #[derive(Clone, Copy)] + enum PostHead { + /// The head is our own submitted event → survived. + OurEvent, + /// The head is a stranger whose `expected-revision` names our submitted + /// event → a later write legitimately built on us → survived. + StrangerBuildsOnUs, + /// The head is a stranger two links above ours: C(exp=B)→B(exp=ours)→ + /// ours, all present in the stream → survived by ancestry walk. + StrangerBuildsOnUsTransitively, + /// The head is an unrelated stranger → our restore was superseded. + Stranger, + /// The post-write verification read fails (HTTP 500). The submit was + /// accepted, so the restore must still report success (exit 0). + ReadFails, + } + + #[derive(Clone)] + struct RelayState { + revision_response: Arc, + pre_head: Arc, + post_head: PostHead, + /// Head queries seen so far: read 0 is pre-write, read 1 is post-write. + head_reads: Arc>, + submitted: Arc>>, + /// All filter arrays sent to POST /query, in call order. + query_bodies: Arc>>>, + } + + async fn relay( + post_head: PostHead, + ) -> ( + String, + Arc>>, + Arc>>>, + ) { + let submitted: Arc>> = Arc::new(Mutex::new(None)); + let query_bodies: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let state = RelayState { + revision_response: Arc::new(json!([canvas_event(REVISION, 1_000, None)]).to_string()), + pre_head: Arc::new(json!([canvas_event(HEAD, 2_000, None)]).to_string()), + post_head, + head_reads: Arc::new(Mutex::new(0)), + submitted: submitted.clone(), + query_bodies: query_bodies.clone(), + }; + let app = Router::new() + .route( + "/query", + post(|State(s): State, body: Bytes| async move { + use axum::http::StatusCode; + // Capture every request body for filter-field assertions. + if let Ok(filters) = serde_json::from_slice::>(&body) { + s.query_bodies.lock().unwrap().push(filters); + } + let is_ids_query = std::str::from_utf8(&body) + .map(|b| b.contains("\"ids\"")) + .unwrap_or(false); + if is_ids_query { + return (StatusCode::OK, (*s.revision_response).clone()); + } + let mut reads = s.head_reads.lock().unwrap(); + let read = *reads; + *reads += 1; + if read == 0 { + return (StatusCode::OK, (*s.pre_head).clone()); + } + // Post-write verification read that fails: the submit was + // accepted, so the command reports success despite this 500. + if matches!(s.post_head, PostHead::ReadFails) { + return (StatusCode::INTERNAL_SERVER_ERROR, String::new()); + } + // Post-write head: synthesize from the captured submission. + let our_id = s + .submitted + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .expect("post-write head read must follow a submit") + .to_string(); + let body = match s.post_head { + PostHead::OurEvent => { + json!([canvas_event(&our_id, 2_001, Some(HEAD))]).to_string() + } + PostHead::StrangerBuildsOnUs => { + json!([canvas_event(STRANGER, 2_002, Some(&our_id))]).to_string() + } + PostHead::StrangerBuildsOnUsTransitively => { + // C(head, exp=STRANGER) → B(STRANGER, exp=ours) → + // ours: the whole chain is in the returned stream so + // the ancestry walk reaches ours. Newest first. + json!([ + canvas_event(THIRD, 2_003, Some(STRANGER)), + canvas_event(STRANGER, 2_002, Some(&our_id)), + canvas_event(&our_id, 2_001, Some(HEAD)), + ]) + .to_string() + } + PostHead::Stranger => { + json!([canvas_event(STRANGER, 2_002, Some(HEAD))]).to_string() + } + PostHead::ReadFails => unreachable!("handled above"), + }; + (StatusCode::OK, body) + }), + ) + .route( + "/events", + post(|State(s): State, body: Bytes| async move { + let event: Value = serde_json::from_slice(&body).expect("event json"); + *s.submitted.lock().unwrap() = Some(event); + r#"{"event_id":"deadbeef","accepted":true,"message":""}"#.to_string() + }), + ) + .with_state(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), submitted, query_bodies) + } + + fn client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + /// Return all filter objects from all /query calls flattened into one vec. + fn all_filters(query_bodies: &Arc>>>) -> Vec { + query_bodies + .lock() + .unwrap() + .iter() + .flat_map(|batch| batch.clone()) + .collect() + } + + /// A canvas event JSON with the given id/created_at and optional + /// `expected-revision` ancestry tag. + fn canvas_event(id: &str, created_at: u64, expected_revision: Option<&str>) -> Value { + let mut tags = vec![json!(["h", CHANNEL])]; + if let Some(rev) = expected_revision { + tags.push(json!(["expected-revision", rev])); + } + json!({ + "id": id, + "pubkey": "c".repeat(64), + "kind": 40100, + "content": "target content", + "created_at": created_at, + "tags": tags, + }) + } + + /// The post-write head is our own event → restore holds the head → success. + #[tokio::test] + async fn restore_survives_when_head_is_our_event() { + let (url, submitted, _) = relay(PostHead::OurEvent).await; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect("restore that holds the head succeeds"); + assert!(submitted.lock().unwrap().is_some(), "restore must publish"); + } + + /// A later write built on our restore (its `expected-revision` names us): + /// the restore is still in the accepted chain → success. + #[tokio::test] + async fn restore_survives_when_head_builds_on_it() { + let (url, submitted, _) = relay(PostHead::StrangerBuildsOnUs).await; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect("restore a later write built on succeeds"); + assert!(submitted.lock().unwrap().is_some(), "restore must publish"); + } + + /// A later write two links above our restore (C→B→ours, whole chain in the + /// stream): the ancestry walk reaches ours → success, not a supersession. + #[tokio::test] + async fn restore_survives_when_head_builds_on_it_transitively() { + let (url, submitted, _) = relay(PostHead::StrangerBuildsOnUsTransitively).await; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect("a transitive descendant of our restore succeeds"); + assert!(submitted.lock().unwrap().is_some(), "restore must publish"); + } + + /// A concurrent stranger won the visible head (no ancestry to our restore): + /// the command returns `CliError::Conflict` naming the persisted revision. + #[tokio::test] + async fn restore_conflicts_when_head_is_a_stranger() { + let (url, submitted, _) = relay(PostHead::Stranger).await; + let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("a stranger head must be a supersession conflict"); + + assert!( + matches!(err, CliError::Conflict(_)), + "expected Conflict (exit 5), got {err:?}" + ); + let our_id = submitted + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .expect("the restore did publish before it was superseded") + .to_string(); + let msg = err.to_string(); + assert!( + msg.contains("superseded") && msg.contains("preserved in history"), + "conflict must describe the supersession: {msg}" + ); + assert!( + msg.contains(&our_id), + "conflict must name the persisted revision id {our_id}: {msg}" + ); + } + + /// The post-write verification read fails after an accepted submit: the + /// restore is durable, so the command reports success (exit 0), not a + /// conflict or error — a failed read must never masquerade as a failed + /// restore. + #[tokio::test] + async fn restore_succeeds_when_verification_read_fails() { + let (url, submitted, _) = relay(PostHead::ReadFails).await; + let mut out = vec![]; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut out) + .await + .expect("an accepted restore whose verification read fails still succeeds"); + assert!( + submitted.lock().unwrap().is_some(), + "the restore did publish before the verification read failed" + ); + let json: serde_json::Value = serde_json::from_slice(&out) + .expect("stdout must be valid JSON even when verification read fails"); + assert!( + json.get("event_id").is_some(), + "stdout JSON must contain event_id" + ); + assert!( + json.get("accepted").is_some(), + "stdout JSON must contain accepted" + ); + assert!( + json.get("message").is_some(), + "stdout JSON must contain message" + ); + } + + /// `restore` sends `"consistency": "strong"` on the write-influencing + /// head reads (pre-write precondition and post-write ancestry) and does NOT + /// send it on the revision-by-id lookup (display read). + /// + /// Mutation oracle — removing either `"consistency"` injection must flip the + /// relevant assertion: + /// * `fetch_canvas_head(writer_pinned=true)` → pre-write head filter loses + /// the field. + /// * `fetch_canvas_ancestry` → post-write filter loses it. + /// * Adding `"consistency"` to `fetch_canvas_revision` → ids filter + /// incorrectly gains it. + #[tokio::test] + async fn restore_write_influencing_reads_carry_strong_consistency() { + let (url, _, query_bodies) = relay(PostHead::OurEvent).await; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect("restore succeeds"); + + let filters = all_filters(&query_bodies); + + // IDs filter (revision fetch — display read): must NOT have consistency. + let ids_filters: Vec<_> = filters.iter().filter(|f| f.get("ids").is_some()).collect(); + assert!(!ids_filters.is_empty(), "restore must issue an ids filter"); + for f in &ids_filters { + assert!( + f.get("consistency").is_none(), + "revision-fetch (ids) filter must NOT carry consistency: {f}" + ); + } + + // Non-ids kind-40100 filters (head reads — write-influencing): must all + // carry consistency=strong. These are the pre-write precondition head and + // the post-write ancestry verification read. + let head_filters: Vec<_> = filters + .iter() + .filter(|f| { + f.get("ids").is_none() + && f.get("kinds") + .and_then(|k| k.as_array()) + .map(|a| a.iter().any(|k| k == 40100)) + .unwrap_or(false) + }) + .collect(); + assert!( + !head_filters.is_empty(), + "restore must issue at least one head/ancestry filter" + ); + for f in &head_filters { + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "write-influencing head/ancestry filter must carry consistency=strong: {f}" + ); + } + } + + /// Relay for conflict-reconciliation tests. Models the real ambiguous retry + /// seam in `submit_stored_event`: + /// + /// 1. Attempt 1 — POST /events: A is persisted on the relay; the relay then + /// returns HTTP 503 (a retryable status), so `with_retry_body` retries + /// with the same bytes. + /// 2. A concurrent writer arrives and becomes the new head. + /// 3. Attempt 2 — POST /events: returns the canonical 409 conflict body + /// because the concurrent write is now the head. `cmd_restore_canvas` + /// enters the reconciliation branch. + /// 4. Ancestry walk — POST /query: returns a stream shaped by `scenario`. + /// 5. IDs existence check — POST /query (only for `GenuinelyAbsent`, + /// `LegacySupersession`, and `ExistenceReadFails`): the ancestry walk + /// returns `canvas_write_survived` = false, so the command follows up + /// with a writer-pinned IDs lookup for our event. + /// + /// The event ID is captured so tests can assert it appears in error messages. + #[derive(Clone, Copy)] + enum ConflictScenario { + /// B(expected=A) is head, A is a reachable ancestor → `canvas_write_survived` + /// true → accepted JSON, exit 0 (same as accepted-submit success path). + ReachableAncestor, + /// B(no expected-revision) is head, A exists in the stream but is not + /// an ancestor → `canvas_write_survived` false, IDs lookup finds A → + /// supersession naming A. + LegacySupersession, + /// A is absent (only a stranger, no A in stream or IDs lookup) → genuine + /// conflict → original `CliError::Relay { 409, .. }` returned unchanged. + GenuinelyAbsent, + /// Ancestry read fails (HTTP 500) → `CliError::DeliveryUnknown`. + ReadFails, + /// Ancestry succeeds with A unreachable (canvas_write_survived = false), + /// then the IDs existence check fails (HTTP 500) → `CliError::DeliveryUnknown`. + ExistenceReadFails, + } + + async fn conflict_relay( + scenario: ConflictScenario, + ) -> ( + String, + Arc>>, + Arc>>, + ) { + use std::sync::atomic::{AtomicU32, Ordering}; + + let submitted: Arc>> = Arc::new(Mutex::new(None)); + let submitted_q = submitted.clone(); + let submitted_ev = submitted.clone(); + // Captures the first post-submit IDs query body (the writer-pinned + // existence check). `None` when the query is never reached (e.g. + // ReachableAncestor where canvas_write_survived = true). + let ids_query_body: Arc>> = Arc::new(Mutex::new(None)); + let ids_query_body_q = ids_query_body.clone(); + let events_attempt: Arc = Arc::new(AtomicU32::new(0)); + let events_attempt2 = events_attempt.clone(); + + let app = Router::new() + .route( + "/query", + post(move |body: Bytes| { + let sub = submitted_q.clone(); + let ids_cap = ids_query_body_q.clone(); + async move { + use axum::http::StatusCode; + let body_str = std::str::from_utf8(&body).unwrap_or(""); + let is_ids_query = body_str.contains("\"ids\""); + let our_id_opt = sub + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if is_ids_query && our_id_opt.is_none() { + // Pre-submit revision content fetch — always succeeds. + return ( + StatusCode::OK, + json!([canvas_event(REVISION, 1_000, None)]).to_string(), + ); + } + if our_id_opt.is_none() { + // Pre-submit head read — REVISION ≠ HEAD so command proceeds. + return ( + StatusCode::OK, + json!([canvas_event(HEAD, 2_000, None)]).to_string(), + ); + } + let our_id = our_id_opt.unwrap(); + if is_ids_query { + // Post-conflict writer-pinned IDs existence check for our_id. + // Only reached when canvas_write_survived is false (i.e. + // LegacySupersession, GenuinelyAbsent, and ExistenceReadFails + // scenarios). Capture the first IDs body for consistency tests. + { + let mut cap = ids_cap.lock().unwrap(); + if cap.is_none() { + *cap = Some(body_str.to_owned()); + } + } + return match scenario { + ConflictScenario::LegacySupersession => { + // A is in the relay — return it so the existence + // check confirms persistence. + ( + StatusCode::OK, + json!([canvas_event(&our_id, 2_001, Some(HEAD))]) + .to_string(), + ) + } + ConflictScenario::GenuinelyAbsent => { + // A was never stored — empty response. + (StatusCode::OK, json!([]).to_string()) + } + ConflictScenario::ExistenceReadFails => { + // IDs existence check itself fails — the outcome + // cannot be determined → DeliveryUnknown. + (StatusCode::INTERNAL_SERVER_ERROR, String::new()) + } + // ReachableAncestor: canvas_write_survived = true, + // never reaches the existence check. + // ReadFails: ancestry read returns 500, + // never reaches the existence check. + _ => unreachable!( + "existence check only reached for LegacySupersession/GenuinelyAbsent/ExistenceReadFails" + ), + }; + } + // Post-conflict ancestry walk: shape per scenario. + match scenario { + ConflictScenario::ReadFails => { + (StatusCode::INTERNAL_SERVER_ERROR, String::new()) + } + ConflictScenario::GenuinelyAbsent => { + // A is absent — genuine conflict. Stream contains + // only an unrelated stranger (no A, no B). + ( + StatusCode::OK, + json!([canvas_event(STRANGER, 2_002, None)]).to_string(), + ) + } + ConflictScenario::ExistenceReadFails => { + // Ancestry succeeds but A is unreachable (stranger + // only, canvas_write_survived = false). The IDs check + // that follows returns 500 above. + ( + StatusCode::OK, + json!([canvas_event(STRANGER, 2_002, None)]).to_string(), + ) + } + ConflictScenario::ReachableAncestor => { + // B(expected=A) is head: B built on A, A is in the + // ancestry chain → canvas_write_survived = true → + // command returns accepted JSON (exit 0). + ( + StatusCode::OK, + json!([ + canvas_event(STRANGER, 2_002, Some(&our_id)), + canvas_event(&our_id, 2_001, Some(HEAD)), + ]) + .to_string(), + ) + } + ConflictScenario::LegacySupersession => { + // B(no expected-revision) is head: B does not link + // to A, so canvas_write_survived = false, but A is + // in the stream (not at head). The IDs existence + // check follows and confirms A is stored. + ( + StatusCode::OK, + json!([ + canvas_event(STRANGER, 2_002, None), + canvas_event(&our_id, 2_001, Some(HEAD)), + ]) + .to_string(), + ) + } + } + } + }), + ) + .route( + "/events", + post(move |body: Bytes| { + let sub = submitted_ev.clone(); + let attempt_ctr = events_attempt2.clone(); + async move { + use axum::http::StatusCode; + let event: Value = serde_json::from_slice(&body).expect("event json"); + let attempt = attempt_ctr.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + // Attempt 1: A is persisted; simulate a lost response by + // returning 503 (retried by with_retry_body). This models + // a network-level response loss where the relay stored the + // event but the client never received confirmation. + // We capture the event ID here for use in ancestry responses. + *sub.lock().unwrap() = Some(event); + return ( + StatusCode::SERVICE_UNAVAILABLE, + r#"{"error":"relay temporarily unavailable"}"#.to_string(), + ); + } + // Attempt 2 (same bytes — `submit_stored_event` retry): + // A is already stored; B built on A; relay returns canonical 409. + ( + StatusCode::CONFLICT, + r#"{"error":"conflict: canvas changed since it was loaded"}"# + .to_string(), + ) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), submitted, ids_query_body) + } + + /// A non-409 relay error whose body happens to contain "canvas changed" + /// must NOT enter the conflict-reconciliation branch — it must propagate + /// as a plain `CliError::Relay` without triggering an ancestry walk. + /// + /// Mutation oracle: removing the `status: 409` guard from the match arm + /// makes this return `CliError::Conflict` (ancestry walk finds our event + /// reachable, so reconciliation misclassifies it as a supersession) instead + /// of `CliError::Relay { status: 500, .. }`. + #[tokio::test] + async fn non_409_lookalike_with_conflict_phrase_is_not_reconciled() { + // Relay: /events returns 500 + conflict phrase but DOES capture the event + // ID. /query post-submit returns a stream where our event IS reachable + // (so that if the classifier incorrectly fires, it produces Conflict — a + // distinct, detectable outcome). + let submitted: Arc>> = Arc::new(Mutex::new(None)); + let submitted_q = submitted.clone(); + let submitted_ev = submitted.clone(); + let app = Router::new() + .route( + "/query", + post(move |body: Bytes| { + let sub = submitted_q.clone(); + async move { + use axum::http::StatusCode; + let is_ids_query = std::str::from_utf8(&body) + .map(|b| b.contains("\"ids\"")) + .unwrap_or(false); + if is_ids_query { + return ( + StatusCode::OK, + json!([canvas_event(REVISION, 1_000, None)]).to_string(), + ); + } + let our_id_opt = sub + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if our_id_opt.is_none() { + // Pre-submit head — REVISION ≠ HEAD so command proceeds. + return ( + StatusCode::OK, + json!([canvas_event(HEAD, 2_000, None)]).to_string(), + ); + } + // Post-error ancestry walk: our event IS reachable. + // If the status guard is missing, reconciliation fires here + // and returns CliError::Conflict — the oracle fires. + let our_id = our_id_opt.unwrap(); + ( + StatusCode::OK, + json!([ + canvas_event(STRANGER, 2_002, Some(&our_id)), + canvas_event(&our_id, 2_001, Some(HEAD)), + ]) + .to_string(), + ) + } + }), + ) + .route( + "/events", + post(move |body: Bytes| { + let sub = submitted_ev.clone(); + async move { + use axum::http::StatusCode; + let event: Value = serde_json::from_slice(&body).expect("event json"); + *sub.lock().unwrap() = Some(event); + // 500 with the conflict phrase in the body — a lookalike. + ( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"error":"canvas changed — internal relay fault"}"#.to_string(), + ) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}"); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("a 500 error must propagate as Relay, not enter reconciliation"); + + // With the 409 status guard: the 500 exits the match via Err(e) => return Err(e) + // and arrives here as Relay{500}. + // Without the guard: the body phrase matches → reconciliation fires → + // ancestry walk finds our event reachable → CliError::Conflict returned instead. + assert!( + !matches!(err, CliError::Conflict(_)), + "non-409 error with conflict phrase must not be classified as Conflict: {err:?}" + ); + assert!( + matches!(err, CliError::Relay { status: 500, .. }), + "non-409 body-lookalike must propagate as Relay{{500}}, got {err:?}" + ); + } + + /// A conflict-shaped submit where `canvas_write_survived` is true (B(expected=A) + /// is head, A is a reachable ancestor): A committed before the 409 response was + /// lost. The command must return accepted JSON (exit 0) — identical to a clean + /// accepted submit — NOT `CliError::Conflict`, which would incorrectly prompt + /// the caller to re-restore over B. + /// + /// Mutation oracle: removing the conflict-reconciliation block from + /// `cmd_restore_canvas` (restoring `client.submit_event(event).await?`) makes + /// this return `CliError::Relay` instead of `Ok(())`. + #[tokio::test] + async fn conflict_shaped_submit_succeeds_when_our_event_is_reachable_ancestor() { + let (url, submitted, _) = conflict_relay(ConflictScenario::ReachableAncestor).await; + let mut out = vec![]; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut out) + .await + .expect("a conflict where A is a reachable ancestor must succeed (exit 0)"); + assert!( + submitted.lock().unwrap().is_some(), + "the restore must have submitted before the 409" + ); + // Must emit the same accepted JSON as a clean submit. + let json: serde_json::Value = + serde_json::from_slice(&out).expect("stdout must be valid JSON"); + assert!( + json.get("event_id").is_some(), + "stdout JSON must contain event_id: {json}" + ); + assert!( + json.get("accepted").is_some(), + "stdout JSON must contain accepted: {json}" + ); + assert!( + json.get("message").is_some(), + "stdout JSON must contain message: {json}" + ); + } + + /// A conflict-shaped submit where an unconditional (legacy, no `expected-revision`) + /// write B is head and our event A is present in the stream but not an ancestor: + /// `canvas_write_survived` is false, but the writer-pinned IDs lookup confirms A + /// exists. The command must return `CliError::Conflict` naming A — superseded, + /// preserved in history — NOT the original 409 relay error that would wrongly + /// tell the caller the restore was rejected. + /// + /// Mutation oracle: removing the IDs existence check (returning the original + /// 409 relay error whenever `canvas_write_survived` is false) makes this return + /// `CliError::Relay` instead of `CliError::Conflict`. + #[tokio::test] + async fn conflict_shaped_submit_superseded_by_legacy_write_when_event_exists_but_not_ancestor() + { + let (url, submitted, _) = conflict_relay(ConflictScenario::LegacySupersession).await; + let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("a conflict where A exists but is not an ancestor must be a supersession"); + + assert!( + matches!(err, CliError::Conflict(_)), + "expected Conflict (exit 5 — superseded by legacy write, preserved), got {err:?}" + ); + let our_id = submitted + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .expect("the restore must have submitted before the 409") + .to_string(); + let msg = err.to_string(); + assert!( + msg.contains("superseded") && msg.contains("preserved in history"), + "conflict message must describe the supersession: {msg}" + ); + assert!( + msg.contains(&our_id), + "conflict message must name the persisted revision id {our_id}: {msg}" + ); + } + + /// A conflict-shaped submit where the ancestry walk shows our event is + /// absent: the relay genuinely rejected the write (stale precondition, not + /// a lost response). The command must return the ORIGINAL `CliError::Relay` + /// unchanged — status 409, body as received from the relay — rather than a + /// fabricated replacement body. + /// + /// Mutation oracle: always returning `Conflict` from the reconciliation + /// block (ignoring `canvas_write_survived`) makes this return `CliError::Conflict` + /// instead of `CliError::Relay`. + #[tokio::test] + async fn conflict_shaped_submit_stays_relay_error_when_our_event_absent() { + let (url, _, _) = conflict_relay(ConflictScenario::GenuinelyAbsent).await; + let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("a genuine conflict must error"); + + // Must be the original Relay error (409) — not fabricated, not Conflict. + match &err { + CliError::Relay { status, body } => { + assert_eq!( + *status, 409, + "absent case must preserve the original 409 status" + ); + assert!( + body.contains("canvas changed"), + "absent case must preserve the original conflict body: {body}" + ); + } + other => { + panic!("expected Relay error (genuine conflict, nothing stored), got {other:?}") + } + } + } + + /// A conflict-shaped submit where the ancestry walk itself fails. The + /// outcome is genuinely unknown: the restore may or may not be stored. + /// The command must return `CliError::DeliveryUnknown` (exit 2, + /// category `delivery_unknown`) — not a false success, not a false conflict, + /// and not the generic `CliError::Other` (exit 4, category `error`). + /// + /// Mutation oracle: returning `CliError::Conflict` when the ancestry read + /// fails makes this return `Conflict` instead of `DeliveryUnknown`. + #[tokio::test] + async fn conflict_shaped_submit_with_failed_ancestry_read_returns_unknown_outcome() { + let (url, submitted, _) = conflict_relay(ConflictScenario::ReadFails).await; + let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("unknown outcome must error"); + + assert!( + matches!(err, CliError::DeliveryUnknown(_)), + "expected DeliveryUnknown (exit 2, category delivery_unknown — outcome unknown), got {err:?}" + ); + let our_id = submitted + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .expect("the restore must have submitted before the failed read") + .to_string(); + let msg = err.to_string(); + assert!( + msg.contains("unknown"), + "error message must state outcome is unknown: {msg}" + ); + assert!( + msg.contains(&our_id), + "error message must name the event id {our_id}: {msg}" + ); + } + + /// A conflict-shaped submit where the ancestry walk finds A unreachable + /// (canvas_write_survived = false) and then the writer-pinned IDs existence + /// check itself fails (HTTP 500). The outcome is unknown — A may or may not + /// be stored. The command must return `CliError::DeliveryUnknown` naming A's + /// ID, not a false `CliError::Relay` (absent/original 409) and not a false + /// `CliError::Conflict` (treated as supersession without confirmation). + /// + /// Mutation oracle: changing the existence-query `Err(_)` arm to return the + /// original 409 relay error makes this return `CliError::Relay` instead of + /// `CliError::DeliveryUnknown`. + #[tokio::test] + async fn conflict_shaped_submit_with_failed_existence_read_returns_unknown_outcome() { + let (url, submitted, _) = conflict_relay(ConflictScenario::ExistenceReadFails).await; + let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("unknown outcome from existence-check failure must error"); + + assert!( + matches!(err, CliError::DeliveryUnknown(_)), + "expected DeliveryUnknown (existence check failed — outcome unknown), got {err:?}" + ); + let our_id = submitted + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .expect("the restore must have submitted before the failed existence check") + .to_string(); + let msg = err.to_string(); + assert!( + msg.contains("unknown"), + "error message must state outcome is unknown: {msg}" + ); + assert!( + msg.contains(&our_id), + "error message must name the event id {our_id}: {msg}" + ); + } + + /// The writer-pinned IDs existence check sent during 409-reconciliation + /// must carry `consistency=strong` with exactly the expected filter fields + /// so that replica lag cannot hide a durable event A and produce a false + /// absence classification. + /// + /// Mutation oracle: removing `"consistency": "strong"` from + /// `fetch_canvas_event_exists` makes the assertion on the captured request + /// body fail. + #[tokio::test] + async fn conflict_shaped_submit_existence_read_carries_strong_consistency() { + // Use LegacySupersession: ancestry succeeds with canvas_write_survived = + // false, so the existence check is reached and its body is captured. + let (url, submitted, ids_body_cap) = + conflict_relay(ConflictScenario::LegacySupersession).await; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION, &mut vec![]) + .await + .expect_err("legacy supersession must return Conflict"); + + let our_id = submitted + .lock() + .unwrap() + .as_ref() + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .expect("the restore must have submitted before the existence check") + .to_string(); + + let raw = ids_body_cap + .lock() + .unwrap() + .clone() + .expect("existence query body must have been captured"); + // query() wraps the filter in a JSON array ([filter]) per the HTTP bridge contract. + let filters: Vec = + serde_json::from_str(&raw).expect("existence query body must be a valid JSON array"); + let filter = filters + .into_iter() + .next() + .expect("existence query body must contain at least one filter"); + + // ids=[our_id] — queries precisely the event we submitted. + assert_eq!( + filter.get("ids").and_then(|v| v.as_array()), + Some(&vec![serde_json::json!(our_id)]), + "existence filter must query exactly our submitted event id: {filter}" + ); + // kinds=[40100] — scoped to canvas events only. + assert_eq!( + filter.get("kinds").and_then(|v| v.as_array()), + Some(&vec![serde_json::json!(40100)]), + "existence filter must restrict to kind 40100: {filter}" + ); + // #h=[channel] — scoped to the correct channel. + assert_eq!( + filter.get("#h").and_then(|v| v.as_array()), + Some(&vec![serde_json::json!(CHANNEL)]), + "existence filter must restrict to the correct channel: {filter}" + ); + // limit=1 — one-shot check. + assert_eq!( + filter.get("limit").and_then(|v| v.as_u64()), + Some(1), + "existence filter must set limit=1: {filter}" + ); + // consistency=strong — writer-pinned, no replica lag. + assert_eq!( + filter.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "existence filter must carry consistency=strong to prevent replica-lag false absence: {filter}" + ); + } +} + +/// Item 4 regression: the already-current short-circuit must emit structured +/// JSON on stdout (matching the JSON-only stdout contract) with the fields +/// `{event_id, accepted, message}`. A bare prose `println!` violates the +/// JSON-only stdout contract. +#[cfg(test)] +mod restore_canvas_already_current_tests { + use axum::body::Bytes; + use axum::extract::State; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use serde_json::{json, Value}; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + use super::cmd_restore_canvas; + use crate::client::BuzzClient; + + const CHANNEL: &str = "326d56bc-c96c-4af0-86a1-5e804cd1b467"; + // REVISION == HEAD so the command short-circuits without submitting. + const REVISION_EQ_HEAD: &str = + "2222222222222222222222222222222222222222222222222222222222222222"; + + /// Relay that serves REVISION_EQ_HEAD both as the revision-by-id content + /// and as the current head, so `cmd_restore_canvas` takes the already-current + /// short-circuit. No `/events` route is needed because the command returns + /// before building or submitting the event. + async fn already_current_relay() -> (String, Arc>>>) { + let query_bodies: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route( + "/query", + post( + |State(qb): State>>>>, body: Bytes| async move { + if let Ok(filters) = serde_json::from_slice::>(&body) { + qb.lock().unwrap().push(filters); + } + let is_ids_query = std::str::from_utf8(&body) + .map(|b| b.contains("\"ids\"")) + .unwrap_or(false); + let event = json!({ + "id": REVISION_EQ_HEAD, + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "canvas content", + "created_at": 2_000u64, + "tags": [["h", CHANNEL]], + }); + if is_ids_query { + return json!([event]).to_string(); + } + // Head query — returns the same id so the command short-circuits. + json!([event]).to_string() + }, + ), + ) + .with_state(query_bodies.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), query_bodies) + } + + fn client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + /// The already-current short-circuit must return Ok (exit 0) AND emit + /// structured JSON on stdout with `event_id`, `accepted`, and `message` + /// fields. Captured via the injected `out` writer. + /// + /// Mutation oracle: reverting to `println!("revision {revision} is already + /// the current revision")` emits prose instead of JSON. Parsing the output + /// with `serde_json::from_str` would fail, and the `accepted` / `event_id` / + /// `message` field assertions would not hold. + #[tokio::test] + async fn already_current_restore_emits_json_with_required_fields() { + let (url, _) = already_current_relay().await; + let mut out: Vec = Vec::new(); + cmd_restore_canvas(&client(&url), CHANNEL, REVISION_EQ_HEAD, &mut out) + .await + .expect("already-current restore must succeed"); + + let output = String::from_utf8(out).expect("stdout must be valid UTF-8"); + let json: Value = serde_json::from_str(output.trim()).expect("stdout must be valid JSON"); + + assert_eq!( + json.get("event_id").and_then(|v| v.as_str()), + Some(REVISION_EQ_HEAD), + "event_id must match the revision: {json}" + ); + assert_eq!( + json.get("accepted").and_then(|v| v.as_bool()), + Some(true), + "accepted must be true: {json}" + ); + assert!( + json.get("message").and_then(|v| v.as_str()).is_some(), + "message field must be present: {json}" + ); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index b38486417a8..c2f6b7e089c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -760,6 +760,10 @@ pub enum CanvasCmd { /// Channel UUID #[arg(long)] channel: String, + /// Fetch a specific historical revision by event ID (64-char hex); + /// defaults to the current head + #[arg(long)] + revision: Option, }, /// Set (replace) the canvas document for a channel Set { @@ -770,6 +774,24 @@ pub enum CanvasCmd { #[arg(long)] content: String, }, + /// List canvas revision history for a channel, newest first + History { + /// Channel UUID + #[arg(long)] + channel: String, + /// Maximum number of revisions to return (1–10000) + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=10_000))] + limit: u32, + }, + /// Restore the canvas to a previous revision by re-publishing its content + Restore { + /// Channel UUID + #[arg(long)] + channel: String, + /// Revision event ID to restore (64-char hex) + #[arg(long)] + revision: String, + }, } #[derive(Subcommand)] @@ -2293,6 +2315,29 @@ mod tests { .is_err()); } + /// `canvas history --limit` is bounded 1–10000 at parse time: zero and + /// max+1 reject, the maximum is accepted, and the max stays above one + /// 1,000-row relay page so the >1,000 pagination path remains reachable. + #[test] + fn canvas_history_limit_is_bounded() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + let parse = |limit: &str| { + Cli::try_parse_from([ + "buzz", + "canvas", + "history", + "--channel", + channel, + "--limit", + limit, + ]) + }; + assert!(parse("0").is_err(), "zero must reject"); + assert!(parse("10001").is_err(), "max+1 must reject"); + assert!(parse("10000").is_ok(), "maximum must be accepted"); + assert!(parse("1000").is_ok(), "one relay page must be reachable"); + } + #[test] fn set_status_clear_rejects_text_and_emoji() { for extra in [["--text", "busy"], ["--emoji", "🎶"]] { @@ -2428,7 +2473,10 @@ mod tests { "update" ] ); - assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]); + assert_eq!( + names(&cmd, "canvas"), + vec!["get", "history", "restore", "set"] + ); assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]); assert_eq!( names(&cmd, "emoji"), @@ -2532,7 +2580,7 @@ mod tests { fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ ("agents", 5), - ("canvas", 2), + ("canvas", 4), ("channels", 16), ("dms", 4), ("emoji", 5), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f749d1a6255..36a70b758de 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -76,6 +76,7 @@ pub use community::{ UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; +pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index a6c3eae8f0a..542a6d80fab 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -10,8 +10,8 @@ use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row, Transaction}; use uuid::Uuid; use buzz_core::kind::{ - event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, + event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, + KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; @@ -915,32 +915,6 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer Ok(cnt) } -/// Soft-delete an event by setting `deleted_at = NOW()`. -/// -/// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted -/// or not found. Callers are responsible for decrementing thread reply counts -/// when the deleted event is a thread reply. -pub async fn soft_delete_event( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], -) -> Result { - let mut connection = crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - let result = sqlx::query( - "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(event_id) - .execute(&mut *connection) - .await?; - - Ok(result.rows_affected() > 0) -} - /// Soft-delete the live row for an addressable coordinate /// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not /// newer than the deletion request. @@ -1006,6 +980,14 @@ pub async fn soft_delete_by_coordinate( /// Wraps the delete + counter update in a single transaction so a crash between /// them cannot leave counters permanently inflated. Returns `Ok(true)` if the /// event was deleted this call. +/// +/// When the target event is a kind-40100 (canvas) event, this function derives +/// the target's `kind` and `channel_id` from the database inside the same +/// transaction and acquires the same `(community, kind, channel)` advisory lock +/// used by [`insert_channel_head_checked`] before the UPDATE. This prevents a +/// concurrent tagged write from observing a head that is simultaneously being +/// removed. The serialization invariant is owned entirely by this function; +/// callers do not classify the target kind. pub async fn soft_delete_event_and_update_thread( pool: &PgPool, community_id: CommunityId, @@ -1013,12 +995,39 @@ pub async fn soft_delete_event_and_update_thread( parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { + use crate::store::replaceable::event_replacement_lock_key; + let connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + // Derive the target event's kind and channel_id inside the transaction so + // that the serialization decision cannot be bypassed by any caller. + let target: Option<(i32, Option)> = sqlx::query_as( + "SELECT kind, channel_id FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .fetch_optional(&mut *tx) + .await?; + + if let Some((kind, Some(channel_id))) = target { + if kind == KIND_CANVAS as i32 { + let lock_key = event_replacement_lock_key( + community_id, + kind, + &[], + Some(channel_id.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + } + } let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", @@ -1511,6 +1520,13 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( /// inconsistent if one succeeded and the other failed. Keep this as one /// transaction so reply metadata and counters commit together with the event. /// +/// For kind-40100 (canvas) events with a `channel_id`, acquires the same +/// `(community, kind, channel)` advisory lock used by +/// [`insert_channel_head_checked`] so that untagged unconditional canvas appends +/// serialize against concurrent tagged writes on the same coordinate. Untagged +/// writes remain unconditional — they never conflict — but must not race the +/// head read inside a concurrent tagged transaction. +/// /// Returns `(StoredEvent, was_inserted)`. pub async fn insert_event_with_thread_metadata( pool: &PgPool, @@ -1519,12 +1535,30 @@ pub async fn insert_event_with_thread_metadata( channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { + use crate::store::replaceable::event_replacement_lock_key; + let connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + + if event_kind_i32(event) == KIND_CANVAS as i32 { + if let Some(ch) = channel_id { + let lock_key = event_replacement_lock_key( + community_id, + KIND_CANVAS as i32, + &[], + Some(ch.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + } + } + let result = insert_event_with_thread_metadata_tx(&mut tx, community_id, event, channel_id, thread_meta) .await?; @@ -1532,6 +1566,160 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } +/// Outcome of a channel-head conditional canvas write. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelHeadWriteStatus { + /// The event was appended as the new channel head. + Inserted, + /// The exact event already existed (idempotent replay of the current head). + Duplicate, + /// `ExpectedHead` was supplied but no live head exists for the channel. + RevisionMissing, + /// The live head id did not match `ExpectedHead`, or `ExpectNoHead` was + /// required but a head already exists. + RevisionMismatch, + /// Precondition matched but the candidate does not sort ahead of the head + /// under `created_at DESC, id ASC`; accepting it would not change the + /// visible canvas. + SupersedeFailed, +} + +/// Optimistic-concurrency precondition for [`insert_channel_head_checked`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelHeadPrecondition<'a> { + /// Require that no live head exists yet (first creation of the canvas). + ExpectNoHead, + /// Require the live head to match this validated 32-byte event ID. + ExpectedHead(&'a [u8]), +} + +/// Returns `true` iff a candidate canvas event sorts strictly ahead of the +/// current head under `created_at DESC, id ASC`. +fn candidate_supersedes_head( + candidate: &Event, + candidate_id: &[u8; 32], + head_created_at: DateTime, + head_id: &[u8], +) -> bool { + let candidate_secs = candidate.created_at.as_secs() as i64; + let head_secs = head_created_at.timestamp(); + match candidate_secs.cmp(&head_secs) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => candidate_id.as_slice() < head_id, + } +} + +/// Conditionally append a channel-head event (canvas kind 40100) under an +/// optimistic-concurrency precondition. +/// +/// Acquires a per-`(community, kind, channel)` advisory lock (author excluded +/// so cross-author concurrent edits serialize on the same head), reads the head +/// under `created_at DESC, id ASC`, evaluates the precondition, and on success +/// inserts the event and its mentions in the same transaction. Failures are +/// pure reads — nothing is mutated. +/// +/// A matching `ExpectedHead` precondition also requires the candidate to sort +/// strictly ahead of the head; if not, returns `SupersedeFailed`. Re-submitting +/// the byte-identical live head short-circuits to `Duplicate` without evaluating +/// the precondition (safe transport-retry semantics). +pub async fn insert_channel_head_checked( + pool: &PgPool, + community_id: CommunityId, + event: &Event, + channel_id: Uuid, + precondition: ChannelHeadPrecondition<'_>, +) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { + use crate::store::replaceable::event_replacement_lock_key; + + let kind_i32 = buzz_core::kind::event_kind_i32(event); + let received_at = Utc::now(); + let incoming_id = event.id.as_bytes(); + + let mut tx = pool.begin().await?; + + // Serialize check+insert per (community, kind, channel). + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + &[], + Some(channel_id.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + let head: Option<(Vec, DateTime)> = sqlx::query_as( + "SELECT id, created_at FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(channel_id) + .fetch_optional(&mut *tx) + .await?; + + // Idempotent replay: the incoming event is already the live head. + if head + .as_ref() + .is_some_and(|(id, _)| id.as_slice() == incoming_id.as_slice()) + { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), true), + ChannelHeadWriteStatus::Duplicate, + )); + } + + let status = match (precondition, head.as_ref()) { + (ChannelHeadPrecondition::ExpectNoHead, None) => None, + (ChannelHeadPrecondition::ExpectNoHead, Some(_)) => { + Some(ChannelHeadWriteStatus::RevisionMismatch) + } + (ChannelHeadPrecondition::ExpectedHead(_), None) => { + Some(ChannelHeadWriteStatus::RevisionMissing) + } + (ChannelHeadPrecondition::ExpectedHead(expected), Some((id, head_created_at))) => { + if id.as_slice() != expected { + Some(ChannelHeadWriteStatus::RevisionMismatch) + } else if !candidate_supersedes_head(event, incoming_id, *head_created_at, id) { + Some(ChannelHeadWriteStatus::SupersedeFailed) + } else { + None + } + } + }; + if let Some(status) = status { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), false), + status, + )); + } + + let (stored, was_inserted) = + insert_event_with_thread_metadata_tx(&mut tx, community_id, event, Some(channel_id), None) + .await?; + if !was_inserted { + // The primary-key row already exists. The idempotent-replay branch above + // already returned `Duplicate` for the case where the incoming event is + // the canonical live head. Reaching here means the row exists but is NOT + // the live head (e.g. soft-deleted). Because every kind-40100 mutator + // serializes on this advisory key, no concurrent writer can have changed + // the live head since our read above, so the truthful result is + // `RevisionMismatch` — a false `Duplicate` would acknowledge a write + // that did not become live. + tx.rollback().await?; + return Ok((stored, ChannelHeadWriteStatus::RevisionMismatch)); + } + crate::insert_mentions_in_transaction(&mut tx, community_id, event, Some(channel_id)).await?; + tx.commit().await?; + + Ok((stored, ChannelHeadWriteStatus::Inserted)) +} + impl Db { /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. #[datastore_span(name = "insert_event", system = "postgresql")] @@ -1898,16 +2086,6 @@ impl Db { .await } - /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. - #[datastore_span(name = "soft_delete_event", system = "postgresql")] - pub async fn soft_delete_event( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result { - crate::event::soft_delete_event(&self.pool, community_id, event_id).await - } - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` /// when it is not newer than the deletion request. /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; @@ -2117,6 +2295,22 @@ impl Db { .await?; Ok(result.rows_affected()) } + + /// Conditionally append a canvas write (kind 40100) under an optimistic-concurrency + /// precondition. Delegates to [`insert_channel_head_checked`]. + /// + /// Always uses the writer pool — the precondition check and the insert must + /// be serialized on the writer to prevent TOCTOU races. + #[datastore_span(name = "insert_channel_head_checked", system = "postgresql")] + pub async fn insert_channel_head_checked( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Uuid, + precondition: ChannelHeadPrecondition<'_>, + ) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { + insert_channel_head_checked(&self.pool, community_id, event, channel_id, precondition).await + } } #[cfg(test)] @@ -2908,4 +3102,941 @@ mod postgres_tests { assert!(!huddle_started_content_links(&wrong_field, channel_id)); assert!(!huddle_started_content_links("not-json", channel_id)); } + + // ─── canvas CAS tests ───────────────────────────────────────────────────── + + fn make_canvas_event_at(content: &str, created_at: u64) -> nostr::Event { + make_event_at(buzz_core::kind::KIND_CANVAS as u16, content, created_at) + } + + /// Build two canvas events sharing `created_at`, returned `(lower, higher)` + /// by event id. Regenerates until the ids differ (always, since keys differ) + /// so tests can assert deterministic head ordering on the id tiebreak. + fn same_second_ordered_pair(created_at: u64) -> (nostr::Event, nostr::Event) { + let a = make_canvas_event_at("# A", created_at); + let b = make_canvas_event_at("# B", created_at); + if a.id.as_bytes() <= b.id.as_bytes() { + (a, b) + } else { + (b, a) + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expect_no_head_creates_first_canvas() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expect_no_head_rejects_when_head_exists() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let second = make_canvas_event_at("# Racing create", 1001); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &second, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("second create attempt"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch); + + // The losing create must not have been persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(second.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count losing create"); + assert_eq!(persisted, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_matches_and_advances() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let second = make_canvas_event_at("# Second", 1001); + let head = first.id.as_bytes(); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &second, + channel, + ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + ) + .await + .expect("edit against head"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_mismatch_rejects() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let stale = make_canvas_event_at("# Stale edit", 1002); + let wrong_head = [0u8; 32]; + let (_, status) = insert_channel_head_checked( + &pool, + community, + &stale, + channel, + ChannelHeadPrecondition::ExpectedHead(&wrong_head), + ) + .await + .expect("stale edit attempt"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_missing_rejects() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# Edit with no head", 1000); + let some_head = [1u8; 32]; + + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectedHead(&some_head), + ) + .await + .expect("edit with no head"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMissing); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_replay_of_head_is_duplicate() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + // Replaying the exact head under ExpectedHead(head) is idempotent. + let head = event.id.as_bytes(); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + ) + .await + .expect("replay head"); + assert_eq!(status, ChannelHeadWriteStatus::Duplicate); + } + + /// Idempotent-replay exception: replaying the byte-identical head succeeds + /// as a no-op even when the supplied precondition would otherwise conflict. + /// Precondition is not evaluated for a duplicate, so safe transport retry + /// never becomes a false conflict. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_replay_skips_stale_precondition() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + // Replay the same bytes with a now-stale `ExpectNoHead` tag: a head + // exists, so the precondition would reject — but replay short-circuits. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("replay with stale none tag"); + assert_eq!(status, ChannelHeadWriteStatus::Duplicate); + } + + /// Head-advancement guarantee: a candidate whose `created_at` equals the + /// head's but whose id is HIGHER cannot become the head under + /// `created_at DESC, id ASC`, so it rejects even though the precondition + /// matches. Accepting it would leave the visible canvas unchanged. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_rejects_same_second_higher_id() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let (lower, higher) = same_second_ordered_pair(1000); + insert_channel_head_checked( + &pool, + community, + &lower, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas is lower-id head"); + + // Candidate has the same created_at but a higher id → cannot supersede. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &higher, + channel, + ChannelHeadPrecondition::ExpectedHead(lower.id.as_bytes().as_slice()), + ) + .await + .expect("same-second higher-id edit"); + assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); + + // The rejected write must not have been persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(higher.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rejected write"); + assert_eq!(persisted, 0); + } + + /// Head-advancement guarantee: a candidate at the same second with a LOWER + /// id does sort strictly ahead of the head, so it advances. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_accepts_same_second_lower_id() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let (lower, higher) = same_second_ordered_pair(1000); + // Seed the higher-id event as the head first. + insert_channel_head_checked( + &pool, + community, + &higher, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas is higher-id head"); + + // Lower id at the same second sorts strictly ahead → advances. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &lower, + channel, + ChannelHeadPrecondition::ExpectedHead(higher.id.as_bytes().as_slice()), + ) + .await + .expect("same-second lower-id edit"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + /// Head-advancement guarantee: a behind-clock writer whose `created_at` + /// predates the head cannot supersede it, even with a matching precondition. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_rejects_behind_clock_writer() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let head = make_canvas_event_at("# Head", 2000); + insert_channel_head_checked( + &pool, + community, + &head, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("head at t=2000"); + + // Writer's clock is behind — created_at earlier than head. + let behind = make_canvas_event_at("# Behind clock", 1100); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &behind, + channel, + ChannelHeadPrecondition::ExpectedHead(head.id.as_bytes().as_slice()), + ) + .await + .expect("behind-clock edit"); + assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); + } + /// Derives the advisory-lock key for a canvas coordinate, matching the key + /// computed inside `insert_channel_head_checked` and + /// `soft_delete_event_and_update_thread`. + fn canvas_lock_key(community: CommunityId, channel: Uuid) -> i64 { + crate::store::replaceable::event_replacement_lock_key( + community, + buzz_core::kind::KIND_CANVAS as i32, + &[], + Some(channel.as_bytes().as_slice()), + ) + } + + /// Polls `pg_locks` until at least `min_waiters` sessions are queued as + /// ungranted waiters on the given `int8` advisory lock key, or until + /// `timeout` elapses. + /// + /// Returns `Ok(())` once the condition is met. Panics if the timeout + /// expires before the required number of waiters appear, or if any waiter + /// task completes (exits the lock wait) before the condition is met. + /// + /// Postgres stores `pg_advisory_xact_lock(int8)` rows in `pg_locks` as + /// `(locktype='advisory', classid=(key>>32)::oid, objid=(key & 0xffffffff)::oid)`. + /// The `classid`/`objid` columns are of type `oid` (unsigned 32-bit integer). + async fn wait_for_advisory_waiters( + pool: &PgPool, + lock_key: i64, + min_waiters: i64, + timeout: std::time::Duration, + ) { + let deadline = std::time::Instant::now() + timeout; + // Split the int8 key into its two oid halves exactly as Postgres does. + // Cast each half to int8 first (no overflow), then let sqlx bind them + // as bigint; Postgres compares oid columns via implicit cast. + let classid = ((lock_key as u64) >> 32) as i64; + let objid = ((lock_key as u64) & 0xffff_ffff) as i64; + loop { + let waiters: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_locks \ + WHERE locktype = 'advisory' \ + AND classid = $1::oid \ + AND objid = $2::oid \ + AND NOT granted", + ) + .bind(classid) + .bind(objid) + .fetch_one(pool) + .await + .expect("pg_locks waiter query"); + + if waiters >= min_waiters { + return; + } + + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {min_waiters} advisory waiters on key {lock_key:#x}; \ + only {waiters} appeared — the production lock was likely removed" + ); + + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + } + + /// Tombstone regression: H → A → soft-delete A → replay A-on-H must return + /// `RevisionMismatch`. A must remain deleted and H must remain the live head. + /// + /// Mutation oracle: the post-insert conflict branch returning `Duplicate` + /// unconditionally makes this test fail with status `Duplicate` instead of + /// `RevisionMismatch`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_deleted_replay_returns_revision_mismatch() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + // H: seed the initial head. + let head = make_canvas_event_at("# Head", 1000); + insert_channel_head_checked( + &pool, + community, + &head, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed head H"); + let head_id = head.id.to_bytes().to_vec(); + + // A: insert a second revision. + let a = make_canvas_event_at("# A", 1001); + insert_channel_head_checked( + &pool, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&head_id), + ) + .await + .expect("insert A"); + let a_id = a.id.to_bytes().to_vec(); + + // Soft-delete A so H becomes the live head again. + soft_delete_event_and_update_thread(&pool, community, &a_id, None, None) + .await + .expect("soft-delete A"); + + // Verify A is deleted and H is the live head before replay. + let a_deleted: Option = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(&a_id) + .fetch_optional(&pool) + .await + .expect("check A deleted"); + assert_eq!( + a_deleted, + Some(true), + "A must be soft-deleted before replay" + ); + + // Replay byte-identical A against live head H — must not return Duplicate. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&head_id), + ) + .await + .expect("replay A"); + assert_eq!( + status, + ChannelHeadWriteStatus::RevisionMismatch, + "replay of a deleted event must return RevisionMismatch, not Duplicate" + ); + + // A must still be deleted after the replay attempt. + let a_still_deleted: Option = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(&a_id) + .fetch_optional(&pool) + .await + .expect("check A still deleted"); + assert_eq!( + a_still_deleted, + Some(true), + "A must remain deleted after replay" + ); + + // H must remain the canonical live head. + let live_head: Vec = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 \ + AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("read live head"); + assert_eq!( + live_head, head_id, + "H must remain the live head after failed replay" + ); + } + + /// Race soundness: two concurrent authors both assert the same live head and + /// both sign strictly-advancing writes. The per-(community, channel) advisory + /// lock must serialize check+insert so exactly one wins (`Inserted`) and the + /// other observes the moved head (`RevisionMismatch`). Exactly one write may + /// become the visible head; the loser must not appear as a live row. + /// + /// Causal oracle: an external connection holds the exact advisory key before + /// either writer starts. Both writers are spawned and `pg_locks` is polled + /// until both appear as ungranted waiters on this exact key. Only then is + /// the blocker released. This is a causal proof: both sessions have opened + /// their transactions and reached `pg_advisory_xact_lock` before the + /// interleaving begins — the outcome is deterministic rather than + /// scheduler-dependent. + /// + /// Mutation oracle: removing `pg_advisory_xact_lock` from + /// `insert_channel_head_checked` means neither writer queues as a waiter; + /// `wait_for_advisory_waiters` times out, or both writers read the same head, + /// both insert, and `head_count` becomes 3 instead of 2. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_concurrent_authors_only_one_advances() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let base = make_canvas_event_at("# Base", 1000); + insert_channel_head_checked( + &pool, + community, + &base, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed base head"); + let base_id = base.id.to_bytes().to_vec(); + + // Acquire the exact advisory key from an external connection so both + // writers block immediately at pg_advisory_xact_lock. + let lock_key = canvas_lock_key(community, channel); + let mut blocker = pool.begin().await.expect("blocker tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *blocker) + .await + .expect("blocker acquires key"); + + // Both edits assert `base` as their head and are timestamped strictly + // ahead of it (writer discipline), so neither trips SupersedeFailed — + // only the advisory-lock serialization decides the winner. + let a = make_canvas_event_at("# Author A", 1001); + let b = make_canvas_event_at("# Author B", 1002); + + let (pool_a, pool_b) = (pool.clone(), pool.clone()); + let (id_a, id_b) = (base_id.clone(), base_id.clone()); + let ta = tokio::spawn(async move { + insert_channel_head_checked( + &pool_a, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&id_a), + ) + .await + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) + }); + let tb = tokio::spawn(async move { + insert_channel_head_checked( + &pool_b, + community, + &b, + channel, + ChannelHeadPrecondition::ExpectedHead(&id_b), + ) + .await + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) + }); + + // Wait until both writers are observed as ungranted advisory-lock + // waiters in pg_locks. This is a causal proof that both sessions have + // opened their transactions and are blocked at pg_advisory_xact_lock + // before we release the blocker. + wait_for_advisory_waiters(&pool, lock_key, 2, std::time::Duration::from_secs(5)).await; + + // Release — both writers unblock and race for the advisory lock. + blocker.rollback().await.expect("release blocker"); + + let (id_a, status_a) = ta.await.expect("join A").expect("call A"); + let (id_b, status_b) = tb.await.expect("join B").expect("call B"); + + let (winner_id, loser_id) = if status_a == ChannelHeadWriteStatus::Inserted { + assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch); + (id_a, id_b) + } else { + assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch); + assert_eq!(status_b, ChannelHeadWriteStatus::Inserted); + (id_b, id_a) + }; + + // Exactly one new canvas row (beyond the base) was committed. + let head_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("count committed canvas rows"); + assert_eq!(head_count, 2, "base plus exactly one winning edit"); + + // The canonical live head must be the winner's event. + let live_head: Vec = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("read live head"); + assert_eq!( + live_head, winner_id, + "live head must be the Inserted writer's event" + ); + + // The losing writer's event must not be present as a live row. + let loser_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&loser_id) + .fetch_one(&pool) + .await + .expect("check loser absent"); + assert_eq!( + loser_count, 0, + "the losing writer's event must not be a live row" + ); + } + + /// Race soundness for first-create: two writers both assert `ExpectNoHead` + /// simultaneously. Exactly one must be `Inserted`; the other gets + /// `RevisionMismatch`. The loser must not be persisted. + /// + /// Causal oracle: an external connection holds the exact advisory key before + /// either writer starts. Both writers are spawned and `pg_locks` is polled + /// until both appear as ungranted waiters on this exact key, proving both + /// transactions have opened and reached `pg_advisory_xact_lock`. Only then + /// is the blocker released. + /// + /// Mutation oracle: removing `pg_advisory_xact_lock` from + /// `insert_channel_head_checked` means neither writer queues as a waiter; + /// `wait_for_advisory_waiters` times out, or both writers read `None` for + /// the head, both pass `ExpectNoHead`, both insert, and `head_count` becomes 2. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_concurrent_first_create_only_one_wins() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + // Acquire the exact advisory key before either writer starts. + let lock_key = canvas_lock_key(community, channel); + let mut blocker = pool.begin().await.expect("blocker tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *blocker) + .await + .expect("blocker acquires key"); + + let a = make_canvas_event_at("# First A", 1000); + let b = make_canvas_event_at("# First B", 1001); + + let (pool_a, pool_b) = (pool.clone(), pool.clone()); + let ta = tokio::spawn(async move { + insert_channel_head_checked( + &pool_a, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) + }); + let tb = tokio::spawn(async move { + insert_channel_head_checked( + &pool_b, + community, + &b, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) + }); + + // Wait until both writers are observed as ungranted advisory-lock + // waiters in pg_locks. This is a causal proof that both sessions have + // opened their transactions and are blocked at pg_advisory_xact_lock + // before we release the blocker. + wait_for_advisory_waiters(&pool, lock_key, 2, std::time::Duration::from_secs(5)).await; + + // Release — both race for the advisory lock. + blocker.rollback().await.expect("release blocker"); + + let (id_a, status_a) = ta.await.expect("join A").expect("call A"); + let (id_b, status_b) = tb.await.expect("join B").expect("call B"); + + let (winner_id, loser_id) = if status_a == ChannelHeadWriteStatus::Inserted { + assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch); + (id_a, id_b) + } else { + assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch); + assert_eq!(status_b, ChannelHeadWriteStatus::Inserted); + (id_b, id_a) + }; + + // Exactly one canvas row exists. + let head_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("count canvas rows"); + assert_eq!(head_count, 1, "exactly one first-create winner"); + + // The stored head is the winner. + let head_id: Vec = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("read head"); + assert_eq!(head_id, winner_id, "stored head must be the winner"); + + // The loser must not appear as a live row. + let loser_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&loser_id) + .fetch_one(&pool) + .await + .expect("check loser absent"); + assert_eq!( + loser_count, 0, + "the losing first-create writer must not be a live row" + ); + } + + /// Serialization coverage: an untagged kind-40100 unconditional append must + /// acquire the same `(community, kind, channel)` advisory key as a tagged + /// write so the two cannot interleave on the head read. + /// + /// Proof: an external connection holds the advisory key; the untagged write + /// task is spawned. Since the write acquires the same key, it blocks while + /// the holder has it — `JoinHandle::is_finished()` returns false. After the + /// holder releases, the task completes and the row is committed. + /// + /// Mutation oracle: removing the `pg_advisory_xact_lock` block from + /// `insert_event_with_thread_metadata` for kind-40100 lets the write proceed + /// without acquiring the key. The task completes immediately (no block), so + /// `is_finished()` returns true while the holder still has the key — + /// `assert!(!write_task.is_finished())` fails. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_untagged_canvas_append_serializes_on_advisory_key() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let lock_key = canvas_lock_key(community, channel); + + // Hold the exact advisory key on a dedicated connection. + let mut holder = pool.begin().await.expect("holder tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("holder acquires key"); + + // Spawn the untagged write — it should block at pg_advisory_xact_lock. + let event = make_canvas_event_at("# Untagged", 1000); + let pool_write = pool.clone(); + let write_task = tokio::spawn(async move { + insert_event_with_thread_metadata(&pool_write, community, &event, Some(channel), None) + .await + }); + + // Give the write task time to open its transaction and reach the lock. + // The runtime drives the task until it blocks (advisory-lock wait suspends + // the async task back to the executor). + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // The write task must NOT have finished while the holder has the key. + assert!( + !write_task.is_finished(), + "untagged canvas write must block on the advisory key while holder has it; \ + the task finished immediately, meaning the lock was not acquired" + ); + + // Release the holder — the blocked write can now acquire the key. + holder.rollback().await.expect("release holder"); + + // The write must now complete successfully. + let (stored, was_inserted) = write_task + .await + .expect("join write task") + .expect("untagged canvas insert"); + assert!( + was_inserted, + "untagged canvas write must be inserted after holder releases" + ); + let row_count: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(stored.event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count untagged row"); + assert_eq!(row_count, 1, "untagged canvas row must be committed"); + } + + /// Serialization coverage: `soft_delete_event_and_update_thread` for a + /// kind-40100 event must acquire the same advisory key as tagged writes. + /// + /// Proof: an external connection holds the advisory key; the delete task + /// is spawned and blocks while the holder has it — `is_finished()` returns + /// false. After the holder releases, the task completes and the row is + /// soft-deleted. + /// + /// Mutation oracle: removing the advisory-lock branch from + /// `soft_delete_event_and_update_thread` lets the delete bypass the key. + /// The task finishes immediately while the holder still holds the key — + /// `assert!(!delete_task.is_finished())` fails. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_canvas_deletion_serializes_on_advisory_key() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let lock_key = canvas_lock_key(community, channel); + + // Insert a canvas event to delete. + let event = make_canvas_event_at("# To delete", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed canvas for deletion test"); + let event_id = event.id.to_bytes().to_vec(); + + // Hold the exact advisory key on a dedicated connection. + let mut holder = pool.begin().await.expect("holder tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("holder acquires key"); + + // Spawn the delete task — it should block at pg_advisory_xact_lock. + let pool_del = pool.clone(); + let eid = event_id.clone(); + let delete_task = tokio::spawn(async move { + soft_delete_event_and_update_thread(&pool_del, community, &eid, None, None).await + }); + + // Give the delete task time to open its transaction and reach the lock. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // The delete task must NOT have finished while the holder has the key. + assert!( + !delete_task.is_finished(), + "canvas soft-delete must block on the advisory key while holder has it; \ + the task finished immediately, meaning the lock was not acquired" + ); + + // Release the holder — the blocked delete can now acquire the key. + holder.rollback().await.expect("release holder"); + + // The delete must now complete successfully. + let deleted = delete_task + .await + .expect("join delete task") + .expect("canvas soft-delete"); + assert!( + deleted, + "canvas event must be deleted after holder releases" + ); + + let is_deleted: bool = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .fetch_one(&pool) + .await + .expect("check deleted_at"); + assert!( + is_deleted, + "canvas event must have deleted_at set after soft-delete" + ); + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index e7433d43b13..6327ee166d0 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -297,6 +297,60 @@ fn extract_before_id(raw: &Value) -> BeforeId { } } +/// The `consistency` extension field: a read-your-writes opt-in. A +/// write-influencing read (a canvas save's head precondition or its post-write +/// ancestry verification) sets `"consistency": "strong"` so the relay serves it +/// from the writer pool, never a replica that may lag behind the caller's own +/// just-accepted write. Absent = the default routed path (replica-eligible when +/// `BUZZ_REPLICA_READ_MAX_AGE_MS` is set). +/// +/// This only ever forces the *writer*, which is always the sound direction (a +/// replica can be stale, the writer never is), so it cannot be abused to skip +/// data — there is deliberately no inverse "force replica" value. Any value +/// other than the single accepted `"strong"` is rejected, so a typo fails loud +/// rather than silently degrading to routed. +enum Consistency { + /// Absent: route normally (replica-eligible under the read budget). + Default, + /// `"strong"`: pin this filter's read to the writer pool. + Strong, + /// Present but not `"strong"`: reject the request. + Malformed, +} + +fn extract_consistency(raw: &Value) -> Consistency { + let Some(value) = raw.get("consistency") else { + return Consistency::Default; + }; + match value.as_str() { + Some("strong") => Consistency::Strong, + _ => Consistency::Malformed, + } +} + +/// Which pool a catchall filter's read is dispatched to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadRoute { + /// The default replica-eligible path (`query_events_routed`). + Routed, + /// The writer pool (`query_events`), pinned by `"consistency": "strong"`. + Writer, +} + +/// Resolve the pool a filter reads from, folding the `consistency` extension +/// into a routing direction. `"strong"` pins the writer; absent routes +/// normally; any other value is a client error (`Err`), rejected before any DB +/// work. This is the single seam that maps client-carried intent to a pool, so +/// a refactor that drops the field flips the mapping this function returns and +/// its tests fail. +fn resolve_read_route(raw: &Value) -> Result { + match extract_consistency(raw) { + Consistency::Default => Ok(ReadRoute::Routed), + Consistency::Strong => Ok(ReadRoute::Writer), + Consistency::Malformed => Err(()), + } +} + fn extract_buzz_channel(raw: &Value) -> Option<&str> { raw.get("#buzz-channel") .and_then(Value::as_array) @@ -802,11 +856,15 @@ pub async fn submit_event( "HTTP bridge request" ); } - SubmitOutcome::Rejected { kind, reason, .. } => { + SubmitOutcome::Rejected { + kind, + reason, + response, + } => { tracing::warn!( pubkey = %pubkey_hex, route = "/events", - status = 400u16, + status = response.0.as_u16(), accepted = false, kind, reason = %reason, @@ -845,7 +903,10 @@ enum SubmitOutcome { column: usize, response: (StatusCode, Json), }, - /// IngestError::Rejected — log kind + truncated reason. + /// IngestError::Rejected or IngestError::CanvasConflict — log kind + truncated reason. + /// + /// Generic rejections yield HTTP 400; canvas CAS conflicts yield HTTP 409. + /// The logged `status` reflects the actual response status carried in `response`. Rejected { kind: u32, reason: String, @@ -989,6 +1050,19 @@ async fn submit_event_authed( response: api_error(StatusCode::BAD_REQUEST, &msg), } } + Err(IngestError::CanvasConflict(msg)) => { + // Canvas CAS precondition failures are a distinct HTTP 409 so the + // CLI's reconciliation branch (which gates on `status == 409`) is + // reachable against the live relay. The message body is unchanged; + // the desktop TypeScript layer matches on message text, not status. + let reason = truncate_reason(&msg, REJECT_REASON_MAX_BYTES).to_owned(); + crate::handlers::ingest::reject_with_transport("http", "invalid"); + SubmitOutcome::Rejected { + kind: kind_u32, + reason, + response: api_error(StatusCode::CONFLICT, &msg), + } + } Err(IngestError::AuthFailed(msg)) => { crate::handlers::ingest::reject_with_transport("http", "auth"); let e = api_error(StatusCode::FORBIDDEN, &msg); @@ -1429,7 +1503,7 @@ async fn query_events_authed( // skips and the `before_id` BAD_REQUEST are decided here, before any DB // work is issued (validation errors are deterministic client mistakes, so // surfacing them ahead of transient DB errors is strictly more predictable). - let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new(); + let mut catchall_queries: Vec<(usize, buzz_db::EventQuery, ReadRoute)> = Vec::new(); for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { if handled.contains(&idx) { continue; @@ -1441,6 +1515,17 @@ async fn query_events_authed( } } + // Read-your-writes opt-in: a write-influencing read pins to the writer + // pool so a lagging replica cannot hide the caller's own just-accepted + // write. Rejected before any DB work, like the `before_id` grammar + // error below — a malformed value is a deterministic client mistake. + let read_route = resolve_read_route(raw).map_err(|()| { + api_error( + StatusCode::BAD_REQUEST, + "consistency must be \"strong\" when present", + ) + })?; + let mut query = crate::handlers::req::build_event_query_from_filter( filter, &pubkey_bytes, @@ -1494,7 +1579,7 @@ async fn query_events_authed( query.offset = Some(offset); } - catchall_queries.push((idx, query)); + catchall_queries.push((idx, query, read_route)); } // Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`). @@ -1502,10 +1587,23 @@ async fn query_events_authed( // and error semantics match the previous serial loop. use futures_util::stream::{self, StreamExt}; let db = state.db.clone(); - let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| { - let db = db.clone(); - async move { (idx, db.query_events_routed("bridge_query", &query).await) } - })) + let mut catchall_results = stream::iter(catchall_queries.into_iter().map( + |(idx, query, read_route)| { + let db = db.clone(); + async move { + // The route was resolved from client-carried `consistency` + // intent in phase 1 (`resolve_read_route`). `Writer` pins the + // read to the writer pool (`query_events`); `Routed` takes the + // replica-eligible path. Only these two directions exist — the + // inverse "force replica" is deliberately unrepresentable. + let result = match read_route { + ReadRoute::Writer => db.query_events(&query).await, + ReadRoute::Routed => db.query_events_routed("bridge_query", &query).await, + }; + (idx, result) + } + }, + )) .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); // Phase 3 — post-processing, strictly in filter order. @@ -3490,6 +3588,76 @@ mod postgres_tests { assert!(matches!(extract_before_id(&raw), BeforeId::Malformed)); } + #[test] + fn extract_consistency_strong_pins_to_writer() { + let raw = serde_json::json!({ "consistency": "strong" }); + assert!(matches!(extract_consistency(&raw), Consistency::Strong)); + } + + #[test] + fn extract_consistency_absent_is_default_routed() { + let raw = serde_json::json!({ "kinds": [40100] }); + assert!(matches!(extract_consistency(&raw), Consistency::Default)); + } + + #[test] + fn extract_consistency_unknown_value_is_malformed() { + // A typo or an attempt to name the inverse "force replica" direction + // must reject the request, never silently degrade to routed. + for bad in [ + serde_json::json!({ "consistency": "weak" }), + serde_json::json!({ "consistency": "replica" }), + serde_json::json!({ "consistency": "eventual" }), + serde_json::json!({ "consistency": "STRONG" }), + serde_json::json!({ "consistency": true }), + serde_json::json!({ "consistency": 1 }), + ] { + assert!( + matches!(extract_consistency(&bad), Consistency::Malformed), + "{bad} must be rejected as malformed" + ); + } + } + + /// The routing direction the catchall loop dispatches on. A filter carrying + /// `"consistency": "strong"` MUST resolve to the writer pool + /// (`ReadRoute::Writer` → `query_events`); one without MUST resolve to the + /// replica-eligible path (`ReadRoute::Routed` → `query_events_routed`). + /// Both directions are pinned here so a refactor that drops the field on + /// the floor — reading every filter from one pool — flips one of these and + /// fails. The writer-vs-replica pool divergence itself is exercised by the + /// two-pool `routed_reads_are_confined_to_the_requested_community` test in + /// buzz-db (`#[ignore]`, requires Postgres). + #[test] + fn resolve_read_route_pins_strong_to_writer() { + let strong = serde_json::json!({ "consistency": "strong" }); + assert_eq!(resolve_read_route(&strong), Ok(ReadRoute::Writer)); + } + + #[test] + fn resolve_read_route_defaults_to_routed_replica() { + let absent = serde_json::json!({ "kinds": [40100], "limit": 1 }); + assert_eq!(resolve_read_route(&absent), Ok(ReadRoute::Routed)); + } + + #[test] + fn resolve_read_route_rejects_unknown_values() { + // Malformed never degrades to a pool — it is a client error, so the + // catchall loop turns this `Err` into a BAD_REQUEST before any DB work. + for bad in [ + serde_json::json!({ "consistency": "weak" }), + serde_json::json!({ "consistency": "replica" }), + serde_json::json!({ "consistency": "STRONG" }), + serde_json::json!({ "consistency": true }), + ] { + assert_eq!( + resolve_read_route(&bad), + Err(()), + "{bad} must be a client error, never a pool" + ); + } + } + /// Extension flags opt in only on a literal JSON `true` — absent, /// non-boolean, and truthy-but-not-bool values all read as false, so a /// malformed filter degrades to a normal query instead of a wrong window. @@ -3941,6 +4109,36 @@ mod postgres_tests { .status() } + /// Like `post_events` but also returns the UTF-8 response body. + async fn post_events_with_body( + state: Arc, + host: &str, + pubkey_hex: &str, + body: &[u8], + ) -> (axum::http::StatusCode, String) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/events") + .header(header::HOST, host) + .header("x-pubkey", pubkey_hex) + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read response body"); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } + /// Collect buzz_events_rejected_total with (transport, reason) labels from /// a DebuggingRecorder snapshot. fn http_reject_counts( @@ -4084,6 +4282,245 @@ mod postgres_tests { ); } + /// Canvas ingest wiring regression: the kind-40100-specific future-timestamp + /// guard in `ingest_event_inner` is actually wired to the shipping call path. + /// + /// Calls `post_events_with_body` → router → `submit_event` → `ingest_event_inner`: + /// - A canvas event with `created_at = relay_now + 600` is rejected 400 with + /// the canvas-specific error "canvas event timestamp too far in the future". + /// + /// The +600 offset sits 300 s above the canvas ceiling (300 s) and 300 s + /// below the general drift bound (900 s). Scheduler latency between test + /// setup and production's independent `Utc::now()` re-sample would need to + /// exceed 300 s to erode the margin — not possible under any realistic load. + /// Exact 300/301 boundary coverage lives in the pure `validate_canvas_future_timestamp` + /// tests (`canvas_ingest_numeric_contract`, `canvas_ingest_future_timestamp_boundary`), + /// which pass fixed arguments and have no clock race. + /// + /// Discriminating: deleting the `if kind_u32 == KIND_CANVAS { … }` call in + /// `ingest_event_inner` removes the guard. The event then passes the general + /// ±900 s drift check (600 s < 900 s) and reaches the channel membership + /// check (no h-tag channel exists → "restricted: not a channel member"), + /// making the message assertion below fail with a different body. + #[test] + #[ignore = "requires Postgres"] + fn canvas_ingest_future_timestamp_guard_is_wired() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(bridge_handler_test_state()) else { + panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + }; + + let host = { + let h = format!( + "canvas-ingest-wiring-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&h)) + .expect("ensure community"); + h + }; + + let client_keys = Keys::generate(); + let pubkey_hex = client_keys.public_key().to_hex(); + + // A canvas event 600 s in the future. The +600 offset sits 300 s above + // the canvas ceiling and 300 s below the general ±900 s drift bound, so + // only the canvas guard can produce a rejection here. Scheduler latency + // between this Utc::now() call and production's independent re-sample + // would need to exceed 300 s to erode the margin — impossible in practice. + // Exact 300/301 boundary assertions live in the pure fixed-literal tests. + let relay_now = chrono::Utc::now().timestamp(); + // Use a random channel UUID that does NOT exist in the DB. If the canvas + // guard is correctly wired, it fires first; if deleted, the event passes + // the general ±900 s check (600 < 900) and reaches the membership check, + // producing "not a channel member" instead of the canvas rejection. + let channel_id = uuid::Uuid::new_v4().to_string(); + let event_past_ceiling = + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .custom_created_at(nostr::Timestamp::from( + (relay_now + 600).try_into().unwrap_or(0u64), + )) + .sign_with_keys(&client_keys) + .expect("sign canvas event past ceiling"); + let body_bytes = + serde_json::to_vec(&event_past_ceiling).expect("serialize event past ceiling"); + + let (status, body) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &pubkey_hex, + &body_bytes, + )); + + // Must be 400 AND the body must name the canvas guard (not the membership check). + // Mutation oracle: deleting the `if kind_u32 == KIND_CANVAS { … }` guard + // makes the body say "not a channel member" instead, failing both assertions. + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "canvas event at relay_now+600 must be rejected 400; body: {body}", + ); + assert!( + body.contains("canvas event timestamp too far in the future"), + "rejection body must name the canvas guard (not the membership check). \ + Got: {body}; mutation oracle: delete the guard call site → body becomes \ + 'not a channel member'", + ); + } + + /// Wire-pinning test: a canvas CAS conflict must reach the HTTP client as + /// **409 CONFLICT**, not 400. + /// + /// The relay's `IngestError::CanvasConflict` variant maps to `409` via the + /// `bridge.rs` HTTP handler. The CLI reconciliation branch gates on + /// `status == 409`; if the bridge emits `400` instead the reconciliation + /// path is dead code against the live relay. + /// + /// Scenario: + /// 1. POST canvas event A (no `expected-revision` tag) → 200, head = A. + /// 2. POST canvas event B with `expected-revision: ` → 200, head = B. + /// 3. POST canvas event C with `expected-revision: ` (stale, A ≠ B) + /// → 409 with a body containing `"canvas changed since it was loaded"`. + /// + /// Mutation oracle: mapping `IngestError::CanvasConflict` to + /// `StatusCode::BAD_REQUEST` (reverting the fix) makes step 3 return 400 + /// and fails the status assertion. The body assertion separately pins the + /// exact `error` envelope value. + #[test] + #[ignore = "requires Postgres"] + fn canvas_cas_conflict_yields_409_through_http_bridge() { + use buzz_core::kind::KIND_CANVAS; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use uuid::Uuid; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(bridge_handler_test_state()) else { + panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + }; + + let (host, channel_id) = rt.block_on(async { + let h = format!("canvas-cas-409-wiring-{}.local", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&h) + .await + .expect("ensure community"); + let creator_keys = Keys::generate(); + let (channel, _) = state + .db + .create_channel_with_id( + community.id, + Uuid::new_v4(), + &format!("canvas-cas-409-{}", Uuid::new_v4().simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator_keys.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + (h, channel.id.to_string()) + }); + + let author_keys = Keys::generate(); + let pubkey_hex = author_keys.public_key().to_hex(); + + let relay_now = chrono::Utc::now().timestamp() as u64; + + // Step 1: unconditional first write — establishes head A. + let event_a = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# first canvas") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .custom_created_at(nostr::Timestamp::from(relay_now)) + .sign_with_keys(&author_keys) + .expect("sign canvas event A"); + let event_a_id = event_a.id.to_hex(); + let body_a = serde_json::to_vec(&event_a).expect("serialize event A"); + + let (status_a, _) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &pubkey_hex, + &body_a, + )); + assert_eq!( + status_a, + axum::http::StatusCode::OK, + "first canvas write must be accepted" + ); + + // Step 2: write B on top of A — advances head so A is no longer current. + let event_b = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# second canvas (on A)") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .tag( + Tag::parse(["expected-revision", event_a_id.as_str()]) + .expect("expected-revision tag"), + ) + .custom_created_at(nostr::Timestamp::from(relay_now + 1)) + .sign_with_keys(&author_keys) + .expect("sign canvas event B"); + let body_b = serde_json::to_vec(&event_b).expect("serialize event B"); + + let (status_b, _) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &pubkey_hex, + &body_b, + )); + assert_eq!( + status_b, + axum::http::StatusCode::OK, + "second canvas write (B on A) must be accepted" + ); + + // Step 3: stale write C with the same `expected-revision: A` — A is no + // longer the head (B is), so this must be a CAS conflict → HTTP 409. + // Mutation oracle: reverting IngestError::CanvasConflict → BAD_REQUEST + // in bridge.rs makes this return 400 and fails the status assertion. + let event_c = EventBuilder::new( + Kind::Custom(KIND_CANVAS as u16), + "# stale write (still on A)", + ) + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .tag(Tag::parse(["expected-revision", event_a_id.as_str()]).expect("expected-revision tag")) + .custom_created_at(nostr::Timestamp::from(relay_now + 2)) + .sign_with_keys(&author_keys) + .expect("sign canvas event C"); + let body_c = serde_json::to_vec(&event_c).expect("serialize event C"); + + let (status_c, body_text) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &pubkey_hex, + &body_c, + )); + + assert_eq!( + status_c, + axum::http::StatusCode::CONFLICT, + "stale canvas CAS write must yield 409 CONFLICT (not 400); body: {body_text}" + ); + // Parse the response body and assert the exact canonical `error` value to + // pin the byte-preservation contract. A substring check would pass even if + // the message were embedded elsewhere; this ensures the envelope is intact. + let body_json: serde_json::Value = + serde_json::from_str(&body_text).expect("response body must be valid JSON"); + assert_eq!( + body_json.get("error").and_then(|v| v.as_str()), + Some("conflict: canvas changed since it was loaded"), + "409 body must carry the exact canonical error value. Got: {body_text}" + ); + } + // ────────────────────────────────────────────────────────────────────────── // Log-capture helpers and attribution-invariant tests // @@ -4266,4 +4703,459 @@ mod postgres_tests { "attribution line must carry the pubkey;\nlog:\n{log}" ); } + + /// T3c — log fidelity for canvas CAS conflict: the terminal attribution line + /// must log `status=409`, not 400, when the relay emits a canvas CAS 409. + /// + /// Before the fix, `SubmitOutcome::Rejected` hardcoded `status = 400u16` in + /// its logging arm, so every canvas CAS conflict — which now correctly + /// returns HTTP 409 to the client — was misattributed as 400 in the relay + /// log. This test pins both the log fidelity and the 400 control so the + /// distinction is exercised in the same run. + /// + /// - **CAS branch:** a stale canvas write (RevisionMismatch) must log `status=409`. + /// - **Generic-rejection control:** a relay-only-kind event must log `status=400`. + /// + /// Discriminating: restoring `status = 400u16` in bridge.rs's `Rejected` logging + /// arm causes the CAS `status=409` assertion to fail while the 400 control + /// continues to pass — the test is split so the regression direction is unambiguous. + #[test] + #[ignore = "requires Postgres"] + fn canvas_cas_conflict_logs_status_409_not_400() { + use buzz_core::kind::KIND_CANVAS; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use uuid::Uuid; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let state = rt + .block_on(bridge_handler_test_state()) + .expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + + let (host, channel_id) = rt.block_on(async { + let h = format!("canvas-cas-log-{}.local", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&h) + .await + .expect("ensure community"); + let creator_keys = nostr::Keys::generate(); + let (channel, _) = state + .db + .create_channel_with_id( + community.id, + Uuid::new_v4(), + &format!("log-test-{}", Uuid::new_v4().simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator_keys.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + (h, channel.id.to_string()) + }); + + let author_keys = Keys::generate(); + let pubkey_hex = author_keys.public_key().to_hex(); + let relay_now = chrono::Utc::now().timestamp() as u64; + + // Establish head A with an unconditional write. + let event_a = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# head") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .custom_created_at(nostr::Timestamp::from(relay_now)) + .sign_with_keys(&author_keys) + .expect("sign event A"); + let event_a_id = event_a.id.to_hex(); + let body_a = serde_json::to_vec(&event_a).expect("serialize event A"); + // Accept A silently (no log assertion here). + rt.block_on(post_events(state.clone(), &host, &pubkey_hex, &body_a)); + + // Advance head to B. + let event_b = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# head B") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .tag( + Tag::parse(["expected-revision", event_a_id.as_str()]) + .expect("expected-revision tag"), + ) + .custom_created_at(nostr::Timestamp::from(relay_now + 1)) + .sign_with_keys(&author_keys) + .expect("sign event B"); + let body_b = serde_json::to_vec(&event_b).expect("serialize event B"); + rt.block_on(post_events(state.clone(), &host, &pubkey_hex, &body_b)); + + // Stale write C: still expects A, but B is now head → RevisionMismatch → 409. + // Capture the log to assert the logged status. + let event_c = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# stale") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .tag( + Tag::parse(["expected-revision", event_a_id.as_str()]) + .expect("expected-revision tag"), + ) + .custom_created_at(nostr::Timestamp::from(relay_now + 2)) + .sign_with_keys(&author_keys) + .expect("sign event C"); + let body_c = serde_json::to_vec(&event_c).expect("serialize event C"); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let (status_cas, log_cas) = metrics::with_local_recorder(&recorder, || { + run_and_capture(&rt, state.clone(), &host, &pubkey_hex, &body_c) + }); + + assert_eq!( + status_cas, + axum::http::StatusCode::CONFLICT, + "canvas CAS conflict must yield HTTP 409" + ); + // The log must record the real response status, not the former hardcoded 400. + // Discriminating: restoring `status = 400u16` in the Rejected logging arm + // makes this assertion fail while the generic-rejection control below still passes. + assert!( + log_cas.contains("status=409"), + "terminal attribution line must log status=409 for canvas CAS conflict;\nlog:\n{log_cas}" + ); + assert_eq!( + count_attribution_lines(&log_cas), + 1, + "exactly one attribution line for canvas CAS conflict;\nlog:\n{log_cas}" + ); + + // ── Generic-rejection control ──────────────────────────────────────── + // A relay-only-kind event is still a Rejected outcome → HTTP 400. + // This control confirms the fix does not break generic-rejection logging. + let relay_only_event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as u16), + "", + ) + .sign_with_keys(&author_keys) + .expect("sign relay-only event"); + let relay_only_json = serde_json::to_vec(&relay_only_event).expect("serialize"); + + let recorder2 = metrics_util::debugging::DebuggingRecorder::new(); + let (status_generic, log_generic) = metrics::with_local_recorder(&recorder2, || { + run_and_capture(&rt, state.clone(), &host, &pubkey_hex, &relay_only_json) + }); + + assert_eq!( + status_generic, + axum::http::StatusCode::BAD_REQUEST, + "generic rejection must still yield HTTP 400" + ); + assert!( + log_generic.contains("status=400"), + "generic rejection must log status=400;\nlog:\n{log_generic}" + ); + assert_eq!( + count_attribution_lines(&log_generic), + 1, + "exactly one attribution line for generic rejection;\nlog:\n{log_generic}" + ); + } + + /// Drive a single POST /query request through the router and return the + /// HTTP status code + body bytes. + async fn post_query( + state: Arc, + host: &str, + pubkey_hex: &str, + body: &[u8], + ) -> (axum::http::StatusCode, axum::body::Bytes) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/query") + .header(header::HOST, host) + .header("x-pubkey", pubkey_hex) + .header("content-type", "application/json") + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + (status, bytes) + } + + // ── Bridge dispatch test: writer-pin routes to the writer pool ──────────── + // + // Exercises the catchall dispatch loop's `ReadRoute` match at the shipping + // seam — the production `match read_route { Writer => db.query_events(...), + // Routed => db.query_events_routed(...) }` block. + // + // The test stages divergent data: a kind-40100 canvas event is inserted into + // the writer pool only. The replica pool starts empty for that channel. With + // the fence open and a bounded-staleness budget set, `query_events_routed` + // routes to the replica and sees nothing. `query_events` reads the writer + // and sees the event. + // + // DoD sequence: + // 1. Routed read (no consistency field) → replica → event absent. This + // proves the replica path is genuinely live in this harness; otherwise + // the strong-read probe proves nothing. + // 2. Strong read (consistency=strong) → writer → event present. + // 3. Malformed consistency value → 400. + // + // Mutation oracle: changing the `ReadRoute::Writer` arm to call + // `db.query_events_routed` makes probe 2 return empty (same as probe 1) → + // the `assert_eq!(strong_events.len(), 1)` assertion fails. This is the + // direct evidence Thufir required: the dispatch IS the seam, and breaking + // the arm breaks this test. + // + // Infrastructure: two scratch Postgres databases on the local instance. + // Requires the same local Postgres as the other `#[ignore]` bridge tests. + #[test] + #[ignore = "requires Postgres"] + fn strong_consistency_dispatches_to_writer_pool_not_replica() { + use buzz_core::CommunityId; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use sqlx::PgPool; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let admin_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| crate::test_support::database_url()); + + // Create a scratch database and run migrations on it. + async fn scratch_db(admin: &PgPool, admin_url: &str, suffix: &str) -> (PgPool, String) { + let name = format!( + "bridge_dispatch_{}_{}", + suffix, + uuid::Uuid::new_v4().simple() + ); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .unwrap_or_else(|e| panic!("create scratch db {name}: {e}")); + let slash = admin_url + .rfind('/') + .expect("URL must have a path component"); + let url = format!("{}/{name}", &admin_url[..slash]); + let pool = PgPool::connect(&url) + .await + .unwrap_or_else(|e| panic!("connect scratch db {name}: {e}")); + buzz_db::migration::run_migrations(&pool) + .await + .unwrap_or_else(|e| panic!("migrate scratch db {name}: {e}")); + (pool, name) + } + + async fn drop_scratch(admin: &PgPool, pool: PgPool, name: &str) { + drop(pool); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; + } + + // --- setup ----------------------------------------------------------- + let admin = rt.block_on(PgPool::connect(&admin_url)).expect( + "connect admin pool — start local Postgres before running ignored bridge tests", + ); + + let (writer_pool, writer_name) = rt.block_on(scratch_db(&admin, &admin_url, "w")); + let (replica_pool, replica_name) = rt.block_on(scratch_db(&admin, &admin_url, "r")); + + let community = uuid::Uuid::new_v4(); + let channel_id = uuid::Uuid::new_v4(); + let host = format!("dispatch-test-{}.local", community.simple()); + let author = Keys::generate(); + + // Seed community + open channel on both writer and replica. + rt.block_on(async { + for pool in [&writer_pool, &replica_pool] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(&host) + .execute(pool) + .await + .expect("seed community"); + buzz_db::channel::create_channel_with_id( + pool, + CommunityId::from_uuid(community), + channel_id, + &format!("canvas-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + } + }); + + // Writer-only canvas event: inserted on writer, NOT replicated. + let canvas_ev = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), + "writer-only canvas content", + ) + .tag(Tag::custom( + nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::H)), + [channel_id.to_string()], + )) + .sign_with_keys(&author) + .expect("sign canvas event"); + + rt.block_on(async { + let db_w = buzz_db::Db::from_pool(writer_pool.clone()); + db_w.insert_event( + CommunityId::from_uuid(community), + &canvas_ev, + Some(channel_id), + ) + .await + .expect("insert canvas event on writer"); + // replica_pool deliberately receives no canvas events. + }); + + // --- build AppState with two-pool Db --------------------------------- + let state = rt.block_on(async { + let mut config = crate::config::Config::from_env() + .expect("Config::from_env required — set DATABASE_URL, REDIS_URL, etc."); + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://dispatch-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + + let mut db = buzz_db::Db::from_pools(writer_pool.clone(), replica_pool.clone()); + // Open the freshness fence and set a bounded-staleness budget so + // `query_events_routed` actually routes to the replica pool. + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(writer_pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(writer_pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("media storage"); + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Arc::new(state) + }); + + let pubkey_hex = author.public_key().to_hex(); + let channel_str = channel_id.to_string(); + + // Probe 1: routed read (no consistency) → replica → event absent. + // This proves the replica path is genuinely live in this harness. + let body = serde_json::to_vec(&serde_json::json!([{ + "kinds": [buzz_core::kind::KIND_CANVAS as u64], + "#h": [&channel_str], + "limit": 10, + }])) + .expect("serialize routed filter"); + let (status, resp_body) = rt.block_on(post_query(state.clone(), &host, &pubkey_hex, &body)); + assert_eq!( + status, + axum::http::StatusCode::OK, + "Probe 1: routed query must return 200: {}", + String::from_utf8_lossy(&resp_body) + ); + let routed_events: Vec = + serde_json::from_slice(&resp_body).expect("parse routed response"); + assert!( + routed_events.is_empty(), + "Probe 1 FAIL — routed read must NOT see writer-only canvas event \ + (replica pool is empty for this channel): {routed_events:?}" + ); + + // Probe 2: writer-pinned read (consistency=strong) → writer pool → event present. + // Mutation oracle: changing Writer arm to query_events_routed → probe 2 returns + // empty → assertion fails. + let body = serde_json::to_vec(&serde_json::json!([{ + "kinds": [buzz_core::kind::KIND_CANVAS as u64], + "#h": [&channel_str], + "limit": 10, + "consistency": "strong", + }])) + .expect("serialize strong filter"); + let (status, resp_body) = rt.block_on(post_query(state.clone(), &host, &pubkey_hex, &body)); + assert_eq!( + status, + axum::http::StatusCode::OK, + "Probe 2: strong query must return 200: {}", + String::from_utf8_lossy(&resp_body) + ); + let strong_events: Vec = + serde_json::from_slice(&resp_body).expect("parse strong response"); + assert_eq!( + strong_events.len(), + 1, + "Probe 2 FAIL — strong-consistency read MUST see the writer-only canvas event. \ + Mutation oracle: if ReadRoute::Writer dispatches to query_events_routed instead \ + of query_events, this returns empty and this assertion fails: {strong_events:?}" + ); + assert_eq!( + strong_events[0].get("content").and_then(|v| v.as_str()), + Some("writer-only canvas content"), + "strong read must return the canvas event inserted into the writer pool" + ); + + // Probe 3: malformed consistency value must 400. + let body = serde_json::to_vec(&serde_json::json!([{ + "kinds": [buzz_core::kind::KIND_CANVAS as u64], + "#h": [&channel_str], + "consistency": "weak", + }])) + .expect("serialize bad filter"); + let (status, _) = rt.block_on(post_query(state.clone(), &host, &pubkey_hex, &body)); + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "Probe 3 FAIL — unknown consistency value must be rejected with 400" + ); + + // --- teardown -------------------------------------------------------- + rt.block_on(async { + let admin2 = PgPool::connect(&admin_url) + .await + .expect("reconnect admin for teardown"); + drop_scratch(&admin2, writer_pool, &writer_name).await; + drop_scratch(&admin2, replica_pool, &replica_name).await; + }); + } } diff --git a/crates/buzz-relay/src/conformance/mod.rs b/crates/buzz-relay/src/conformance/mod.rs index 93ebe5de9f5..0ceb8b67b64 100644 --- a/crates/buzz-relay/src/conformance/mod.rs +++ b/crates/buzz-relay/src/conformance/mod.rs @@ -426,13 +426,14 @@ impl Drop for EmitGuard { /// Map an `IngestError` variant onto the closed `SanitizedReason` /// alphabet (spec line 778, `Inv_SanitizedErrors`). The alphabet is -/// asserted 1:1 with the relay's error variants — if a fourth variant +/// asserted 1:1 with the relay's error variants — if a fifth variant /// is ever added to `IngestError` this match goes non-exhaustive and /// CI catches it. pub fn sanitized_reason_for(err: &crate::handlers::ingest::IngestError) -> SanitizedReason { use crate::handlers::ingest::IngestError as E; match err { E::Rejected(_) => SanitizedReason::Invalid, + E::CanvasConflict(_) => SanitizedReason::Invalid, E::AuthFailed(_) => SanitizedReason::Restricted, E::Internal(_) => SanitizedReason::ServerError, } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 074f6b391d0..b1859146d86 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -1422,6 +1422,9 @@ mod postgres_tests { fn rejection_message(result: Result>, IngestError>) -> String { match result { Err(IngestError::Rejected(message)) => message, + Err(IngestError::CanvasConflict(message)) => { + panic!("unexpected canvas conflict: {message}") + } Err(IngestError::AuthFailed(message)) => panic!("unexpected auth failure: {message}"), Err(IngestError::Internal(message)) => panic!("unexpected internal failure: {message}"), Ok(_) => panic!("expected revision parsing to fail"), diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 3f767c18741..b5477ecef97 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -753,6 +753,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc (message, "invalid"), + IngestError::CanvasConflict(message) => (message, "invalid"), IngestError::AuthFailed(message) => (message, "auth"), IngestError::Internal(message) => (message, "error"), }; @@ -794,6 +795,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc (m.clone(), "invalid"), + IngestError::CanvasConflict(m) => (m.clone(), "invalid"), IngestError::AuthFailed(m) => (m.clone(), "auth"), IngestError::Internal(_) => ("error: internal server error".to_string(), "error"), }; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 7f343c2c10c..9b95218a026 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -191,6 +191,60 @@ fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError Ok(()) } +/// A validated canvas `expected-revision` precondition. +/// +/// The tag value is either the literal `none` (expect no canvas head yet) or a +/// 64-hex event ID (expect the live head to match it). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CanvasRevisionSpec { + /// Literal `none` — the writer expects no canvas head to exist. + NoHead, + /// A 32-byte event ID the live canvas head must equal. + Head(Vec), +} + +/// Parse the optional canvas `expected-revision` precondition from an event. +/// +/// Returns `Ok(None)` when the tag is absent (backward-compatible unconditional +/// append). The tag shape is exactly `["expected-revision", value]`: a +/// one-element `["expected-revision"]` or any three-or-more-element form is +/// malformed and rejects `invalid:` — it is never treated as absent. At most +/// one `expected-revision` tag may be present. A single well-formed tag yields +/// `Some(spec)`. +pub(crate) fn parse_canvas_expected_revision( + event: &Event, +) -> Result, IngestError> { + let mut tags = event + .tags + .iter() + .map(nostr::Tag::as_slice) + .filter(|parts| parts.first().map(String::as_str) == Some("expected-revision")); + + let Some(tag) = tags.next() else { + return Ok(None); + }; + if tags.next().is_some() { + return Err(IngestError::Rejected( + "invalid: duplicate expected-revision tag".into(), + )); + } + if tag.len() != 2 { + return Err(IngestError::Rejected( + "invalid: expected-revision tag must have exactly one value".into(), + )); + } + let value = tag[1].as_str(); + + if value == "none" { + return Ok(Some(CanvasRevisionSpec::NoHead)); + } + let bytes = hex::decode(value) + .ok() + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| IngestError::Rejected("invalid: bad expected canvas revision".into()))?; + Ok(Some(CanvasRevisionSpec::Head(bytes))) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -385,6 +439,14 @@ pub struct IngestResult { pub enum IngestError { /// Client error (bad event) — WS: OK false, HTTP: 400. Rejected(String), + /// Canvas CAS precondition failure — WS: OK false, HTTP: 409. + /// + /// Emitted when a canvas write's `expected-revision` tag no longer matches + /// the relay's canonical head: the revision is missing, has changed, or the + /// new event does not supersede the current one. Kept separate from + /// [`IngestError::Rejected`] so the HTTP bridge can map it to + /// `409 CONFLICT` while generic client mistakes remain `400 BAD_REQUEST`. + CanvasConflict(String), /// Auth/scope error — WS: OK false, HTTP: 401/403. AuthFailed(String), /// Server error — WS: OK false, HTTP: 500. @@ -2167,6 +2229,26 @@ pub async fn ingest_event( result } +/// Maximum seconds in the future a kind:40100 canvas event may be timestamped. +/// Tighter than the general ±900 s drift window to prevent a ceiling-timestamped +/// head from producing a write at `head + 1` that the relay would accept (the +/// boundary head itself is within ±900 s) but that permanently stalls all later +/// legitimate writes behind an inflated floor. Invariant: +/// client ceiling (60 s, CANVAS_MAX_FUTURE_SKEW_SECS) < canvas relay bound (300 s) < relay general bound (900 s) +const CANVAS_MAX_INGEST_FUTURE_SECS: i64 = 300; + +/// Returns `Ok(())` if the canvas event timestamp is within the allowed future +/// window, or `Err` with a rejection message otherwise. +/// +/// Extracted as a pure function so the boundary can be regression-tested without +/// a live database or HTTP stack. +fn validate_canvas_future_timestamp(event_ts: i64, now: i64) -> Result<(), &'static str> { + if event_ts - now > CANVAS_MAX_INGEST_FUTURE_SECS { + return Err("invalid: canvas event timestamp too far in the future"); + } + Ok(()) +} + async fn ingest_event_inner( state: &Arc, tracer: &Arc, @@ -2240,6 +2322,14 @@ async fn ingest_event_inner( )); } + // kind:40100 canvas events carry a tighter future ceiling — see + // `validate_canvas_future_timestamp` for the rationale and invariant. + if kind_u32 == KIND_CANVAS { + if let Err(msg) = validate_canvas_future_timestamp(event_ts, now) { + return Err(IngestError::Rejected(msg.into())); + } + } + const MAX_EVENT_CONTENT_BYTES: usize = 256 * 1024; // 256 KB if event.content.len() > MAX_EVENT_CONTENT_BYTES { return Err(IngestError::Rejected(format!( @@ -3144,6 +3234,15 @@ async fn ingest_event_inner( }); } + // Parse a canvas `expected-revision` precondition once, ahead of the write + // dispatch. Malformed or duplicate tags reject here (never reaching the DB); + // an absent tag yields `None`, routing canvas writes to the generic append. + let canvas_revision_spec = if kind_u32 == KIND_CANVAS { + parse_canvas_expected_revision(&event)? + } else { + None + }; + let workflow_deletion = crate::handlers::side_effects::is_workflow_deletion(&event); let (stored_event, was_inserted) = if workflow_deletion { // A single commit owns public acceptance, domain mutation, and dispatch. @@ -3175,6 +3274,42 @@ async fn ingest_event_inner( .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await .map_err(|e| IngestError::Internal(format!("error: {e}")))? + } else if let Some(spec) = canvas_revision_spec.as_ref() { + // Canvas write carrying an optimistic-concurrency precondition. Plain + // canvas writes (no `expected-revision` tag) fall through to the generic + // append path below, preserving unconditional behavior. The channel is + // guaranteed present here: KIND_CANVAS requires an `h` tag and step 5b + // resolved it into `channel_id`. + let channel = channel_id + .ok_or_else(|| IngestError::Rejected("invalid: canvas event missing channel".into()))?; + let precondition = match spec { + CanvasRevisionSpec::NoHead => buzz_db::ChannelHeadPrecondition::ExpectNoHead, + CanvasRevisionSpec::Head(id) => buzz_db::ChannelHeadPrecondition::ExpectedHead(id), + }; + let (stored_event, status) = state + .db + .insert_channel_head_checked(tenant.community(), &event, channel, precondition) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + match status { + buzz_db::ChannelHeadWriteStatus::RevisionMissing => { + return Err(IngestError::CanvasConflict( + "conflict: canvas revision does not exist".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::RevisionMismatch => { + return Err(IngestError::CanvasConflict( + "conflict: canvas changed since it was loaded".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::SupersedeFailed => { + return Err(IngestError::CanvasConflict( + "conflict: canvas write does not supersede the current head".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::Inserted => (stored_event, true), + buzz_db::ChannelHeadWriteStatus::Duplicate => (stored_event, false), + } } else { let thread_params = thread_meta.as_ref().map(|m| m.as_params()); match state @@ -5545,4 +5680,604 @@ mod postgres_tests { Some(&1) ); } + + /// Boundary regression for the canvas-specific ingest future-timestamp guard. + /// `validate_canvas_future_timestamp` is the pure seam; mutation: changing + /// `CANVAS_MAX_INGEST_FUTURE_SECS` to 900 or removing the guard makes the + /// "at ceiling + 1" case pass when it must not. + /// + /// Fixed-literal contract (pinned so constant mutations go red): the relay + /// canvas bound IS 300 s; now+300 accepted, now+301 rejected. The relay + /// general bound is 900 s (a separate guard); the client ceiling is 60 s + /// (CANVAS_MAX_FUTURE_SKEW_SECS in buzz-sdk). Mutating the 300 s constant + /// back to 900 makes the numeric assertions below fail. + #[test] + fn canvas_ingest_future_timestamp_boundary() { + let now = 1_700_000_000i64; + + // Exactly at the ceiling: accepted. + assert!( + validate_canvas_future_timestamp(now + CANVAS_MAX_INGEST_FUTURE_SECS, now).is_ok(), + "canvas event at now+300 is within the relay canvas future bound" + ); + + // One second past the ceiling: rejected. + assert!( + validate_canvas_future_timestamp(now + CANVAS_MAX_INGEST_FUTURE_SECS + 1, now).is_err(), + "canvas event at now+301 exceeds the relay canvas future bound and must be rejected" + ); + + // Past the general ±900 s window: also rejected (guard fires first). + assert!( + validate_canvas_future_timestamp(now + 901, now).is_err(), + "canvas event at now+901 exceeds both the canvas bound and the general drift window" + ); + + // In the past: accepted (canvas guard is future-only; general past check is separate). + assert!( + validate_canvas_future_timestamp(now - 1, now).is_ok(), + "canvas event in the past is not affected by the future-timestamp guard" + ); + + // Fixed-literal contract: relay canvas bound IS 300 s, NOT 900 s. + // Mutating CANVAS_MAX_INGEST_FUTURE_SECS back to 900 makes these fail. + assert!( + validate_canvas_future_timestamp(now + 300, now).is_ok(), + "now+300: accepted at the 300 s relay canvas ceiling" + ); + assert!( + validate_canvas_future_timestamp(now + 301, now).is_err(), + "now+301: rejected one second past the 300 s relay canvas ceiling" + ); + // The old 900 s value must be rejected by this guard. + assert!( + validate_canvas_future_timestamp(now + 900, now).is_err(), + "now+900 must be rejected by the 300 s relay canvas ceiling" + ); + } + + /// Standalone numeric contract for the relay-side canvas ingest guard. + /// + /// No constants used — if CANVAS_MAX_INGEST_FUTURE_SECS changes, this test + /// catches it regardless of whether constant-based assertions remain + /// self-consistent. The relay canvas ceiling IS 300 s: now+300 is the last + /// accepted timestamp; now+301 is the first rejected timestamp. + #[test] + fn canvas_ingest_numeric_contract() { + let now = 1_700_000_000i64; + // These assertions use only fixed numeric literals; they cannot be + // self-referential regardless of what CANVAS_MAX_INGEST_FUTURE_SECS holds. + assert!( + validate_canvas_future_timestamp(now + 300, now).is_ok(), + "now+300 must be accepted: relay canvas ceiling is 300 s", + ); + assert!( + validate_canvas_future_timestamp(now + 301, now).is_err(), + "now+301 must be rejected: one second past the 300 s relay canvas ceiling", + ); + // Old 900 s value must also be rejected (prevents silent reversion to + // the general drift bound). + assert!( + validate_canvas_future_timestamp(now + 900, now).is_err(), + "now+900 must be rejected: the general 900 s bound does not apply to canvas events", + ); + } + + /// Ingest-path wiring regression: the kind-40100 canvas future-timestamp + /// guard in `ingest_event_inner` must be exercised through the real ingest + /// path, not only the pure `validate_canvas_future_timestamp` helper. + /// + /// A signed kind-40100 event with `created_at = relay_now + 600` is + /// submitted through `ingest_event_inner`. The offset is chosen to be + /// well inside the guard's rejection zone (300 s ceiling) so that + /// scheduler latency between test setup and production's `Utc::now()` + /// re-sample cannot shrink the apparent offset to within 300 s and + /// accidentally let the event through. Exact 300/301 boundary coverage + /// lives in `canvas_ingest_numeric_contract` and + /// `canvas_ingest_future_timestamp_boundary`, which exercise the pure + /// `validate_canvas_future_timestamp` helper with fixed arguments. + /// + /// Mutation oracle: deleting the `if kind_u32 == KIND_CANVAS { … }` call + /// site in `ingest_event_inner` changes the rejection reason to the h-tag + /// check ("channel-scoped events must include an h tag"), causing this + /// assertion to fail. + /// + /// Infrastructure: a real Postgres is required to pass the community + /// deletion-fence check that precedes the canvas guard. Redis is not + /// needed — the canvas guard fires before any Redis-backed path. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn canvas_ingest_guard_wired_through_ingest_event_inner() { + use buzz_auth::Nip98ReplayGuard; + use nostr::{Keys, Kind, Timestamp}; + + const FAKE_REDIS_URL: &str = "redis://127.0.0.1:1"; // no Redis needed for this path + + // ── Postgres connection ────────────────────────────────────────────── + let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&db_url).await.expect( + "connect test Postgres — start local Postgres before running ignored ingest tests", + ); + let db = buzz_db::Db::from_pool(pool.clone()); + // Do not call db.migrate() here: CI migrates the schema before running + // integration tests; calling migrate() locally risks version conflicts + // if the DB was provisioned via a different path. + + // ── AppState ───────────────────────────────────────────────────────── + // Redis is lazy and never actually contacted on this rejection path. + let redis_pool = deadpool_redis::Config::from_url(FAKE_REDIS_URL) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("deadpool redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(FAKE_REDIS_URL, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let mut config = crate::config::Config::from_env().expect("relay config from env"); + config.database_url = db_url.clone(); + config.redis_url = FAKE_REDIS_URL.to_string(); + config.require_relay_membership = false; + + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth_svc = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db.clone(), + redis_pool, + audit, + pubsub, + auth_svc, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + + // Replace the NIP-98 replay guard so no live Redis is required. + struct AlwaysFreshReplayGuard; + impl Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async { Ok(true) }) + } + } + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + let state = Arc::new(state); + + // ── Provision a fresh community so the deletion fence allows writes ── + let host = format!("canvas-ts-guard-{}.test", Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + + // ── Build a kind-40100 event 600 seconds in the future ─────────────── + // +600 is well inside the guard's rejection zone (>300 s), so scheduler + // latency between this Utc::now() call and production's independent + // Utc::now() re-sample inside ingest_event_inner cannot close the gap + // to within 300 s. Exact 300/301 boundary assertions live in the pure + // `validate_canvas_future_timestamp` tests which have no clock race. + let keys = Keys::generate(); + let relay_now = chrono::Utc::now().timestamp() as u64; + let event = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "") + .custom_created_at(Timestamp::from(relay_now + 600)) + .sign_with_keys(&keys) + .expect("sign canvas event"); + + let auth = IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![Scope::ChannelsWrite], + auth_method: HttpAuthMethod::Nip98, + }; + let tracer: Arc = Arc::new(VecTracer::default()); + + // ── Submit through the real ingest path ─────────────────────────────── + let result = ingest_event_inner(&state, &tracer, &tenant, event, auth).await; + + // The canvas-specific guard must fire before the h-tag check. + // created_at = relay_now + 600 is 300 s above the canvas ceiling, so + // even under heavy load the guard fires and rejects with this message. + // + // Mutation oracle: delete `if kind_u32 == KIND_CANVAS { … }` in + // ingest_event_inner → no canvas guard fires → the event reaches the + // h-tag check → Rejected("invalid: channel-scoped events must include + // an h tag") → assert! below fails. + let err = match result { + Ok(_) => panic!( + "kind-40100 event at now+600 must be rejected, but ingest_event_inner returned Ok" + ), + Err(e) => e, + }; + assert!( + matches!(&err, IngestError::Rejected(msg) if msg.contains("canvas event timestamp too far in the future")), + "rejection must be the canvas guard, not the h-tag check; \ + deleting the guard call site changes this error to the h-tag rejection. \ + Got: {err:?}", + ); + } + + // ── parse_canvas_expected_revision unit tests ───────────────────────── + // + // These cover every branch in the parser without requiring Postgres or + // Redis. A helper builds a signed kind-40100 event from a tag-list so the + // tests only state the tags they care about. + + /// Build a signed kind-40100 event carrying the given tags. The event is + /// fully signed so the nostr library populates `tags` correctly; content + /// and timestamp are irrelevant for the parser. + fn canvas_event_with_tags(tags: impl IntoIterator) -> nostr::Event { + use nostr::{EventBuilder, Keys, Kind}; + EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign canvas event for parser test") + } + + /// No `expected-revision` tag → `Ok(None)` (backward-compatible unconditional + /// append; mutation: adding a spurious tag-match makes the call return + /// `Some`, changing the Ok(None) assertion to fail). + #[test] + fn parse_canvas_revision_absent_returns_none() { + let event = canvas_event_with_tags([nostr::Tag::parse(["h", "chan-uuid"]).unwrap()]); + let result = parse_canvas_expected_revision(&event); + assert_eq!(result.unwrap(), None); + } + + /// `expected-revision = "none"` → `Ok(Some(NoHead))` (first-create + /// precondition; mutation: changing `"none"` check to `"NONE"` makes the + /// parser fall through to the hex decoder and return `Rejected`). + #[test] + fn parse_canvas_revision_none_literal_yields_no_head() { + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", "none"]).unwrap()]); + assert_eq!( + parse_canvas_expected_revision(&event).unwrap(), + Some(CanvasRevisionSpec::NoHead), + ); + } + + /// A well-formed 64-hex event ID → `Ok(Some(Head(bytes)))` where the + /// bytes equal the decoded hex (mutation: changing `bytes.len() == 32` + /// to `!= 32` makes this return `Rejected`). + #[test] + fn parse_canvas_revision_valid_hex_yields_head() { + let hex_id = "a".repeat(64); + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", &hex_id]).unwrap()]); + let spec = parse_canvas_expected_revision(&event) + .expect("valid hex must parse") + .expect("must be Some"); + assert_eq!(spec, CanvasRevisionSpec::Head(vec![0xaa; 32])); + } + + /// Two `expected-revision` tags → `Rejected("invalid: duplicate …")`. + /// Mutation: removing the `tags.next().is_some()` guard makes this return + /// `Ok(Some(…))` instead. + #[test] + fn parse_canvas_revision_duplicate_tag_rejects() { + let hex_id = "b".repeat(64); + let event = canvas_event_with_tags([ + nostr::Tag::parse(["expected-revision", &hex_id]).unwrap(), + nostr::Tag::parse(["expected-revision", &hex_id]).unwrap(), + ]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("duplicate expected-revision tag") + ), + "duplicate tags must be rejected", + ); + } + + /// A one-element `["expected-revision"]` tag (no value) → `Rejected`. + /// Mutation: changing `tag.len() != 2` to `< 2` also catches zero-element + /// forms but not three-element; this case specifically exercises the + /// `len == 1` branch. + #[test] + fn parse_canvas_revision_missing_value_rejects() { + // nostr::Tag::parse requires ≥1 element; build a tag with only the key. + let event = canvas_event_with_tags([nostr::Tag::parse(["expected-revision"]).unwrap()]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("expected-revision tag must have exactly one value") + ), + "tag with no value must be rejected", + ); + } + + /// A three-element `["expected-revision", value, extra]` tag → `Rejected`. + /// Mutation: changing `tag.len() != 2` to `tag.len() < 2` lets three-element + /// tags through; this test catches that. + #[test] + fn parse_canvas_revision_extra_value_rejects() { + let hex_id = "c".repeat(64); + let event = + canvas_event_with_tags([ + nostr::Tag::parse(["expected-revision", &hex_id, "extra"]).unwrap() + ]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("expected-revision tag must have exactly one value") + ), + "tag with extra value must be rejected", + ); + } + + /// A 62-character hex string (too short — not a 32-byte id) → `Rejected`. + /// Mutation: removing the `bytes.len() == 32` length check makes this return + /// `Ok(Some(Head(…)))` with 31 bytes instead of rejecting. + #[test] + fn parse_canvas_revision_too_short_hex_rejects() { + let short_hex = "d".repeat(62); + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", &short_hex]).unwrap()]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("bad expected canvas revision") + ), + "too-short hex must be rejected", + ); + } + + /// A non-hex value → `Rejected("invalid: bad expected canvas revision")`. + /// Mutation: removing `hex::decode(value).ok()` makes this panic instead. + #[test] + fn parse_canvas_revision_non_hex_rejects() { + let not_hex = "g".repeat(64); // 'g' is not a valid hex digit + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", ¬_hex]).unwrap()]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("bad expected canvas revision") + ), + "non-hex value must be rejected", + ); + } + + // ── CAS ingest-path wiring test ─────────────────────────────────────── + // + // Proves that the `expected-revision` parser → dispatch → DB transaction + // round-trip is wired end-to-end through `ingest_event_inner`. Deletng or + // bypassing the CAS dispatch block (the `} else if let Some(spec) = + // canvas_revision_spec.as_ref() {` branch) must turn this test red. + // + // Mutation oracle for the dispatch: + // - Removing the `canvas_revision_spec` branch makes tagged writes fall + // through to the generic append; the stale-head step no longer returns + // a conflict: rejection, causing the assert! below to fail. + // - Replacing `insert_channel_head_checked` with `insert_event_with_thread_metadata` + // has the same effect — no conflict is surfaced. + // + // Requires Postgres (and does NOT need Redis — the fake replay guard fires + // before any Redis-backed path, and the CAS path never touches Redis). + + /// Build the minimal AppState for an ingest-path CAS test. + /// + /// Replaces the NIP-98 replay guard with an always-fresh stub so that no + /// live Redis is needed. The returned `AppState` is ready for + /// `ingest_event_inner` calls. + async fn build_canvas_ingest_state( + db_url: &str, + pool: &sqlx::PgPool, + ) -> Arc { + use buzz_auth::Nip98ReplayGuard; + use nostr::Keys; + + const FAKE_REDIS_URL: &str = "redis://127.0.0.1:1"; // never contacted + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(FAKE_REDIS_URL) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("deadpool redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(FAKE_REDIS_URL, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let mut config = crate::config::Config::from_env().expect("relay config from env"); + config.database_url = db_url.to_owned(); + config.redis_url = FAKE_REDIS_URL.to_string(); + config.require_relay_membership = false; + + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth_svc = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db.clone(), + redis_pool, + audit, + pubsub, + auth_svc, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + + struct AlwaysFresh; + impl Nip98ReplayGuard for AlwaysFresh { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async { Ok(true) }) + } + } + state.nip98_replay = Arc::new(AlwaysFresh); + Arc::new(state) + } + + /// End-to-end CAS dispatch wiring: a tagged write inserts, a stale same-head + /// competitor returns the exact conflict: rejection, the loser is absent from + /// the DB, and an untagged write still appends unconditionally. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn canvas_cas_dispatch_wired_through_ingest_event_inner() { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use nostr::{Keys, Kind, Tag, Timestamp}; + + let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&db_url).await.expect( + "connect test Postgres — start local Postgres before running ignored ingest tests", + ); + let state = build_canvas_ingest_state(&db_url, &pool).await; + + // Provision a fresh community + channel so each test run is isolated. + let host = format!("canvas-cas-wiring-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + + let channel_id = Uuid::new_v4(); + let creator_keys = Keys::generate(); + state + .db + .create_channel_with_id( + community, + channel_id, + &format!("canvas-cas-wiring-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator_keys.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + + let now = chrono::Utc::now().timestamp() as u64; + let channel_uuid_str = channel_id.to_string(); + let tracer: Arc = Arc::new(VecTracer::default()); + + let make_auth = |keys: &Keys| IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![Scope::ChannelsWrite], + auth_method: HttpAuthMethod::Nip98, + }; + + // ── Step 1: first write with expected-revision=none → Inserted ──────── + let author = Keys::generate(); + let first = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# v1") + .custom_created_at(Timestamp::from(now)) + .tags([ + Tag::parse(["h", &channel_uuid_str]).unwrap(), + Tag::parse(["expected-revision", "none"]).unwrap(), + ]) + .sign_with_keys(&author) + .expect("sign first canvas"); + let first_id_hex = first.id.to_hex(); + + ingest_event_inner(&state, &tracer, &tenant, first, make_auth(&author)) + .await + .expect("first canvas write must succeed"); + + // ── Step 2: advance with expected-revision= → Inserted ────── + let second = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# v2") + .custom_created_at(Timestamp::from(now + 1)) + .tags([ + Tag::parse(["h", &channel_uuid_str]).unwrap(), + Tag::parse(["expected-revision", &first_id_hex]).unwrap(), + ]) + .sign_with_keys(&author) + .expect("sign second canvas"); + + ingest_event_inner(&state, &tracer, &tenant, second, make_auth(&author)) + .await + .expect("second canvas write must succeed"); + + // ── Step 3: stale competitor — same first-id precondition → conflict ── + // The head is now the second event, so expected-revision= is stale. + // Mutation oracle: deleting the `canvas_revision_spec` dispatch block makes + // this return Ok instead of the conflict: rejection below. + let stale = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# stale") + .custom_created_at(Timestamp::from(now + 2)) + .tags([ + Tag::parse(["h", &channel_uuid_str]).unwrap(), + Tag::parse(["expected-revision", &first_id_hex]).unwrap(), + ]) + .sign_with_keys(&author) + .expect("sign stale canvas"); + let stale_id_bytes = stale.id.as_bytes().to_vec(); + + let err = + match ingest_event_inner(&state, &tracer, &tenant, stale, make_auth(&author)).await { + Ok(_) => panic!("stale precondition must be rejected, but ingest returned Ok"), + Err(e) => e, + }; + assert!( + matches!(&err, IngestError::CanvasConflict(msg) if msg.starts_with("conflict:")), + "stale write must return a canvas conflict: rejection; got {:?}", + err, + ); + + // The losing write must not be persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(stale_id_bytes.as_slice()) + .fetch_one(&pool) + .await + .expect("count stale canvas row"); + assert_eq!(persisted, 0, "losing CAS write must not be stored"); + + // ── Step 4: untagged write still appends unconditionally ────────────── + // No expected-revision tag → the event is routed through the generic + // append path, NOT through insert_channel_head_checked. It must succeed + // regardless of the current head state. + let untagged = + nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# unconditional") + .custom_created_at(Timestamp::from(now + 3)) + .tags([Tag::parse(["h", &channel_uuid_str]).unwrap()]) + .sign_with_keys(&author) + .expect("sign untagged canvas"); + + ingest_event_inner(&state, &tracer, &tenant, untagged, make_auth(&author)) + .await + .expect("untagged canvas write must append unconditionally"); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f43887b65b1..f698598b936 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1,4 +1,4 @@ -//! Typed event builder functions (38 builders). +//! Typed event builder functions (39 builders). //! //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. @@ -555,11 +555,188 @@ pub fn build_custom_emoji_set(emojis: &[CustomEmoji]) -> Result Result { - let tags = vec![tag(&["h", &channel_id.to_string()])?]; +/// +/// When `expected_revision` is set, an `["expected-revision", …]` tag is +/// attached. A 64-hex event ID names the head the write was composed against; +/// the literal `none` asserts no head exists yet. The relay enforces this tag +/// as a compare-and-swap (CAS): it reads the canonical live head under an +/// advisory lock, checks the precondition, and rejects mismatched writes before +/// insertion. Omit the tag for an unconditional append (backward compatible). +pub fn build_set_canvas( + channel_id: Uuid, + content: &str, + expected_revision: Option<&str>, +) -> Result { + let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; + if let Some(expected_revision) = expected_revision { + if expected_revision != "none" + && (expected_revision.len() != 64 + || !expected_revision.chars().all(|c| c.is_ascii_hexdigit())) + { + return Err(SdkError::InvalidInput(format!( + "expected_revision must be the literal \"none\" or a 64-character hex event id (got {expected_revision:?})" + ))); + } + tags.push(tag(&["expected-revision", expected_revision])?); + } Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) } +/// Build a canvas write (kind 40100) that edits or restores against a known +/// head, applying writer discipline in one place. +/// +/// Sets the `expected-revision` precondition to `head_id` and stamps +/// `created_at = max(now, head_created_at + 1)` so the event sorts strictly +/// ahead of the head it asserts under `created_at DESC, id ASC`. This keeps a +/// legitimate first-party restore/edit whose local clock lags the head from +/// landing behind that head in read order (which would "succeed" without +/// changing the visible canvas). First-party signers (CLI `set`/restore, +/// Desktop save/restore) MUST route disciplined canvas writes through this +/// helper rather than re-deriving the timestamp. +/// +/// Ordering note: the `+ 1` bump guarantees a strictly greater `created_at`, so +/// the write never ties the head. Writes that *do* share a second resolve by +/// `id ASC` under `created_at DESC, id ASC` — the smallest event id wins the +/// visible head, not the last write. This helper sidesteps that tie by stamping +/// ahead; unconditional appends that omit the bump remain subject to it. +pub fn build_set_canvas_after_head( + channel_id: Uuid, + content: &str, + head_id: &str, + head_created_at: u64, +) -> Result { + let created_at = canvas_write_created_at(head_created_at)?; + Ok(build_set_canvas(channel_id, content, Some(head_id))? + .custom_created_at(nostr::Timestamp::from(created_at))) +} + +/// Build an unconditional canvas write (kind 40100) that still applies writer +/// discipline: stamps `created_at = max(now, head_created_at + 1)` so the +/// event sorts strictly ahead of the current head under `created_at DESC, id ASC`. +/// +/// Unlike [`build_set_canvas_after_head`], no `expected-revision` tag is added +/// — the write is an unconditional append that can never conflict. Use this for +/// `buzz canvas set`, which documents unconditional replace semantics. +/// +/// Returns an error if `head_created_at` is more than +/// [`CANVAS_MAX_FUTURE_SKEW_SECS`] beyond `now` (poisoned timeline guard). +pub fn build_set_canvas_unconditional_after_head( + channel_id: Uuid, + content: &str, + head_created_at: u64, +) -> Result { + let created_at = canvas_write_created_at(head_created_at)?; + Ok(build_set_canvas(channel_id, content, None)? + .custom_created_at(nostr::Timestamp::from(created_at))) +} + +/// Maximum future skew a canvas head may carry before a first-party client +/// refuses to ratchet past it (seconds). A head timestamped beyond `now + +/// CANVAS_MAX_FUTURE_SKEW_SECS` is treated as poisoned: stamping +/// `max(now, head + 1)` against it would silently extend a bogus timeline +/// arbitrarily far ahead, and every later legitimate write would inherit that +/// floor. +/// +/// This is kept well below the relay's general ±900 s drift window. A ceiling +/// head at `now + 60` produces a write at `now + 61`, which is within the relay's +/// kind-40100-specific future bound (`CANVAS_MAX_INGEST_FUTURE_SECS = 300` in +/// ingest.rs) and far inside the general ±900 s window — first-party clients +/// can never construct an ingest-rejected event through ordinary use. +pub const CANVAS_MAX_FUTURE_SKEW_SECS: u64 = 60; + +/// Contract-v3 writer-discipline timestamp for a canvas write asserting a head +/// at `head_created_at`: `max(now, head_created_at + 1)` (Unix seconds). +/// +/// The single home for canvas timestamp discipline. `build_set_canvas_after_head` +/// stamps CLI restore/`set` writes with this, and Desktop's `set_canvas` calls +/// it directly for the same reason, so the `max(now, head + 1)` rule is never +/// re-derived per surface. +/// +/// Rejects a head timestamped more than [`CANVAS_MAX_FUTURE_SKEW_SECS`] beyond +/// `now` rather than ratcheting past it: extending a poisoned future timeline +/// would strand every later write behind a floor arbitrarily far ahead. +/// `u64::MAX` is covered by the same ceiling. +pub fn canvas_write_created_at(head_created_at: u64) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + canvas_write_created_at_at(head_created_at, now) +} + +/// Pure core of [`canvas_write_created_at`] with `now` injected — the clock +/// seam. The public wrapper reads the real clock; tests drive the exact +/// `now + 60` / `now + 61` boundaries against a fixed `now`. +fn canvas_write_created_at_at(head_created_at: u64, now: u64) -> Result { + if head_created_at > now.saturating_add(CANVAS_MAX_FUTURE_SKEW_SECS) { + return Err(SdkError::InvalidInput( + "canvas head is timestamped too far in the future; refusing to extend it".into(), + )); + } + Ok(now.max(head_created_at.saturating_add(1))) +} + +/// Maximum ancestry links the post-write supersession walk follows before +/// failing safe (reporting superseded). A legitimate descendant chain visible +/// in one history page is far shorter; the bound only caps a pathological or +/// adversarial revision stream so the walk can never loop or run unbounded. +pub const CANVAS_ANCESTRY_WALK_MAX: usize = 256; + +/// Whether a just-published canvas write still survives as — or anywhere in the +/// accepted ancestry of — the live head read back after submit. The post-write +/// supersession check. +/// +/// `revisions` is a recent slice of the channel's canvas stream ordered newest +/// first, each entry `(event_id, the expected-revision it built on)`; +/// `revisions[0]` is the live head. A conflict-checked write (Desktop +/// save/restore, CLI `restore`) re-reads this stream after publishing and the +/// walk starts at the head, following `expected-revision` links backward: +/// - the head **is** our event → survived; +/// - our event is reached anywhere in the head's ancestry chain (e.g. A→B→C +/// with C the head and A ours, each linked by `expected-revision`) → a later +/// write legitimately layered on top of ours → survived; +/// - the chain ends, reaches a link outside `revisions`, cycles, exceeds +/// [`CANVAS_ANCESTRY_WALK_MAX`], or there is no head at all → not survived: a +/// concurrent write won the visible head and ours is superseded (preserved in +/// history, not lost). Every non-survival outcome, including a truncated or +/// adversarial stream, fails safe as superseded and never hangs. +/// +/// All id comparisons are case-insensitive, matching the precondition check's +/// `eq_ignore_ascii_case` convention. This only detects a competitor already +/// visible at verification time; a competitor that lands after this read is +/// still missed — the relay's advisory-lock CAS prevents concurrent conflicting +/// writes from both being accepted, but this client check is a secondary +/// confirmation for the caller's own visibility. +pub fn canvas_write_survived(our_id: &str, revisions: &[(String, Option)]) -> bool { + let Some((head_id, _)) = revisions.first() else { + return false; // no head after an accepted write → conservatively superseded + }; + if head_id.eq_ignore_ascii_case(our_id) { + return true; + } + // Lowercased id → its expected-revision, for walking the chain backward. + let by_id: std::collections::HashMap> = revisions + .iter() + .map(|(id, expected)| (id.to_ascii_lowercase(), expected.as_deref())) + .collect(); + let mut seen = std::collections::HashSet::new(); + let mut cursor = head_id.to_ascii_lowercase(); + for _ in 0..CANVAS_ANCESTRY_WALK_MAX { + if !seen.insert(cursor.clone()) { + return false; // cycle guard + } + // The link out of `cursor`; absent id or missing tag ends the walk. + let Some(expected) = by_id.get(&cursor).copied().flatten() else { + return false; + }; + if expected.eq_ignore_ascii_case(our_id) { + return true; + } + cursor = expected.to_ascii_lowercase(); + } + false // depth exhausted → fail safe (superseded) +} + /// Build a NIP-01 profile metadata event (kind 0). /// /// Only present (Some) fields are included in the JSON object. @@ -2991,10 +3168,238 @@ mod tests { #[test] fn set_canvas_happy_path() { let cid = uuid(); - let ev = sign(build_set_canvas(cid, "# Canvas\nHello").unwrap()); + let ev = sign(build_set_canvas(cid, "# Canvas\nHello", None).unwrap()); assert_eq!(ev.kind.as_u16(), 40100); assert!(has_tag(&ev, "h", &cid.to_string())); assert_eq!(ev.content, "# Canvas\nHello"); + assert!(!ev.tags.iter().any(|t| t + .as_slice() + .first() + .is_some_and(|k| k == "expected-revision"))); + } + + #[test] + fn set_canvas_pins_expected_revision() { + let cid = uuid(); + let head = event_id().to_hex(); + let ev = sign(build_set_canvas(cid, "# Canvas\nHi", Some(&head)).unwrap()); + assert!(has_tag(&ev, "expected-revision", &head)); + + let create = sign(build_set_canvas(cid, "# New", Some("none")).unwrap()); + assert!(has_tag(&create, "expected-revision", "none")); + } + + #[test] + fn set_canvas_after_head_pins_revision_and_bumps_timestamp() { + let cid = uuid(); + let head = event_id().to_hex(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Head created ahead of the signer's clock but within the future-skew + // ceiling: the discipline must still stamp strictly ahead of it. + let future_head = now + CANVAS_MAX_FUTURE_SKEW_SECS; + let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, future_head).unwrap()); + assert!(has_tag(&ev, "expected-revision", &head)); + assert!( + ev.created_at.as_secs() > future_head, + "created_at {} must be strictly ahead of future head {future_head}", + ev.created_at.as_secs() + ); + + // Head in the past: the signer's `now` wins and is still ahead. + let past_head = 1_000_u64; + let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, past_head).unwrap()); + assert!(ev.created_at.as_secs() > past_head); + } + + #[test] + fn set_canvas_rejects_malformed_expected_revision() { + let cid = uuid(); + // Wrong length (63 hex chars). + assert!(matches!( + build_set_canvas(cid, "x", Some(&"a".repeat(63))), + Err(SdkError::InvalidInput(_)) + )); + // Correct length but non-hex. + assert!(matches!( + build_set_canvas(cid, "x", Some(&"z".repeat(64))), + Err(SdkError::InvalidInput(_)) + )); + // Literal "none" and a valid 64-hex id are accepted. + assert!(build_set_canvas(cid, "x", Some("none")).is_ok()); + assert!(build_set_canvas(cid, "x", Some(&"a".repeat(64))).is_ok()); + } + + #[test] + fn set_canvas_after_head_rejects_max_head_created_at() { + let cid = uuid(); + let head = event_id().to_hex(); + // u64::MAX is far beyond the future-skew ceiling: reject instead of + // silently saturating and breaking the head-advancement guarantee. + assert!(matches!( + build_set_canvas_after_head(cid, "# Restored", &head, u64::MAX), + Err(SdkError::InvalidInput(_)) + )); + } + + #[test] + fn canvas_write_created_at_skew_boundary() { + // Fixed clock: the injected-`now` core removes the second-rollover seam + // that a real-clock read introduces. A head exactly at the ceiling + // (`now + 60`) is accepted and stamped strictly ahead; one second past + // it is rejected as poisoned. + // + // Invariant contract (pinned with literals, not constants, so a constant + // mutation also fails this test): the client ceiling is 60 s; the relay + // canvas ingest bound is 300 s; the relay general drift bound is 900 s. + // A ceiling head at now+60 produces now+61, well below both relay bounds. + // These numeric assertions must stay consistent with CANVAS_MAX_FUTURE_SKEW_SECS + // and the relay's CANVAS_MAX_INGEST_FUTURE_SECS (300) / MAX_TIMESTAMP_DRIFT_SECS (900). + let now = 1_700_000_000u64; + let at_ceiling = now + CANVAS_MAX_FUTURE_SKEW_SECS; + assert_eq!( + canvas_write_created_at_at(at_ceiling, now).unwrap(), + at_ceiling + 1, + "a head at now+60 is accepted and stamped strictly ahead" + ); + assert!( + matches!( + canvas_write_created_at_at(at_ceiling + 1, now), + Err(SdkError::InvalidInput(_)) + ), + "a head at now+61 is poisoned and rejected" + ); + + // Fixed-literal contract: client ceiling IS 60 s, NOT the old 900 s. + // Mutating CANVAS_MAX_FUTURE_SKEW_SECS back to 900 makes these fail. + assert!( + canvas_write_created_at_at(now + 60, now).is_ok(), + "now+60 is within the 60 s client ceiling" + ); + assert!( + matches!( + canvas_write_created_at_at(now + 61, now), + Err(SdkError::InvalidInput(_)) + ), + "now+61 is one second past the 60 s client ceiling" + ); + // Confirm the old 900 s ceiling is now rejected (prevents silent + // reversion): a head at now+900 must not be ratcheted past. + assert!( + matches!( + canvas_write_created_at_at(now + 900, now), + Err(SdkError::InvalidInput(_)) + ), + "now+900 must be rejected by the 60 s client ceiling" + ); + } + + /// Standalone numeric contract for the client-side canvas future-skew ceiling. + /// + /// No constants used — if CANVAS_MAX_FUTURE_SKEW_SECS changes, this test + /// catches it regardless of whether the constant-based assertions remain + /// self-consistent. The client ceiling IS 60 s: now+60 is the last accepted + /// head; now+61 is the first rejected head. + /// + /// Cross-crate invariant: the relay canvas ingest bound is 300 s and the + /// relay general drift bound is 900 s. A ceiling head at now+60 is stamped + /// now+61, comfortably below both relay bounds. + #[test] + fn canvas_write_created_at_numeric_contract() { + let now = 1_700_000_000u64; + // The client ceiling is exactly 60 s — not 900 s (the old value). + // These assertions use only fixed numeric literals; they cannot be + // self-referential regardless of what CANVAS_MAX_FUTURE_SKEW_SECS holds. + assert!( + canvas_write_created_at_at(now + 60, now).is_ok(), + "now+60 must be accepted: client ceiling is 60 s", + ); + assert!( + canvas_write_created_at_at(now + 61, now).is_err(), + "now+61 must be rejected: one second past the 60 s client ceiling", + ); + // Old 900 s value must also be rejected (prevents silent reversion). + assert!( + canvas_write_created_at_at(now + 900, now).is_err(), + "now+900 must be rejected: the old 900 s ceiling is no longer valid", + ); + } + + #[test] + fn canvas_write_survived_classifies_head_ancestry() { + let ours = "a".repeat(64); + let other = "b".repeat(64); + let third = "c".repeat(64); + // Head is our own event → survived. + assert!(canvas_write_survived(&ours, &[(ours.clone(), None)])); + // Head is case-insensitively our event → survived. + assert!(canvas_write_survived(&ours, &[(ours.to_uppercase(), None)])); + // Head is a stranger that builds directly on us (case-insensitively) → + // survived. + assert!(canvas_write_survived( + &ours, + &[(other.clone(), Some(ours.to_uppercase()))] + )); + // Transitive descendant: A(ours) → B(exp=A) → C(exp=B, the head). Ours + // is reached by walking the chain, so the write survived. + assert!(canvas_write_survived( + &ours, + &[ + (third.clone(), Some(other.clone())), + (other.clone(), Some(ours.clone())), + (ours.clone(), None), + ] + )); + // Head is a stranger that builds on someone else, and that ancestor is + // not ours and not in the stream → superseded. + assert!(!canvas_write_survived( + &ours, + &[(other.clone(), Some(third.clone()))] + )); + // Head is a stranger with no ancestry tag → superseded. + assert!(!canvas_write_survived(&ours, &[(other.clone(), None)])); + // No head at all after an accepted write → superseded, never silent + // success (shouldn't happen, classified conservatively). + assert!(!canvas_write_survived(&ours, &[])); + // A descendant chain that never reaches ours before the stream ends → + // superseded (fails safe rather than assuming survival). + assert!(!canvas_write_survived( + &ours, + &[ + (third.clone(), Some(other.clone())), + (other.clone(), Some("d".repeat(64))), + ] + )); + } + + #[test] + fn canvas_write_survived_fails_safe_on_cycles_and_depth() { + let ours = "a".repeat(64); + let x = "b".repeat(64); + let y = "c".repeat(64); + // A cycle in the stream (x→y→x) that never touches ours must terminate + // as superseded, not loop forever. + assert!(!canvas_write_survived( + &ours, + &[(x.clone(), Some(y.clone())), (y.clone(), Some(x.clone()))] + )); + // A chain longer than the walk bound that never reaches ours fails safe + // as superseded. Each link i points to link i+1; ours is never in it. + let mut revisions: Vec<(String, Option)> = (0..(CANVAS_ANCESTRY_WALK_MAX + 10)) + .map(|i| { + let id = format!("{i:064x}"); + let next = format!("{:064x}", i + 1); + (id, Some(next)) + }) + .collect(); + // Terminate the last link at ours so only the depth bound (not a missing + // link) can stop the walk — proving the bound itself is the guard. + let last = revisions.len() - 1; + revisions[last].1 = Some(ours.clone()); + assert!(!canvas_write_survived(&ours, &revisions)); } #[test] diff --git a/desktop/package.json b/desktop/package.json index fb8e3138719..a665c8ca451 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -63,7 +63,7 @@ "@tiptap/starter-kit": "^3.22.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.4.0", + "diff": "^8.0.4", "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 191fafe60d8..68ae4315df1 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -12,15 +12,7 @@ pub async fn get_canvas( channel_id: String, state: State<'_, AppState>, ) -> Result { - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [40100], - "#h": [channel_id], - "limit": 1 - })], - ) - .await?; + let events = query_relay(&state, &[get_canvas_filter(&channel_id)]).await?; let Some(event) = events.first() else { // Explicit nulls: the TS caller distinguishes "no canvas yet" from @@ -46,15 +38,553 @@ pub async fn get_canvas( pub async fn set_canvas( channel_id: String, content: String, + expected_revision: Option, state: State<'_, AppState>, ) -> Result { let uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; - let builder = events::build_set_canvas(uuid, &content)?; + + // Advisory optimistic-concurrency check (client-side, two-stage). A + // conflict-checked save asserts the revision the editor loaded. Stage one: + // read the live head once before publishing and compare locally, returning + // a frozen pre-write conflict marker if it already moved — this catches the + // realistic stale-edit case (head moved minutes ago). Stage two, after + // publishing (below): re-read the head once and confirm our write is (or is + // built upon by) the visible head, surfacing a distinct post-write + // supersession marker otherwise. Detection is bounded to a competitor + // visible at check time; preventing the race entirely — a competitor that + // lands between our read and write, or after the post-write read — needs + // relay-side linearization (phase 2). + // + // `head` is `None` when the channel has no canvas yet. A matched head's + // `created_at` is the floor for writer discipline: an accepted save stamps + // `created_at = max(now, head + 1)` via the SDK's `canvas_write_created_at` + // — the one home for canvas timestamp discipline — so it sorts strictly + // ahead of the head it read under `created_at DESC, id ASC`. That helper + // also refuses a head timestamped far in the future, so a poisoned timeline + // fails loudly here rather than being silently extended. The no-head / + // unconditional-append case has no floor and keeps the default `now`. + let head = current_canvas_head(&state, &channel_id).await?; + let prior_head_created_at = check_canvas_precondition(expected_revision.as_deref(), head)?; + + let mut builder = events::build_set_canvas(uuid, &content, expected_revision.as_deref())?; + if let Some(floor) = prior_head_created_at { + builder = builder.custom_created_at(nostr::Timestamp::from( + buzz_sdk_pkg::canvas_write_created_at(floor as u64).map_err(|e| e.to_string())?, + )); + } let result = submit_event(builder, &state).await?; + // Post-write supersession detection (only for conflict-checked writes). The + // precondition above closes the stale-edit case; this closes the narrower + // window where a concurrent write we could not see at precondition time has + // become visible by now. An unconditional append (`None`) has nothing to + // assert, so it stays fire-and-forget. + // + // The submit above was accepted, so the write is durable. `classify_post_write` + // maps the ancestry read to a report: a failed read is durable-but-unverified + // (`verified: false`), not a failed save; a stranger head is a supersession + // (frozen conflict marker); our head or a descendant is verified success. + let mut verified = true; + if expected_revision.is_some() { + let ancestry = current_canvas_head_ancestry(&state, &channel_id).await; + verified = classify_post_write(&result.event_id, ancestry)?; + } + Ok(serde_json::json!({ "ok": true, "event_id": result.event_id, + "verified": verified, + })) +} + +/// Classify a conflict-checked write's post-submit outcome from the ancestry +/// read (a recent slice of the canvas revision stream, newest first, each +/// entry `(event_id, the expected-revision it built on)`, or an empty slice for +/// no canvas). The submit was already accepted, so the write is durable — this +/// only decides how to report it: +/// +/// - read error → `Ok(false)`: accepted but unverified. A failed verification +/// read must never masquerade as a failed save; the caller reports success +/// with `verified: false`. +/// - our event is the head, or reachable through the head's ancestry chain → +/// `Ok(true)`. +/// - any other head → `Err(CANVAS_SUPERSEDED)`: a concurrent write won the +/// visible head; our revision is preserved in history, not lost. +/// +/// This cannot close the residual race where a competitor lands *after* this +/// read — that needs relay linearization (phase 2). +fn classify_post_write( + our_id: &str, + ancestry: Result)>, String>, +) -> Result { + match ancestry { + Err(_) => Ok(false), + Ok(revisions) => { + if buzz_sdk_pkg::canvas_write_survived(our_id, &revisions) { + Ok(true) + } else { + Err(CANVAS_SUPERSEDED.to_string()) + } + } + } +} + +/// Frozen conflict markers the desktop TypeScript layer (`canvasConflict.ts`) +/// matches to render the "canvas changed — reload" state. The advisory check +/// in [`set_canvas`] produces these directly; keep them byte-identical to the +/// `CANVAS_CONFLICT_MARKERS` list on the TS side. +const CANVAS_CHANGED: &str = "conflict: canvas changed since it was loaded"; +const CANVAS_REVISION_MISSING: &str = "conflict: canvas revision does not exist"; +/// Post-write marker: the save published successfully but a concurrent write is +/// now the visible head. The user's revision is **not** lost — it is preserved +/// in history — so the TS surface renders a distinct "reload, then restore it +/// if needed" message rather than the pre-write "reapply your edit" message. +/// Keep byte-identical to `CANVAS_SUPERSEDED_MARKER` on the TS side. +const CANVAS_SUPERSEDED: &str = "conflict: canvas save was superseded by a concurrent write"; + +/// Pure advisory precondition: compare the revision the editor asserts against +/// the live `head` (`(event_id, created_at)` or `None` when no canvas exists), +/// returning the head `created_at` floor for writer discipline on success or a +/// frozen conflict marker on mismatch. +/// +/// - `None` asserts nothing (unconditional append) — no floor. +/// - `Some("none")` asserts no canvas yet — a present head is a conflict. +/// - `Some(id)` asserts that head — a missing head is `revision does not +/// exist`, a different head is `changed since it was loaded`, a match returns +/// its `created_at` as the floor. +fn check_canvas_precondition( + expected_revision: Option<&str>, + head: Option<(String, i64)>, +) -> Result, String> { + match expected_revision { + None => Ok(None), + Some("none") => { + if head.is_some() { + Err(CANVAS_CHANGED.to_string()) + } else { + Ok(None) + } + } + Some(revision) => match head { + None => Err(CANVAS_REVISION_MISSING.to_string()), + Some((head_id, _)) if !head_id.eq_ignore_ascii_case(revision) => { + Err(CANVAS_CHANGED.to_string()) + } + Some((_, created_at)) => Ok(Some(created_at)), + }, + } +} + +/// Build the filter for [`get_canvas`] (display read). +/// +/// Display reads must NOT carry `"consistency"` — they are replica-eligible +/// reads that do not gate writes. Exposed for unit tests so asserting the +/// field is absent is causal: adding `"consistency"` to this function turns +/// the inverse-guard test red. +fn get_canvas_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + }) +} + +/// Build the base filter for [`get_canvas_history`] (display read). +/// +/// Like [`get_canvas_filter`], this must NOT carry `"consistency"` — history +/// pagination is a display read, never write-gating. Exposed for unit tests +/// for the same causal reason. The caller layers optional `until`/`before_id` +/// pagination fields on top. +fn get_canvas_history_base_filter(channel_id: &str, page_size: usize) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": page_size, + }) +} + +/// Build the filter for [`current_canvas_head`]. +/// +/// The filter carries `"consistency": "strong"` because this read gates a +/// write (the save's precondition); it must never route to a lagging replica. +/// Exposed for unit tests so asserting the field is present/absent is causal — +/// removing the field from the returned JSON makes the test red, not just a +/// stale copy. +fn canvas_head_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + // Read-your-writes: this head read gates a write (the save's + // precondition), so it must never route to a lagging replica. + "consistency": "strong", + }) +} + +/// Build the filter for [`current_canvas_head_ancestry`]. +/// +/// Like [`canvas_head_filter`], carries `"consistency": "strong"` because +/// this post-write verification read must observe the caller's own just-accepted +/// save. Exposed for unit tests for the same mutation-killable reason. +fn canvas_ancestry_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": buzz_sdk_pkg::CANVAS_ANCESTRY_WALK_MAX, + // Read-your-writes: this post-write verification read must observe + // the caller's own just-accepted save, so it pins to the writer. + "consistency": "strong", + }) +} + +/// Read the live canvas head as `(event_id, created_at)`, or `None` when the +/// channel has no canvas yet. The relay orders `created_at DESC, id ASC`, so a +/// `limit: 1` query returns exactly the head every surface agrees on. +async fn current_canvas_head( + state: &AppState, + channel_id: &str, +) -> Result, String> { + let events = query_relay(state, &[canvas_head_filter(channel_id)]).await?; + Ok(events + .first() + .map(|event| (event.id.to_hex(), event.created_at.as_secs() as i64))) +} + +/// Read a recent slice of the canvas revision stream as `(event_id, +/// expected-revision tag)` pairs, newest first, for the post-write supersession +/// check. The head is the first element; the rest let `canvas_write_survived` +/// walk `expected-revision` links back through a descendant chain (A→B→C) so a +/// legitimate later write layered on ours is not misread as a supersession. The +/// second element of each pair is that revision's own `["expected-revision", …]` +/// tag value (the id it built on), or `None` when absent. An empty vec means the +/// channel has no canvas. +async fn current_canvas_head_ancestry( + state: &AppState, + channel_id: &str, +) -> Result)>, String> { + let events = query_relay(state, &[canvas_ancestry_filter(channel_id)]).await?; + Ok(events + .iter() + .map(|event| { + let expected_revision = event + .tags + .iter() + .find(|t| { + t.as_slice() + .first() + .is_some_and(|k| k == "expected-revision") + }) + .and_then(|t| t.as_slice().get(1).cloned()); + (event.id.to_hex(), expected_revision) + }) + .collect()) +} + +/// One page of a channel canvas's revision stream (kind:40100), newest first. +/// Each 40100 write is a regular signed event the relay retains, so the +/// standard query surface holds the complete history. The composite +/// `(until, before_id)` cursor mirrors the relay read order +/// (`created_at DESC, id ASC`) so paging never skips or repeats a revision when +/// several share the same second. `next_cursor` is present only when a full +/// page came back, i.e. older revisions may remain. +#[tauri::command] +pub async fn get_canvas_history( + channel_id: String, + limit: Option, + until: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result { + if before_id.is_some() && until.is_none() { + return Err("before_id requires until".to_string()); + } + // Bound the page size to the relay's read maximum. Beyond 1,000 the relay + // silently clamps the returned rows, which would make `events.len() == + // page_size` false and null the cursor even when older revisions remain, + // stranding them behind an unreachable page. + let page_size = resolve_history_page_size(limit)?; + + let mut filter = get_canvas_history_base_filter(&channel_id, page_size); + if let Some(value) = until { + filter["until"] = serde_json::json!(value); + } + if let Some(ref value) = before_id { + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("before_id must be a 64-character hex event id".to_string()); + } + filter["before_id"] = serde_json::json!(value); + } + + let events = query_relay(&state, &[filter]).await?; + + let revisions: Vec = events + .iter() + .map(|event| { + serde_json::json!({ + "event_id": event.id.to_hex(), + "content": event.content, + "created_at": event.created_at.as_secs(), + "author": event.pubkey.to_hex(), + }) + }) + .collect(); + + // A full page means the relay may hold older revisions; hand back the + // last event as the cursor for the next "Load older" request. A short page + // is the tail, so there is no next cursor. + let next_cursor = if events.len() == page_size { + events.last().map(|last| { + serde_json::json!({ + "created_at": last.created_at.as_secs(), + "event_id": last.id.to_hex(), + }) + }) + } else { + None + }; + + Ok(serde_json::json!({ + "revisions": revisions, + "next_cursor": next_cursor, })) } + +/// Resolve and validate the history page size against the relay's read +/// maximum. Defaults to 100 when unset; a value outside `1..=1000` is rejected +/// so cursor generation is never based on a size the relay would silently +/// clamp (which strands older revisions behind a falsely-terminated page). +fn resolve_history_page_size(limit: Option) -> Result { + let page_size = limit.unwrap_or(100); + if !(1..=1000).contains(&page_size) { + return Err("limit must be between 1 and 1000".to_string()); + } + Ok(page_size) +} + +#[cfg(test)] +mod tests { + use super::{check_canvas_precondition, classify_post_write, resolve_history_page_size}; + + const HEAD_ID: &str = "aa11bb22cc33dd44ee55ff66aa11bb22cc33dd44ee55ff66aa11bb22cc33dd44"; + const OUR_ID: &str = "bb22cc33dd44ee55ff66aa11bb22cc33dd44ee55ff66aa11bb22cc33dd44ee55"; + const STRANGER_ID: &str = "cc33dd44ee55ff66aa11bb22cc33dd44ee55ff66aa11bb22cc33dd44ee55ff66"; + const THIRD_ID: &str = "dd44ee55ff66aa11bb22cc33dd44ee55ff66aa11bb22cc33dd44ee55ff66aa11"; + + #[test] + fn post_write_read_error_is_durable_but_unverified() { + // The submit was accepted; a failed verification read is durable + // success with verified=false, never a failed save. + assert_eq!( + classify_post_write(OUR_ID, Err("relay unreachable".to_string())), + Ok(false) + ); + } + + #[test] + fn post_write_our_head_or_descendant_is_verified() { + // Our own event is the head → verified. + assert_eq!( + classify_post_write(OUR_ID, Ok(vec![(OUR_ID.to_string(), None)])), + Ok(true) + ); + // A later write directly built on ours (its expected-revision names us) + // → verified. + assert_eq!( + classify_post_write( + OUR_ID, + Ok(vec![(STRANGER_ID.to_string(), Some(OUR_ID.to_string()))]) + ), + Ok(true) + ); + // Transitive descendant A(ours) → B(exp=A) → C(exp=B, head): ours is + // reached by walking the head's ancestry, so it is not a supersession. + assert_eq!( + classify_post_write( + OUR_ID, + Ok(vec![ + (THIRD_ID.to_string(), Some(STRANGER_ID.to_string())), + (STRANGER_ID.to_string(), Some(OUR_ID.to_string())), + (OUR_ID.to_string(), None), + ]) + ), + Ok(true) + ); + } + + #[test] + fn post_write_stranger_head_is_superseded() { + // A stranger won the visible head with no ancestry to ours → frozen + // supersession marker (the read succeeded, so this is detection, not an + // unverified read). + assert_eq!( + classify_post_write( + OUR_ID, + Ok(vec![(STRANGER_ID.to_string(), Some(HEAD_ID.to_string()))]) + ), + Err(super::CANVAS_SUPERSEDED.to_string()) + ); + // No head at all is likewise a supersession, not verified. + assert_eq!( + classify_post_write(OUR_ID, Ok(vec![])), + Err(super::CANVAS_SUPERSEDED.to_string()) + ); + } + + #[test] + fn precondition_none_assertion_is_unconditional_append() { + // No asserted revision: append regardless of head, no floor. + assert_eq!(check_canvas_precondition(None, None), Ok(None)); + assert_eq!( + check_canvas_precondition(None, Some((HEAD_ID.to_string(), 100))), + Ok(None) + ); + } + + #[test] + fn precondition_expect_none_conflicts_when_a_head_exists() { + // First-creation race: expected no canvas but one now exists. + assert_eq!(check_canvas_precondition(Some("none"), None), Ok(None)); + assert_eq!( + check_canvas_precondition(Some("none"), Some((HEAD_ID.to_string(), 100))), + Err(super::CANVAS_CHANGED.to_string()) + ); + } + + #[test] + fn precondition_expect_head_returns_floor_or_conflict() { + // Matching head returns its created_at as the writer-discipline floor. + assert_eq!( + check_canvas_precondition(Some(HEAD_ID), Some((HEAD_ID.to_string(), 100))), + Ok(Some(100)) + ); + // Case-insensitive id match still resolves. + assert_eq!( + check_canvas_precondition( + Some(&HEAD_ID.to_uppercase()), + Some((HEAD_ID.to_string(), 100)) + ), + Ok(Some(100)) + ); + // Head moved to a different revision since load. + assert_eq!( + check_canvas_precondition(Some(HEAD_ID), Some(("ff".repeat(32), 100))), + Err(super::CANVAS_CHANGED.to_string()) + ); + // Asserted a head but the canvas no longer has one. + assert_eq!( + check_canvas_precondition(Some(HEAD_ID), None), + Err(super::CANVAS_REVISION_MISSING.to_string()) + ); + } + + #[test] + fn defaults_to_100_when_unset() { + assert_eq!(resolve_history_page_size(None).unwrap(), 100); + } + + #[test] + fn rejects_zero() { + assert!(resolve_history_page_size(Some(0)).is_err()); + } + + #[test] + fn accepts_relay_maximum() { + assert_eq!(resolve_history_page_size(Some(1000)).unwrap(), 1000); + } + + #[test] + fn rejects_above_relay_maximum() { + assert!(resolve_history_page_size(Some(1001)).is_err()); + } + + // ── Writer-pin contract: filter-construction assertions ────────────────── + // + // `canvas_head_filter` and `canvas_ancestry_filter` build the JSON that + // `current_canvas_head` and `current_canvas_head_ancestry` pass to + // `query_relay`. `get_canvas_filter` and `get_canvas_history_base_filter` + // build the corresponding display filters. These tests assert the + // `"consistency"` field is present/absent in the produced filter, and that + // each command delegates to its helper (the helpers ARE the production code + // path — there is no copy). + // + // Mutation oracle: + // * Removing `"consistency"` from `canvas_head_filter` → the + // `head_filter_carries_strong_consistency` test fails. + // * Removing `"consistency"` from `canvas_ancestry_filter` → the + // `ancestry_filter_carries_strong_consistency` test fails. + // * Adding `"consistency"` to `get_canvas_filter` → the + // `get_canvas_filter_does_not_carry_consistency` test fails. + // * Adding `"consistency"` to `get_canvas_history_base_filter` → the + // `get_canvas_history_base_filter_does_not_carry_consistency` test fails. + + #[test] + fn head_filter_carries_strong_consistency() { + let f = super::canvas_head_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467"); + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "current_canvas_head filter must carry consistency=strong: {f}" + ); + // Structural sanity: correct kind and limit. + assert_eq!( + f["kinds"], + serde_json::json!([40100]), + "head filter must query kind 40100" + ); + assert_eq!( + f.get("limit").and_then(|v| v.as_u64()), + Some(1), + "head filter must have limit=1" + ); + } + + #[test] + fn ancestry_filter_carries_strong_consistency() { + let f = super::canvas_ancestry_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467"); + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "current_canvas_head_ancestry filter must carry consistency=strong: {f}" + ); + // Structural sanity: correct kind and limit > 1. + assert_eq!( + f["kinds"], + serde_json::json!([40100]), + "ancestry filter must query kind 40100" + ); + assert!( + f.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) > 1, + "ancestry filter limit must be > 1 (walk depth): {f}" + ); + } + + /// `get_canvas` and `get_canvas_history` are display reads: they must NOT + /// carry `"consistency"`. These inverse guards call the actual production + /// filter builders — adding `"consistency"` to either builder turns the + /// relevant test red immediately (unlike a copied literal, which would + /// silently stay green while production drifted). + #[test] + fn get_canvas_filter_does_not_carry_consistency() { + let f = super::get_canvas_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467"); + assert!( + f.get("consistency").is_none(), + "display (get_canvas) filter must NOT carry consistency: {f}" + ); + // Structural sanity. + assert_eq!(f["kinds"], serde_json::json!([40100])); + assert_eq!(f.get("limit").and_then(|v| v.as_u64()), Some(1)); + } + + #[test] + fn get_canvas_history_base_filter_does_not_carry_consistency() { + let f = super::get_canvas_history_base_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467", 50); + assert!( + f.get("consistency").is_none(), + "display (get_canvas_history) filter must NOT carry consistency: {f}" + ); + // Structural sanity. + assert_eq!(f["kinds"], serde_json::json!([40100])); + assert_eq!(f.get("limit").and_then(|v| v.as_u64()), Some(50)); + } +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..7003968998e 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -416,9 +416,23 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result Result { +/// +/// When `expected_revision` is `Some`, an `["expected-revision", ]` +/// tag is attached. The relay enforces this tag as a compare-and-swap (CAS): +/// it reads the canonical live head under an advisory lock and rejects writes +/// whose precondition no longer matches. Client-side checks (stale-edit before +/// submit and `canvas_write_survived` after) are secondary confirmations. +/// Omitting the tag preserves the historical unconditional-append behavior. +pub fn build_set_canvas( + channel_id: Uuid, + content: &str, + expected_revision: Option<&str>, +) -> Result { check_content(content)?; - let tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + if let Some(revision) = expected_revision { + tags.push(tag(vec!["expected-revision", revision])?); + } Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e8b767e7c56..f09abbe064b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -642,6 +642,7 @@ pub fn run() { join_channel, leave_channel, get_canvas, + get_canvas_history, set_canvas, get_feed, search_messages, diff --git a/desktop/src/features/channels/canvasConflict.test.mjs b/desktop/src/features/channels/canvasConflict.test.mjs new file mode 100644 index 00000000000..eeff467b91a --- /dev/null +++ b/desktop/src/features/channels/canvasConflict.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CANVAS_EXPECTED_REVISION_NONE, + CANVAS_CONFLICT_MESSAGE, + CANVAS_SUPERSEDED_MESSAGE, + canvasConflictMessage, + isCanvasConflictError, + isCanvasSupersededError, +} from "./canvasConflict.ts"; + +// The two frozen pre-write conflict strings are both conflicts from the user's +// perspective: the head moved, or the revision the client expected no longer +// exists. The third string is the post-write supersession marker. The desktop +// `set_canvas` command produces all three client-side. The helpers must +// recognize each whether it arrives as an Error or a raw string (the Tauri IPC +// layer hands back either), and must not misfire on unrelated errors. + +test("head-moved reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas changed since it was loaded"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("revision-does-not-exist reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas revision does not exist"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("conflict marker embedded in a longer wrapped message still matches", () => { + const wrapped = new Error( + "submit failed: conflict: canvas revision does not exist", + ); + assert.equal(isCanvasConflictError(wrapped), true); +}); + +test("supersession marker is a post-write conflict, not a pre-write one", () => { + const message = "conflict: canvas save was superseded by a concurrent write"; + // The post-write predicate matches it, as Error and as raw string. + assert.equal(isCanvasSupersededError(new Error(message)), true); + assert.equal(isCanvasSupersededError(message), true); + // The pre-write predicate must NOT — the two carry different user guidance. + assert.equal(isCanvasConflictError(message), false); + // And a pre-write marker is not a supersession. + assert.equal( + isCanvasSupersededError("conflict: canvas changed since it was loaded"), + false, + ); +}); + +test("canvasConflictMessage maps each marker to its distinct copy", () => { + assert.equal( + canvasConflictMessage("conflict: canvas changed since it was loaded"), + CANVAS_CONFLICT_MESSAGE, + ); + assert.equal( + canvasConflictMessage("conflict: canvas revision does not exist"), + CANVAS_CONFLICT_MESSAGE, + ); + assert.equal( + canvasConflictMessage( + "conflict: canvas save was superseded by a concurrent write", + ), + CANVAS_SUPERSEDED_MESSAGE, + ); + // Non-conflict errors fall through to null so callers show the raw message. + assert.equal(canvasConflictMessage(new Error("relay unreachable")), null); +}); + +test("unrelated errors are not conflicts", () => { + assert.equal(isCanvasConflictError(new Error("relay unreachable")), false); + assert.equal(isCanvasConflictError("some other failure"), false); + assert.equal(isCanvasConflictError(null), false); + assert.equal(isCanvasConflictError(undefined), false); + assert.equal(isCanvasSupersededError(null), false); + assert.equal( + isCanvasConflictError({ + message: "conflict: canvas changed since it was loaded", + }), + false, + ); +}); + +test("the create-race sentinel is the literal contract value", () => { + assert.equal(CANVAS_EXPECTED_REVISION_NONE, "none"); +}); diff --git a/desktop/src/features/channels/canvasConflict.ts b/desktop/src/features/channels/canvasConflict.ts new file mode 100644 index 00000000000..79b777ec533 --- /dev/null +++ b/desktop/src/features/channels/canvasConflict.ts @@ -0,0 +1,103 @@ +/** + * Optimistic-concurrency conflict detection for the channel canvas. + * + * A conflict-checked save (`set_canvas` / restore) asserts the revision the + * editor loaded via an `["expected-revision", ]` tag. + * The desktop Rust command reads the live head and compares locally before + * publishing; when the head no longer matches what the client loaded it fails + * with one of the frozen conflict strings below. Callers use this to render a + * distinct "canvas changed — reload" state instead of a generic error. + * + * Two of the markers are *pre-write* rejections — the save never published + * because the head moved (or the expected revision no longer exists) between + * load and submit. The third is a *post-write* marker: the save DID publish, + * but a concurrent write became the visible head before verification. Its + * message is deliberately different — the edit is preserved in History, so the + * user reloads and restores rather than re-typing a lost edit. + * + * Detection is client-side and best-effort: it catches a competing write that + * is visible at check time. Preventing the race entirely requires relay-side + * linearization (phase 2). These strings are produced by the desktop + * `set_canvas` command in `desktop/src-tauri/src/commands/canvas.rs`; keep them + * byte-identical there. + */ + +/** + * Post-write supersession marker: the save published, but a concurrent write is + * now current. The edit is NOT lost — it is persisted in History. Kept separate + * from `CANVAS_CONFLICT_MARKERS` because it carries a distinct user message. + * Keep byte-identical to `CANVAS_SUPERSEDED` in `canvas.rs`. + */ +const CANVAS_SUPERSEDED_MARKER = + "conflict: canvas save was superseded by a concurrent write"; + +const CANVAS_CONFLICT_MARKERS = [ + "conflict: canvas changed since it was loaded", + "conflict: canvas revision does not exist", +] as const; + +export const CANVAS_CONFLICT_MESSAGE = + "This canvas changed since you loaded it — reload to see the latest, then reapply your edit."; + +export const CANVAS_SUPERSEDED_MESSAGE = + "A concurrent edit is now current. Your save was preserved in History — reload, then restore it if needed."; + +/** + * Literal `expected-revision` value asserting "I expect no canvas exists yet". + * Sent by the first save of a new canvas so a concurrent first creation is + * detected as a conflict rather than silently overwritten. Matched by the + * desktop `set_canvas` command; keep it byte-identical there. + */ +export const CANVAS_EXPECTED_REVISION_NONE = "none"; + +/** Extract a comparable message from whatever the Tauri IPC layer hands back. */ +function errorMessage(error: unknown): string | null { + return error instanceof Error + ? error.message + : typeof error === "string" + ? error + : null; +} + +/** + * True when `error` is a *pre-write* precondition failure — the head moved or + * the expected revision no longer exists between load and save, so the write + * never published. Accepts `Error` instances and raw strings. + */ +export function isCanvasConflictError(error: unknown): boolean { + const message = errorMessage(error); + if (message === null) { + return false; + } + return CANVAS_CONFLICT_MARKERS.some((marker) => message.includes(marker)); +} + +/** + * True when `error` is the *post-write* supersession marker — the save + * published but a concurrent write became current. Distinct from + * {@link isCanvasConflictError} because the edit is preserved in History and the + * user-facing guidance differs. + */ +export function isCanvasSupersededError(error: unknown): boolean { + const message = errorMessage(error); + if (message === null) { + return false; + } + return message.includes(CANVAS_SUPERSEDED_MARKER); +} + +/** + * The user-facing message for a canvas save error, or `null` when the error is + * not a canvas conflict (callers fall back to the raw error). Post-write + * supersession takes precedence so its "preserved in History" guidance is never + * masked by the generic conflict copy. + */ +export function canvasConflictMessage(error: unknown): string | null { + if (isCanvasSupersededError(error)) { + return CANVAS_SUPERSEDED_MESSAGE; + } + if (isCanvasConflictError(error)) { + return CANVAS_CONFLICT_MESSAGE; + } + return null; +} diff --git a/desktop/src/features/channels/canvasHooks.ts b/desktop/src/features/channels/canvasHooks.ts new file mode 100644 index 00000000000..9f39553a31f --- /dev/null +++ b/desktop/src/features/channels/canvasHooks.ts @@ -0,0 +1,82 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { getCanvas, getCanvasHistory, setCanvas } from "@/shared/api/tauri"; +import type { + CanvasHistoryCursor, + CanvasHistoryResponse, +} from "@/shared/api/types"; + +export function useCanvasQuery(channelId: string | null, enabled = true) { + return useQuery({ + queryKey: ["channel-canvas", channelId], + queryFn: () => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvas(channelId); + }, + enabled: enabled && channelId !== null, + }); +} + +export function useCanvasHistoryQuery( + channelId: string | null, + enabled: boolean, +) { + return useInfiniteQuery({ + queryKey: ["channel-canvas-history", channelId], + queryFn: ({ pageParam }) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvasHistory(channelId, { + cursor: (pageParam as CanvasHistoryCursor | null) ?? null, + }); + }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + initialPageParam: null, + enabled: enabled && channelId !== null, + }); +} + +export function useSetCanvasMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: { + content: string; + expectedRevision?: string | null; + }) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return setCanvas({ + channelId, + content: input.content, + expectedRevision: input.expectedRevision ?? null, + }); + }, + // Invalidate on every settled outcome, not just success: an accepted + // write reported as CANVAS_SUPERSEDED is durable and in history, yet it + // rejects the mutation — the UI tells the user to reload and restore the + // retained revision, so the stale current/history caches must refetch on + // that rejection too. The pre-publish conflict paths and plain network + // failures also mean the canvas may have moved, so a refetch is correct + // there as well. + onSettled: () => { + if (channelId) { + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas", channelId], + }); + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas-history", channelId], + }); + } + }, + }); +} diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 18eb2699d2e..fb43d00cd94 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -11,7 +11,6 @@ import { archiveChannel, createChannel, deleteChannel, - getCanvas, getChannelDetails, getChannelMembers, getChannels, @@ -21,7 +20,6 @@ import { openDm, invokeTauri, removeChannelMember, - setCanvas, setChannelPurpose, setChannelTopic, unarchiveChannel, @@ -963,35 +961,11 @@ export function useSelectedChannel( } // ── Canvas ──────────────────────────────────────────────────────────────────── -export function useCanvasQuery(channelId: string | null, enabled = true) { - return useQuery({ - queryKey: ["channel-canvas", channelId], - queryFn: () => { - if (!channelId) { - return Promise.reject(new Error("No channel selected")); - } - return getCanvas(channelId); - }, - enabled: enabled && channelId !== null, - }); -} - -export function useSetCanvasMutation(channelId: string | null) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (content: string) => { - if (!channelId) { - return Promise.reject(new Error("No channel selected")); - } - return setCanvas({ channelId, content }); - }, - onSuccess: () => { - if (channelId) { - void queryClient.invalidateQueries({ - queryKey: ["channel-canvas", channelId], - }); - } - }, - }); -} +// Canvas query/mutation hooks live in their own module to keep this file under +// the desktop file-size ratchet; re-exported here so existing import paths +// (`@/features/channels/hooks`) keep working. +export { + useCanvasHistoryQuery, + useCanvasQuery, + useSetCanvasMutation, +} from "@/features/channels/canvasHooks"; diff --git a/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs b/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs new file mode 100644 index 00000000000..a4f64fc2895 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs @@ -0,0 +1,274 @@ +/** + * Accessibility regression for canvas mutation outcomes (WCAG 2.1 AA, per + * VISION.md). Informational states (loading, the unverified notice) expose + * `role="status"` and error states expose `role="alert"` so assistive tech + * announces them; and after a save/restore removes the focused control, focus + * lands on a sensible destination (the unverified notice) instead of falling + * back to the document body. + * + * Mounts the shipping ChannelCanvas and CanvasHistoryPanel through a mocked + * IPC. Dropping the roles or the focus restoration turns these assertions RED. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let CommunitiesProvider; +let ChannelCanvas; +let CanvasHistoryPanel; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "get_canvas") { + return { content: "hi", event_id: HEAD, updated_at: 1, author: HEAD }; + } + if (cmd === "set_canvas") { + // Accepted but unverified: exercises the notice + focus destination. + return { ok: true, event_id: "e".repeat(64), verified: false }; + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); +} + +test("save: unverified notice is a status live region and receives focus", async () => { + const client = makeClient(); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + let observed; + try { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + await act(async () => { + await client.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(); + + // Snapshot before teardown so a failing run reports cleanly rather than + // leaving a mounted tree that stalls the process. + const notice = container.querySelector( + "[data-testid='channel-canvas-unverified-notice']", + ); + observed = { + hasNotice: notice !== null, + role: notice?.getAttribute("role"), + focused: dom.window.document.activeElement === notice, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok(observed.hasNotice, "the unverified notice renders"); + assert.equal(observed.role, "status", "notice is a status region"); + assert.ok( + observed.focused, + "focus lands on the notice after the editor's Save button unmounts", + ); +}); + +test("restore: unverified notice is a status live region and receives focus", async () => { + const client = makeClient(); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + let observed; + try { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Restore opens a confirmation dialog before mutating the shared canvas; + // confirm to publish. The dialog portals into document.body. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + + const notice = container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ); + observed = { + hasNotice: notice !== null, + role: notice?.getAttribute("role"), + focused: dom.window.document.activeElement === notice, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok(observed.hasNotice, "the unverified restore notice renders"); + assert.equal(observed.role, "status", "notice is a status region"); + assert.ok( + observed.focused, + "focus lands on the notice after the Restore button unmounts", + ); +}); diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx new file mode 100644 index 00000000000..d5db3e3de70 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -0,0 +1,388 @@ +import { diffLines } from "diff"; +import { RotateCcw } from "lucide-react"; +import * as React from "react"; + +import { + useCanvasHistoryQuery, + useSetCanvasMutation, +} from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { CanvasRevision } from "@/shared/api/types"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + isRelayUnreachableError, + RELAY_UNREACHABLE_SHORT, +} from "@/shared/lib/relayError"; +import { + CANVAS_EXPECTED_REVISION_NONE, + canvasConflictMessage, +} from "@/features/channels/canvasConflict"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +type CanvasHistoryPanelProps = { + channelId: string; + currentContent: string; + currentRevision: string | null; + canRestore: boolean; +}; + +/** + * Revision history for a channel canvas. Every kind:40100 write is a regular + * signed event the relay retains, so the list is the complete edit stream — + * newest first, the head marked "Current". Selecting an older revision reveals + * a line diff against the current content and (when the viewer can edit) a + * Restore action. + * + * Restore never mutates history: it publishes a new head carrying the selected + * revision's content, guarded by `expected-revision` = the current head so a + * concurrent edit surfaces the same conflict state as a normal save. + */ +export function CanvasHistoryPanel({ + channelId, + currentContent, + currentRevision, + canRestore, +}: CanvasHistoryPanelProps) { + const historyQuery = useCanvasHistoryQuery(channelId, true); + const restoreMutation = useSetCanvasMutation(channelId); + const [selectedId, setSelectedId] = React.useState(null); + // Restore rewrites the shared channel canvas for everyone, so it is gated + // behind an explicit confirmation identifying the target revision. Holds the + // revision awaiting confirmation and the head revision captured at open time; + // `null` means no dialog is open. Nothing mutates until the user confirms. + // + // `frozenExpectedRevision` is snapshotted at dialog-open rather than read + // from the current render at confirm-time. Without this, a background + // refetch that installs a new head between "open" and "confirm" would + // silently submit the newer head as the CAS guard, bypassing the conflict + // check for the user's original intent. + const [confirmRevision, setConfirmRevision] = React.useState<{ + revision: CanvasRevision; + frozenExpectedRevision: string | null; + } | null>(null); + // Non-destructive notice shown after a restore the relay accepted but could + // not verify (the post-write supersession read failed). The restore is + // durable; the note tells the user to check the current canvas if a + // concurrent edit later appears. Cleared whenever the selection changes. + const [unverifiedRestoreNotice, setUnverifiedRestoreNotice] = + React.useState(false); + // After a restore the selection collapses and the focused Restore button + // unmounts. Move focus to the most informative surviving destination: the + // unverified notice when it renders, otherwise the toggle of the row that was + // just restored. `pendingRestoreFocus` arms the move; the effect runs it once + // the collapsed tree paints. + const noticeRef = React.useRef(null); + const restoredRowRef = React.useRef(null); + const [restoredId, setRestoredId] = React.useState(null); + const [pendingRestoreFocus, setPendingRestoreFocus] = React.useState(false); + React.useEffect(() => { + if (pendingRestoreFocus && selectedId === null) { + (noticeRef.current ?? restoredRowRef.current)?.focus(); + setPendingRestoreFocus(false); + } + }, [pendingRestoreFocus, selectedId]); + + const revisions = React.useMemo( + () => historyQuery.data?.pages.flatMap((page) => page.revisions) ?? [], + [historyQuery.data], + ); + const authorPubkeys = React.useMemo( + () => revisions.map((revision) => revision.author), + [revisions], + ); + const profilesQuery = useUsersBatchQuery(authorPubkeys, { + enabled: authorPubkeys.length > 0, + }); + + function authorLabel(pubkey: string): string { + const summary = profilesQuery.data?.profiles[pubkey.toLowerCase()]; + return summary?.displayName?.trim() || truncatePubkey(pubkey); + } + + async function handleRestore( + revision: CanvasRevision, + frozenExpectedRevision: string | null, + ) { + // Restore is a conflict-checked publish against the head that was live when + // the user opened the confirmation dialog. Using the frozen value rather + // than the current render's `currentRevision` prevents a background refetch + // that lands a new head between "open" and "confirm" from silently advancing + // the CAS guard past the user's decision point. + const result = await restoreMutation.mutateAsync({ + content: revision.content, + expectedRevision: frozenExpectedRevision ?? CANVAS_EXPECTED_REVISION_NONE, + }); + // The restore was accepted. `verified: false` means the post-write + // supersession read failed, not that the restore failed — collapse the + // selection and surface the same non-destructive note as an unverified + // save. A detected supersession rejects the promise (handled by the catch + // in the click wiring), so it never reaches here. + setUnverifiedRestoreNotice(!result.verified); + setRestoredId(revision.eventId); + setSelectedId(null); + setPendingRestoreFocus(true); + } + + if (historyQuery.isLoading) { + return ( +

+ Loading history... +

+ ); + } + + // An initial load failure with no cached data: surface the full error state. + // A failed background refetch (data is defined, error is also set) must not + // unmount the history panel or clear the restore notice — show a non-destructive + // refresh warning inside the list view instead. + if (historyQuery.error instanceof Error && historyQuery.data === undefined) { + return ( +

+ {isRelayUnreachableError(historyQuery.error) + ? RELAY_UNREACHABLE_SHORT + : historyQuery.error.message} +

+ ); + } + + if (revisions.length === 0) { + return ( +

+ No revisions yet. +

+ ); + } + + return ( +
+ {historyQuery.error instanceof Error ? ( +

+ {isRelayUnreachableError(historyQuery.error) + ? RELAY_UNREACHABLE_SHORT + : "Couldn't refresh history — showing last known revisions."} +

+ ) : null} + {unverifiedRestoreNotice ? ( +

+ Restored. We couldn't verify against the latest revision just now — + check the canvas if a concurrent edit appears. +

+ ) : null} +
    + {revisions.map((revision) => { + const isCurrent = revision.eventId === currentRevision; + const isSelected = revision.eventId === selectedId; + return ( +
  • + + {isSelected ? ( +
    + + {canRestore && !isCurrent ? ( + + ) : null} + {restoreMutation.error instanceof Error ? ( +

    + {canvasConflictMessage(restoreMutation.error) ?? + restoreMutation.error.message} +

    + ) : null} +
    + ) : null} +
  • + ); + })} +
+ {historyQuery.hasNextPage ? ( + + ) : null} + { + if (!open) setConfirmRevision(null); + }} + open={confirmRevision !== null} + > + + + Restore this revision? + + This publishes{" "} + {confirmRevision + ? `${authorLabel(confirmRevision.revision.author)}'s revision from ${formatItemTimestamp(confirmRevision.revision.createdAt, { withTime: true })}` + : "the selected revision"}{" "} + as the current canvas for everyone in this channel. History is + preserved. + + + + + + + + + + + + +
+ ); +} + +/** + * Line-level diff of a past revision against the current canvas content. + * Additions are the revision's lines not in current; removals are current + * lines the revision drops. Unchanged runs render muted for context. + */ +function CanvasRevisionDiff({ + current, + revision, +}: { + current: string; + revision: string; +}) { + const parts = React.useMemo( + () => diffLines(current, revision), + [current, revision], + ); + if (parts.length === 1 && !parts[0].added && !parts[0].removed) { + return ( +

+ Identical to the current canvas. +

+ ); + } + // Each part covers a distinct, non-overlapping slice of the concatenated + // diff, so its cumulative character offset is a stable, unique key. + let offset = 0; + return ( +
+      {parts.map((part) => {
+        const prefix = part.added ? "+" : part.removed ? "-" : " ";
+        const tone = part.added
+          ? "text-emerald-600 dark:text-emerald-400"
+          : part.removed
+            ? "text-destructive"
+            : "text-muted-foreground";
+        const key = `${prefix}${offset}`;
+        offset += part.value.length;
+        return (
+          
+            {part.value
+              .replace(/\n$/, "")
+              .split("\n")
+              .map((line) => `${prefix} ${line}`)
+              .join("\n")}
+            {"\n"}
+          
+        );
+      })}
+    
+ ); +} diff --git a/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs b/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs new file mode 100644 index 00000000000..e9435759d88 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs @@ -0,0 +1,569 @@ +/** + * Refetch-error recovery regression: after a save or restore is accepted with + * `verified: false`, subsequent settlement-triggered query refetch failures must + * not replace the accepted-write notice or unmount the cached canvas/history. + * + * The distinguishing invariant in both components: `error && data === undefined` + * is an initial load failure with no usable data and renders the full error + * state; `error && data !== undefined` is a failed background refetch and must + * leave the cached subtree mounted with a separate refresh warning. Reverting + * either component's `data === undefined` guard to an unconditional error return + * causes the corresponding scenario below to fail. + * + * Tests mount the shipping ChannelCanvas (which embeds CanvasHistoryPanel) with + * a real QueryClient, drive actual setCanvas settlement invalidation, and reject + * subsequent refetches to produce the error+data state. + * + * Also covers the no-data initial-error branch for both components to confirm + * the guard does not suppress genuine first-load failures. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); +// A distinct head used to simulate a concurrent writer installing a new canvas +// head between the time the user opens the restore dialog and the time they +// confirm. Must differ from HEAD to make the frozen vs live read observable. +const CONCURRENT = "c".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let CommunitiesProvider; +let ChannelCanvas; + +// `failRefetches` is flipped to true before a mutation fires so that the +// settlement-triggered invalidation refetch fails, exercising the error+data +// state that the components must handle non-destructively. +let failRefetches = false; +let nextSetCanvasVerified = false; +// Overrides the event_id returned by `get_canvas` (and matching history head) +// to simulate a concurrent-writer head arriving via a successful refetch. +// `null` means use the default HEAD constant. +let overrideCanvasEventId = null; +// Tracks the most-recent arguments passed to `set_canvas` so tests can assert +// on the frozen expectedRevision without coupling to mutation internals. +let lastSetCanvasArgs = null; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "get_canvas") { + if (failRefetches) { + throw new Error("relay unavailable"); + } + const eventId = overrideCanvasEventId ?? HEAD; + return { + content: "hi", + event_id: eventId, + updated_at: 1, + author: HEAD, + }; + } + if (cmd === "set_canvas") { + lastSetCanvasArgs = args; + return { + ok: true, + event_id: "e".repeat(64), + verified: nextSetCanvasVerified, + }; + } + if (cmd === "get_canvas_history") { + if (failRefetches) { + throw new Error("relay unavailable"); + } + const headId = overrideCanvasEventId ?? HEAD; + return { + revisions: [ + { event_id: headId, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); +} + +async function mountCanvas(queryClient) { + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ), + ); + }); + // Prime the canvas query with one successful fetch. + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + return { container, root }; +} + +// ── Save scenario ───────────────────────────────────────────────────────────── + +test("save: verified:false + refetch failure keeps canvas and save notice mounted", async () => { + failRefetches = false; + nextSetCanvasVerified = false; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + // Confirm initial canvas rendered. + assert.ok( + container.querySelector("[data-testid='channel-canvas-content']"), + "canvas content renders after initial load", + ); + + // Open editor and save. + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + + // Arm refetch failures BEFORE the save (the mutation's onSettled will + // invalidate and trigger a refetch that must fail). + failRefetches = true; + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(20); + + // The save notice must still be visible — refetch failure must not clear it. + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "unverified save notice survives a failed settlement refetch", + ); + // The cached canvas must remain mounted. + assert.ok( + container.querySelector("[data-testid='channel-canvas-content']"), + "cached canvas content remains mounted after failed refetch", + ); + // A non-destructive refresh warning must appear. + assert.ok( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + "refresh warning renders alongside the cached canvas", + ); + // The full-error destructive path must NOT have fired. + assert.equal( + container.querySelector("[role='alert']"), + null, + "the full destructive error state must not render when data is cached", + ); + + failRefetches = false; + await settle(4); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +test("save: successful refetch after recovery clears the refresh warning", async () => { + failRefetches = false; + nextSetCanvasVerified = false; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + + failRefetches = true; + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(20); + assert.ok( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + "refresh warning present after failed refetch", + ); + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "save notice still visible", + ); + + // Restore connectivity and manually trigger a successful refetch. + failRefetches = false; + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(12); + + assert.equal( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + null, + "refresh warning clears once refetch succeeds", + ); + // Save notice persists until next edit session (its existing reset boundary). + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "save notice persists after refetch recovery", + ); + + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +// ── Restore scenario ────────────────────────────────────────────────────────── + +test("restore: verified:false + refetch failure keeps history panel, restore notice, and rows mounted", async () => { + failRefetches = false; + nextSetCanvasVerified = false; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + // Open history panel. + await act(async () => + click( + container.querySelector("[data-testid='channel-canvas-history-toggle']"), + ), + ); + // Prime the history query with a successful fetch. + await act(async () => { + await queryClient.refetchQueries({ + queryKey: ["channel-canvas-history"], + }); + }); + await settle(12); + + assert.ok( + container.querySelector("[data-testid='channel-canvas-history']"), + "history panel renders after initial load", + ); + + // Expand the older (non-current) revision. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + assert.ok( + container.querySelector("[data-testid='channel-canvas-restore']"), + "restore button visible for older revision", + ); + + // Arm refetch failures BEFORE the restore mutation fires. + failRefetches = true; + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Confirm the restore in the dialog. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(20); + + // History panel must remain mounted — not replaced by error state. + assert.ok( + container.querySelector("[data-testid='channel-canvas-history']"), + "history panel remains mounted after failed settlement refetch", + ); + // Restore notice must be visible. + assert.ok( + container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ), + "restore notice survives failed settlement refetch", + ); + // History rows must still be present. + const rows = container.querySelectorAll( + "[data-testid='channel-canvas-history-item']", + ); + assert.ok( + rows.length > 0, + "history rows remain visible after failed refetch", + ); + // A non-destructive history refresh warning must appear. + assert.ok( + container.querySelector( + "[data-testid='channel-canvas-history-refresh-error']", + ), + "history refresh warning renders alongside cached rows", + ); + // Parent canvas must also remain mounted with its own refresh warning. + assert.ok( + container.querySelector("[data-testid='channel-canvas-content']"), + "parent canvas content remains mounted", + ); + assert.ok( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + "canvas refresh warning renders", + ); + + failRefetches = false; + await settle(4); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +// ── Initial-error no-data branches ──────────────────────────────────────────── + +test("canvas: initial load failure with no data renders full error state", async () => { + // Start with refetches failing so the very first load fails. + failRefetches = true; + nextSetCanvasVerified = false; + + const queryClient = makeClient(); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ), + ); + }); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(12); + + // Full destructive error state must render. + assert.ok( + container.querySelector("[role='alert']"), + "full error state renders on initial load failure with no data", + ); + // Non-destructive refresh warning must NOT appear (no cached data to show). + assert.equal( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + null, + "no refresh warning on initial failure — there is no cached canvas", + ); + + failRefetches = false; + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +// ── Frozen expectedRevision ─────────────────────────────────────────────────── + +test("restore: expectedRevision is frozen at dialog-open, not re-read from current render at confirm", async () => { + // This test exercises the fix for the following sequence: + // 1. User opens the restore confirmation dialog while head = HEAD. + // 2. A background refetch succeeds and installs a new head = CONCURRENT. + // 3. User confirms the dialog. + // The mutation must submit expectedRevision = HEAD (the head at open time), + // NOT CONCURRENT (the head at confirm time). If handleRestore reads + // `currentRevision` from the live render instead of the frozen value, + // the CAS guard silently advances past the user's decision point. + // + // Revert-causality: removing the `frozenExpectedRevision` parameter from + // handleRestore and restoring the `currentRevision` closure read causes this + // test to fail because the mutation receives CONCURRENT instead of HEAD. + failRefetches = false; + nextSetCanvasVerified = true; + overrideCanvasEventId = null; + lastSetCanvasArgs = null; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + // Open history panel and prime history query. + await act(async () => + click( + container.querySelector("[data-testid='channel-canvas-history-toggle']"), + ), + ); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas-history"] }); + }); + await settle(12); + + // Expand the older (non-current) revision. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + // Click Restore to open the confirmation dialog while head is still HEAD. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Verify dialog is open. + assert.ok( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm']", + ), + "restore confirmation dialog is open at HEAD", + ); + + // Simulate a concurrent writer: next successful refetch installs CONCURRENT. + overrideCanvasEventId = CONCURRENT; + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(12); + + // Confirm the dialog. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(20); + + // The mutation must have fired with the head that was current when the dialog + // opened (HEAD), not the head installed by the intervening refetch (CONCURRENT). + assert.ok(lastSetCanvasArgs, "set_canvas was called"); + assert.equal( + lastSetCanvasArgs.expectedRevision, + HEAD, + "expectedRevision is the head at dialog-open (HEAD), not the post-refetch head (CONCURRENT)", + ); + assert.notEqual( + lastSetCanvasArgs.expectedRevision, + CONCURRENT, + "expectedRevision must not be the concurrent writer's head", + ); + + overrideCanvasEventId = null; + lastSetCanvasArgs = null; + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs b/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs new file mode 100644 index 00000000000..ac1f3743098 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs @@ -0,0 +1,228 @@ +/** + * Restore-confirmation regression: restore rewrites the shared channel canvas + * for everyone, so activating "Restore this revision" must NOT mutate on its + * own — it opens a confirmation dialog identifying the target revision. + * Only confirming publishes the restore. + * + * Mounts the shipping CanvasHistoryPanel, expands an older revision, activates + * Restore, and asserts no set_canvas call fired and the confirm dialog is + * visible; then confirms and asserts exactly one set_canvas call fired. + * + * Mutation-killable: wiring the Restore button back to call handleRestore + * directly (the pre-fix behavior) makes the "no mutation before confirm" + * assertion fail — set_canvas fires on the first click. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let CanvasHistoryPanel; + +// Records every set_canvas invocation so the test can assert a restore +// mutated only after confirmation. +let setCanvasCalls = 0; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + self: dom.window, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "set_canvas") { + setCanvasCalls += 1; + return { ok: true, event_id: "e".repeat(64), verified: true }; + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +test("restore requires explicit confirmation before mutating the shared canvas", async () => { + setCanvasCalls = 0; + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + // Expand the older (non-current) revision to reveal its Restore action. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + // Activate Restore — must open the confirm dialog, NOT mutate. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + let observed; + try { + // The dialog renders through a Radix portal into document.body, not the + // mounted container, so query the whole document. + const confirmVisibleBeforeConfirm = + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ) !== null; + const callsBeforeConfirm = setCanvasCalls; + + // Confirm — this is the only path that mutates. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + + observed = { + confirmVisibleBeforeConfirm, + callsBeforeConfirm, + callsAfterConfirm: setCanvasCalls, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok( + observed.confirmVisibleBeforeConfirm, + "activating Restore opens the confirmation dialog", + ); + assert.equal( + observed.callsBeforeConfirm, + 0, + "no set_canvas call fires before the user confirms", + ); + assert.equal( + observed.callsAfterConfirm, + 1, + "confirming publishes exactly one restore", + ); +}); diff --git a/desktop/src/features/channels/ui/CanvasRestorePendingGuard.test.mjs b/desktop/src/features/channels/ui/CanvasRestorePendingGuard.test.mjs new file mode 100644 index 00000000000..83662ff3f38 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasRestorePendingGuard.test.mjs @@ -0,0 +1,300 @@ +/** + * Pending-restore guard regression: row buttons must be disabled while a + * restore is pending so clicking a different row cannot call + * `restoreMutation.reset()` — which would unobserve the running mutation, + * hide a subsequent rejection, and allow a second concurrent restore. + * + * This test verifies three invariants: + * + * 1. **Guard**: while IPC is deferred, row buttons carry `disabled`, preventing + * `reset()` from being called mid-flight. + * 2. **Rejection visibility**: rejecting the IPC makes the error render under + * the originating row, and `set_canvas` was called exactly once. + * 3. **No spurious second dispatch**: attempting another restore while the + * mutation is pending (before the rejection settles) does not fire a second + * `set_canvas` call — the row expand buttons also carry `disabled` while + * pending, preventing `reset()` from being called mid-flight. + * + * Revert-causality: removing `disabled={restoreMutation.isPending}` from the + * row button un-disables the row during the pending phase. In that case the + * test's second-row click would call `restoreMutation.reset()`, which wipes the + * mutation state — the rejection lands on a cleared mutation and the error is + * never rendered. The rejection-visible assertion then fails. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER_A = "b".repeat(64); +const OLDER_B = "c".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let CanvasHistoryPanel; + +// Controls the IPC: null means the test has not triggered set_canvas yet. +let _deferredResolve = null; +let deferredReject = null; +let setCanvasCalls = 0; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + self: dom.window, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "set_canvas") { + setCanvasCalls += 1; + return new Promise((resolve, reject) => { + _deferredResolve = resolve; + deferredReject = reject; + }); + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 3, author: HEAD }, + { + event_id: OLDER_A, + content: "older-a", + created_at: 2, + author: HEAD, + }, + { + event_id: OLDER_B, + content: "older-b", + created_at: 1, + author: HEAD, + }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 8) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +/** + * Full pending-guard contract: + * + * 1. Row expand buttons are disabled while restoreMutation.isPending is true. + * 2. Rejecting the IPC makes the error visible under the originating row; + * set_canvas remains at exactly 1 call (no reset() fired mid-flight). + * 3. Clicking another row's expand button while pending does not fire a second + * IPC (disabled prevents reset() from being called mid-flight). + */ +test("pending-guard: row disabled, rejection visible, no second dispatch", async () => { + setCanvasCalls = 0; + _deferredResolve = null; + deferredReject = null; + + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0, retry: false }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + // Expand OLDER_A (second item) to reveal its Restore button. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[1])); + await settle(); + + // Click Restore → opens confirmation dialog. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Confirm → dispatches the deferred set_canvas call (isPending becomes true). + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + // Allow one tick for the mutation to enter isPending state without resolving. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2)); + }); + + // ── Invariant 1: row buttons are disabled while pending ──────────────── + const rowButtons = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + const secondRowIsDisabled = + rowButtons[rowButtons.length - 1]?.disabled === true; + + // ── Invariant 3: clicking OLDER_B's row while pending must not call reset() + // (which would unobserve the running mutation and swallow the rejection). + // The disabled attribute prevents the click handler from firing, so + // set_canvas must still be 1 after this click. + const lastRowButton = rowButtons[rowButtons.length - 1]; + if (lastRowButton) { + await act(async () => click(lastRowButton)); + await settle(); + } + const callsAfterSecondClick = setCanvasCalls; + + // ── Invariant 2: reject the IPC and assert error is visible ─────────── + const rejectionMessage = "test conflict error"; + await act(async () => { + deferredReject?.(new Error(rejectionMessage)); + deferredReject = null; + }); + await settle(12); + + // After rejection the error must render under the OLDER_A row (the + // originating row — OLDER_A was selected when set_canvas was dispatched). + const olderALi = items[1]?.closest("li"); + const errorEl = olderALi?.querySelector("[role='alert']") ?? null; + const errorVisible = errorEl !== null; + const errorText = errorEl?.textContent ?? ""; + + const finalCallCount = setCanvasCalls; + + try { + // Teardown: unmount before assertions so cleanup always runs. + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } finally { + // Assertions after teardown so the test body always cleans up. + assert.ok( + secondRowIsDisabled, + "row buttons must be disabled while restoreMutation.isPending is true — " + + "a disabled button prevents reset() from being called mid-flight, " + + "which would unobserve the pending mutation and hide subsequent errors", + ); + assert.equal( + callsAfterSecondClick, + 1, + "clicking another row while pending must not fire a second set_canvas " + + "(reset() would unobserve the running mutation)", + ); + assert.ok( + errorVisible, + "rejecting the IPC must render an error under the originating row", + ); + assert.ok( + errorText.includes(rejectionMessage), + `error text must contain the rejection message; got: "${errorText}"`, + ); + assert.equal( + finalCallCount, + 1, + "exactly one set_canvas call must have fired throughout the test", + ); + } +}); diff --git a/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs b/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs new file mode 100644 index 00000000000..ad4f69deeaf --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs @@ -0,0 +1,236 @@ +/** + * Unverified-restore regression: when the restore's set_canvas call returns + * `verified: false` (the relay accepted the write but the post-write + * verification read failed), the restore is durable — CanvasHistoryPanel must + * collapse the selection and show the same non-destructive informational note + * as an unverified save, not treat it as a failure. A `verified: true` restore + * shows no such note. + * + * Mounts the shipping CanvasHistoryPanel, expands an older revision, restores + * it, and drives set_canvas to return `verified: false`, then asserts the + * non-destructive restore note renders. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let CanvasHistoryPanel; + +// Controls the `verified` flag the mocked set_canvas returns. +let nextVerified = false; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "set_canvas") { + return { ok: true, event_id: "e".repeat(64), verified: nextVerified }; + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +async function mountAndRestore(nextVerifiedValue) { + nextVerified = nextVerifiedValue; + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + // Expand the older (non-current) revision to reveal its Restore action. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Restore now opens a confirmation dialog (it rewrites the shared canvas); + // confirm to publish. The dialog portals into document.body, not container. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + + return { client, container, root }; +} + +test("verified:false restore collapses selection and shows the non-destructive note", async () => { + const { client, container, root } = await mountAndRestore(false); + + let observed; + try { + // Snapshot before teardown so a failing run reports cleanly rather than + // leaving a mounted tree with a pending mutation that stalls the process. + observed = { + hasNotice: + container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ) !== null, + restoreGone: + container.querySelector("[data-testid='channel-canvas-restore']") === + null, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok( + observed.hasNotice, + "the non-destructive unverified-restore note renders", + ); + assert.ok( + observed.restoreGone, + "the selection collapses after an accepted-but-unverified restore", + ); +}); + +test("verified:true restore shows no unverified note", async () => { + const { client, container, root } = await mountAndRestore(true); + + let observed; + try { + observed = { + hasNotice: + container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ) !== null, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok(!observed.hasNotice, "a verified restore shows no unverified note"); +}); diff --git a/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs b/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs new file mode 100644 index 00000000000..80a4fe6de54 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs @@ -0,0 +1,275 @@ +/** + * Cache-invalidation regression: an accepted canvas write reported as + * CANVAS_SUPERSEDED is a *post-write* rejection — the event is durable and in + * history, but a concurrent write is now the visible head, so the UI tells the + * user to reload and restore the retained revision. The current and history + * caches must therefore refetch on that rejection, not only on success. + * + * `useSetCanvasMutation` invalidates `channel-canvas` + `channel-canvas-history` + * in `onSettled`, so both the save path (ChannelCanvas) and the restore path + * (CanvasHistoryPanel) refresh their stale data when the mutation rejects with + * the supersession marker. Reverting `onSettled` to `onSuccess` (invalidating + * only on a resolved mutation) drops these invalidations and turns this RED. + * + * The tests mount the shipping components with a mocked IPC that rejects + * set_canvas with the frozen marker, and spy on invalidateQueries. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Byte-identical to CANVAS_SUPERSEDED in canvas.rs / the marker in +// canvasConflict.ts. The relay accepted the write; a concurrent head is now +// current, so set_canvas rejects with this after publishing. +const CANVAS_SUPERSEDED = + "conflict: canvas save was superseded by a concurrent write"; + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let CommunitiesProvider; +let ChannelCanvas; +let CanvasHistoryPanel; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "get_canvas") { + return { content: "hi", event_id: HEAD, updated_at: 1, author: HEAD }; + } + if (cmd === "set_canvas") { + // Accepted publish, then a stranger head is current: post-write + // supersession rejects the mutation with the frozen marker. + throw CANVAS_SUPERSEDED; + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +// A QueryClient that records the keys passed to invalidateQueries so a test can +// assert refetch of both canvas keys after a mutation settles. +function makeSpyingClient() { + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const invalidated = []; + const original = client.invalidateQueries.bind(client); + client.invalidateQueries = (filters, ...rest) => { + const key = filters?.queryKey; + if (Array.isArray(key)) { + invalidated.push(key[0]); + } + return original(filters, ...rest); + }; + return { client, invalidated }; +} + +function assertBothKeysInvalidated(invalidated, context) { + assert.ok( + invalidated.includes("channel-canvas"), + `${context}: channel-canvas cache must invalidate on supersession`, + ); + assert.ok( + invalidated.includes("channel-canvas-history"), + `${context}: channel-canvas-history cache must invalidate on supersession`, + ); +} + +test("save path: supersession rejection invalidates both canvas caches", async () => { + const { client, invalidated } = makeSpyingClient(); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + await act(async () => { + await client.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + invalidated.length = 0; // Ignore the get_canvas fetch settling. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(); + + assertBothKeysInvalidated(invalidated, "save"); + + await act(async () => root.unmount()); + client.clear(); + container.remove(); +}); + +test("restore path: supersession rejection invalidates both canvas caches", async () => { + const { client, invalidated } = makeSpyingClient(); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + // Expand the older (non-current) revision to reveal its Restore action. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + invalidated.length = 0; // Ignore history/profile fetches settling. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Restore opens a confirmation dialog before mutating the shared canvas; + // confirm to fire the (rejecting) set_canvas. The dialog portals into + // document.body. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + + assertBothKeysInvalidated(invalidated, "restore"); + + await act(async () => root.unmount()); + client.clear(); + container.remove(); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 785be39227b..a2e1e6f76dd 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -1,4 +1,4 @@ -import { Pencil, Save, X } from "lucide-react"; +import { History, Pencil, Save, X } from "lucide-react"; import * as React from "react"; import { @@ -13,6 +13,11 @@ import { isRelayUnreachableError, RELAY_UNREACHABLE_SHORT, } from "@/shared/lib/relayError"; +import { + CANVAS_EXPECTED_REVISION_NONE, + canvasConflictMessage, +} from "@/features/channels/canvasConflict"; +import { CanvasHistoryPanel } from "./CanvasHistoryPanel"; type ChannelCanvasProps = { channelId: string | null; @@ -33,15 +38,54 @@ export function ChannelCanvas({ [channels], ); const [isEditing, setIsEditing] = React.useState(false); + const [showHistory, setShowHistory] = React.useState(false); const [draft, setDraft] = React.useState(""); + // Non-destructive notice shown after a save the relay accepted but could not + // verify (the post-write supersession read failed). The write is durable; the + // note tells the user to check History if a concurrent edit later appears. + // Cleared whenever a new edit session starts. + const [unverifiedSaveNotice, setUnverifiedSaveNotice] = React.useState(false); + // Head event id captured at edit-start. A background canvas refetch can move + // the live head mid-edit; the save must assert against what the editor + // actually loaded, not the latest head. `null` means "no canvas existed when + // I started" and maps to the `none` create-race sentinel below. + const [editBaseRevision, setEditBaseRevision] = React.useState( + null, + ); + // After a save settles the focused Save button unmounts with the editor. To + // keep keyboard focus from falling back to the document body, we move it to + // the most informative surviving destination: the unverified notice when it + // renders, otherwise the Edit button next to the canvas. `pendingSaveFocus` + // arms the move; the effect below runs it once the non-editing tree paints. + const noticeRef = React.useRef(null); + const editButtonRef = React.useRef(null); + const [pendingSaveFocus, setPendingSaveFocus] = React.useState(false); + React.useEffect(() => { + if (pendingSaveFocus && !isEditing) { + (noticeRef.current ?? editButtonRef.current)?.focus(); + setPendingSaveFocus(false); + } + }, [pendingSaveFocus, isEditing]); const canvasContent = canvasQuery.data?.content ?? null; + const canvasRevision = canvasQuery.data?.eventId ?? null; + // A canvas exists whenever a persisted revision is present — an empty-string + // revision is a valid kind:40100 write (restore can republish one), so + // existence, the Create/Edit label, and History must key off the revision id, + // not content truthiness. + const canvasExists = canvasRevision !== null; // Defer the single large Markdown parse so opening the canvas commits the // surrounding chrome immediately and the heavy render reconciles after. const deferredCanvasContent = React.useDeferredValue(canvasContent); function handleStartEditing() { + // Clear any prior rejected-save error so it can't render in the fresh + // editor session — the mutation state persists across edit sessions and + // the editor renders `setCanvasMutation.error` whenever it opens. + setCanvasMutation.reset(); setDraft(canvasContent ?? ""); + setEditBaseRevision(canvasRevision); + setUnverifiedSaveNotice(false); setIsEditing(true); } @@ -51,17 +95,42 @@ export function ChannelCanvas({ } async function handleSave() { - await setCanvasMutation.mutateAsync(draft); + // Assert against the head snapshotted at edit-start, not the live head — + // a refetch may have moved `canvasRevision` while the editor was open. + // A null snapshot means no canvas existed then, so send the `none` + // sentinel to close the concurrent-first-creation race. + const result = await setCanvasMutation.mutateAsync({ + content: draft, + expectedRevision: editBaseRevision ?? CANVAS_EXPECTED_REVISION_NONE, + }); + // The write was accepted. `verified: false` means the post-write + // supersession read failed, not that the save failed — close the editor and + // surface a non-destructive note rather than a conflict. A detected + // supersession is a rejected promise handled by the catch in the click + // wiring below, so it never reaches here. + setUnverifiedSaveNotice(!result.verified); setIsEditing(false); + setPendingSaveFocus(true); } if (canvasQuery.isLoading) { - return

Loading canvas...

; + return ( +

+ Loading canvas... +

+ ); } - if (canvasQuery.error instanceof Error) { + // An initial load failure with no cached data: surface the full error state. + // A failed background refetch (data is defined, error is also set) must not + // replace the cached canvas and accepted-write notice — show a non-destructive + // refresh warning inline instead. + if (canvasQuery.error instanceof Error && canvasQuery.data === undefined) { return ( -

+

{isRelayUnreachableError(canvasQuery.error) ? RELAY_UNREACHABLE_SHORT : canvasQuery.error.message} @@ -109,8 +178,9 @@ export function ChannelCanvas({ {setCanvasMutation.error instanceof Error ? ( -

- {setCanvasMutation.error.message} +

+ {canvasConflictMessage(setCanvasMutation.error) ?? + setCanvasMutation.error.message}

) : null} @@ -119,7 +189,32 @@ export function ChannelCanvas({ return (
- {canvasContent ? ( + {canvasQuery.error instanceof Error ? ( +

+ {isRelayUnreachableError(canvasQuery.error) + ? RELAY_UNREACHABLE_SHORT + : "Couldn't refresh canvas — showing last known content."} +

+ ) : null} + {unverifiedSaveNotice ? ( +

+ Saved. We couldn't verify against the latest revision just now — check + History if a concurrent edit appears. +

+ ) : null} + {canvasExists ? (
- {canvasContent ? "Edit canvas" : "Create canvas"} + {canvasExists ? "Edit canvas" : "Create canvas"} ) : null} + {canvasExists ? ( + <> + + {showHistory && channelId ? ( + + ) : null} + + ) : null}
); } diff --git a/desktop/src/features/channels/ui/ChannelCanvasChannelSwitchReset.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasChannelSwitchReset.test.mjs new file mode 100644 index 00000000000..d5bfadfa3f9 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasChannelSwitchReset.test.mjs @@ -0,0 +1,226 @@ +/** + * Channel-switch reset regression: ChannelManagementSheet renders + * KeyedChannelCanvas, the production wrapper that owns + * `key={channelId ?? "none"}`, so a channel change remounts the ChannelCanvas + * subtree and drops all edit state (isEditing, draft, editBaseRevision, + * showHistory, unverifiedSaveNotice, the set-canvas mutation instance). Without + * the key the sheet stays mounted and a draft typed against canvas-less channel + * A would publish as channel B's canvas under A's retained `none` precondition. + * + * Mounts the production KeyedChannelCanvas directly (the same seam the sheet + * consumes): starts creating on canvas-less A, types a draft, switches to + * canvas-less B, and asserts the editor is gone (state reset) and nothing was + * submitted. Deleting the wrapper's key turns this RED. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let KeyedChannelCanvas; + +const setCanvasCalls = []; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + // Both channels are canvas-less: get_canvas returns a null head everywhere. + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "get_canvas") { + return { content: "", event_id: null, updated_at: null, author: null }; + } + if (cmd === "set_canvas") { + setCanvasCalls.push(args); + return { ok: true, event_id: "e".repeat(64), verified: true }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ KeyedChannelCanvas } = await import("./KeyedChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +// Renders the production KeyedChannelCanvas wrapper, which owns the +// channelId-derived key — the exact seam ChannelManagementSheet consumes. A +// re-render with a new channelId remounts the ChannelCanvas subtree. +function Harness({ channelId }) { + return React.createElement(KeyedChannelCanvas, { + channelId, + canEdit: true, + isArchived: false, + }); +} + +test("switching channels mid-create resets edit state and submits nothing", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + function render(channelId) { + return act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(Harness, { channelId }), + ), + ), + ); + }); + } + + // Load canvas-less channel A and start creating. + let observed; + try { + await render("channel-a"); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + const createButton = container.querySelector( + "[data-testid='channel-canvas-edit']", + ); + assert.ok(createButton, "create button renders for canvas-less channel A"); + await act(async () => click(createButton)); + const editor = container.querySelector( + "[data-testid='channel-canvas-editor']", + ); + assert.ok(editor, "editor opens on channel A"); + + // Type a draft against A. + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + dom.window.HTMLTextAreaElement.prototype, + "value", + ).set; + setter.call(editor, "draft written for channel A"); + editor.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + }); + await settle(); + + // Switch to canvas-less channel B — the key change remounts ChannelCanvas. + await render("channel-b"); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + // Snapshot what the switch produced before tearing down. Capturing here and + // asserting after teardown keeps a failing run (e.g. the production key + // deleted) from leaving a live editing tree mounted, which would hang the + // process instead of reporting a clean failure. + observed = { + editorStillOpen: container.querySelector( + "[data-testid='channel-canvas-editor']", + ), + editButtonLabel: container + .querySelector("[data-testid='channel-canvas-edit']") + ?.textContent.trim(), + submitCount: setCanvasCalls.length, + }; + } finally { + // Always tear down so the process exits whether or not the assertions hold. + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); + } + + // Edit state must not survive the switch: the editor is gone, B shows its own + // fresh Create action, and A's draft never published against B. + assert.equal( + observed.editorStillOpen, + null, + "editor does not carry across the channel switch", + ); + assert.equal( + observed.editButtonLabel, + "Create canvas", + "channel B is treated as canvas-less, not carrying A's edit session", + ); + assert.equal( + observed.submitCount, + 0, + "no canvas save fired across the channel switch", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs new file mode 100644 index 00000000000..727fbf09fee --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs @@ -0,0 +1,190 @@ +/** + * Edit-session snapshot regression: ChannelCanvas must assert the save against + * the head that existed when editing started, not the live head. A background + * canvas refetch can move the head mid-edit; without the snapshot the save + * would silently overwrite the newer revision instead of surfacing a conflict. + * + * Mounts the shipping ChannelCanvas, opens the editor at head A, moves the live + * head to B via a refetch, then saves and asserts the submitted + * `expectedRevision` is still A. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The real Markdown component pulls in the remark/rehype/emoji stack, which +// never releases its jsdom handles and hangs the node:test process. This test +// only exercises the save-snapshot wiring, so serve an inert stub. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD_A = "a".repeat(64); +const HEAD_B = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let ChannelCanvas; + +// Mutable relay-head mock the Tauri bridge reads on each get_canvas. +let currentHead = { content: "original", eventId: HEAD_A }; +const setCanvasCalls = []; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + // Stub the Tauri IPC bridge invokeTauri ultimately calls. + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "get_canvas") { + return { + content: currentHead.content, + event_id: currentHead.eventId, + updated_at: 1, + author: HEAD_A, + }; + } + if (cmd === "set_canvas") { + setCanvasCalls.push(args); + return { ok: true, event_id: HEAD_B, verified: true }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +// Flush microtasks, pending query promises, and React's scheduler (the +// deferred canvas render is posted on a MessageChannel) so nothing is left +// pending at teardown. +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +test("head moves mid-edit — save still asserts the head snapshotted at edit-start", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + // gcTime: 0 lets the settled mutation drop from cache immediately so no + // mutation promise is left pending when the node:test process tears down. + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + + // Canvas at head A has loaded; open the editor. + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + const editButton = container.querySelector( + "[data-testid='channel-canvas-edit']", + ); + assert.ok(editButton, "edit button renders after head A loads"); + await act(async () => click(editButton)); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + + // Head moves to B under the open editor via a background refetch. + currentHead = { content: "moved", eventId: HEAD_B }; + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + // Save — the submitted expected revision must be the snapshot (A), not B. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + + assert.equal(setCanvasCalls.length, 1); + assert.equal(setCanvasCalls[0].expectedRevision, HEAD_A); + + // Drain the refetch the save's onSuccess invalidation triggers, plus any + // deferred render still scheduled, so no work is pending at teardown. + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvasEmptyExistence.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasEmptyExistence.test.mjs new file mode 100644 index 00000000000..db6f6794cc2 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasEmptyExistence.test.mjs @@ -0,0 +1,158 @@ +/** + * Empty-canvas existence regression: an empty-string canvas is still a valid + * persisted kind:40100 revision (restore can republish one). ChannelCanvas must + * key existence — the rendered content block, the Edit-vs-Create label, and the + * History toggle — off the presence of a revision id, not content truthiness. + * + * Mounts the shipping ChannelCanvas with a canvas whose content is "" but whose + * event id is non-null, then asserts the action reads "Edit" and History stays + * available. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The real Markdown component pulls in the remark/rehype/emoji stack, which +// never releases its jsdom handles and hangs the node:test process. This test +// only exercises existence gating, so serve an inert stub. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let ChannelCanvas; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + // A persisted-but-empty canvas: content is "" while the head event id is set. + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "get_canvas") { + return { content: "", event_id: HEAD, updated_at: 1, author: HEAD }; + } + if (cmd === "get_canvas_history") { + return { revisions: [], next_cursor: null }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +test("empty-string canvas with a revision id still exists — Edit label and History remain", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + const editButton = container.querySelector( + "[data-testid='channel-canvas-edit']", + ); + assert.ok(editButton, "edit button renders for an existing empty canvas"); + assert.equal( + editButton.textContent.trim(), + "Edit canvas", + "an existing empty canvas labels the action Edit, not Create", + ); + assert.ok( + container.querySelector("[data-testid='channel-canvas-history-toggle']"), + "History remains available for an existing empty canvas", + ); + + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs new file mode 100644 index 00000000000..dcc887c8515 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs @@ -0,0 +1,99 @@ +/** + * Canvas ingress gating regression: `canOpenCanvas` must key on the presence of + * a persisted relay revision (`eventId !== null`), not on content length. After + * a restore-to-empty the relay holds a kind:40100 event with `event_id` set and + * `content: ""` — a read-only member who cannot edit would lose the only ingress + * to that revision stream if we gate on `hasCanvas` (content truthiness). + * + * Tests the `canvasIngressOpen` pure function that ChannelManagementSheet + * delegates to for the `canOpenCanvas` flag. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const { canvasIngressOpen } = await import("./canvasIngress.ts"); + +// Source-wiring oracle: read ChannelManagementSheet.tsx and verify it calls +// `canvasIngressOpen` with `eventId` as the existence signal, not with +// `hasCanvas` (content-length truthiness). Reverting the sheet call site to +// `hasCanvas || canEditNarrative` removes the `canvasIngressOpen` call from +// `canOpenCanvas` and makes the match below fail. +const sheetSource = readFileSync( + new URL("./ChannelManagementSheet.tsx", import.meta.url), + "utf8", +).replace(/\s+/g, " "); + +test("ChannelManagementSheet: canOpenCanvas is wired to canvasIngressOpen with eventId", () => { + // The sheet must delegate existence gating to the canonical helper, passing + // `canvasQuery.data?.eventId` so persisted-empty canvases are not hidden from + // read-only members. Reverting to `hasCanvas || canEditNarrative` removes + // this call and the assertion below fails. + assert.match( + sheetSource, + /canvasIngressOpen\( canvasQuery\.data\?\.eventId,/, + "canOpenCanvas must call canvasIngressOpen(canvasQuery.data?.eventId, …)", + ); +}); + +test("ChannelManagementSheet: hasCanvas is not used as the ingress-open predicate", () => { + // `hasCanvas` is a content-length check valid only for preview text. It must + // NOT be the existence gate for canOpenCanvas. + assert.doesNotMatch( + sheetSource, + /canOpenCanvas = hasCanvas/, + "canOpenCanvas must not be derived directly from hasCanvas", + ); +}); + +const EVENT_ID = "a".repeat(64); + +// Read-only member (canEditNarrative = false). + +test("read-only member: no persisted canvas → ingress closed", () => { + assert.equal(canvasIngressOpen(null, false), false); + assert.equal(canvasIngressOpen(undefined, false), false); +}); + +test("read-only member: persisted canvas with content → ingress open", () => { + assert.equal(canvasIngressOpen(EVENT_ID, false), true); +}); + +test("read-only member: persisted empty canvas (content='') with eventId → ingress open", () => { + // This is the restored-to-empty case. Content is "" but a revision exists. + // The old `hasCanvas || canEditNarrative` gating would return false here, + // losing the only ingress to the revision stream for read-only members. + assert.equal(canvasIngressOpen(EVENT_ID, false), true); +}); + +// Editor (canEditNarrative = true) — always open regardless of eventId. + +test("editor: no persisted canvas → ingress open (seeds first revision)", () => { + assert.equal(canvasIngressOpen(null, true), true); + assert.equal(canvasIngressOpen(undefined, true), true); +}); + +test("editor: persisted canvas → ingress open", () => { + assert.equal(canvasIngressOpen(EVENT_ID, true), true); +}); + +// Mutation oracle: confirms the test catches the content-based regression. + +test("regression oracle: old content-only gating fails for read-only + persisted-empty", () => { + // Old code: `hasCanvas || canEditNarrative` where hasCanvas = content.trim().length > 0. + // For an empty-content revision, hadOldBug === false — ingress closed for read-only. + const emptyContent = ""; + const hadOldBug = emptyContent.trim().length > 0 || false; + assert.equal( + hadOldBug, + false, + "old logic closes ingress for read-only + empty content", + ); + // canvasIngressOpen must NOT replicate that defect. + assert.equal( + canvasIngressOpen(EVENT_ID, false), + true, + "canvasIngressOpen keeps ingress open when eventId is non-null", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvasRejectedSaveReset.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasRejectedSaveReset.test.mjs new file mode 100644 index 00000000000..87d904564c2 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasRejectedSaveReset.test.mjs @@ -0,0 +1,222 @@ +/** + * Rejected-save reset regression: after a save is rejected, `ChannelCanvas` + * must clear the shared set-canvas mutation before the next edit session so a + * stale error alert does not reappear in the fresh editor. + * + * `useSetCanvasMutation` (TanStack Query) retains the mutation `error` across + * edit sessions, and the editor renders it whenever it opens. Without + * `setCanvasMutation.reset()` in `handleStartEditing`, the sequence + * reject -> Cancel -> Edit re-surfaces the prior error before the user acts. + * + * Mounts the shipping ChannelCanvas, drives a rejected save (asserting the + * error alert renders), cancels, reopens the editor, and asserts the alert is + * gone. `CanvasHistoryPanel` mirrors this via `restoreMutation.reset()`. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The real Markdown component pulls in the remark/rehype/emoji stack, which +// never releases its jsdom handles and hangs the node:test process. This test +// only exercises the mutation-reset wiring, so serve an inert stub. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD_A = "a".repeat(64); +const HEAD_B = "b".repeat(64); +const REJECTION_MESSAGE = "stale revision — reload and retry"; + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let ChannelCanvas; + +// The bridge rejects the first save, then accepts the second. +let rejectNextSave = true; +const setCanvasCalls = []; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "get_canvas") { + return { + content: "original", + event_id: HEAD_A, + updated_at: 1, + author: HEAD_A, + }; + } + if (cmd === "set_canvas") { + setCanvasCalls.push(args); + if (rejectNextSave) { + throw new Error(REJECTION_MESSAGE); + } + return { ok: true, event_id: HEAD_B, verified: true }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +// Flush microtasks, pending query promises, and React's scheduler so nothing +// is left pending at teardown. +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +function saveErrorAlert(container) { + return [...container.querySelectorAll("[role='alert']")].find((node) => + node.textContent?.includes(REJECTION_MESSAGE), + ); +} + +test("rejected save error does not reappear when reopening the editor", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0, retry: false }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + // Open the editor and drive a rejected save. + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(); + + // The editor stays open on rejection and surfaces the error. + const editorOpenAfterReject = Boolean( + container.querySelector("[data-testid='channel-canvas-editor']"), + ); + const errorRenderedAfterReject = Boolean(saveErrorAlert(container)); + + // Cancel back to the read view, then reopen the editor. + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-cancel']")), + ); + await settle(); + rejectNextSave = false; + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + await settle(); + + const editorReopened = Boolean( + container.querySelector("[data-testid='channel-canvas-editor']"), + ); + const staleErrorPresent = Boolean(saveErrorAlert(container)); + + // Tear down before asserting so a failure reports cleanly instead of hanging + // the node:test process on still-mounted React/query handles. + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); + + assert.ok(editorOpenAfterReject, "editor stays open after a rejected save"); + assert.ok( + errorRenderedAfterReject, + "the rejected-save error alert renders in the editor", + ); + assert.ok(editorReopened, "editor reopens on the second edit session"); + assert.equal( + staleErrorPresent, + false, + "the stale rejected-save error is cleared in the fresh edit session", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvasUnverifiedSave.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasUnverifiedSave.test.mjs new file mode 100644 index 00000000000..2896c567d16 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasUnverifiedSave.test.mjs @@ -0,0 +1,212 @@ +/** + * Unverified-save regression: when set_canvas returns `verified: false` (the + * relay accepted the write but the post-write verification read failed), the + * save is durable — ChannelCanvas must close the editor and show a + * non-destructive informational note, not a conflict or error. A `verified: + * true` save shows no such note. + * + * Mounts the shipping ChannelCanvas, edits an existing canvas, and drives + * set_canvas to return `verified: false`, then asserts the editor closes and + * the unverified note renders. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let ChannelCanvas; + +// Controls the `verified` flag the mocked set_canvas returns. +let nextVerified = false; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "get_canvas") { + return { content: "hi", event_id: HEAD, updated_at: 1, author: HEAD }; + } + if (cmd === "set_canvas") { + return { ok: true, event_id: "e".repeat(64), verified: nextVerified }; + } + if (cmd === "get_canvas_history") { + return { revisions: [], next_cursor: null }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +async function mount(queryClient) { + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + return { container, root }; +} + +test("verified:false save closes the editor and shows the non-destructive note", async () => { + nextVerified = false; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const { container, root } = await mount(queryClient); + + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(12); + + assert.equal( + container.querySelector("[data-testid='channel-canvas-editor']"), + null, + "editor closes after an accepted-but-unverified save", + ); + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "the non-destructive unverified-save note renders", + ); + + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +test("verified:true save closes the editor with no unverified note", async () => { + nextVerified = true; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const { container, root } = await mount(queryClient); + + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(12); + + assert.equal( + container.querySelector("[data-testid='channel-canvas-editor']"), + null, + "editor closes after a verified save", + ); + assert.equal( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + null, + "a verified save shows no unverified note", + ); + + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 8a1aaf6a02e..01941893d15 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -21,6 +21,7 @@ import { useUnarchiveChannelMutation, useUpdateChannelMutation, } from "@/features/channels/hooks"; +import { canvasIngressOpen } from "./canvasIngress"; import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelWorkflowsQuery } from "@/features/workflows/hooks"; @@ -56,7 +57,7 @@ import { PANEL_ENTER_MOTION_CLASS, PANEL_OVERLAY_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; -import { ChannelCanvas } from "./ChannelCanvas"; +import { KeyedChannelCanvas } from "./KeyedChannelCanvas"; import { ChannelWorkflowsSection } from "./ChannelWorkflowsSection"; import { CHANNEL_FORM_FIELD_CONTROL_CLASS, @@ -309,7 +310,10 @@ export function ChannelManagementSheet({ const canvasPreview = hasCanvas ? getMarkdownPreviewText(canvasContent) : undefined; - const canOpenCanvas = hasCanvas || canEditNarrative; + const canOpenCanvas = canvasIngressOpen( + canvasQuery.data?.eventId, + canEditNarrative, + ); function handleEditDialogOpenChange(next: boolean) { if (next) { @@ -972,7 +976,7 @@ function ChannelManagementPanelContent({
) : activeView === "canvas" ? (
- + ); +} diff --git a/desktop/src/features/channels/ui/canvasDialogTestEnv.mjs b/desktop/src/features/channels/ui/canvasDialogTestEnv.mjs new file mode 100644 index 00000000000..b2842cc12fa --- /dev/null +++ b/desktop/src/features/channels/ui/canvasDialogTestEnv.mjs @@ -0,0 +1,67 @@ +/** + * Shared JSDOM setup for the canvas history tests that mount + * CanvasHistoryPanel, whose Restore action opens a Radix AlertDialog. Radix's + * focus/dismiss machinery reaches for DOM-level globals without a `window.` + * prefix (getComputedStyle, NodeFilter, the HTML/SVG constructors, ...) and + * its layer coordination dispatches plain objects through dispatchEvent, which + * JSDOM's strict Event validation rejects. This installs both so the dialog's + * effects settle under bare `node:test` + jsdom. + * + * Call once from a test's `before()` after constructing the JSDOM instance and + * before importing React. Additive to the basic globals (document, window, + * HTMLElement, navigator, matchMedia, localStorage) each test already sets. + */ +export function installRadixDialogGlobals(dom) { + globalThis.self = dom.window; + globalThis.MutationObserver = dom.window.MutationObserver; + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + dom.window.ResizeObserver = globalThis.ResizeObserver; + dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + + // Bulk-copy the DOM-level globals Radix references without a `window.` + // prefix. Bulk copy avoids per-internal whack-a-mole. + for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + [ + "Node", + "NodeFilter", + "NodeList", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "PointerEvent", + "EventTarget", + "DocumentFragment", + "getComputedStyle", + ].includes(key)) + ) { + const val = dom.window[key]; + if (val !== undefined) globalThis[key] = val; + } + } + // getComputedStyle must stay bound to dom.window or it throws "Illegal + // invocation" when Radix calls it. + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + // Radix DismissableLayer/FocusScope dispatch plain objects through + // dispatchEvent for layer coordination; JSDOM's strict Event validation + // throws on those. Drop non-Event objects so the dialog's effects settle + // without affecting real Event delivery. + const origDispatch = dom.window.EventTarget.prototype.dispatchEvent; + dom.window.EventTarget.prototype.dispatchEvent = function (event) { + if (!(event instanceof dom.window.Event)) return false; + return origDispatch.call(this, event); + }; + globalThis.EventTarget = dom.window.EventTarget; +} diff --git a/desktop/src/features/channels/ui/canvasIngress.ts b/desktop/src/features/channels/ui/canvasIngress.ts new file mode 100644 index 00000000000..56d41984f79 --- /dev/null +++ b/desktop/src/features/channels/ui/canvasIngress.ts @@ -0,0 +1,14 @@ +/** + * Whether the canvas ingress row should be shown. + * + * Existence is keyed on `eventId` (a persisted kind:40100 revision exists on + * the relay), not on content length — a restore to empty still leaves a live + * revision, and a read-only member must be able to reach it. `canEditNarrative` + * independently grants access so editors can seed the first revision. + */ +export function canvasIngressOpen( + eventId: string | null | undefined, + canEditNarrative: boolean, +): boolean { + return eventId != null || canEditNarrative; +} diff --git a/desktop/src/shared/api/canvasTypes.ts b/desktop/src/shared/api/canvasTypes.ts new file mode 100644 index 00000000000..723566a49e9 --- /dev/null +++ b/desktop/src/shared/api/canvasTypes.ts @@ -0,0 +1,43 @@ +export type CanvasResponse = { + content: string | null; + eventId: string | null; + updatedAt: number | null; + author: string | null; +}; + +export type SetCanvasInput = { + channelId: string; + content: string; + expectedRevision?: string | null; +}; + +export type SetCanvasResult = { + ok: boolean; + eventId: string; + /** + * `false` when the write was accepted by the relay but the post-write + * verification read failed, so supersession could not be checked. The save is + * durable; the caller shows a non-destructive "saved, verification + * unavailable" note rather than a failure. `true` on the normal verified path. + */ + verified: boolean; +}; + +export type CanvasRevision = { + eventId: string; + content: string; + createdAt: number; + author: string; +}; + +/** Composite `(created_at DESC, id ASC)` cursor for "Load older" paging. */ +export type CanvasHistoryCursor = { + createdAt: number; + eventId: string; +}; + +export type CanvasHistoryResponse = { + revisions: CanvasRevision[]; + /** Present only when older revisions may remain. */ + nextCursor: CanvasHistoryCursor | null; +}; diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts index b42d9f39612..cb6e197a7b2 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.ts +++ b/desktop/src/shared/api/relayQueryInvalidation.ts @@ -1,6 +1,7 @@ const RELAY_QUERY_ROOTS = new Set([ "archivedIdentities", "channel-canvas", + "channel-canvas-history", "channel-messages", "channels", "contact-list", diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index e3de3d54046..b70965be8a2 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -8,7 +8,6 @@ import type { AddChannelMembersResult, BackendProviderCandidate, BackendProviderProbeResult, - CanvasResponse, GetHomeFeedInput, HomeFeedResponse, ManagedAgent, @@ -21,8 +20,6 @@ import type { RelayEvent, SearchMessagesInput, SearchMessagesResponse, - SetCanvasInput, - SetCanvasResult, ThreadCursor, ThreadRepliesResponse, CreateManagedAgentInput, @@ -38,6 +35,11 @@ import type { } from "@/shared/api/types"; export * from "@/shared/api/tauriChannels"; +export { + getCanvas, + getCanvasHistory, + setCanvas, +} from "@/shared/api/tauriCanvas"; export { sendChannelMessage } from "@/shared/api/tauriMessages"; export { getEventById, getEventsByIds } from "@/shared/api/tauriEvents"; @@ -230,17 +232,6 @@ type RawListRelayMembersResponse = { members: RawRelayMember[]; }; -type RawCanvasResponse = { - content: string | null; - updated_at: number | null; - author: string | null; -}; - -type RawSetCanvasResult = { - ok: boolean; - event_id: string; -}; - /** Error normalized from a rejected Tauri invocation with its wire payload. */ export class TauriInvokeError extends Error { readonly payload: unknown; @@ -383,33 +374,6 @@ export async function leaveChannel(channelId: string): Promise { await invokeTauri("leave_channel", { channelId }); } -export async function getCanvas(channelId: string): Promise { - const response = await invokeTauri("get_canvas", { - channelId, - }); - return { - content: response.content, - // Normalize absent keys to null: ensureWelcomeCanvas treats null as - // "no canvas yet", and `undefined !== null` would make every fresh - // channel look already-seeded. - updatedAt: response.updated_at ?? null, - author: response.author ?? null, - }; -} - -export async function setCanvas( - input: SetCanvasInput, -): Promise { - const response = await invokeTauri("set_canvas", { - channelId: input.channelId, - content: input.content, - }); - return { - ok: response.ok, - eventId: response.event_id, - }; -} - export async function getHomeFeed( input: GetHomeFeedInput = {}, ): Promise { diff --git a/desktop/src/shared/api/tauriCanvas.ts b/desktop/src/shared/api/tauriCanvas.ts new file mode 100644 index 00000000000..4d14fb6a03b --- /dev/null +++ b/desktop/src/shared/api/tauriCanvas.ts @@ -0,0 +1,90 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { + CanvasHistoryCursor, + CanvasHistoryResponse, + CanvasResponse, + SetCanvasInput, + SetCanvasResult, +} from "@/shared/api/canvasTypes"; + +type RawCanvasResponse = { + content: string | null; + event_id: string | null; + updated_at: number | null; + author: string | null; +}; + +type RawCanvasHistoryResponse = { + revisions: { + event_id: string; + content: string; + created_at: number; + author: string; + }[]; + next_cursor: { created_at: number; event_id: string } | null; +}; + +type RawSetCanvasResult = { + ok: boolean; + event_id: string; + verified: boolean; +}; + +export async function getCanvas(channelId: string): Promise { + const response = await invokeTauri("get_canvas", { + channelId, + }); + return { + content: response.content, + eventId: response.event_id ?? null, + // Normalize absent keys to null: ensureWelcomeCanvas treats null as + // "no canvas yet", and `undefined !== null` would make every fresh + // channel look already-seeded. + updatedAt: response.updated_at ?? null, + author: response.author ?? null, + }; +} + +export async function setCanvas( + input: SetCanvasInput, +): Promise { + const response = await invokeTauri("set_canvas", { + channelId: input.channelId, + content: input.content, + expectedRevision: input.expectedRevision ?? null, + }); + return { + ok: response.ok, + eventId: response.event_id, + verified: response.verified, + }; +} + +export async function getCanvasHistory( + channelId: string, + options: { limit?: number; cursor?: CanvasHistoryCursor | null } = {}, +): Promise { + const response = await invokeTauri( + "get_canvas_history", + { + channelId, + limit: options.limit ?? null, + until: options.cursor?.createdAt ?? null, + beforeId: options.cursor?.eventId ?? null, + }, + ); + return { + revisions: response.revisions.map((revision) => ({ + eventId: revision.event_id, + content: revision.content, + createdAt: revision.created_at, + author: revision.author, + })), + nextCursor: response.next_cursor + ? { + createdAt: response.next_cursor.created_at, + eventId: response.next_cursor.event_id, + } + : null, + }; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 95bbdd96429..dee34b43e6c 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -69,21 +69,15 @@ export type SetChannelPurposeInput = { purpose: string; }; -export type CanvasResponse = { - content: string | null; - updatedAt: number | null; - author: string | null; -}; - -export type SetCanvasInput = { - channelId: string; - content: string; -}; +export type { + CanvasHistoryCursor, + CanvasHistoryResponse, + CanvasResponse, + CanvasRevision, + SetCanvasInput, + SetCanvasResult, +} from "@/shared/api/canvasTypes"; -export type SetCanvasResult = { - ok: boolean; - eventId: string; -}; export type AddChannelMembersInput = { channelId: string; pubkeys: string[]; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ae641800c51..0e733b72f40 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -372,6 +372,10 @@ type E2eConfig = { deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; + /** Seed canvas revisions (oldest first) so history/restore journeys can + * drive the real panel against a stateful store. Each save appends a new + * head; `get_canvas_history` pages over the accumulated stream. */ + canvasRevisions?: MockCanvasRevisionSeed[]; /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; @@ -3361,6 +3365,49 @@ type MockSaveSubscriptionRow = { }; let mockSaveSubscriptions: MockSaveSubscriptionRow[] = []; +// Stateful mock canvas: an append-only revision stream keyed by channel, newest +// first, mirroring the relay's 40100 history. `get_canvas` returns the head, +// `set_canvas` appends a new head, and `get_canvas_history` pages over the +// stream with the same `(created_at DESC, id ASC)` composite cursor the Rust +// command uses — so history/save-conflict/restore journeys run against real +// state instead of a fixed stub. +type MockCanvasRevisionSeed = { + content: string; + /** Optional; defaults to a monotonically increasing second per seed. */ + createdAt?: number; + /** Optional 64-hex id; defaults to a fresh mock event id. */ + eventId?: string; + /** Optional author pubkey; defaults to the mock viewer. */ + author?: string; +}; +type MockCanvasRevision = { + eventId: string; + content: string; + createdAt: number; + author: string; +}; +let mockCanvasRevisions = new Map(); + +// The canvas UI reaches the mock via the starter "general" channel in specs. +const DEFAULT_STARTER_CANVAS_CHANNEL = STARTER_GENERAL_CHANNEL_ID; + +function resetMockCanvasRevisions(config: E2eConfig | undefined) { + mockCanvasRevisions = new Map(); + const seeds = config?.mock?.canvasRevisions; + if (!seeds || seeds.length === 0) { + return; + } + // Seeds are oldest-first; store newest-first so index 0 is always the head. + const revisions = seeds.map((seed, index) => ({ + eventId: seed.eventId ?? mockEventId(), + content: seed.content, + createdAt: seed.createdAt ?? 1_700_000_000 + index, + author: seed.author ?? DEFAULT_MOCK_IDENTITY.pubkey, + })); + revisions.reverse(); + mockCanvasRevisions.set(DEFAULT_STARTER_CANVAS_CHANNEL, revisions); +} + type MockObservedUnreadScope = { generation: string; revision: number; @@ -11432,6 +11479,7 @@ export function maybeInstallE2eTauriMocks() { resetMockObservedUnread(); resetMockTeamCatalogEvents(config); resetMockSaveSubscriptions(config); + resetMockCanvasRevisions(config); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); resetMockPendingEntityDeepLinks(config); @@ -14805,15 +14853,100 @@ export function maybeInstallE2eTauriMocks() { // The spec only verifies UI state, not the submitted request shape; // returning null mirrors the Rust submit_event success path. return null; - case "set_canvas": - return { ok: true, event_id: mockEventId() }; + case "set_canvas": { + const req = payload as { + channelId: string; + content: string; + expectedRevision?: string | null; + }; + const stream = mockCanvasRevisions.get(req.channelId) ?? []; + const head = stream[0] ?? null; + // Mirror the desktop command's client-side advisory check: read the + // live head, compare locally, and fail with the frozen conflict + // strings so canvasConflict.ts recognizes them. + const expected = req.expectedRevision; + if (expected !== undefined && expected !== null) { + if (expected === "none" && head) { + throw new Error("conflict: canvas changed since it was loaded"); + } + if (expected !== "none" && !head) { + throw new Error("conflict: canvas revision does not exist"); + } + if (expected !== "none" && head && expected !== head.eventId) { + throw new Error("conflict: canvas changed since it was loaded"); + } + } + const revision: MockCanvasRevision = { + eventId: mockEventId(), + content: req.content, + createdAt: (head?.createdAt ?? 1_700_000_000) + 1, + author: DEFAULT_MOCK_IDENTITY.pubkey, + }; + mockCanvasRevisions.set(req.channelId, [revision, ...stream]); + return { ok: true, event_id: revision.eventId, verified: true }; + } case "get_canvas": { const canvasReadError = activeConfig?.mock?.canvasReadError; if (canvasReadError) { throw new Error(canvasReadError); } - // Return the no-canvas success shape — content null means no canvas set. - return { content: null, updated_at: null, author: null }; + const req = payload as { channelId: string }; + const head = mockCanvasRevisions.get(req.channelId)?.[0] ?? null; + if (!head) { + // No-canvas success shape — content null means no canvas set. + return { + content: null, + event_id: null, + updated_at: null, + author: null, + }; + } + return { + content: head.content, + event_id: head.eventId, + updated_at: head.createdAt, + author: head.author, + }; + } + case "get_canvas_history": { + const req = payload as { + channelId: string; + limit?: number | null; + until?: number | null; + beforeId?: string | null; + }; + const pageSize = Math.max(req.limit ?? 100, 1); + const stream = mockCanvasRevisions.get(req.channelId) ?? []; + // Keyset over the newest-first stream: strictly older than the + // composite cursor, preserving (created_at DESC, id ASC) so a tied + // second never skips or repeats a revision across a page boundary. + const until = req.until; + const beforeId = req.beforeId; + const windowed = + until == null + ? stream + : stream.filter((rev) => { + if (rev.createdAt < until) return true; + if (rev.createdAt > until) return false; + return beforeId != null && rev.eventId > beforeId; + }); + const page = windowed.slice(0, pageSize); + const nextCursor = + page.length === pageSize && page.length > 0 + ? { + created_at: page[page.length - 1].createdAt, + event_id: page[page.length - 1].eventId, + } + : null; + return { + revisions: page.map((rev) => ({ + event_id: rev.eventId, + content: rev.content, + created_at: rev.createdAt, + author: rev.author, + })), + next_cursor: nextCursor, + }; } // ── Local-save archive ────────────────────────────────────────────── // These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 1797def2622..694b0b70184 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3189,6 +3189,88 @@ test("manage channel places canvas between channel info and actions", async ({ expect(canvasBox?.y).toBeLessThan(leaveBox?.y); }); +test("canvas history, save-conflict, and restore journey", async ({ page }) => { + // Seed a two-revision canvas so the history panel has an older revision to + // diff and restore, and the head is the newer one. + await installMockBridge(page, { + canvasRevisions: [ + { content: "# Kickoff\n\nfirst draft", createdAt: 1_700_000_000 }, + { content: "# Kickoff\n\nsecond draft", createdAt: 1_700_000_100 }, + ], + }); + await page.goto("/"); + await openChannelManagement(page, "general"); + await page.getByTestId("channel-canvas-ingress").click(); + + const section = page.getByTestId("channel-canvas-section"); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "second draft", + ); + + // History lists both revisions, newest first with the head marked Current. + await section.getByTestId("channel-canvas-history-toggle").click(); + const historyItems = section.getByTestId("channel-canvas-history-item"); + await expect(historyItems).toHaveCount(2); + await expect(historyItems.first()).toContainText("Current"); + + // Expanding the older revision shows a diff against the current content. + await historyItems.nth(1).getByRole("button").first().click(); + await expect(section.getByTestId("channel-canvas-diff")).toContainText( + "first draft", + ); + + // Save-conflict: open the editor (snapshots head), move the head via a + // concurrent save through the bridge, then save — the save command's + // advisory check must surface the frozen conflict string as the + // reload-required conflict copy, not a raw error. + await section.getByTestId("channel-canvas-history-toggle").click(); + await section.getByTestId("channel-canvas-edit").click(); + await page.evaluate((channelId) => { + return ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: (cmd: string, args: unknown) => Promise; + }; + } + ).__TAURI_INTERNALS__.invoke("set_canvas", { + channelId, + content: "# Kickoff\n\nconcurrent edit", + expectedRevision: null, + }); + }, GENERAL_CHANNEL_ID); + await section.getByTestId("channel-canvas-editor").fill("# Kickoff\n\nmine"); + await section.getByTestId("channel-canvas-save").click(); + await expect(section).toContainText( + "This canvas changed since you loaded it", + ); + + // Cancel, reload the head (reopen the sheet → fresh get_canvas), and restore + // the oldest revision — restore must publish a new head (not mutate history), + // so the count grows. + await section.getByTestId("channel-canvas-cancel").click(); + await closeChannelManagement(page); + await openChannelManagement(page, "general"); + await page.getByTestId("channel-canvas-ingress").click(); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "concurrent edit", + ); + + await section.getByTestId("channel-canvas-history-toggle").click(); + const items = section.getByTestId("channel-canvas-history-item"); + await expect(items).toHaveCount(3); + await items.last().getByRole("button").first().click(); + await section.getByTestId("channel-canvas-restore").click(); + // Restore now requires explicit confirmation before it mutates the shared + // canvas; the dialog identifies the target revision. + await page.getByTestId("channel-canvas-restore-confirm-action").click(); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "first draft", + ); + await expect(section.getByTestId("channel-canvas-history-item")).toHaveCount( + 4, + ); +}); + test("channel settings hides workflows and skips its query when the experiment is disabled", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 87fe86b5b23..98d16e5745f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -288,6 +288,13 @@ type MockBridgeOptions = { deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; + /** Seed canvas revisions (oldest first); see e2eBridge mock config. */ + canvasRevisions?: Array<{ + content: string; + createdAt?: number; + eventId?: string; + author?: string; + }>; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; /** Reject `clear_pending_navigation_deep_links` with this message. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8476d3dd321..3847cdc5e7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,9 +180,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - date-fns: - specifier: ^4.4.0 - version: 4.4.0 + diff: + specifier: ^8.0.4 + version: 8.0.4 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.8) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f4a06635806..e8511828e6f 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -90,11 +90,19 @@ run_unit_tests() { run_test_step "buzz-cli tests" \ cargo test -p buzz-cli -- --nocapture + # buzz-sdk builder/validation unit tests: pure event-builder and input + # validation, no infra. Mirrors the nextest path in `just test-unit` — the + # two lists must stay in step. `--lib` matches the nextest invocation and + # avoids the full-package rustdoc dependency-resolution flake. + run_test_step "buzz-sdk unit tests" \ + cargo test -p buzz-sdk --lib -- --nocapture + # Keep the relay-to-agent trust-boundary regressions in the fallback path # when cargo-nextest is unavailable. run_test_step "buzz-acp tests" \ cargo test -p buzz-acp -- --nocapture + # buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator # invariant (exactly the consolidated 0001; cutover/backfill stays an operator # script, not startup state) and the tenant-scoping lints. The Postgres-backed