mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user