diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20a0fb65..2c5731d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,6 +365,19 @@ jobs: echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s" sleep $((i * 30)) done + # Chocolatey is one distribution channel, not the dependency. When + # its feed is down across every retry (2026-08-13: three attempts, + # three 'installed 0/1'), fall back to the static gyan.dev release + # build GitHub mirror — the same binary, no feed in the path. + if ! command -v ffmpeg >/dev/null 2>&1; then + echo "::warning::choco feed down — falling back to static ffmpeg build" + curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \ + https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip + unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg + bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)") + echo "$bindir" >> "$GITHUB_PATH" + export PATH="$bindir:$PATH" + fi ffmpeg -version - name: System deps (Linux) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1faac047..ff026575 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -731,6 +731,62 @@ jobs: find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing" echo "OK — MSI installed shell + uv + backend resources" + # linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build + # machine AFTER tauri's files-map has placed the real icon bytes — the + # exact bug #1518 guarded against, resurfacing on the first real tag + # build (v0.5.0). The seam tauri-action leaves us is post-upload: repack + # the AppImage with the icon as a REGULAR FILE, re-sign it (the updater + # signature covered the old bytes), and clobber the draft release's + # asset + the linux signature inside latest.json. The smoke below then + # validates the repaired artifact, not the broken one. + - name: Repair AppImage .DirIcon, re-sign, re-upload + if: runner.os == 'Linux' + timeout-minutes: 10 + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # Data, not shell source (zizmor template-injection): a crafted ref + # must never expand inside a script that holds the signing key. + TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} + run: | + set -euo pipefail + APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1) + APPIMAGE=$(realpath "$APPIMAGE") + WORK="$(mktemp -d)"; cd "$WORK" + "$APPIMAGE" --appimage-extract >/dev/null + ROOT="$WORK/squashfs-root" + ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true) + if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then + echo ".DirIcon already resolves inside the bundle — no repair needed" + exit 0 + fi + # The real bytes are at the AppDir root (linuxdeploy put them there + # before mislinking). Ship a regular file: nothing left to dangle. + SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1) + [ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1) + [ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; } + rm -f "$ROOT/.DirIcon" + cp "$SRC" "$ROOT/.DirIcon" + # Pinned immutable release + checksum: this binary runs with the + # updater signing key and a release-write token in its environment, + # so a mutable 'continuous' asset is not acceptable supply chain. + AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage" + AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0" + curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL" + echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; } + chmod +x "$WORK/appimagetool" + # Same FUSE-less trick the build itself uses. + APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE" + cd "$GITHUB_WORKSPACE/frontend" + bunx tauri signer sign "$APPIMAGE" + gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY" + # latest.json is NOT patched here: every tauri-action leg re-uploads + # the shared manifest, so an in-leg patch races the other platforms — + # the repair-updater-manifest job below is the single final writer. + echo "repacked, re-signed, re-uploaded" + - name: Installer smoke (Linux) if: runner.os == 'Linux' timeout-minutes: 5 @@ -844,6 +900,50 @@ jobs: # the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs: # [build]` guarantees the release already exists; `--clobber` makes a re-run # idempotent. This can never create a second release. + # The Linux leg may repack + re-sign its AppImage (see the repair step in + # the build matrix); every tauri-action leg also re-uploads the SHARED + # latest.json, so patching the manifest inside any leg races the others. + # This job runs once after the whole matrix as the single final writer: + # it makes the manifest's linux signature agree with the .sig asset that + # actually shipped, and refuses to leave a mismatch behind. + repair-updater-manifest: + needs: [build, preview-gate] + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Data, not shell source — same zizmor rule as the leg step. + TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} + steps: + - name: Align latest.json's linux signature with the shipped .sig asset + shell: bash + run: | + set -euo pipefail + WORK="$(mktemp -d)" + HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])') + if [ "$HAS_MANIFEST" != "true" ]; then + echo "no latest.json on the release — nothing to align"; exit 0 + fi + gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY" + # Same fail-closed rule as the manifest: absence is checked against + # the asset LIST; an actual download failure must fail the job, or + # the manifest keeps a signature nobody shipped. + HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0') + if [ "$HAS_SIG" != "true" ]; then + echo "no AppImage .sig asset on the release — nothing to align"; exit 0 + fi + gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY" + SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1) + [ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; } + NEW_SIG=$(cat "$SIG_FILE") + CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG") + if [ "$CHANGED" -ge 1 ]; then + gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY" + echo "aligned $CHANGED linux signature(s) with the shipped .sig" + else + echo "manifest already agrees with the shipped .sig — no write" + fi + uninstall-scripts: needs: [build] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') diff --git a/CHANGELOG.md b/CHANGELOG.md index c62c794c..6d27192a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,61 +6,38 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/). `frontend/package.json` is the app-version source of truth; Cargo, Python, and the frozen-backend fallback mirror it for their toolchains. -## [Unreleased] +## [0.5.0] — 2026-08-13 **Highlights** -- Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) -- The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) -- A faster, cleaner Dub workspace for multilingual production (#1489) -- VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity -- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter -- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures -- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local -- RTX 40-series GPUs are used again instead of being sent to the CPU -- A warning before a slow generation, rather than after a five-minute wait -- The watermark can be turned off in Settings, as the docs always said -- Your other GPU can take the work now — send individual jobs to a second machine, opt-in -- More than one person can share one GPU machine, without shell access to it or taking turns -- A Model Catalogue workspace: every engine and model in one place, with the defaults set there -- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412) -- macOS support now matches what the app actually delivers -- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally -- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436) -- A failed audiobook chapter says why, instead of turning red and saying nothing +- The app is now **VoiceStudio** (previously OmniVoice-Studio) — one waveform-and-spark identity across the app, docs and installers. Your data folder, settings and Docker image paths stay put. +- **Model Catalogue** — engines and models in one workspace: every TTS, transcription and LLM engine with its device routing and install state, defaults picked there. +- Switch TTS, ASR and LLM engines from the status bar or any workspace — ready-only choices, memory status, environment-pin protection, `Ctrl/Cmd+E`. (#1530) +- Lend another machine's GPU with a join code and a QR scan — a Compute control in the status bar picks where jobs run, and several people can share one GPU box with revocable, certificate-pinned connections. (#1516, #1496) +- Server mode is locked down: admin actions require an API key (#1525), and the remote UI exchanges it for short-lived sessions that never sit in browser storage or WebSocket URLs (#1528) — thanks @bultodepapas! +- A faster, cleaner Dub workspace for multilingual production, with a production command bar and per-language cards. (#1489) +- The demo audio and video the app always advertised now actually ship, rendered by VoiceStudio's own engine. (#1517) +- Dictation works on Wayland now — the portal shortcut actually fires (#1490, #1526) — and the recording pill is back on every desktop. +- The Launchpad wears the project's signal-field waveform artwork over a quieter, borderless layout. (#1533) +- The catalogue reads as headroom, not breakage: available engines sort first, uninstalled ones say what they need (#1531), and the LLM row names the provider that actually answers (#1538). +- Gallery voices can be saved as local profiles — audio lands in your profile store with validated, content-addressed references. (#1542) -### Fixed +Switching TTS engines from the status bar -- A development UI running on port 3000 no longer mistakes itself for the backend when `VITE_API_PORT` has the same value; requests recover to the local backend on port 3900. (no issue) -- Text in the desktop header and log panel can be selected and copied again, and right-click now opens the platform context menu. (no issue) -- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526) -- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520! -- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518) -- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518) - -### Added - -- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517) -- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517) - -### Added - -- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516) -- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516) -- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516) -- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516) -- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517) -- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517) +| The Model Catalogue | The Voice Gallery | +| --- | --- | +| Model Catalogue — engines pane | Voice Gallery — save a voice as a profile | ### Changed +- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542) +- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530) - Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522) - Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522) - Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522) - Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516) - The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516) - Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred. -- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516) - Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror. - The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls. - The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists. @@ -80,6 +57,13 @@ the frozen-backend fallback mirror it for their toolchains. ### Added +- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542) +- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517) +- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517) +- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516) +- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516) +- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516) +- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516) - **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories. - Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496) - Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478) @@ -93,14 +77,22 @@ the frozen-backend fallback mirror it for their toolchains. - Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308) - A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304) - **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328) +- A warning before a slow generation, rather than after a five-minute wait. (#1280) -### CI +### Docs -- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408) +- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306) +- macOS install notes and the README support table now state the real floor (#1268) +- Contact: the project X account is listed alongside Discord (#1313) +- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348) ### Fixed -- The Linux app icon is no longer blank: the AppImage shipped `.DirIcon` as a symlink into the machine that built it, so file managers and app menus drew nothing. (#1518) +- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529) +- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510) +- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526) +- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520! +- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518) - The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518) - Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490) - The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514) @@ -214,16 +206,14 @@ the frozen-backend fallback mirror it for their toolchains. - Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332) - Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330) - Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331) - -### Docs - -- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306) -- macOS install notes and the README support table now state the real floor (#1268) -- Contact: the project X account is listed alongside Discord (#1313) -- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348) +- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289) +- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436) +- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325) ### CI +- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542) +- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408) - Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290) - Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269) - The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds. diff --git a/README.md b/README.md index 37c4daba..eee9f5eb 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,38 @@ > [!WARNING] > **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9). + + +## 🆕 What's new in 0.5.0 + +The rename release — full notes: [v0.5.0 release](https://github.com/debpalash/VoiceStudio/releases/tag/v0.5.0) · [CHANGELOG](CHANGELOG.md). + +- 🏷️ **A new name** — VoiceStudio (previously OmniVoice-Studio): one waveform-and-spark identity across app, docs, and installers. Your data folder, settings, and Docker image paths stay put. +- 📚 **Model Catalogue** — engines and models in one workspace: every TTS, ASR, and LLM engine with its device routing and install state; pick defaults, install or remove weights. +- ⚡ **Engine quick-switch** — change TTS/ASR/LLM engines from the status bar or anywhere with Ctrl/Cmd+E — ready-only choices, memory status, environment-pin protection. +- 🖧 **Remote GPU workers** — lend another machine's GPU with a join code and a QR scan; a **Compute** control picks where jobs run, and several people can share one GPU box over revocable, certificate-pinned connections. +- 🔐 **Hardened server mode** — admin actions require an API key, exchanged for short-lived scoped sessions that never sit in browser storage or WebSocket URLs. +- 💾 **Gallery voices → local profiles** — save any gallery voice as a profile of your own and use it in every picker. +- 🎤 **Dictation on Wayland** — the portal shortcut actually fires now, and the recording pill is back on every desktop. + +
+ Switching engines from the status bar +
Engine quick-switch from the status bar — Ctrl/Cmd+E from any workspace +
+ +
+ + + + + + + + + + +
Model Catalogue — engines paneSaving a gallery voice as a profile
Model Catalogue — every engine, its routing and install stateGallery → profile — keep a gallery voice as your own
+ ## ✨ Features @@ -83,24 +115,21 @@ Three flagships, five more headliners, and a dozen under the fold.
-…and 12 more — isolation, diarization, batch, watermarking, diagnostics, and friends +…and 12 more — catalogue, remote GPUs, isolation, diarization, batch, watermarking, and friends
+- 📚 **Model Catalogue** — one workspace for every TTS/ASR/LLM engine and model: defaults, device routing, install or remove weights — and quick-switch engines from anywhere with Ctrl/Cmd+E. +- 🖧 **Remote GPU workers** — send jobs to GPUs on your other machines: join code + QR enrolment, Remote Model Downloads with per-worker live progress, chapter-by-chapter audiobook rendering with local fallback. Off by default; see [docs/remote-workers.md](docs/remote-workers.md). - 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed. - 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what. - 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars. - 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression. - 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles. -- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads. -- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress. -- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback. -- 📚 **Model Catalogue** — one workspace listing every TTS/ASR/LLM engine and model: set the defaults, install or remove weights. +- ⚡ **GPU Auto-Detect & Routing** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads; per-engine GPU preflight, no silent CPU fallback. - 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines. - 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark. - ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket. -- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth. -- ☁️ **Hosted Voice opt-in** — consent-backed profile sync and durable hosted Jobs; local synthesis remains the default. See [Hosted Voice](docs/hosted-voice.md). - 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
@@ -152,22 +181,15 @@ Cloud voice tools are convenient, but they put your workflow behind an account, | **Video Dubbing** | ✅ Cloud-only | ✅ Fully local | | **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins | | **API Keys** | Account required | Not needed for the local workflow | -| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU | +| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU — plus your other machines' GPUs as [remote workers](docs/remote-workers.md) | | **Desktop App** | ❌ | ✅ macOS · Windows · Linux | -| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) | +| **TTS Engines** | 1 | **16** — [full matrix](#tts-engines) | | **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) | | **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client | | **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles | | **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it | -Professional-grade voice AI, minus the subscription and the cloud. - -
-
- Convinced? Come build with us.
- Join Discord -

-
+Professional-grade voice AI, minus the subscription and the cloud. Convinced? [Come build with us.](https://discord.gg/bzQavDfVV9) --- @@ -189,10 +211,10 @@ Professional-grade voice AI, minus the subscription and the cloud. ### 🗣️ TTS Engines -**14 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens. +**16 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus eight lazy-installed opt-ins (IndexTTS 2.5, OmniVoice GGUF, OmniVoice subprocess, PocketTTS, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Model Catalogue → Engines** — or from anywhere with Ctrl/Cmd+E; the choice applies everywhere synthesis happens.
-📊 The full matrix — 14 engines × platform × clone/instruct × license +📊 The full matrix — 16 engines × platform × clone/instruct × license
@@ -208,6 +230,8 @@ Professional-grade voice AI, minus the subscription and the cloud. | **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 | | **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili model license¹ | | **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in | +| **OmniVoice (subprocess)** ⚡² | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in | +| **PocketTTS** ⚡ (Kyutai) | EN · FR · DE · PT · IT · ES | ✅ | — | ✅ CPU | ✅ CPU | ✅ CPU | CC-BY-4.0 (gated)³ | | **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M | | **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 | | **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 | @@ -218,12 +242,21 @@ monthly active users or RMB 1 billion in annual revenue. Review its [model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE) before enabling the optional sidecar. +² **OmniVoice (subprocess)** is the same resident model as the default engine, run +in a crash-isolated child process: a wedged generation can be hard-killed and its +VRAM reclaimed. Opt-in for unattended synthesis and VRAM-tight MPS hosts — +[docs/engines/omnivoice-subprocess.md](docs/engines/omnivoice-subprocess.md). + +³ **PocketTTS** (Kyutai) is a fast, low-latency CPU engine with zero-shot cloning; +its gated model access and CC-BY-4.0 conditions are shown for review in-app before +first use. + GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free `http://` or `https://` origin and add that machine's CIDR to `OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected. -> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use) +> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS, MOSS-TTS-Nano, and PocketTTS run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use) > > **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio. > @@ -250,17 +283,15 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free | **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) | | **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) | | **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) | -| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Model Catalogue → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. | +| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — word timestamps, ~2 GB unified memory, dictation-grade speed via MLX. Dictation prefers it automatically for its 25 European languages; other languages keep multilingual Whisper. | | **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX | | **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) | -| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. | -| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). | - -> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud. +| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models, CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. | +| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server), any OpenAI-compatible transcription endpoint, or OpenAI's own API — configure + test in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). | > If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready. - -> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend. +> +> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU) and restart the backend.
@@ -268,7 +299,7 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free ## 🏗️ Architecture -A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine. +A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Every layer runs on your machine by default; the only network paths are the ones you opt into (remote GPU workers, a remote backend, or an OpenAI-compatible ASR endpoint). ``` ┌────────────────────────────────────────────────────────────────────┐ @@ -278,28 +309,24 @@ A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/ │ first-run bootstrap (installs uv + Python venv) · blank guard │ ├────────────────────────────────────────────────────────────────────┤ │ Frontend — React + Vite │ -│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │ -│ Batch · Diagnostics · MCP client — Zustand store · WS bus │ +│ Studio · Dub · Stories · Audiobook · Gallery · Catalogue · │ +│ Dictation · Batch · Diagnostics — Zustand store · WS bus │ │ ▲ IPC / HTTP + WS │ ├──────────────────────────┼─────────────────────────────────────────┤ │ Backend — FastAPI sidecar @ localhost:3900 │ │ 100+ REST endpoints · SSE + WebSocket streaming · │ │ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │ ├───────────┬───────────┬───────────┬───────────┬────────────────────┤ -│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │ +│ TTS ×16 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │ │ clone / │ WhisperX │ vocal │ speaker │ watermark │ │ design │ +10 more │ isolation│ diariz. │ embed / detect │ ├───────────┴───────────┴───────────┴───────────┴────────────────────┤ │ Engine routing — per-engine GPU preflight, no silent CPU fallback │ │ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │ +│ + optional remote GPU workers on your other machines │ └────────────────────────────────────────────────────────────────────┘ ``` -- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv. -- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes. -- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface. -- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU. - ## 🔌 OpenAI-compatible API @@ -319,28 +346,17 @@ Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally | Endpoint | What it does | |---|---| -| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. | +| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. | | `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. | | `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. | -**Speak with your own cloned voice** — list the IDs, then pass one as `voice`: - -```sh -# 1 — find a cloned voice's profile ID -curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}' - -# 2 — synthesize with it -curl http://localhost:3900/v1/audio/speech \ - -H "Content-Type: application/json" \ - -d '{"model":"tts-1","voice":"","input":"Made on my own hardware.","response_format":"wav"}' \ - --output speech.wav -``` +**Speak with your own cloned voice:** ```python from openai import OpenAI client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it -# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine) +# Find your cloned voices: GET /v1/audio/voices lists profile IDs with client.audio.speech.with_streaming_response.create( model="tts-1", voice="", input="Made on my own hardware.") as r: r.stream_to_file("speech.wav") @@ -351,13 +367,13 @@ print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav" Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer. -Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption. +Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key, and admin actions require the key — exchanged for short-lived scoped sessions. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption. ### 📓 Run on Google Colab -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb) +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb) -No local GPU? The [official notebook](notebooks/VoiceStudio_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys. +No local GPU? The [official notebook](notebooks/OmniVoice_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface as a guided tour with inline playback. No tunnels, no API keys. ### 🤝 Agent Skills @@ -367,50 +383,15 @@ Teach your coding agent to speak and listen through your local VoiceStudio — o npx skills add debpalash/omnivoice-studio ``` -Ships two [skills](https://skills.sh): - -- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install. -- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent. +Ships two skills: **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline — and **`oss-maintainer`** — the maintainer methodology this project is run with. --- + + ## 🗺️ Roadmap -### 🔜 Up Next - -- 🎬 **Lip-sync v2** — visual speech timing with wav2lip -- 🌐 **Hosted Demo** — try VoiceStudio without installing anything -- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects -- 🎵 **Real-time Voice Changer** — live microphone transformation during calls - -
-✅ Everything shipped so far — the receipts, by category - -
- -| Category | Features | -|----------|----------| -| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody | -| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home | -| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace | -| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation | -| **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution | -| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment | -| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend | -| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight | -| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support | -| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API | -| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, screen-sized first-run UI scaling, and native WebKitGTK scaling | -| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect | -| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` | -| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix | -| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement | -| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking | -| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client | -| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) | -| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair | - -
+What's up next (lip-sync v2, hosted demo, plugin marketplace, real-time voice changer) and the full history of everything shipped so far live in **[docs/ROADMAP.md](docs/ROADMAP.md)**. --- @@ -430,10 +411,6 @@ One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in    PayPal -

- -Also from the maker: Opal 💠 · memxt 🧠 — a ⭐ helps too. - @@ -452,8 +429,6 @@ VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Spon -💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via .github/FUNDING.yml. - --- ## 💬 Community @@ -462,66 +437,21 @@ VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Spon Join Discord Follow on X
- We respond to setup questions within hours, not days. + Release news, setup help, GPU troubleshooting, feature votes, and showing off your dubs. We respond to setup questions within hours, not days. -
-What happens in there - -
- -| Channel | What happens there | -|---------|--------------------| -| `#announcements` | Release news and the big moments — new versions land here first | -| `#releases` + `#changelog` | Every build and exactly what's inside it | -| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues | -| `#ideas` | Feature requests, discussed and voted on | -| `#discuss-ideas` | Design talk before things get built | -| `#general` | Setup help, GPU troubleshooting, and showing off your dubs | - -
- --- ## 🤝 Contributing -Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it. - -- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow -- 🐛 Browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) -- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help -- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next +Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it. Start with the **[Contributing Guide](.github/CONTRIBUTING.md)** (setup, code style, PR workflow), browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue), or ask in [Discord](https://discord.gg/bzQavDfVV9). --- ## ❓ FAQ -
-Is this really as good as ElevenLabs? -
-Honest answer: it depends on what you're doing. - -Where VoiceStudio is genuinely competitive: voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation). - -Where ElevenLabs still wins: out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one). - -For dubbing specifically: a dub is a chain — transcription → translation → cloning → synthesis — only as good as its weakest link on your source material. If parts come out incoherent, check the segment table's original text first: when the transcription is already wrong, switch the ASR engine or use cleaner source audio — that's usually the fix, not the voice. - -Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us. -
- -
-Why doesn't a longer reference clip sound more like me? -
-Because VoiceStudio's cloning is zero-shot: your clip is a prompt the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (backend/services/speaker_clone.py), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs Professional Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt. - -What actually moves clone quality is the clip, not its length. Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 5–15 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage. - -Want audiobook-grade, trained-on-your-voice fidelity? That path exists, but it's offline fine-tuning, not an in-app button: prepare a dataset of your recordings (docs/data_preparation.md) and fine-tune the bundled checkpoint via init_from_checkpoint (docs/training.md). Fair warning — it's a technical, command-line workflow that needs a capable GPU and hours of transcribed audio. In-app fine-tuning / long-reference "professional" cloning is on the roadmap as research only; no promised date. -
-
Does it work on Apple Silicon (M1/M2/M3/M4)?
@@ -531,13 +461,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
How much VRAM do I need?
-4 GB minimum. With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS). -
- -
-Can I use this commercially? -
-Yes — commercial use is free under the AGPL-3.0: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you modify VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see License. +4 GB minimum. With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS). You can also lend a GPU from another machine you own via remote workers.
@@ -546,10 +470,22 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab 646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
+
+Why doesn't a longer reference clip sound more like me? +
+Because VoiceStudio's cloning is zero-shot: your clip is a prompt the model conditions on — it is never trained on, and past a short window extra audio is simply unused (the dubbing pipeline targets ~8 s and hard-caps at 15 s). What moves clone quality is the clip, not its length: record 5–15 seconds of continuous natural speech, close to the mic, in a quiet room with no reverb or music, one speaker, delivered in the tone and pace you want — the clone copies your delivery, not just your timbre. Want trained-on-your-voice fidelity? That's offline fine-tuning, not an in-app button: docs/data_preparation.md + docs/training.md. +
+ +
+Can I use this commercially? +
+Yes — commercial use is free under the AGPL-3.0: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you modify VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see License. +
+
Can I add my own TTS engine?
-Yes. Subclass TTSBackend in backend/services/tts_backend.py and add it to the _REGISTRY dictionary — ~50 lines. The fourteen built-in engines all work this way; see TTS Engines. +Yes. Subclass TTSBackend in backend/services/tts_backend.py and add it to the _REGISTRY dictionary — ~50 lines. The sixteen built-in engines all work this way; see TTS Engines and docs/engine-acceptance.md.
@@ -557,7 +493,7 @@ Yes. Subclass TTSBackend in backend/services/tts_backend.py Not unless you explicitly say yes. On first run the app asks — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way. -If you do opt in (also togglable anytime under Settings → Privacy → "Help improve VoiceStudio"), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character count, error type), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a bucketed uptime, never logs), error types (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (backend/core/analytics.py), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in Settings → Usage, computed locally, sent nowhere. +If you do opt in (also togglable anytime under Settings → Privacy → "Help improve VoiceStudio"), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character count, error type), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a bucketed uptime, never logs), error types (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (backend/core/analytics.py), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes. Your own numbers live in Settings → Usage, computed locally, sent nowhere.
@@ -584,55 +520,13 @@ The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [ ## 🙏 Acknowledgments -VoiceStudio is built on the shoulders of exceptional open-source work: - -| Project | Role | -|---------|------| -| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model | -| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment | -| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation | -| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what | -| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU | -| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance | -| [**Tauri**](https://tauri.app) | Native desktop app framework | -| [**Supertone / Supertonic 3**](https://huggingface.co/Supertone/supertonic-3) | ONNX TTS engine — 31 languages, CPU-efficient | -| [**Sherpa-ONNX**](https://github.com/k2-fsa/sherpa-onnx) | WASM-ready universal TTS/ASR runtime | -| [**GPT-SoVITS**](https://github.com/RVC-Boss/GPT-SoVITS) | Zero-shot TTS engine — 5 languages, RTF 0.014 | - ---- +VoiceStudio stands on exceptional open-source work: [OmniVoice (k2-fsa)](https://github.com/k2-fsa/OmniVoice) — the core zero-shot TTS model · [WhisperX](https://github.com/m-bain/whisperX) · [Demucs](https://github.com/facebookresearch/demucs) · [Pyannote](https://github.com/pyannote/pyannote-audio) · [CTranslate2](https://github.com/OpenNMT/CTranslate2) · [AudioSeal](https://github.com/facebookresearch/audioseal) · [Tauri](https://tauri.app) · [Supertonic](https://huggingface.co/Supertone/supertonic-3) · [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) · [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS) · [Kyutai PocketTTS](https://kyutai.org) — thank you. -## 🧰 More local open-source from the maker +### 🧰 More local open-source from the maker -Like the local-first philosophy? It runs in the family — same maker, same rule: **your data stays on your machine.** - - - - - - -
-
- Opal logo -

Opal 💠

-

Play everything. The media player for the AI era.

-

Video, anime, comics, torrents, Jellyfin & Plex — one player for all of it, with local AI memory and context built in. Written in Zig, runs on macOS & Windows.

-

- Opal stars - Opal website -

-
-
- memxt logo -

memxt 🧠

-

The fastest benchmarked open-source AI memory system.

-

Local long-term memory for Claude Code and coding agents — an MCP server on SQLite + embeddings, 100% on your machine. Your agent finally remembers yesterday.

-

- memxt stars - memxt docs -

-
+[**Opal** 💠](https://github.com/debpalash/Opal) — play everything: the media player for the AI era · [**memxt** 🧠](https://github.com/debpalash/memxt) — local long-term memory for coding agents. Same rule: **your data stays on your machine.** --- diff --git a/backend/api/dependencies.py b/backend/api/dependencies.py index 25e6edaf..d825e870 100644 --- a/backend/api/dependencies.py +++ b/backend/api/dependencies.py @@ -17,67 +17,21 @@ Currently exposed: keep their own inline loopback guards. """ -import ipaddress import os -import secrets from fastapi import HTTPException, Request - -# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname. -# `request.client.host` carries an address, not a hostname, so the literal -# "localhost" entry is defensive — some upstream wrappers (TestClient with -# a custom client tuple, certain reverse-proxy headers) may pass strings -# rather than parsed addresses. We accept the broader set without weakening -# the guard: nothing here matches a non-loopback origin. -_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) - - -def _trusted_networks(): - """CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as - loopback-trusted — e.g. a reverse proxy or self-hosted LAN, so the API-key / - PIN gates don't block LAN clients that can't present the credential (a proxy - that strips the Authorization header). Read at call time (matching - `_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart - to apply changes in production.""" - nets = [] - for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","): - cidr = cidr.strip() - if cidr: - try: - nets.append(ipaddress.ip_network(cidr, strict=False)) - except ValueError: - pass # malformed entry ignored — never wedge the auth gate - return nets - - -def is_loopback(host): - """True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted - network. Admin gates (``require_admin`` → ``/system/set-env``, - ``/api/settings/*``) use this so a trusted-network CIDR exempts consumption - (TTS / dictation) but never the RCE-class admin surface.""" - return host in _LOOPBACK_HOSTS - - -def is_local_host(host): - """Loopback address, OR on a configured trusted network. The consumption - gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is - exempted. Admin gates use :func:`is_loopback` — NOT this — to preserve the - two-tier privilege model: consumption trust ≠ admin trust.""" - if is_loopback(host): - return True - try: - ip = ipaddress.ip_address(host) - except (ValueError, TypeError): - return False - # Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs — - # dual-stack proxies (Caddy, Node.js) frequently pass the mapped form. - if getattr(ip, "ipv4_mapped", None): - ip = ip.ipv4_mapped - return any(ip in net for net in _trusted_networks()) +from core.auth import ( + CredentialTransport, + PrincipalKind, + is_local_host, + is_loopback, + principal_for, + remote_api_key, +) +from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed _TRUTHY = frozenset({"1", "true", "yes", "on"}) -_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) def _server_mode() -> bool: @@ -100,34 +54,6 @@ def _server_mode() -> bool: return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY -def remote_api_key() -> str | None: - """The normalized remote-backend bearer key, or None when remote mode is - off. Surrounding whitespace is configuration noise, never a valid secret. - Read at call time so tests can monkeypatch the environment.""" - return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None - - -def presented_api_key(connection) -> str: - """Return the first non-empty normalized API key on an HTTP/WS connection. - - Authorization wins over query, which wins over cookie. Each channel is - stripped before fallback so whitespace in a higher-priority channel cannot - shadow a valid lower-priority credential. - """ - headers = getattr(connection, "headers", None) or {} - query = getattr(connection, "query_params", None) or {} - cookies = getattr(connection, "cookies", None) or {} - - auth = headers.get("authorization", "") - supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else "" - if supplied: - return supplied - supplied = (query.get("api_key") or "").strip() - if supplied: - return supplied - return (cookies.get("ov_key") or "").strip() - - def _configured_pin(request) -> str | None: """The active share PIN (``app.state.network_share.pin``) or None. Read via getattr so a bare Request stub (or a request that hit before lifespan set @@ -150,25 +76,34 @@ def _admin_credential_configured(request) -> bool: return bool(_configured_pin(request)) -def _request_presents_admin_credential(request) -> bool: - """Whether the request carries a valid **API key** via the channels the - middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key`` - cookie). +def _request_presents_admin_credential( + request, + *, + side_effectful_get: bool = False, +) -> bool: + """Whether the canonical principal carries remote admin capability. - Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the - API key — a long operator-chosen secret — unlocks it. The 6-digit share PIN - is deliberately NOT accepted here: it is a *consumption* credential for LAN - playback and is short enough to brute-force (10^6, no lockout), so it must - never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR - (``is_local_host`` — also a consumption exemption) likewise never unlocks - admin. Net: remote admin in server mode requires the API key; a PIN-only - deployment keeps admin loopback-only. getattr-defensive so a minimal Request - stub never raises.""" - api_key = remote_api_key() or "" - if not api_key: + API-key and short-lived session principals may unlock server-mode admin. + PIN and trusted-network principals remain consumption-only. + """ + principal = principal_for(request) + if principal.kind not in { + PrincipalKind.API_KEY, + PrincipalKind.ADMIN_SESSION, + }: return False - supplied = presented_api_key(request) - return bool(supplied and secrets.compare_digest(supplied, api_key)) + if principal.transport not in { + CredentialTransport.COOKIE, + CredentialTransport.LEGACY_COOKIE, + }: + return True + method = str(getattr(request, "method", "GET")).upper() + if side_effectful_get or method not in SAFE_HTTP_METHODS: + return cookie_csrf_allowed( + request, + side_effectful_get=side_effectful_get, + ) + return True def require_loopback(request: Request) -> None: @@ -209,7 +144,7 @@ def require_loopback(request: Request) -> None: return if _server_mode(): method = str(getattr(request, "method", "GET")).upper() - if method not in _READ_ONLY_METHODS: + if method not in SAFE_HTTP_METHODS: # Defense in depth. Privileged routers should declare # ``require_admin`` directly, but a missed migration must not turn # into an unauthenticated Docker write primitive. @@ -240,7 +175,7 @@ def require_admin(request: Request) -> None: return if _server_mode(): method = str(getattr(request, "method", "GET")).upper() - read_only = method in _READ_ONLY_METHODS + read_only = method in SAFE_HTTP_METHODS if read_only and not _admin_credential_configured(request): return if _request_presents_admin_credential(request): @@ -258,7 +193,10 @@ def require_admin_action(request: Request) -> None: host = request.client.host if request.client else None if is_loopback(host): return - if _server_mode() and _request_presents_admin_credential(request): + if _server_mode() and _request_presents_admin_credential( + request, + side_effectful_get=True, + ): return raise HTTPException(status_code=403, detail="loopback origin or admin API key required") @@ -307,14 +245,8 @@ def require_native_access(request: Request) -> None: def ws_remote_authorized(websocket) -> bool: - """Whether a WebSocket handshake presents the remote API key. - - Browser WebSockets cannot set an Authorization header, so the key may - arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer - middleware sets on the first authenticated HTTP request. Returns False - when remote mode is off — callers keep their loopback-only behavior. - """ - key = remote_api_key() - if not key: - return False - return secrets.compare_digest(presented_api_key(websocket), key) + """Whether the canonical WS principal has a remote admin credential.""" + return principal_for(websocket).kind in { + PrincipalKind.API_KEY, + PrincipalKind.ADMIN_SESSION, + } diff --git a/backend/api/routers/archetypes.py b/backend/api/routers/archetypes.py index 3d88c88a..32142559 100644 --- a/backend/api/routers/archetypes.py +++ b/backend/api/routers/archetypes.py @@ -26,8 +26,10 @@ Design notes from __future__ import annotations import hashlib +import json import logging import os +import re import time import uuid from pathlib import Path @@ -37,6 +39,7 @@ from fastapi import APIRouter, Body, HTTPException, Query from fastapi.responses import FileResponse from core import archetypes +from core.audio_validation import is_playable_wav, resolve_regular_file from core.config import OUTPUTS_DIR, VOICES_DIR from services import gallery @@ -69,6 +72,153 @@ def _preview_key(a: dict) -> str: ).hexdigest()[:16] +def _design_profile_values(a: dict) -> tuple[str, str]: + """Canonical instruct + complete picker state for a designed archetype.""" + return a["instruct"], json.dumps(a["attrs"], sort_keys=True) + + +def _profile_audio_path(ref_audio_path: object) -> Optional[Path]: + """Resolve only a regular, non-symlinked file inside ``VOICES_DIR``.""" + return resolve_regular_file(VOICES_DIR, ref_audio_path) + + +def _materialized_audio_is_current(row, a: dict) -> bool: + """Whether an existing row still has the sample described by its metadata.""" + expected_filename = _profile_audio_filename(row["id"]) + path = _profile_audio_path(row["ref_audio_path"]) + return bool( + row["ref_audio_path"] == expected_filename + and is_playable_wav(path) + and row["instruct"] == a["instruct"] + and row["language"] == a["language"] + and row["ref_text"] == a["sample_script"] + and row["seed"] == _PREVIEW_SEED + ) + + +def _profile_audio_filename(profile_id: str) -> str: + safe_id = ( + profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "") + else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16] + ) + return f"{safe_id}.wav" + + +def _archetype_personality(a: dict) -> str: + return f"archetype:{a['id']}" + + +def _legacy_archetype_profile(conn, a: dict): + """Adopt only a row that an older archetype materializer could have made.""" + row = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? LIMIT 1", + (a["id"],), + ).fetchone() + if row is None: + return None + expected_audio = _profile_audio_filename(row["id"]) + try: + states_match = ( + not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"] + ) + except (TypeError, ValueError): + states_match = False + if ( + row["ref_audio_path"] == expected_audio + and row["instruct"] == a["instruct"] + and row["language"] == a["language"] + and row["ref_text"] == a["sample_script"] + and row["seed"] == _PREVIEW_SEED + and row["kind"] in (None, "", "clone", "design") + and not row["is_locked"] + and not row["verified_own_voice"] + and states_match + ): + return row + return None + + +def _is_materialized_archetype_row(row, a: dict) -> bool: + """Recognize rows owned by this materializer without trusting identity text alone.""" + try: + states_match = json.loads(row["vd_states"]) == a["attrs"] + except (TypeError, ValueError): + return False + return bool( + row["personality"] == _archetype_personality(a) + and row["kind"] == "design" + and row["seed"] == _PREVIEW_SEED + and row["ref_audio_path"] == _profile_audio_filename(row["id"]) + and row["instruct"] == a["instruct"] + and row["language"] == a["language"] + and row["ref_text"] == a["sample_script"] + and states_match + and not row["is_locked"] + and not row["verified_own_voice"] + ) + + +def _existing_archetype_profile(conn, a: dict): + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id", + (_archetype_personality(a),), + ).fetchall() + owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None) + return owned if owned is not None else _legacy_archetype_profile(conn, a) + + +async def _render_profile_audio( + a: dict, profile_id: str, *, publish: bool = True, +) -> tuple[str, Path]: + """Render one validated sample, optionally staging it for a later CAS.""" + audio_filename = _profile_audio_filename(profile_id) + safe_id = Path(audio_filename).stem + audio_path = Path(VOICES_DIR) / audio_filename + if publish: + await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-") + else: + audio_path.parent.mkdir(parents=True, exist_ok=True) + audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav" + try: + await _render_archetype_wav(a, audio_path) + if not is_playable_wav(audio_path): + raise RuntimeError("the voice engine produced an invalid WAV") + except BaseException: + with __import__("contextlib").suppress(OSError): + audio_path.unlink() + raise + return audio_filename, audio_path + + +async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path: + """Render and validate a WAV before atomically replacing *out_path*.""" + audio_path = Path(out_path) + audio_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav" + try: + await _render_archetype_wav(a, tmp_path) + if not is_playable_wav(tmp_path): + raise RuntimeError("the voice engine produced an invalid WAV") + os.replace(tmp_path, audio_path) + finally: + with __import__("contextlib").suppress(OSError): + tmp_path.unlink() + return audio_path + + +def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None: + """Repair profiles created before archetype `/use` persisted design kind.""" + instruct, vd_states = _design_profile_values(a) + conn.execute( + "UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, " + "ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?", + ( + instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED, + audio_filename, _archetype_personality(a), row["id"], + ), + ) + + # A non-empty script is always required — synthesizing empty text yields # silence. Every archetype carries a use-case script, but guard the render path # too so a malformed archetype can never drive a blank render. @@ -255,7 +405,7 @@ def _preview_source(a: dict) -> tuple[str, str]: "Pre-rendered preview from the voice gallery — a fixed reference " "rendering, not a render from your current engine." ) - if (_PREVIEW_DIR / f"{key}.wav").exists(): + if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"): return "cached", "" if _no_voice_model_downloaded(): return "no_model", ( @@ -388,9 +538,9 @@ async def preview_archetype( ) cache_path = _PREVIEW_DIR / f"{key}.wav" - if not cache_path.exists(): + if not is_playable_wav(cache_path): try: - await _render_archetype_wav(a, cache_path) + await _render_wav_atomic(a, cache_path, prefix=".preview-") except Exception as e: # model missing / OOM / inference failure logger.error("Archetype preview render failed", exc_info=True) # Two different failures, two different answers. Without a model @@ -442,70 +592,122 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)): # Idempotent (dedup): an archetype materializes to exactly ONE voice profile. # Picking the same gallery voice again — from any picker (Gallery grid, # VoiceSelector, …) — must reuse that one row instead of rendering + inserting - # a fresh duplicate every time. The `personality` column already carries the - # source archetype id (stamped by the INSERT below), so it's the natural - # dedup key; the expensive render + INSERT only run on first use. + # a fresh duplicate every time. Use a namespaced personality identity so an + # imported persona cannot collide with and be rewritten by an archetype id. with db_conn() as conn: - existing = conn.execute( - "SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1", - (a["id"],), - ).fetchone() + existing = _existing_archetype_profile(conn, a) + + profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8] + audio_path: Optional[Path] = None + if existing is not None and _materialized_audio_is_current(existing, a): + audio_filename = existing["ref_audio_path"] + else: + try: + audio_filename, audio_path = await _render_profile_audio( + a, profile_id, publish=existing is None, + ) + except Exception as e: + logger.error("Archetype 'use' render failed", exc_info=True) + # Same actionable/diagnostic split as /preview — minus the gallery + # suggestion, which cannot help here. + if _no_voice_model_downloaded(): + detail = ( + "Creating a voice needs the voice model — no voice model is " + "downloaded yet. Model Catalogue → Models → Download." + ) + else: + detail = ( + "Couldn't create a voice from this archetype — the voice engine " + f"reported: {e}" + ) + raise HTTPException(status_code=503, detail=detail) from e + if existing is not None: - return {"profile_id": existing["id"], "name": existing["name"]} - - profile_id = str(uuid.uuid4())[:8] - audio_filename = f"{profile_id}.wav" - audio_path = Path(VOICES_DIR) / audio_filename - - try: - await _render_archetype_wav(a, audio_path) - except Exception as e: - logger.error("Archetype 'use' render failed", exc_info=True) - # Same actionable/diagnostic split as /preview — minus the gallery - # suggestion, which cannot help here. - if _no_voice_model_downloaded(): - detail = ( - "Creating a voice needs the voice model — no voice model is " - "downloaded yet. Model Catalogue → Models → Download." + with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") + current = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (existing["id"],), + ).fetchone() + owned = _existing_archetype_profile(conn, a) + still_owned = current is not None and ( + owned is not None and owned["id"] == current["id"] ) + if still_owned: + if audio_path is not None: + destination = Path(VOICES_DIR) / audio_filename + os.replace(audio_path, destination) + audio_path = None + _heal_materialized_profile(conn, current, a, audio_filename) + existing_result = {"profile_id": current["id"], "name": current["name"]} + else: + existing_result = None + if existing_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]}) + return existing_result + # The row was edited/deleted while rendering. Preserve it and use the + # validated staged sample for a fresh canonical materialization. + profile_id = str(uuid.uuid4())[:8] + audio_filename = _profile_audio_filename(profile_id) + destination = Path(VOICES_DIR) / audio_filename + if audio_path is None: + try: + audio_filename, audio_path = await _render_profile_audio(a, profile_id) + except Exception as e: + raise HTTPException( + status_code=503, detail="Couldn't create a voice from this archetype.", + ) from e else: - detail = ( - "Couldn't create a voice from this archetype — the voice engine " - f"reported: {e}" - ) - raise HTTPException(status_code=503, detail=detail) + os.replace(audio_path, destination) + audio_path = destination + + if audio_path is None: # defensive: a new profile always rendered above + raise RuntimeError("new archetype profile has no rendered audio") profile_name = (name or a["name"]).strip() or a["name"] try: with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") # Re-check under the write connection right before inserting: a # concurrent /use for the same archetype may have inserted while we # were rendering (the pre-render SELECT above raced). Reuse that row # and drop our just-rendered sample instead of creating a duplicate. - # (personality is NOT globally unique — marketplace/persona imports - # reuse the column — so a UNIQUE index isn't an option; this closes - # the realistic window for the single-user desktop app.) - dup = conn.execute( - "SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1", - (a["id"],), - ).fetchone() + # `personality` is not globally UNIQUE, so serialize and re-check. + dup = _existing_archetype_profile(conn, a) if dup is not None: + duplicate_audio = dup["ref_audio_path"] + if not _materialized_audio_is_current(dup, a): + duplicate_audio = _profile_audio_filename(dup["id"]) + _duplicate_path = Path(VOICES_DIR) / duplicate_audio + _duplicate_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(audio_path, _duplicate_path) + audio_path = None + _heal_materialized_profile(conn, dup, a, duplicate_audio) with __import__("contextlib").suppress(OSError): - os.remove(audio_path) - return {"profile_id": dup["id"], "name": dup["name"]} - conn.execute( - "INSERT INTO voice_profiles " - "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, kind, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ( - profile_id, profile_name, audio_filename, a["sample_script"], - a["instruct"], a["language"], _PREVIEW_SEED, a["id"], "design", time.time(), - ), - ) + if audio_path is not None: + os.remove(audio_path) + duplicate_result = {"profile_id": dup["id"], "name": dup["name"]} + else: + duplicate_result = None + if duplicate_result is None: + instruct, vd_states = _design_profile_values(a) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)", + ( + profile_id, profile_name, audio_filename, a["sample_script"], + instruct, a["language"], _PREVIEW_SEED, + _archetype_personality(a), time.time(), vd_states, + ), + ) except Exception: with __import__("contextlib").suppress(OSError): - os.remove(audio_path) + if audio_path is not None: + os.remove(audio_path) raise + if duplicate_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]}) + return duplicate_result event_bus.emit("profiles", {"action": "created", "id": profile_id}) return {"profile_id": profile_id, "name": profile_name} diff --git a/backend/api/routers/auth.py b/backend/api/routers/auth.py new file mode 100644 index 00000000..04b2b384 --- /dev/null +++ b/backend/api/routers/auth.py @@ -0,0 +1,231 @@ +"""Short-lived credentials for the first-party remote administration UI.""" + +from __future__ import annotations + +import math +import threading +import time +from collections import OrderedDict, deque +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Literal + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from core.auth import ( + CredentialTransport, + PrincipalKind, + authorization_credential_present, + legacy_master_cookie_valid, + master_header_valid, + principal_for, + remote_api_key, +) +from core.csrf import cookie_csrf_allowed, effective_scheme +from services.admin_sessions import ( + SESSION_TTL_SECONDS, + WS_TICKET_TTL_SECONDS, + admin_session_store, +) + + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +_FAILED_EXCHANGE_LIMIT = 10 +_FAILED_EXCHANGE_WINDOW_SECONDS = 60 +_MAX_TRACKED_CLIENTS = 1024 + + +class _ExchangeAttemptLimiter: + """Bounded per-client sliding window for failed pre-auth exchanges.""" + + def __init__( + self, + *, + monotonic: Callable[[], float] = time.monotonic, + limit: int = _FAILED_EXCHANGE_LIMIT, + window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS, + max_clients: int = _MAX_TRACKED_CLIENTS, + ) -> None: + if limit <= 0 or window_seconds <= 0 or max_clients <= 0: + raise ValueError("rate-limit bounds must be positive") + self._monotonic = monotonic + self._limit = limit + self._window_seconds = window_seconds + self._max_clients = max_clients + self._attempts: OrderedDict[str, deque[float]] = OrderedDict() + self._lock = threading.Lock() + + def register_failure(self, client_id: str) -> int | None: + now = self._monotonic() + cutoff = now - self._window_seconds + with self._lock: + failures = self._attempts.setdefault(client_id, deque()) + while failures and failures[0] <= cutoff: + failures.popleft() + self._attempts.move_to_end(client_id) + while len(self._attempts) > self._max_clients: + self._attempts.popitem(last=False) + if len(failures) >= self._limit: + return max( + 1, + math.ceil(self._window_seconds - (now - failures[0])), + ) + failures.append(now) + return None + + def clear(self, client_id: str) -> None: + with self._lock: + self._attempts.pop(client_id, None) + + def reset(self) -> None: + with self._lock: + self._attempts.clear() + + +_exchange_attempt_limiter = _ExchangeAttemptLimiter() + + +class SessionRequest(BaseModel): + transport: Literal["cookie", "bearer"] + + +class WebSocketTicketRequest(BaseModel): + path: str + + +def _secure_cookie(request: Request) -> bool: + # Same effective-scheme logic as the exact-origin CSRF check: the resolved + # scope first (uvicorn's trusted-proxy rewrite), upgraded — never + # downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn + # doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on + # a plain-http hop can only ADD the Secure flag, which fails safe: the + # browser drops such a cookie, so the spoofer only breaks their own + # session. See core.csrf.effective_scheme for the full analysis. + return effective_scheme(request) == "https" + + +def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None: + response.set_cookie( + "ov_session", + token, + max_age=SESSION_TTL_SECONDS, + expires=datetime.fromtimestamp(expires_at, tz=UTC), + path="/", + secure=_secure_cookie(request), + httponly=True, + samesite="strict", + ) + + +def _expire_cookie(response: Response, request: Request, name: str) -> None: + response.delete_cookie( + name, + path="/", + secure=_secure_cookie(request), + httponly=name == "ov_session", + samesite="strict", + ) + + +def _client_id(request: Request) -> str: + host = request.client.host if request.client else "unknown" + return str(host).strip().lower()[:255] or "unknown" + + +def _reject_master_exchange(request: Request) -> None: + retry_after = _exchange_attempt_limiter.register_failure(_client_id(request)) + if retry_after is not None: + raise HTTPException( + status_code=429, + detail="Too many authentication attempts", + headers={"Retry-After": str(retry_after)}, + ) + raise HTTPException(status_code=401, detail="API key required") + + +@router.post("/session") +def create_session(payload: SessionRequest, request: Request) -> Response: + configured = remote_api_key() + if not configured: + raise HTTPException(status_code=401, detail="API key required") + + authorization_present = authorization_credential_present(request) + header_authorized = master_header_valid(request) + legacy_authorized = legacy_master_cookie_valid(request) + migrating_legacy = False + + if authorization_present: + if not header_authorized: + _reject_master_exchange(request) + elif legacy_authorized: + if payload.transport != "cookie" or not cookie_csrf_allowed(request): + raise HTTPException(status_code=403, detail="browser origin rejected") + migrating_legacy = True + else: + _reject_master_exchange(request) + + _exchange_attempt_limiter.clear(_client_id(request)) + issued = admin_session_store.issue(configured) + if payload.transport == "bearer": + return JSONResponse( + { + "token": issued.token, + "expires_at": issued.expires_at, + "expires_in": SESSION_TTL_SECONDS, + }, + status_code=201, + ) + + response = Response(status_code=204) + _set_session_cookie(response, request, issued.token, issued.expires_at) + if migrating_legacy or request.cookies.get("ov_key"): + _expire_cookie(response, request, "ov_key") + return response + + +@router.delete("/session", status_code=204) +def delete_session(request: Request) -> Response: + principal = principal_for(request) + if principal.kind is PrincipalKind.ADMIN_SESSION: + if ( + principal.transport is CredentialTransport.COOKIE + and not cookie_csrf_allowed(request) + ): + raise HTTPException(status_code=403, detail="browser origin rejected") + admin_session_store.revoke_by_credential(principal.credential_id) + response = Response(status_code=204) + _expire_cookie(response, request, "ov_session") + return response + + +@router.post("/ws-ticket") +def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse: + principal = principal_for(request) + if principal.kind is not PrincipalKind.ADMIN_SESSION: + raise HTTPException(status_code=403, detail="admin session required") + if ( + principal.transport is CredentialTransport.COOKIE + and not cookie_csrf_allowed(request) + ): + raise HTTPException(status_code=403, detail="browser origin rejected") + try: + ticket = admin_session_store.issue_ws_ticket_for_credential( + principal.credential_id, + payload.path, + remote_api_key(), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from None + except PermissionError: + raise HTTPException(status_code=401, detail="admin session required") from None + return JSONResponse( + { + "ticket": ticket.token, + "expires_at": ticket.expires_at, + "expires_in": WS_TICKET_TTL_SECONDS, + }, + status_code=201, + ) diff --git a/backend/api/routers/community.py b/backend/api/routers/community.py index 12c433ab..21e1a1c5 100644 --- a/backend/api/routers/community.py +++ b/backend/api/routers/community.py @@ -20,18 +20,26 @@ Design / safety from __future__ import annotations import asyncio +import contextlib +import hashlib import json import logging import os import re +import shutil +import tempfile +import time +import uuid from pathlib import Path from typing import Optional -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse from core import archetypes -from core.config import DATA_DIR +from core.audio_validation import is_playable_wav, resolve_regular_file +from core.config import DATA_DIR, VOICES_DIR logger = logging.getLogger("omnivoice.community") router = APIRouter() @@ -42,9 +50,32 @@ _ALLOWED_AUDIO_HOSTS = { "cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com", } +_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"} _VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID) _USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES} -_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only +_SOURCE_RE = re.compile( + r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$", +) # owner/repo only +_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + +# A gallery open may touch this loader several times (grid, preview, use). Keep +# a successful response for six hours, then revalidate it once. On a network +# failure the readable stale copy remains usable and its check time advances, +# preventing every offline gallery open from waiting through the same timeout. +_MANIFEST_MAX_AGE_S = 6 * 60 * 60 +_MAX_MANIFEST_BYTES = 4 << 20 +_MAX_SAMPLE_SCRIPT_CHARS = 2_000 +_MAX_REF_TEXT_CHARS = 4_000 + +# Community voice submissions are documented as short clean WAV clips. The cap +# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a +# remote manifest from turning Preview into an unbounded disk/memory download. +_MAX_VOICE_AUDIO_BYTES = 32 << 20 + +_ATTR_NAMES = ( + "Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect", +) # ── Config: which content repos to load ─────────────────────────────────────── @@ -52,14 +83,18 @@ def configured_sources() -> list[str]: """Gallery sources, in priority order. Env var > config file > default.""" env = os.environ.get("OMNIVOICE_GALLERY_SOURCES") if env: - return [s.strip() for s in env.split(",") if s.strip()] + sources = [s.strip() for s in env.split(",")] + valid = [s for s in sources if _SOURCE_RE.fullmatch(s)] + return valid or list(_DEFAULT_SOURCES) cfg = Path(DATA_DIR) / "gallery_sources.json" if cfg.exists(): try: data = json.loads(cfg.read_text(encoding="utf-8")) srcs = data.get("sources") if isinstance(srcs, list) and srcs: - return [str(s) for s in srcs] + valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)] + if valid: + return valid except Exception: logger.warning("gallery_sources.json unreadable; using default") return list(_DEFAULT_SOURCES) @@ -81,9 +116,51 @@ def _safe_audio_url(url: str) -> bool: return False +def _safe_manifest_url(url: str) -> bool: + try: + parsed = urlparse(url or "") + return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS + except Exception: + return False + + +def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]: + """Normalize one validator-safe tag per design category. + + Membership in the vocabulary is not enough: ``male, female`` contains two + individually valid tokens but the engine rejects the pair as conflicting. + Build the frontend's full ``vd_states`` shape at this trust boundary too, + so Magic Wand never inherits stale sliders from the previous voice. + """ + attrs = {name: "Auto" for name in _ATTR_NAMES} + normalized: list[str] = [] + seen_categories: set[int] = set() + for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")): + token = raw.strip().lower() + if not token or token not in _VALID_TOKENS: + return None + category = archetypes._VD._instruct_category_index(token) + if category < 0 or category in seen_categories: + return None + seen_categories.add(category) + + # The picker represents the universal gender/age/pitch/style axes in + # English even for Chinese speech; dialect remains Chinese-only. + canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token) + attrs[_ATTR_NAMES[category]] = canonical + normalized.append(canonical) + + if not normalized: + return None + # Accent and Chinese dialect are separate taxonomy buckets but the engine + # deliberately forbids mixing them in a single design. + if 4 in seen_categories and 5 in seen_categories: + return None + return ", ".join(normalized), attrs + + def is_valid_instruct(instruct: str) -> bool: - toks = [t.strip() for t in (instruct or "").split(",") if t.strip()] - return bool(toks) and all(t in _VALID_TOKENS for t in toks) + return normalize_preset_instruct(instruct) is not None def validate_item(raw: dict) -> Optional[dict]: @@ -93,62 +170,203 @@ def validate_item(raw: dict) -> Optional[dict]: it = dict(raw) if it.get("type") not in ("preset", "voice"): return None - if not it.get("id") or not it.get("name"): + if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]): return None + if not isinstance(it.get("name"), str) or not it["name"].strip(): + return None + it["name"] = it["name"].strip()[:80] if it.get("use_case") not in _USE_CASE_IDS: return None - if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")): - return None # would crash synthesis — drop it - if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")): - return None - it.setdefault("facets", {}) + raw_facets = it.get("facets") + if not isinstance(raw_facets, dict): + raw_facets = {} + language = it.get("language") + if not isinstance(language, str) or not language.strip(): + language = raw_facets.get("lang", "English") + it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English" + + facets = dict(raw_facets) + if it["type"] == "preset": + normalized = normalize_preset_instruct(it.get("instruct", "")) + if normalized is None: + return None # unknown/conflicting tokens would crash synthesis + it["instruct"], it["attrs"] = normalized + attrs = it["attrs"] + facets.update({ + "gender": None if attrs["Gender"] == "Auto" else attrs["Gender"], + "age": None if attrs["Age"] == "Auto" else attrs["Age"], + "pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"], + "accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"], + "whisper": attrs["Style"] == "whisper", + "lang": it["language"], + }) + sample_script = it.get("sample_script") + it["sample_script"] = ( + sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS] + if isinstance(sample_script, str) else "" + ) + else: + audio = it.get("audio") + if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")): + return None + expected = audio.get("sha256") + if expected is not None: + expected = str(expected).lower() + if not _SHA256_RE.fullmatch(expected): + return None + audio = {**audio, "sha256": expected} + ref_text = audio.get("ref_text") + audio = { + **audio, + "ref_text": ( + ref_text.strip()[:_MAX_REF_TEXT_CHARS] + if isinstance(ref_text, str) else "" + ), + } + it["audio"] = audio + facets.setdefault("gender", None) + facets.setdefault("age", None) + facets.setdefault("pitch", None) + facets.setdefault("accent", None) + facets.setdefault("whisper", False) + facets.setdefault("lang", it["language"]) + it["facets"] = facets it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles")) - it.setdefault("language", it.get("facets", {}).get("lang", "English")) it["is_community"] = it.get("source") != "starter" + it["preview_url"] = f"/community/items/{it['id']}/preview" return it def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]: items, packs, seen = [], [], set() for src, m in manifests: - if not m: + if not isinstance(m, dict): continue - for raw in (m.get("items") or []): + raw_items = m.get("items") + for raw in raw_items if isinstance(raw_items, list) else []: v = validate_item(raw) if v and v["id"] not in seen: v["_source_repo"] = src seen.add(v["id"]) items.append(v) - for p in (m.get("packs") or []): + raw_packs = m.get("packs") + for p in raw_packs if isinstance(raw_packs, list) else []: if isinstance(p, dict): packs.append({**p, "_source_repo": src}) return items, packs -def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]: - """Return a source's manifest from cache, or fetch + cache it. None if both fail.""" - cache = _cache_path(source) - if not refresh and cache.exists(): - try: - return json.loads(cache.read_text(encoding="utf-8")) - except Exception: - pass +def _read_manifest_cache(cache: Path) -> Optional[dict]: try: - import httpx - with httpx.Client(timeout=15.0, follow_redirects=True) as client: - resp = client.get(_manifest_url(source)) - resp.raise_for_status() - data = resp.json() - cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_text(json.dumps(data), encoding="utf-8") + if cache.stat().st_size > _MAX_MANIFEST_BYTES: + return None + data = json.loads(cache.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def _write_bytes_atomic(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + + +def _fetch_remote_manifest(source: str, *, client=None) -> dict: + """Fetch one bounded manifest, validating every redirect before request.""" + import httpx + + if not _SOURCE_RE.fullmatch(source or ""): + raise ValueError("invalid gallery source") + owned_client = client is None + http = client or httpx.Client(timeout=15.0, follow_redirects=False) + current_url = _manifest_url(source) + payload = bytearray() + try: + fetched = False + for _redirect in range(6): + if not _safe_manifest_url(current_url): + raise ValueError("gallery manifest URL is not from an allowed host") + with http.stream("GET", current_url, follow_redirects=False) as response: + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + next_url = urljoin(current_url, location or "") + if not location or not _safe_manifest_url(next_url): + raise ValueError("gallery manifest redirected to a disallowed host") + current_url = next_url + continue + response.raise_for_status() + length = response.headers.get("content-length") + if length: + try: + declared_length = int(length) + except ValueError: + declared_length = None + if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES: + raise ValueError("gallery manifest exceeded the size limit") + for chunk in response.iter_bytes(): + if not chunk: + continue + if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES: + raise ValueError("gallery manifest exceeded the size limit") + payload.extend(chunk) + fetched = True + break + if not fetched: + raise ValueError("gallery manifest followed too many redirects") + finally: + if owned_client: + http.close() + if not payload: + raise ValueError("gallery manifest was empty") + data = json.loads(payload) + if not isinstance(data, dict): + raise ValueError("gallery manifest is not a JSON object") + return data + + +def _fetch_manifest( + source: str, refresh: bool, *, now: Optional[float] = None, +) -> Optional[dict]: + """Return a fresh manifest, with a throttled stale-cache offline fallback.""" + cache = _cache_path(source) + cached = _read_manifest_cache(cache) + checked_at = time.time() if now is None else float(now) + if not refresh and cached is not None: + try: + if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S: + return cached + except OSError: + pass # treat a stat race as stale and try the source once + try: + data = _fetch_remote_manifest(source) + encoded = json.dumps( + data, ensure_ascii=False, separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > _MAX_MANIFEST_BYTES: + raise ValueError("gallery manifest exceeded the cache size limit") + _write_bytes_atomic(cache, encoded) + # Tests inject their own clock; production's value equals wall time. + os.utime(cache, (checked_at, checked_at)) return data except Exception as e: # offline / 404 / bad json logger.warning("manifest fetch failed for %s: %s", source, e) - if cache.exists(): - try: - return json.loads(cache.read_text(encoding="utf-8")) - except Exception: - pass + if cached is not None: + # This mtime is a last-*check* marker. Advancing it on failure keeps + # an offline app responsive while guaranteeing another check after + # the bounded freshness interval. + with contextlib.suppress(OSError): + os.utime(cache, (checked_at, checked_at)) + return cached return None @@ -214,6 +432,385 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source: return {"url": f"https://github.com/{src}/issues/new?template={template}"} +def _find_item(items: list[dict], item_id: str) -> dict: + if not _ITEM_ID_RE.fullmatch(item_id or ""): + raise HTTPException(status_code=404, detail="Item not found in the gallery.") + item = next((it for it in items if it["id"] == item_id), None) + if item is None: + raise HTTPException(status_code=404, detail="Item not found in the gallery.") + return item + + +def _canonical_archetype(item: dict) -> Optional[dict]: + """The built-in archetype represented exactly by a marketplace preset.""" + if item.get("type") != "preset": + return None + canonical = archetypes.get_archetype(item["id"]) + if canonical is None: + return None + if (canonical.get("instruct") != item.get("instruct") + or canonical.get("language") != item.get("language")): + return None + remote_script = (item.get("sample_script") or "").strip() + if remote_script and remote_script != (canonical.get("sample_script") or "").strip(): + return None + return canonical + + +def _preset_preview_path(item: dict) -> Path: + fingerprint = hashlib.sha256( + json.dumps({ + "instruct": item.get("instruct"), + "language": item.get("language"), + "sample_script": item.get("sample_script"), + }, sort_keys=True).encode("utf-8") + ).hexdigest()[:16] + return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav" + + +def _voice_audio_fingerprint(item: dict) -> str: + audio = item.get("audio") or {} + return hashlib.sha256( + f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8") + ).hexdigest()[:16] + + +def _voice_audio_path(item: dict) -> Path: + return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav" + + +async def _render_preset_atomic(item: dict, out_path: Path) -> Path: + if is_playable_wav(out_path): + return out_path + from api.routers.archetypes import _render_archetype_wav + + out_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav") + os.close(fd) + tmp = Path(tmp_name) + try: + await _render_archetype_wav({ + "instruct": item["instruct"], + "language": item.get("language", "English"), + "sample_script": ( + (item.get("sample_script") or "").strip() + or "Hello — this is a preview of this voice." + ), + }, tmp) + if not is_playable_wav(tmp): + raise RuntimeError("the voice engine produced an invalid preview WAV") + os.replace(tmp, out_path) + return out_path + finally: + with contextlib.suppress(OSError): + tmp.unlink() + + +def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None: + """Stream one allow-listed voice clip into an atomic, size-bounded file.""" + audio = item.get("audio") or {} + url = audio.get("url", "") + if not _safe_audio_url(url): + raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.") + + import httpx + + owned_client = client is None + http = client or httpx.Client(timeout=30.0, follow_redirects=False) + out_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part") + total = 0 + digest = hashlib.sha256() + try: + with os.fdopen(fd, "wb") as handle: + current_url = url + downloaded = False + for _redirect in range(6): + with http.stream("GET", current_url, follow_redirects=False) as response: + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + next_url = urljoin(current_url, location or "") + if not location or not _safe_audio_url(next_url): + raise HTTPException( + status_code=502, + detail="Community voice audio redirected to a disallowed host.", + ) + current_url = next_url + continue + response.raise_for_status() + length = response.headers.get("content-length") + if length: + try: + if int(length) > _MAX_VOICE_AUDIO_BYTES: + raise HTTPException( + status_code=502, + detail="Community voice audio exceeded the download size limit.", + ) + except ValueError: + # A non-numeric Content-Length header is the + # server's problem, not a reason to refuse the + # download — the streamed byte counter below + # still enforces the same cap on what actually + # arrives. + pass + for chunk in response.iter_bytes(): + if not chunk: + continue + total += len(chunk) + if total > _MAX_VOICE_AUDIO_BYTES: + raise HTTPException( + status_code=502, + detail="Community voice audio exceeded the download size limit.", + ) + digest.update(chunk) + handle.write(chunk) + downloaded = True + break + if not downloaded: + raise HTTPException( + status_code=502, + detail="Community voice audio followed too many redirects.", + ) + if total == 0: + raise HTTPException(status_code=502, detail="Community voice audio was empty.") + expected = audio.get("sha256") + if expected and digest.hexdigest() != expected: + raise HTTPException( + status_code=502, + detail="Downloaded voice failed its integrity check.", + ) + handle.flush() + os.fsync(handle.fileno()) + if not is_playable_wav(Path(tmp_name)): + raise HTTPException( + status_code=502, detail="Community voice audio was not a valid WAV.", + ) + os.replace(tmp_name, out_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + finally: + if owned_client: + http.close() + + +def _cached_voice_audio(item: dict) -> Path: + path = _voice_audio_path(item) + if is_playable_wav(path): + return path + with contextlib.suppress(OSError): + path.unlink() + _download_voice_audio(item, path) + return path + + +def _copy_atomic(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part", + ) + try: + with os.fdopen(fd, "wb") as out, source.open("rb") as src: + shutil.copyfileobj(src, out) + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_name, destination) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + + +@router.get("/community/items/{item_id}/preview") +async def community_preview( + item_id: str, + local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"), +): + """Serve every community preview through the authenticated same-origin API.""" + _, items, _, _ = await asyncio.to_thread(_load, False) + item = _find_item(items, item_id) + + canonical = _canonical_archetype(item) + if canonical is not None: + # Reuse the signed-gallery/local-render fallback and cache owned by the + # canonical endpoint rather than synthesizing the same preset twice. + # Delegate in-process: a root-relative HTTP redirect drops supported + # reverse-proxy path prefixes such as ``https://host/api``. + from api.routers.archetypes import preview_archetype + return await preview_archetype(canonical["id"], local=local) + + try: + if item["type"] == "preset": + path = await _render_preset_atomic(item, _preset_preview_path(item)) + else: + path = await asyncio.to_thread(_cached_voice_audio, item) + except HTTPException: + raise + except Exception as exc: + logger.warning("Community preview unavailable (%s)", type(exc).__name__) + raise HTTPException( + status_code=503, detail="This community voice preview is unavailable right now.", + ) from exc + return FileResponse( + path, media_type="audio/wav", + headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"}, + ) + + +def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]: + if item["type"] == "preset": + return "design", item["instruct"], json.dumps(item["attrs"]), 42 + return "clone", "", None, None + + +def _community_profile_audio_filename(profile_id: str, item: dict) -> str: + safe_id = ( + profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "") + else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16] + ) + if item["type"] == "voice": + # The manifest URL/checksum fingerprint makes a changed submission + # invalidate its already-materialized clone without a schema change. + return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav" + return f"{safe_id}.wav" + + +def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]: + return resolve_regular_file(VOICES_DIR, ref_audio_path) + + +def _community_audio_is_current(row, item: dict, ref_text: str) -> bool: + path = _stored_profile_audio(row["ref_audio_path"]) + expected_filename = _community_profile_audio_filename(row["id"], item) + if row["ref_audio_path"] != expected_filename or not is_playable_wav(path): + return False + kind, instruct, _vd_states, seed = _profile_fields(item) + inputs_match = ( + row["instruct"] == instruct + and row["language"] == item.get("language", "Auto") + and row["ref_text"] == ref_text + and row["seed"] == seed + ) + if not inputs_match: + return False + return True + + +async def _materialize_item_audio( + item: dict, profile_id: str, *, publish: bool = True, +) -> tuple[str, Path]: + """Copy the current manifest audio, optionally staging it for a later CAS.""" + audio_filename = _community_profile_audio_filename(profile_id, item) + destination = Path(VOICES_DIR) / audio_filename + audio_path = destination + if not publish: + destination.parent.mkdir(parents=True, exist_ok=True) + audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav" + if item["type"] == "preset": + cached = await _render_preset_atomic(item, _preset_preview_path(item)) + else: + cached = await asyncio.to_thread(_cached_voice_audio, item) + await asyncio.to_thread(_copy_atomic, cached, audio_path) + return audio_filename, audio_path + + +def _community_personality(item: dict) -> str: + source = item.get("_source_repo") + if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source): + source = _DEFAULT_SOURCES[0] + return f"community:{source}:{item['id']}" + + +def _is_materialized_community_row(row, item: dict) -> bool: + if ( + row["personality"] != _community_personality(item) + or row["is_locked"] or row["verified_own_voice"] + ): + return False + if item["type"] == "voice": + safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split( + "-community-", 1, + )[0] + return bool( + row["kind"] == "clone" + and row["seed"] is None + and not row["vd_states"] + and row["instruct"] == "" + and row["language"] == item.get("language", "Auto") + and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "") + and re.fullmatch( + rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav", + row["ref_audio_path"] or "", + ) + ) + try: + states = json.loads(row["vd_states"]) + except (TypeError, ValueError): + return False + return bool( + row["kind"] == "design" + and row["seed"] == 42 + and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item) + and row["instruct"] == item["instruct"] + and row["language"] == item.get("language", "Auto") + and row["ref_text"] == (item.get("sample_script") or "") + and states == item["attrs"] + ) + + +def _existing_community_profile(conn, item: dict, personality: str): + candidates = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id", + (personality,), + ).fetchall() + existing = next( + (row for row in candidates if _is_materialized_community_row(row, item)), None, + ) + if existing is not None: + return existing + # Old builds stored the bare item id. Import formats preserve arbitrary + # personality text too, so adopt only the exact shape the old materializer + # wrote; otherwise a remote item id could rewrite a user's imported voice. + if archetypes.get_archetype(item["id"]) is None: + legacy = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? LIMIT 1", + (item["id"],), + ).fetchone() + if legacy is not None: + kind, instruct, _vd_states, _seed = _profile_fields(item) + ref_text = item.get("sample_script") or (item.get("audio") or {}).get( + "ref_text", "", + ) + if ( + legacy["ref_audio_path"] == f"{legacy['id']}.wav" + and legacy["kind"] == kind + and legacy["instruct"] == instruct + and legacy["language"] == item.get("language", "Auto") + and legacy["ref_text"] == ref_text + and legacy["seed"] is None + and not legacy["vd_states"] + and not legacy["is_locked"] + and not legacy["verified_own_voice"] + ): + return legacy + return None + + +def _heal_existing_profile( + conn, row, item: dict, ref_text: str, personality: str, audio_filename: str, +) -> None: + kind, instruct, vd_states, seed = _profile_fields(item) + conn.execute( + "UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, " + "ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?", + ( + kind, instruct, vd_states, item.get("language", "Auto"), ref_text, + seed, personality, audio_filename, row["id"], + ), + ) + + @router.post("/community/items/{item_id}/use") async def community_use(item_id: str, name: Optional[str] = Query(None)): """Materialize a community item into a reusable voice profile. @@ -223,76 +820,108 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)): ``voice_profiles`` row usable everywhere voices are picked. """ _, items, _, _ = await asyncio.to_thread(_load, False) - item = next((it for it in items if it["id"] == item_id), None) - if item is None: - raise HTTPException(status_code=404, detail="Item not found in the gallery.") + item = _find_item(items, item_id) + + canonical = _canonical_archetype(item) + if canonical is not None: + from api.routers.archetypes import use_archetype + return await use_archetype(canonical["id"], name) - import time - import uuid from core import event_bus from core.db import db_conn - from core.config import VOICES_DIR - profile_id = str(uuid.uuid4())[:8] - audio_filename = f"{profile_id}.wav" - audio_path = Path(VOICES_DIR) / audio_filename - profile_name = (name or item["name"]).strip() or item["name"] - instruct = item.get("instruct", "") if item["type"] == "preset" else "" ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "") + personality = _community_personality(item) + with db_conn() as conn: + existing = _existing_community_profile(conn, item, personality) - try: - if item["type"] == "preset": - from api.routers.archetypes import _render_archetype_wav - pseudo = { - "instruct": instruct, - "language": item.get("language", "English"), - "sample_script": ref_text or "Hello — this is a preview of this voice.", - } - await _render_archetype_wav(pseudo, audio_path) - else: # voice — download the reference clip (off the event loop) - await asyncio.to_thread(_download_voice_audio, item, audio_path) - except HTTPException: - raise - except Exception as e: - logger.error("Community 'use' failed", exc_info=True) - raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}") - - try: - # A community "preset" is a synthetic designed voice (rendered from an - # instruct string) → kind='design'; a "voice" carries a real reference - # clip → kind='clone'. Setting kind makes the persona-gallery - # synthetic-only gating work (§R3) instead of defaulting all imports to - # 'clone'. - kind = "design" if item["type"] == "preset" else "clone" - with db_conn() as conn: - conn.execute( - "INSERT INTO voice_profiles " - "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (profile_id, profile_name, audio_filename, ref_text, instruct, - item.get("language", "Auto"), None, item["id"], time.time(), kind), + profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8] + audio_path: Optional[Path] = None + if existing is not None and _community_audio_is_current(existing, item, ref_text): + audio_filename = existing["ref_audio_path"] + else: + try: + audio_filename, audio_path = await _materialize_item_audio( + item, profile_id, publish=existing is None, ) + except HTTPException: + raise + except Exception as e: + logger.error("Community 'use' failed", exc_info=True) + raise HTTPException( + status_code=503, detail="Couldn't add this voice right now.", + ) from e + + if existing is not None: + with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") + current = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (existing["id"],), + ).fetchone() + owned = _existing_community_profile(conn, item, personality) + still_owned = current is not None and ( + _is_materialized_community_row(current, item) + or (owned is not None and owned["id"] == current["id"]) + ) + if still_owned: + if audio_path is not None: + destination = Path(VOICES_DIR) / audio_filename + os.replace(audio_path, destination) + audio_path = None + _heal_existing_profile( + conn, current, item, ref_text, personality, audio_filename, + ) + existing_result = {"profile_id": current["id"], "name": current["name"]} + else: + existing_result = None + if existing_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]}) + return existing_result + profile_id = str(uuid.uuid4())[:8] + audio_filename = _community_profile_audio_filename(profile_id, item) + destination = Path(VOICES_DIR) / audio_filename + if audio_path is None: + audio_filename, audio_path = await _materialize_item_audio(item, profile_id) + else: + os.replace(audio_path, destination) + audio_path = destination + + if audio_path is None: # defensive: a new profile always materialized above + raise RuntimeError("new community profile has no materialized audio") + profile_name = (name or item["name"]).strip() or item["name"] + kind, instruct, vd_states, seed = _profile_fields(item) + try: + with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") + duplicate = _existing_community_profile(conn, item, personality) + if duplicate is not None: + duplicate_audio = duplicate["ref_audio_path"] + if not _community_audio_is_current(duplicate, item, ref_text): + duplicate_audio = _community_profile_audio_filename(duplicate["id"], item) + duplicate_path = Path(VOICES_DIR) / duplicate_audio + _copy_atomic(audio_path, duplicate_path) + _heal_existing_profile( + conn, duplicate, item, ref_text, personality, duplicate_audio, + ) + with contextlib.suppress(OSError): + audio_path.unlink() + duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]} + else: + duplicate_result = None + if duplicate_result is None: + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (profile_id, profile_name, audio_filename, ref_text, instruct, + item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states), + ) except Exception: - with __import__("contextlib").suppress(OSError): - os.remove(audio_path) + with contextlib.suppress(OSError): + audio_path.unlink() raise + if duplicate_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]}) + return duplicate_result event_bus.emit("profiles", {"action": "created", "id": profile_id}) return {"profile_id": profile_id, "name": profile_name} - - -def _download_voice_audio(item: dict, out_path: Path) -> None: - import hashlib - audio = item.get("audio") or {} - url = audio.get("url", "") - if not _safe_audio_url(url): - raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.") - import httpx - with httpx.Client(timeout=30.0, follow_redirects=True) as client: - resp = client.get(url) - resp.raise_for_status() - data = resp.content - expected = audio.get("sha256") - if expected and hashlib.sha256(data).hexdigest() != expected: - raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.") - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_bytes(data) diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 0b0cdd6f..42e8e966 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -41,6 +41,15 @@ _FAMILIES = { "llm": (llm_backend, "llm_backend"), } + +def _family_payload(family: str, module): + """Public inventory plus whether an environment pin owns this family.""" + return { + "active": module.active_backend_id(), + "env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")), + "backends": public_backends(module.list_backends()), + } + def _is_hf_repo_id(value: str) -> bool: """Validate the route's ``owner/repo`` contract in bounded time.""" if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1: @@ -55,34 +64,25 @@ def _is_hf_repo_id(value: str) -> bool: @router.get("/engines") def list_all_engines(): return { - "tts": { - "active": tts_backend.active_backend_id(), - "backends": public_backends(tts_backend.list_backends()), - }, - "asr": { - "active": asr_backend.active_backend_id(), - "backends": public_backends(asr_backend.list_backends()), - }, - "llm": { - "active": llm_backend.active_backend_id(), - "backends": public_backends(llm_backend.list_backends()), - }, + "tts": _family_payload("tts", tts_backend), + "asr": _family_payload("asr", asr_backend), + "llm": _family_payload("llm", llm_backend), } @router.get("/engines/tts") def list_tts_backends(): - return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())} + return _family_payload("tts", tts_backend) @router.get("/engines/asr") def list_asr_backends(): - return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())} + return _family_payload("asr", asr_backend) @router.get("/engines/llm") def list_llm_backends(): - return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())} + return _family_payload("llm", llm_backend) @router.get("/engines/effects/presets", response_model=EffectPresetsResponse) diff --git a/backend/api/routers/gallery.py b/backend/api/routers/gallery.py index 217914a6..fc8d08ff 100644 --- a/backend/api/routers/gallery.py +++ b/backend/api/routers/gallery.py @@ -1,18 +1,24 @@ -import os -import json -import uuid -import time import asyncio +import contextlib +import json import logging -from typing import Optional, List +import os +import re +import shutil +import tempfile +import time +import uuid from pathlib import Path +from typing import List, Optional + from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query -from fastapi.responses import FileResponse, RedirectResponse +from fastapi.responses import FileResponse from pydantic import BaseModel from core.db import db_conn from core.config import VOICES_DIR, OUTPUTS_DIR from core import event_bus +from core.audio_validation import resolve_regular_file from core.file_cleanup import FileCleanupError, unlink_if_present from services.ffmpeg_utils import spawn_subprocess @@ -360,46 +366,223 @@ async def upload_voice_clip( } +def _stage_profile_audio(source: Path, directory: Path) -> Path: + """Copy an imported clip to a hidden temp file inside ``directory``. + + The temp lives in the destination directory itself so a later + ``os.replace`` to the final name is an atomic same-filesystem rename — + cheap enough to run while holding a DB write lock, unlike the copy. + Callers own cleanup of the returned path if they never publish it. + """ + directory.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(directory), prefix=".gallery-import-", suffix=".part", + ) + os.close(fd) + try: + shutil.copy2(source, tmp_name) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + return Path(tmp_name) + + +def _copy_profile_audio(source: Path, destination: Path) -> None: + """Copy an imported clip without exposing a partial profile audio file.""" + staged = _stage_profile_audio(source, destination.parent) + try: + os.replace(staged, destination) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(staged) + raise + + +def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str: + """Return the canonical, portable filename for a My Imports profile.""" + safe_id = ( + profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "") + else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16] + ) + suffix = source.suffix.lower() + if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix): + suffix = ".wav" + return f"{safe_id}_gallery{suffix}" + + +def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool: + """Recognize only rows created by this materializer, not identity collisions.""" + return bool( + row["personality"] == f"gallery:{voice['id']}" + and row["ref_audio_path"] == audio_filename + and row["ref_text"] == "" + and row["instruct"] == "" + and row["language"] == "Auto" + and row["seed"] is None + and row["kind"] == "clone" + and not row["vd_states"] + and row["description"] == (voice.get("description") or "") + and not row["is_locked"] + and not row["verified_own_voice"] + and not row["locked_audio_path"] + ) + + +def _existing_gallery_profile(conn, voice: dict, source: Path): + personality = f"gallery:{voice['id']}" + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id", + (personality,), + ).fetchall() + for row in rows: + expected = _gallery_profile_audio_filename(row["id"], source) + if _is_materialized_gallery_profile(row, voice, expected): + return row + return None + + +def _gallery_profile_audio_is_current(row, source: Path) -> bool: + """Detect missing/replaced copies without re-hashing unchanged imports.""" + destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"]) + if destination is None: + return False + try: + source_stat = source.stat() + destination_stat = destination.stat() + # copy2 preserves mtime; size + nanosecond mtime catches ordinary edits + # and partial writes while keeping repeated Use clicks inexpensive. + return ( + source_stat.st_size == destination_stat.st_size + and source_stat.st_mtime_ns == destination_stat.st_mtime_ns + ) + except OSError: + return False + + +def _materialize_gallery_profile( + voice_id: str, requested_name: Optional[str] = None, +) -> dict: + """Idempotently materialize/heal one My Imports clip as a clone profile.""" + personality = f"gallery:{voice_id}" + copied_path: Optional[Path] = None + created = False + staged_path: Optional[Path] = None + staged_source: Optional[Path] = None + try: + # Stage the (potentially large) audio copy BEFORE taking SQLite's + # write lock: copying inside BEGIN IMMEDIATE would stall every other + # backend writer for the whole copy. The staged temp lives in + # VOICES_DIR itself, so publishing it inside the transaction is an + # atomic same-filesystem os.replace. This pre-read is advisory only — + # the locked transaction below re-reads and re-decides everything. + copy_needed = False + with db_conn() as conn: + pre_row = conn.execute( + "SELECT * FROM voice_gallery WHERE id = ?", (voice_id,), + ).fetchone() + if pre_row is not None: + pre_source = Path(pre_row["audio_path"]) + if pre_source.is_file(): + pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source) + copy_needed = pre_existing is None or not _gallery_profile_audio_is_current( + pre_existing, pre_source, + ) + if copy_needed: + staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR)) + staged_source = pre_source + + with db_conn() as conn: + # The identity is not globally UNIQUE because personality is shared + # with other import mechanisms. Serialize this check+insert in + # SQLite so simultaneous Use clicks cannot both create a row. + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT * FROM voice_gallery WHERE id = ?", (voice_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Voice not found") + + voice = dict(row) + source = Path(voice["audio_path"]) + if not source.is_file(): + raise HTTPException(status_code=404, detail="Audio file not found on disk") + + def _install_audio(destination: Path) -> None: + """Publish the staged copy under the lock via atomic rename.""" + nonlocal staged_path + if staged_path is not None and staged_source == source: + os.replace(staged_path, destination) + staged_path = None + else: + # Rare race: the gallery row changed between the advisory + # pre-read and taking the lock, so any staged bytes may be + # from the wrong source. Fall back to the blocking copy + # rather than publish stale audio. + _copy_profile_audio(source, destination) + + existing = _existing_gallery_profile(conn, voice, source) + if existing is not None: + ref_filename = _gallery_profile_audio_filename(existing["id"], source) + if not _gallery_profile_audio_is_current(existing, source): + ref_path = Path(VOICES_DIR) / ref_filename + _install_audio(ref_path) + copied_path = ref_path + conn.execute( + "UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', " + "language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, " + "personality=? WHERE id=?", + ( + ref_filename, voice["description"] or "", personality, + existing["id"], + ), + ) + result = {"profile_id": existing["id"], "name": existing["name"]} + else: + profile_id = str(uuid.uuid4())[:8] + profile_name = (requested_name or voice["name"]).strip() or voice["name"] + ref_filename = _gallery_profile_audio_filename(profile_id, source) + copied_path = Path(VOICES_DIR) / ref_filename + _install_audio(copied_path) + conn.execute( + """INSERT INTO voice_profiles + (id, name, ref_audio_path, ref_text, instruct, language, seed, + personality, is_locked, locked_audio_path, description, kind, + vd_states, created_at) + VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""", + ( + profile_id, profile_name, ref_filename, personality, + voice["description"] or "", time.time(), + ), + ) + created = True + result = {"profile_id": profile_id, "name": profile_name} + except BaseException: + if copied_path is not None: + with contextlib.suppress(OSError): + copied_path.unlink() + raise + finally: + # Staged but never published (failure, or a concurrent request healed + # the profile first) — never leave .part droppings in VOICES_DIR. + if staged_path is not None: + with contextlib.suppress(OSError): + os.unlink(staged_path) + + event_bus.emit( + "profiles", {"action": "created" if created else "updated", "id": result["profile_id"]}, + ) + return result + + @router.post("/gallery/voices/{voice_id}/save-as-profile") async def save_voice_as_profile( voice_id: str, profile_name: str = Query(..., description="Name for the voice profile"), ): """Save a gallery voice as a voice profile for cloning.""" - with db_conn() as conn: - row = conn.execute( - "SELECT * FROM voice_gallery WHERE id = ?", (voice_id,) - ).fetchone() - - if not row: - raise HTTPException(status_code=404, detail="Voice not found") - - profile_id = str(uuid.uuid4())[:8] - import shutil - - ext = os.path.splitext(row["audio_path"])[1] - new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}") - shutil.copy(row["audio_path"], new_audio_path) - - conn.execute( - """ - INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - profile_id, - profile_name, - f"{profile_id}{ext}", - row["description"] or "", - row["character"] or "", - "Auto", - None, - time.time(), - ), - ) - event_bus.emit("profiles", {"action": "created", "id": profile_id}) - - return {"profile_id": profile_id, "name": profile_name} + result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name) + return {"profile_id": result["profile_id"], "name": result["name"]} @router.get("/gallery/voices/{voice_id}/preview") @@ -415,22 +598,10 @@ def preview_voice(voice_id: str): audio_path = row["audio_path"] - # Debug logging - is_absolute = os.path.isabs(audio_path) - path_exists = os.path.exists(audio_path) if audio_path else False - - # If absolute path, serve directly or redirect - if is_absolute and path_exists: - # Get just the relative path from outputs dir - outputs_path = str(OUTPUTS_DIR) - if audio_path.startswith(outputs_path): - # Remove outputs_dir prefix to get relative path within outputs - rel_path = os.path.relpath(audio_path, outputs_path) - # The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav - # rel_path becomes: voice_gallery/file.wav - # We want to serve from /audio/ so: /audio/voice_gallery/file.wav - return RedirectResponse(f"/audio/{rel_path}") - return FileResponse(audio_path, media_type="audio/wav") + if os.path.isabs(audio_path) and os.path.exists(audio_path): + # Serve the file from this API route so deployments mounted below a + # path prefix do not lose that prefix while following a redirect. + return FileResponse(audio_path) raise HTTPException( status_code=404, @@ -503,33 +674,5 @@ def batch_delete_voices(body: dict): @router.post("/gallery/voices/{voice_id}/to-profile") def voice_to_profile(voice_id: str): """Create a voice profile from a gallery clip.""" - with db_conn() as conn: - row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Voice not found") - - voice = dict(row) - audio_path = voice["audio_path"] - if not os.path.exists(audio_path): - raise HTTPException(status_code=404, detail="Audio file not found on disk") - - import shutil - import uuid - - profile_id = str(uuid.uuid4())[:8] - # Copy audio to voices dir - dest_filename = f"{profile_id}_gallery.wav" - dest_path = os.path.join(VOICES_DIR, dest_filename) - shutil.copy2(audio_path, dest_path) - - import time - now = time.time() - conn.execute( - """INSERT INTO voice_profiles - (id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now), - ) - event_bus.emit("profiles", {"action": "created", "id": profile_id}) - - return {"success": True, "profile_id": profile_id, "name": voice["name"]} + result = _materialize_gallery_profile(voice_id) + return {"success": True, "profile_id": result["profile_id"], "name": result["name"]} diff --git a/backend/core/audio_validation.py b/backend/core/audio_validation.py new file mode 100644 index 00000000..66a1746b --- /dev/null +++ b/backend/core/audio_validation.py @@ -0,0 +1,106 @@ +"""Lightweight validation for persisted profile WAV references. + +This module deliberately uses only the standard library. Gallery routers import +it during startup, so pulling in torch/torchaudio merely to validate a cached +file would make every Gallery open pay the model stack's import cost. +""" +from __future__ import annotations + +import os +import wave +from pathlib import Path +from typing import Optional + +from core.path_security import UnsafePath, resolve_within, safe_filename + +_READ_CHUNK_BYTES = 1 << 20 +_MAX_CHANNELS = 64 +_MAX_SAMPLE_RATE = 768_000 +_MAX_SAMPLE_WIDTH = 8 + + +def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]: + """Resolve a portable bare filename inside *root*, rejecting symlinks.""" + try: + name = safe_filename(value) + unresolved = Path(root).resolve(strict=False) / name + if unresolved.is_symlink(): + return None + return resolve_within(root, name) + except (OSError, UnsafePath): + return None + + +def is_playable_wav(path: Optional[Path]) -> bool: + """Return true only for a regular, decodable WAV with audio frames.""" + if path is None: + return False + try: + if not path.is_file() or path.is_symlink(): + return False + file_size = path.stat().st_size + with wave.open(str(path), "rb") as wav: + channels = wav.getnchannels() + sample_rate = wav.getframerate() + sample_width = wav.getsampwidth() + frame_count = wav.getnframes() + if ( + not 0 < channels <= _MAX_CHANNELS + or not 0 < sample_rate <= _MAX_SAMPLE_RATE + or not 0 < sample_width <= _MAX_SAMPLE_WIDTH + or frame_count <= 0 + ): + return False + # ``wave.getnframes`` trusts the header. Read through the declared + # payload so an interrupted write with a complete header but a + # truncated data chunk cannot masquerade as playable audio. + frame_size = channels * sample_width + expected_bytes = frame_count * frame_size + # A PCM payload cannot be larger than the containing file. Check + # before calling ``readframes`` so hostile header values cannot + # turn a tiny file into a multi-gigabyte allocation request. + if expected_bytes > file_size: + return False + read_bytes = 0 + chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size)) + while read_bytes < expected_bytes: + chunk = wav.readframes(chunk_frames) + if not chunk or len(chunk) % frame_size: + return False + read_bytes += len(chunk) + return read_bytes == expected_bytes + except (MemoryError, OSError, EOFError, OverflowError, wave.Error): + # Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE + # files. SoundFile is already a runtime dependency and recognizes those + # containers; import it only on the uncommon fallback path. + try: + import soundfile as sf + + with sf.SoundFile(str(path)) as audio: + if ( + audio.format != "WAV" + or not 0 < audio.channels <= _MAX_CHANNELS + or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE + or len(audio) <= 0 + ): + return False + remaining = len(audio) + # Decode through the declared payload in byte-bounded chunks; + # ``sf.info`` alone also trusts a truncated file's header. + chunk_frames = max( + 1, _READ_CHUNK_BYTES // (audio.channels * 4), + ) + while remaining: + frames = audio.read( + min(remaining, chunk_frames), dtype="float32", always_2d=True, + ) + count = len(frames) + if count <= 0: + return False + remaining -= count + return True + except Exception: + return False + + +__all__ = ["is_playable_wav", "resolve_regular_file"] diff --git a/backend/core/auth.py b/backend/core/auth.py new file mode 100644 index 00000000..83754f9b --- /dev/null +++ b/backend/core/auth.py @@ -0,0 +1,421 @@ +"""Canonical authentication identity for HTTP and WebSocket connections. + +Transport parsing belongs here; authorization remains in FastAPI dependencies. +Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so +middleware and route guards cannot disagree about credential precedence. +""" + +from __future__ import annotations + +import ipaddress +import importlib +import os +import secrets +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum + +from services.admin_sessions import ( + AdminSessionStore, +) + + +_AUTH_STATE_KEY = "auth_principal" +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + +CONSUME_CAPABILITIES = frozenset({"consume"}) +ADMIN_CAPABILITIES = frozenset({"consume", "admin"}) +LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"}) + + +class PrincipalKind(str, Enum): + ANONYMOUS = "anonymous" + LOOPBACK = "loopback" + TRUSTED_NETWORK = "trusted_network" + PIN = "pin" + API_KEY = "api_key" + ADMIN_SESSION = "admin_session" + + +class CredentialTransport(str, Enum): + NONE = "none" + HEADER = "header" + QUERY = "query" + COOKIE = "cookie" + LEGACY_COOKIE = "legacy_cookie" + WS_TICKET = "ws_ticket" + + +@dataclass(frozen=True) +class AuthPrincipal: + kind: PrincipalKind + capabilities: frozenset[str] + credential_id: str | None = None + transport: CredentialTransport = CredentialTransport.NONE + + def allows(self, capability: str) -> bool: + return capability in self.capabilities + + +@dataclass(frozen=True) +class _CredentialCandidate: + value: str = field(repr=False) + transport: CredentialTransport + allow_master: bool = False + allow_session: bool = False + allow_ticket: bool = False + + +def remote_api_key() -> str | None: + """Normalized remote operator key, read dynamically for rotation support.""" + return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None + + +def credential_matches(supplied: str | None, configured: str | None) -> bool: + """Constant-time credential comparison that accepts the full Unicode range.""" + if not supplied or not configured: + return False + return secrets.compare_digest( + supplied.encode("utf-8", errors="surrogatepass"), + configured.encode("utf-8", errors="surrogatepass"), + ) + + +def _active_admin_session_store() -> AdminSessionStore: + """Resolve mutable process state at call time so app reloads cannot split it.""" + module = importlib.import_module("services.admin_sessions") + return module.admin_session_store + + +def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: + networks = [] + for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","): + value = value.strip() + if not value: + continue + try: + networks.append(ipaddress.ip_network(value, strict=False)) + except ValueError: + # Invalid configuration never makes the gate fail open or wedge the + # backend. It simply contributes no trusted range. + continue + return tuple(networks) + + +def is_loopback(host: str | None) -> bool: + return host in _LOOPBACK_HOSTS + + +def is_local_host(host: str | None) -> bool: + if is_loopback(host): + return True + try: + address = ipaddress.ip_address(host) + except (TypeError, ValueError): + return False + if getattr(address, "ipv4_mapped", None): + address = address.ipv4_mapped + return any(address in network for network in _trusted_networks()) + + +def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str: + if not mapping: + return "" + getter = getattr(mapping, "get", None) + if callable(getter): + value = getter(name, "") + if value: + return str(value) + # Real Starlette Headers are case-insensitive. This small fallback keeps + # minimal request stubs and non-Starlette callers correct too. + items = getattr(mapping, "items", None) + if callable(items): + for key, value in items(): + if str(key).lower() == name.lower(): + return str(value or "") + return "" + + +def _scope_type(connection) -> str: + scope = getattr(connection, "scope", None) + return str(scope.get("type", "http")) if isinstance(scope, dict) else "http" + + +def _path(connection) -> str: + scope = getattr(connection, "scope", None) + if isinstance(scope, dict): + return str(scope.get("path", "")) + return str(getattr(connection, "url", "") or "") + + +def _canonical_websocket_path(connection) -> str: + """Remove only the ASGI-configured deployment prefix from a WS path.""" + path = _path(connection) + scope = getattr(connection, "scope", None) + if not isinstance(scope, dict): + return path + root_path = str(scope.get("root_path", "") or "").rstrip("/") + if not root_path or root_path == "/": + return path + root_path = "/" + root_path.lstrip("/") + if path.startswith(root_path + "/"): + return path[len(root_path) :] + return path + + +def _client_host(connection) -> str | None: + client = getattr(connection, "client", None) + if client is not None: + return getattr(client, "host", None) + scope = getattr(connection, "scope", None) + if isinstance(scope, dict) and scope.get("client"): + return scope["client"][0] + return None + + +def _credential_candidate(connection) -> _CredentialCandidate | None: + query = getattr(connection, "query_params", None) or {} + cookies = getattr(connection, "cookies", None) or {} + + raw_authorization = authorization_header(connection) + authorization = raw_authorization.strip() + if raw_authorization.lower().startswith("bearer "): + value = raw_authorization[7:].strip() + if value: + return _CredentialCandidate( + value=value, + transport=CredentialTransport.HEADER, + allow_master=True, + allow_session=True, + ) + # Preserve the legacy normalization contract: ``Bearer`` followed + # only by whitespace is equivalent to an empty credential channel. + elif authorization: + # Any non-empty explicit Authorization value is authoritative, even + # when its scheme is unsupported or its Bearer payload is missing. + # It must never fall through to a stale ambient cookie. + return _CredentialCandidate( + value=authorization, + transport=CredentialTransport.HEADER, + ) + + if _scope_type(connection) == "websocket": + ticket = _mapping_get(query, "ws_ticket").strip() + if ticket: + return _CredentialCandidate( + value=ticket, + transport=CredentialTransport.WS_TICKET, + allow_ticket=True, + ) + + query_key = _mapping_get(query, "api_key").strip() + if query_key: + return _CredentialCandidate( + value=query_key, + transport=CredentialTransport.QUERY, + allow_master=True, + ) + + session = _mapping_get(cookies, "ov_session").strip() + if session: + return _CredentialCandidate( + value=session, + transport=CredentialTransport.COOKIE, + allow_session=True, + ) + + legacy_key = _mapping_get(cookies, "ov_key").strip() + if legacy_key: + return _CredentialCandidate( + value=legacy_key, + transport=CredentialTransport.LEGACY_COOKIE, + allow_master=True, + ) + return None + + +def presented_api_key(connection) -> str: + """Compatibility extractor for the durable API-key transports only.""" + candidate = _credential_candidate(connection) + if candidate is None or not candidate.allow_master: + return "" + return candidate.value + + +def authorization_header(connection) -> str: + headers = getattr(connection, "headers", None) or {} + return _mapping_get(headers, "authorization") + + +def authorization_credential_present(connection) -> bool: + """Whether Authorization contains an authoritative credential channel. + + This deliberately mirrors :func:`_credential_candidate`: whitespace and + ``Bearer`` followed only by spaces are empty channels that may fall back to + legacy migration state. Unsupported schemes and ``Bearer`` without the + required separating space remain explicit invalid credentials. + """ + authorization = authorization_header(connection) + if authorization.lower().startswith("bearer ") and not authorization[7:].strip(): + return False + return bool(authorization.strip()) + + +def bearer_header_value(connection) -> str: + authorization = authorization_header(connection) + if not authorization.lower().startswith("bearer "): + return "" + return authorization[7:].strip() + + +def legacy_master_cookie_valid(connection) -> bool: + configured = remote_api_key() + cookies = getattr(connection, "cookies", None) or {} + supplied = _mapping_get(cookies, "ov_key").strip() + return credential_matches(supplied, configured) + + +def master_header_valid(connection) -> bool: + configured = remote_api_key() + supplied = bearer_header_value(connection) + return credential_matches(supplied, configured) + + +def _configured_pin(connection) -> str | None: + app = getattr(connection, "app", None) + state = getattr(app, "state", None) if app is not None else None + network_share = getattr(state, "network_share", None) if state is not None else None + pin = getattr(network_share, "pin", None) if network_share is not None else None + return str(pin) if pin else None + + +def _valid_pin(connection) -> bool: + configured = _configured_pin(connection) + if not configured: + return False + headers = getattr(connection, "headers", None) or {} + query = getattr(connection, "query_params", None) or {} + cookies = getattr(connection, "cookies", None) or {} + supplied = ( + _mapping_get(headers, "x-omnivoice-pin").strip() + or _mapping_get(query, "pin").strip() + or _mapping_get(cookies, "ov_pin").strip() + ) + return credential_matches(supplied, configured) + + +def _attached_principal(connection) -> AuthPrincipal | None: + scope = getattr(connection, "scope", None) + if not isinstance(scope, dict): + return None + state = scope.get("state") + if isinstance(state, dict): + principal = state.get(_AUTH_STATE_KEY) + return principal if isinstance(principal, AuthPrincipal) else None + return None + + +def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal: + scope = getattr(connection, "scope", None) + if isinstance(scope, dict): + state = scope.setdefault("state", {}) + if isinstance(state, dict): + state[_AUTH_STATE_KEY] = principal + return principal + + +def resolve_principal( + connection, + *, + store: AdminSessionStore | None = None, +) -> AuthPrincipal: + """Resolve and attach the single authentication decision for one scope.""" + attached = _attached_principal(connection) + if attached is not None: + return attached + if store is None: + store = _active_admin_session_store() + + host = _client_host(connection) + if is_loopback(host): + return _attach_principal( + connection, + AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES), + ) + + candidate = _credential_candidate(connection) + configured_key = remote_api_key() + if candidate is not None: + principal: AuthPrincipal | None = None + if ( + candidate.allow_master + and credential_matches(candidate.value, configured_key) + ): + principal = AuthPrincipal( + PrincipalKind.API_KEY, + ADMIN_CAPABILITIES, + credential_id="api-key", + transport=candidate.transport, + ) + elif candidate.allow_session: + session = store.resolve(candidate.value, configured_key) + if session is not None: + principal = AuthPrincipal( + PrincipalKind.ADMIN_SESSION, + session.capabilities, + credential_id=session.credential_id, + transport=candidate.transport, + ) + elif candidate.allow_ticket: + session = store.consume_ws_ticket( + candidate.value, + _canonical_websocket_path(connection), + configured_key, + ) + if session is not None: + principal = AuthPrincipal( + PrincipalKind.ADMIN_SESSION, + session.capabilities, + credential_id=session.credential_id, + transport=candidate.transport, + ) + if principal is not None: + return _attach_principal(connection, principal) + # An explicit, non-empty credential is authoritative. Do not silently + # fall back to network or PIN trust after an invalid higher-priority + # credential was presented. + return _attach_principal( + connection, + AuthPrincipal( + PrincipalKind.ANONYMOUS, + frozenset(), + transport=candidate.transport, + ), + ) + + if is_local_host(host): + return _attach_principal( + connection, + AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES), + ) + if _valid_pin(connection): + return _attach_principal( + connection, + AuthPrincipal( + PrincipalKind.PIN, + CONSUME_CAPABILITIES, + transport=CredentialTransport.HEADER, + ), + ) + return _attach_principal( + connection, + AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()), + ) + + +def principal_for( + connection, + *, + store: AdminSessionStore | None = None, +) -> AuthPrincipal: + return _attached_principal(connection) or resolve_principal(connection, store=store) diff --git a/backend/core/csrf.py b/backend/core/csrf.py new file mode 100644 index 00000000..c6b22ea6 --- /dev/null +++ b/backend/core/csrf.py @@ -0,0 +1,140 @@ +"""Exact-origin CSRF checks for ambient browser authentication.""" + +from __future__ import annotations + +import os +from urllib.parse import SplitResult, urlsplit + + +CSRF_HEADER = "x-voicestudio-csrf" +CSRF_VALUE = "1" +SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + +_FORWARDED_PROTO_HEADER = "x-forwarded-proto" + + +def effective_scheme(connection) -> str: + """Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence. + + Behind a TLS-terminating proxy (Tailscale Serve — the flagship remote-GPU + deployment in docs/remote-gpu.md — nginx, Caddy, ...) the browser talks + ``https`` while the backend hop is plain ``http``. uvicorn's + ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run`` + in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already + rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer + is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on + bare metal, and we prefer that signal — the scope is consulted first — but + it misses Docker (the proxy connects from the bridge gateway) and any other + non-loopback proxy topology, so the header is honored here as well. + + Spoofing analysis — why honoring it never weakens a check: the upgrade is + one-way. ``https``/``wss`` as the first forwarded value promotes ``http`` + to ``https``; every other value is ignored, so a forged header can never + downgrade a genuine TLS hop. For the exact-origin comparison the host:port + half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto + cross-site without a CORS preflight this API never grants, and a + non-browser client able to forge the header can already forge Origin + itself — it gains nothing. For cookies the upgrade can only ADD the Secure + flag (a Secure cookie set over plain http is simply dropped by the + browser — the spoofer only breaks their own session), never strip it. + """ + url = getattr(connection, "url", None) + scheme = getattr(url, "scheme", None) + if not scheme: + scope = getattr(connection, "scope", None) + scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http" + scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme) + if scheme != "https": + headers = getattr(connection, "headers", None) or {} + forwarded = ( + headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else "" + ) + if forwarded.split(",")[0].strip().lower() in {"https", "wss"}: + scheme = "https" + return scheme + + +def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None: + if not value or value == "null": + return None + try: + parsed: SplitResult = urlsplit(value) + port = parsed.port + except (TypeError, ValueError): + return None + if ( + not parsed.scheme + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + return None + scheme = parsed.scheme.lower() + if scheme not in {"http", "https", "tauri"}: + return None + if port is None: + if scheme == "http": + port = 80 + elif scheme == "https": + port = 443 + return scheme, parsed.hostname.lower(), port + + +def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]: + raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901") + try: + ui_port = int(raw_port) + except (TypeError, ValueError): + ui_port = 3901 + values = os.environ.get( + "OMNIVOICE_ALLOWED_ORIGINS", + f"http://localhost:{ui_port},http://127.0.0.1:{ui_port}," + "tauri://localhost,http://tauri.localhost", + ).split(",") + return frozenset( + origin + for value in values + if (origin := _origin_tuple(value.strip())) is not None + ) + + +def _destination_origin(connection) -> tuple[str, str, int | None] | None: + scheme = effective_scheme(connection) + url = getattr(connection, "url", None) + netloc = getattr(url, "netloc", None) + if netloc: + return _origin_tuple(f"{scheme}://{netloc}") + scope = getattr(connection, "scope", None) + headers = getattr(connection, "headers", None) or {} + if not isinstance(scope, dict): + return None + host = headers.get("host", "") if hasattr(headers, "get") else "" + return _origin_tuple(f"{scheme}://{host}") + + +def origin_allowed(connection) -> bool: + headers = getattr(connection, "headers", None) or {} + origin_value = headers.get("origin", "") if hasattr(headers, "get") else "" + presented = _origin_tuple(origin_value) + if presented is None: + return False + return presented == _destination_origin(connection) or presented in configured_allowed_origins() + + +def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool: + headers = getattr(connection, "headers", None) or {} + marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else "" + if marker != CSRF_VALUE or not origin_allowed(connection): + return False + method = getattr(connection, "method", None) + if method is None: + scope = getattr(connection, "scope", None) + method = scope.get("method", "GET") if isinstance(scope, dict) else "GET" + method = str(method).upper() + if side_effectful_get or method in SAFE_HTTP_METHODS: + fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else "" + return fetch_site == "same-origin" + return True diff --git a/backend/core/version.py b/backend/core/version.py index 8f712023..6af1b907 100644 --- a/backend/core/version.py +++ b/backend/core/version.py @@ -24,7 +24,7 @@ from pathlib import Path # tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by # release.yml's version-bump job, so it stays equal to # pyproject/tauri.conf/Cargo/package.json. -_FALLBACK_VERSION = "0.4.2" +_FALLBACK_VERSION = "0.5.0" def _fallback_version() -> str: diff --git a/backend/main.py b/backend/main.py index d88a45ed..e5c525cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -341,7 +341,6 @@ if not os.environ.get("OMNIVOICE_DISABLE_FILE_LOG"): logger = logging.getLogger("omnivoice.api") import asyncio -import secrets import time import threading from contextlib import asynccontextmanager @@ -375,11 +374,15 @@ from services.model_manager import ( ) from services import network_share -from api.dependencies import ( # loopback + OMNIVOICE_TRUSTED_NETWORKS +from core.auth import ( + CredentialTransport, + PrincipalKind, + credential_matches, is_local_host, - presented_api_key, + principal_for, remote_api_key, ) +from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed, origin_allowed from api.routers import ( system, @@ -416,6 +419,7 @@ from api.routers import ( pronunciation, # Expressive-TTS Spec 01: user pronunciation dictionary settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp management + auth as auth_router, ) from utils import hf_progress @@ -1111,7 +1115,12 @@ class NetworkAccessMiddleware: if is_local_host(client): return await self.app(scope, receive, send) path = scope["path"] - if path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon"): + if ( + path in _SHELL_PATHS + or path.startswith("/assets/") + or path.startswith("/favicon") + or path == "/api/auth/session" + ): return await self.app(scope, receive, send) supplied = ( request.headers.get("x-omnivoice-pin") @@ -1119,7 +1128,7 @@ class NetworkAccessMiddleware: or request.cookies.get("ov_pin") or "" ) - if not secrets.compare_digest(supplied, pin): + if not credential_matches(supplied, pin): resp = JSONResponse({"detail": "PIN required"}, status_code=401) return await resp(scope, receive, send) # Valid PIN. Set the cookie by wrapping send to inject Set-Cookie on the @@ -1161,9 +1170,10 @@ class BackendMarkerMiddleware: async def send_with_marker(message): if message["type"] == "http.response.start": - MutableHeaders(scope=message).setdefault( - BACKEND_MARKER_HEADER, _backend_marker_value() - ) + headers = MutableHeaders(scope=message) + headers.setdefault(BACKEND_MARKER_HEADER, _backend_marker_value()) + if str(scope.get("path", "")).startswith("/api/auth/"): + headers["cache-control"] = "no-store" await send(message) return await self.app(scope, receive, send_with_marker) @@ -1183,11 +1193,12 @@ def _backend_marker_value() -> str: class BearerKeyMiddleware: """When OMNIVOICE_API_KEY is set, non-loopback clients must present it on - every HTTP + WebSocket request: ``Authorization: Bearer ``, - ``?api_key=`` (browser WebSockets cannot set headers), or the - ``ov_key`` cookie (set on the first successful HTTP auth). Loopback - always bypasses — the desktop default is unchanged — and the SPA shell - paths stay reachable so a remote UI can load and show what's wrong. + every HTTP + WebSocket request. Durable API-key transports remain compatible; + the first-party UI may instead present a short-lived admin session. The + middleware never reflects a presented master key into browser state. + + Loopback always bypasses — the desktop default is unchanged — and the SPA + shell paths stay reachable so a remote UI can load and show what's wrong. Inert when the env var is unset (the default). Pure ASGI for the same no-buffering reason as NetworkAccessMiddleware above. Plain-HTTP caveat @@ -1204,21 +1215,25 @@ class BearerKeyMiddleware: key = remote_api_key() or "" if not key: return await self.app(scope, receive, send) - client = scope["client"][0] if scope.get("client") else None - if is_local_host(client): - return await self.app(scope, receive, send) path = scope.get("path", "") if scope["type"] == "http" and ( - path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon") + path in _SHELL_PATHS + or path.startswith("/assets/") + or path.startswith("/favicon") + or path == "/api/auth/session" ): return await self.app(scope, receive, send) from starlette.requests import HTTPConnection conn = HTTPConnection(scope) - supplied = presented_api_key(conn) - - if not secrets.compare_digest(supplied, key): + principal = principal_for(conn) + if principal.kind not in { + PrincipalKind.LOOPBACK, + PrincipalKind.TRUSTED_NETWORK, + PrincipalKind.API_KEY, + PrincipalKind.ADMIN_SESSION, + }: if scope["type"] == "websocket": # Reject the handshake; 1008 = policy violation. await receive() # consume websocket.connect @@ -1226,17 +1241,31 @@ class BearerKeyMiddleware: return resp = JSONResponse({"detail": "API key required"}, status_code=401) return await resp(scope, receive, send) - - if scope["type"] == "http" and conn.cookies.get("ov_key") != key: - async def send_with_cookie(message): - if message["type"] == "http.response.start": - headers = MutableHeaders(scope=message) - headers.append( - "set-cookie", f"ov_key={key}; Path=/; SameSite=Lax" - ) - await send(message) - - return await self.app(scope, receive, send_with_cookie) + if ( + scope["type"] == "http" + and str(scope.get("method", "GET")).upper() not in SAFE_HTTP_METHODS + and principal.transport + in {CredentialTransport.COOKIE, CredentialTransport.LEGACY_COOKIE} + and not cookie_csrf_allowed(conn) + ): + resp = JSONResponse( + {"detail": "browser origin rejected"}, + status_code=403, + ) + return await resp(scope, receive, send) + if ( + scope["type"] == "websocket" + and principal.transport + in { + CredentialTransport.COOKIE, + CredentialTransport.LEGACY_COOKIE, + CredentialTransport.WS_TICKET, + } + and not origin_allowed(conn) + ): + await receive() + await send({"type": "websocket.close", "code": 1008}) + return return await self.app(scope, receive, send) @@ -1258,6 +1287,20 @@ _allowed = os.environ.get( f"http://localhost:{_ui},http://127.0.0.1:{_ui},tauri://localhost,http://tauri.localhost", ).split(",") +# Inert unless a PIN is set. CORS is registered after both auth gates below so +# Starlette places it outside them: browser preflights carry no credentials and +# must reach CORS before either gate can reject the request. +app.add_middleware(NetworkAccessMiddleware) + +# Remote-backend bearer gate (parity program Wave 2.3 / §R2). Inert unless +# OMNIVOICE_API_KEY is set. Distinct from the PIN gate above: the PIN guards +# casual LAN-share guests for one session; the API key is the durable +# credential for running this backend remotely (Tailscale / Docker GPU box). +# Covers WebSockets too — the PIN gate never did, because every WS endpoint +# carried its own loopback guard; remote mode is exactly the case where a +# keyed non-loopback client must reach them. +app.add_middleware(BearerKeyMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in _allowed if o.strip()], @@ -1270,23 +1313,10 @@ app.add_middleware( expose_headers=["Content-Disposition", BACKEND_MARKER_HEADER], ) -# Registered AFTER CORS so CORS remains the outermost layer (CORS headers are -# applied even to the 401 PIN-required responses). Inert unless a PIN is set. -app.add_middleware(NetworkAccessMiddleware) - -# Remote-backend bearer gate (parity program Wave 2.3 / §R2). Inert unless -# OMNIVOICE_API_KEY is set. Distinct from the PIN gate above: the PIN guards -# casual LAN-share guests for one session; the API key is the durable -# credential for running this backend remotely (Tailscale / Docker GPU box). -# Covers WebSockets too — the PIN gate never did, because every WS endpoint -# carried its own loopback guard; remote mode is exactly the case where a -# keyed non-loopback client must reach them. -app.add_middleware(BearerKeyMiddleware) - # Registered LAST, which in Starlette means OUTERMOST — so the marker lands on -# every response, including the two gates' 401s above and StaticFiles' bare -# "Not Found". Its absence is what lets a client conclude "whatever answered -# me is not a VoiceStudio backend" (#1385). +# every response. CORS is immediately inside it and outside both auth gates, so +# preflights and gate-generated 401s retain the browser contract. The marker's +# absence lets a client conclude that the responder is not VoiceStudio (#1385). app.add_middleware(BackendMarkerMiddleware) # Register canonical audio MIME types before any StaticFiles mount. @@ -1363,6 +1393,7 @@ app.include_router(longform_jobs.router) app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciation dictionary app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal +app.include_router(auth_router.router) # short-lived first-party remote admin sessions from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402 from api.routers import workers as workers_router # noqa: E402 app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings diff --git a/backend/services/admin_sessions.py b/backend/services/admin_sessions.py new file mode 100644 index 00000000..5a59548f --- /dev/null +++ b/backend/services/admin_sessions.py @@ -0,0 +1,402 @@ +"""Process-bound credentials for the first-party remote administration UI. + +The durable ``OMNIVOICE_API_KEY`` is an operator secret, not a browser session. +This module exchanges it for opaque, bounded-lifetime credentials without +depending on FastAPI or persisting a verifier to disk. +""" + +from __future__ import annotations + +import hmac +import re +import secrets +import sys +import threading +import time +from types import ModuleType +from base64 import urlsafe_b64encode +from collections import OrderedDict +from collections.abc import Callable +from dataclasses import dataclass, field + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + +SESSION_TTL_SECONDS = 8 * 60 * 60 +WS_TICKET_TTL_SECONDS = 30 +MAX_ADMIN_SESSIONS = 256 +MAX_WS_TICKETS = 512 + +ADMIN_SESSION_PREFIX = "ovs_admin_session_" +WS_TICKET_PREFIX = "ovs_ws_ticket_" +_TOKEN_BYTES = 32 +_ENCODED_TOKEN_LENGTH = 43 +_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$") +_ALLOWED_WS_PATHS = frozenset({"/ws/events", "/ws/transcribe"}) +_ADMIN_CAPABILITIES = frozenset({"consume", "admin"}) +_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1" + + +def _hash_token(token: str, pepper: bytes) -> str: + # These are 256-bit random values, not user-chosen passwords. A keyed, + # process-local index is the right primitive: there is no feasible password + # dictionary to slow down, and a copied record is unusable without the + # store's independently generated pepper. + return hmac.digest(pepper, token.encode("utf-8"), "sha256").hex() + + +def _encode_token(raw: bytes) -> str: + return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +@dataclass(frozen=True) +class IssuedSession: + token: str = field(repr=False) + expires_at: float + + +@dataclass(frozen=True) +class IssuedTicket: + token: str = field(repr=False) + expires_at: float + + +@dataclass(frozen=True) +class SessionRecord: + credential_id: str + capabilities: frozenset[str] + issued_at: float + expires_at: float + + +@dataclass(frozen=True) +class _StoredSession: + credential_id: str + issued_monotonic: float + expires_monotonic: float + issued_at: float + expires_at: float + + def public(self) -> SessionRecord: + return SessionRecord( + credential_id=self.credential_id, + capabilities=_ADMIN_CAPABILITIES, + issued_at=self.issued_at, + expires_at=self.expires_at, + ) + + +@dataclass(frozen=True) +class _StoredTicket: + session_hash: str + path: str + issued_monotonic: float + expires_monotonic: float + + +class AdminSessionStore: + """Thread-safe, process-local store for admin sessions and WS tickets.""" + + def __init__( + self, + *, + monotonic: Callable[[], float] = time.monotonic, + wall_time: Callable[[], float] = time.time, + token_bytes: Callable[[int], bytes] = secrets.token_bytes, + pepper: bytes | None = None, + session_ttl_seconds: int = SESSION_TTL_SECONDS, + ws_ticket_ttl_seconds: int = WS_TICKET_TTL_SECONDS, + max_sessions: int = MAX_ADMIN_SESSIONS, + max_tickets: int = MAX_WS_TICKETS, + ) -> None: + if session_ttl_seconds <= 0 or ws_ticket_ttl_seconds <= 0: + raise ValueError("credential TTLs must be positive") + if max_sessions <= 0 or max_tickets <= 0: + raise ValueError("credential store capacities must be positive") + self._monotonic = monotonic + self._wall_time = wall_time + self._token_bytes = token_bytes + self._pepper = pepper if pepper is not None else secrets.token_bytes(32) + if len(self._pepper) < 32: + raise ValueError("session-store pepper must contain at least 256 bits") + self._session_ttl = session_ttl_seconds + self._ticket_ttl = ws_ticket_ttl_seconds + self._max_sessions = max_sessions + self._max_tickets = max_tickets + self._sessions: OrderedDict[str, _StoredSession] = OrderedDict() + self._tickets: OrderedDict[str, _StoredTicket] = OrderedDict() + self._ticket_hashes_by_session: dict[str, set[str]] = {} + self._key_generation: bytes | None = None + self._lock = threading.RLock() + + def __repr__(self) -> str: + snapshot = self.debug_snapshot() + return ( + "AdminSessionStore(" + f"sessions={snapshot['sessions']}, ws_tickets={snapshot['ws_tickets']})" + ) + + @staticmethod + def _normalize_master(api_key: str | None) -> str: + return api_key.strip() if isinstance(api_key, str) else "" + + def _generation(self, api_key: str) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=self._pepper, + info=_KEY_GENERATION_INFO, + ).derive(api_key.encode("utf-8", errors="surrogatepass")) + + def _sync_key_locked(self, api_key: str | None) -> bool: + normalized = self._normalize_master(api_key) + if not normalized: + self._clear_credentials_locked() + self._key_generation = None + return False + generation = self._generation(normalized) + if self._key_generation is None: + self._key_generation = generation + return True + if not hmac.compare_digest(self._key_generation, generation): + self._clear_credentials_locked() + self._key_generation = generation + return True + + @staticmethod + def _valid_token(token: str | None, prefix: str) -> bool: + if not isinstance(token, str) or not token.startswith(prefix): + return False + return bool(_TOKEN_BODY_RE.fullmatch(token.removeprefix(prefix))) + + def _new_token_locked(self, prefix: str, existing: object) -> tuple[str, str]: + for _attempt in range(8): + raw = self._token_bytes(_TOKEN_BYTES) + if not isinstance(raw, bytes) or len(raw) != _TOKEN_BYTES: + raise RuntimeError("token source must return exactly 32 bytes") + token = prefix + _encode_token(raw) + token_hash = _hash_token(token, self._pepper) + if token_hash not in existing: + return token, token_hash + raise RuntimeError("credential token source produced repeated collisions") + + def _clear_credentials_locked(self) -> None: + self._sessions.clear() + self._tickets.clear() + self._ticket_hashes_by_session.clear() + + def _remove_ticket_locked(self, ticket_hash: str) -> _StoredTicket | None: + ticket = self._tickets.pop(ticket_hash, None) + if ticket is None: + return None + session_tickets = self._ticket_hashes_by_session.get(ticket.session_hash) + if session_tickets is not None: + session_tickets.discard(ticket_hash) + if not session_tickets: + self._ticket_hashes_by_session.pop(ticket.session_hash, None) + return ticket + + def _remove_session_locked(self, session_hash: str) -> _StoredSession | None: + record = self._sessions.pop(session_hash, None) + for ticket_hash in tuple(self._ticket_hashes_by_session.get(session_hash, ())): + self._remove_ticket_locked(ticket_hash) + # Defensive cleanup keeps a prior partial mutation from preserving a + # dangling reverse-index bucket even when the session was already gone. + self._ticket_hashes_by_session.pop(session_hash, None) + return record + + def _purge_locked(self, now: float) -> None: + # TTLs are fixed per store and monotonic issue times never decrease, so + # insertion order is expiry order. Only the expired prefix can require + # work; the common request path examines at most one record per type. + while self._sessions: + session_hash = next(iter(self._sessions)) + if now < self._sessions[session_hash].expires_monotonic: + break + self._remove_session_locked(session_hash) + + while self._tickets: + ticket_hash = next(iter(self._tickets)) + if now < self._tickets[ticket_hash].expires_monotonic: + break + self._remove_ticket_locked(ticket_hash) + + def _evict_sessions_locked(self) -> None: + while len(self._sessions) >= self._max_sessions: + self._remove_session_locked(next(iter(self._sessions))) + + def _evict_tickets_locked(self) -> None: + while len(self._tickets) >= self._max_tickets: + self._remove_ticket_locked(next(iter(self._tickets))) + + def issue(self, api_key: str) -> IssuedSession: + normalized = self._normalize_master(api_key) + if not normalized: + raise ValueError("configured API key required") + with self._lock: + self._sync_key_locked(normalized) + now = self._monotonic() + wall_now = self._wall_time() + self._purge_locked(now) + self._evict_sessions_locked() + token, token_hash = self._new_token_locked(ADMIN_SESSION_PREFIX, self._sessions) + expires_monotonic = now + self._session_ttl + expires_at = wall_now + self._session_ttl + self._sessions[token_hash] = _StoredSession( + credential_id=token_hash, + issued_monotonic=now, + expires_monotonic=expires_monotonic, + issued_at=wall_now, + expires_at=expires_at, + ) + return IssuedSession(token=token, expires_at=expires_at) + + def resolve(self, token: str | None, api_key: str | None) -> SessionRecord | None: + if not self._valid_token(token, ADMIN_SESSION_PREFIX): + return None + assert isinstance(token, str) + with self._lock: + if not self._sync_key_locked(api_key): + return None + now = self._monotonic() + self._purge_locked(now) + record = self._sessions.get(_hash_token(token, self._pepper)) + if record is None or now >= record.expires_monotonic: + return None + return record.public() + + def revoke(self, token: str | None) -> bool: + if not self._valid_token(token, ADMIN_SESSION_PREFIX): + return False + assert isinstance(token, str) + token_hash = _hash_token(token, self._pepper) + with self._lock: + return self._remove_session_locked(token_hash) is not None + + def revoke_by_credential(self, credential_id: str | None) -> bool: + if not isinstance(credential_id, str) or len(credential_id) != 64: + return False + with self._lock: + return self._remove_session_locked(credential_id) is not None + + def issue_ws_ticket( + self, + session_token: str | None, + path: str, + api_key: str | None, + ) -> IssuedTicket: + if path not in _ALLOWED_WS_PATHS: + raise ValueError("WebSocket path is not allowed") + if not self._valid_token(session_token, ADMIN_SESSION_PREFIX): + raise PermissionError("valid admin session required") + assert isinstance(session_token, str) + session_hash = _hash_token(session_token, self._pepper) + return self.issue_ws_ticket_for_credential(session_hash, path, api_key) + + def issue_ws_ticket_for_credential( + self, + credential_id: str | None, + path: str, + api_key: str | None, + ) -> IssuedTicket: + if path not in _ALLOWED_WS_PATHS: + raise ValueError("WebSocket path is not allowed") + with self._lock: + if not isinstance(credential_id, str) or len(credential_id) != 64: + raise PermissionError("valid admin session required") + if not self._sync_key_locked(api_key): + raise PermissionError("valid admin session required") + now = self._monotonic() + self._purge_locked(now) + session = self._sessions.get(credential_id) + if session is None or now >= session.expires_monotonic: + raise PermissionError("valid admin session required") + self._evict_tickets_locked() + token, token_hash = self._new_token_locked(WS_TICKET_PREFIX, self._tickets) + expires_at = self._wall_time() + self._ticket_ttl + self._tickets[token_hash] = _StoredTicket( + session_hash=credential_id, + path=path, + issued_monotonic=now, + expires_monotonic=now + self._ticket_ttl, + ) + self._ticket_hashes_by_session.setdefault(credential_id, set()).add( + token_hash + ) + return IssuedTicket(token=token, expires_at=expires_at) + + def consume_ws_ticket( + self, + ticket_token: str | None, + path: str, + api_key: str | None, + ) -> SessionRecord | None: + if not self._valid_token(ticket_token, WS_TICKET_PREFIX): + return None + assert isinstance(ticket_token, str) + with self._lock: + if not self._sync_key_locked(api_key): + return None + now = self._monotonic() + self._purge_locked(now) + ticket = self._remove_ticket_locked( + _hash_token(ticket_token, self._pepper) + ) + if ticket is None or now >= ticket.expires_monotonic or ticket.path != path: + return None + session = self._sessions.get(ticket.session_hash) + if session is None or now >= session.expires_monotonic: + return None + return session.public() + + def clear(self) -> None: + with self._lock: + self._clear_credentials_locked() + self._key_generation = None + + @property + def active_session_count(self) -> int: + with self._lock: + self._purge_locked(self._monotonic()) + return len(self._sessions) + + def debug_snapshot(self) -> dict[str, int]: + with self._lock: + self._purge_locked(self._monotonic()) + return {"sessions": len(self._sessions), "ws_tickets": len(self._tickets)} + + +#: Synthetic ``sys.modules`` key holding the one per-process store. A module +#: object in ``sys.modules`` is the only namespace that survives everything +#: test suites do to this package: ``importlib.reload`` re-executes module +#: code but never touches unrelated ``sys.modules`` entries, and the purges +#: that pop whole ``services.*`` / ``api.*`` trees match package prefixes this +#: underscore-prefixed top-level name is outside of. +_ANCHOR_MODULE_NAME = "_omnivoice_admin_session_store_anchor" + + +def _process_store() -> AdminSessionStore: + """Return THE per-process store, however this module was (re)imported. + + Auth is process-global state: the copy of this module that issues a + credential and the copy that later resolves it must always be looking at + the same store. A bare module-level ``AdminSessionStore()`` breaks that + the moment anything reloads or re-imports this module (fresh module dict → + fresh store → freshly issued sessions vanish for holders of the old + reference, and vice versa). Anchoring the instance outside the module's + own namespace makes every copy of this module share one store. + """ + anchor = sys.modules.get(_ANCHOR_MODULE_NAME) + if not isinstance(anchor, ModuleType): + anchor = ModuleType(_ANCHOR_MODULE_NAME) + anchor.__doc__ = "Process-global anchor for the VoiceStudio admin-session store." + sys.modules[_ANCHOR_MODULE_NAME] = anchor + store = getattr(anchor, "admin_session_store", None) + if store is None: + store = AdminSessionStore() + anchor.admin_session_store = store + return store + + +admin_session_store = _process_store() diff --git a/backend/services/asr_backend.py b/backend/services/asr_backend.py index 29846a1a..4e317ddc 100644 --- a/backend/services/asr_backend.py +++ b/backend/services/asr_backend.py @@ -520,12 +520,10 @@ class WhisperXBackend(ASRBackend): def _pick_device() -> tuple[str, str]: # CUDA fp16 when available; otherwise CPU int8 (fastest CPU path, # negligible WER regression vs fp32 for whisper-large-v3). - try: - import torch - if torch.cuda.is_available(): - return "cuda", "float16" - except Exception: - pass + # _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also + # answers True there, and CTranslate2 has no HIP backend (#1529). + if _ctranslate2_cuda_ok(): + return "cuda", "float16" return "cpu", "int8" # Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2 @@ -981,12 +979,10 @@ class FasterWhisperBackend(ASRBackend): # - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible # WER regression vs fp32 for whisper-large-v3) device, compute_type = "cpu", "int8" - try: - import torch - if torch.cuda.is_available(): - device, compute_type = "cuda", "float16" - except Exception: - pass + # _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also + # answers True there, and CTranslate2 has no HIP backend (#1529). + if _ctranslate2_cuda_ok(): + device, compute_type = "cuda", "float16" logger.info( "faster-whisper loading %s on %s (%s)", self._model_name, device, compute_type, @@ -2464,6 +2460,45 @@ def _mps_available() -> bool: return False +def _cuda_reported_available() -> bool: + """``torch.cuda.is_available()`` verbatim — True on real CUDA *and* HIP.""" + try: + import torch + + return bool(torch.cuda.is_available()) + except Exception: # noqa: BLE001 — no torch + return False + + +def _rocm_torch() -> bool: + """True when torch is the ROCm (HIP) build. + + ROCm torch masquerades as CUDA: ``torch.cuda.is_available()`` answers True + and tensors live on ``"cuda"`` devices, but the CUDA *runtime libraries* + other packages ship are still NVIDIA-only. ``torch.version.hip`` is the + one honest tell. + """ + try: + import torch + + return getattr(torch.version, "hip", None) is not None + except Exception: # noqa: BLE001 — no torch + return False + + +def _ctranslate2_cuda_ok() -> bool: + """Whether CTranslate2 (whisperx / faster-whisper) may use ``"cuda"``. + + CTranslate2 has NO HIP backend. On a ROCm host torch says cuda is + available (HIP), the device string is handed to CTranslate2, and its + NVIDIA CUDA runtime dies with "CUDA driver version is insufficient for + CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the + :rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here + (auto-detect prefers pytorch-whisper there, which does use HIP). + """ + return _cuda_reported_available() and not _rocm_torch() + + def _auto_detect() -> str: """Pick the best available ASR engine **for this hardware**. @@ -2495,6 +2530,14 @@ def _auto_detect() -> str: """ if _mps_available() and _probe_available(MLXWhisperBackend): return "mlx-whisper" + # Same class as the Apple case, on the ROCm axis (#1529): whisperx and + # faster-whisper are CTranslate2, which has no HIP backend — on a ROCm + # host they run on the CPU while the GPU sits idle (and before + # _ctranslate2_cuda_ok they died outright trying NVIDIA's runtime). + # pytorch-whisper is a pure transformers pipeline riding torch itself, + # so it genuinely uses the HIP GPU there. + if _rocm_torch() and _cuda_reported_available() and _probe_available(PyTorchWhisperBackend): + return "pytorch-whisper" if _probe_available(WhisperXBackend): return "whisperx" if _probe_available(FasterWhisperBackend): diff --git a/backend/services/llm_backend.py b/backend/services/llm_backend.py index b895fc0a..cdc63873 100644 --- a/backend/services/llm_backend.py +++ b/backend/services/llm_backend.py @@ -251,10 +251,33 @@ def list_backends() -> list[dict]: "effective_device": "network", "routing_status": "n/a", "routing_reason": None, + # The openai-compat family entry and the LLM Providers panel are + # ONE system (this backend resolves through the active provider), + # but the UI presented them as unrelated. Naming the resolved + # provider + model here lets the catalogue row say which endpoint + # actually answers, instead of a generic family label. + "hint": _provider_hint(bid) if ok else None, }) return out +def _provider_hint(bid: str) -> str | None: + """``Provider · model`` for the openai-compat row, None for everything else.""" + if bid != "openai-compat": + return None + try: + from services import llm_providers + p = llm_providers.active_provider() + if p is None: + return None + model = llm_providers.resolve_model(p) + return f"{p.display_name} · {model}" if model else p.display_name + except Exception: + # The hint is decoration; a provider-registry hiccup must not take + # down the whole engines listing. + return None + + def active_backend_id() -> str: explicit = os.environ.get("OMNIVOICE_LLM_BACKEND") if explicit: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ac99bf4e..45f75272 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -49,9 +49,38 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"): os.environ["OMNIVOICE_MODEL"] = "test" +import functools +import shutil + import pytest +@functools.lru_cache(maxsize=1) +def supports_symlinks() -> bool: + """True when this process may create symlinks. On Windows, + ``os.symlink`` raises OSError without Developer Mode or admin rights, so + symlink-dependent assertions must be skipped there rather than fail.""" + probe_dir = tempfile.mkdtemp(prefix="omnivoice-symlink-probe-") + try: + target = os.path.join(probe_dir, "target") + with open(target, "w", encoding="utf-8"): + pass + try: + os.symlink(target, os.path.join(probe_dir, "link")) + except (OSError, NotImplementedError): + return False + return True + finally: + shutil.rmtree(probe_dir, ignore_errors=True) + + +@pytest.fixture(scope="session") +def symlinks_supported() -> bool: + """Bool fixture over :func:`supports_symlinks` for guarding the + symlink-only assertions of a test while its other assertions still run.""" + return supports_symlinks() + + @pytest.fixture def asr_model_installed(monkeypatch, request): """Neutralize the no-ASR-installed preflight (asr_model_missing_error → diff --git a/backend/tests/test_archetypes_api.py b/backend/tests/test_archetypes_api.py index 4cf3219e..fe91389b 100644 --- a/backend/tests/test_archetypes_api.py +++ b/backend/tests/test_archetypes_api.py @@ -9,7 +9,10 @@ generation.py's proven ``_run_inference`` rather than re-implementing it. """ from __future__ import annotations +import io +import json from pathlib import Path +import wave import pytest @@ -23,6 +26,23 @@ from core import archetypes # noqa: E402 from api.routers import archetypes as arch_router # noqa: E402 +def _wav_bytes() -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(24_000) + wav.writeframes(b"\x00\x01" * 64) + return buf.getvalue() + + +def _write_wav(path: Path) -> bytes: + data = _wav_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return data + + @pytest.fixture(scope="module") def client(): app = FastAPI() @@ -133,8 +153,7 @@ def test_preview_serves_cached_wav_without_model(client): key = arch_router._preview_key(sample) cache_dir = Path(arch_router._PREVIEW_DIR) cache_dir.mkdir(parents=True, exist_ok=True) - dummy = b"RIFF\x24\x00\x00\x00WAVEfmt cached-archetype-preview" - (cache_dir / f"{key}.wav").write_bytes(dummy) + dummy = _write_wav(cache_dir / f"{key}.wav") r = client.get(f"/archetypes/{sample['id']}/preview") assert r.status_code == 200 @@ -143,7 +162,7 @@ def test_preview_serves_cached_wav_without_model(client): # ── Materialize-on-use idempotency (dedup, no re-render) ─────────────────────── -def test_use_is_idempotent_dedup(client, monkeypatch): +def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_supported): """The 2nd `/use` of the same archetype reuses its one materialized profile and does NOT render again — the guarantee that materialize-on-select in any voice picker can't spawn duplicate rows on repeated picks. @@ -151,6 +170,7 @@ def test_use_is_idempotent_dedup(client, monkeypatch): The render boundary (``_render_archetype_wav``) is mocked so no model/GPU is needed: it just drops a stub WAV where the row expects one. """ + from core import event_bus from core.db import init_db init_db() # ensure the voice_profiles table exists in the hermetic tmp DB @@ -159,10 +179,13 @@ def test_use_is_idempotent_dedup(client, monkeypatch): async def _fake_render(a, out_path): render_calls["n"] += 1 - Path(out_path).parent.mkdir(parents=True, exist_ok=True) - Path(out_path).write_bytes(b"RIFF\x24\x00\x00\x00WAVEfmt stub") + _write_wav(Path(out_path)) monkeypatch.setattr(arch_router, "_render_archetype_wav", _fake_render) + emitted = [] + monkeypatch.setattr( + event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)), + ) sample = archetypes.list_archetypes(featured=True)[0] @@ -181,10 +204,248 @@ def test_use_is_idempotent_dedup(client, monkeypatch): from core.db import db_conn with db_conn() as conn: rows = conn.execute( - "SELECT id, kind FROM voice_profiles WHERE personality = ?", (sample["id"],) + "SELECT * FROM voice_profiles WHERE personality = ?", + (arch_router._archetype_personality(sample),), ).fetchall() assert len(rows) == 1 - # An archetype is a voice-design recipe, not an audio clone. Its rendered - # WAV is an identity sample, so it must still use the design conditioning - # branch when selected for generation. assert rows[0]["kind"] == "design" + assert json.loads(rows[0]["vd_states"]) == sample["attrs"] + + with db_conn() as conn: + row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone() + assert row["kind"] == "design" + assert row["instruct"] == sample["instruct"] + assert json.loads(row["vd_states"]) == sample["attrs"] + + # A missing sample or synthesis-input drift must be repaired before the + # existing profile is returned; Preview and Use must describe one voice. + audio_path = arch_router._profile_audio_path(row["ref_audio_path"]) + assert audio_path is not None + audio_path.unlink() + repaired = client.post(f"/archetypes/{sample['id']}/use") + assert repaired.status_code == 200 and repaired.json()["profile_id"] == pid + assert render_calls["n"] == 2 + assert audio_path.read_bytes().startswith(b"RIFF") + + with db_conn() as conn: + conn.execute("UPDATE voice_profiles SET instruct='male' WHERE id=?", (pid,)) + refreshed = client.post(f"/archetypes/{sample['id']}/use") + assert refreshed.status_code == 200 + assert refreshed.json()["profile_id"] != pid + assert render_calls["n"] == 3 + with db_conn() as conn: + edited = conn.execute("SELECT instruct FROM voice_profiles WHERE id=?", (pid,)).fetchone() + assert edited["instruct"] == "male" + + # Continue corruption checks against the new canonical materialization. + pid = refreshed.json()["profile_id"] + with db_conn() as conn: + row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone() + audio_path = arch_router._profile_audio_path(row["ref_audio_path"]) + assert audio_path is not None + + audio_path.write_bytes(b"not a WAV") + repaired_corrupt = client.post(f"/archetypes/{sample['id']}/use") + assert repaired_corrupt.status_code == 200 + assert render_calls["n"] == 4 + + if symlinks_supported: # Windows needs Developer Mode to create symlinks + outside = tmp_path / "outside.wav" + outside_bytes = _write_wav(outside) + audio_path.unlink() + audio_path.symlink_to(outside) + repaired_symlink = client.post(f"/archetypes/{sample['id']}/use") + assert repaired_symlink.status_code == 200 + assert render_calls["n"] == 5 + assert not audio_path.is_symlink() + assert outside.read_bytes() == outside_bytes + + # A valid header with a missing payload is not playable and must self-heal. + renders_before = render_calls["n"] + truncated = _wav_bytes()[:44] + audio_path.write_bytes(truncated) + repaired_truncated = client.post(f"/archetypes/{sample['id']}/use") + assert repaired_truncated.status_code == 200 + assert render_calls["n"] == renders_before + 1 + assert audio_path.read_bytes() != truncated + + +def test_archetype_staged_repair_preserves_concurrently_edited_profile( + client, monkeypatch, +): + """A repair may publish only if the row still belongs to the archetype.""" + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + sample = archetypes.list_archetypes(featured=True)[3] + personality = arch_router._archetype_personality(sample) + edited_personality = f"user-edited:{sample['id']}" + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)", + (sample["id"], personality, edited_personality), + ) + + original_id = {"value": None} + mutation_seen = {"value": False} + + async def racing_render(_item, path): + destination = Path(path) + if destination.name.endswith(".staged.wav"): + assert original_id["value"] is not None + with db_conn() as conn: + conn.execute( + "UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?", + (edited_personality, original_id["value"]), + ) + mutation_seen["value"] = True + _write_wav(destination) + + monkeypatch.setattr(arch_router, "_render_archetype_wav", racing_render) + first = client.post(f"/archetypes/{sample['id']}/use") + assert first.status_code == 200 + original_id["value"] = first.json()["profile_id"] + + with db_conn() as conn: + original = conn.execute( + "SELECT ref_audio_path FROM voice_profiles WHERE id=?", + (original_id["value"],), + ).fetchone() + original_audio = arch_router._profile_audio_path(original["ref_audio_path"]) + assert original_audio is not None + corrupt_bytes = b"corrupt user-owned sample" + original_audio.write_bytes(corrupt_bytes) + + repaired = client.post(f"/archetypes/{sample['id']}/use") + assert repaired.status_code == 200 + repaired_id = repaired.json()["profile_id"] + assert mutation_seen["value"] + assert repaired_id != original_id["value"] + + with db_conn() as conn: + edited = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],), + ).fetchone() + canonical = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (repaired_id,), + ).fetchone() + canonical_count = conn.execute( + "SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,), + ).fetchone()[0] + assert edited["name"] == "User edit" + assert edited["personality"] == edited_personality + assert edited["instruct"] == sample["instruct"] + assert original_audio.read_bytes() == corrupt_bytes + assert canonical["personality"] == personality + assert canonical["ref_audio_path"] == arch_router._profile_audio_filename(repaired_id) + assert canonical_count == 1 + assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes() + assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav")) + + +def test_archetype_use_adopts_only_a_compatible_legacy_row(client, monkeypatch): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + sample = archetypes.list_archetypes(featured=True)[1] + legacy_id = "legacyarch" + legacy_audio = Path(VOICES_DIR) / f"{legacy_id}.wav" + _write_wav(legacy_audio) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (sample["id"], arch_router._archetype_personality(sample)), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, created_at) VALUES (?, 'Legacy archetype', ?, ?, ?, ?, 42, ?, " + "'clone', NULL, 1)", + ( + legacy_id, legacy_audio.name, sample["sample_script"], sample["instruct"], + sample["language"], sample["id"], + ), + ) + + async def unexpected_render(*_args): + raise AssertionError("a valid legacy archetype sample must be reused") + + monkeypatch.setattr(arch_router, "_render_archetype_wav", unexpected_render) + response = client.post(f"/archetypes/{sample['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] == legacy_id + with db_conn() as conn: + row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (legacy_id,)).fetchone() + assert row["personality"] == arch_router._archetype_personality(sample) + assert row["kind"] == "design" + assert json.loads(row["vd_states"]) == sample["attrs"] + + +def test_archetype_use_does_not_rewrite_an_imported_personality_collision( + client, monkeypatch, +): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + sample = archetypes.list_archetypes(featured=True)[2] + imported_id = "importedarch" + imported_ns_id = "importedarchns" + imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav" + imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav" + original_audio = _write_wav(imported_audio) + original_ns_audio = _write_wav(imported_ns_audio) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (sample["id"], arch_router._archetype_personality(sample)), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, " + "'clone', 1, 1, 1)", + (imported_id, imported_audio.name, sample["id"]), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, " + "'design', NULL, 0, 0, 2)", + ( + imported_ns_id, imported_ns_audio.name, sample["sample_script"], + sample["instruct"], sample["language"], + arch_router._archetype_personality(sample), + ), + ) + + async def render(_item, path): + _write_wav(Path(path)) + + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + response = client.post(f"/archetypes/{sample['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] != imported_id + with db_conn() as conn: + imported = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_id,), + ).fetchone() + imported_ns = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,), + ).fetchone() + created = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert imported["personality"] == sample["id"] + assert imported["instruct"] == "male" + assert imported["ref_text"] == "user transcript" + assert imported_audio.read_bytes() == original_audio + assert imported_ns["instruct"] == sample["instruct"] + assert imported_ns["ref_text"] == sample["sample_script"] + assert imported_ns["vd_states"] is None + assert imported_ns_audio.read_bytes() == original_ns_audio + assert created["personality"] == arch_router._archetype_personality(sample) diff --git a/backend/tests/test_audio_validation.py b/backend/tests/test_audio_validation.py new file mode 100644 index 00000000..5948d494 --- /dev/null +++ b/backend/tests/test_audio_validation.py @@ -0,0 +1,44 @@ +"""Regression tests for the lightweight persisted-WAV trust boundary.""" +from __future__ import annotations + +import struct + +from core.audio_validation import is_playable_wav, resolve_regular_file + + +def test_oversized_declared_wav_payload_is_not_treated_as_playable(tmp_path): + """A hostile frame count must be bounded and backed by real payload bytes.""" + path = tmp_path / "oversized.wav" + declared_size = 0xFFFF_FFF0 + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", + 0xFFFF_FFFF, + b"WAVE", + b"fmt ", + 16, + 1, + 1, + 24_000, + 48_000, + 2, + 16, + b"data", + declared_size, + ) + path.write_bytes(header + b"\x00\x01") + + assert not is_playable_wav(path) + + +def test_profile_wav_resolution_rejects_escape_and_symlink(tmp_path, symlinks_supported): + root = tmp_path / "voices" + root.mkdir() + outside = tmp_path / "outside.wav" + outside.write_bytes(b"outside") + + assert resolve_regular_file(root, "../outside.wav") is None + assert resolve_regular_file(root, str(outside)) is None + if symlinks_supported: # Windows needs Developer Mode to create symlinks + (root / "linked.wav").symlink_to(outside) + assert resolve_regular_file(root, "linked.wav") is None diff --git a/backend/tests/test_community.py b/backend/tests/test_community.py index 42396e99..b578e4e8 100644 --- a/backend/tests/test_community.py +++ b/backend/tests/test_community.py @@ -1,25 +1,43 @@ """Tests for the community gallery (marketplace) loader. -Covers the no-network surface: strict item validation (invalid presets and -unsafe audio URLs are dropped so they can never crash synthesis or fetch from -an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and -the prefilled submit URL. The render/download paths need the model/network and -are exercised at runtime. +Covers strict item validation, manifest/cache boundaries, same-origin preview, +and idempotent profile materialization without a model or network dependency. """ from __future__ import annotations +import io import json +import os +from pathlib import Path +import wave import pytest # conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a # throwaway tmpdir before this module imports the REAL core.config (the old # sys.modules stub leaked at collection time and broke mixed runs). -from fastapi import FastAPI # noqa: E402 +from fastapi import FastAPI, HTTPException, Response # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from api.routers import community # noqa: E402 + +def _wav_bytes() -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(24_000) + wav.writeframes(b"\x00\x01" * 64) + return buf.getvalue() + + +def _write_wav(path: Path) -> bytes: + data = _wav_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return data + _FIXTURE = { "schema_version": 1, "items": [ @@ -75,12 +93,51 @@ def test_unknown_use_case_dropped(): assert community.validate_item(_FIXTURE["items"][4]) is None +def test_malformed_manifest_entries_do_not_break_other_sources(): + valid = _FIXTURE["items"][0] + items, packs = community._merge([ + ("bad/repo", {"items": 42, "packs": "not-a-list"}), + ("good/repo", {"items": [None, "not-an-item", valid], "packs": [None]}), + ]) + + assert [item["id"] for item in items] == [valid["id"]] + assert packs == [] + + def test_is_valid_instruct(): assert community.is_valid_instruct("male, elderly, very low pitch") assert not community.is_valid_instruct("male, sultry") + assert not community.is_valid_instruct("male, female") + assert not community.is_valid_instruct("british accent, 四川话") assert not community.is_valid_instruct("") +def test_preset_attrs_are_normalized_and_complete(): + item = community.validate_item(_FIXTURE["items"][0]) + assert item["instruct"] == "female, middle-aged, low pitch" + assert item["attrs"] == { + "Gender": "female", "Age": "middle-aged", "Pitch": "low pitch", + "Style": "Auto", "EnglishAccent": "Auto", "ChineseDialect": "Auto", + } + assert item["preview_url"] == "/community/items/p1/preview" + + +def test_remote_transcript_fields_are_bounded(): + preset = community.validate_item({ + **_FIXTURE["items"][0], + "sample_script": " x " * (community._MAX_SAMPLE_SCRIPT_CHARS + 10), + }) + voice = community.validate_item({ + **_FIXTURE["items"][3], + "audio": { + **_FIXTURE["items"][3]["audio"], + "ref_text": " y " * (community._MAX_REF_TEXT_CHARS + 10), + }, + }) + assert len(preset["sample_script"]) == community._MAX_SAMPLE_SCRIPT_CHARS + assert len(voice["audio"]["ref_text"]) == community._MAX_REF_TEXT_CHARS + + # ── merge keeps only valid items ────────────────────────────────────────────── def test_merge_drops_invalid_and_dedups(): items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)]) @@ -116,3 +173,620 @@ def test_submit_url(client): voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"] assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset assert "voice-submission.yml" in voice + + +# ── bounded cache freshness + stale offline fallback ───────────────────────── +def test_stale_manifest_refreshes_then_stays_fresh(tmp_path, monkeypatch): + monkeypatch.setattr(community, "_CACHE_DIR", tmp_path) + source = "debpalash/omnivoice-gallery" + cache = community._cache_path(source) + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps(_FIXTURE), encoding="utf-8") + os.utime(cache, (100.0, 100.0)) + + fresh = {**_FIXTURE, "updated_at": "new"} + calls = [] + monkeypatch.setattr( + community, "_fetch_remote_manifest", + lambda src: calls.append(src) or fresh, + ) + now = 100.0 + community._MANIFEST_MAX_AGE_S + 1 + assert community._fetch_manifest(source, False, now=now)["updated_at"] == "new" + assert community._fetch_manifest(source, False, now=now + 1)["updated_at"] == "new" + assert calls == [source] + + +def test_stale_manifest_falls_back_and_throttles_offline_retry(tmp_path, monkeypatch): + monkeypatch.setattr(community, "_CACHE_DIR", tmp_path) + source = "debpalash/omnivoice-gallery" + cache = community._cache_path(source) + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps(_FIXTURE), encoding="utf-8") + os.utime(cache, (100.0, 100.0)) + + calls = [] + def offline(src): + calls.append(src) + raise OSError("offline") + monkeypatch.setattr(community, "_fetch_remote_manifest", offline) + now = 100.0 + community._MANIFEST_MAX_AGE_S + 1 + assert community._fetch_manifest(source, False, now=now) == _FIXTURE + assert community._fetch_manifest(source, False, now=now + 1) == _FIXTURE + assert calls == [source] + + +def test_manifest_fetch_is_bounded(monkeypatch): + monkeypatch.setattr(community, "_MAX_MANIFEST_BYTES", 8) + + class Response: + status_code = 200 + headers = {} + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield b'{"items":[]}' + class Client: + def stream(self, method, url, **kwargs): + assert method == "GET" + assert url.startswith("https://cdn.jsdelivr.net/") + assert kwargs == {"follow_redirects": False} + return Response() + + with pytest.raises(ValueError, match="size limit"): + community._fetch_remote_manifest("test/source", client=Client()) + + +def test_manifest_fetch_rejects_redirect_before_external_request(): + requested = [] + + class Response: + status_code = 302 + headers = {"location": "https://evil.example/manifest.json"} + def __enter__(self): return self + def __exit__(self, *_args): return False + class Client: + def stream(self, _method, url, **_kwargs): + requested.append(url) + return Response() + + with pytest.raises(ValueError, match="disallowed host"): + community._fetch_remote_manifest("test/source", client=Client()) + assert requested == [community._manifest_url("test/source")] + + +# ── Preview proxy ───────────────────────────────────────────────────────────── +def test_canonical_preset_preview_delegates_same_origin(client, monkeypatch): + from core import archetypes + from api.routers import archetypes as arch_router + + canonical = archetypes.list_archetypes(featured=True)[0] + item = community.validate_item({ + **canonical, "type": "preset", "source": "starter", + }) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + delegated = [] + + async def preview(archetype_id, local=False): + delegated.append((archetype_id, local)) + return Response(_wav_bytes(), media_type="audio/wav") + + monkeypatch.setattr(arch_router, "preview_archetype", preview) + response = client.get(f"/community/items/{item['id']}/preview") + local = client.get(f"/community/items/{item['id']}/preview?local=true") + + assert response.status_code == local.status_code == 200 + assert "location" not in response.headers + assert delegated == [(item["id"], False), (item["id"], True)] + + +def test_noncanonical_preset_preview_renders_once(client, tmp_path, monkeypatch): + item = community.validate_item(_FIXTURE["items"][0]) + monkeypatch.setattr(community, "_CACHE_DIR", tmp_path) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + from api.routers import archetypes as arch_router + calls = [] + async def render(_item, path): + calls.append(path) + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + + first = client.get("/community/items/p1/preview") + second = client.get("/community/items/p1/preview") + assert first.status_code == second.status_code == 200 + assert first.content == _wav_bytes() + assert first.headers["x-omnivoice-preview-source"] == "community" + assert len(calls) == 1 + + community._preset_preview_path(item).write_bytes(b"not audio") + repaired = client.get("/community/items/p1/preview") + assert repaired.status_code == 200 + assert repaired.content == _wav_bytes() + assert len(calls) == 2 + + +def test_recorded_preview_is_served_from_same_origin(client, tmp_path, monkeypatch): + item = community.validate_item(_FIXTURE["items"][3]) + clip = tmp_path / "voice.wav" + expected = _write_wav(clip) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + monkeypatch.setattr(community, "_cached_voice_audio", lambda _item: clip) + response = client.get("/community/items/v1/preview") + assert response.status_code == 200 + assert response.content == expected + + +def test_recorded_download_cap_is_atomic(tmp_path, monkeypatch): + item = community.validate_item(_FIXTURE["items"][3]) + destination = tmp_path / "voice.wav" + destination.write_bytes(b"existing-good-audio") + monkeypatch.setattr(community, "_MAX_VOICE_AUDIO_BYTES", 8) + + class Response: + status_code = 200 + headers = {} + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield b"123456789" + class Client: + def stream(self, method, url, **kwargs): + assert method == "GET" and url.startswith("https://github.com/") + assert kwargs == {"follow_redirects": False} + return Response() + + with pytest.raises(HTTPException) as exc: + community._download_voice_audio(item, destination, client=Client()) + assert getattr(exc.value, "status_code", None) == 502 + assert destination.read_bytes() == b"existing-good-audio" + assert not list(tmp_path.glob(".*.part")) + + +def test_recorded_download_rejects_redirect_before_external_request(tmp_path): + item = community.validate_item(_FIXTURE["items"][3]) + requested = [] + + class Response: + status_code = 302 + headers = {"location": "https://evil.example/private.wav"} + def __enter__(self): return self + def __exit__(self, *_args): return False + class Client: + def stream(self, _method, url, **_kwargs): + requested.append(url) + return Response() + + with pytest.raises(HTTPException) as exc: + community._download_voice_audio(item, tmp_path / "voice.wav", client=Client()) + assert getattr(exc.value, "status_code", None) == 502 + assert requested == [item["audio"]["url"]] + + +def test_recorded_download_follows_allowlisted_redirect(tmp_path): + item = community.validate_item(_FIXTURE["items"][3]) + destination = tmp_path / "voice.wav" + requested = [] + expected = _wav_bytes() + + class Response: + def __init__(self, status, headers, body=b""): + self.status_code, self.headers, self.body = status, headers, body + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield self.body + class Client: + def stream(self, _method, url, **_kwargs): + requested.append(url) + if len(requested) == 1: + return Response(302, {"location": "https://objects.githubusercontent.com/v1.wav"}) + return Response(200, {}, expected) + + community._download_voice_audio(item, destination, client=Client()) + assert destination.read_bytes() == expected + assert requested == [item["audio"]["url"], "https://objects.githubusercontent.com/v1.wav"] + + +def test_recorded_download_rejects_non_audio_bytes(tmp_path): + item = community.validate_item(_FIXTURE["items"][3]) + destination = tmp_path / "voice.wav" + + class Response: + status_code = 200 + headers = {} + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield b"this is not audio" + class Client: + def stream(self, _method, _url, **_kwargs): return Response() + + with pytest.raises(HTTPException, match="valid WAV"): + community._download_voice_audio(item, destination, client=Client()) + assert not destination.exists() + assert not list(tmp_path.glob(".*.part")) + + +# ── Materialization ─────────────────────────────────────────────────────────── +def test_community_use_is_idempotent_design_profile( + client, tmp_path, monkeypatch, symlinks_supported, +): + from core import event_bus + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + calls = [] + emitted = [] + async def render(_item, path): + calls.append(path) + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + monkeypatch.setattr( + event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)), + ) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + + first = client.post("/community/items/p1/use") + second = client.post("/community/items/p1/use") + assert first.status_code == second.status_code == 200 + assert second.json()["profile_id"] == first.json()["profile_id"] + assert len(calls) == 1 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "design" + assert row["personality"] == personality + assert json.loads(row["vd_states"])["Gender"] == "female" + assert row["instruct"] == item["instruct"] + assert emitted[-1] == ( + "profiles", {"action": "updated", "id": first.json()["profile_id"]}, + ) + + profile_audio = community._stored_profile_audio(row["ref_audio_path"]) + assert profile_audio is not None + profile_audio.unlink() + repaired = client.post("/community/items/p1/use") + assert repaired.status_code == 200 + assert repaired.json()["profile_id"] == first.json()["profile_id"] + assert profile_audio.read_bytes() == _wav_bytes() + # The current preset preview cache repairs the profile without another + # model render. + assert len(calls) == 1 + + profile_audio.write_bytes(b"not a WAV") + repaired_corrupt = client.post("/community/items/p1/use") + assert repaired_corrupt.status_code == 200 + assert profile_audio.read_bytes() == _wav_bytes() + + if symlinks_supported: # Windows needs Developer Mode to create symlinks + outside = tmp_path / "outside.wav" + outside_bytes = _write_wav(outside) + profile_audio.unlink() + profile_audio.symlink_to(outside) + repaired_symlink = client.post("/community/items/p1/use") + assert repaired_symlink.status_code == 200 + assert not profile_audio.is_symlink() + assert outside.read_bytes() == outside_bytes + + +def test_community_staged_repair_preserves_concurrently_edited_profile( + client, monkeypatch, +): + """A staged community repair must not reclaim a row edited mid-copy.""" + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + edited_personality = f"user-edited:{personality}" + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)", + (item["id"], personality, edited_personality), + ) + _write_wav(community._preset_preview_path(item)) + + original_id = {"value": None} + mutation_seen = {"value": False} + real_copy_atomic = community._copy_atomic + + def racing_copy(source, destination): + destination = Path(destination) + if destination.name.endswith(".staged.wav"): + assert original_id["value"] is not None + with db_conn() as conn: + conn.execute( + "UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?", + (edited_personality, original_id["value"]), + ) + mutation_seen["value"] = True + real_copy_atomic(Path(source), destination) + + monkeypatch.setattr(community, "_copy_atomic", racing_copy) + first = client.post(f"/community/items/{item['id']}/use") + assert first.status_code == 200 + original_id["value"] = first.json()["profile_id"] + + with db_conn() as conn: + original = conn.execute( + "SELECT ref_audio_path FROM voice_profiles WHERE id=?", + (original_id["value"],), + ).fetchone() + original_audio = community._stored_profile_audio(original["ref_audio_path"]) + assert original_audio is not None + corrupt_bytes = b"corrupt user-owned sample" + original_audio.write_bytes(corrupt_bytes) + + repaired = client.post(f"/community/items/{item['id']}/use") + assert repaired.status_code == 200 + repaired_id = repaired.json()["profile_id"] + assert mutation_seen["value"] + assert repaired_id != original_id["value"] + + with db_conn() as conn: + edited = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],), + ).fetchone() + canonical = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (repaired_id,), + ).fetchone() + canonical_count = conn.execute( + "SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,), + ).fetchone()[0] + assert edited["name"] == "User edit" + assert edited["personality"] == edited_personality + assert edited["instruct"] == item["instruct"] + assert original_audio.read_bytes() == corrupt_bytes + assert canonical["personality"] == personality + assert canonical["ref_audio_path"] == community._community_profile_audio_filename( + repaired_id, item, + ) + assert canonical_count == 1 + assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes() + assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav")) + + +def test_recorded_community_use_is_idempotent_clone_profile(client, tmp_path, monkeypatch): + from core.db import db_conn, init_db + + init_db() + item = community.validate_item(_FIXTURE["items"][3]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + clip = tmp_path / "recorded.wav" + _write_wav(clip) + cache_calls = [] + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + monkeypatch.setattr( + community, "_cached_voice_audio", lambda _item: cache_calls.append(_item["id"]) or clip, + ) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + + first = client.post("/community/items/v1/use") + second = client.post("/community/items/v1/use") + assert first.status_code == second.status_code == 200 + assert second.json()["profile_id"] == first.json()["profile_id"] + assert cache_calls == ["v1"] + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "clone" + assert row["personality"] == personality + assert row["vd_states"] is None and row["instruct"] == "" + assert row["ref_text"] == "" + + old_audio_filename = row["ref_audio_path"] + item["audio"]["url"] = "https://raw.githubusercontent.com/test/source/main/v2.wav" + refreshed = client.post("/community/items/v1/use") + assert refreshed.status_code == 200 + assert refreshed.json()["profile_id"] == first.json()["profile_id"] + assert cache_calls == ["v1", "v1"] + with db_conn() as conn: + refreshed_row = conn.execute( + "SELECT ref_audio_path FROM voice_profiles WHERE id=?", + (first.json()["profile_id"],), + ).fetchone() + assert refreshed_row["ref_audio_path"] != old_audio_filename + + +def test_noncanonical_builtin_id_cannot_heal_archetype_profile(client, monkeypatch): + from core import archetypes + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + canonical = archetypes.list_archetypes(featured=True)[0] + changed_instruct = "female" if canonical["instruct"] != "female" else "male" + item = community.validate_item({ + **canonical, + "type": "preset", + "source": "community", + "instruct": changed_instruct, + }) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + builtin_profile_id = f"b{os.urandom(4).hex()[:7]}" + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (canonical["id"], personality), + ) + conn.execute( + "INSERT INTO voice_profiles (id, name, personality, instruct, kind, created_at) " + "VALUES (?, 'Built-in profile', ?, 'sentinel', 'design', 1)", + (builtin_profile_id, canonical["id"]), + ) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + async def render(_item, path): + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + + response = client.post(f"/community/items/{canonical['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] != builtin_profile_id + with db_conn() as conn: + builtin = conn.execute( + "SELECT instruct FROM voice_profiles WHERE id=?", (builtin_profile_id,), + ).fetchone() + community_row = conn.execute( + "SELECT personality FROM voice_profiles WHERE id=?", + (response.json()["profile_id"],), + ).fetchone() + conn.execute( + "DELETE FROM voice_profiles WHERE id IN (?, ?)", + (builtin_profile_id, response.json()["profile_id"]), + ) + assert builtin["instruct"] == "sentinel" + assert community_row["personality"] == personality + + +def test_community_use_does_not_rewrite_an_imported_bare_id_collision( + client, monkeypatch, +): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + imported_id = "importedcomm" + imported_ns_id = "importedcommns" + imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav" + imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav" + original_audio = _write_wav(imported_audio) + original_ns_audio = _write_wav(imported_ns_audio) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, " + "'clone', 1, 1, 1)", + (imported_id, imported_audio.name, item["id"]), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, " + "'design', NULL, 0, 0, 2)", + ( + imported_ns_id, imported_ns_audio.name, item["sample_script"], + item["instruct"], item["language"], personality, + ), + ) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + + async def render(_item, path): + _write_wav(Path(path)) + + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + response = client.post(f"/community/items/{item['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] != imported_id + with db_conn() as conn: + imported = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_id,), + ).fetchone() + imported_ns = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,), + ).fetchone() + created = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert imported["personality"] == item["id"] + assert imported["instruct"] == "male" + assert imported["ref_text"] == "user transcript" + assert imported_audio.read_bytes() == original_audio + assert imported_ns["instruct"] == item["instruct"] + assert imported_ns["ref_text"] == item["sample_script"] + assert imported_ns["vd_states"] is None + assert imported_ns_audio.read_bytes() == original_ns_audio + assert created["personality"] == personality + + +def test_noncolliding_legacy_community_profile_is_adopted(client, monkeypatch): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + legacy_id = f"l{os.urandom(4).hex()[:7]}" + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, created_at) VALUES " + "(?, 'Legacy community profile', ?, '', ?, ?, NULL, ?, 'design', NULL, 1)", + (legacy_id, f"{legacy_id}.wav", item["instruct"], item["language"], item["id"]), + ) + _write_wav(Path(VOICES_DIR) / f"{legacy_id}.wav") + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + community._preset_preview_path(item).unlink(missing_ok=True) + rendered = [] + async def render(_item, path): + rendered.append(path) + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + + response = client.post(f"/community/items/{item['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] == legacy_id + assert len(rendered) == 1 + with db_conn() as conn: + adopted = conn.execute( + "SELECT personality, kind, ref_audio_path FROM voice_profiles WHERE id=?", + (legacy_id,), + ).fetchone() + assert adopted["personality"] == personality + assert adopted["kind"] == "design" + adopted_audio = community._stored_profile_audio(adopted["ref_audio_path"]) + assert adopted_audio is not None and adopted_audio.is_file() diff --git a/backend/tests/test_gallery_profiles.py b/backend/tests/test_gallery_profiles.py new file mode 100644 index 00000000..942d5608 --- /dev/null +++ b/backend/tests/test_gallery_profiles.py @@ -0,0 +1,250 @@ +"""Gallery-import profile materialization contracts.""" +from __future__ import annotations + +import shutil +import sqlite3 +import time +import uuid +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routers import gallery +from core.db import db_conn, init_db + + +@pytest.fixture(scope="module") +def client(): + init_db() + gallery._init_gallery_db() + app = FastAPI() + app.include_router(gallery.router) + return TestClient(app) + + +def _gallery_voice( + suffix: str = ".wav", content: bytes = b"RIFF imported voice", +) -> tuple[str, Path]: + voice_id = f"g{uuid.uuid4().hex[:7]}" + path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + with db_conn() as conn: + conn.execute( + """INSERT INTO voice_gallery + (id, name, character, category, source_type, source_url, audio_path, + duration, description, tags, created_at) + VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""", + ( + voice_id, "Imported narrator", "Video title is not an instruct", + "https://example.invalid/source", str(path), + "Source URL/notes are not a spoken transcript", time.time(), + ), + ) + return voice_id, path + + +def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client): + voice_id, _ = _gallery_voice() + response = client.post( + f"/gallery/voices/{voice_id}/save-as-profile", + params={"profile_name": "Reusable import"}, + ) + assert response.status_code == 200 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "clone" + assert row["personality"] == f"gallery:{voice_id}" + assert row["ref_text"] == "" + assert row["instruct"] == "" + assert row["description"] == "Source URL/notes are not a spoken transcript" + + +def test_to_profile_uses_live_schema_and_clone_metadata(client): + voice_id, _ = _gallery_voice() + response = client.post(f"/gallery/voices/{voice_id}/to-profile") + assert response.status_code == 200 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "clone" + assert row["personality"] == f"gallery:{voice_id}" + assert row["ref_text"] == row["instruct"] == "" + assert row["description"] == "Source URL/notes are not a spoken transcript" + + +def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch): + emitted = [] + monkeypatch.setattr( + gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)), + ) + voice_id, _ = _gallery_voice() + first = client.post( + f"/gallery/voices/{voice_id}/save-as-profile", + params={"profile_name": "One reusable profile"}, + ) + repeated = client.post( + f"/gallery/voices/{voice_id}/save-as-profile", + params={"profile_name": "Ignored duplicate name"}, + ) + alternate = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert first.status_code == repeated.status_code == alternate.status_code == 200 + assert { + first.json()["profile_id"], + repeated.json()["profile_id"], + alternate.json()["profile_id"], + } == {first.json()["profile_id"]} + with db_conn() as conn: + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=?", + (f"gallery:{voice_id}",), + ).fetchall() + assert len(rows) == 1 + assert rows[0]["name"] == "One reusable profile" + assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None + assert emitted[-1] == ( + "profiles", {"action": "updated", "id": first.json()["profile_id"]}, + ) + + +def test_gallery_profile_repairs_a_missing_copy_without_duplication(client): + voice_id, source = _gallery_voice() + first = client.post(f"/gallery/voices/{voice_id}/to-profile") + assert first.status_code == 200 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],), + ).fetchone() + copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"] + copied.unlink() + + repaired = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert repaired.status_code == 200 + assert repaired.json()["profile_id"] == first.json()["profile_id"] + assert copied.read_bytes() == source.read_bytes() + + +def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client): + voice_id, source = _gallery_voice() + collision_id = f"c{uuid.uuid4().hex[:7]}" + personality = f"gallery:{voice_id}" + collision_name = gallery._gallery_profile_audio_filename(collision_id, source) + collision_audio = Path(gallery.VOICES_DIR) / collision_name + collision_audio.parent.mkdir(parents=True, exist_ok=True) + collision_audio.write_bytes(b"user-owned audio") + with db_conn() as conn: + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "description, kind, vd_states, is_locked, verified_own_voice, created_at) " + "VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, " + "'user-owned metadata', 'clone', NULL, 0, 0, ?)", + (collision_id, collision_name, personality, time.time()), + ) + + response = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert response.status_code == 200 + assert response.json()["profile_id"] != collision_id + with db_conn() as conn: + collision = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (collision_id,), + ).fetchone() + created = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert collision["description"] == "user-owned metadata" + assert collision_audio.read_bytes() == b"user-owned audio" + assert created["personality"] == personality + + +def _part_files() -> set[Path]: + return set(Path(gallery.VOICES_DIR).glob("*.part")) | set( + Path(gallery.VOICES_DIR).glob(".*.part") + ) + + +def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch): + """The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction. + + While the copy runs, another backend writer takes (and releases) SQLite's + write lock. If materialization copied inside its own write transaction, + this concurrent writer would hit `database is locked` and the test fails. + """ + from core.config import DB_PATH + + voice_id, _ = _gallery_voice() + real_copy2 = shutil.copy2 + concurrent_writes = [] + + def copy_and_probe(src, dst, **kwargs): + probe = sqlite3.connect(DB_PATH, timeout=0.5) + try: + probe.execute("BEGIN IMMEDIATE") + probe.execute( + "UPDATE voice_gallery SET category = category WHERE id = ?", + (voice_id,), + ) + probe.commit() + concurrent_writes.append(True) + finally: + probe.close() + return real_copy2(src, dst, **kwargs) + + monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe) + + response = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert response.status_code == 200 + assert concurrent_writes == [True] + assert _part_files() == set() + + +def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch): + """A copy that dies mid-write must not leave .part files or a DB row.""" + voice_id, _ = _gallery_voice() + + def exploding_copy(src, dst, **kwargs): + Path(dst).write_bytes(b"partial bytes") + raise OSError("disk full mid-copy") + + monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy) + + with pytest.raises(OSError, match="disk full mid-copy"): + client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert _part_files() == set() + with db_conn() as conn: + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality = ?", + (f"gallery:{voice_id}",), + ).fetchall() + assert rows == [] + + +def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client): + voice_id, source = _gallery_voice() + + response = client.get( + f"/gallery/voices/{voice_id}/preview", follow_redirects=False, + ) + + assert response.status_code == 200 + assert "location" not in response.headers + assert response.content == source.read_bytes() + + +def test_gallery_preview_preserves_non_wav_content_type(client): + voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice") + + response = client.get(f"/gallery/voices/{voice_id}/preview") + + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/mpeg" diff --git a/backend/worker/transport/client.py b/backend/worker/transport/client.py index 9f35e4a2..37dd8723 100644 --- a/backend/worker/transport/client.py +++ b/backend/worker/transport/client.py @@ -657,8 +657,25 @@ class WorkerClient: ) ) return - await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref))) + # Reserve the slot BEFORE the accept-send await: awaiting yields to + # the event loop, and a concurrently delivered assignment would read + # the un-reserved counter and over-accept past capacity (#1536 — a + # capacity-1 worker accepted a second task on a slow runner). Message + # order on the stream survives the swap: _send enqueues synchronously + # (put_nowait before any suspension), so ACCEPTED is in the outbox + # before this handler ever yields to the just-created _run task. self._running[key] = asyncio.create_task(self._run(assignment)) + try: + await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref))) + except BaseException: + # BaseException, not Exception: a handler CANCELLED mid-send must + # release the slot too, or the reserved task keeps running work + # the scheduler never saw accepted — and double-executes after + # reassignment. The stream-death case lands here as well. + task = self._running.pop(key, None) + if task is not None: + task.cancel() + raise async def _run(self, assignment: pb.TaskAssignment) -> None: key = self._key(assignment.ref) diff --git a/bun.lock b/bun.lock index 5421c87a..03d1055f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "frontend": { "name": "omnivoice-studio", - "version": "0.4.2", + "version": "0.5.0", "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/source-serif-4": "^5.2.9", diff --git a/docs/api-auth.md b/docs/api-auth.md index 5b1bcb26..89c24c5d 100644 --- a/docs/api-auth.md +++ b/docs/api-auth.md @@ -12,7 +12,7 @@ env var that exempts trusted callers: | Gate | Turn on with | Guards | Applies to | |---|---|---|---| | **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** | -| **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** | +| **API key** | `OMNIVOICE_API_KEY` env var on the backend | direct clients and first-party session bootstrap | non-loopback **HTTP + WebSocket** | | **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only | Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local @@ -25,7 +25,9 @@ tools keep working unchanged whichever gate is set. > desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)). > Both gates can be active at once. The PIN and the API key are independent; when -> both are set, each is checked on the paths it covers. +> both are set, each is checked on the paths it covers. Session exchange validates +> the master key before the PIN gate so the UI can bootstrap safely; ordinary HTTP +> requests still require the PIN afterward, and the UI prompts for it next. --- @@ -42,14 +44,14 @@ present it. Supply it any one of three ways: | Where | How | |---|---| -| Header | `X-VoiceStudio-Pin: ` | +| Header | `X-OmniVoice-Pin: ` | | Query param | `?pin=` | | Cookie | `ov_pin=` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once | ```bash # From another device on the LAN — with the PIN curl http://:3900/v1/audio/voices \ - -H "X-VoiceStudio-Pin: 123456" + -H "X-OmniVoice-Pin: 123456" ``` A missing or wrong PIN returns: @@ -75,9 +77,11 @@ Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`): ## API key -The API key is the durable credential for running the backend somewhere and -driving it remotely — a GPU box on your tailnet, a Docker container, a -reverse-proxied host. Set it on the **backend** process: +The API key is the backend's durable root credential for a GPU box, Docker +container, or reverse-proxied host. Direct API clients may send it on each +request. The first-party browser/Tauri UI instead exchanges it once for a +short-lived administrator session and never stores the master. Set it on the +**backend** process: ```bash # Generate a strong key and start the backend with it @@ -87,14 +91,16 @@ uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900 ``` While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket** -request must present it (the SPA shell paths below are the only HTTP exception). -Supply it any one of three ways: +request must present an accepted credential. SPA shell paths remain public; +`POST /api/auth/session` passes through the middleware only so its route can +validate the master and perform the one-time exchange. Direct-client +compatibility accepts: | Where | How | |---|---| -| Header | `Authorization: Bearer ` — **preferred**; the one place a key isn't at risk of landing in a log | -| Cookie | `ov_key=` — set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets | -| Query param | `?api_key=` — last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie | +| Header | `Authorization: Bearer ` — **preferred** for scripts and SDKs | +| Legacy cookie | `ov_key=` — accepted only for compatibility and migrated by the first-party UI; the backend no longer creates it | +| Legacy query param | `?api_key=` — compatibility only. **A key in a URL leaks into proxy/access logs and browser history** | ```bash # Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain @@ -133,6 +139,8 @@ code **1008** (policy violation) instead of a JSON body. Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`): - The key is compared in **constant time** and is **never logged**. +- The backend never copies the master into a response cookie. Browser clients + receive only `ov_session`, an opaque, HttpOnly, SameSite=Strict credential. - The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and show what's wrong; WebSockets have no such exemption. - **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile @@ -140,6 +148,40 @@ Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`): anything beyond a fully trusted LAN. See [docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup. +### First-party administrator sessions + +The bundled UI uses a narrower protocol: + +1. `POST /api/auth/session` receives the master in an `Authorization` header + exactly once and selects `{"transport":"cookie"}` for exact same-origin + browsers or `{"transport":"bearer"}` for Tauri/cross-origin clients. +2. Cookie transport returns `204` and sets `ov_session` as HttpOnly, + SameSite=Strict, path `/`, with an eight-hour maximum lifetime. Bearer + transport returns an opaque `ovs_admin_session_…` value which the UI keeps + in **sessionStorage only**, bound to the exact backend base URL. Bearer JSON + responses include both `expires_at` and a bounded `expires_in`; the UI uses + the relative lifetime when available so clock skew between a remote GPU host + and the browser cannot reject a valid session. `expires_at` remains for + backward compatibility with older clients and servers. +3. `DELETE /api/auth/session` revokes the session. Removing or rotating + `OMNIVOICE_API_KEY`, backend restart, explicit logout, and the eight-hour + deadline also invalidate it. + +The master is never written to localStorage/sessionStorage, never returned by +the backend, and never placed in a WebSocket URL. Legacy `ov_api_key` browser +storage is deleted before migration waits on the network. All auth responses, +including errors, carry `Cache-Control: no-store`. + +Failed session exchanges are limited per client to ten attempts in a rolling +60-second window and then return `429` with `Retry-After`. A correct master key +is always evaluated and clears the failure window, so an attacker cannot lock +an operator out by deliberately exhausting the limit. + +Cookie-authenticated mutations require both an exact allowed `Origin` and +`X-VoiceStudio-CSRF: 1`. Side-effectful GET actions additionally require the +browser's `Sec-Fetch-Site: same-origin`. Bearer/header clients are not subject +to the ambient-cookie CSRF check. + --- ## Dictation WebSocket @@ -149,13 +191,22 @@ own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the API-key middleware. A non-loopback client reaches it only if it is **either**: - on a [trusted network](#trusted-networks) (`is_local_host` passes), **or** -- presenting the **API key** — as `Authorization: Bearer `, the `ov_key` - cookie, or `?api_key=` (URL keys leak into logs — prefer the cookie). +- presenting a direct-client **API key** in `Authorization`, or through a + legacy `ov_key`/`?api_key=` transport. ``` ws://gpu-box:3900/ws/transcribe?api_key= ``` +That URL form is retained for non-browser compatibility only. The first-party +UI never constructs it. A bearer administrator session first calls +`POST /api/auth/ws-ticket` and puts only the returned `ws_ticket` in the URL. +Tickets are scoped to `/ws/transcribe` or `/ws/events`, expire after 30 seconds, +return the same bounded `expires_in`/`expires_at` pair, and are consumed +atomically at most once. Same-origin UI WebSockets use the +HttpOnly session cookie and must pass exact `Origin` validation; `null`, missing, +and lookalike origins are rejected. + The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and the dictation guard checks only the API key (or trusted-network membership). A LAN guest who has only entered a PIN can use the HTTP API but **not** live @@ -215,11 +266,12 @@ requirement is dropped (issue #261, else the operator is 403'd out of their own a model, and LLM provider discovery makes a request with the saved provider credential. Set `OMNIVOICE_API_KEY` before changing settings or triggering those actions remotely. -- **An API key is configured** → admin requires that **API key** (`Authorization: - Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit - share PIN does not gate admin** (it is brute-forceable), and trusted-network - membership never does either. A **PIN-only** server-mode deployment therefore - keeps admin routes loopback-only; remote admin requires the long API key. +- **An API key is configured** → admin requires that **API key** (direct-client + `Authorization` / legacy query or cookie), a valid short-lived administrator + session, or genuine loopback. The **6-digit share PIN does not gate admin** + (it is brute-forceable), and trusted-network membership never does either. A + **PIN-only** server-mode deployment therefore keeps admin routes loopback-only; + remote admin starts from the long API key. Managed sidecar installation remains true-loopback-only even with an API key. Its installer fetches mutable source and creates an editable environment, so it @@ -268,13 +320,30 @@ the default list, so restate the loopback/Tauri origins alongside your own. (The same origin.) If you only moved the Vite dev server's port, set `OMNIVOICE_UI_PORT` instead and the default list follows it. +CORS wraps both authentication gates: credentialless browser preflights are +answered before PIN/API-key enforcement, and gate-generated `401` responses +retain CORS headers so the UI can read the actual failure and prompt for the +right credential. + +TLS-terminating proxies must establish the effective scheme at the ASGI server +boundary. Uvicorn's proxy-header handling trusts loopback by default, which +covers Tailscale Serve; a custom proxy on another address must be listed with +`--forwarded-allow-ips=` (and proxy headers must remain enabled). +VoiceStudio deliberately does not trust a raw `X-Forwarded-Proto` header inside +the application: once Uvicorn accepts a trusted proxy, the resolved ASGI scheme +drives exact-Origin checks and the session cookie's `Secure` attribute. +For a public path prefix such as `/studio`, either strip that prefix before +forwarding or configure the ASGI `root_path` to the same value. WebSocket ticket +validation removes only that trusted, configured prefix; it never accepts an +arbitrary path merely because it ends in `/ws/events` or `/ws/transcribe`. + ## Status codes | Code | Meaning | What to do | |---|---|---| | **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. | -| **403** | Authorization failed: loopback/native access was required, a server-mode mutation lacked the API key, or a native path capability was invalid, expired, or for a different operation. | A PIN cannot grant admin or filesystem access. Run native operations from the desktop app; configure and present the API key for remote server-mode mutations; reopen the native picker if a one-shot capability expired. | -| **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. | +| **403** | Authorization failed: loopback/native access was required, cookie Origin/CSRF validation failed, a server-mode mutation lacked an admin credential, or a native path capability was invalid/expired. | A PIN cannot grant admin or filesystem access. Re-authenticate the UI; scripts should use the API-key header; run native operations from the desktop app. | +| **429** | A failed administrator-session exchange exceeded its per-client limit, the GPU pool is saturated, or a model download is rate-limited. Ships with `Retry-After`; workload throttles also carry `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds. For authentication, verify the master before retrying; a correct master is never locked out. | --- diff --git a/docs/media/0.5.0/catalogue.png b/docs/media/0.5.0/catalogue.png new file mode 100644 index 00000000..2a268764 Binary files /dev/null and b/docs/media/0.5.0/catalogue.png differ diff --git a/docs/media/0.5.0/gallery-save.png b/docs/media/0.5.0/gallery-save.png new file mode 100644 index 00000000..541fc70f Binary files /dev/null and b/docs/media/0.5.0/gallery-save.png differ diff --git a/docs/media/0.5.0/quick-switch.gif b/docs/media/0.5.0/quick-switch.gif new file mode 100644 index 00000000..1926fb6e Binary files /dev/null and b/docs/media/0.5.0/quick-switch.gif differ diff --git a/docs/remote-gpu.md b/docs/remote-gpu.md index 724600f1..8b3ca82f 100644 --- a/docs/remote-gpu.md +++ b/docs/remote-gpu.md @@ -24,13 +24,14 @@ loopback-only exactly as before. ┌──────────────┐ tailnet (WireGuard) ┌─────────────────────┐ │ laptop │ ws/https to MagicDNS URL │ gpu-box │ │ VoiceStudio UI │ ──────────────────────────▶ │ VoiceStudio backend │ -│ (thin client) │ Authorization: Bearer … │ OMNIVOICE_API_KEY set │ +│ (thin client) │ short-lived session/ticket │ OMNIVOICE_API_KEY set │ └──────────────┘ └─────────────────────┘ ``` -The desktop app *is* the thin client — there is no separate binary. You set a -**Backend URL** and an **API key** in Settings, and every request (including -the dictation and TTS WebSockets) is sent to the remote with the key attached. +The desktop app *is* the thin client — there is no separate binary. You enter a +**Backend URL** and an **API key** in Settings. The key is exchanged once for a +short-lived session; ordinary HTTP requests use that session and WebSockets use +path-bound, single-use tickets. The master is never stored or put in a URL. ## 1. On the GPU box: run the backend with a key @@ -50,11 +51,11 @@ the backend's CORS allow-list to include that origin — see [Browsers from another origin (CORS)](api-auth.md#browsers-from-another-origin-cors); neither server mode nor trusted networks covers CORS. -When `OMNIVOICE_API_KEY` is set, **every non-loopback HTTP and WebSocket -request must present it**, as `Authorization: Bearer `, `?api_key=` -(browser WebSockets can't set headers), or the `ov_key` cookie the backend -sets after the first authenticated request. Loopback traffic on the box -itself is never gated, so local tools keep working. +When `OMNIVOICE_API_KEY` is set, every non-loopback request needs an accepted +credential. Scripts should use `Authorization: Bearer `. Legacy +`?api_key=` and `ov_key` transports remain accepted for compatibility, but the +backend no longer creates a master-key cookie and the bundled UI uses only +short-lived sessions. Loopback traffic on the box itself remains ungated. ## 2. Reach it over Tailscale @@ -79,6 +80,11 @@ Serve terminates on the node and forwards from `127.0.0.1`, so to the backend the request looks like loopback — which is why the **API key is still required** in that path (the bearer gate doesn't rely on the source address for non-local exposure; set the key and it always applies to keyed clients). +Uvicorn trusts proxy headers from loopback by default, so Serve's forwarded +HTTPS scheme becomes the authoritative ASGI scheme and browser session cookies +receive `Secure`. For a non-loopback reverse proxy, explicitly configure +Uvicorn's `--forwarded-allow-ips=`; the application never trusts an +arbitrary `X-Forwarded-Proto` header itself. > **Do not use `tailscale funnel`** (public-internet exposure) for this. Even > with a key, a voice-cloning backend should not be on the open internet. @@ -90,10 +96,11 @@ Settings → Sharing → **Remote backend**: - **Backend URL**: the MagicDNS URL from step 2 (with `:3900` if you didn't use Serve, or no port if you did). - **API key**: the value of `OMNIVOICE_API_KEY` from step 1. -- **Test connection** hits `{url}/health` and shows the remote's version and - device. -- **Save & reload** stores both in this browser/app and restarts the UI - against the remote. The URL must be a full `http://` or `https://` URL +- **Test connection** hits the auth-exempt `{url}/health` with no credential, + then exchanges the entered key for a session if health succeeds. +- **Save & reload** stores only the URL and restarts the UI against the remote. + The key input is cleared after its single exchange. The URL must be a full + `http://` or `https://` URL (`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed **Test connection** asks for confirmation first — a wrong base would leave the app unable to reach any backend until you change it back here. @@ -111,12 +118,13 @@ https://gpu-box.your-tailnet.ts.net/#api_key= Use the fragment (`#`, not `?`) deliberately: fragments are never sent to the server, so the key stays out of the GPU box's and any reverse proxy's request -logs. The key is stored for that browser and the fragment is scrubbed from the -address bar (so it doesn't linger in history or get re-applied on a reload). If +logs. The fragment is scrubbed synchronously, then the key is exchanged once +for an eight-hour maximum session; the master is not stored. If your key contains `+`, `&`, `#`, or `=`, URL-encode it (e.g. `#api_key=a%2Bb`); keys from `secrets.token_urlsafe` (above) need no encoding. -Thereafter the UI loads normally with the key attached to every request. If a -request ever 401s again (wrong/rotated key), you're prompted to re-enter it. The +Thereafter the UI loads normally with the short-lived session. Cross-origin +bearer sessions are tab-scoped; closing the tab requires re-entry. If a request +401s again (expired/wrong/rotated key), you're prompted to re-enter it. The same gate shows a LAN-share **PIN** prompt instead when network sharing — not a remote key — is what's gating access. @@ -125,6 +133,9 @@ remote key — is what's gating access. - **Plain HTTP is sniffable.** A bearer key over `http://` on a hostile network can be read off the wire. Use Tailscale (WireGuard-encrypted) or Tailscale Serve (TLS) for anything beyond a fully trusted LAN. +- The first-party UI never persists `OMNIVOICE_API_KEY`, never creates a URL + containing it, and never puts its administrator session in a WebSocket URL. + WebSocket tickets expire after 30 seconds and work once for one path. - The API key and the LAN-share **PIN** are independent: the PIN guards a casual share session, the key is the durable remote credential. Either can be active; both are checked when set. diff --git a/docs/remote-workers.md b/docs/remote-workers.md index d4aeefaa..6d028de7 100644 --- a/docs/remote-workers.md +++ b/docs/remote-workers.md @@ -1,6 +1,6 @@ # Remote GPU workers -Run OmniVoice on this machine, but hand individual jobs to GPUs on your other +Run VoiceStudio on this machine, but hand individual jobs to GPUs on your other machines. Results come back here. This is **opt-in and off by default**. Until you turn it on and approve a @@ -23,7 +23,7 @@ administration. ## What you need -* OmniVoice on both machines, on versions no more than two releases apart. +* VoiceStudio on both machines, on versions no more than two releases apart. * The worker machine must be able to **reach** this one over the network. Same LAN is enough at home; across networks, a VPN such as [Tailscale](https://tailscale.com/) is the reliable answer. The worker dials @@ -204,7 +204,7 @@ The row tells you what happened in words — "Paused after 3 failures … retryi in 45s" — and **Resume** clears it immediately when you've fixed the machine. **You quit the app mid-task.** Remote work keeps running on the worker. On next -launch OmniVoice recovers those tasks and reconciles with each worker about +launch VoiceStudio recovers those tasks and reconciles with each worker about what is genuinely still in flight. **Version or feature mismatch.** The protocol keeps a two-release compatibility diff --git a/docs/screenshot-launchpad.png b/docs/screenshot-launchpad.png index e5a0bc41..71d21987 100644 Binary files a/docs/screenshot-launchpad.png and b/docs/screenshot-launchpad.png differ diff --git a/docs/specs/2026-08-13-remote-admin-session-hardening.md b/docs/specs/2026-08-13-remote-admin-session-hardening.md new file mode 100644 index 00000000..fbbbb009 --- /dev/null +++ b/docs/specs/2026-08-13-remote-admin-session-hardening.md @@ -0,0 +1,1030 @@ +# Remote Admin Session Hardening + +**Status:** Implemented in the working tree; pending review and Linux CI + +**Date:** 2026-08-13 + +**Priority:** High + +**Scope:** FastAPI authentication, browser/Tauri credential handling, HTTP and WebSocket authorization + +**Out of scope:** Multi-user accounts, cloud identity providers, changing the existing PIN/trusted-network policy + +## Executive decision + +The first-party UI must stop treating `OMNIVOICE_API_KEY` as a browser session. +The API key is a durable operator secret with access to the server-mode admin +surface; it must not be copied into browser storage, cookies, WebSocket URLs, +response headers, or logs. + +Implement a one-time exchange from the master API key to a short-lived, +process-bound admin session: + +- Direct API clients keep using `Authorization: Bearer `. +- The browser/Tauri UI presents the master key once to create an admin session. +- Same-origin browsers receive an `HttpOnly` session cookie. +- Cross-origin/Tauri clients retain only the short-lived session in + `sessionStorage`. +- Cross-origin WebSockets use a path-bound, single-use ticket with a 30-second + lifetime. The master key and admin session token never enter a URL. +- Session authorization grants consumption and admin capabilities, but never + native host-path access. +- Restarting the backend or rotating/removing the API key invalidates every + outstanding session. + +This is a credential-lifecycle hardening change. It must not alter the current +loopback, server-mode bootstrap, PIN, trusted-network, or direct Bearer-client +contracts. + +## Verified current state + +The following behavior exists on the current `fix/server-mode-admin-auth` +branch: + +| Surface | Current behavior | Consequence | +| ---------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| `frontend/src/api/client.ts` | Reads and writes `ov_api_key` in `localStorage` | A same-origin script can extract a durable admin secret | +| `wsUrl()` | Appends `?api_key=` to every generated WebSocket URL | The durable secret can reach proxy/access logs and browser tooling | +| Deep-link bootstrap | Reads `#api_key=...`, scrubs the fragment, then persists the value | A transient bootstrap secret becomes durable browser state | +| `RemoteAuthGate` | Calls `saveApiKey()` and reloads | The login form creates permanent JS-readable state | +| `RemoteBackendPanel` | Initializes and saves the raw key through local storage | Remote-backend configuration retains the master secret indefinitely | +| `BearerKeyMiddleware` | Reflects a valid presented key into `ov_key=` | The backend copies the master into a non-`HttpOnly`, non-expiring cookie | +| Middleware and dependencies | Parse and compare credentials independently | Authentication precedence and authorization can drift | + +Runtime reproduction with a non-loopback `TestClient` request and +`OMNIVOICE_API_KEY=s3cret-key`: + +```text +GET /v1/audio/voices +Authorization: Bearer s3cret-key + +HTTP 200 +Set-Cookie: ov_key=s3cret-key; Path=/; SameSite=Lax +``` + +The client cookie jar then contains the literal master key. + +The current access-control baseline is green: + +```text +Backend: 99 passed +Frontend: 28 passed +``` + +The backend baseline covered: + +```text +tests/test_bearer_middleware.py +tests/test_loopback_server_mode.py +tests/test_admin_route_policy.py +tests/test_mcp_bindings.py +``` + +The frontend baseline covered: + +```text +frontend/src/api/client.test.ts +frontend/src/components/RemoteAuthGate.test.jsx +frontend/src/components/settings/RemoteBackendPanel.test.jsx +``` + +Passing baseline tests prove that the current policy works. They do not prove +that the master secret has a safe lifecycle; there are no assertions covering +secret reflection, cookie hardening, expiry, revocation, or first-party URL and +storage leakage. + +## Threat model + +### Assets + +- The operator's `OMNIVOICE_API_KEY`. +- The server-mode admin surface, including RCE/filesystem-capable operations. +- Native-only host-path capabilities. +- Remote HTTP and WebSocket sessions. + +### Defended attack paths + +- Exfiltration of the master key by same-origin XSS or a compromised frontend + dependency. +- Durable replay after a browser session ends. +- Disclosure through WebSocket URLs, proxy logs, browser history, diagnostics, + response headers, or exception messages. +- CSRF and cross-site WebSocket hijacking when browser cookies authenticate the + request. +- Privilege escalation from PIN, trusted-network, or admin-session credentials + into native host-path operations. +- Reuse of a session after expiry, logout, backend restart, or API-key rotation. + +### Explicit limitations + +- `HttpOnly` does not stop active XSS from making same-origin requests while the + page is compromised. It prevents extraction and later/lateral replay of the + durable master credential. +- This change does not make plaintext HTTP confidential. Remote operators must + still use TLS or a secure overlay such as Tailscale. +- This is not an account system and does not introduce users, passwords, + refresh tokens, OAuth, or cloud dependencies. +- Durable, unattended Tauri reconnect is not implemented. That requires a + separate cross-platform secure-storage decision; it must never fall back to + `localStorage`. + +## Non-negotiable security invariants + +Use these identifiers in tests and review comments. + +1. **AUTH-S1 — No master persistence:** First-party production code never writes + the master API key to `localStorage`, `sessionStorage`, IndexedDB, a cookie, + or a persisted application setting. +2. **AUTH-S2 — No master reflection:** No response body, response header, + exception, or log record contains the presented master API key. +3. **AUTH-S3 — No durable credential in URLs:** Neither the master API key nor + an admin session token appears in HTTP or WebSocket URLs generated by the UI. +4. **AUTH-S4 — Header-only issuance:** Normal session issuance accepts the + master key only through `Authorization: Bearer`. Query parameters and an + existing admin session cannot mint or renew sessions. +5. **AUTH-S5 — Opaque random sessions:** Session tokens contain at least 256 bits + from `secrets`; the store retains only HMAC-SHA-256 indexes keyed by a + process-local 256-bit pepper. +6. **AUTH-S6 — Absolute expiry:** Admin sessions expire after eight hours. There + is no sliding expiry, refresh token, or implicit renewal. +7. **AUTH-S7 — Immediate invalidation:** Logout, backend restart, and API-key + rotation/removal invalidate sessions before the next protected operation. +8. **AUTH-S8 — Capability ceiling:** API-key and session principals have + `consume + admin`, never `native`. Only genuine loopback has `native`. +9. **AUTH-S9 — Cookie hardening:** Cookie sessions use `HttpOnly`, `Path=/`, + `SameSite=Strict`, an exact `Max-Age`, and `Secure` when the effective scheme + is HTTPS. No `Domain` attribute is set. +10. **AUTH-S10 — CSRF enforcement:** Cookie-authenticated unsafe HTTP methods + require an exact allowed `Origin` and the first-party CSRF marker header. + Cookie-authenticated side-effectful GET routes require the marker too; + cookie-authenticated WebSockets require exact origin validation. `null` and + wildcard origins fail closed. +11. **AUTH-S11 — One-use WebSocket tickets:** A ticket is scoped to one normalized + WebSocket path, expires after 30 seconds, and succeeds at most once under + concurrent redemption. +12. **AUTH-S12 — Deterministic precedence:** Credential extraction has one + canonical implementation and an explicitly tested precedence order. +13. **AUTH-S13 — Compatibility:** Loopback defaults, PIN consumption, trusted + networks, server-mode read-only bootstrap, direct Bearer clients, and native + path restrictions retain their existing behavior. + +## Authorization model + +Authentication identifies a principal. Dependencies authorize capabilities. +Do not infer admin access from HTTP method alone and do not treat network +location as an API key. + +| Principal | Consumption | Admin | Native | May mint admin session | +| ------------------------------- | --------------------------------------: | -----------------------: | -----: | -------------------------------: | +| Genuine loopback | Yes | Yes | Yes | No; master key is still required | +| Trusted network | Yes | No | No | No | +| Share PIN | Yes | No | No | No | +| Master API key | Yes | Yes | No | Yes | +| Admin session | Yes | Yes | No | No | +| Server-mode anonymous bootstrap | Existing consumption/read-only behavior | Read-only exception only | No | No | + +`ADMIN` is a credential capability, not permission to bypass deployment mode. +Desktop admin routes remain genuine-loopback-only; remote `API_KEY` and +`ADMIN_SESSION` principals can exercise `ADMIN` only under server mode. + +The existing `require_admin`, `require_admin_action`, and +`require_native_access` dependencies remain explicit route-level boundaries. +They consume the canonical principal instead of reparsing credentials. + +## Target architecture + +### New backend modules + +```text +backend/core/auth.py +backend/services/admin_sessions.py +backend/api/routers/auth.py +``` + +`backend/core/auth.py` owns transport-independent types: + +```text +Capability: CONSUME | ADMIN | NATIVE +PrincipalKind: ANONYMOUS | LOOPBACK | TRUSTED_NETWORK | PIN | API_KEY | ADMIN_SESSION +AuthPrincipal(kind, capabilities, credential_id, transport) +CredentialTransport: NONE | HEADER | QUERY | COOKIE | WS_TICKET +``` + +The principal contains no reusable secret. `credential_id` is a short identifier +derived from the credential hash and is safe for diagnostics. + +`backend/services/admin_sessions.py` owns a process-local, bounded, +thread-safe `AdminSessionStore`: + +```text +issue(current_api_key) -> IssuedSession +resolve(token, current_api_key) -> SessionRecord | None +revoke(token) -> bool +issue_ws_ticket(session, path) -> IssuedTicket +consume_ws_ticket(ticket, path) -> SessionRecord | None +clear() -> None +``` + +Design constraints: + +- Use `secrets.token_bytes(32)` and URL-safe encoding. +- Give admin sessions and WebSocket tickets distinct prefixes/namespaces. +- Store hashes, capabilities, monotonic deadlines, and non-secret identifiers; + never store raw tokens after returning them. +- Enforce expiry with an injected monotonic clock. Use wall time only to render + cookie `Expires` metadata. +- Keep at most 256 live sessions and 512 live tickets. Purge expired entries + before deterministic oldest-first eviction. +- Keep fixed-TTL credentials in expiry order and maintain a hash-only reverse + ticket index per session. Routine validation must not scan every live + credential, and every removal path must update both indexes atomically. +- Protect mutation and ticket redemption with one lock. Ticket validation and + deletion must be atomic. +- Track the current API-key generation with domain-separated HKDF-SHA-256 using + a random, process-local pepper. If the normalized key changes or disappears, + clear the store before resolving or issuing another credential. +- Reject malformed or unreasonably long tokens before hashing. +- Do not persist the store. Restart invalidation is intentional, reduces + attack lifetime, avoids a database verifier for a possibly weak operator key, + and matches VoiceStudio's single-process backend architecture. + +Do not reuse `backend.worker.identity.Session` directly. Its worker/stream +binding semantics are different. Follow its established random-token, +hash-only, TTL-tested pattern without coupling API authentication to the worker +package. + +### Canonical authentication resolver + +Add one resolver used by HTTP middleware, HTTP dependencies, and WebSocket +guards. It must: + +1. Normalize the configured API key exactly once. +2. Parse the `Authorization` header exactly once. +3. Distinguish session tokens by their prefix. +4. Preserve current API-key compatibility for header, query, and legacy cookie + transports outside session issuance. +5. Prefer a non-empty `Authorization` header, then the legacy query parameter, + then cookies. A stale cookie must not override a valid explicit header. +6. Attach `AuthPrincipal` to ASGI `scope["state"]` so `Request`, `WebSocket`, + middleware, and dependencies observe the same decision. +7. Return authentication failure without embedding any presented value in the + detail, headers, logs, or `repr` output. + +The existing middleware ordering must be covered by a test. A refactor is not +accepted if `NetworkAccessMiddleware`, SPA-shell exemptions, CORS preflight, or +backend-marker headers change behavior accidentally. + +### HTTP contract + +#### `POST /api/auth/session` + +Request: + +```http +Authorization: Bearer +Content-Type: application/json + +{"transport":"cookie"} +``` + +Allowed transports: + +- `cookie` — default; return `204` and set `ov_session`. +- `bearer` — return `201` with the session token and expiry for Tauri or an + explicitly cross-origin frontend. + +Requirements: + +- Require the master key even from loopback. +- Reject API-key query parameters, PINs, trusted-network identity, and existing + admin sessions. +- Return `Cache-Control: no-store` for every response, including errors. +- Never echo the master key. +- Apply exact CORS origin policy before a browser can submit `Authorization`. +- Set or return the issued session exactly once. + +Legacy migration is a narrowly scoped exception: a same-origin request carrying +the old `ov_key` cookie may exchange it once. A successful migration sets +`ov_session` and expires `ov_key` with `Max-Age=0`. It never accepts `api_key` +from the query string. + +#### `DELETE /api/auth/session` + +- Revoke the current session and expire `ov_session`. +- Be idempotent; a missing or already-expired session still produces `204`. +- Require valid origin when cookie-authenticated. +- Return `Cache-Control: no-store`. + +#### `POST /api/auth/ws-ticket` + +Request: + +```json +{ "path": "/ws/transcribe" } +``` + +- Require a valid admin session, not a master API key or PIN. +- Normalize and allow-list the WebSocket path. +- Return one opaque ticket with a 30-second expiry. +- Never return an admin session or master key. +- The WebSocket handshake consumes the ticket atomically. + +### Cookie and origin policy + +For `ov_session`: + +```text +HttpOnly; Path=/; SameSite=Strict; Max-Age=28800 +``` + +Add `Secure` when the effective request scheme is HTTPS. Do not trust arbitrary +forwarded scheme or host headers; use only the proxy configuration already +trusted by the application. Plain-HTTP compatibility may use a non-`Secure` +session cookie, but the documentation must state that the transport remains +sniffable. + +For cookie-authenticated `POST`, `PUT`, `PATCH`, `DELETE`, and WebSocket +handshakes: + +- Compare the parsed origin tuple `(scheme, host, port)` against the exact + configured allow-list. +- Do not use suffix matching. +- Do not allow `*` with credentials. +- Reject absent or `null` origins for unsafe cookie-authenticated operations. +- Bearer-authenticated non-browser clients do not require `Origin`; their + credential is not ambient. + +In addition, first-party cookie-authenticated HTTP requests that can mutate +state must send a fixed custom header such as `X-VoiceStudio-CSRF: 1`. Requiring +the header forces a cross-origin script through CORS preflight; it is not a +substitute for the exact origin check. `require_admin_action` must require this +header even for the known side-effectful GET routes. For same-origin GET, where +browsers may omit `Origin`, require the marker plus +`Sec-Fetch-Site: same-origin`; never accept a cross-site value. This avoids +relying on `SameSite`, which is site-based rather than origin-based. + +### WebSocket transport + +Same-origin browser WebSockets use `ov_session`; `wsUrl()` becomes a pure URL +builder and appends no credentials. + +Cross-origin/Tauri WebSockets cannot set an `Authorization` header reliably. +They must: + +1. Request a one-use ticket over authenticated HTTP. +2. Connect with `?ws_ticket=`. +3. Have the backend consume the ticket for the exact `scope["path"]` before + accepting the socket. + +A ticket may appear in access logs, but it is path-bound, single-use, and valid +for no more than 30 seconds. It cannot be exchanged for an HTTP/admin session. + +Update both known first-party consumers: + +```text +frontend/src/components/CaptureWidget.jsx +frontend/src/hooks/useRealtimeEvents.js +``` + +### Frontend credential lifecycle + +Create: + +```text +frontend/src/api/authSession.ts +frontend/src/api/authSession.test.ts +``` + +Responsibilities: + +- Determine same-origin browser versus cross-origin/Tauri transport. +- Exchange an in-memory master key for a session using a direct, non-retrying + request. Do not route the exchange through `apiFetch`; retry/reload logic must + never replay a master credential implicitly. +- Use `credentials: "include"` for cookie sessions. +- Keep bearer sessions only in `sessionStorage` and clear them on logout, + expiry, backend change, or API-key rotation response. +- Fetch WebSocket tickets for cross-origin/Tauri sessions. +- Redact credentials from all thrown errors and diagnostics. + +Modify `RemoteAuthGate` so submission is asynchronous: + +1. Hold the master key only in controlled component state. +2. Exchange it once. +3. Clear the input state immediately after completion. +4. Remove legacy `ov_api_key` storage before reload/retry. +5. Display a localized error without serializing the submitted value. +6. Reload only after a successful exchange and storage cleanup. + +Modify deep-link handling so `#api_key=...` is scrubbed synchronously before any +await, exchanged directly from memory, and never written to storage. A failed +exchange requires the operator to enter the key again; preserving a failed +master secret is forbidden. + +Modify `RemoteBackendPanel` so testing/saving a remote backend creates a session +instead of persisting its master key. A Tauri restart may require re-entry. +Adding a native keychain/Stronghold dependency is a separate decision requiring +Windows/macOS/Linux parity tests and is not hidden inside this change. + +## File-level change map + +### Create + +```text +backend/core/auth.py +backend/services/admin_sessions.py +backend/api/routers/auth.py +tests/test_admin_sessions.py +tests/test_auth_session_api.py +tests/test_auth_secret_hygiene.py +frontend/src/api/authSession.ts +frontend/src/api/authSession.test.ts +frontend/e2e/remote-auth.spec.ts +``` + +### Modify + +```text +backend/main.py +backend/api/dependencies.py +backend/api/routers/capture_ws.py +frontend/src/api/client.ts +frontend/src/api/client.test.ts +frontend/src/components/RemoteAuthGate.jsx +frontend/src/components/RemoteAuthGate.test.jsx +frontend/src/components/settings/RemoteBackendPanel.jsx +frontend/src/components/settings/RemoteBackendPanel.test.jsx +frontend/src/components/CaptureWidget.jsx +frontend/src/components/CaptureWidget.test.jsx +frontend/src/hooks/useRealtimeEvents.js +frontend/src/test/useRealtimeEvents.test.jsx +tests/test_bearer_middleware.py +tests/test_loopback_server_mode.py +tests/test_admin_route_policy.py +tests/test_capture_ws.py +docs/api-auth.md +docs/remote-gpu.md +CHANGELOG.md +frontend/src/i18n/locales/*.json # only if new user-visible text is introduced +``` + +No dependency or version change is expected. If a frontend dependency changes, +regenerate the root `bun.lock`; do not bump `frontend/package.json` without the +owner's instruction. + +## Test strategy + +Tests are part of the design, not a final verification phase. Every task below +starts with a failing regression. No production implementation is accepted +without fail-before/pass-after evidence. + +### Layer 1 — `AdminSessionStore` unit tests + +File: `tests/test_admin_sessions.py` + +Use injected token factories and fake monotonic/wall clocks. Never use `sleep()` +or wall-clock timing assertions. + +Required tests: + +- `test_issue_returns_a_namespaced_256_bit_token_once` +- `test_store_retains_only_the_token_hash` +- `test_resolve_returns_the_expected_capabilities` +- `test_expiry_boundary_is_closed_at_deadline` +- `test_wall_clock_rollback_does_not_extend_a_session` +- `test_logout_revokes_immediately_and_is_idempotent` +- `test_key_rotation_invalidates_all_sessions` +- `test_key_removal_invalidates_all_sessions` +- `test_process_store_starts_empty` +- `test_expired_sessions_are_purged_before_capacity_eviction` +- `test_capacity_evicts_the_oldest_live_session_deterministically` +- `test_malformed_and_oversized_tokens_fail_without_mutation` +- `test_session_and_worker_token_namespaces_do_not_overlap` +- `test_ticket_is_scoped_to_the_normalized_path` +- `test_ticket_expires_at_thirty_seconds` +- `test_ticket_cannot_be_redeemed_twice` +- `test_concurrent_ticket_redemption_has_exactly_one_winner` +- `test_concurrent_session_issuance_produces_unique_tokens` +- `test_repr_and_debug_snapshot_contain_no_raw_credentials` + +Do not add a timing-based "constant-time" test; it will be flaky and will not +prove constant-time behavior. Review must verify use of `secrets.compare_digest` +where secret-derived values are compared. + +### Layer 2 — principal resolver tests + +Add focused tests to `tests/test_bearer_middleware.py` or a dedicated +`tests/test_auth_principal.py` if the file becomes unwieldy. + +Required matrix: + +| Input | Expected principal/capabilities | +| --------------------------------------------------------- | ---------------------------------------------------------- | +| Genuine loopback, no credential | `LOOPBACK`; consume/admin/native | +| Trusted remote network | `TRUSTED_NETWORK`; consume only | +| Valid PIN | `PIN`; consume only | +| Valid master header | `API_KEY`; consume/admin | +| Valid master query, no header | `API_KEY`; consume/admin; legacy transport | +| Valid legacy `ov_key`, no header/query | `API_KEY`; consume/admin; legacy transport | +| Valid cookie session | `ADMIN_SESSION`; consume/admin | +| Valid Bearer session | `ADMIN_SESSION`; consume/admin | +| Empty/whitespace header plus valid fallback | Preserve the current normalized fallback contract | +| Non-empty invalid explicit header plus valid stale cookie | Fail according to the documented authoritative-header rule | +| Expired/revoked session | Authentication failure | +| Session from before key rotation | Authentication failure | + +Also assert: + +- HTTP dependencies and WebSocket guards receive the same principal object. +- Credential parsing occurs once per request/scope. +- No principal contains the raw credential. +- `request.state` is isolated between concurrent requests. +- CORS `OPTIONS`, health, and SPA-shell exemptions retain current behavior. + +### Layer 3 — session HTTP API tests + +File: `tests/test_auth_session_api.py` + +Use a sentinel master such as `MASTER_DO_NOT_LEAK_7d29` and inspect the complete +response body, all response headers, cookies, captured logs, and exception text. + +#### Issuance + +- Correct master header + cookie transport returns `204`. +- Correct master header + bearer transport returns `201` with one session and + expiry, never the master. +- Missing, wrong, whitespace-only, PIN, trusted-network, query, and existing + session credentials cannot mint a session. +- Loopback without the master cannot mint a session. +- Unsupported transport and malformed JSON return `422` without issuance. +- Successful issuance sets `Cache-Control: no-store`. +- Failed issuance also sets `Cache-Control: no-store`. +- The response never sets `ov_key`. +- Multiple successful issuances produce distinct tokens. + +#### Cookie attributes + +Parse attributes instead of comparing one formatting-specific string: + +- `HttpOnly` present. +- `SameSite=Strict` present. +- `Path=/` present. +- `Max-Age=28800` present. +- `Domain` absent. +- `Secure` present for HTTPS and absent for explicitly supported HTTP. +- Logout emits an expiry cookie with the same name/path. + +#### Legacy migration + +- Same-origin valid `ov_key` migrates to `ov_session` exactly once. +- Migration expires `ov_key` and never reflects its value. +- Cross-origin, missing-origin, query-key, and wrong-cookie migration fail. +- A stale `ov_key` cannot override a valid explicit session/header. + +#### Revocation and rotation + +- Logout makes the next HTTP request fail. +- Logout makes the next WebSocket handshake fail. +- Expiry returns `401` for HTTP and policy close `1008` for WebSocket. +- Changing or removing `OMNIVOICE_API_KEY` rejects the session on the next + request without restarting the process. + +### Layer 4 — authorization regression matrix + +Extend `tests/test_loopback_server_mode.py` and +`tests/test_admin_route_policy.py`. + +For representative read, write, and side-effectful GET routes across settings, +system, engines, media tools, MCP bindings, pronunciation, and workers, test: + +| Context | Read-only admin | Write/side-effect admin | Native host-path action | +| ----------------------------------------- | ---------------------------------------------------------: | ----------------------: | ----------------------: | +| Desktop loopback | Allow | Allow | Allow | +| Desktop remote + master/session | Deny | Deny | Deny | +| Server mode, no key/PIN | Allow existing read-only bootstrap | Deny | Deny | +| Server mode + valid master | Allow | Allow | Deny | +| Server mode + valid admin session | Allow | Allow | Deny | +| Server mode + PIN only | Deny | Deny | Deny | +| Server mode + trusted network, no key/PIN | Allow anonymous bootstrap only; trust grants nothing extra | Deny | Deny | +| Server mode + expired/revoked session | Deny | Deny | Deny | + +The AST policy test must continue proving that every privileged router declares +the intended dependency and that every side-effectful GET uses +`require_admin_action`. A runtime session test does not replace the static route +inventory guard. + +### Layer 5 — CSRF and origin tests + +Add table-driven tests covering both HTTP and WebSocket: + +- Exact allowed origin succeeds. +- Wrong scheme, host, subdomain, or port fails. +- Suffix attacks such as `trusted.example.evil.test` fail. +- `Origin: null`, wildcard, malformed, duplicated, and absent origins fail for + unsafe cookie-authenticated requests. +- Safe cookie-authenticated `GET` preserves the documented policy. +- Bearer-authenticated CLI requests without `Origin` continue to work. +- Cookie-authenticated unsafe requests without `X-VoiceStudio-CSRF: 1` fail. +- Cross-origin requests with the marker still fail origin/CORS validation. +- Side-effectful GET with marker plus `Sec-Fetch-Site: same-origin` succeeds; + missing marker or any cross-site fetch metadata fails. +- CORS preflight does not create a session and does not emit credentials. +- Untrusted forwarded scheme/host headers cannot manufacture an allowed origin + or force/strip the `Secure` decision. + +Expected distinction: + +- Authentication failure: `401` HTTP / `1008` WebSocket. +- Authenticated cookie with invalid origin: `403` HTTP / `1008` WebSocket. + +### Layer 6 — WebSocket ticket tests + +Backend tests in `tests/test_capture_ws.py` plus equivalent coverage for +`/ws/events`: + +- A valid cookie session connects without a URL credential. +- A valid cross-origin session can mint and redeem one ticket. +- Ticket A cannot open a different path. +- Ticket A cannot be reused after a successful handshake. +- Two simultaneous redemption attempts yield exactly one accepted connection. +- Expired, malformed, revoked-session, pre-rotation, and random tickets close + with `1008`. +- A failed handshake still consumes a presented valid one-use ticket once it + reaches redemption. +- A ticket cannot authenticate HTTP, mint another ticket, or reach native + actions. +- The master and session sentinels do not appear in the WebSocket URL, close + reason, server logs, or error events. + +### Layer 7 — frontend unit/component tests + +#### `frontend/src/api/authSession.test.ts` + +- Exchanges the master exactly once and never retries automatically. +- Uses cookie transport for same-origin browser execution. +- Uses bearer-session transport for explicit cross-origin/Tauri execution. +- Writes only the short-lived bearer session to `sessionStorage`. +- Never writes the master to any storage API. +- Clears session state on logout, expiry, backend base-URL change, and rotation + failure. +- Requests a WebSocket ticket only when cookie transport is unavailable. +- Redacts the master/session from thrown errors and `console` calls. +- Does not include credentials in query strings or referrers. + +#### `frontend/src/api/client.test.ts` + +- `apiFetch` uses `credentials: "include"` for cookie mode. +- Bearer-session mode adds the session, not the master. +- `wsUrl()` is a pure credential-free URL builder for `ws:` and `wss:`. +- Existing PIN handling remains unchanged. +- Existing transport retry behavior does not retry authentication exchange. +- `401` session expiry emits one `ov:auth-required` event without a reload loop. + +#### `RemoteAuthGate.test.jsx` + +- API-key submission waits for exchange success before reload. +- Failure keeps the dialog visible, clears the submitted secret, and renders a + localized generic error. +- Success removes `ov_api_key` before reload. +- The master never reaches local/session storage, event details, or console. +- Repeated submit while pending produces one request. +- PIN mode retains its existing behavior and never mints an admin session. + +#### `RemoteBackendPanel.test.jsx` + +- Existing `ov_api_key` is migrated and deleted. +- A new master remains only in component state during exchange. +- Switching backend URL clears the old session before probing the new backend. +- A failed probe does not persist either master or session. +- Tauri/cross-origin success stores only the expiring session. + +#### WebSocket consumers + +Extend `CaptureWidget.test.jsx` and `useRealtimeEvents.test.jsx`: + +- Same-origin creates credential-free WebSocket URLs. +- Cross-origin waits for ticket issuance before constructing the socket. +- Ticket failure does not open an unauthenticated socket or leak credentials. +- Reconnect obtains a new ticket; it never reuses the consumed ticket. +- Existing audio/realtime reconnection and teardown behavior remains green. + +### Layer 8 — static secret-hygiene guard + +File: `tests/test_auth_secret_hygiene.py` + +This deterministic guard should scan production frontend/backend sources and +fail if the retired first-party patterns return. Keep a narrow allow-list for +legacy parsing/migration and tests. + +Required assertions: + +- No production `localStorage.setItem(...ov_api_key...)`. +- No generated `api_key=${...}` or equivalent master-key query construction. +- No middleware `Set-Cookie` value derived from the presented master. +- No session token is interpolated into a WebSocket URL; only `ws_ticket` is + allowed. +- Credential-bearing error/log templates do not interpolate raw request values. + +The static guard supplements runtime sentinel tests; it does not replace them. + +### Layer 9 — Playwright end-to-end tests + +File: `frontend/e2e/remote-auth.spec.ts` + +Run against a disposable backend with a non-loopback test client/proxy and a +known sentinel key. + +Scenarios: + +1. Open remote UI, enter key, load an admin screen, and connect realtime events. +2. Assert no master in local/session storage, cookies, page URL, WebSocket URL, + request URLs, response headers, console, or page errors. +3. Assert `ov_session` is `HttpOnly` through browser context cookie inspection. +4. Reload and confirm cookie-session continuity. +5. Log out and confirm protected HTTP and WebSocket access stops immediately. +6. Advance/inject the test clock to expiry and confirm one clean re-auth prompt. +7. Exercise cross-origin mode and verify a one-use `ws_ticket`, never a master or + admin session, appears in the WebSocket URL. +8. Attempt a hostile-origin POST and WebSocket connection; both must fail. + +Do not assert secret absence by screenshot. Inspect browser context storage, +cookies, requests, WebSockets, console messages, and backend-captured logs. + +### Layer 10 — compatibility and full-suite gates + +Targeted backend gate while iterating: + +```bash +HF_HUB_OFFLINE=1 HF_HUB_CACHE="$(mktemp -d)" \ + uv run pytest -q \ + tests/test_admin_sessions.py \ + tests/test_auth_session_api.py \ + tests/test_auth_secret_hygiene.py \ + tests/test_bearer_middleware.py \ + tests/test_loopback_server_mode.py \ + tests/test_admin_route_policy.py \ + tests/test_capture_ws.py \ + tests/test_mcp_bindings.py +``` + +Targeted frontend gate while iterating: + +```bash +cd frontend +bun run test \ + src/api/authSession.test.ts \ + src/api/client.test.ts \ + src/components/RemoteAuthGate.test.jsx \ + src/components/settings/RemoteBackendPanel.test.jsx \ + src/components/CaptureWidget.test.jsx \ + src/test/useRealtimeEvents.test.jsx +``` + +Pre-review gate: + +```bash +HF_HUB_OFFLINE=1 HF_HUB_CACHE="$(mktemp -d)" uv run pytest -q +cd frontend +bun run lint +bun run typecheck:ci +bun run test +bun run build +bun run e2e -- remote-auth.spec.ts +``` + +Run the existing deterministic repository gates, including changelog style, +locale parity, version lockstep, and hardcoded-CJK checks. Full backend tests +must use `HF_HUB_OFFLINE=1` and a genuinely empty `HF_HUB_CACHE`; a populated +developer cache is not valid evidence. + +No wall-clock performance threshold belongs in cross-platform CI. Instead, +exercise 10,000 store resolutions in a diagnostic benchmark and report the +before/after request-auth overhead in the PR. Reject a design that performs +database or filesystem I/O per request or holds the store lock while calling +downstream application code. + +## Test-to-invariant traceability + +| Invariant | Primary evidence | +| --------- | --------------------------------------------------------------------------- | +| AUTH-S1 | Frontend storage spies, static hygiene guard, Playwright storage inspection | +| AUTH-S2 | Backend sentinel scan across headers/body/logs/exceptions | +| AUTH-S3 | `wsUrl` unit tests, request/WebSocket inspection, static guard | +| AUTH-S4 | Session issuance negative credential matrix | +| AUTH-S5 | Store token/hash unit tests | +| AUTH-S6 | Fake-clock boundary and no-renewal tests | +| AUTH-S7 | Logout, rotation, removal, and fresh-store tests | +| AUTH-S8 | Route capability matrix, native denial tests | +| AUTH-S9 | Parsed cookie-attribute matrix under HTTP/HTTPS | +| AUTH-S10 | HTTP + WebSocket exact-origin matrix | +| AUTH-S11 | Ticket path/expiry/concurrency/single-use tests | +| AUTH-S12 | Principal precedence and single-parse tests | +| AUTH-S13 | Existing targeted suites plus full backend/frontend CI | + +## Implementation order + +Keep the change in one reviewed PR so the backend contract, first-party client, +and secret-hygiene regression land atomically. Use independently green commits. + +### Task 1 — Lock the leak with failing tests + +- Add the backend sentinel test proving the master currently appears in + `Set-Cookie`. +- Add frontend tests proving the master currently reaches `localStorage` and + WebSocket URLs. +- Record fail-before output in the PR description. +- Do not weaken assertions to match current behavior. + +### Task 2 — Implement the bounded session/ticket store + +- Write all fake-clock, hash-only, expiry, rotation, capacity, and concurrency + tests first. +- Implement `AdminSessionStore` without FastAPI imports. +- Run Layer 1 only until green. + +### Task 3 — Introduce canonical principals + +- Add principal and precedence tests first. +- Refactor middleware/dependencies to consume one decision from ASGI state. +- Keep all existing authorization tests green after every edit. + +### Task 4 — Add session HTTP endpoints + +- Add issuance, cookie, migration, revocation, rotation, CSRF, and no-leak tests. +- Register the auth router. +- Confirm no master value is emitted before moving to frontend work. + +### Task 5 — Add WebSocket tickets + +- Write single-use/path/expiry/concurrency tests first. +- Integrate ticket resolution into the canonical WebSocket guard. +- Preserve direct API-key and cookie-session WebSocket compatibility. + +### Task 6 — Migrate first-party frontend flows + +- Implement `authSession.ts` from its failing unit tests. +- Migrate `RemoteAuthGate`, deep links, `RemoteBackendPanel`, `CaptureWidget`, and + realtime events. +- Remove all first-party master persistence and URL generation. +- Keep the legacy parser only for one-time migration. + +### Task 7 — Add static and browser-level regression coverage + +- Land the source hygiene guard. +- Add Playwright remote-auth scenarios. +- Inspect actual browser storage, cookies, requests, WebSockets, console, and + backend logs using sentinel credentials. + +### Task 8 — Documentation, i18n, and full validation + +- Update `docs/api-auth.md` and `docs/remote-gpu.md` with session behavior, + transport limitations, TLS requirements, logout/expiry, and direct-client + compatibility. +- Add concise `CHANGELOG.md` Unreleased entry. +- Translate any new visible strings in all 21 locale files. +- Run the complete offline backend suite, frontend lint/typecheck/test/build, + Playwright scenario, and deterministic repository gates. +- Read CodeRabbit/Greptile findings and resolve every Critical/P1 before merge. +- Merge current `main` into a stale branch before trusting CI, require + `MERGEABLE`, and watch post-merge `main` runs to green. + +## Failure and rollback behavior + +- Session-store failure must fail closed for remote authenticated requests. It + must not fall back to the master cookie or silently grant loopback identity. +- A frontend exchange failure leaves the auth gate visible with a generic, + localized error and no retained master key. +- WebSocket ticket failure does not fall back to putting the session/master in + the URL. +- Direct Bearer clients remain the operational rollback path; no feature flag + is required. +- Legacy `ov_key` remains accepted only for controlled one-time migration. The + backend stops creating it immediately. + +## Acceptance criteria + +The change is complete only when all conditions hold: + +- A sentinel master key is absent from browser durable/transient storage after + exchange, all cookies, URLs, response data, console output, and backend logs. +- In API-key mode, the only reusable browser credential after exchange is an + eight-hour admin session; the only URL credential is a 30-second, path-bound, + one-use WebSocket ticket. The independent PIN flow remains unchanged. +- Same-origin HTTP and WebSocket flows work with the hardened cookie. +- Cross-origin/Tauri HTTP and WebSocket flows work without persisting the master + or putting the admin session into a URL. +- Logout, expiry, key rotation/removal, and backend restart invalidate access. +- Admin sessions cannot reach native host-path capabilities. +- PIN and trusted networks cannot reach write/side-effect admin operations. +- Direct curl/OpenAI SDK Bearer behavior remains compatible. +- Server-mode no-credential read-only bootstrap remains compatible. +- No new required network call, platform divergence, dependency, or version + bump is introduced. +- Targeted tests, full offline backend tests, frontend lint/typecheck/test/build, + Playwright remote-auth coverage, CI, and post-merge `main` are green. + +## Implementation and verification status — 2026-08-13 + +The implementation is complete in the working tree. Merge readiness still +requires the repository's normal PR review and Linux CI gates; no claim below +substitutes for CodeRabbit/Greptile review or post-merge `main` monitoring. + +Delivered behavior: + +- Process-local, hash-only administrator sessions: 256-bit tokens, eight-hour + non-renewing lifetime, deterministic capacity, explicit revocation, and + immediate invalidation on master-key rotation/removal or process restart. +- Thirty-second, path-bound, atomically single-use tickets for `/ws/events` and + `/ws/transcribe`; the reusable session and master never enter a WebSocket URL. +- One canonical principal decision across middleware, HTTP dependencies, and + WebSockets, including authoritative rejection of malformed or invalid + explicit credentials instead of fall-through to ambient trust. +- Exact-origin cookie CSRF enforcement, side-effectful-GET protection, strict + cookie attributes, `Cache-Control: no-store`, and controlled legacy migration. +- Unicode-safe constant-time credential comparisons and a bounded, per-client + failed-exchange window that throttles brute force without locking out a + request carrying the correct master key. +- Same-origin HttpOnly-cookie and cross-origin/Tauri bearer transports. The + bearer contract prefers bounded `expires_in` values so independent browser + and server clocks cannot invalidate otherwise-valid credentials. +- CORS is outside both authentication gates, so credentialless preflights and + gate-generated `401` responses remain readable by an allowed browser origin. +- WebSocket URL construction preserves reverse-proxy base-path prefixes while + tickets remain bound to the backend's canonical `/ws/events` or + `/ws/transcribe` route. +- First-party removal of durable master-key persistence, master/session URL + credentials, accidental auth forwarding to foreign absolute URLs, and + storage-policy failures that previously could abort credential cleanup. + +Verification evidence: + +| Gate | Result | +|---|---| +| Session/principal/CSRF/HTTP contract + API/network/ASR compatibility | 324 passed, 1 expected xfail | +| Branch coverage for the four new backend modules | 95% total; 162 dedicated tests passed | +| Isolated `backend/tests/`, empty HF cache and offline | 254 passed | +| Prior Linux CI failure order (`mcp_bindings` → network middleware → principal) | 54 passed after runtime singleton resolution fix | +| Changelog, locale parity, version, CJK, route inventory, and install-doc gates | 243 passed | +| Full Vitest suite | 265 files, 2,084 passed | +| Review-focused auth/client regression | 6 files, 98 passed | +| Session lookup diagnostic | 14.2 μs median over seven 10,000-resolution samples; no database or filesystem I/O | +| Frontend TypeScript | clean | +| Frontend oxlint | zero errors; repository baseline warnings only | +| Production build | passed | +| Production-bundle Playwright | 4 passed in real Chrome, including same-origin cookie and cross-origin bearer credential-hygiene regressions | +| Real backend + cross-origin browser exercise | exchange `201`; protected call `200`; ticket `201`; WebSocket opened; logout `204`; subsequent access `401`; no master in storage, location, or request URLs | +| Session-store diagnostic | 10,000 resolutions in 92.886 ms (9.289 µs/op) on the verification host | + +Repository-wide offline `tests/` was also executed, not sampled: 5,591 tests +passed before the run exposed four change-adjacent regressions (route snapshot, +two ASR WebSocket compatibility cases, and an order-sensitive network case). +The three deterministic regressions were corrected; all four are included in +the 324-test compatibility gate above. Re-running the remaining last-failed set +produced 33 failures, three +setup errors, and one pass, all confined to unmodified engine/FFmpeg/IndexTTS, +AppRun/shell, dubbing-export, worker-permission, and Windows path/permission +tests. No failing assertion targets the new auth/session behavior, and the +affected route/middleware suites pass in the compatibility gate above. These +existing Windows/offline failures are recorded rather than hidden; the required +Linux CI gate must still be green before merge. + +The legacy Node runner passed 70/71. Its only failure is the unmodified +`clearDevPorts.test.mjs` POSIX-path fixture under a Windows host. The general +development E2E suite passed 12/17; its five Gallery cases remained on the +installer splash even with a real offline backend, while the production-bundle +gate and the dedicated real remote-auth flow both passed. No product assertion +in those five Gallery cases covers this change. + +## Rejected alternatives + +### Put the master key in a hardened `HttpOnly` cookie + +Rejected. Cookie flags reduce JavaScript extraction but retain a permanent, +full-power credential as ambient browser authority and do not provide expiry, +scope, or revocation. + +### Use JWT/stateless HMAC sessions + +Rejected. Stateless sessions complicate individual revocation and key rotation. +Deriving a signing key from a potentially human-chosen API key also creates an +offline verifier if a token is stolen. A small process-local opaque-token store +is simpler and safer for VoiceStudio's single-process architecture. + +### Persist browser sessions in SQLite + +Rejected for this change. Persistence extends credential lifetime across +backend restarts, adds a verifier and migration surface, and provides little +value for a remote admin session. Re-authentication after restart is an +intentional security boundary. + +### Store the master in the Tauri webview until a native keychain is added + +Rejected. Platform secure-storage work must be explicit and parity-tested. +Until then, the correct fallback is re-authentication, not durable plaintext +storage. + +### Disable remote admin entirely + +Rejected. Server-mode remote administration is required behavior. The solution +must harden credential lifecycle without removing a working cross-platform +capability. diff --git a/docs/specs/longform/26-gallery-use-handoff.md b/docs/specs/longform/26-gallery-use-handoff.md index d5bf43aa..40dc0a66 100644 --- a/docs/specs/longform/26-gallery-use-handoff.md +++ b/docs/specs/longform/26-gallery-use-handoff.md @@ -1,5 +1,7 @@ # Spec — TASK #26: Gallery "Use in Stories" / "Set as Audiobook default" + create-voice handoff +> **Implemented (2026-08-13).** Gallery and Community persona cards now materialize once and hand the returned profile directly to Studio, the current Stories cast, or the current Audiobook default. The implementation uses the unified `longformSlice` that superseded the store additions proposed below; the remainder of this document preserves the original design record. + ## TL;DR Today the Gallery's "Use voice" action materializes an archetype/community voice into a profile and hard-codes a handoff into the **Studio** synthesis view (`frontend/src/pages/VoiceGallery.jsx:199-210` for archetypes; `frontend/src/App.jsx:254-268` for the studio-side pickup). There is no path from the Gallery into the **Stories** cast or the **Audiobook** default narrator. This task adds two quick-actions to gallery + community cards — "Use in Stories" and "Set as Audiobook default" — that (a) materialize the voice into a real profile (same backend call as today) and (b) land it in the right destination: appended to the Stories cast as a new character, or set as the persisted Audiobook default voice. The Audiobook default currently has no store binding at all, so this task also promotes it from local `useState` to a persisted store field. diff --git a/frontend/e2e-prod/admin-session-hygiene.spec.ts b/frontend/e2e-prod/admin-session-hygiene.spec.ts new file mode 100644 index 00000000..72a5a135 --- /dev/null +++ b/frontend/e2e-prod/admin-session-hygiene.spec.ts @@ -0,0 +1,130 @@ +import { expect, test, type Page, type Request, type Route } from '@playwright/test'; + +const MASTER = 'root-master-never-retained'; +const SESSION = `ovs_admin_session_${'S'.repeat(43)}`; + +async function browserCredentialSnapshot(page: Page) { + return page.evaluate(() => ({ + href: location.href, + legacyMaster: localStorage.getItem('ov_api_key'), + storedSession: sessionStorage.getItem('ov_admin_session'), + localValues: Object.values(localStorage), + sessionValues: Object.values(sessionStorage), + })); +} + +test('same-origin production bootstrap exchanges once into an HttpOnly cookie', async ({ + context, + page, +}) => { + const seen: Request[] = []; + page.on('request', (request) => seen.push(request)); + await page.addInitScript((master) => localStorage.setItem('ov_api_key', master), MASTER); + + let exchange: Request | undefined; + await page.route('**/api/auth/session', async (route) => { + exchange = route.request(); + await route.fulfill({ + status: 204, + headers: { + 'cache-control': 'no-store', + 'set-cookie': `ov_session=${SESSION}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`, + }, + }); + }); + + await page.goto(`/#api_key=${MASTER}&tab=voices`, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`); + + expect(exchange?.postDataJSON()).toEqual({ transport: 'cookie' }); + const snapshot = await browserCredentialSnapshot(page); + expect(snapshot.href).toMatch(/#tab=voices$/); + expect(snapshot.href).not.toContain(MASTER); + expect(snapshot.legacyMaster).toBeNull(); + expect(snapshot.storedSession).toBeNull(); + expect([...snapshot.localValues, ...snapshot.sessionValues].join('\n')).not.toContain(MASTER); + expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER); + + const cookies = await context.cookies(); + const cookie = cookies.find(({ name }) => name === 'ov_session'); + expect(cookie).toMatchObject({ value: SESSION, httpOnly: true, sameSite: 'Strict', path: '/' }); + expect(cookies.some(({ name }) => name === 'ov_key')).toBe(false); + expect(cookies.map(({ value }) => value).join('\n')).not.toContain(MASTER); +}); + +test('cross-origin production bootstrap stores only a backend-bound tab session', async ({ + page, +}) => { + const remote = 'http://gpu.test:3900'; + const seen: Request[] = []; + page.on('request', (request) => seen.push(request)); + await page.addInitScript( + ({ backend, master }) => { + localStorage.setItem('ov_backend_url', backend); + localStorage.setItem('ov_api_key', master); + }, + { backend: remote, master: MASTER }, + ); + + let exchange: Request | undefined; + await page.route(`${remote}/**`, async (route: Route) => { + const request = route.request(); + const corsHeaders = { + 'access-control-allow-credentials': 'true', + 'access-control-allow-headers': 'authorization,content-type,x-voicestudio-csrf', + 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', + 'access-control-allow-origin': request.headers().origin ?? 'http://localhost:4173', + 'access-control-expose-headers': 'x-omnivoice-backend', + 'x-omnivoice-backend': 'e2e', + }; + if (request.method() === 'OPTIONS') { + await route.fulfill({ status: 204, headers: corsHeaders }); + return; + } + if (new URL(request.url()).pathname === '/api/auth/session') { + exchange = request; + await route.fulfill({ + status: 201, + headers: { + ...corsHeaders, + 'cache-control': 'no-store', + 'content-type': 'application/json', + }, + body: JSON.stringify({ token: SESSION, expires_at: 1, expires_in: 3600 }), + }); + return; + } + if (new URL(request.url()).pathname === '/health') { + await route.fulfill({ + status: 200, + headers: { ...corsHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ status: 'ok', version: 'e2e', device: 'cpu' }), + }); + return; + } + await route.fulfill({ + status: 200, + headers: { ...corsHeaders, 'content-type': 'application/json' }, + body: '{}', + }); + }); + + await page.goto(`/#api_key=${MASTER}`, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`); + + expect(exchange?.postDataJSON()).toEqual({ transport: 'bearer' }); + const snapshot = await browserCredentialSnapshot(page); + expect(snapshot.href).not.toContain(MASTER); + expect(snapshot.legacyMaster).toBeNull(); + expect(snapshot.storedSession).not.toBeNull(); + expect(JSON.parse(snapshot.storedSession ?? '{}')).toMatchObject({ + token: SESSION, + apiBase: remote, + }); + expect(snapshot.localValues.join('\n')).not.toContain(MASTER); + expect(snapshot.sessionValues.join('\n')).not.toContain(MASTER); + expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER); + expect( + seen.filter((request) => request.headers().authorization === `Bearer ${MASTER}`), + ).toHaveLength(1); +}); diff --git a/frontend/e2e/footer-clipping.spec.ts b/frontend/e2e/footer-clipping.spec.ts index 9a1d6548..f533d61b 100644 --- a/frontend/e2e/footer-clipping.spec.ts +++ b/frontend/e2e/footer-clipping.spec.ts @@ -27,7 +27,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => { test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => { await gotoMode(page, 'gallery'); - const cards = page.locator('.archetype-card'); + const cards = page.getByTestId('gallery-persona-card'); await expect(cards.first()).toBeVisible({ timeout: 20_000 }); const top = await footerTop(page); @@ -45,7 +45,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => { page, }) => { await gotoMode(page, 'gallery'); - const cards = page.locator('.archetype-card'); + const cards = page.getByTestId('gallery-persona-card'); await expect(cards.first()).toBeVisible({ timeout: 20_000 }); // Expand the logs panel (chevron toggle in the collapsed bar). diff --git a/frontend/e2e/gallery.spec.ts b/frontend/e2e/gallery.spec.ts index 2c5d2948..db3d9648 100644 --- a/frontend/e2e/gallery.spec.ts +++ b/frontend/e2e/gallery.spec.ts @@ -9,7 +9,9 @@ test.describe('VoiceStudio Gallery', () => { test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => { await gotoMode(page, 'gallery'); - const select = page.locator('select.facet-select').first(); + // Scope by testid, not the translated 'Archetypes' label — the accessible + // name follows the app locale and breaks under non-English navigators. + const select = page.getByTestId('archetypes-zone').getByRole('combobox').first(); await expect(select).toBeVisible(); // Regression guard for the undefined-var fallback: the fixed style resolves // --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface @@ -25,7 +27,7 @@ test.describe('VoiceStudio Gallery', () => { await gotoMode(page, 'gallery'); // Cards load from the backend; wait for the first one. - const designerBtn = page.locator('.archetype-card .designer-btn').first(); + const designerBtn = page.getByRole('button', { name: /Open in Designer/i }).first(); await expect(designerBtn).toBeVisible({ timeout: 20_000 }); await designerBtn.click(); diff --git a/frontend/e2e/support-compact.spec.ts b/frontend/e2e/support-compact.spec.ts new file mode 100644 index 00000000..c789d8a1 --- /dev/null +++ b/frontend/e2e/support-compact.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { gotoMode } from './_helpers'; + +const MIN_WINDOW = { width: 900, height: 600 }; + +test.describe('Support page stays on one screen @ 900x600', () => { + test.use({ viewport: MIN_WINDOW }); + + test('support, commercial licence and contact panels do not overflow', async ({ page }) => { + await gotoMode(page, 'donate'); + await expect(page.getByRole('heading', { name: 'Support VoiceStudio' })).toBeVisible({ + timeout: 20_000, + }); + + for (const tabName of ['Support', 'Commercial License', 'Contact']) { + const tab = page.getByRole('tab', { name: tabName }); + if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click({ force: true }); + + await expect(tab).toHaveAttribute('aria-selected', 'true'); + const panel = page.getByRole('tabpanel'); + await expect(panel).toBeVisible(); + const { clientHeight, scrollHeight } = await panel.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect( + scrollHeight, + `${tabName} panel should not need vertical scrolling`, + ).toBeLessThanOrEqual(clientHeight + 1); + } + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 62189714..5d991121 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "omnivoice-studio", - "version": "0.4.2", + "version": "0.5.0", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index e28a64a3..5f1e1839 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2941,7 +2941,7 @@ dependencies = [ [[package]] name = "omnivoice-studio" -version = "0.4.2" +version = "0.5.0" dependencies = [ "arboard", "dirs-next", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 2cdd78dc..763e6cc3 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ # launcher's pkill matches `omnivoice-studio` and must never match a user's # installed app. Renaming it would collapse that distinction. name = "omnivoice-studio" -version = "0.4.2" +version = "0.5.0" description = "VoiceStudio – AI voice cloning & dubbing desktop app" authors = ["Debpalash"] license = "AGPL-3.0-only" diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index a207552d..fe133ced 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -262,18 +262,117 @@ pub fn backend_log_path() -> PathBuf { } /// Read the last N lines from backend_err.log for diagnostic messages. +/// +/// Whole-file view — bootstrap phases (uv sync et al.) that predate any +/// backend run use this. Anything reporting on a specific backend process +/// (crash markers, death diagnostics) must use [`read_error_log_tail_for_run`] +/// instead: the file outlives runs, so an unbounded tail can attribute one +/// run's output to another (#1510). pub fn read_error_log_tail(max_lines: usize) -> String { let err_path = backend_log_path().with_file_name("backend_err.log"); - match fs::read_to_string(&err_path) { - Ok(content) => { - let lines: Vec<&str> = content.lines().collect(); - let start = lines.len().saturating_sub(max_lines); - lines[start..].join("\n") + read_error_log_tail_at(&err_path, 0, max_lines) +} + +// ── Per-run crash evidence (#1510) ──────────────────────────────────────── +// +// backend_err.log is one file shared by every backend run in an app session, +// and it used to be TRUNCATED on each spawn. Both properties destroyed crash +// evidence: a respawn wiped the dead process's final words, and any tail read +// after the replacement started could attach the new run's healthy startup to +// the old run's crash marker — exactly the undiagnosable report in #1510. +// The file is append-only now, each spawn records where its run begins, and +// death paths read only their own run's slice. + +/// Byte offset in backend_err.log where the CURRENT run's output begins. +/// Set by `spawn_backend` before the child starts writing. +static ERR_LOG_RUN_START: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Rotate once the shared file gets this big (append-only would otherwise +/// grow across runs forever). Generous: evidence beats disk here. +const ERR_LOG_ROTATE_BYTES: u64 = 1024 * 1024; + +/// Where the current backend run's slice of backend_err.log begins. +pub fn err_log_run_start() -> u64 { + ERR_LOG_RUN_START.load(std::sync::atomic::Ordering::SeqCst) +} + +/// Last N lines of the CURRENT run's slice of backend_err.log. +/// +/// This is the reader every death path must use: it cannot see another run's +/// output, so a crash marker carries the dying process's words or nothing. +pub fn read_error_log_tail_for_run(max_lines: usize) -> String { + let err_path = backend_log_path().with_file_name("backend_err.log"); + read_error_log_tail_at(&err_path, err_log_run_start(), max_lines) +} + +/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or +/// no longer valid — an externally replaced/shrunk file must degrade to the +/// old whole-file behaviour, never to a silent empty capture). +fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return String::new(), + }; + let start = usize::try_from(start).unwrap_or(0); + let slice = if start > 0 && start <= content.len() && content.is_char_boundary(start) { + &content[start..] + } else { + &content[..] + }; + let lines: Vec<&str> = slice.lines().collect(); + let from = lines.len().saturating_sub(max_lines); + lines[from..].join("\n") +} + +/// The previous run's stderr-drainer thread. Joined (bounded) before a new +/// spawn records its offset, so a dying run's still-buffered stderr cannot be +/// appended AFTER the new run's start offset and get attributed to the new +/// run. (Full per-child offset binding isn't needed: spawns are serialized by +/// the #1223 spawn-once flow, so the only race left was this buffered tail.) +static ERR_LOG_DRAINER: Mutex>> = Mutex::new(None); + +/// Wait briefly for the previous run's stderr drainer to flush. A wedged +/// drainer (pipe held open by an orphaned grandchild) must not block a +/// respawn forever — after the bound we proceed; the offset then simply +/// includes whatever the old run still manages to write, which degrades to +/// attributing too MUCH to the new run, never to destroying evidence. +fn join_previous_err_drainer(bound: Duration) { + let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take()); + if let Some(handle) = handle { + let deadline = std::time::Instant::now() + bound; + while !handle.is_finished() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + if handle.is_finished() { + let _ = handle.join(); } - Err(_) => String::new(), } } +/// Open backend_err.log for a new run: append-only (a respawn must not +/// destroy the previous run's evidence), rotated when oversized, with the +/// run's start offset returned for `ERR_LOG_RUN_START`. +fn open_err_log_for_run(err_path: &Path) -> (Option, u64) { + let len = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0); + if len > ERR_LOG_ROTATE_BYTES { + let rotated = err_path.with_file_name("backend_err.log.1"); + // Rename preferred (keeps the old evidence in .1); on failure — + // e.g. the file is still held open on Windows — fall back to + // truncating, which is exactly the pre-#1510 behaviour. + if fs::rename(err_path, &rotated).is_err() { + let file = fs::File::create(err_path).ok(); + return (file, 0); + } + } + let file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(err_path) + .ok(); + let start = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0); + (file, start) +} + /// Human-readable diagnostic for a failed `Command::spawn()` of the backend. /// /// #144 / #127: when the bundled venv Python can't exec (the common Linux/ @@ -281,6 +380,21 @@ pub fn read_error_log_tail(max_lines: usize) -> String { /// process "never started" and we previously surfaced "no error output /// captured". Writing this to backend_err.log lets read_error_log_tail show the /// real OS error + an actionable hint instead. +/// Replace the user's home-directory prefix with `~`. This diagnostic is +/// retained in backend_err.log across runs and lands verbatim in bug +/// reports, so the username must not travel with it. +fn redact_home(text: &str) -> String { + for var in ["HOME", "USERPROFILE"] { + if let Ok(home) = std::env::var(var) { + let home = home.trim_end_matches(['/', '\\']); + if home.len() > 1 && text.starts_with(home) { + return format!("~{}", &text[home.len()..]); + } + } + } + text.to_string() +} + fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String { // Platform-specific tail (cfg! resolves to this build's target OS, i.e. the // OS it runs on) — don't show AppImage/loader wording to macOS/Windows users. @@ -305,7 +419,7 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String { Interpreter present on disk: {}\n\ OS error: {}\n\n\ {} Use \"Clean & Retry\" to rebuild the environment.", - python.display(), + redact_home(&python.display().to_string()), python.exists(), err, os_hint, @@ -379,7 +493,24 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt } let stdout_file = fs::File::create(&log_path).ok(); - let err_log_file = fs::File::create(&err_path).ok(); + // Append + per-run offset, never truncate: the previous run's stderr is + // crash evidence until someone reads it (#1510). Flush the previous + // drainer first so old buffered lines land BEFORE this run's offset. + join_previous_err_drainer(Duration::from_secs(2)); + let (err_log_file, err_log_start) = open_err_log_for_run(&err_path); + ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst); + if let Some(ref f) = err_log_file { + use std::io::Write; + let mut f = f; + let _ = writeln!( + f, + "──── backend run starting (unix {}s) ────", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + } let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())]; // Pin the child's OMNIVOICE_PORT to the value Rust resolved so Python's @@ -493,7 +624,16 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt // real exec error instead of "no error output captured". let diag = spawn_failure_diagnostic(&python, &e); log::error!("{}", diag); - let _ = fs::write(&err_path, &diag); + // Append (not overwrite): the run header above already marks this + // run's slice, and earlier runs' evidence stays intact. + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&err_path) + { + use std::io::Write; + let _ = writeln!(f, "{}", diag); + } return None; } }; @@ -516,7 +656,9 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt if let Some(stderr_pipe) = child.stderr.take() { let app_clone = app.clone(); - std::thread::spawn(move || { + // Tracked (not detached): the next spawn joins this handle so this + // run's buffered tail flushes before the next run's offset is taken. + let drainer = std::thread::spawn(move || { use std::io::Write; let reader = BufReader::new(stderr_pipe); let mut log_file = err_log_file; @@ -528,6 +670,9 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt } } }); + if let Ok(mut guard) = ERR_LOG_DRAINER.lock() { + *guard = Some(drainer); + } } Some(child) @@ -647,4 +792,131 @@ mod tests { // unversioned (pre-app_version backend) is stale by definition assert!(!same_app_version("")); } + + // ── Per-run crash evidence (#1510) ─────────────────────────────────── + // The reported failure shape: a crash marker whose stderr tail was the + // REPLACEMENT process's healthy startup, because the shared err log was + // truncated on respawn and read unbounded afterwards. + + #[test] + fn a_respawn_preserves_the_previous_runs_evidence() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + + let (file, start) = open_err_log_for_run(&path); + assert_eq!(start, 0); + writeln!(file.unwrap(), "run1: fatal abort, last words").unwrap(); + + // Respawn: pre-#1510 this truncated the file (File::create), turning + // the dead run's final output into nothing. + let (file2, start2) = open_err_log_for_run(&path); + let content = fs::read_to_string(&path).unwrap(); + assert!( + content.contains("run1: fatal abort"), + "respawn destroyed the previous run's evidence: {content:?}" + ); + assert_eq!( + start2 as usize, + content.len(), + "run2 must begin at the old EOF" + ); + drop(file2); + } + + #[test] + fn a_run_bounded_tail_cannot_show_another_runs_output() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + + let (file, _) = open_err_log_for_run(&path); + writeln!(file.unwrap(), "run1: Traceback — the actual crash").unwrap(); + let (file2, start2) = open_err_log_for_run(&path); + writeln!(file2.unwrap(), "run2: OmniVoice model loaded successfully.").unwrap(); + + // The dead run's slice: only its own words. + let run1 = read_error_log_tail_at(&path, 0, 10); + assert!(run1.contains("the actual crash")); + // The replacement's slice: its startup, and NEVER run1's crash — + // and, symmetrically, a marker bounded to run1's slice could never + // have contained run2's healthy startup (the #1510 report). + let run2 = read_error_log_tail_at(&path, start2, 10); + assert!(run2.contains("model loaded successfully")); + assert!( + !run2.contains("the actual crash"), + "run-bounded tail leaked another run's output: {run2:?}" + ); + } + + #[test] + fn an_invalid_offset_degrades_to_the_whole_file_not_to_silence() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + fs::write(&path, "only line\n").unwrap(); + // Offset beyond EOF (file replaced/shrunk externally): evidence + // beats precision — degrade to the whole file, never to "". + assert_eq!(read_error_log_tail_at(&path, 10_000, 10), "only line"); + } + + #[test] + fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + fs::write(&path, "run1: early line\n").unwrap(); + + // A drainer still flushing the dead run's buffered tail… + let p = path.clone(); + let late = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(120)); + let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap(); + writeln!(f, "run1: buffered last words").unwrap(); + }); + *ERR_LOG_DRAINER.lock().unwrap() = Some(late); + + // …must land BEFORE the next run records where its output begins. + join_previous_err_drainer(Duration::from_secs(2)); + let (_file, start) = open_err_log_for_run(&path); + let run2 = read_error_log_tail_at(&path, start, 10); + assert!( + !run2.contains("buffered last words"), + "old run's buffered stderr was attributed to the new run: {run2:?}" + ); + assert!(fs::read_to_string(&path) + .unwrap() + .contains("buffered last words")); + } + + #[test] + fn the_spawn_diagnostic_never_carries_the_users_home_path() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved = std::env::var("HOME").ok(); + std::env::set_var("HOME", "/home/realname"); + let diag = spawn_failure_diagnostic( + Path::new("/home/realname/.local/share/app/venv/bin/python"), + &io::Error::new(io::ErrorKind::NotFound, "nope"), + ); + match saved { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + assert!(!diag.contains("/home/realname"), "home path leaked: {diag}"); + assert!(diag.contains("~/.local/share/app/venv/bin/python")); + } + + #[test] + fn an_oversized_log_rotates_instead_of_growing_forever() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + fs::write(&path, "x".repeat((ERR_LOG_ROTATE_BYTES + 1) as usize)).unwrap(); + + let (_file, start) = open_err_log_for_run(&path); + assert_eq!(start, 0, "a rotated log starts the new run at offset 0"); + let rotated = path.with_file_name("backend_err.log.1"); + assert!( + rotated.exists(), + "old evidence must survive rotation in the sibling file" + ); + } } diff --git a/frontend/src-tauri/src/bootstrap.rs b/frontend/src-tauri/src/bootstrap.rs index a1c04929..e02806e5 100644 --- a/frontend/src-tauri/src/bootstrap.rs +++ b/frontend/src-tauri/src/bootstrap.rs @@ -338,7 +338,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc setSelectedProfile(null), [setSelectedProfile]); const { refAudio, @@ -657,7 +659,7 @@ function App() { let cancelled = false; (async () => { if (remoteBackend) { - const result = await probeRemoteBackend(remoteBackend.url, remoteBackend.key); + const result = await probeRemoteBackend(remoteBackend.url); if (cancelled) return; setRemoteFailure(result.ok ? null : result); setSetupNeeded(false); @@ -765,6 +767,22 @@ function App() { // ── KEYBOARD SHORTCUTS ── useEffect(() => { const handler = (e) => { + // In-webview navigation only: using DOM keydown keeps this identical in + // browser, macOS, Windows and Linux builds (unlike OS-level hotkeys). + if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) { + const key = e.key.toLowerCase(); + if (key === 'e') { + e.preventDefault(); + window.dispatchEvent(new Event('engine-quick-switch')); + return; + } + const index = Number(key); + if (index >= 1 && index <= NAV_ITEMS.length) { + e.preventDefault(); + setMode(NAV_ITEMS[index - 1].id); + return; + } + } // ⌘+Enter or Ctrl+Enter → Generate if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); @@ -1458,7 +1476,7 @@ function App() { ) : mode === 'gallery' ? ( }> - + ) : mode === 'transcriptions' ? ( diff --git a/frontend/src/api/authCredentialHygiene.test.js b/frontend/src/api/authCredentialHygiene.test.js new file mode 100644 index 00000000..da129749 --- /dev/null +++ b/frontend/src/api/authCredentialHygiene.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); + +function* productionFiles(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'test') continue; + yield* productionFiles(absolute); + continue; + } + if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !/\.test\.[jt]sx?$/.test(entry.name)) { + yield absolute; + } + } +} + +const sources = () => + [...productionFiles(SRC)].map((file) => ({ + file: path.relative(SRC, file).replaceAll('\\', '/'), + source: fs.readFileSync(file, 'utf8'), + })); + +// Any storage receiver counts: `sessionStorage.setItem('ov_api_key', …)` is the +// same credential-persistence class as localStorage, and production code passes +// injected stores under other names (sessionStore, localStore, legacyStorage, +// storage). Matching `.setItem(` — whatever the receiver, whatever +// the quote style, optional chaining included — closes the whole class instead +// of one spelling. getItem/removeItem (the migration/removal call sites) and +// setItem of other keys stay legal. +const PERSISTED_MASTER_RE = + /\.setItem(?:\?\.)?\(\s*(?:LS_API_KEY\b|LEGACY_API_KEY_STORAGE_KEY\b|[`'"]ov_api_key[`'"])/; + +describe('administrator credential hygiene static guard', () => { + it('has no production path that writes the legacy master key to any Web Storage', () => { + const violations = sources() + .filter(({ source }) => PERSISTED_MASTER_RE.test(source)) + .map(({ file }) => file); + + expect(violations, 'OMNIVOICE_API_KEY must never enter localStorage or sessionStorage').toEqual( + [], + ); + }); + + it('catches realistic storage receivers, aliases, and quote styles', () => { + const caught = [ + "localStorage.setItem('ov_api_key', key)", + "localStorage.setItem?.('ov_api_key', key)", + 'sessionStorage.setItem("ov_api_key", key)', + 'window.localStorage.setItem(`ov_api_key`, key)', + 'sessionStore?.setItem(LS_API_KEY, key)', + 'localStore.setItem( LEGACY_API_KEY_STORAGE_KEY, key)', + 'legacyStorage?.setItem(LS_API_KEY, master)', + ]; + const allowed = [ + "localStorage.removeItem('ov_api_key')", + 'localStore?.getItem(LS_API_KEY)', + 'storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record))', + "sessionStore?.setItem('ov_pin', pin)", + 'localStorage.setItem(LS_BACKEND_URL, normalized)', + ]; + for (const line of caught) expect(PERSISTED_MASTER_RE.test(line), line).toBe(true); + for (const line of allowed) expect(PERSISTED_MASTER_RE.test(line), line).toBe(false); + }); + + it('has no production WebSocket query builder for a master API key', () => { + const forbidden = [ + /searchParams\.set\(\s*['"]api_key['"]/, + /[?&]api_key=\$\{/, + /[?&]api_key=['"]\s*\+/, + ]; + const violations = sources() + .filter(({ source }) => forbidden.some((pattern) => pattern.test(source))) + .map(({ file }) => file); + + expect(violations, 'WebSocket URLs may contain ws_ticket, never a master key').toEqual([]); + }); + + it('keeps both WebSocket consumers behind the authenticated URL boundary', () => { + const constructors = sources() + .filter(({ source }) => source.includes('new WebSocket(')) + .map(({ file, source }) => ({ file, authenticated: source.includes('authenticatedWsUrl') })); + + expect(constructors).toEqual([ + { file: 'components/CaptureWidget.jsx', authenticated: true }, + { file: 'hooks/useRealtimeEvents.js', authenticated: true }, + ]); + }); +}); diff --git a/frontend/src/api/authSession.test.ts b/frontend/src/api/authSession.test.ts new file mode 100644 index 00000000..8b2835a7 --- /dev/null +++ b/frontend/src/api/authSession.test.ts @@ -0,0 +1,548 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ADMIN_SESSION_STORAGE_KEY, + AuthSessionError, + LEGACY_API_KEY_STORAGE_KEY, + authenticatedWsUrl, + clearAdminSession, + exchangeApiKey, + getAdminSession, + isSameOriginApi, + requestWebSocketTicket, + revokeAdminSession, +} from './authSession'; + +const SESSION = `ovs_admin_session_${'A'.repeat(43)}`; +const TICKET = `ovs_ws_ticket_${'B'.repeat(43)}`; +const MASTER = 'master-must-never-persist'; +const NOW_SECONDS = 1_800_000_000; + +const response = (body: unknown, status = 201) => + new Response(body === null ? null : JSON.stringify(body), { + status, + headers: body === null ? undefined : { 'content-type': 'application/json' }, + }); + +const sameOriginWindow = { + location: { origin: 'https://voice.test' }, + dispatchEvent: vi.fn(), +}; + +const crossOriginWindow = { + location: { origin: 'tauri://localhost' }, + __TAURI_INTERNALS__: {}, + dispatchEvent: vi.fn(), +}; + +describe('short-lived admin session client', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + sessionStorage.clear(); + }); + + it('selects cookie transport only for an exact same-origin HTTP API', () => { + expect(isSameOriginApi('https://voice.test', sameOriginWindow)).toBe(true); + expect(isSameOriginApi('https://voice.test:444', sameOriginWindow)).toBe(false); + expect(isSameOriginApi('http://voice.test', sameOriginWindow)).toBe(false); + expect(isSameOriginApi('https://voice.test.evil.test', sameOriginWindow)).toBe(false); + expect(isSameOriginApi('http://127.0.0.1:3900', crossOriginWindow)).toBe(false); + }); + + it('exchanges a same-origin master for an HttpOnly cookie without persisting any token', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const fetchImpl = vi.fn().mockResolvedValue(response(null, 204)); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://voice.test/', + fetchImpl, + windowLike: sameOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toEqual({ transport: 'cookie' }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://voice.test/api/auth/session', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + cache: 'no-store', + referrerPolicy: 'no-referrer', + headers: expect.objectContaining({ Authorization: `Bearer ${MASTER}` }), + body: JSON.stringify({ transport: 'cookie' }), + }), + ); + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull(); + expect(sessionStorage.length).toBe(0); + }); + + it('stores only a backend-bound short-lived bearer session for cross-origin clients', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 })); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900/', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 }); + + const persisted = sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY) ?? ''; + expect(persisted).toContain(SESSION); + expect(persisted).toContain('https://gpu.test:3900'); + expect(persisted).not.toContain(MASTER); + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull(); + expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }); + }); + + it('uses relative lifetime when remote and browser clocks are not synchronized', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + response({ + token: SESSION, + expires_at: 1, + expires_in: 3600, + }), + ); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 }); + + expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }); + }); + + it('retains the legacy master while the exchange is pending and removes it on success', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + let resolveFetch: (value: Response) => void = () => {}; + const fetchImpl = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const pending = exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }); + + // Not yet: only a session that actually exists may consume the stored key. + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER); + resolveFetch(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 })); + await pending; + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull(); + }); + + it('never retries a failed exchange and exposes no master or response body in its error', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const reflected = `invalid credential: ${MASTER}`; + const fetchImpl = vi.fn().mockResolvedValue(response({ detail: reflected }, 401)); + + const error = await exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }).catch((value) => value); + + expect(error).toBeInstanceOf(AuthSessionError); + expect(error.status).toBe(401); + expect(String(error)).not.toContain(MASTER); + expect(String(error)).not.toContain(reflected); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(sessionStorage.length).toBe(0); + // A failed exchange leaves the durable key for the next launch's retry. + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER); + }); + + it('bounds a hung exchange and retains the durable master for the next migration attempt', async () => { + vi.useFakeTimers(); + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + const pending = exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl: fetchImpl as typeof fetch, + windowLike: crossOriginWindow, + timeoutMs: 25, + }); + const observed = pending.catch((error) => error); + await vi.advanceTimersByTimeAsync(25); + + expect(await observed).toBeInstanceOf(AuthSessionError); + expect(fetchImpl).toHaveBeenCalledOnce(); + // Unreachable/hung backend: the stored copy is the user's only copy. + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER); + expect(sessionStorage.length).toBe(0); + vi.useRealTimers(); + }); + + it.each([ + [{ token: MASTER, expires_at: NOW_SECONDS + 3600 }, 'master-shaped token'], + [{ token: SESSION, expires_at: NOW_SECONDS - 1 }, 'expired session'], + [{ token: SESSION, expires_at: NOW_SECONDS + 40_000 }, 'implausible expiry'], + [{ token: SESSION }, 'missing expiry'], + [null, 'missing body'], + ])('rejects and does not persist a malformed bearer response: %s (%s)', async (body, _label) => { + const fetchImpl = vi.fn().mockResolvedValue(response(body)); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + expect(sessionStorage.length).toBe(0); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, 40_000, '3600'])( + 'rejects an invalid relative session lifetime: %s', + async (expiresIn) => { + const fetchImpl = vi.fn().mockResolvedValue( + response({ + token: SESSION, + expires_at: NOW_SECONDS + 3600, + expires_in: expiresIn, + }), + ); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + expect(sessionStorage.length).toBe(0); + }, + ); + + it('rejects oversized bearer responses before parsing them', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response('x'.repeat(20_000), { + status: 201, + headers: { 'content-length': '20000' }, + }), + ); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + expect(sessionStorage.length).toBe(0); + }); + + it('drops malformed, expired, or wrong-backend session storage', () => { + sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, '{bad json'); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ token: SESSION, expiresAt: NOW_SECONDS - 1, apiBase: 'https://gpu.test' }), + ); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 10, + apiBase: 'https://other.test', + }), + ); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 40_000, + apiBase: 'https://gpu.test', + }), + ); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); + }); + + it('mints a path-bound WebSocket ticket with the session only in an HTTP header', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 })); + + await expect( + requestWebSocketTicket('/ws/transcribe?model=live', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toBe(TICKET); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gpu.test:3900/api/auth/ws-ticket', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }), + body: JSON.stringify({ path: '/ws/transcribe' }), + }), + ); + expect(JSON.stringify(fetchImpl.mock.calls[0][0])).not.toContain(SESSION); + }); + + it('accepts a ticket lifetime independent of server wall-clock skew', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_at: 1, expires_in: 30 })); + + await expect( + requestWebSocketTicket('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toBe(TICKET); + }); + + it('places only the one-use ticket in a bearer-authenticated WebSocket URL', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 })); + + const url = await authenticatedWsUrl('/ws/transcribe?model=live&api_key=legacy', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + + expect(url).toBe(`wss://gpu.test:3900/ws/transcribe?model=live&ws_ticket=${TICKET}`); + expect(url).not.toContain(SESSION); + expect(url).not.toContain(MASTER); + expect(url).not.toContain('api_key'); + }); + + it('preserves a reverse-proxy base path while binding the ticket to the logical WS route', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test/studio', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_in: 30, expires_at: 1 })); + + await expect( + authenticatedWsUrl('/ws/events?view=active', { + apiBase: 'https://gpu.test/studio', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toBe(`wss://gpu.test/studio/ws/events?view=active&ws_ticket=${TICKET}`); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gpu.test/studio/api/auth/ws-ticket', + expect.objectContaining({ body: JSON.stringify({ path: '/ws/events' }) }), + ); + }); + + it.each(['/ws/events/../admin', '//evil.test/ws/events', 'https://gpu.test/ws/events'])( + 'rejects a non-canonical WebSocket target: %s', + async (path) => { + await expect( + authenticatedWsUrl(path, { apiBase: 'https://gpu.test', fetchImpl: vi.fn() }), + ).rejects.toBeInstanceOf(AuthSessionError); + }, + ); + + it('requests a fresh ticket for every WebSocket connection attempt', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const secondTicket = `ovs_ws_ticket_${'C'.repeat(43)}`; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 })) + .mockResolvedValueOnce(response({ ticket: secondTicket, expires_at: NOW_SECONDS + 30 })); + + const first = await authenticatedWsUrl('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + const second = await authenticatedWsUrl('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + + expect(first).toContain(TICKET); + expect(second).toContain(secondTicket); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('uses a credential-free WebSocket URL when no bearer session exists', async () => { + const fetchImpl = vi.fn(); + await expect( + authenticatedWsUrl('/ws/events?api_key=must-be-removed', { + apiBase: 'http://127.0.0.1:3900', + fetchImpl, + }), + ).resolves.toBe('ws://127.0.0.1:3900/ws/events'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('clears an invalid session and raises the auth gate when ticket issuance is rejected', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const windowLike = { ...crossOriginWindow, dispatchEvent: vi.fn() }; + + await expect( + requestWebSocketTicket('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl: vi.fn().mockResolvedValue(response({ detail: 'expired' }, 401)), + windowLike, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + + expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + expect(windowLike.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ov:auth-required' }), + ); + }); + + it('clears session state idempotently without touching unrelated storage', () => { + sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, 'value'); + sessionStorage.setItem('unrelated', 'keep'); + clearAdminSession(); + clearAdminSession(); + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); + expect(sessionStorage.getItem('unrelated')).toBe('keep'); + }); + + it('revokes a bearer session while clearing local state before the request settles', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + let resolveFetch: (response: Response) => void = () => {}; + const fetchImpl = vi.fn(() => new Promise((resolve) => (resolveFetch = resolve))); + + const pending = revokeAdminSession('https://gpu.test:3900', { + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gpu.test:3900/api/auth/session', + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }), + credentials: 'include', + }), + ); + resolveFetch(response(null, 204)); + await expect(pending).resolves.toBe(true); + }); + + it('revokes a same-origin cookie session with the CSRF marker', async () => { + const fetchImpl = vi.fn().mockResolvedValue(response(null, 204)); + + await expect( + revokeAdminSession('https://voice.test', { + fetchImpl, + windowLike: sameOriginWindow, + }), + ).resolves.toBe(true); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://voice.test/api/auth/session', + expect.objectContaining({ + headers: { 'X-VoiceStudio-CSRF': '1' }, + credentials: 'include', + }), + ); + }); +}); diff --git a/frontend/src/api/authSession.ts b/frontend/src/api/authSession.ts new file mode 100644 index 00000000..0f5f4b43 --- /dev/null +++ b/frontend/src/api/authSession.ts @@ -0,0 +1,483 @@ +/** + * Browser-side boundary for the remote administrator credential. + * + * The configured master key is accepted only as an input to `exchangeApiKey`. + * It is never written to storage and never placed in a WebSocket URL. Browser + * clients retain only a backend-bound, short-lived session in sessionStorage; + * same-origin clients use an HttpOnly cookie that JavaScript cannot read. + */ + +export const LEGACY_API_KEY_STORAGE_KEY = 'ov_api_key'; +export const ADMIN_SESSION_STORAGE_KEY = 'ov_admin_session'; +export const CSRF_HEADER_NAME = 'X-VoiceStudio-CSRF'; + +const ADMIN_SESSION_RE = /^ovs_admin_session_[A-Za-z0-9_-]{43}$/; +const WS_TICKET_RE = /^ovs_ws_ticket_[A-Za-z0-9_-]{43}$/; +const MAX_AUTH_RESPONSE_BYTES = 16 * 1024; +const MAX_SESSION_LIFETIME_SECONDS = 9 * 60 * 60; +const MAX_TICKET_LIFETIME_SECONDS = 60; + +type StorageLike = Pick; + +type AuthWindow = { + location?: { origin?: string }; + dispatchEvent?: (event: Event) => boolean; + __TAURI__?: unknown; + __TAURI_INTERNALS__?: unknown; +}; + +type CommonOptions = { + apiBase: string; + fetchImpl?: typeof fetch; + storage?: StorageLike | null; + windowLike?: AuthWindow; + now?: () => number; + timeoutMs?: number; +}; + +export type StoredAdminSession = { + token: string; + expiresAt: number; + apiBase: string; +}; + +export class AuthSessionError extends Error { + status?: number; + + constructor(status?: number) { + super('Remote administrator authentication failed.'); + this.name = 'AuthSessionError'; + this.status = status; + } +} + +function defaultWindow(): AuthWindow | undefined { + return typeof window === 'undefined' ? undefined : window; +} + +function defaultSessionStorage(): StorageLike | null { + try { + return typeof sessionStorage === 'undefined' ? null : sessionStorage; + } catch { + return null; + } +} + +function defaultLocalStorage(): StorageLike | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +function normalizedApiBase(raw: string): string { + const candidate = raw.trim(); + let url: URL; + try { + url = new URL(candidate); + } catch { + throw new AuthSessionError(); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash + ) { + throw new AuthSessionError(); + } + return url.toString().replace(/\/+$/, ''); +} + +export function isSameOriginApi( + apiBase: string, + windowLike: AuthWindow | undefined = defaultWindow(), +): boolean { + try { + const apiOrigin = new URL(normalizedApiBase(apiBase)).origin; + const pageOrigin = windowLike?.location?.origin; + return Boolean(pageOrigin && pageOrigin !== 'null' && apiOrigin === pageOrigin); + } catch { + return false; + } +} + +function removeLegacyMaster(storage: StorageLike | null = defaultLocalStorage()): void { + try { + storage?.removeItem(LEGACY_API_KEY_STORAGE_KEY); + } catch { + // A blocked storage API is already equivalent to the key not persisting. + } +} + +export function clearAdminSession({ + storage = defaultSessionStorage(), +}: { storage?: StorageLike | null } = {}): void { + try { + storage?.removeItem(ADMIN_SESSION_STORAGE_KEY); + } catch { + // Best effort; callers still stop using the in-memory value immediately. + } +} + +export function getAdminSession( + apiBase: string, + { + storage = defaultSessionStorage(), + now = Date.now, + }: { storage?: StorageLike | null; now?: () => number } = {}, +): StoredAdminSession | null { + let normalized: string; + try { + normalized = normalizedApiBase(apiBase); + } catch { + clearAdminSession({ storage }); + return null; + } + + let raw: string | null = null; + try { + raw = storage?.getItem(ADMIN_SESSION_STORAGE_KEY) ?? null; + } catch { + return null; + } + if (!raw || raw.length > 4096) { + if (raw) clearAdminSession({ storage }); + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const nowSeconds = now() / 1000; + if ( + !ADMIN_SESSION_RE.test(String(parsed.token ?? '')) || + typeof parsed.expiresAt !== 'number' || + !Number.isFinite(parsed.expiresAt) || + parsed.expiresAt <= nowSeconds || + parsed.expiresAt > nowSeconds + MAX_SESSION_LIFETIME_SECONDS || + parsed.apiBase !== normalized + ) { + clearAdminSession({ storage }); + return null; + } + return { + token: parsed.token as string, + expiresAt: parsed.expiresAt, + apiBase: normalized, + }; + } catch { + clearAdminSession({ storage }); + return null; + } +} + +async function readBoundedText(response: Response): Promise { + const advertisedBytes = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_AUTH_RESPONSE_BYTES) { + throw new AuthSessionError(response.status); + } + + const reader = response.body?.getReader(); + if (!reader) return ''; + const decoder = new TextDecoder(); + const parts: string[] = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_AUTH_RESPONSE_BYTES) { + await reader.cancel(); + throw new AuthSessionError(response.status); + } + parts.push(decoder.decode(value, { stream: true })); + } + parts.push(decoder.decode()); + return parts.join(''); + } finally { + reader.releaseLock(); + } +} + +async function readBoundedObject(response: Response): Promise> { + const text = await readBoundedText(response); + try { + const value = JSON.parse(text); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(); + return value as Record; + } catch (error) { + if (error instanceof AuthSessionError) throw error; + throw new AuthSessionError(response.status); + } +} + +function plausibleExpiry( + value: unknown, + nowMs: number, + maxLifetimeSeconds: number, +): value is number { + if (typeof value !== 'number' || !Number.isFinite(value)) return false; + const nowSeconds = nowMs / 1000; + return value > nowSeconds && value <= nowSeconds + maxLifetimeSeconds; +} + +function responseExpiry( + payload: Record, + nowMs: number, + maxLifetimeSeconds: number, +): number | null { + const relative = payload.expires_in; + if (relative !== undefined) { + if ( + typeof relative !== 'number' || + !Number.isFinite(relative) || + relative <= 0 || + relative > maxLifetimeSeconds + ) { + return null; + } + return nowMs / 1000 + relative; + } + return plausibleExpiry(payload.expires_at, nowMs, maxLifetimeSeconds) ? payload.expires_at : null; +} + +function dispatchAuthRequired(windowLike: AuthWindow | undefined): void { + try { + windowLike?.dispatchEvent?.( + new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } }), + ); + } catch { + // Non-browser callers can still handle the typed error. + } +} + +export async function exchangeApiKey( + apiKey: string, + { + apiBase, + fetchImpl = fetch, + storage = defaultSessionStorage(), + windowLike = defaultWindow(), + now = Date.now, + legacyStorage = defaultLocalStorage(), + timeoutMs = 10_000, + }: CommonOptions & { legacyStorage?: StorageLike | null }, +): Promise<{ transport: 'cookie' } | { transport: 'bearer'; expiresAt: number }> { + // A stale session must not outlive a new exchange attempt, but the + // historical durable master is deleted only after the backend ACCEPTS the + // exchange. Deleting it up front stranded remote-backend users whose box was + // unreachable at first launch after upgrade: the failed exchange consumed + // their only stored copy of OMNIVOICE_API_KEY. Keeping it on failure lets + // the next launch retry the migration; every success path below removes it, + // so the key never coexists with a live session. + clearAdminSession({ storage }); + + const master = apiKey.trim(); + if (!master || master.length > 8192) throw new AuthSessionError(); + const base = normalizedApiBase(apiBase); + const transport = isSameOriginApi(base, windowLike) ? 'cookie' : 'bearer'; + + let response: Response; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 60_000))); + try { + response = await fetchImpl(`${base}/api/auth/session`, { + method: 'POST', + headers: { + Authorization: `Bearer ${master}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ transport }), + credentials: 'include', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + } catch { + throw new AuthSessionError(); + } finally { + clearTimeout(timer); + } + + if (transport === 'cookie') { + if (response.status !== 204) throw new AuthSessionError(response.status); + removeLegacyMaster(legacyStorage); + return { transport }; + } + if (response.status !== 201) throw new AuthSessionError(response.status); + + const payload = await readBoundedObject(response); + const token = payload.token; + const expiresAt = responseExpiry(payload, now(), MAX_SESSION_LIFETIME_SECONDS); + if (typeof token !== 'string' || !ADMIN_SESSION_RE.test(token) || expiresAt === null) { + throw new AuthSessionError(response.status); + } + + const record: StoredAdminSession = { token, expiresAt, apiBase: base }; + try { + if (!storage) throw new TypeError(); + storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record)); + } catch { + clearAdminSession({ storage }); + throw new AuthSessionError(); + } + removeLegacyMaster(legacyStorage); + return { transport, expiresAt }; +} + +/** Best-effort server revocation used when switching away from a backend. + * Local state is cleared before the network await, so a hung or unreachable + * backend cannot prolong the browser's ability to use the session. */ +export async function revokeAdminSession( + apiBase: string, + { + fetchImpl = fetch, + storage = defaultSessionStorage(), + windowLike = defaultWindow(), + now = Date.now, + timeoutMs = 1500, + }: Omit = {}, +): Promise { + let base: string; + try { + base = normalizedApiBase(apiBase); + } catch { + clearAdminSession({ storage }); + return false; + } + const session = getAdminSession(base, { storage, now }); + const sameOrigin = isSameOriginApi(base, windowLike); + clearAdminSession({ storage }); + // Cross-origin cookie auth cannot work (the cookie is SameSite=Strict), and + // without a bearer token there is nothing meaningful to revoke remotely. + if (!session && !sameOrigin) return true; + + const headers: Record = {}; + if (session) headers.Authorization = `Bearer ${session.token}`; + if (sameOrigin) headers[CSRF_HEADER_NAME] = '1'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 10_000))); + try { + const response = await fetchImpl(`${base}/api/auth/session`, { + method: 'DELETE', + headers, + credentials: 'include', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + return response.status === 204; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +const ALLOWED_WS_PATHS = new Set(['/ws/events', '/ws/transcribe']); +const LOGICAL_WS_ORIGIN = 'http://omnivoice.invalid'; + +function websocketTarget(path: string, apiBase: string): { url: URL; logicalPath: string } { + const base = normalizedApiBase(apiBase); + const baseUrl = new URL(base); + let logical: URL; + try { + if (!path.startsWith('/') || path.startsWith('//')) throw new TypeError(); + logical = new URL(path, `${LOGICAL_WS_ORIGIN}/`); + } catch { + throw new AuthSessionError(); + } + if (logical.origin !== LOGICAL_WS_ORIGIN || !ALLOWED_WS_PATHS.has(logical.pathname)) { + throw new AuthSessionError(); + } + + // Resolve relative to `${base}/`, not the origin root. Reverse proxies may + // publish the backend under a path prefix (for example `/studio`). The + // server still receives the logical route after the proxy strips its prefix, + // so ticket binding uses `logical.pathname` below. + const url = new URL(path.slice(1), `${base}/`); + if (url.origin !== baseUrl.origin) throw new AuthSessionError(); + url.username = ''; + url.password = ''; + url.hash = ''; + url.searchParams.delete('api_key'); + url.searchParams.delete('ws_ticket'); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return { url, logicalPath: logical.pathname }; +} + +export async function requestWebSocketTicket( + path: string, + { + apiBase, + fetchImpl = fetch, + storage = defaultSessionStorage(), + windowLike = defaultWindow(), + now = Date.now, + timeoutMs = 5000, + }: CommonOptions, +): Promise { + const base = normalizedApiBase(apiBase); + const { logicalPath } = websocketTarget(path, base); + const session = getAdminSession(base, { storage, now }); + if (!session) throw new AuthSessionError(401); + + let response: Response; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 30_000))); + try { + response = await fetchImpl(`${base}/api/auth/ws-ticket`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path: logicalPath }), + credentials: 'include', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + } catch { + throw new AuthSessionError(); + } finally { + clearTimeout(timer); + } + + if (response.status !== 201) { + if (response.status === 401 || response.status === 403) { + clearAdminSession({ storage }); + dispatchAuthRequired(windowLike); + } + throw new AuthSessionError(response.status); + } + const payload = await readBoundedObject(response); + const expiresAt = responseExpiry(payload, now(), MAX_TICKET_LIFETIME_SECONDS); + if ( + typeof payload.ticket !== 'string' || + !WS_TICKET_RE.test(payload.ticket) || + expiresAt === null + ) { + throw new AuthSessionError(response.status); + } + return payload.ticket; +} + +export async function authenticatedWsUrl(path: string, options: CommonOptions): Promise { + const { url } = websocketTarget(path, options.apiBase); + const session = getAdminSession(options.apiBase, { + storage: options.storage, + now: options.now, + }); + if (!session) return url.toString(); + + const ticket = await requestWebSocketTicket(path, options); + url.searchParams.set('ws_ticket', ticket); + return url.toString(); +} diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 6666ca03..89c42e36 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { _parseDeepLinkCredentials } from './client'; +import { + API, + _bootstrapBrowserCredentials, + _isApiTarget, + _parseDeepLinkCredentials, + wsUrl, +} from './client'; +import { ADMIN_SESSION_STORAGE_KEY, CSRF_HEADER_NAME } from './authSession'; describe('apiFetch PIN header', () => { let realFetch: typeof globalThis.fetch; @@ -21,7 +28,8 @@ describe('apiFetch PIN header', () => { }) as any; const { apiFetch } = await import('./client'); await apiFetch('/system/info'); - expect((seen.headers || {})['X-OmniVoice-Pin']).toBe('424242'); + expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBe('424242'); + expect(seen.credentials).toBe('include'); }); it('omits the header when no pin', async () => { @@ -32,7 +40,27 @@ describe('apiFetch PIN header', () => { }) as any; const { apiFetch } = await import('./client'); await apiFetch('/system/info'); - expect((seen.headers || {})['X-OmniVoice-Pin']).toBeUndefined(); + expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBeNull(); + }); + + it('keeps cookie and loopback requests usable when Web Storage is blocked', async () => { + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as any; + + try { + const { apiFetch } = await import('./client'); + await expect(apiFetch('/system/info')).resolves.toMatchObject({ ok: true }); + } finally { + getItem.mockRestore(); + } + + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get('X-OmniVoice-Pin')).toBeNull(); + expect(headers.get('Authorization')).toBeNull(); + expect(fetchMock.mock.calls[0][1]?.credentials).toBe('include'); }); it('turns a thrown fetch into an actionable ApiError (backend unreachable)', async () => { @@ -55,6 +83,85 @@ describe('apiFetch PIN header', () => { }); }); +describe('apiFetch short-lived admin authentication', () => { + let realFetch: typeof globalThis.fetch; + + beforeEach(() => { + realFetch = globalThis.fetch; + sessionStorage.clear(); + localStorage.clear(); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + sessionStorage.clear(); + localStorage.clear(); + }); + + it('attaches only the backend-bound short-lived session, never the persisted master', async () => { + const session = `ovs_admin_session_${'S'.repeat(43)}`; + localStorage.setItem('ov_api_key', 'legacy-master'); + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }), + ); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as any; + + const { apiFetch } = await import('./client'); + await apiFetch('/system/info'); + + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get('Authorization')).toBe(`Bearer ${session}`); + expect(headers.get('Authorization')).not.toContain('legacy-master'); + }); + + it('builds credential-free legacy WebSocket URLs even if old storage is populated', () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const url = wsUrl('/ws/events?view=active'); + expect(url).toContain('/ws/events?view=active'); + expect(url).not.toContain('api_key'); + expect(url).not.toContain('legacy-master'); + }); + + it('never sends backend credentials to an absolute foreign URL', async () => { + const session = `ovs_admin_session_${'S'.repeat(43)}`; + sessionStorage.setItem('ov_pin', '424242'); + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }), + ); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as any; + + const { apiFetch } = await import('./client'); + await apiFetch('https://voice.example.evil.test/public.wav', { + credentials: 'omit', + headers: { 'X-Public-Media': '1' }, + }); + + const [target, init] = fetchMock.mock.calls[0]; + const headers = new Headers(init?.headers); + expect(target).toBe('https://voice.example.evil.test/public.wav'); + expect(headers.get('Authorization')).toBeNull(); + expect(headers.get('X-OmniVoice-Pin')).toBeNull(); + expect(headers.get(CSRF_HEADER_NAME)).toBeNull(); + expect(headers.get('X-Public-Media')).toBe('1'); + expect(init?.credentials).toBe('omit'); + }); + + it('binds credentials to the exact configured API path prefix', () => { + expect(_isApiTarget('https://voice.test/studio/v1/audio', 'https://voice.test/studio')).toBe( + true, + ); + expect(_isApiTarget('https://voice.test/studio-evil/v1', 'https://voice.test/studio')).toBe( + false, + ); + expect(_isApiTarget('https://voice.test/other', 'https://voice.test/studio')).toBe(false); + expect(_isApiTarget('https://voice.test.evil/v1', 'https://voice.test')).toBe(false); + }); +}); + describe('apiFetch 401 routing', () => { // The backend has two 401-returning middlewares distinguished only by their // `detail` body: "API key required" (BearerKeyMiddleware) vs "PIN required" @@ -89,6 +196,14 @@ describe('apiFetch 401 routing', () => { dispatch.mock.calls.map((c) => c[0]).find((e) => (e as Event).type === 'ov:auth-required'); it('dispatches ov:auth-required {mode:"apikey"} on an "API key required" 401', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: `ovs_admin_session_${'S'.repeat(43)}`, + expiresAt: Date.now() / 1000 + 3600, + apiBase: API, + }), + ); globalThis.fetch = stub401('API key required'); const { apiFetch } = await import('./client'); try { @@ -98,6 +213,7 @@ describe('apiFetch 401 routing', () => { } expect(authEvent()).toBeTruthy(); expect((authEvent() as any).detail.mode).toBe('apikey'); + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); }); it('dispatches ov:auth-required {mode:"pin"} on a "PIN required" 401', async () => { @@ -231,3 +347,127 @@ describe('_parseDeepLinkCredentials', () => { expect(r.cleanUrl).toBe('/path?page=2#top'); }); }); + +describe('_bootstrapBrowserCredentials', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('scrubs the fragment before exchanging exactly once, deleting legacy storage on success', async () => { + const order: string[] = []; + localStorage.setItem('ov_api_key', 'older-master'); + const win = { + location: { href: 'https://voice.test/app?pin=1234#api_key=fragment-master&tab=voices' }, + history: { + replaceState: (_data: unknown, _unused: string, url?: string | URL | null) => { + order.push(`scrub:${String(url)}`); + }, + }, + }; + const exchange = vi.fn(async (master) => { + order.push('exchange'); + expect(master).toBe('fragment-master'); + // The durable key survives until the backend accepts the exchange — a + // failure at this point must leave it for the next launch to retry. + expect(localStorage.getItem('ov_api_key')).toBe('older-master'); + }); + + await _bootstrapBrowserCredentials(win, { + apiBase: 'https://voice.test', + exchange: exchange as any, + }); + + expect(sessionStorage.getItem('ov_pin')).toBe('1234'); + expect(order).toEqual(['scrub:/app#tab=voices', 'exchange']); + expect(exchange).toHaveBeenCalledOnce(); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('retains the stored master when the backend is unreachable (no stranding)', async () => { + // The upgrade-day disaster this guards against: a remote-backend user's + // only copy of OMNIVOICE_API_KEY lives in localStorage, and the backend is + // down at first launch. The failed exchange must NOT consume the key. + localStorage.setItem('ov_api_key', 'legacy-master'); + const exchange = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + + await expect( + _bootstrapBrowserCredentials( + { location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } }, + { apiBase: 'https://voice.test', exchange }, + ), + ).rejects.toThrow(); + + expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' }); + expect(localStorage.getItem('ov_api_key')).toBe('legacy-master'); + }); + + it('re-runs the migration on the next launch and consumes the key once it succeeds', async () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const exchange = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 }); + const launch = () => + _bootstrapBrowserCredentials( + { location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } }, + { apiBase: 'https://voice.test', exchange }, + ); + + // Launch 1: backend unreachable — key survives. + await expect(launch()).rejects.toThrow(); + expect(localStorage.getItem('ov_api_key')).toBe('legacy-master'); + + // Launch 2: backend back — the retained key is retried and then removed. + await launch(); + expect(exchange).toHaveBeenNthCalledWith(2, 'legacy-master', { + apiBase: 'https://voice.test', + }); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('consumes a legacy stored master without writing it anywhere else', async () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' }); + + await _bootstrapBrowserCredentials( + { + location: { href: 'https://voice.test/' }, + history: { replaceState: vi.fn() }, + }, + { apiBase: 'https://voice.test', exchange }, + ); + + expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' }); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + expect(sessionStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('still deletes and exchanges the master when PIN session storage is blocked', async () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const replaceState = vi.fn(); + const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' }); + + await _bootstrapBrowserCredentials( + { + location: { href: 'https://voice.test/?pin=1234#api_key=fragment-master' }, + history: { replaceState }, + }, + { + apiBase: 'https://voice.test', + sessionStore: { + setItem: vi.fn(() => { + throw new DOMException('blocked'); + }), + }, + exchange, + }, + ); + + expect(replaceState).toHaveBeenCalledWith(null, '', '/'); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + expect(exchange).toHaveBeenCalledWith('fragment-master', { + apiBase: 'https://voice.test', + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f16d8d11..021ae2ad 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -26,13 +26,23 @@ import { recordBackendContact, unreachableBackendMessage, } from '../utils/backendContact.ts'; +import { + CSRF_HEADER_NAME, + LEGACY_API_KEY_STORAGE_KEY, + clearAdminSession, + exchangeApiKey, + getAdminSession, + isSameOriginApi, +} from './authSession.ts'; const viteEnv = import.meta.env ?? {}; // Remote-backend settings (Wave 2.3): user-configured in Settings → Sharing. // localStorage so the choice survives restarts; read once at module load — // the Settings panel reloads the app on save. export const LS_BACKEND_URL = 'ov_backend_url'; -export const LS_API_KEY = 'ov_api_key'; +// Compatibility name used only to delete data written by older releases. +// New code must never persist the configured master credential. +export const LS_API_KEY = LEGACY_API_KEY_STORAGE_KEY; // Pure + exported for unit testing — takes env + window so tests don't need to // re-import the module or stub import.meta.env. export function _resolveApiBase(env: any, win: any): string { @@ -73,40 +83,25 @@ export function _resolveApiBase(env: any, win: any): string { } export const API = _resolveApiBase(viteEnv, typeof window !== 'undefined' ? window : undefined); -function _apiKey(): string | null { +function sessionPin(): string | null { try { - return typeof localStorage !== 'undefined' ? localStorage.getItem(LS_API_KEY) : null; + return typeof sessionStorage === 'undefined' ? null : sessionStorage.getItem('ov_pin'); } catch { + // Cookie-authenticated and loopback requests must still work when a + // privacy policy blocks Web Storage. return null; } } -/** Persist the durable remote API key (trimmed). localStorage so it survives - * reloads; read back by `_apiKey()` on every request. Returns false (without - * writing) when the value is empty-after-trim or storage is unavailable, so the - * caller can avoid reloading into a loop. */ -export function saveApiKey(v: string): boolean { - const t = v.trim(); - if (!t) return false; - try { - localStorage.setItem(LS_API_KEY, t); - return true; - } catch { - return false; - } -} - /** Build a ws:// or wss:// URL for a backend WebSocket endpoint. * * Scheme derives from the API base itself (NOT window.location — a Tauri - * webview pointing at an https remote must still get wss), and the remote - * API key rides as ?api_key= because browser WebSockets can't set headers. */ + * webview pointing at an https remote must still get wss). Credentials are + * intentionally excluded; authenticated callers obtain a one-use ticket via + * `authenticatedWsUrl` in authSession.ts. */ export function wsUrl(path: string): string { const base = API.replace(/^http/, 'ws').replace(/\/+$/, ''); - const url = `${base}${path.startsWith('/') ? '' : '/'}${path}`; - const key = _apiKey(); - if (!key) return url; - return `${url}${url.includes('?') ? '&' : '?'}api_key=${encodeURIComponent(key)}`; + return `${base}${path.startsWith('/') ? '' : '/'}${path}`; } /** @@ -115,9 +110,9 @@ export function wsUrl(path: string): string { * the effects. * • ?pin= (query) — LAN-share QR. Returned as `pin` (session). * • #api_key= (fragment) — remote-backend deep link. Returned as `apiKey` - * (durable). Read from the FRAGMENT because fragments aren't sent to the - * server, so the durable secret stays out of request logs; the PIN stays in - * the query since the QR flow needs the server to see it. + * for one immediate exchange. Read from the FRAGMENT because fragments + * aren't sent to the server, so the root secret stays out of request logs; + * the PIN stays in the query since the QR flow needs the server to see it. * A stray legacy ?api_key= in the query is scrubbed from `cleanUrl` but NOT * returned — reading it would resend the secret to the server on reload, the * very leak the fragment avoids. `scrubbed` is true when any credential param @@ -152,15 +147,95 @@ export function _parseDeepLinkCredentials(href: string): { }; } -// On load, capture deep-link credentials (?pin= from the QR query, #api_key= -// from a remote-backend fragment) so apiFetch attaches them automatically, then -// scrub them from the address bar (one-shot — see _parseDeepLinkCredentials). +type BootstrapWindow = { + location: { href: string }; + history: { replaceState: (data: unknown, unused: string, url?: string | URL | null) => void }; +}; + +/** One-shot migration seam kept injectable so ordering is regression-tested: + * scrub the URL synchronously, read (never re-write) the durable master, then + * perform the only request that may carry it. The durable copy is deleted only + * after that exchange SUCCEEDS: deleting it first stranded remote-backend + * users whose backend was unreachable at first launch after upgrade — the + * failed exchange destroyed their only copy of the admin key. On failure the + * key stays put so the next launch retries this migration. */ +export async function _bootstrapBrowserCredentials( + win: BootstrapWindow, + { + apiBase = API, + sessionStore, + localStore, + exchange = exchangeApiKey, + }: { + apiBase?: string; + sessionStore?: Pick | null; + localStore?: Pick | null; + exchange?: typeof exchangeApiKey; + } = {}, +): Promise { + if (sessionStore === undefined) { + try { + sessionStore = sessionStorage; + } catch { + sessionStore = null; + } + } + if (localStore === undefined) { + try { + localStore = localStorage; + } catch { + localStore = null; + } + } + const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(win.location.href); + if (scrubbed) { + try { + win.history.replaceState(null, '', cleanUrl); + } catch { + /* keep deleting retained credentials even if history is unavailable */ + } + } + + let master = apiKey; + try { + const legacy = localStore?.getItem(LS_API_KEY) ?? null; + if (!master) master = legacy; + } catch { + /* a fragment exchange can still proceed */ + } + if (pin) { + try { + sessionStore?.setItem('ov_pin', pin); + } catch { + /* blocked PIN storage must not prevent the exchange */ + } + } + if (master) { + // A rejected/unreachable exchange throws past this point, leaving the + // durable key in place for the next launch's retry (the module-load catch + // below still raises the auth gate). Only a session that actually exists + // may consume the stored master. + await exchange(master, { apiBase }); + try { + localStore?.removeItem(LS_API_KEY); + } catch { + /* storage unavailable; exchangeApiKey performed the same best-effort deletion */ + } + } +} + +// On load, capture deep-link credentials, scrub the address bar synchronously, +// and exchange a master key exactly once. Historical durable master storage is +// read before the first await and deleted only once the exchange succeeds, so +// an unreachable backend leaves it for the next launch to retry. apiFetch +// waits for this one-shot migration so no request races ahead with an +// unauthenticated first call. +let authBootstrapPromise: Promise = Promise.resolve(); if (typeof window !== 'undefined') { try { - const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(window.location.href); - if (pin) sessionStorage.setItem('ov_pin', pin); - if (apiKey) saveApiKey(apiKey); - if (scrubbed) window.history.replaceState(null, '', cleanUrl); + authBootstrapPromise = _bootstrapBrowserCredentials(window).catch(() => { + window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } })); + }); } catch { /* noop */ } @@ -182,6 +257,21 @@ export function apiUrl(path?: string): string { return path.startsWith('http') ? path : `${API}${path.startsWith('/') ? '' : '/'}${path}`; } +/** Whether an already-resolved request URL stays inside the configured API + * origin and path prefix. Absolute URLs remain supported for public media, but + * they must never inherit backend credentials by accident. */ +export function _isApiTarget(target: string, apiBase: string = API): boolean { + try { + const base = new URL(apiBase.replace(/\/+$/, '') + '/'); + const url = new URL(target); + if (url.origin !== base.origin) return false; + const prefix = base.pathname.replace(/\/+$/, ''); + return !prefix || url.pathname === prefix || url.pathname.startsWith(`${prefix}/`); + } catch { + return false; + } +} + // Stamped on EVERY response by the backend's BackendMarkerMiddleware and // exposed cross-origin, so its presence is AUTHORITATIVE: this really is an // VoiceStudio backend answering, whatever the body looks like (#1385). @@ -252,18 +342,25 @@ const RECONCILE_INTERVAL_MS = 1000; export type ApiFetchOptions = RequestInit & { retryTransport?: boolean }; export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promise { - const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null; - const key = _apiKey(); - // Only modify the request when a PIN/API key is set, so the default call - // shape (e.g. FormData posts with no headers / no Content-Type override) - // is preserved exactly. - const extra: Record = {}; - if (pin) extra['X-OmniVoice-Pin'] = pin; - if (key) extra['Authorization'] = `Bearer ${key}`; + await authBootstrapPromise; + const requestUrl = apiUrl(path); + const backendTarget = _isApiTarget(requestUrl); + const pin = backendTarget ? sessionPin() : null; + const session = backendTarget ? getAdminSession(API) : null; const { retryTransport = true, ...requestOpts } = opts; - const finalOpts: RequestInit = Object.keys(extra).length - ? { ...requestOpts, headers: { ...(requestOpts.headers as Record), ...extra } } - : requestOpts; + const headers = new Headers(requestOpts.headers); + if (pin) headers.set('X-OmniVoice-Pin', pin); + if (session) headers.set('Authorization', `Bearer ${session.token}`); + // Same-origin browser clients authenticate through an HttpOnly cookie. The + // marker makes ambient-cookie mutations fail closed under the backend's + // exact-Origin CSRF policy; browser-managed Sec-Fetch-Site supplies the + // additional guard for side-effectful GET routes. + if (backendTarget && isSameOriginApi(API)) headers.set(CSRF_HEADER_NAME, '1'); + const finalOpts: RequestInit = { + ...requestOpts, + headers, + ...(backendTarget ? { credentials: 'include' as RequestCredentials } : {}), + }; const signal = finalOpts.signal as AbortSignal | null | undefined; let lastDetail = ''; // The shell's last word on the backend. When it still says `ready` after we've @@ -279,12 +376,12 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); let res: Response; try { - res = await fetch(apiUrl(path), finalOpts); + res = await fetch(requestUrl, finalOpts); // Any response — success or HTTP error alike — proves the backend // process is alive and answering. Recording it lets a LATER transport // failure say "it was answering Xs ago and stopped" instead of the // one-size "can't reach" (#1164). - recordBackendContact(); + if (backendTarget) recordBackendContact(); } catch (e) { // A thrown fetch (TypeError "Failed to fetch" / "NetworkError") means the // request never reached the backend — it's still starting up, crashed, or @@ -430,8 +527,8 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis // or a reverse proxy with no route for this path. Echoing that page // ("NOT_FOUND bom1::…") sends the user chasing a page that never // existed; name the actual problem instead: where requests are going. - if (res.status === 404 && !backendShaped) { - throw new ApiError(misroutedBackendMessage(apiUrl(path)), { + if (backendTarget && res.status === 404 && !backendShaped) { + throw new ApiError(misroutedBackendMessage(requestUrl), { status: res.status, detail, }); @@ -440,13 +537,14 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis // "API key required" (BearerKeyMiddleware, OMNIVOICE_API_KEY) vs anything // else, i.e. "PIN required" (NetworkAccessMiddleware). Both are 401; the // detail is the only discriminator (only two 401 sites exist backend-side). - if (res.status === 401 && typeof window !== 'undefined') { + if (backendTarget && res.status === 401 && typeof window !== 'undefined') { // readError's declared `string` return isn't guaranteed at runtime — // `j.detail` can be a structured object/array on a future 401. Match only // real strings (avoids both a `.toLowerCase()` crash and `String()` itself // throwing on a malformed object); anything else falls back to PIN. const mode = typeof detail === 'string' && detail.toLowerCase().includes('api key') ? 'apikey' : 'pin'; + if (mode === 'apikey') clearAdminSession(); window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode } })); } // Structured details (e.g. the typed asr_model_missing 409) carry a diff --git a/frontend/src/api/community.ts b/frontend/src/api/community.ts index b2dfac21..b29a710e 100644 --- a/frontend/src/api/community.ts +++ b/frontend/src/api/community.ts @@ -19,7 +19,9 @@ interface CommunityItem { author?: string; license?: string; source?: string; + _source_repo?: string; is_community?: boolean; + attrs?: Record; } export interface CommunityPage { @@ -59,3 +61,7 @@ export const addCommunityItem = ( const q = name ? `?name=${encodeURIComponent(name)}` : ''; return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' }); }; + +/** Same-origin preview path for both designed presets and recorded voices. */ +export const communityPreviewUrl = (id: string, local = false): string => + `/community/items/${encodeURIComponent(id)}/preview${local ? '?local=true' : ''}`; diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index a13e45a7..18187307 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -14,6 +14,8 @@ import * as archetypesApi from './archetypes'; import type { ArchetypeFilters } from './archetypes'; import * as communityApi from './community'; import type { CommunityFilters } from './community'; +import * as enginesApi from './engines'; +import type { AllEnginesResponse, EngineFamily } from './types'; // ── Keys (prevents typos, enables targeted invalidation) ───────────────── export const queryKeys = { @@ -26,6 +28,7 @@ export const queryKeys = { models: ['models'] as const, recommendations: ['recommendations'] as const, preflight: ['preflight'] as const, + engines: ['engines'] as const, setupStatus: ['setup-status'] as const, galleryVoices: (params?: any) => ['gallery-voices', params] as const, galleryCategories: ['gallery-categories'] as const, @@ -171,6 +174,39 @@ export function usePreflight() { }); } +/** + * The app-wide engine inventory. Engine selection affects more than the + * catalogue (for example Audiobook's expressive controls), so every consumer + * must share this cache rather than take its own one-off snapshot. + * + * `queryFn` is injectable for the compatibility-matrix test seam. + */ +export function useEngines(queryFn: () => Promise = enginesApi.listEngines) { + return useQuery({ + queryKey: queryKeys.engines, + queryFn, + staleTime: 30_000, + retry: 1, + }); +} + +/** Select an engine and invalidate every view derived from `/engines`. */ +export function useSelectEngine() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + family, + backendId, + modelId, + }: { + family: EngineFamily; + backendId: string; + modelId?: string; + }) => enginesApi.selectEngine(family, backendId, modelId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.engines }), + }); +} + export function useSetupStatus() { return useQuery({ queryKey: queryKeys.setupStatus, @@ -230,6 +266,9 @@ export function useInstallModel() { qc.invalidateQueries({ queryKey: queryKeys.models }); qc.invalidateQueries({ queryKey: queryKeys.setupStatus }); qc.invalidateQueries({ queryKey: queryKeys.recommendations }); + // Some model installs make an engine selectable; refresh every engine + // indicator rather than leaving a stale unavailable snapshot behind. + qc.invalidateQueries({ queryKey: queryKeys.engines }); }, }); } @@ -242,6 +281,7 @@ export function useDeleteModel() { qc.invalidateQueries({ queryKey: queryKeys.models }); qc.invalidateQueries({ queryKey: queryKeys.setupStatus }); qc.invalidateQueries({ queryKey: queryKeys.recommendations }); + qc.invalidateQueries({ queryKey: queryKeys.engines }); }, }); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6cab4d7e..5e9697b4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -24,7 +24,7 @@ type EffectiveDevice = GPUTarget | 'network'; // `n/a` is LLM-only; resolve_routing only ever returns the first four. type RoutingStatus = 'accelerated' | 'cpu_fallback' | 'cpu_only' | 'unavailable' | 'n/a'; -interface EngineBackend { +export interface EngineBackend { id: string; display_name: string; available: boolean; @@ -76,8 +76,10 @@ export interface CuratedModel { repo_id: string; } -interface EngineFamilyResponse { +export interface EngineFamilyResponse { active: string; + /** A process environment pin wins over a saved UI selection. */ + env_override?: boolean; backends: EngineBackend[]; } @@ -175,12 +177,14 @@ export interface Profile { id: string; name: string; kind: ProfileKind; + language?: string; language_code?: string; ref_audio?: string; ref_text?: string; instruct?: string; /** Deterministic identity seed for a designed profile. */ seed?: number | null; + vd_states?: string | null; description?: string; created_at?: string; is_locked?: boolean; diff --git a/frontend/src/assets/signal-field.webp b/frontend/src/assets/signal-field.webp new file mode 100644 index 00000000..42c862c1 Binary files /dev/null and b/frontend/src/assets/signal-field.webp differ diff --git a/frontend/src/components/CaptureWidget.jsx b/frontend/src/components/CaptureWidget.jsx index 8b9ce9e0..fb64af24 100644 --- a/frontend/src/components/CaptureWidget.jsx +++ b/frontend/src/components/CaptureWidget.jsx @@ -5,7 +5,8 @@ import { toast } from 'react-hot-toast'; import { useAppStore } from '../store'; import { useTranslation } from 'react-i18next'; -import { wsUrl as buildWsUrl, apiFetch } from '../api/client'; +import { API, apiFetch } from '../api/client'; +import { authenticatedWsUrl } from '../api/authSession'; import { addTranscription } from '../pages/Transcriptions'; import { describeMicError, detectPlatform, micErrorMessage, micHintKey } from '../utils/micError'; import { checkMicrophone, openMicrophoneSettings } from '../utils/permissions'; @@ -898,8 +899,10 @@ export default function CaptureWidget({ onDismiss }) { // Open WebSocket BEFORE starting capture. try { - // Scheme + host + remote api key all derive from the API base - // (Wave 2.3) — window.location lies inside the Tauri webview. + // Scheme + host derive from the API base (window.location lies inside + // the Tauri webview). A remote bearer session is converted to a fresh, + // path-bound WebSocket ticket; neither the master nor session token is + // ever placed in this URL. // • sherpa → ?model=&sr=16000 (raw int16 PCM, live partials) // • AEC → ?aec=1&sr=16000 (tagged raw PCM, NLMS canceller) // • both → ?model=&aec=1&sr=16000 @@ -911,7 +914,8 @@ export default function CaptureWidget({ onDismiss }) { if (pcmFallback) params.push('pcm=1'); if (pcmMode) params.push('sr=16000'); const wsPath = params.length ? `/ws/transcribe?${params.join('&')}` : '/ws/transcribe'; - const ws = new WebSocket(buildWsUrl(wsPath)); + const endpoint = await authenticatedWsUrl(wsPath, { apiBase: API }); + const ws = new WebSocket(endpoint); ws.binaryType = 'arraybuffer'; const failRawPcmSession = () => { if ( @@ -1113,7 +1117,7 @@ export default function CaptureWidget({ onDismiss }) { } }; wsRef.current = ws; - } catch (err) { + } catch { wsRef.current = null; if (pcmMode) { // Raw-PCM has no POST fallback — a socket that can't even be @@ -1121,13 +1125,13 @@ export default function CaptureWidget({ onDismiss }) { // recording into the void. stream.getTracks().forEach((tr) => tr.stop()); streamRef.current = null; - setErrorInfo({ kind: 'server', message: String(err?.message || err) }); + setErrorInfo({ kind: 'server', message: '' }); setState('error'); return; } // Legacy path continues below: the recorder still buffers chunks and // the POST /transcribe fallback delivers the result on stop. - console.warn('ws open failed — will fall back to POST /transcribe:', err); + console.warn('ws open failed — will fall back to POST /transcribe'); } if (pcmMode) { diff --git a/frontend/src/components/CaptureWidget.test.jsx b/frontend/src/components/CaptureWidget.test.jsx index 8c0dc8cd..766021d3 100644 --- a/frontend/src/components/CaptureWidget.test.jsx +++ b/frontend/src/components/CaptureWidget.test.jsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => { return { state, holder, + authenticatedWsUrl: vi.fn(async (path) => `ws://test${path}&ws_ticket=one-use`), invoke: async (cmd, args) => { holder.calls.push([cmd, args]); if (cmd === 'check_accessibility') return holder.a11y; @@ -47,9 +48,11 @@ vi.mock('../store', () => ({ useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }), })); vi.mock('../api/client', () => ({ + API: 'http://test', wsUrl: (p) => `ws://test${p}`, apiFetch: vi.fn(async () => ({ json: async () => ({}) })), })); +vi.mock('../api/authSession', () => ({ authenticatedWsUrl: mocks.authenticatedWsUrl })); vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() })); vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) })); vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } })); @@ -132,6 +135,10 @@ describe('CaptureWidget', () => { mocks.holder.paste = async () => undefined; mocks.holder.calls = []; mocks.holder.onFrame = null; + mocks.authenticatedWsUrl.mockClear(); + mocks.authenticatedWsUrl.mockImplementation( + async (path) => `ws://test${path}${path.includes('?') ? '&' : '?'}ws_ticket=one-use`, + ); mocks.state.dictationMode = 'toggle'; mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3'; FakeWebSocket.instances = []; @@ -186,6 +193,10 @@ describe('CaptureWidget', () => { const ws = await startSession(); expect(ws.url).toContain('/ws/transcribe?pcm=1&sr=16000'); + expect(mocks.authenticatedWsUrl).toHaveBeenCalledWith('/ws/transcribe?pcm=1&sr=16000', { + apiBase: 'http://test', + }); + expect(ws.url).toContain('ws_ticket=one-use'); expect(mocks.holder.onFrame).toBeTypeOf('function'); act(() => mocks.holder.onFrame(new Float32Array([0.25, -0.25]))); diff --git a/frontend/src/components/EngineCompatibilityMatrix.jsx b/frontend/src/components/EngineCompatibilityMatrix.jsx index 9397602a..b8005f64 100644 --- a/frontend/src/components/EngineCompatibilityMatrix.jsx +++ b/frontend/src/components/EngineCompatibilityMatrix.jsx @@ -23,6 +23,7 @@ import { getSidecarInstallStatus, } from '../api/engines'; import { listLoadedModels, unloadLoadedModel } from '../api/system'; +import { useAppStore } from '../store'; import { copyText } from '../utils/copyText'; import { ChevronRight } from 'lucide-react'; import { Badge, Button, Select, Table, Tabs } from '../ui'; @@ -226,6 +227,10 @@ export default function EngineCompatibilityMatrix({ showFamilyTabs = true, onFamilyChange = null, reloadToken = 0, + // The catalogue passes its app-wide query here. Keeping the standalone + // fallback preserves the matrix's injectable API seam for isolated hosts + // and its extensive focused test suite. + sharedEngines = null, // Injectable API layer — lets the RTL suite mock it without module-level // vi.mock incantations, and keeps the "one GET /engines per Settings open" // contract overridable by hosts. @@ -242,9 +247,15 @@ export default function EngineCompatibilityMatrix({ apiInstallStatus = getSidecarInstallStatus, }) { const { t } = useTranslation(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [localData, setLocalData] = useState(null); + const [localLoading, setLocalLoading] = useState(true); + const [localError, setLocalError] = useState(null); + const sharedRefetch = sharedEngines?.refetch; + const isShared = Boolean(sharedEngines); + const sharedReloadToken = useRef(reloadToken); + const data = sharedEngines?.data ?? localData; + const loading = isShared ? sharedEngines.isLoading : localLoading; + const error = sharedEngines?.error ?? localError; const [activeFamily, setActiveFamily] = useState(family); // Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog // currently open, or null. Only one dialog is ever open at a time. @@ -288,26 +299,42 @@ export default function EngineCompatibilityMatrix({ }, [apiListLoadedModels]); const reload = useCallback(async () => { - setLoading(true); - setError(null); - try { - const fresh = await apiListEngines(); - setData(fresh); - } catch (e) { - const msg = e?.message || String(e); - setError(msg); - toastErrorWithReport(t('engines.loadFailed', { message: msg }), e); - } finally { - setLoading(false); + if (sharedRefetch) { + const result = await sharedRefetch(); + if (result.error) { + const message = result.error?.message || String(result.error); + toastErrorWithReport(t('engines.loadFailed', { message }), result.error); + } + } else { + setLocalLoading(true); + setLocalError(null); + try { + setLocalData(await apiListEngines()); + } catch (requestError) { + const message = requestError?.message || String(requestError); + setLocalError(requestError); + toastErrorWithReport(t('engines.loadFailed', { message }), requestError); + } finally { + setLocalLoading(false); + } } refreshResidency(); - }, [apiListEngines, refreshResidency, t]); + }, [apiListEngines, refreshResidency, sharedRefetch, t]); useEffect(() => { - reload(); + if (isShared) { + if (sharedReloadToken.current !== reloadToken) { + sharedReloadToken.current = reloadToken; + void reload(); + return; + } + refreshResidency(); + return; + } + void reload(); // reloadToken: an external bump (e.g. the ASR config panel just saved a // server URL) refetches so availability + "Use" reflect the new config. - }, [reload, reloadToken]); + }, [reload, reloadToken, refreshResidency, isShared]); // Unload a resident engine's model/sidecar by its /model/loaded id. Safe by // contract: the model reloads lazily on the next generation. @@ -598,7 +625,8 @@ export default function EngineCompatibilityMatrix({ className="engine-matrix engine-matrix--error flex flex-col gap-[8px] items-center p-[16px]" role="alert" > - {t('engines.couldNotLoad', { message: error })} + {' '} + {t('engines.couldNotLoad', { message: error.message || String(error) })} @@ -726,7 +754,7 @@ export default function EngineCompatibilityMatrix({ data-testid="engine-list-scroll" aria-label={t('engines.engineCompatLabel', { family: activeFamily })} > - {backends.map((b) => { + {backends.map((b, index) => { const isActive = b.id === activeBackendId; const health = healthByEngine[b.id]; const selfTest = selfTestByEngine[b.id]; @@ -781,6 +809,20 @@ export default function EngineCompatibilityMatrix({ ) : null; return ( + {(index === 0 || (backends[index - 1]?.available && !b.available)) && ( +
+ {/* Section framing, not status: "ready to use" vs "add + more" frames the grey majority as headroom to unlock + rather than a mostly-broken app (13 of 16 rows read as + failures under a plain "Not installed" caption). */} + {b.available ? t('engines.sectionReady') : t('engines.sectionMore')} +
+ )}
)} + {/* The openai-compat family entry and the LLM Providers + panel are one system (the backend resolves through the + active provider); this is the door between the two, so + picking the family and configuring the endpoint stop + being separate discoveries. */} + {activeFamily === 'llm' && b.id === 'openai-compat' && ( + + )} {/* TTS-05: license-acceptance entry point. Surfaced when the backend says the user hasn't accepted the engine's license yet AND we have a dialog diff --git a/frontend/src/components/EngineQuickSwitch.jsx b/frontend/src/components/EngineQuickSwitch.jsx new file mode 100644 index 00000000..21af470f --- /dev/null +++ b/frontend/src/components/EngineQuickSwitch.jsx @@ -0,0 +1,180 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Check, ChevronRight, Cpu } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { listLoadedModels } from '../api/system'; +import { useEngines, useSelectEngine } from '../api/hooks'; +import { notifyEngineSelected } from '../utils/engineSelectToast'; +import { useAppStore } from '../store'; +import { MENU_SURFACE } from './computeTarget'; + +/** + * A compact TTS/ASR/LLM picker for chrome that needs to expose the active + * engine without growing a second engine-management surface. The matrix stays + * the detailed view; this lists only engines that are ready to be used. + */ +export default function EngineQuickSwitch({ + family = 'tts', + className = '', + shortcutTarget = false, + // The footer chip opens upward (nothing below the last row on screen); + // workspace-header chips must open downward or the popover clips off the + // top of the viewport. + dropUp = false, +}) { + const { t } = useTranslation(); + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + const [switchError, setSwitchError] = useState(''); + const { data: engines } = useEngines(); + const selectMutation = useSelectEngine(); + const { data: residency } = useQuery({ + queryKey: ['loaded-models'], + queryFn: listLoadedModels, + staleTime: 10_000, + retry: false, + enabled: open, + }); + + const familyData = engines?.[family]; + const active = familyData?.backends?.find((engine) => engine.id === familyData.active); + const available = useMemo( + () => (familyData?.backends || []).filter((engine) => engine.available), + [familyData], + ); + const residentIds = useMemo( + () => + new Set( + (residency?.models || []).flatMap((model) => (model.engine_id ? [model.engine_id] : [])), + ), + [residency], + ); + + useEffect(() => { + if (!open) return undefined; + const close = (event) => { + if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false); + }; + const escape = (event) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', close); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', close); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + // In-webview shortcut bridge. This stays a DOM event (not a Tauri global + // shortcut), so every desktop and browser build behaves the same way. + useEffect(() => { + if (!shortcutTarget) return undefined; + const show = () => { + setSwitchError(''); + setOpen(true); + }; + window.addEventListener('engine-quick-switch', show); + return () => window.removeEventListener('engine-quick-switch', show); + }, [shortcutTarget]); + + if (!active || available.length === 0) return null; + + const locked = Boolean(familyData.env_override); + const choose = async (backendId) => { + if (backendId === familyData.active || locked) return; + setSwitchError(''); + try { + const result = await selectMutation.mutateAsync({ family, backendId }); + if (result.env_override) { + setSwitchError(t('settings.llmp_env_override')); + return; + } + notifyEngineSelected(result, t, family); + setOpen(false); + } catch (error) { + setSwitchError(error?.message || t('engines.switch_failed')); + } + }; + + return ( +
+ + + {open && ( +
+ {locked && ( +

+ {t('settings.llmp_env_override')} +

+ )} + {available.map((engine) => { + const isActive = engine.id === familyData.active; + const warm = residentIds.has(engine.id); + return ( + + ); + })} + {switchError && ( +

+ {switchError} +

+ )} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/EngineQuickSwitch.test.jsx b/frontend/src/components/EngineQuickSwitch.test.jsx new file mode 100644 index 00000000..8083c842 --- /dev/null +++ b/frontend/src/components/EngineQuickSwitch.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn() } })); + +const { listEngines, selectEngine, listLoadedModels } = vi.hoisted(() => ({ + listEngines: vi.fn(), + selectEngine: vi.fn(), + listLoadedModels: vi.fn(), +})); +vi.mock('../api/engines', () => ({ listEngines, selectEngine })); +vi.mock('../api/system', () => ({ listLoadedModels })); + +import EngineQuickSwitch from './EngineQuickSwitch'; + +const inventory = (env_override = false) => ({ + tts: { + active: 'omnivoice', + env_override, + backends: [ + { id: 'omnivoice', display_name: 'OmniVoice', available: true }, + { id: 'indextts2', display_name: 'IndexTTS 2', available: true }, + { id: 'offline', display_name: 'Offline', available: false }, + ], + }, + asr: { active: 'whisper', backends: [] }, + llm: { active: 'off', backends: [] }, +}); + +function renderPicker(props = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('EngineQuickSwitch', () => { + beforeEach(() => { + vi.clearAllMocks(); + listEngines.mockResolvedValue(inventory()); + listLoadedModels.mockResolvedValue({ models: [{ engine_id: 'omnivoice' }] }); + }); + + it('lists only available engines and annotates residency', async () => { + renderPicker(); + fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i })); + + expect(screen.getByText('IndexTTS 2')).toBeInTheDocument(); + expect(screen.queryByText('Offline')).not.toBeInTheDocument(); + expect(await screen.findByText('In memory')).toBeInTheDocument(); + }); + + it('selects through the shared mutation', async () => { + selectEngine.mockResolvedValue({ family: 'tts', active: 'indextts2', env_override: false }); + renderPicker(); + fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i })); + fireEvent.click(screen.getByText('IndexTTS 2')); + + await waitFor(() => expect(selectEngine).toHaveBeenCalledWith('tts', 'indextts2', undefined)); + }); + + it('locks a family owned by an environment variable', async () => { + listEngines.mockResolvedValue(inventory(true)); + renderPicker(); + fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i })); + + expect(screen.getByText(/set via an environment variable/i)).toBeInTheDocument(); + expect(screen.getByText('IndexTTS 2').closest('button')).toBeDisabled(); + }); + + it('opens only the designated picker from the global shortcut bridge', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + , + ); + await screen.findAllByRole('button', { name: /active tts: omnivoice/i }); + + fireEvent(window, new Event('engine-quick-switch')); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/KeyboardCheatsheet.jsx b/frontend/src/components/KeyboardCheatsheet.jsx index d1af3ce0..998d02bb 100644 --- a/frontend/src/components/KeyboardCheatsheet.jsx +++ b/frontend/src/components/KeyboardCheatsheet.jsx @@ -19,6 +19,8 @@ export default function KeyboardCheatsheet({ open, onClose }) { title: t('keyboard.nav'), items: [ ['?', t('keyboard.nav_cheatsheet')], + [t('keyboard.nav_enginePickerKey'), t('engines.matrixTitle')], + [t('keyboard.nav_workspacesKey'), t('keyboard.nav')], ['Esc', t('keyboard.nav_closeModal')], ['Cmd/Ctrl+S', t('keyboard.nav_save')], ], diff --git a/frontend/src/components/LogsFooter.jsx b/frontend/src/components/LogsFooter.jsx index d6d89610..abff272a 100644 --- a/frontend/src/components/LogsFooter.jsx +++ b/frontend/src/components/LogsFooter.jsx @@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next'; import { useAppStore } from '../store'; import NetworkToggle from './NetworkToggle'; import ComputeQuickSettings from './ComputeQuickSettings'; +import EngineQuickSwitch from './EngineQuickSwitch'; import { APP_VERSION, whatsNewPending } from '../utils/appVersion'; import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover'; import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments'; @@ -639,6 +640,7 @@ export default function LogsFooter() { )} + + {error && ( +

+ {error.status + ? t('settings.remote_backend_error_http', { status: error.status }) + : t('settings.remote_backend_error_network')} +

+ )} +
); diff --git a/frontend/src/components/RemoteAuthGate.test.jsx b/frontend/src/components/RemoteAuthGate.test.jsx index 15b1071b..a5c756d4 100644 --- a/frontend/src/components/RemoteAuthGate.test.jsx +++ b/frontend/src/components/RemoteAuthGate.test.jsx @@ -1,9 +1,18 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { exchangeApiKey } = vi.hoisted(() => ({ exchangeApiKey: vi.fn() })); +vi.mock('../api/authSession', async (importOriginal) => ({ + ...(await importOriginal()), + exchangeApiKey, +})); + import RemoteAuthGate from './RemoteAuthGate'; describe('RemoteAuthGate', () => { beforeEach(() => { + vi.clearAllMocks(); + exchangeApiKey.mockResolvedValue({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 }); sessionStorage.clear(); localStorage.clear(); }); @@ -32,7 +41,7 @@ describe('RemoteAuthGate', () => { expect(sessionStorage.getItem('ov_pin')).toBe('999111'); }); - it('stores the entered API key (apikey mode)', () => { + it('exchanges the entered API key without persisting the master (apikey mode)', async () => { render(
app-content
@@ -40,6 +49,51 @@ describe('RemoteAuthGate', () => { ); fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'secret123' } }); fireEvent.click(screen.getByRole('button', { name: /connect/i })); - expect(localStorage.getItem('ov_api_key')).toBe('secret123'); + + await waitFor(() => expect(exchangeApiKey).toHaveBeenCalledOnce()); + expect(exchangeApiKey).toHaveBeenCalledWith( + 'secret123', + expect.objectContaining({ apiBase: expect.any(String) }), + ); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + expect(sessionStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('does not retain or reflect the master when exchange fails', async () => { + exchangeApiKey.mockRejectedValueOnce(Object.assign(new Error('generic'), { status: 401 })); + render( + +
app-content
+
, + ); + const input = screen.getByLabelText(/api key/i); + fireEvent.change(input, { target: { value: 'do-not-reflect' } }); + fireEvent.click(screen.getByRole('button', { name: /connect/i })); + + await screen.findByRole('alert'); + expect(input).toHaveValue(''); + expect(screen.queryByText(/do-not-reflect/)).not.toBeInTheDocument(); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('coalesces repeated submissions while an exchange is pending', async () => { + let resolveExchange; + exchangeApiKey.mockImplementationOnce( + () => new Promise((resolve) => (resolveExchange = resolve)), + ); + render( + +
app-content
+
, + ); + fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'secret123' } }); + const button = screen.getByRole('button', { name: /connect/i }); + fireEvent.click(button); + fireEvent.click(button); + + expect(exchangeApiKey).toHaveBeenCalledOnce(); + expect(button).toBeDisabled(); + resolveExchange({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 }); + await waitFor(() => expect(button).not.toBeDisabled()); }); }); diff --git a/frontend/src/components/RemoteBackendRecovery.jsx b/frontend/src/components/RemoteBackendRecovery.jsx index 7001af4b..1b80b852 100644 --- a/frontend/src/components/RemoteBackendRecovery.jsx +++ b/frontend/src/components/RemoteBackendRecovery.jsx @@ -31,7 +31,7 @@ export default function RemoteBackendRecovery({ - - + {onDesign ? ( + + ) : null} + {onUseInStories || onUseAsAudiobookDefault ? ( + onUseInStories(a), + } + : null, + onUseAsAudiobookDefault + ? { + id: 'audiobook', + icon: Headphones, + label: t('gallery.set_audiobook_default', { + defaultValue: 'Set as Audiobook default', + }), + onSelect: () => onUseAsAudiobookDefault(a), + } + : null, + ].filter(Boolean)} + > + + + ) : null} ); diff --git a/frontend/src/components/gallery/ArchetypesZone.jsx b/frontend/src/components/gallery/ArchetypesZone.jsx index 6408f75e..648f434d 100644 --- a/frontend/src/components/gallery/ArchetypesZone.jsx +++ b/frontend/src/components/gallery/ArchetypesZone.jsx @@ -59,6 +59,9 @@ export default function ArchetypesZone({ onPreview, onUse, onDesign, + onUseInStories, + onUseAsAudiobookDefault, + materializingId, }) { const [favOnly, setFavOnly] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false); @@ -111,10 +114,15 @@ export default function ArchetypesZone({ isFavorite: favSet.has(a.id), isPlaying: playingId === a.id, isLoadingPreview: loadingPreviewId === a.id, + previewLocked: Boolean(loadingPreviewId), onPreview, onUse, onDesign, + onUseInStories, + onUseAsAudiobookDefault, onToggleFavorite: toggleFavorite, + isMaterializing: materializingId === a.id, + materializationLocked: Boolean(materializingId), }); const facetToggle = @@ -125,7 +133,9 @@ export default function ArchetypesZone({ : 'flex flex-col gap-[6px]'; return ( -
+ // data-testid: stable e2e hook — locale-independent, unlike the translated + // aria-labels/headings inside (see e2e/gallery.spec.ts). +