diff --git a/crates/buzz-relay/src/api/transcribe.rs b/crates/buzz-relay/src/api/transcribe.rs index 1e3ff7694..a7f1746da 100644 --- a/crates/buzz-relay/src/api/transcribe.rs +++ b/crates/buzz-relay/src/api/transcribe.rs @@ -62,15 +62,20 @@ pub async fn transcribe_status( /// `POST /transcribe/session` — create an ephemeral OpenAI Realtime session. /// -/// Requires NIP-98 auth. Returns a short-lived client secret that the frontend -/// uses to establish a WebRTC connection directly with OpenAI for real-time -/// transcription. +/// Requires NIP-98 auth **and** relay membership (even on open relays). +/// Each session mints a metered OpenAI Realtime connection on the relay +/// operator's bill, so we require the caller to be an actual relay member +/// regardless of `BUZZ_REQUIRE_RELAY_MEMBERSHIP`. pub async fn create_transcribe_session( State(state): State>, headers: HeaderMap, ) -> Result, (StatusCode, Json)> { let (pubkey, community) = authenticate(&state, &headers, "/transcribe/session", "POST").await?; + // Hard membership gate — billable endpoint requires actual relay membership + // even on open relays where `enforce_relay_membership` would short-circuit. + require_relay_member(&state, &headers, &pubkey).await?; + // Per-(community, pubkey) rate limit — each session mints a metered OpenAI // Realtime connection on the operator's bill. if transcribe_rate_limited(&state, community, &pubkey) { @@ -202,6 +207,78 @@ async fn authenticate( Ok((pubkey, tenant.community())) } +/// Hard relay-membership check for billable endpoints. +/// +/// Unlike `enforce_relay_membership` (which short-circuits on open relays), +/// this always verifies that the pubkey is an actual relay member or is +/// delegated via NIP-OA by a member. This prevents arbitrary NIP-98 signers +/// from minting metered sessions on the operator's bill. +async fn require_relay_member( + state: &AppState, + headers: &HeaderMap, + pubkey: &nostr::PublicKey, +) -> Result<(), (StatusCode, Json)> { + // If the relay already requires membership globally, the `authenticate` + // call above handled it — no need to double-check. + if state.config.require_relay_membership { + return Ok(()); + } + + // On open relays we still require membership for this billable endpoint. + let raw_host = headers + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let pubkey_hex = pubkey.to_hex(); + let is_member = state + .db + .is_relay_member(tenant.community(), &pubkey_hex) + .await + .map_err(|e| { + tracing::error!("transcribe membership check failed: {e}"); + api_error(StatusCode::INTERNAL_SERVER_ERROR, "membership check failed") + })?; + + if is_member { + return Ok(()); + } + + // NIP-OA fallback: check if the agent's owner is a member. + if state.config.allow_nip_oa_auth { + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + if let Some(owner) = + super::relay_members::extract_nip_oa_owner(&pubkey.to_bytes(), auth_tag) + { + let owner_hex = owner.to_hex(); + let owner_is_member = state + .db + .is_relay_member(tenant.community(), &owner_hex) + .await + .map_err(|e| { + tracing::error!("transcribe owner membership check failed: {e}"); + api_error(StatusCode::INTERNAL_SERVER_ERROR, "membership check failed") + })?; + if owner_is_member { + return Ok(()); + } + } + } + + Err(api_error( + StatusCode::FORBIDDEN, + "relay membership required for transcription sessions", + )) +} + /// Per-(community, pubkey) sliding-window rate limiter for transcription session minting. fn transcribe_rate_limited(state: &AppState, community: CommunityId, pubkey: &nostr::PublicKey) -> bool { let key = (community, pubkey.to_bytes()); diff --git a/desktop/src/features/dictation/hooks/useComposerDictation.ts b/desktop/src/features/dictation/hooks/useComposerDictation.ts index b945e68a3..fec03c71c 100644 --- a/desktop/src/features/dictation/hooks/useComposerDictation.ts +++ b/desktop/src/features/dictation/hooks/useComposerDictation.ts @@ -5,6 +5,8 @@ import { useDictation } from "./useDictation"; interface UseComposerDictationOptions { /** Ref to a function that syncs contentRef from the Tiptap editor and returns it. */ syncContentRef: React.MutableRefObject<() => string>; + /** Whether the composer is currently disabled (read-only, etc.). */ + disabled?: boolean; disabledRef: React.MutableRefObject; isSendingRef: React.MutableRefObject; isUploadingRef: React.MutableRefObject; @@ -23,6 +25,7 @@ interface UseComposerDictationOptions { */ export function useComposerDictation({ syncContentRef, + disabled = false, disabledRef, isSendingRef, isUploadingRef, @@ -59,5 +62,14 @@ export function useComposerDictation({ dictation.stopRecording(); }, [draftKey]); + // Auto-stop dictation when the composer becomes disabled mid-recording + // (e.g. channel becomes read-only, parent send state disables thread composer). + // Without this, the WebRTC session keeps running with no way to stop it. + useEffect(() => { + if (disabled && dictation.isRecording) { + dictation.stopRecording(); + } + }, [disabled, dictation.isRecording, dictation.stopRecording]); + return dictation; } diff --git a/desktop/src/features/dictation/ui/DictationButton.tsx b/desktop/src/features/dictation/ui/DictationButton.tsx index dabefa4a3..a1151bdc1 100644 --- a/desktop/src/features/dictation/ui/DictationButton.tsx +++ b/desktop/src/features/dictation/ui/DictationButton.tsx @@ -28,6 +28,12 @@ export function DictationButton({ ? "Transcribing…" : "Dictate message"; + // Allow the stop action even when the composer is disabled — the user must + // always be able to stop an active recording session. Only block *starting* + // a new recording when disabled. + const isDisabled = + dictation.isStarting || (disabled && !dictation.isRecording); + return ( @@ -40,7 +46,7 @@ export function DictationButton({ "bg-destructive text-destructive-foreground hover:bg-destructive/90 hover:text-destructive-foreground active:bg-destructive active:text-destructive-foreground", dictation.isTranscribing && "animate-pulse", )} - disabled={disabled || dictation.isStarting} + disabled={isDisabled} onClick={dictation.toggleRecording} size="icon" type="button" diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 99f4ce644..2e18c13d4 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -258,7 +258,6 @@ function MessageComposerImpl({ onEditSaveRef.current = onEditSave; onEditLastOwnMessageRef.current = onEditLastOwnMessage; editTargetRef.current = editTarget; - const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = mentions.isMentionOpen || @@ -270,6 +269,7 @@ function MessageComposerImpl({ const stopDictationRef = React.useRef<() => void>(() => {}); const dictation = useComposerDictation({ syncContentRef: syncContentRefFromEditorRef, + disabled, disabledRef, isSendingRef, isUploadingRef,