From 5d16753fa0bf9e0d12e6e5bd4f10148cd7a75299 Mon Sep 17 00:00:00 2001 From: DGPisces Date: Tue, 19 May 2026 10:43:54 -0700 Subject: [PATCH 1/5] chore(13): scrub personal email from tracked files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace gaodingyun2@gmail.com with the GitHub noreply address (40358663+DGPisces@users.noreply.github.com) across the four tracked files where it appeared: - README.md (invite-request line; full invite section removed later in this phase) - README.zh-CN.md (mirror of above) - SECURITY.md (vulnerability-report backup channel) - pyproject.toml (package authors metadata) The noreply address routes through GitHub without exposing the real mailbox, which is the standard OSS-maintainer privacy pattern. The real address is already hidden at account level (GitHub `Keep my email addresses private` is on; `gh api user` returns email: null). Phase: v1.2 / 13-working-tree-cleanse (cluster 1/3 — email scrub) Requirements: PRIV-NOW-01, PRIV-NOW-02 --- README.md | 2 +- README.zh-CN.md | 2 +- SECURITY.md | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fec8533..132d529 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ exists in v1; rotation requires a deploy-time env-var change plus frontend rebuild. Per-user authentication is v1.x scope (requirement AUTH-01). Anyone who holds the token can use the service. To request an invite token, -email [gaodingyun2@gmail.com](mailto:gaodingyun2@gmail.com). +email [40358663+DGPisces@users.noreply.github.com](mailto:40358663+DGPisces@users.noreply.github.com). ## Quick Start diff --git a/README.zh-CN.md b/README.zh-CN.md index 7eb3393..c6da822 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ v1 采用共享邀请令牌机制:`POST /api/sessions` 须携带 `X-Invite-Tok 并重新构建前端。每用户认证属于 v1.x 范畴(需求 AUTH-01)。 持有令牌的人均可使用本服务。如需申请邀请令牌,请发邮件至 -[gaodingyun2@gmail.com](mailto:gaodingyun2@gmail.com)。 +[40358663+DGPisces@users.noreply.github.com](mailto:40358663+DGPisces@users.noreply.github.com)。 ## 快速开始 diff --git a/SECURITY.md b/SECURITY.md index 2df4f72..39c7bc3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ keeps the report private and tracked alongside the codebase. **Backup channel: email.** Until Private Vulnerability Reporting is enabled, or if you cannot use it, email -[gaodingyun2@gmail.com](mailto:gaodingyun2@gmail.com) with subject prefix +[40358663+DGPisces@users.noreply.github.com](mailto:40358663+DGPisces@users.noreply.github.com) with subject prefix `[VocalizeAI Security]`. Do **not** file a public GitHub issue for security topics. Disclosure diff --git a/pyproject.toml b/pyproject.toml index 20d88b7..d834004 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "vocalize-ai" version = "0.1.0" description = "Bilingual (zh/en) universal phone-task AI agent" -authors = [{name = "DGPisces", email = "gaodingyun2@gmail.com"}] +authors = [{name = "DGPisces", email = "40358663+DGPisces@users.noreply.github.com"}] license = "Apache-2.0" readme = "README.md" requires-python = ">=3.11" From 7e822fc578c010513e0359ec50fe59e3ecda9ea6 Mon Sep 17 00:00:00 2001 From: DGPisces Date: Tue, 19 May 2026 10:46:15 -0700 Subject: [PATCH 2/5] chore(13): scrub personal domain from tracked files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace every reference to the personal Cloudflare-tunneled hostname (vocalize.dgpisces.com / vocalize-api.dgpisces.com / *.dgpisces.com) and the personal tunnel alias (dgpisces-server1) with reserved-example placeholders, so the public working tree contains no personal-domain identifiers (PRIV-NOW criterion #3 + 'Cloudflare Tunnel config contains placeholder values only'). Substitution rules - api.example.com ← vocalize-api.dgpisces.com (reserved per RFC 2606) - vocalize.example.com ← vocalize.dgpisces.com - your-tunnel-name ← dgpisces-server1 (Cloudflare alias) - shared design system ← dgpisces design system (CSS attribution) - Apple-style design tokens ← dgpisces Apple-style tokens Code defaults turned empty (env required) - `_DEFAULT_PROD_ORIGINS` in `src/vocalize/server/__init__.py` is now `[]`; non-localhost deployments MUST set `VOCALIZE_CORS_ORIGINS`. Docstring updated. Existing CORS-wildcard guard already raises if the operator sets a permissive value, so behavior on misconfiguration is fail-closed. Pytest sanity check - `tests/test_app_startup_guards.py` (the only test touching these URLs) still passes locally (8/8 green) after the substitution. Phase: v1.2 / 13-working-tree-cleanse (cluster 2/3 — domain scrub) Requirements: PRIV-NOW-03, PRIV-NOW-04, PRIV-NOW-05 --- .env.example | 4 ++-- SECURITY.md | 4 ++-- docs/release/24h-stability-evidence.md | 4 ++-- frontend/app/components.css | 2 +- frontend/app/design-tokens.css | 2 +- frontend/app/globals.css | 2 +- frontend/scripts/generate-og.py | 4 ++-- infra/pi-orchestrator/.env.template | 4 ++-- infra/pi-orchestrator/cloudflared-config.yml | 8 ++++---- infra/pi-orchestrator/setup.sh | 6 +++--- scripts/stability-24h-driver.py | 6 +++--- src/vocalize/server/__init__.py | 6 +++--- tests/test_app_startup_guards.py | 4 ++-- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index fdc36f2..17f4a44 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,6 @@ LOG_DIR=logs # ------------------------------------------------------------------------- # Frontend (Next.js — baked into the JS bundle at build time) # ------------------------------------------------------------------------- -NEXT_PUBLIC_VOCALIZE_API_BASE_URL=https://vocalize-api.dgpisces.com -NEXT_PUBLIC_VOCALIZE_WS_BASE_URL=wss://vocalize-api.dgpisces.com +NEXT_PUBLIC_VOCALIZE_API_BASE_URL=https://api.example.com +NEXT_PUBLIC_VOCALIZE_WS_BASE_URL=wss://api.example.com NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN= diff --git a/SECURITY.md b/SECURITY.md index 39c7bc3..ac8380a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,8 +46,8 @@ configured server-side. This is an intentional design for v1 (requirement ### Backend security controls -- **CORS**: single allowed origin (`https://vocalize.dgpisces.com`) in - production; localhost origins preserved in dev mode via +- **CORS**: single allowed origin (set via `VOCALIZE_CORS_ORIGINS`, e.g. + `https://vocalize.example.com`) in production; localhost origins preserved in dev mode via `VOCALIZE_HOST` env-conditional config. `allow_methods` restricted to `["GET", "POST", "DELETE"]`. - **WS base URL enforcement**: server raises at startup if diff --git a/docs/release/24h-stability-evidence.md b/docs/release/24h-stability-evidence.md index bb893e6..498dbbc 100644 --- a/docs/release/24h-stability-evidence.md +++ b/docs/release/24h-stability-evidence.md @@ -40,7 +40,7 @@ closes this gap: | Operator | DGPisces | | Date / time (start) | 2026-05-17T19:56:20Z | | Date / time (end) | 2026-05-18T19:56:20Z | -| Backend URL | `http://localhost:8080` (Pi loopback; same orchestrator that `https://vocalize-api.dgpisces.com` fronts via Cloudflare Tunnel) | +| Backend URL | `http://localhost:8080` (Pi loopback; same orchestrator that `https://api.example.com` fronts via Cloudflare Tunnel) | | Browser and version | N/A — driver-only run; real-audio interleave intentionally skipped (see Real-Audio Interleave Log below) | | STT service health | Pass (`/health` reported `gpu_reachable: true` throughout) | | TTS service health | Pass (CosyVoice docker container up 2 days, healthy) | @@ -95,7 +95,7 @@ python scripts/stability-24h-driver.py \ --evidence-out docs/release/24h-stability-evidence-runs/2026-05-17T195620Z-pi-loopback.md ``` -Driver invoked with API base = `http://localhost:8080` (Pi loopback) rather than `https://vocalize-api.dgpisces.com` because the driver ran on the Pi itself per operator preference (full automation, no Attu host). The Cloudflare Tunnel ingress for `vocalize.dgpisces.com` was verified live separately during DEPLOY-03 deploy (curl → HTTP/2 502 + Universal SSL `*.dgpisces.com` cert). +Driver invoked with API base = `http://localhost:8080` (Pi loopback) rather than `https://api.example.com` because the driver ran on the Pi itself per operator preference (full automation, no Attu host). The Cloudflare Tunnel ingress for `vocalize.example.com` was verified live separately during DEPLOY-03 deploy (curl → HTTP/2 502 + Universal SSL `*.example.com` cert). --- diff --git a/frontend/app/components.css b/frontend/app/components.css index 62d9a41..1ea7733 100644 --- a/frontend/app/components.css +++ b/frontend/app/components.css @@ -6,7 +6,7 @@ * except where a value is component-shape (e.g. timer-bar height) and * expressed in design-system spacing/timing units. * - * Visual character: dgpisces design system (Apple-style; SF font stack; + * Visual character: shared design system (Apple-style; SF font stack; * 12px radius cards, 8px sm radius, pill radius for chips). Bubbles and * speaker-attributed surfaces follow conversation-app conventions while * keeping a refined, minimal tone consistent with the existing B2 chrome. diff --git a/frontend/app/design-tokens.css b/frontend/app/design-tokens.css index 28de729..76ab096 100644 --- a/frontend/app/design-tokens.css +++ b/frontend/app/design-tokens.css @@ -1,5 +1,5 @@ /* - * dgpisces design system — token primitives (web platform). + * shared design system — token primitives (web platform). * * Source: ~/Claude Code/Web Dev/design-system/platforms/web/_tokens.css * (mirror of tokens/tokens.json v1.3.0). Vendored here because the design diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 1316f72..268b185 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,7 +1,7 @@ /* * globals.css — base resets + B2 (browser audio bridge) chrome. * - * Token primitives live in design-tokens.css (vendored from the dgpisces + * Token primitives live in design-tokens.css (vendored from the shared * design system). Component classes for B3a phases live in components.css. * Keep this file scoped to: design-token import, base resets, body chrome, * skip-link, and the B2 browser-audio shell (.app-shell / .page-frame / diff --git a/frontend/scripts/generate-og.py b/frontend/scripts/generate-og.py index 55cd608..ed5ba04 100644 --- a/frontend/scripts/generate-og.py +++ b/frontend/scripts/generate-og.py @@ -5,7 +5,7 @@ frontend/public/og/og-zh.png (1200×630, Chinese) frontend/public/og/og-en.png (1200×630, English) -Design system: dgpisces Apple-style tokens +Design system: Apple-style design tokens - Accent: #007aff (blue) / #5e5ce6 (purple) - BG: linear gradient from #0a0a0f → #1a1a2e - Text: #f5f5f7 (primary), #98989d (soft) @@ -312,7 +312,7 @@ def generate(lang: str, tagline: str, out_path: str): font_url = ImageFont.truetype(FONT_MONO, 15) except Exception: font_url = ImageFont.load_default() - draw.text((tag_x, strip_y), "vocalize.dgpisces.com", font=font_url, fill=TEXT_SOFT) + draw.text((tag_x, strip_y), "vocalize.example.com", font=font_url, fill=TEXT_SOFT) # 10. Decorative divider line (vertical, separating text from illustration) div_x = int(W * 0.60) diff --git a/infra/pi-orchestrator/.env.template b/infra/pi-orchestrator/.env.template index 6ca1163..2f113b2 100644 --- a/infra/pi-orchestrator/.env.template +++ b/infra/pi-orchestrator/.env.template @@ -8,7 +8,7 @@ VOCALIZE_PORT=8080 # Required for non-localhost deployments — see D-11. # If unset on non-localhost host, create_app() raises RuntimeError and systemd will restart-loop. -VOCALIZE_WS_BASE_URL=wss://vocalize-api.dgpisces.com +VOCALIZE_WS_BASE_URL=wss://api.example.com # Required for the X-Invite-Token gate on POST /api/sessions — see D-08. # Generate with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))' @@ -16,7 +16,7 @@ VOCALIZE_WS_BASE_URL=wss://vocalize-api.dgpisces.com VOCALIZE_INVITE_TOKEN= # Production CORS — see D-10. Pin to single origin in prod. -VOCALIZE_CORS_ORIGINS=https://vocalize.dgpisces.com +VOCALIZE_CORS_ORIGINS=https://vocalize.example.com # Test-only: enables merchant_text_inject frame for AI-merchant scenarios. # DO NOT set this outside the 24h evidence run. diff --git a/infra/pi-orchestrator/cloudflared-config.yml b/infra/pi-orchestrator/cloudflared-config.yml index 8982b47..92f4bbc 100644 --- a/infra/pi-orchestrator/cloudflared-config.yml +++ b/infra/pi-orchestrator/cloudflared-config.yml @@ -7,16 +7,16 @@ # # This file documents the intended ingress shape so reviewers can see what the # tunnel exposes; the actual ingress lives in the Cloudflare dashboard -# (Zero Trust → Networks → Tunnels → dgpisces-server1 → Public Hostname) / +# (Zero Trust → Networks → Tunnels → your-tunnel-name → Public Hostname) / # Cloudflare API tunnel config. The dashboard / API is the source of truth — # update this YAML whenever the live ingress changes so reviewers of the OSS # mirror see the current shape. # -# Tunnel: dgpisces-server1 () +# Tunnel: your-tunnel-name () ingress: - - hostname: vocalize-api.dgpisces.com + - hostname: api.example.com service: http://localhost:8080 - - hostname: vocalize.dgpisces.com + - hostname: vocalize.example.com service: http://localhost:3000 - service: http_status:404 diff --git a/infra/pi-orchestrator/setup.sh b/infra/pi-orchestrator/setup.sh index b23d110..99146cd 100755 --- a/infra/pi-orchestrator/setup.sh +++ b/infra/pi-orchestrator/setup.sh @@ -88,7 +88,7 @@ echo " ssh ${PI_USER}@${PI_HOST} 'nano ${PI_PROJECT_DIR}/.env'" echo "" echo "2. Install cloudflared service on Pi using a tunnel token (token-based auth):" echo " a. Get the connector token from the Cloudflare dashboard:" -echo " Zero Trust → Networks → Tunnels → dgpisces-server1 → Configure" +echo " Zero Trust → Networks → Tunnels → your-tunnel-name → Configure" echo " → Install and run a connector → copy the long token string" echo " b. SSH to Pi and install the service with that token:" echo " ssh ${PI_USER}@${PI_HOST} 'sudo cloudflared service install '" @@ -97,13 +97,13 @@ echo " config.yml is needed. (If you ever want to inspect intended ingress, echo " infra/pi-orchestrator/cloudflared-config.yml; that file is reference-only.)" echo "" echo "3. Make sure Public Hostname routing is configured in the dashboard:" -echo " vocalize-api.dgpisces.com → http://localhost:8080" +echo " api.example.com → http://localhost:8080" echo "" echo "4. Start services:" echo " ssh ${PI_USER}@${PI_HOST} 'sudo systemctl start vocalize cloudflared'" echo "" echo "5. Verify both services are running and the public URL responds:" echo " ssh ${PI_USER}@${PI_HOST} 'sudo systemctl status vocalize cloudflared --no-pager'" -echo " curl -fsS https://vocalize-api.dgpisces.com/health" +echo " curl -fsS https://api.example.com/health" echo "" echo "After completing manual steps, use deploy.sh for subsequent deploys." diff --git a/scripts/stability-24h-driver.py b/scripts/stability-24h-driver.py index 1692c53..7f25b6d 100644 --- a/scripts/stability-24h-driver.py +++ b/scripts/stability-24h-driver.py @@ -7,14 +7,14 @@ Usage (run from a remote runner host): ssh cd /path/to/VocalizeAI - export VOCALIZE_API_BASE=https://vocalize-api.dgpisces.com + export VOCALIZE_API_BASE=https://api.example.com export VOCALIZE_INVITE_TOKEN= python scripts/stability-24h-driver.py --duration-minutes 1440 \\ --scenario balance_inquiry_en_query --seed direct Environment variables: VOCALIZE_API_BASE Base URL for the Pi orchestrator REST API. - Default: https://vocalize-api.dgpisces.com + Default: https://api.example.com VOCALIZE_INVITE_TOKEN Required when API base is non-localhost. Set to match VOCALIZE_INVITE_TOKEN on the Pi. @@ -53,7 +53,7 @@ DEFAULT_DURATION_MIN = 24 * 60 # 24 hours DEFAULT_SCENARIO_ID = "balance_inquiry_en_query" DEFAULT_SEED_ID = "direct" -DEFAULT_API_BASE = "https://vocalize-api.dgpisces.com" +DEFAULT_API_BASE = "https://api.example.com" # Path to scenarios.yaml (sibling of tests/integration/ai_merchant.py) _SCENARIOS_YAML = ( diff --git a/src/vocalize/server/__init__.py b/src/vocalize/server/__init__.py index 4396792..1cc2032 100644 --- a/src/vocalize/server/__init__.py +++ b/src/vocalize/server/__init__.py @@ -47,7 +47,7 @@ def _default_user_pipeline_factory(transport): ) -_DEFAULT_PROD_ORIGINS = ["https://vocalize.dgpisces.com"] +_DEFAULT_PROD_ORIGINS: list[str] = [] _DEFAULT_DEV_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"] @@ -58,7 +58,7 @@ def create_app() -> FastAPI: VOCALIZE_HOST / VOCALIZE_PORT — where uvicorn binds (handled by main()). VOCALIZE_CORS_ORIGINS — comma-separated allowed origins; defaults to dev origins when VOCALIZE_HOST is 127.0.0.1/localhost, else - the single production origin https://vocalize.dgpisces.com (D-10). + empty (operator MUST set this env var in non-localhost mode — see D-10). VOCALIZE_WS_BASE_URL — REQUIRED when VOCALIZE_HOST is not localhost. The public WS prefix echoed back from POST /api/sessions. Raises RuntimeError at startup when absent in non-localhost mode @@ -126,7 +126,7 @@ async def _refresh_runtime_gauges_on_metrics_scrape( raise RuntimeError( "VOCALIZE_WS_BASE_URL is required when VOCALIZE_HOST is not localhost " "(closes Host-header spoofing vector — see CONCERNS.md). " - "Example: wss://vocalize-api.dgpisces.com" + "Example: wss://api.example.com" ) register_session_routes(app, registry=registry) diff --git a/tests/test_app_startup_guards.py b/tests/test_app_startup_guards.py index 7d377b0..9b4cc66 100644 --- a/tests/test_app_startup_guards.py +++ b/tests/test_app_startup_guards.py @@ -71,7 +71,7 @@ def test_non_localhost_with_ws_base_url_succeeds(monkeypatch): client = _make_client( monkeypatch, host="0.0.0.0", - ws_base_url="wss://vocalize-api.dgpisces.com", + ws_base_url="wss://api.example.com", ) # App started without RuntimeError; 404 here just means session not found. resp = client.get("/api/sessions/nonexistent-id") @@ -159,7 +159,7 @@ def test_cors_wildcard_origins_raises_at_startup(monkeypatch): def test_cors_wildcard_mixed_with_real_origins_raises(monkeypatch): monkeypatch.setenv("VOCALIZE_HOST", "0.0.0.0") - monkeypatch.setenv("VOCALIZE_WS_BASE_URL", "wss://vocalize-api.dgpisces.com") + monkeypatch.setenv("VOCALIZE_WS_BASE_URL", "wss://api.example.com") monkeypatch.setenv("VOCALIZE_CORS_ORIGINS", "https://example.com,*") monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) reset_config() From 63f251adee194b21d57a75d7db9957c54d802b26 Mon Sep 17 00:00:00 2001 From: DGPisces Date: Tue, 19 May 2026 10:53:53 -0700 Subject: [PATCH 3/5] chore(13): remove invite-token gate from backend, frontend, tests, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1's shared `X-Invite-Token` gate (D-08) was a private-beta access control that no longer fits a public self-deploy posture. Self-deploy operators now restrict reachability at the network/proxy layer; per-user authentication remains v1.x scope (requirement AUTH-01). Backend - src/vocalize/server/sessions.py: drop `_check_invite_token` and the `Depends(_check_invite_token)` on `POST /api/sessions`; drop now-unused imports (`Depends`, `Header`, `secrets`, `status`). - src/vocalize/config.py: drop `invite_token` field and its env wiring. - src/vocalize/server/__init__.py: drop `VOCALIZE_INVITE_TOKEN` from the docstring; drop `X-Invite-Token` from `allow_headers`. Frontend - frontend/lib/api.ts: drop `inviteToken()` + `X-Invite-Token` injection in `createSession`; bundle no longer reads `NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN`. `tsc --noEmit` is clean. Tests - tests/test_invite_token_gate.py: deleted (160 LOC) — entire suite validated the now-removed gate. - tests/test_app_startup_guards.py: drop `VOCALIZE_INVITE_TOKEN` envvar monkeypatch in 4 fixtures; update CORS preflight tests to assert `Content-Type` (the remaining `allow_header`). Env / scripts / install - .env.example, infra/pi-orchestrator/.env.template: drop `VOCALIZE_INVITE_TOKEN` and `NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN` lines. - scripts/smoke.sh: drop `TOKEN` resolution and `X-Invite-Token` header on `POST /api/sessions`. - scripts/stability-24h-driver.py: drop `invite_token` arg threading across `one_cycle`, `fetch_metrics_snapshot`, and `main`; drop env resolution and the non-localhost requirement check. - install/pi-install.sh: drop `VOCALIZE_INVITE_TOKEN` from the operator reminder; advertise `VOCALIZE_CORS_ORIGINS` instead. Docs - README.md / README.zh-CN.md: remove the "Why VocalizeAI is invite-only" section entirely; remove the `VOCALIZE_INVITE_TOKEN` env table row. - SECURITY.md: rewrite "Access posture" to describe the self-deploy expectation (operator-managed network restriction). Drop the shared-invite-token "Known limitations" subsection. Drop invite-token steps from the emergency-rollback runbook. - docs/architecture.md: drop `X-Invite-Token` from the end-to-end flow, REST surface, and design-decisions sections; mark D-08 as retired with a short pointer to the new self-deploy posture. - docs/deploy/local.md, docs/deploy/pi.md: drop the `VOCALIZE_INVITE_TOKEN` / `NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN` env table rows, the localhost-dev shortcut paragraph, and the curl example that injected a token; replace with a short note about the v1 self-deploy auth posture. - docs/release/24h-stability-evidence.md: drop the `export VOCALIZE_INVITE_TOKEN=` line from the historical driver-command record. - CONTRIBUTING.md: drop the `add X-Invite-Token gate` example commit message. Verification - `git ls-files | xargs grep -liE "x-invite-token|invite_token|invite-only|invite only"` returns zero hits — P13 success criterion #4 satisfied verbatim. - pytest tests/ (excluding the two known-red Cluster B files left for a later session) passes 100%; tests/test_app_startup_guards.py 8/8 green after the gate removal. Phase: v1.2 / 13-working-tree-cleanse (cluster 3/3 — invite gate removal) Requirements: SELF-INVITE-01..07 --- .env.example | 4 - CONTRIBUTING.md | 2 +- README.md | 15 +-- README.zh-CN.md | 14 +-- SECURITY.md | 37 +++--- docs/architecture.md | 28 ++--- docs/deploy/local.md | 16 ++- docs/deploy/pi.md | 13 +- docs/release/24h-stability-evidence.md | 1 - frontend/lib/api.ts | 25 ---- infra/pi-orchestrator/.env.template | 5 - install/pi-install.sh | 2 +- scripts/smoke.sh | 6 +- scripts/stability-24h-driver.py | 33 +---- src/vocalize/config.py | 4 - src/vocalize/server/__init__.py | 4 +- src/vocalize/server/sessions.py | 43 +------ tests/test_app_startup_guards.py | 12 +- tests/test_invite_token_gate.py | 160 ------------------------- 19 files changed, 59 insertions(+), 365 deletions(-) delete mode 100644 tests/test_invite_token_gate.py diff --git a/.env.example b/.env.example index 17f4a44..fe0f0c8 100644 --- a/.env.example +++ b/.env.example @@ -31,9 +31,6 @@ VOCALIZE_HOST=127.0.0.1 VOCALIZE_PORT=8080 ORCHESTRATOR_LISTEN_PORT=8080 -# X-Invite-Token shared secret. Required when VOCALIZE_HOST != 127.0.0.1. -VOCALIZE_INVITE_TOKEN= - # Public WebSocket base URL. Required when VOCALIZE_HOST != 127.0.0.1. VOCALIZE_WS_BASE_URL= @@ -51,4 +48,3 @@ LOG_DIR=logs # ------------------------------------------------------------------------- NEXT_PUBLIC_VOCALIZE_API_BASE_URL=https://api.example.com NEXT_PUBLIC_VOCALIZE_WS_BASE_URL=wss://api.example.com -NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5037c6..9fcebd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ test(): refactor(): ``` -Examples: `feat(server): add X-Invite-Token gate`, `fix(frontend): handle 401 on session create`. +Examples: `feat(server): bound task length`, `fix(frontend): handle 401 on session create`. ## Branches diff --git a/README.md b/README.md index 132d529..840978f 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,6 @@ inquiries, status checks, and more. An OSS mirror is available at [github.com/DGPisces/VocalizeAI](https://github.com/DGPisces/VocalizeAI) under Apache 2.0. -## Why VocalizeAI is invite-only right now - -v1 uses a shared invite token (`X-Invite-Token` header on `POST /api/sessions`) -distributed out-of-band. This is a long-lived shared secret — no rotation flow -exists in v1; rotation requires a deploy-time env-var change plus frontend -rebuild. Per-user authentication is v1.x scope (requirement AUTH-01). - -Anyone who holds the token can use the service. To request an invite token, -email [40358663+DGPisces@users.noreply.github.com](mailto:40358663+DGPisces@users.noreply.github.com). - ## Quick Start **Prerequisites:** Python 3.11+, Node 20+, git, curl. Optional: `uv` (auto-installed by the script). @@ -121,9 +111,8 @@ VocalizeAI/ | Variable | Purpose | |----------|---------| -| `VOCALIZE_INVITE_TOKEN` | Shared invite token for `POST /api/sessions`; required when `VOCALIZE_HOST != 127.0.0.1` | -| `VOCALIZE_WS_BASE_URL` | WebSocket base URL returned to clients (e.g., `wss://vocalize-api.example.com`); required in non-localhost mode to prevent Host-header spoofing | -| `VOCALIZE_CORS_ORIGINS` | Comma-separated allowed CORS origins; defaults to `https://vocalize.example.com` in non-localhost mode | +| `VOCALIZE_WS_BASE_URL` | WebSocket base URL returned to clients (e.g., `wss://api.example.com`); required in non-localhost mode to prevent Host-header spoofing | +| `VOCALIZE_CORS_ORIGINS` | Comma-separated allowed CORS origins; **required** in non-localhost mode (no default) | See `.env.example` for the full env-var inventory including LLM, GPU service, and frontend build-time variables. diff --git a/README.zh-CN.md b/README.zh-CN.md index c6da822..80a9c6b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,15 +15,6 @@ clarification_collector / relay)可处理任何电话任务 —— 餐厅订 [github.com/DGPisces/VocalizeAI](https://github.com/DGPisces/VocalizeAI), 协议 Apache 2.0。 -## 为什么 VocalizeAI 当前是邀请制 - -v1 采用共享邀请令牌机制:`POST /api/sessions` 须携带 `X-Invite-Token` 请求头, -令牌通过线下渠道发放。这是长效共享密钥 —— v1 没有轮换流程;轮换需要修改部署环境变量 -并重新构建前端。每用户认证属于 v1.x 范畴(需求 AUTH-01)。 - -持有令牌的人均可使用本服务。如需申请邀请令牌,请发邮件至 -[40358663+DGPisces@users.noreply.github.com](mailto:40358663+DGPisces@users.noreply.github.com)。 - ## 快速开始 **前提条件:** Python 3.11+、Node 20+、git、curl。可选:`uv`(安装脚本会自动安装)。 @@ -114,9 +105,8 @@ VocalizeAI/ | 变量 | 用途 | |------|------| -| `VOCALIZE_INVITE_TOKEN` | `POST /api/sessions` 所需的共享邀请令牌;`VOCALIZE_HOST != 127.0.0.1` 时必填 | -| `VOCALIZE_WS_BASE_URL` | 返回给客户端的 WebSocket 基地址(如 `wss://vocalize-api.example.com`);非 localhost 模式必填,防止 Host 头欺骗 | -| `VOCALIZE_CORS_ORIGINS` | 允许的 CORS 来源(逗号分隔);非 localhost 模式默认为 `https://vocalize.example.com` | +| `VOCALIZE_WS_BASE_URL` | 返回给客户端的 WebSocket 基地址(如 `wss://api.example.com`);非 localhost 模式必填,防止 Host 头欺骗 | +| `VOCALIZE_CORS_ORIGINS` | 允许的 CORS 来源(逗号分隔);非 localhost 模式**必填**(无默认值) | 完整环境变量清单(含 LLM、GPU 服务、前端构建变量)见 `.env.example`。 diff --git a/SECURITY.md b/SECURITY.md index ac8380a..1fdbdf9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,11 +31,11 @@ Please include: ### Access posture -v1 uses a shared invite-token model. `POST /api/sessions` requires an -`X-Invite-Token` header whose value must match `VOCALIZE_INVITE_TOKEN` -configured server-side. This is an intentional design for v1 (requirement -`AUTH-DEFER-TO-v1.x`); per-user authentication is v1.x scope (requirement -`AUTH-01`). +VocalizeAI is **self-deploy**: each operator runs their own backend on +their own infrastructure and is responsible for restricting access to it +(reverse-proxy auth, VPN, Cloudflare Access policy, etc.). The codebase +does not ship a built-in authentication gate. Per-user authentication is +v1.x scope (requirement `AUTH-01`). ### Network layer @@ -58,18 +58,12 @@ configured server-side. This is an intentional design for v1 (requirement ## Known limitations -### Shared invite token in JS bundle +### No built-in authentication in v1 -`NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN` is embedded in the Next.js JS bundle -at build time by design — this is inherent to the shared-token model. -It is not a vulnerability; the token IS the gate. Anyone who can load the -page already has the token. Per-user secrets are v1.x scope (AUTH-01). - -### No token rotation flow in v1 - -The invite token is a long-lived shared secret. Rotation requires a -deploy-time env-var update plus a frontend rebuild. There is no in-band -rotation API in v1. +VocalizeAI v1 ships no request-level auth on `POST /api/sessions` or +the WebSocket endpoint. Self-deploy operators MUST restrict reachability +at the network or proxy layer until per-user authentication lands (v1.x +scope; requirement `AUTH-01`). ### No prompt-injection mitigation @@ -78,13 +72,12 @@ This is a known gap flagged for v1.x scope. ## Emergency rollback for leaked secret in public mirror -If a secret (invite token, API key, tunnel ID) is accidentally committed -to the public repo: +If a secret (API key, tunnel ID, third-party token) is accidentally +committed to the public repo: -1. **Rotate the leaked secret immediately.** - - `VOCALIZE_INVITE_TOKEN`: update Pi `.env`, restart `vocalize` service, - rebuild frontend with the new `NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN`. - - Other secrets: follow their respective rotation procedures. +1. **Rotate the leaked secret immediately** per the secret's own rotation + procedure (provider dashboard for API keys, `cloudflared` rotate for + tunnel credentials, etc.). 2. **Force-push the public `main`** to a re-sanitized commit by re-running the `sync-private-to-public` skill against a clean private tree. This diff --git a/docs/architecture.md b/docs/architecture.md index f33b82d..fd8463d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ the AI audio pipeline and the PSTN call. ### End-to-End Request Flow (Quick Reference) -1. **User creates a session** — `POST /api/sessions` with `X-Invite-Token` → receives +1. **User creates a session** — `POST /api/sessions` → receives `session_id` + `ws_url`. 2. **User sets the task** — `POST /api/sessions/{id}/task` with `{"task": "..."}` → Phase transitions `draft → task_planning → collecting`. @@ -374,16 +374,17 @@ See: `src/vocalize/server/frames.py` ## REST Surface -The REST API is mounted at `/api/sessions`. Authentication is via a shared -invite token (`X-Invite-Token` header on session creation). In localhost-dev -mode (`VOCALIZE_HOST=127.0.0.1` and `VOCALIZE_INVITE_TOKEN` unset), the token -gate is disabled. +The REST API is mounted at `/api/sessions`. The backend ships no +request-level authentication in v1; self-deploy operators restrict +reachability at the network or proxy layer (per-user auth is v1.x scope — +see SECURITY.md and requirement `AUTH-01`). See: `src/vocalize/server/sessions.py`, `src/vocalize/server/health.py` ### `POST /api/sessions` -**Auth:** `X-Invite-Token` header (design constraint D-08; uses `secrets.compare_digest`) +**Auth:** None at the backend layer in v1 (network/proxy restriction is the +operator's responsibility). **Request body** (`CreateSessionRequest`, all optional): ```json @@ -405,7 +406,7 @@ See: `src/vocalize/server/sessions.py`, `src/vocalize/server/health.py` } ``` -See: `src/vocalize/server/sessions.py` — `_check_invite_token`, `CreateSessionRequest` +See: `src/vocalize/server/sessions.py` — `CreateSessionRequest` --- @@ -568,14 +569,13 @@ The security controls relevant to the architecture are documented here for API consumers and security researchers. For the full threat model and disclosure channel, see [SECURITY.md](../SECURITY.md). -### Invite-Token Gate (D-08) +### Authentication (D-08, retired) -`POST /api/sessions` requires `X-Invite-Token: ` matching the server-side -`VOCALIZE_INVITE_TOKEN` env var. The comparison uses `secrets.compare_digest` to -prevent timing attacks. In localhost-dev mode (`VOCALIZE_HOST=127.0.0.1` and -`VOCALIZE_INVITE_TOKEN` unset), the gate is disabled for development convenience. - -See: `src/vocalize/server/sessions.py` — `_check_invite_token` +The original D-08 shared-invite-token gate has been removed; v1 ships no +backend-level auth on `POST /api/sessions` or the WebSocket. Self-deploy +operators are expected to restrict reachability at the network or proxy +layer (Cloudflare Access, VPN, reverse-proxy auth, etc.). Per-user +authentication is v1.x scope (requirement `AUTH-01`). ### Task Length Bound (D-09) diff --git a/docs/deploy/local.md b/docs/deploy/local.md index 3181281..631d5fb 100644 --- a/docs/deploy/local.md +++ b/docs/deploy/local.md @@ -78,19 +78,18 @@ $EDITOR .env | `VOCALIZE_HOST` | default ok | uvicorn bind host; `127.0.0.1` for local dev, `0.0.0.0` for production | | `VOCALIZE_PORT` | default ok | uvicorn bind port; default `8080` (note: dev `main.py` defaults to 8000) | | `ORCHESTRATOR_LISTEN_PORT` | default ok | Pi service port; default `8080` (legacy; mirrors `VOCALIZE_PORT`) | -| `VOCALIZE_INVITE_TOKEN` | required when non-localhost | Shared invite secret for `POST /api/sessions`; **gate is disabled when `VOCALIZE_HOST=127.0.0.1` and this var is unset** — localhost-dev shortcut | -| `VOCALIZE_WS_BASE_URL` | required when non-localhost | Public WS base URL (e.g. `wss://vocalize-api.example.com`); startup raises if missing in non-localhost mode (D-11) | +| `VOCALIZE_WS_BASE_URL` | required when non-localhost | Public WS base URL (e.g. `wss://api.example.com`); startup raises if missing in non-localhost mode (D-11) | | `VOCALIZE_CORS_ORIGINS` | default ok | Comma-separated allowed CORS origins; auto-picked from VOCALIZE_HOST in dev mode | | `DEFAULT_LANGUAGE` | default ok | Session default language; `zh` or `en`; default `zh` | | `LOG_DIR` | default ok | Log directory; default `logs` | | `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` | yes for frontend | Frontend API base URL baked into the Next.js JS bundle at build time | | `NEXT_PUBLIC_VOCALIZE_WS_BASE_URL` | optional | Frontend WS base; derived from `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` if absent | -| `NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN` | yes for frontend in prod | Invite token baked into Next.js JS bundle; required for the frontend to authenticate session creation | -**Localhost-dev shortcut:** when `VOCALIZE_HOST` is `127.0.0.1` and -`VOCALIZE_INVITE_TOKEN` is unset, the `X-Invite-Token` gate on `POST /api/sessions` -is disabled. This means you can `curl http://127.0.0.1:8000/api/sessions` without -supplying a token — convenient for local development. +**Backend auth posture:** v1 ships no request-level auth on +`POST /api/sessions` or the WebSocket. For non-localhost deployments, +restrict reachability at the network or proxy layer (Cloudflare Access, +VPN, reverse-proxy auth, etc.). Per-user auth is v1.x scope +(requirement `AUTH-01`). **Minimum for local dev (no GPU):** ```bash @@ -156,8 +155,7 @@ Exit code 0 = the development environment is working. The smoke script exercises 6 round-trips: health check, create session, set task, WS upgrade + send/recv, delete session. Total runtime is ~20 seconds. -The smoke script uses `VOCALIZE_API_BASE` (default `http://127.0.0.1:8000`) and -`VOCALIZE_INVITE_TOKEN` (default empty, gate disabled in localhost-dev mode). +The smoke script uses `VOCALIZE_API_BASE` (default `http://127.0.0.1:8000`). --- diff --git a/docs/deploy/pi.md b/docs/deploy/pi.md index 904d501..1a56e20 100644 --- a/docs/deploy/pi.md +++ b/docs/deploy/pi.md @@ -127,14 +127,12 @@ sudo nano /opt/vocalize/.env | `VOCALIZE_HOST` | default ok | uvicorn bind host; set to `0.0.0.0` for Pi production | | `VOCALIZE_PORT` | default ok | uvicorn bind port; default `8080` | | `ORCHESTRATOR_LISTEN_PORT` | default ok | Pi service port; default `8080` (legacy compatibility) | -| `VOCALIZE_INVITE_TOKEN` | **yes** | Shared invite secret for `POST /api/sessions`; generate with `python3 -c 'import secrets; print(secrets.token_urlsafe(32))'` | -| `VOCALIZE_WS_BASE_URL` | **yes** | Public WS base URL; e.g. `wss://vocalize-api.` — startup raises if missing in non-localhost mode | +| `VOCALIZE_WS_BASE_URL` | **yes** | Public WS base URL; e.g. `wss://api.` — startup raises if missing in non-localhost mode | | `VOCALIZE_CORS_ORIGINS` | default ok | Comma-separated allowed CORS origins; default auto-picked from VOCALIZE_HOST | | `DEFAULT_LANGUAGE` | default ok | `zh` or `en`; default `zh` | | `LOG_DIR` | default ok | Log directory; default `logs` | | `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` | yes (for frontend) | Frontend API base URL; baked into JS bundle at build time | | `NEXT_PUBLIC_VOCALIZE_WS_BASE_URL` | optional | Frontend WS base; derived from API base if absent | -| `NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN` | yes (for frontend in prod) | Shared invite token baked into JS bundle | **Example production `.env` (use your own values for all `<...>` placeholders):** @@ -147,9 +145,8 @@ SENSEVOICE_WS_PORT=8000 COSYVOICE_WS_PORT=8001 VOCALIZE_HOST=0.0.0.0 VOCALIZE_PORT=8080 -VOCALIZE_INVITE_TOKEN= -VOCALIZE_WS_BASE_URL=wss://vocalize-api. -VOCALIZE_CORS_ORIGINS=https://vocalize. +VOCALIZE_WS_BASE_URL=wss://api. +VOCALIZE_CORS_ORIGINS=https:// ``` --- @@ -248,9 +245,7 @@ VOCALIZE_API_BASE=http://127.0.0.1:8080 bash scripts/smoke.sh # Exit 0 = working deployment # Smoke test against the public Cloudflare Tunnel URL: -VOCALIZE_API_BASE=https://vocalize-api. \ - VOCALIZE_INVITE_TOKEN= \ - bash scripts/smoke.sh +VOCALIZE_API_BASE=https://api. bash scripts/smoke.sh ``` The smoke script exercises 6 round-trips: `GET /health`, `POST /api/sessions`, diff --git a/docs/release/24h-stability-evidence.md b/docs/release/24h-stability-evidence.md index 498dbbc..4d7a34d 100644 --- a/docs/release/24h-stability-evidence.md +++ b/docs/release/24h-stability-evidence.md @@ -87,7 +87,6 @@ transient failures acceptable). Driver command used: ```bash export VOCALIZE_API_BASE=http://localhost:8080 -export VOCALIZE_INVITE_TOKEN= python scripts/stability-24h-driver.py \ --duration-minutes 1440 \ --scenario balance_inquiry_en_query \ diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 42d5183..8537e01 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -53,34 +53,10 @@ function apiBaseUrl(): string { return value.replace(/\/$/, ""); } -/** - * Returns the invite token from the build-time env var, or null when unset. - * - * Returning null (not throwing) is intentional: localhost-dev must work without - * any token configured. - * - * WARNING: NEXT_PUBLIC_ vars are embedded in the client-side JS bundle and are - * therefore recoverable by any user who can load the page. This is an accepted - * v1 limitation — the shared token provides friction, not true access control. - * Per-user auth (AUTH-01, v1.x) will replace this gate entirely. - * - * For stronger protection before AUTH-01 lands, consider routing the session - * creation call through a Next.js API route that reads the token from a - * server-side env var (no NEXT_PUBLIC_ prefix) so it never reaches the bundle. - */ -function inviteToken(): string | null { - const value = process.env.NEXT_PUBLIC_VOCALIZE_INVITE_TOKEN; - return value && value.length > 0 ? value : null; -} - export async function createSession( body: CreateSessionRequest = {}, ): Promise { const headers: Record = { "Content-Type": "application/json" }; - const token = inviteToken(); - if (token !== null) { - headers["X-Invite-Token"] = token; - } const res = await fetch(`${apiBaseUrl()}/api/sessions`, { method: "POST", cache: "no-store", @@ -88,7 +64,6 @@ export async function createSession( body: JSON.stringify(body), }); if (!res.ok) { - // 401 = invite-required per D-08; bubble to caller for user-facing "invite required" message throw new Error(`createSession failed: ${res.status}`); } return (await res.json()) as SessionResponse; diff --git a/infra/pi-orchestrator/.env.template b/infra/pi-orchestrator/.env.template index 2f113b2..dcf758e 100644 --- a/infra/pi-orchestrator/.env.template +++ b/infra/pi-orchestrator/.env.template @@ -10,11 +10,6 @@ VOCALIZE_PORT=8080 # If unset on non-localhost host, create_app() raises RuntimeError and systemd will restart-loop. VOCALIZE_WS_BASE_URL=wss://api.example.com -# Required for the X-Invite-Token gate on POST /api/sessions — see D-08. -# Generate with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))' -# MUST be set; an empty value disables the gate, exposing session creation publicly. -VOCALIZE_INVITE_TOKEN= - # Production CORS — see D-10. Pin to single origin in prod. VOCALIZE_CORS_ORIGINS=https://vocalize.example.com diff --git a/install/pi-install.sh b/install/pi-install.sh index 07b2686..511c54b 100755 --- a/install/pi-install.sh +++ b/install/pi-install.sh @@ -197,7 +197,7 @@ step6_systemd_unit() { run_or_dry "copy .env.template -> ${INSTALL_DIR}/.env" \ sudo cp "${ENV_SRC}" "${ENV_DST}" echo " Created ${ENV_DST} from template — edit it before starting the service." - echo " At minimum, set: OPENAI_API_KEY, VOCALIZE_INVITE_TOKEN, VOCALIZE_WS_BASE_URL, GPU_HOST" + echo " At minimum, set: OPENAI_API_KEY, VOCALIZE_WS_BASE_URL, VOCALIZE_CORS_ORIGINS, GPU_HOST" else echo " ${ENV_DST} already exists — preserving." fi diff --git a/scripts/smoke.sh b/scripts/smoke.sh index fa8c4a3..e5cf46e 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -15,14 +15,13 @@ IFS=$'\n\t' # Usage: # bash scripts/smoke.sh # VOCALIZE_API_BASE=http://127.0.0.1:8080 bash scripts/smoke.sh -# VOCALIZE_INVITE_TOKEN= VOCALIZE_API_BASE=https://api.example.com bash scripts/smoke.sh +# VOCALIZE_API_BASE=https://api.example.com bash scripts/smoke.sh # # Exits 0 on all-pass; exits 1 with descriptive message on any failure. # Total runtime budget: ~20 seconds (WS step has a 10s timeout). # --------------------------------------------------------------------------- BASE="${VOCALIZE_API_BASE:-http://127.0.0.1:8000}" -TOKEN="${VOCALIZE_INVITE_TOKEN:-}" SMOKE_START=$(date +%s) SID="" @@ -81,10 +80,9 @@ echo "" echo "[2/5] POST ${BASE}/api/sessions..." SESSIONS_RESP=$(curl -fsS --max-time 10 -X POST "${BASE}/api/sessions" \ - -H "X-Invite-Token: ${TOKEN}" \ -H "Content-Type: application/json" \ -d '{"default_lang":"zh","auto_translate_merchant":true}' 2>/dev/null) \ - || fail "[2/5]" "curl failed — is the backend running and invite token correct?" + || fail "[2/5]" "curl failed — is the backend running?" SID=$(echo "$SESSIONS_RESP" | jq -r '.session_id' 2>/dev/null) \ || fail "[2/5]" "response is not valid JSON: ${SESSIONS_RESP}" diff --git a/scripts/stability-24h-driver.py b/scripts/stability-24h-driver.py index 7f25b6d..a0dec12 100644 --- a/scripts/stability-24h-driver.py +++ b/scripts/stability-24h-driver.py @@ -8,19 +8,15 @@ ssh cd /path/to/VocalizeAI export VOCALIZE_API_BASE=https://api.example.com - export VOCALIZE_INVITE_TOKEN= python scripts/stability-24h-driver.py --duration-minutes 1440 \\ --scenario balance_inquiry_en_query --seed direct Environment variables: VOCALIZE_API_BASE Base URL for the Pi orchestrator REST API. Default: https://api.example.com - VOCALIZE_INVITE_TOKEN Required when API base is non-localhost. - Set to match VOCALIZE_INVITE_TOKEN on the Pi. Prerequisites on Pi (MUST be set before the 24h run): VOCALIZE_ENABLE_TEST_FRAMES=1 in /opt/vocalize/.env (or your VOCALIZE_HOME path) - VOCALIZE_INVITE_TOKEN= in /opt/vocalize/.env (or your VOCALIZE_HOME path) After the run, paste the output file into docs/release/24h-stability-evidence.md for the release record. @@ -118,7 +114,6 @@ def _get_merchant_lang(scenario_id: str) -> str: # --------------------------------------------------------------------------- async def one_cycle( api_base: str, - invite_token: str | None, scenario_id: str, seed_id: str, ) -> dict[str, Any]: @@ -131,8 +126,6 @@ async def one_cycle( """ cycle_start = time.time() headers: dict[str, str] = {} - if invite_token: - headers["X-Invite-Token"] = invite_token user_lang = _get_user_lang(scenario_id) merchant_lang = _get_merchant_lang(scenario_id) @@ -263,14 +256,9 @@ async def one_cycle( # --------------------------------------------------------------------------- # Metrics snapshot # --------------------------------------------------------------------------- -async def fetch_metrics_snapshot( - api_base: str, - invite_token: str | None = None, -) -> str: +async def fetch_metrics_snapshot(api_base: str) -> str: """GET {api_base}/metrics and return the Prometheus exposition text.""" headers: dict[str, str] = {} - if invite_token: - headers["X-Invite-Token"] = invite_token try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.get(f"{api_base}/metrics", headers=headers) @@ -370,16 +358,6 @@ def _write_evidence( # --------------------------------------------------------------------------- async def main(args: argparse.Namespace) -> None: api_base = args.api_base - invite_token = args.invite_token - - # Require token for non-localhost - is_localhost = api_base.startswith("http://127.") or api_base.startswith("http://localhost") - if not is_localhost and not invite_token: - log.error( - "VOCALIZE_INVITE_TOKEN is required when VOCALIZE_API_BASE is not localhost.\n" - "Set it in your environment before running the driver." - ) - sys.exit(1) log.info( "stability-24h-driver starting: api_base=%s scenario=%s seed=%s duration_min=%d", @@ -393,7 +371,7 @@ async def main(args: argparse.Namespace) -> None: metrics_snapshots: list[tuple[float, str]] = [] # Initial metrics snapshot - snap = await fetch_metrics_snapshot(api_base, invite_token) + snap = await fetch_metrics_snapshot(api_base) metrics_snapshots.append((time.time(), snap)) log.info("initial /metrics snapshot taken") @@ -403,7 +381,7 @@ async def main(args: argparse.Namespace) -> None: cycle_start = time.time() log.info("--- cycle %d start (remaining: %.0f min) ---", cycle_num, (end - cycle_start) / 60) - result = await one_cycle(api_base, invite_token, args.scenario, args.seed) + result = await one_cycle(api_base, args.scenario, args.seed) session_log.append(result) status = "OK" if result["ok"] else f"FAIL: {result.get('error', '')}" log.info("cycle %d result: %s (%.1fs)", cycle_num, status, result.get("elapsed_s", 0)) @@ -411,7 +389,7 @@ async def main(args: argparse.Namespace) -> None: # Periodic metrics snapshot last_snap_t = metrics_snapshots[-1][0] if (cycle_start - last_snap_t) >= args.metrics_snapshot_interval_hours * 3600: - snap = await fetch_metrics_snapshot(api_base, invite_token) + snap = await fetch_metrics_snapshot(api_base) metrics_snapshots.append((time.time(), snap)) log.info("metrics snapshot taken at cycle %d", cycle_num) @@ -423,7 +401,7 @@ async def main(args: argparse.Namespace) -> None: await asyncio.sleep(min(sleep_s, remaining)) # Final metrics snapshot - snap = await fetch_metrics_snapshot(api_base, invite_token) + snap = await fetch_metrics_snapshot(api_base) metrics_snapshots.append((time.time(), snap)) log.info("final /metrics snapshot taken; %d cycles completed", cycle_num) @@ -484,7 +462,6 @@ def _build_arg_parser() -> argparse.ArgumentParser: # Resolve env vars args.api_base = os.environ.get("VOCALIZE_API_BASE", DEFAULT_API_BASE).rstrip("/") - args.invite_token = os.environ.get("VOCALIZE_INVITE_TOKEN") or None # Default evidence output path if args.evidence_out is None: diff --git a/src/vocalize/config.py b/src/vocalize/config.py index ae75d7e..efccbed 100644 --- a/src/vocalize/config.py +++ b/src/vocalize/config.py @@ -53,9 +53,6 @@ class Config: # 日志配置 log_dir: str = "logs" - # Phase 4 D-08: X-Invite-Token 共享密钥;None = 本地开发模式,跳过邀请验证 - invite_token: str | None = None - @classmethod def from_env(cls) -> "Config": """从环境变量和 .env 文件加载配置。""" @@ -74,7 +71,6 @@ def from_env(cls) -> "Config": ), default_language=os.getenv("DEFAULT_LANGUAGE", cls.default_language), log_dir=os.getenv("LOG_DIR", cls.log_dir), - invite_token=os.getenv("VOCALIZE_INVITE_TOKEN") or None, ) def validate_for_phase( diff --git a/src/vocalize/server/__init__.py b/src/vocalize/server/__init__.py index 1cc2032..8e0de69 100644 --- a/src/vocalize/server/__init__.py +++ b/src/vocalize/server/__init__.py @@ -64,8 +64,6 @@ def create_app() -> FastAPI: Raises RuntimeError at startup when absent in non-localhost mode (closes Host-header spoofing vector D-11 — see CONCERNS.md). In localhost-dev mode the WS URL is derived from the request base_url. - VOCALIZE_INVITE_TOKEN — shared invite secret for POST /api/sessions; - gate disabled in localhost-dev mode when unset (D-08). GPU_HOST / SENSEVOICE_WS_PORT / COSYVOICE_WS_PORT — GPU service targets. """ app = FastAPI(title="VocalizeAI", version="0.1.0") @@ -99,7 +97,7 @@ def create_app() -> FastAPI: allow_origins=cors_origins, allow_credentials=False, allow_methods=["GET", "POST", "DELETE"], # D-10: explicit list; no wildcards - allow_headers=["Content-Type", "X-Invite-Token"], # explicit; covers auth header + allow_headers=["Content-Type"], # explicit; no wildcards ) registry = SessionRegistry() diff --git a/src/vocalize/server/sessions.py b/src/vocalize/server/sessions.py index 28f6704..848386c 100644 --- a/src/vocalize/server/sessions.py +++ b/src/vocalize/server/sessions.py @@ -7,10 +7,9 @@ import logging import os -import secrets from typing import Literal -from fastapi import Depends, FastAPI, Header, HTTPException, Request, status +from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel, Field from vocalize.server.review import register_review_routes @@ -52,45 +51,6 @@ class SetTaskResponse(BaseModel): ok: bool = True -def _check_invite_token( - x_invite_token: str | None = Header(default=None, alias="X-Invite-Token"), -) -> None: - """Verify the shared invite secret on session creation (D-08). - - Gate behaviour: - - If VOCALIZE_INVITE_TOKEN is not configured (localhost-dev mode), the - check is skipped so local development keeps working without any env setup. - - In production (non-localhost host), the token is required and must - match via constant-time comparison to avoid timing oracles (T-04c-02). - - # TODO(v1.x AUTH-01): no rotation; this is a long-lived shared secret. - # Per-user auth replaces this gate in v1.x. - """ - from vocalize.config import get_config - - expected = get_config().invite_token - if expected is None: - return # localhost-dev mode: gate disabled - try: - match = secrets.compare_digest(x_invite_token or "", expected) - except (TypeError, ValueError): - # secrets.compare_digest raises TypeError when either string contains - # non-ASCII characters. This is operator misconfiguration (e.g. a - # Unicode passphrase). Log once and return 401 — do not propagate as - # a 500 which would increment vocalize_error_log_total toward the - # D-05 budget (T-04c-02). - log.warning( - "VOCALIZE_INVITE_TOKEN contains non-ASCII characters; " - "compare_digest raised TypeError — returning 401" - ) - match = False - if not match: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid or missing X-Invite-Token", - ) - - def register_session_routes( app: FastAPI, *, @@ -108,7 +68,6 @@ def register_session_routes( async def create_session( request: Request, payload: CreateSessionRequest | None = None, - _gate: None = Depends(_check_invite_token), ) -> CreateSessionResponse: registry.sweep_stale(max_age_s=1800) payload = payload or CreateSessionRequest() diff --git a/tests/test_app_startup_guards.py b/tests/test_app_startup_guards.py index 9b4cc66..9a7cfe6 100644 --- a/tests/test_app_startup_guards.py +++ b/tests/test_app_startup_guards.py @@ -29,7 +29,6 @@ def _make_client(monkeypatch, *, host: str, ws_base_url: str | None = None) -> T monkeypatch.setenv("VOCALIZE_WS_BASE_URL", ws_base_url) else: monkeypatch.delenv("VOCALIZE_WS_BASE_URL", raising=False) - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) monkeypatch.delenv("VOCALIZE_CORS_ORIGINS", raising=False) reset_config() return TestClient(create_app()) @@ -56,7 +55,6 @@ def test_localhost_without_ws_base_url_succeeds(monkeypatch): def test_non_localhost_without_ws_base_url_raises(monkeypatch): monkeypatch.setenv("VOCALIZE_HOST", "0.0.0.0") monkeypatch.delenv("VOCALIZE_WS_BASE_URL", raising=False) - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) monkeypatch.delenv("VOCALIZE_CORS_ORIGINS", raising=False) reset_config() with pytest.raises(RuntimeError, match="VOCALIZE_WS_BASE_URL is required"): @@ -89,7 +87,7 @@ def test_cors_localhost_origin_allowed(monkeypatch): headers={ "Origin": "http://localhost:3000", "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "X-Invite-Token", + "Access-Control-Request-Headers": "Content-Type", }, ) assert resp.status_code == 200 @@ -124,7 +122,7 @@ def test_cors_preflight_returns_explicit_methods_and_headers(monkeypatch): headers={ "Origin": "http://localhost:3000", "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "Content-Type, X-Invite-Token", + "Access-Control-Request-Headers": "Content-Type", }, ) assert resp.status_code == 200 @@ -135,8 +133,8 @@ def test_cors_preflight_returns_explicit_methods_and_headers(monkeypatch): assert "POST" in allow_methods assert "DELETE" in allow_methods assert "*" not in allow_methods - # Headers must include X-Invite-Token - assert "X-Invite-Token" in allow_headers + # Headers must include Content-Type + assert "Content-Type" in allow_headers # --------------------------------------------------------------------------- @@ -147,7 +145,6 @@ def test_cors_wildcard_origins_raises_at_startup(monkeypatch): monkeypatch.setenv("VOCALIZE_HOST", "127.0.0.1") monkeypatch.setenv("VOCALIZE_CORS_ORIGINS", "*") monkeypatch.delenv("VOCALIZE_WS_BASE_URL", raising=False) - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) reset_config() with pytest.raises(RuntimeError, match="VOCALIZE_CORS_ORIGINS must not contain"): create_app() @@ -161,7 +158,6 @@ def test_cors_wildcard_mixed_with_real_origins_raises(monkeypatch): monkeypatch.setenv("VOCALIZE_HOST", "0.0.0.0") monkeypatch.setenv("VOCALIZE_WS_BASE_URL", "wss://api.example.com") monkeypatch.setenv("VOCALIZE_CORS_ORIGINS", "https://example.com,*") - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) reset_config() with pytest.raises(RuntimeError, match="VOCALIZE_CORS_ORIGINS must not contain"): create_app() diff --git a/tests/test_invite_token_gate.py b/tests/test_invite_token_gate.py deleted file mode 100644 index 9682381..0000000 --- a/tests/test_invite_token_gate.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tests for the X-Invite-Token gate on POST /api/sessions (D-08). - -Covers: - - Localhost-dev mode (no token configured): gate disabled, POST succeeds. - - Production mode (token configured): gate enforces header presence + value. - - SetTaskRequest.task length bound (max_length=2000). - - Source assertion: _check_invite_token uses secrets.compare_digest. -""" -from __future__ import annotations - -import inspect - -import pytest -from fastapi.testclient import TestClient - -from vocalize.config import reset_config -from vocalize.server import create_app - - -def _client(monkeypatch) -> TestClient: - """Build a TestClient for the full production app. - - VOCALIZE_HOST is forced to 127.0.0.1 so create_app() does not raise the - startup RuntimeError for missing VOCALIZE_WS_BASE_URL (Task 2). - VOCALIZE_CORS_ORIGINS and VOCALIZE_WS_BASE_URL are cleared to prevent a - CI environment with VOCALIZE_CORS_ORIGINS='*' from raising RuntimeError - inside create_app() and masking the invite-token test failures. - """ - monkeypatch.setenv("VOCALIZE_HOST", "127.0.0.1") - monkeypatch.delenv("VOCALIZE_CORS_ORIGINS", raising=False) - monkeypatch.delenv("VOCALIZE_WS_BASE_URL", raising=False) - reset_config() - return TestClient(create_app()) - - -# --------------------------------------------------------------------------- -# Test 1: No token configured → localhost-dev gate disabled, POST succeeds -# --------------------------------------------------------------------------- - -def test_no_token_configured_allows_session_creation(monkeypatch): - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) - client = _client(monkeypatch) - resp = client.post("/api/sessions") - assert resp.status_code == 200 - assert "session_id" in resp.json() - - -# --------------------------------------------------------------------------- -# Test 2: Token configured, header missing → 401 -# --------------------------------------------------------------------------- - -def test_missing_header_returns_401_when_token_configured(monkeypatch): - monkeypatch.setenv("VOCALIZE_INVITE_TOKEN", "abc123") - client = _client(monkeypatch) - resp = client.post("/api/sessions") - assert resp.status_code == 401 - assert resp.json()["detail"] == "invalid or missing X-Invite-Token" - - -# --------------------------------------------------------------------------- -# Test 3: Token configured, wrong header value → 401 -# --------------------------------------------------------------------------- - -def test_wrong_header_value_returns_401(monkeypatch): - monkeypatch.setenv("VOCALIZE_INVITE_TOKEN", "abc123") - client = _client(monkeypatch) - resp = client.post("/api/sessions", headers={"X-Invite-Token": "wrong-value"}) - assert resp.status_code == 401 - assert resp.json()["detail"] == "invalid or missing X-Invite-Token" - - -# --------------------------------------------------------------------------- -# Test 4: Token configured, correct header value → 200 + session_id -# --------------------------------------------------------------------------- - -def test_correct_header_value_allows_session_creation(monkeypatch): - monkeypatch.setenv("VOCALIZE_INVITE_TOKEN", "abc123") - client = _client(monkeypatch) - resp = client.post("/api/sessions", headers={"X-Invite-Token": "abc123"}) - assert resp.status_code == 200 - data = resp.json() - assert "session_id" in data - assert data["session_id"] - - -# --------------------------------------------------------------------------- -# Test 5: SetTaskRequest.task > 2000 chars → 422 -# --------------------------------------------------------------------------- - -def test_task_length_over_2000_returns_422(monkeypatch): - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) - client = _client(monkeypatch) - # Create a session first - sess_resp = client.post("/api/sessions") - assert sess_resp.status_code == 200 - session_id = sess_resp.json()["session_id"] - - oversized_task = "x" * 2001 - resp = client.post( - f"/api/sessions/{session_id}/task", - json={"task": oversized_task}, - ) - assert resp.status_code == 422 - - -# --------------------------------------------------------------------------- -# Test 6: SetTaskRequest.task == 2000 chars → 200 -# --------------------------------------------------------------------------- - -def test_task_length_exactly_2000_is_accepted(monkeypatch): - monkeypatch.delenv("VOCALIZE_INVITE_TOKEN", raising=False) - client = _client(monkeypatch) - sess_resp = client.post("/api/sessions") - assert sess_resp.status_code == 200 - session_id = sess_resp.json()["session_id"] - - max_task = "x" * 2000 - resp = client.post( - f"/api/sessions/{session_id}/task", - json={"task": max_task}, - ) - assert resp.status_code == 200 - - -# --------------------------------------------------------------------------- -# Test 7: Source assertion — _check_invite_token uses secrets.compare_digest -# --------------------------------------------------------------------------- - -def test_check_invite_token_uses_timing_safe_comparison(): - from vocalize.server.sessions import _check_invite_token # noqa: PLC0415 - - source = inspect.getsource(_check_invite_token) - assert "secrets.compare_digest" in source, ( - "_check_invite_token must use secrets.compare_digest for timing-safe " - "comparison (T-04c-02)" - ) - - -# --------------------------------------------------------------------------- -# Test 8: Non-ASCII invite token → 401 (not 500) — regression for WR-04 -# --------------------------------------------------------------------------- - -def test_non_ascii_invite_token_returns_401_not_500(monkeypatch): - """secrets.compare_digest raises TypeError when VOCALIZE_INVITE_TOKEN contains - non-ASCII characters (e.g. a Unicode passphrase set by the operator). - - HTTP headers are ASCII-only, so the client always sends an ASCII (or empty) - token value; the non-ASCII value lives in the expected side only. - The gate must catch TypeError and return 401, not propagate a 500 that would - increment vocalize_error_log_total toward the D-05 error budget (T-04c-02). - """ - monkeypatch.setenv("VOCALIZE_INVITE_TOKEN", "パスワード123") - client = _client(monkeypatch) - # Send an ASCII token value; the server side holds the non-ASCII expected value. - # compare_digest raises TypeError when either argument is non-ASCII. - resp = client.post("/api/sessions", headers={"X-Invite-Token": "ascii-token"}) - assert resp.status_code == 401, ( - f"Expected 401 for non-ASCII configured token, got {resp.status_code}" - ) - assert resp.json()["detail"] == "invalid or missing X-Invite-Token" From 06d1bbbb6764c289db61878c704db863601f5bad Mon Sep 17 00:00:00 2001 From: DGPisces Date: Tue, 19 May 2026 11:02:08 -0700 Subject: [PATCH 4/5] chore(13): drop noreply email entirely; route contact through GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even GitHub-noreply addresses surface a numeric account ID and create a mail relay. Per maintainer preference, the public repo should not expose any email at all — security and general contact route through GitHub's own channels. - pyproject.toml: drop `email = ""` from `authors`; PEP 621 treats email as optional on author entries. - SECURITY.md: rewrite "Reporting a vulnerability" to make GitHub Private Vulnerability Reporting the sole private channel. When PVR is not yet enabled, instruct reporters to open a public issue titled exactly "Private security contact request" (no technical details) so the maintainer can respond with a private channel out-of-band. The shared-token recovery runbook lower in the file was already cleaned up in the gate-removal commit (`63f251a`). Net effect: `git grep -E "gaodingyun|noreply\.github\.com|40358663"` on the working tree returns zero hits. Phase: v1.2 / 13-working-tree-cleanse (follow-up to 5d16753) --- SECURITY.md | 29 ++++++++++++++++------------- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 1fdbdf9..fd5ecd4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,19 +8,22 @@ shipped with the next release tag. ## Reporting a vulnerability -**Primary channel: GitHub Private Vulnerability Reporting.** Once enabled on -the public repository (post-Phase-9 setting), open a private security advisory -at Security → Advisories → New draft advisory. This is the preferred route — it -keeps the report private and tracked alongside the codebase. - -**Backup channel: email.** Until Private Vulnerability Reporting is enabled, or -if you cannot use it, email -[40358663+DGPisces@users.noreply.github.com](mailto:40358663+DGPisces@users.noreply.github.com) with subject prefix -`[VocalizeAI Security]`. - -Do **not** file a public GitHub issue for security topics. Disclosure -follows responsible-disclosure norms: a private 90-day window by default, -with earlier coordinated release if an active exploit is confirmed. +**Channel: GitHub Private Vulnerability Reporting.** Open a private security +advisory at the repository's Security → Advisories → New draft advisory. This +is the only private reporting channel and keeps the report tracked alongside +the codebase. + +If GitHub Private Vulnerability Reporting is not yet enabled on the public +repository, open a public GitHub issue titled exactly +**"Private security contact request"** with no technical details; a +maintainer will respond there with a private channel for follow-up. Do +**not** include vulnerability details, repro steps, or impact information +in that initial public issue. + +Do **not** file a public GitHub issue with the vulnerability details for any +security topic. Disclosure follows responsible-disclosure norms: a private +90-day window by default, with earlier coordinated release if an active +exploit is confirmed. Please include: - Description of the vulnerability and affected component diff --git a/pyproject.toml b/pyproject.toml index d834004..77394c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "vocalize-ai" version = "0.1.0" description = "Bilingual (zh/en) universal phone-task AI agent" -authors = [{name = "DGPisces", email = "40358663+DGPisces@users.noreply.github.com"}] +authors = [{name = "DGPisces"}] license = "Apache-2.0" readme = "README.md" requires-python = ">=3.11" From 0b6d88ec2113d88b0141a62a4e07702f56630303 Mon Sep 17 00:00:00 2001 From: DGPisces Date: Tue, 19 May 2026 11:09:43 -0700 Subject: [PATCH 5/5] chore(13): drop SECURITY.md; route all contact through GitHub Issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VocalizeAI is a self-deploy project — every operator runs their own backend on their own infrastructure, so there is no central instance to defend and no maintainer-side embargo window that needs a private disclosure channel. Each operator is responsible for their own deployment's security; security-relevant findings should be public so every operator can pick up the fix. Removes: - SECURITY.md (its content has been superseded by the self-deploy posture; the threat-model summary it carried is already mirrored in docs/architecture.md "Security Posture"). - install/public-allowlist.md: SECURITY.md entry. Updated references → GitHub Issues: - README.md / README.zh-CN.md "Security" section: rewrite to explain the self-deploy posture and direct reporters to GitHub Issues. - CONTRIBUTING.md: (a) security-related contribution intro now says "flag in the PR description"; (b) "Issue triage / vulnerability reporting" section says self-deploy ⇒ all reports go via Issues; (c) Code-of-Conduct dispute escalation now routes through Issues instead of email-via-SECURITY. - docs/architecture.md: drop the "see SECURITY.md" pointers in the REST surface, Security Posture intro, and Further Reading list. - .github/ISSUE_TEMPLATE/bug_report.yml: the "for security vulnerabilities, see SECURITY.md" preamble becomes "self-deploy ⇒ file here". Note: also disabled GitHub Private Vulnerability Reporting via `gh api -X DELETE repos/.../private-vulnerability-reporting`. It was briefly enabled earlier in this session before the self-deploy decision was confirmed. CHANGELOG.md retains the historical line listing "OSS launch with SECURITY.md, ..." — that is a record of what shipped in a past tag and is intentionally left intact. Phase: v1.2 / 13-working-tree-cleanse (follow-up) --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++- CONTRIBUTING.md | 18 ++++-- README.md | 9 ++- README.zh-CN.md | 6 +- SECURITY.md | 93 --------------------------- docs/architecture.md | 10 +-- install/public-allowlist.md | 1 - 7 files changed, 34 insertions(+), 111 deletions(-) delete mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f8fd229..c10026e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -5,9 +5,11 @@ body: - type: markdown attributes: value: | - Thank you for reporting a bug! Before submitting, please search existing - issues to avoid duplicates. For security vulnerabilities, see - [SECURITY.md](../../SECURITY.md) instead of filing a public issue. + Thank you for reporting a bug! Before submitting, please search + existing issues to avoid duplicates. VocalizeAI is a self-deploy + project — every operator runs their own backend on their own + infrastructure — so all reports (including security-relevant ones) + go through GitHub Issues here. - type: textarea id: what-happened diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fcebd5..4341644 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,10 @@ VocalizeAI follows an out-of-band contribution model (D-16): 3. This keeps the private repo as the single source of truth and preserves the `.planning/` workflow. -If your contribution is security-related, see [SECURITY.md](SECURITY.md) first. +VocalizeAI is a self-deploy project, so there is no centrally hosted +instance to defend. Security-relevant fixes go through the same PR flow +as any other contribution; flag the concern in the PR description so +reviewers know to prioritise it. ## Setting up the dev environment @@ -77,8 +80,11 @@ directly to `main`. ## Issue triage / vulnerability reporting - Ordinary bugs and feature requests: file a GitHub issue. -- Security vulnerabilities: follow the process in [SECURITY.md](SECURITY.md). - Do NOT file public GitHub issues for security topics. +- Security-relevant findings: file a GitHub issue here. VocalizeAI is a + self-deploy project (no centrally hosted instance), so there is no + separate private disclosure channel. Each operator is responsible for + their own deployment; report findings publicly so every operator can + pick up the fix. ## CI behavior for external PRs @@ -110,9 +116,9 @@ VocalizeAI's CI pipeline has two tiers depending on where your PR originates: VocalizeAI does not adopt a formal Code of Conduct at this stage. Standard professional conduct is expected: be respectful, assume good faith, focus on -the technical content. Disputes that cannot be resolved in-thread escalate to -the maintainer via email (see SECURITY.md for the contact channel; for -non-security disputes use the same address). +the technical content. Disputes that cannot be resolved in-thread escalate +by opening a GitHub issue with a clear summary; the maintainer will follow +up there. ## License diff --git a/README.md b/README.md index 840978f..ed2aec1 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,13 @@ follow code style, and submit contributions. Issue + PR templates live under `.g ## Security -See [SECURITY.md](SECURITY.md) for the vulnerability reporting channel, -threat model summary, and emergency rollback procedure. +VocalizeAI is self-deploy: every operator runs their own backend on +their own infrastructure, and there is no centrally hosted instance to +defend. Report any security-relevant finding via GitHub Issues — same +as any other bug — so every operator can pick up the fix. Self-deploy +operators are responsible for restricting reachability at the network +or proxy layer (Cloudflare Access, VPN, reverse-proxy auth, etc.). +Per-user authentication is v1.x scope (requirement `AUTH-01`). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 80a9c6b..570dcf1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -185,7 +185,11 @@ Issue 模板和 PR 模板位于 `.github/` 目录。 ## 安全 -漏洞上报渠道、威胁模型摘要和紧急回滚流程,见 [SECURITY.md](SECURITY.md)。 +VocalizeAI 是自部署项目 —— 每个运维者在自己的基础设施上跑自己的后端, +没有"统一托管实例"可被攻击。任何安全相关发现请通过 GitHub Issues 上报, +与普通 bug 同一通道,这样每个运维者都能拿到修复。自部署运维者负责在网络/ +代理层(Cloudflare Access、VPN、反向代理认证等)限制可达性。每用户认证属于 +v1.x 范畴(需求 `AUTH-01`)。 ## 许可证 diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index fd5ecd4..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,93 +0,0 @@ -# Security Policy - -## Supported versions - -Only the most recent release tag receives security fixes. v1.x is -pre-release development — fixes are applied on the main branch and -shipped with the next release tag. - -## Reporting a vulnerability - -**Channel: GitHub Private Vulnerability Reporting.** Open a private security -advisory at the repository's Security → Advisories → New draft advisory. This -is the only private reporting channel and keeps the report tracked alongside -the codebase. - -If GitHub Private Vulnerability Reporting is not yet enabled on the public -repository, open a public GitHub issue titled exactly -**"Private security contact request"** with no technical details; a -maintainer will respond there with a private channel for follow-up. Do -**not** include vulnerability details, repro steps, or impact information -in that initial public issue. - -Do **not** file a public GitHub issue with the vulnerability details for any -security topic. Disclosure follows responsible-disclosure norms: a private -90-day window by default, with earlier coordinated release if an active -exploit is confirmed. - -Please include: -- Description of the vulnerability and affected component -- Steps to reproduce -- Potential impact assessment - -## Threat model summary - -### Access posture - -VocalizeAI is **self-deploy**: each operator runs their own backend on -their own infrastructure and is responsible for restricting access to it -(reverse-proxy auth, VPN, Cloudflare Access policy, etc.). The codebase -does not ship a built-in authentication gate. Per-user authentication is -v1.x scope (requirement `AUTH-01`). - -### Network layer - -- TLS termination at the Cloudflare edge via Universal SSL. -- A `cloudflared` tunnel connects the Pi orchestrator to the Cloudflare - edge; all external traffic enters through Cloudflare before reaching - the backend. - -### Backend security controls - -- **CORS**: single allowed origin (set via `VOCALIZE_CORS_ORIGINS`, e.g. - `https://vocalize.example.com`) in production; localhost origins preserved in dev mode via - `VOCALIZE_HOST` env-conditional config. `allow_methods` restricted to - `["GET", "POST", "DELETE"]`. -- **WS base URL enforcement**: server raises at startup if - `VOCALIZE_HOST != "127.0.0.1"` and `VOCALIZE_WS_BASE_URL` is unset, - preventing Host-header spoofing. -- **Task length bound**: `SetTaskRequest.task` has a `max_length=2000` - field constraint to limit prompt-injection surface area. - -## Known limitations - -### No built-in authentication in v1 - -VocalizeAI v1 ships no request-level auth on `POST /api/sessions` or -the WebSocket endpoint. Self-deploy operators MUST restrict reachability -at the network or proxy layer until per-user authentication lands (v1.x -scope; requirement `AUTH-01`). - -### No prompt-injection mitigation - -User-supplied task descriptions are passed to the LLM without sanitization. -This is a known gap flagged for v1.x scope. - -## Emergency rollback for leaked secret in public mirror - -If a secret (API key, tunnel ID, third-party token) is accidentally -committed to the public repo: - -1. **Rotate the leaked secret immediately** per the secret's own rotation - procedure (provider dashboard for API keys, `cloudflared` rotate for - tunnel credentials, etc.). - -2. **Force-push the public `main`** to a re-sanitized commit by re-running - the `sync-private-to-public` skill against a clean private tree. This - overwrites the public history and removes the leaked commit. - -3. **Contact GitHub Support** to purge cached references: - https://support.github.com/contact/github-private-information-removal - -4. **Audit the blast radius**: determine whether the secret was used by - any unauthorized party before rotation. diff --git a/docs/architecture.md b/docs/architecture.md index fd8463d..b080669 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -376,8 +376,8 @@ See: `src/vocalize/server/frames.py` The REST API is mounted at `/api/sessions`. The backend ships no request-level authentication in v1; self-deploy operators restrict -reachability at the network or proxy layer (per-user auth is v1.x scope — -see SECURITY.md and requirement `AUTH-01`). +reachability at the network or proxy layer (per-user auth is v1.x +scope — requirement `AUTH-01`). See: `src/vocalize/server/sessions.py`, `src/vocalize/server/health.py` @@ -566,8 +566,9 @@ See: `src/vocalize/server/ws.py`, `frontend/lib/audio*`, `frontend/components/Br ## Security Posture The security controls relevant to the architecture are documented here for -API consumers and security researchers. For the full threat model and disclosure -channel, see [SECURITY.md](../SECURITY.md). +API consumers and security researchers. VocalizeAI is a self-deploy +project (no centrally hosted instance); report security-relevant findings +via GitHub Issues — same channel as any other bug. ### Authentication (D-08, retired) @@ -612,4 +613,3 @@ See: `src/vocalize/server/ws.py`, `src/vocalize/server/sessions.py` - **[docs/deploy/local.md](docs/deploy/local.md)** — Mac/Linux dev environment setup and env-var reference - **[docs/deploy/pi.md](docs/deploy/pi.md)** — End-to-end Pi production deployment runbook - **[CONTRIBUTING.md](../CONTRIBUTING.md)** — Contributor flow, code style, commit conventions -- **[SECURITY.md](../SECURITY.md)** — Vulnerability reporting, threat model, emergency rollback diff --git a/install/public-allowlist.md b/install/public-allowlist.md index 8664e33..90eae26 100644 --- a/install/public-allowlist.md +++ b/install/public-allowlist.md @@ -15,7 +15,6 @@ Paths are anchored at repo root. Used by `scripts/build-public-filelist.py`. - CHANGELOG.md - CODEOWNERS - LICENSE -- SECURITY.md - CONTRIBUTING.md - pyproject.toml - .env.example