Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,34 +462,41 @@ new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyph
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
script.

### Workspace Switching
### Community Switching

The desktop app supports multiple workspaces (each backed by a different relay).
Switching workspaces does **not** reload the page — it uses React key-based
remounting. `<AppReady key={workspaceKey} />` in `App.tsx` forces the entire
workspace-scoped subtree to unmount and remount with fresh state.
The desktop app supports multiple communities (each backed by a different relay).
Switching communities does **not** reload the page — it uses React key-based
remounting. `<AppReady key={communityKey} />` in `App.tsx` forces the entire
community-scoped subtree to unmount and remount with fresh state.

**Module-level singletons must be explicitly reset.** React remounting only
clears React state (useState, useRef, context). Module-level variables (Maps,
class instances, cached promises) survive across remounts. Every workspace-scoped
singleton needs a reset function wired into `resetWorkspaceState()` in
`desktop/src/features/workspaces/useWorkspaceInit.ts`.
class instances, cached promises) survive across remounts. Every community-scoped
singleton needs a reset function wired into `resetCommunityState()` in
`desktop/src/features/communities/useCommunityInit.ts`.

Current singletons that are reset on workspace switch:
Current singletons that are reset on community switch:
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
- `resetRateLimitGate()` — clears any active rate-limit window from the old relay
- `clearAllDrafts()` — message draft cache
- `resetAgentObserverStore()` — agent observer relay store
- `resetActiveAgentTurnsStore()` — active agent turn timers
- `resetAgentWorkingSignal()` — agent working indicator signal
- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
- `resetMediaCaches()` — proxy port and relay origin caches
- `resetVideoPlayerState()` — video player singleton
- `resetRenderScopedReactionHydration()` — reaction hydration cache
- `clearSearchHitEventCache()` — search result event cache
- `clearAllDrafts()` — message draft cache
- `clearMarkdownNodeCache()` — markdown parse-node cache

**If you add a new module-level cache, Map, or class instance that holds
workspace-scoped data, you must add its reset to `resetWorkspaceState()`.**
Failure to do so causes data from the old workspace to leak into the new one.
community-scoped data, you must add its reset to `resetCommunityState()`.**
Failure to do so causes data from the old community to leak into the new one.

