fix: address remaining review comments on dictation PR

1. P1 — Require relay membership for billable /transcribe/session even on
   open relays. Added require_relay_member() that always checks actual
   membership (with NIP-OA fallback) regardless of the
   BUZZ_REQUIRE_RELAY_MEMBERSHIP setting. Prevents arbitrary NIP-98 signers
   from minting metered OpenAI sessions on the operator's bill.

2. P2 — Keep stop control usable while recording. DictationButton now
   allows the stop action even when the composer is disabled — only
   *starting* a new recording is blocked. Additionally, useComposerDictation
   auto-stops the active session when the composer becomes disabled
   mid-recording (e.g. channel becomes read-only).

3. P2 — .expect() already removed in prior commit (openai_client() returns
   Result and propagates errors). No additional change needed.

4. P2 — Stop dictation before edit saves already addressed in prior commit
   (stopDictationRef.current() at line 540). No additional change needed.
This commit is contained in:
klopez4212
2026-07-11 16:18:36 +01:00
parent 1815e02931
commit 577b7bb434
4 changed files with 100 additions and 5 deletions
+80 -3
View File
@@ -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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<TranscribeSession>, (StatusCode, Json<Value>)> {
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<Value>)> {
// 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());
@@ -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<boolean>;
isSendingRef: React.MutableRefObject<boolean>;
isUploadingRef: React.MutableRefObject<boolean>;
@@ -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;
}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
@@ -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"
@@ -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,