diff --git a/backend/core/spa_inject.py b/backend/core/spa_inject.py
new file mode 100644
index 00000000..c7ef3855
--- /dev/null
+++ b/backend/core/spa_inject.py
@@ -0,0 +1,35 @@
+"""Runtime API-base injection for the served SPA (Docker / reverse-proxy).
+
+`VITE_*` vars are inlined at build time, so a prebuilt image cannot take an
+API-base override from `docker run -e`. When `OMNIVOICE_PUBLIC_API_BASE` is set,
+the backend injects it into `index.html` as `window.__OMNIVOICE_API_BASE__`,
+which the SPA's API resolver reads first. These helpers are pure so they can be
+unit-tested without booting the app.
+"""
+from __future__ import annotations
+
+import json
+import re
+
+# Operator-controlled value, but validate to a plain http(s) URL with no
+# whitespace, quotes, or angle brackets so it can never break out of the
+# injected "
+ if "
" in html_doc:
+ return html_doc.replace("", "" + snippet, 1)
+ return snippet + html_doc
diff --git a/backend/main.py b/backend/main.py
index 7b225610..6845be79 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -635,6 +635,37 @@ app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
if os.path.exists(frontend_path):
+ # ── Runtime API-base override (Docker / reverse-proxy deployments) ──────
+ # When OMNIVOICE_PUBLIC_API_BASE is set we inject it into index.html as
+ # `window.__OMNIVOICE_API_BASE__`, which the SPA's API resolver reads first.
+ # Unset (the default) → StaticFiles serves index.html untouched: same-origin,
+ # zero overhead, no behavior change. See core/spa_inject.py.
+ from core.spa_inject import is_valid_public_api_base, inject_api_base
+
+ _public_api_base = os.environ.get("OMNIVOICE_PUBLIC_API_BASE", "").strip().rstrip("/")
+ _index_path = os.path.join(frontend_path, "index.html")
+ if _public_api_base and not is_valid_public_api_base(_public_api_base):
+ logging.getLogger("omnivoice.api").warning(
+ "OMNIVOICE_PUBLIC_API_BASE=%r is not a valid http(s) URL; ignoring.",
+ _public_api_base,
+ )
+ _public_api_base = ""
+
+ if _public_api_base and os.path.isfile(_index_path):
+ from fastapi.responses import HTMLResponse
+
+ def _index_with_api_base() -> "HTMLResponse":
+ with open(_index_path, "r", encoding="utf-8") as _fh:
+ return HTMLResponse(inject_api_base(_fh.read(), _public_api_base))
+
+ @app.get("/", include_in_schema=False)
+ def _index_root():
+ return _index_with_api_base()
+
+ @app.get("/index.html", include_in_schema=False)
+ def _index_html():
+ return _index_with_api_base()
+
app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend")
else:
diff --git a/docs/install/docker.md b/docs/install/docker.md
index aa4c6451..93180acb 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -63,18 +63,27 @@ services:
- "0.0.0.0:3900:3900" # ← was 127.0.0.1:3900:3900
```
-The OmniVoice frontend uses `window.location.host` for its API base when no
-explicit override is set, so opening the UI from `http://:3900` Just
-Works for both the page load *and* the media-preview requests it kicks off
-afterwards. If you front the app with a reverse proxy and the API and UI
-land on different origins, pin the API base explicitly:
+The OmniVoice frontend defaults to the **same origin** the page was served
+from, so opening the UI from `http://:3900` Just Works for both the
+page load *and* the API/media requests it makes afterwards.
+
+If you front the app with a **reverse proxy** and the API and UI land on
+different origins, pin the API base explicitly. Use **`OMNIVOICE_PUBLIC_API_BASE`**
+— a *runtime* env var the backend injects into the page, so it works with the
+prebuilt image via `docker run -e` (the older `VITE_OMNIVOICE_API` is inlined at
+*build* time and cannot be set on a prebuilt image):
```bash
-docker run -e VITE_OMNIVOICE_API=https://api.your-host.example \
+docker run -e OMNIVOICE_PUBLIC_API_BASE=https://api.your-host.example \
-p 0.0.0.0:3900:3900 \
ghcr.io/debpalash/omnivoice-studio:latest
```
+> `OMNIVOICE_PUBLIC_API_BASE` must be a plain `http(s)://…` URL; anything else
+> is ignored and the app falls back to same-origin. If you build from source you
+> may instead bake `VITE_OMNIVOICE_API` at build time, but the runtime var above
+> is simpler and image-agnostic.
+
> **Security:** OmniVoice ships no authentication. Anything on your LAN with
> the URL can use the app. Put it behind a reverse proxy with `basic_auth`
> (Caddy / nginx + htpasswd) or a private network overlay (Tailscale, ZeroTier)
diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md
index eeeaeb90..9d90ffc1 100644
--- a/docs/install/troubleshooting.md
+++ b/docs/install/troubleshooting.md
@@ -104,9 +104,11 @@ pane shows 404s for `/media/...`.
**Cause:** pre-v0.3, the frontend hardcoded `localhost:3900` for media-preview
URLs, which is wrong when the UI is reached from a different LAN host.
-**Fix:** Plan 01-03 ships a fix that derives the media-preview base from
-`window.location.host`. See [docker.md#lan-access](docker.md#lan-access) for the
-override env var (`VITE_OMNIVOICE_API`) when running behind a reverse proxy.
+**Fix:** the frontend derives its API/media base from the page's own origin.
+When running behind a reverse proxy where the UI and API are on different
+origins, set the runtime override `OMNIVOICE_PUBLIC_API_BASE` (works on the
+prebuilt image via `docker run -e`) — see
+[docker.md#lan-access](docker.md#lan-access).
## 9. Apple Silicon `mlx-whisper` unavailable on Intel mac
diff --git a/frontend/src/api/client.apibase.test.ts b/frontend/src/api/client.apibase.test.ts
index 80e3fb31..389d057f 100644
--- a/frontend/src/api/client.apibase.test.ts
+++ b/frontend/src/api/client.apibase.test.ts
@@ -32,4 +32,20 @@ describe('_resolveApiBase', () => {
const win = { __TAURI__: {}, location: { origin: 'x', hostname: 'x' } };
expect(_resolveApiBase({ VITE_API_PORT: '4000' }, win)).toBe('http://127.0.0.1:4000');
});
+
+ it('runtime window.__OMNIVOICE_API_BASE__ wins over everything (Docker prebuilt-image override)', () => {
+ const win = { __TAURI__: {}, __OMNIVOICE_API_BASE__: 'https://api.example.com/', location: { origin: 'http://x', hostname: 'x' } };
+ // Beats Tauri loopback AND VITE_API_URL; trailing slash stripped.
+ expect(_resolveApiBase({ VITE_API_URL: 'http://10.0.0.5:9' }, win)).toBe('https://api.example.com');
+ });
+
+ it('honors VITE_OMNIVOICE_API (the documented Docker var) and strips trailing slash', () => {
+ const win = { location: { origin: 'http://x', hostname: 'x' } };
+ expect(_resolveApiBase({ VITE_OMNIVOICE_API: 'http://10.0.0.5:9/' }, win)).toBe('http://10.0.0.5:9');
+ });
+
+ it('Tauri via __TAURI_INTERNALS__ → loopback', () => {
+ const win = { __TAURI_INTERNALS__: {}, location: { origin: 'tauri://localhost', hostname: 'localhost' } };
+ expect(_resolveApiBase({}, win)).toBe('http://127.0.0.1:3900');
+ });
});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 01db2936..8c1a8ade 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -13,9 +13,18 @@ const viteEnv = import.meta.env ?? {};
// re-import the module or stub import.meta.env.
export function _resolveApiBase(env: any, win: any): string {
const port = env?.VITE_API_PORT || '3900';
- if (env?.VITE_API_URL) return env.VITE_API_URL;
+ // Explicit override, in precedence order:
+ // 1. window.__OMNIVOICE_API_BASE__ — RUNTIME global the backend injects
+ // into index.html from OMNIVOICE_PUBLIC_API_BASE. The only override that
+ // works on a prebuilt Docker image (VITE_* is inlined at build time).
+ // 2. VITE_OMNIVOICE_API — the build-time var documented for Docker/proxy
+ // deploys and used by utils/apiBase.ts.
+ // 3. VITE_API_URL — legacy alias.
+ const runtime = win && typeof win.__OMNIVOICE_API_BASE__ === 'string' ? win.__OMNIVOICE_API_BASE__ : '';
+ const override = runtime || env?.VITE_OMNIVOICE_API || env?.VITE_API_URL;
+ if (override) return String(override).replace(/\/+$/, '');
if (!win) return `http://127.0.0.1:${port}`;
- if (win.__TAURI__) return `http://127.0.0.1:${port}`;
+ if (win.__TAURI__ || win.__TAURI_INTERNALS__) return `http://127.0.0.1:${port}`;
if (env?.DEV) return `http://${win.location.hostname}:${port}`;
return win.location.origin;
}
diff --git a/frontend/src/utils/apiBase.test.ts b/frontend/src/utils/apiBase.test.ts
index 7b040042..91c3ad35 100644
--- a/frontend/src/utils/apiBase.test.ts
+++ b/frontend/src/utils/apiBase.test.ts
@@ -24,6 +24,7 @@ describe("apiBase.getApiBase", () => {
});
afterEach(() => {
+ delete (window as Window).__OMNIVOICE_API_BASE__;
if (originalTauriInternals === undefined) {
delete (window as Window).__TAURI_INTERNALS__;
} else {
@@ -64,6 +65,16 @@ describe("apiBase.getApiBase", () => {
mod._setEnvOverrideForTesting(undefined);
});
+ it("runtime window.__OMNIVOICE_API_BASE__ wins over Tauri + env (Docker prebuilt-image override)", async () => {
+ (window as Window).__TAURI_INTERNALS__ = {};
+ (window as Window).__OMNIVOICE_API_BASE__ = "https://api.example.com/";
+ vi.resetModules();
+ const mod = await import("./apiBase");
+ mod._setEnvOverrideForTesting("http://10.0.0.5:3900");
+ expect(mod.getApiBase()).toBe("https://api.example.com"); // trailing slash stripped
+ mod._setEnvOverrideForTesting(undefined);
+ });
+
it("returns localhost:3900 in Tauri context", async () => {
(window as Window).__TAURI_INTERNALS__ = {};
const { getApiBase } = await import("./apiBase");
diff --git a/frontend/src/utils/apiBase.ts b/frontend/src/utils/apiBase.ts
index d8fb2ddb..aba7d013 100644
--- a/frontend/src/utils/apiBase.ts
+++ b/frontend/src/utils/apiBase.ts
@@ -27,6 +27,9 @@ declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
__TAURI__?: unknown;
+ /** Runtime API base injected into index.html by the backend from
+ * OMNIVOICE_PUBLIC_API_BASE (Docker / reverse-proxy deployments). */
+ __OMNIVOICE_API_BASE__?: string;
}
}
@@ -63,7 +66,16 @@ function _readEnvOverride(): string | undefined {
}
export function getApiBase(): string {
- // 1. Explicit override always wins.
+ // 0. Runtime override injected by the backend (Docker/proxy) wins over
+ // everything — it's the only knob a prebuilt image can turn at run time.
+ if (typeof window !== "undefined") {
+ const runtime = window.__OMNIVOICE_API_BASE__;
+ if (typeof runtime === "string" && runtime) {
+ return stripTrailingSlash(runtime);
+ }
+ }
+
+ // 1. Explicit build-time override.
const override = _readEnvOverride();
if (override) {
return stripTrailingSlash(override);
diff --git a/tests/test_spa_inject.py b/tests/test_spa_inject.py
new file mode 100644
index 00000000..37bf53e3
--- /dev/null
+++ b/tests/test_spa_inject.py
@@ -0,0 +1,43 @@
+"""Runtime API-base injection helpers (Docker / reverse-proxy deployments).
+
+A prebuilt image can't take a build-time VITE_* override, so the backend
+injects OMNIVOICE_PUBLIC_API_BASE into index.html as a window global. These
+test the pure helpers without booting the app.
+"""
+from __future__ import annotations
+
+from core.spa_inject import inject_api_base, is_valid_public_api_base
+
+
+def test_valid_public_api_base_accepts_http_urls():
+ assert is_valid_public_api_base("https://api.example.com")
+ assert is_valid_public_api_base("http://10.0.0.5:3900")
+ assert is_valid_public_api_base("https://voice.example.com/api")
+
+
+def test_valid_public_api_base_rejects_unsafe_or_empty():
+ assert not is_valid_public_api_base("")
+ assert not is_valid_public_api_base("not a url")
+ assert not is_valid_public_api_base("javascript:alert(1)")
+ # No script breakout possible — angle brackets / quotes are rejected.
+ assert not is_valid_public_api_base('https://x"")
+
+
+def test_inject_api_base_into_head():
+ doc = "x"
+ out = inject_api_base(doc, "https://api.example.com")
+ assert '' in out
+ assert out.count("") == 1 # injected once, original head preserved
+
+
+def test_inject_api_base_prepends_when_no_head():
+ out = inject_api_base("x", "http://10.0.0.5:3900")
+ assert out.startswith('')
+
+
+def test_inject_api_base_json_encodes_value():
+ # json.dumps wraps in double quotes; combined with is_valid_public_api_base
+ # the value can't contain a quote, so the snippet is always well-formed.
+ out = inject_api_base("", "https://a/b")
+ assert '="https://a/b";' in out