Key files:
- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary
- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend
- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell)
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`)
- `desktop/src/app/App.tsx` — community key, init gate, remount boundary
- `desktop/src/features/communities/useCommunityInit.ts` — `resetCommunityState()`, applies config to Tauri backend
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `App`)

---

Expand Down
11 changes: 10 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,16 @@ const overrides = new Map([
// migration/materialize.rs; ratchet held at 1110.
["src-tauri/src/migration_tests.rs", 1110],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
// degraded-network resilience: relay.rs grew past 1000 with the addition of
// relay_error_message hint-capping (oversized-hint test via loopback TCP) and
// the relay_admission freshness-verification test. The loopback mock was
// hardened (std::net + request-read-before-write) adding ~10 lines.
// Queued to split test helpers to relay/tests.rs.
["src-tauri/src/relay.rs", 1047],
// degraded-network resilience: visibleChannelId field + getter/setter, NOTICE
// handler for relay back-pressure, and rate-limit gate imports add ~74 lines
// of load-bearing degraded-network recovery code. Queued to split.
["src/shared/api/relayClientSession.ts", 1096],
// Boot-time event sync (persona/team/agent event reconcile) was split out
// to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is
// the pre-identity data migrations; still queued to split further.
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,6 @@ tempfile = "3"
strip-ansi-escapes = "0.2"

[dev-dependencies]
# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
# admission gate tests can assert exact wait durations without real sleeps.
tokio = { version = "1", features = ["test-util"] }
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/commands/personas/snapshot/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,10 @@ async fn submit_engram_event(
use crate::relay::build_nip98_auth_header_for_keys;
use reqwest::Method;

// Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
// gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
// wait produces a stale `created_at` that the relay will reject.
crate::relay_admission::wait_for_rate_limit().await;
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
let mut request = state
.http_client
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,10 @@ async fn submit_engram_event(
use crate::relay::build_nip98_auth_header_for_keys;
use reqwest::Method;

// Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
// gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
// wait produces a stale `created_at` that the relay will reject.
crate::relay_admission::wait_for_rate_limit().await;
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
let mut request = state
.http_client
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ pub async fn apply_workspace(
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
*override_guard = Some(relay_url);
}
// Reset the Rust-side admission gate when switching workspace/community,
// matching `resetRateLimitGate()` on the TS side (useCommunityInit.ts:38).
crate::relay_admission::reset_gate_for_workspace_change();

if let Some(keys) = parsed_keys {
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
Expand Down
29 changes: 18 additions & 11 deletions desktop/src-tauri/src/huddle/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ pub(crate) fn spawn_transcription_task(
continue;
}
};
// Wait before signing: the relay enforces NIP-98 freshness (±60s)
// and the gate may hold for up to MAX_HINT_SECONDS (300s). Sign
// the kind event and build NIP-98 auth after the wait so both
// timestamps are fresh — single clean order: wait → sign → auth → send.
crate::relay_admission::wait_for_rate_limit().await;
let event = match builder.sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => {
Expand All @@ -327,21 +332,23 @@ pub(crate) fn spawn_transcription_task(
}
};

let response = http_client
.post(&url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await;
let response = {
http_client
.post(&url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await
};

match response {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
eprintln!(
"buzz-desktop: STT kind:9 post failed: HTTP {}",
resp.status()
);
// Route through relay_error_message so a 429 arms the
// admission gate for subsequent relay sends.
let msg = crate::relay::relay_error_message(resp).await;
eprintln!("buzz-desktop: STT kind:9 post failed: {msg}");
}
Err(e) => {
eprintln!("buzz-desktop: STT kind:9 post failed: {e}");
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod nostr_convert;
mod prevent_sleep;
mod ptt_shortcut;
mod relay;
mod relay_admission;
mod reset;
mod secret_store;
mod shutdown;
Expand Down
135 changes: 134 additions & 1 deletion desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ pub(crate) async fn parse_json_response<T: DeserializeOwned>(
.map_err(|_| MALFORMED_RESPONSE_MESSAGE.to_string())
}

/// Extract the `retry in Ns` hint from a rate-limit error string.
///
/// Matches the canonical format emitted by the relay in both HTTP 429 bodies
/// and CLOSED/NOTICE messages: `quota exceeded; retry in 4s`.
fn extract_retry_in_hint(body: &str) -> Option<u64> {
let re_match = body.find("retry in ")?;
let after = &body[re_match + "retry in ".len()..];
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse::<u64>().ok()
}

pub async fn relay_error_message(response: reqwest::Response) -> String {
let status = response.status();

Expand All @@ -250,6 +261,27 @@ pub async fn relay_error_message(response: reqwest::Response) -> String {
// Real relay error: extract the structured message field if available.
let body = response.text().await.unwrap_or_default();

// 429 Too Many Requests → typed `relay rate-limited:` prefix so the TS
// client can activate the rate-limit gate without confusing it with a
// connectivity failure (`relay unreachable:`). Also arm the Rust-side
// admission gate here — the one place every relay HTTP error funnels
// through — so the next relay-backed command waits out the quota window
// instead of burning it (see `relay_admission`).
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let hint = extract_retry_in_hint(&body);
// Clamp the hint to MAX_HINT_SECONDS before arming the Rust gate AND
// before embedding it in the returned string. Every consumer (Rust gate
// via `activate_rate_limit` and TS gate via `applyTauriRateLimitIfNeeded`)
// must see the same capped value — a single policy point prevents the TS
// gate from receiving an uncapped hint from an untrusted relay.
let capped_hint = hint.map(|s| s.min(crate::relay_admission::MAX_HINT_SECONDS));
crate::relay_admission::activate_rate_limit(capped_hint);
if let Some(secs) = capped_hint {
return format!("relay rate-limited: retry in {secs}s");
}
return "relay rate-limited: quota exceeded".to_string();
}

if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) {
return format!("relay returned {status}: {message}");
Expand Down Expand Up @@ -286,6 +318,7 @@ pub async fn query_relay_at(
api_base_url: &str,
filters: &[serde_json::Value],
) -> Result<Vec<nostr::Event>, String> {
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/query", api_base_url);
let body_bytes =
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
Expand Down Expand Up @@ -382,6 +415,7 @@ pub async fn sync_managed_agent_profile(
avatar_url: Option<&str>,
auth_tag: Option<&str>, // NIP-OA auth tag JSON
) -> Result<(), String> {
crate::relay_admission::wait_for_rate_limit().await;
// Build a signed kind:0 profile event (with optional NIP-OA auth tag).
let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?;
let event_json = event.as_json();
Expand Down Expand Up @@ -482,6 +516,7 @@ pub async fn submit_event(
builder: nostr::EventBuilder,
state: &AppState,
) -> Result<SubmitEventResponse, String> {
crate::relay_admission::wait_for_rate_limit().await;
// All synchronous work (signing) must complete before any .await
// so the MutexGuard is dropped and the future remains Send.
let url = format!("{}/events", relay_api_base_url_with_override(state));
Expand Down Expand Up @@ -529,6 +564,7 @@ pub async fn submit_signed_event(
event: &nostr::Event,
state: &AppState,
) -> Result<SubmitEventResponse, String> {
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
let auth_header = {
Expand Down Expand Up @@ -586,6 +622,7 @@ pub async fn submit_signed_event_with_keys(
if event.pubkey != keys.public_key() {
return Err("signed event does not match the publishing identity".to_string());
}
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;
Expand Down Expand Up @@ -624,10 +661,106 @@ pub async fn submit_signed_event_with_keys(
mod tests {
use super::{
build_profile_event, classify_intercepted_response, effective_agent_relay_url,
parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE,
extract_retry_in_hint, parse_command_response, relay_http_base_url,
MALFORMED_RESPONSE_MESSAGE,
};
use serde::Deserialize;

// ── extract_retry_in_hint ────────────────────────────────────────────────

#[test]
fn extracts_hint_from_429_body() {
assert_eq!(
extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#),
Some(4)
);
}

#[test]
fn extracts_hint_when_no_json_wrapper() {
assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30));
}

#[test]
fn returns_none_when_no_hint_present() {
assert_eq!(
extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#),
None
);
assert_eq!(extract_retry_in_hint(""), None);
}

#[test]
fn overlong_digit_string_returns_none() {
// A digit sequence that exceeds u64::MAX cannot be parsed; the function
// must return None (→ caller uses the default) rather than panicking.
assert_eq!(
extract_retry_in_hint("retry in 99999999999999999999999s"),
None
);
}

// ── relay_error_message: hint capping ────────────────────────────────────
//
// Verify that an oversized relay hint is capped in the returned message
// string, not just inside `activate_rate_limit()`. This guarantees every
// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` —
// receives the capped value rather than the raw untrusted relay value.

