diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index b0c246e..557b732 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest concurrency: deploy-group # optional: ensure only one action runs at a time steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: superfly/flyctl-actions/setup-flyctl@master - run: flyctl deploy --remote-only env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..912bc47 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Tests + +# The deploy workflow (fly-deploy.yml) has NO test gate: it deploys every push +# to main. This workflow is the gate. Keep it required on main so a red suite +# cannot reach production. + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + # Exact pin, not a floating major: setup-uv's newest release is v9.0.0 + # but its floating major tags stop at v7, so `@v9` does not resolve. + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + + # Interpreter comes from pyproject's requires-python, never a hardcoded + # version that can drift away from the project. + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --frozen + + - name: Run tests + env: + # A fake key is correct: the suite asserts on wiring and accepts a + # Deepgram 401. It must never need a real credential to run, and it + # must never need APP_ACCESS_TOKEN either, since an unset token is the + # "everyone is privileged" path the tests rely on. + DEEPGRAM_API_KEY: ci-test-key + run: uv run pytest tests/ -q diff --git a/.github/workflows/update-deepgram-sdk.yaml b/.github/workflows/update-deepgram-sdk.yaml index edd5854..9d724ad 100644 --- a/.github/workflows/update-deepgram-sdk.yaml +++ b/.github/workflows/update-deepgram-sdk.yaml @@ -1,105 +1,80 @@ name: Update Deepgram SDK +# Opens a PR when a newer deepgram-sdk is published. +# +# The pin lives in exactly ONE place: [project] dependencies in pyproject.toml, +# resolved through uv.lock. DO NOT reintroduce requirements.txt here. The +# previous version of this workflow edited requirements.txt and then ran +# `pip install -r requirements.txt`, which pulled the dead v1 Flask stack +# (Flask 3.0.0, deepgram-sdk v3.10.1) on Python 3.10 against a project that +# requires >=3.12. It could only no-op or open a broken PR, and it left roughly +# a dozen abandoned update-deepgram-sdk-v* branches on the remote. + on: schedule: - - cron: "0 0 * * 1" # Runs every Monday at midnight + - cron: "0 0 * * 1" # Every Monday at midnight UTC workflow_dispatch: +permissions: + contents: write + pull-requests: write + jobs: check-update: runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - - name: Set up Python - uses: actions/setup-python@v4 + - name: Install uv + # Exact pin, not a floating major: setup-uv's newest release is v9.0.0 + # but its floating major tags stop at v7, so `@v9` does not resolve. + uses: astral-sh/setup-uv@v9.0.0 with: - python-version: "3.10" - - - name: Install required tools - run: sudo apt-get install -y jq curl + enable-cache: true - - name: Get current Deepgram Python SDK version - id: get-deepgram-version - run: | - DEEPGRAM_VERSION=$(curl -s https://api.github.com/repos/deepgram/deepgram-python-sdk/releases/latest | jq -r '.tag_name') - echo "version=$DEEPGRAM_VERSION" >> $GITHUB_ENV + # Read the interpreter from pyproject's requires-python rather than + # hardcoding a version that can drift away from the project. + - name: Set up Python + run: uv python install - - name: Check installed Deepgram SDK version - id: check-installed-version + - name: Resolve current and latest deepgram-sdk + id: versions run: | - INSTALLED_VERSION=$(pip show deepgram-sdk | grep Version | cut -d' ' -f2) - echo "installed_version=$INSTALLED_VERSION" >> $GITHUB_ENV + set -euo pipefail + CURRENT=$(uv run python -c "import importlib.metadata as m; print(m.version('deepgram-sdk'))") + LATEST=$(curl -fsSL https://pypi.org/pypi/deepgram-sdk/json | jq -r '.info.version') + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + echo "Current: $CURRENT Latest: $LATEST" - - name: Config git - env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_ACCESS_TOKEN }} - shell: bash + - name: Bump the pin and lockfile + if: steps.versions.outputs.current != steps.versions.outputs.latest run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global init.defaultBranch main - git config --global pull.rebase true - git config --global url."https://git:$GITHUB_TOKEN@github.com".insteadOf "https://github.com" + set -euo pipefail + uv add "deepgram-sdk==${{ steps.versions.outputs.latest }}" + uv lock - - name: Compare versions and update if necessary + - name: Run tests against the new version + if: steps.versions.outputs.current != steps.versions.outputs.latest env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_ACCESS_TOKEN }} - run: | - LATEST_VERSION=${{ env.version }} - INSTALLED_VERSION=${{ env.installed_version }} + # A fake key is correct here: the suite asserts on wiring and accepts + # a Deepgram 401. It must never need a real credential to run. + DEEPGRAM_API_KEY: ci-test-key + run: uv run pytest tests/ -q - if [ "$LATEST_VERSION" != "$INSTALLED_VERSION" ]; then - echo "Updating Deepgram SDK from $INSTALLED_VERSION to $LATEST_VERSION" - pip install deepgram-sdk==$LATEST_VERSION - - # Update requirements.txt - sed -i "s/deepgram-sdk==.*/deepgram-sdk==$LATEST_VERSION/" requirements.txt - git add requirements.txt - - # Create a new branch and commit changes - BRANCH_NAME="update-deepgram-sdk-$LATEST_VERSION" - git checkout -b "$BRANCH_NAME" - git commit -m "chore: update Deepgram SDK to $LATEST_VERSION" - - # Push the new branch and create a PR - git push origin "$BRANCH_NAME" - gh pr create --title "chore: update Deepgram SDK to $LATEST_VERSION" --body "This PR updates the Deepgram SDK to version $LATEST_VERSION." --base "main" --head "$BRANCH_NAME" - else - echo "Deepgram SDK is up to date" - fi - - - name: Install dependencies - run: pip install -r requirements.txt - - - name: Install dev dependencies - run: pip install -r requirements-dev.txt - - - name: Run tests - env: - DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} - run: pytest tests/ - - - name: Notify on failure - if: failure() - uses: slackapi/slack-github-action@v1.23.0 + - name: Open a PR + if: steps.versions.outputs.current != steps.versions.outputs.latest + uses: peter-evans/create-pull-request@v8 with: - payload: | - { - "text": "The tests have FAILED for ${{ github.repository }}." - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + branch: chore/deepgram-sdk-${{ steps.versions.outputs.latest }} + delete-branch: true + title: "chore: update deepgram-sdk to ${{ steps.versions.outputs.latest }}" + commit-message: "chore: update deepgram-sdk to ${{ steps.versions.outputs.latest }}" + body: | + Bumps `deepgram-sdk` from `${{ steps.versions.outputs.current }}` + to `${{ steps.versions.outputs.latest }}` in `pyproject.toml` and + `uv.lock`. - - name: Notify on success - if: success() - uses: slackapi/slack-github-action@v1.23.0 - with: - payload: | - { - "text": "The tests have passed for ${{ github.repository }}." - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + The test suite passed against the new version in CI. A major bump + can still change the streaming API shape, so check + `.planning/research/STACK.md` before merging one. diff --git a/.gitignore b/.gitignore index 99649c6..ee88d90 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ __pycache__/ .python-version .env outputs/* + +# Generated evidence from scripts/. Regenerable; keep deliberately, not by accident. +scripts/*.csv + +# Claude Code local overrides +.claude/settings.local.json diff --git a/README.md b/README.md index e54c989..92e7b7a 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,84 @@ Copy `sample.env` to `.env`: ```env DEEPGRAM_API_KEY=your_key_here +ELEVENLABS_API_KEY= # optional, only for the ElevenLabs TTS provider ``` ---- +### Security and abuse limits + +This app holds a Deepgram key server-side and calls Deepgram on behalf of +whoever hits it, so these bound what a caller can do: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `APP_ACCESS_TOKEN` | *(unset)* | Shared secret that LIFTS the anonymous limits. Unset means every caller is privileged, which is what local dev wants. | +| `REQUIRE_AUTH` | *(unset)* | When true, the app refuses to boot without `APP_ACCESS_TOKEN`, so a deploy cannot silently come up with its limits disabled. Set to `true` in `fly.toml`. | +| `ANON_ACCESS` | `true` | Set false to require a token outright. Leaving it true is the point: the demo has to work for a visitor. | +| `ANON_RATE_LIMIT` / `ANON_RATE_WINDOW_S` | `15` / `300` | Per-IP request budget. | +| `ANON_GLOBAL_LIMIT` / `ANON_GLOBAL_WINDOW_S` | `300` / `3600` | Budget across ALL anonymous traffic. This is the control that actually caps the bill. | +| `ANON_MAX_CONCURRENT_STREAMS` | `3` | Simultaneous untokened streams. | +| `ANON_MAX_STREAM_SECONDS` | `120` | Wall-clock cap on an untokened stream. | +| `ANON_MAX_TTS_CHARS` / `ANON_MAX_UPLOAD_BYTES` | `400` / `10MB` | Tighter per-request caps for the anonymous tier. | +| `MAX_TTS_CHARS` / `MAX_UPLOAD_BYTES` | `2000` / `100MB` | Per-request caps for the privileged tier. | +### Access is a budget, not a wall + +This app is both a capability demo and a diagnostic tool, so an anonymous +visitor gets a **working** app, mic streaming included. A dead UI demos nothing. +The token raises the limits rather than unlocking the door. + +| | Anonymous | With a token | +|---|---|---| +| Load the UI | yes | yes | +| Mic / file / batch / TTS | yes | yes | +| Requests | 15 per IP per 5 min, and 300/hour across all anonymous traffic | unlimited | +| Concurrent streams | 3 | unlimited | +| Stream length | 120s | unlimited | +| TTS text | 400 chars | 2000 chars | +| Upload | 10MB | 100MB | +| Audio by URL | must report a `Content-Length` within the upload cap | any | + +**The global hourly ceiling is the load-bearing control, not the per-IP limit.** +IPs are cheap, so per-IP only stops one person hammering; the global cap is what +makes a distributed attempt pointless. Do not remove it on the grounds that +per-IP covers it. + +**The SocketIO stream is metered exactly like the HTTP API.** Mic and file +streaming spend the Deepgram key over that socket. Do not exempt it because +"only our own page opens it": the browser is attacker-controlled, so there is no +way to authenticate the page as distinct from a user, and anyone who loads the +public page can read its network calls and replay them. + +**An anonymous caller may not hand Deepgram a URL of unknown length.** Uploads +are capped as they stream, but one allowed request pointing at a ten-hour +recording is unbounded spend the rate limit cannot see, so `/transcribe` HEADs +the URL first and refuses a missing or oversized `Content-Length`. + +Three ways to send the token, in precedence order: + +```bash +curl -H "X-App-Token: $APP_ACCESS_TOKEN" ... # canonical +curl -H "Authorization: Bearer $APP_ACCESS_TOKEN" ... +curl "https://…/files/clip.wav?token=$APP_ACCESS_TOKEN" # for