#[tokio::test]
async fn oversized_hint_is_capped_in_relay_error_message_string() {
use crate::relay_admission::MAX_HINT_SECONDS;
use std::io::{Read as _, Write as _};

// Use a std::net listener on a std::thread — the same pattern as the
// relay_admission loopback tests. This avoids two races that cause CI
// failures with tokio::net + into_std():
// 1. No request read: the client is still sending when the response
// arrives → hyper `UnexpectedMessage`/`Canceled` under load.
// 2. into_std() leaves the socket in nonblocking mode → write_all
// may return WouldBlock and silently drop the response.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();

// Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300).
let oversized = 1_000_000u64;
let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#);
let body_len = body.len();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
// Read the request first so the client finishes sending before
// we write the response — mirrors relay_admission.rs pattern.
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}"
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});

let client = reqwest::Client::new();
let response = client
.get(format!("http://{addr}/"))
.send()
.await
.expect("request must succeed");

let msg = super::relay_error_message(response).await;

// The message must embed the CAPPED hint, not the raw 1 000 000.
assert_eq!(
msg,
format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"),
"relay_error_message must embed the capped hint, not the raw untrusted value"
);
assert!(
!msg.contains(&oversized.to_string()),
"raw oversized hint must not appear in the message string"
);
}

// ── effective_agent_relay_url: per-agent override precedence ─────────────

#[test]
Expand Down
Loading
Loading