From e09950bc1f91ef79a6380f60bd955fc07494fc1c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 06:45:48 +0000 Subject: [PATCH 01/18] chore(deps): update actions/checkout action to v7 --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-health.yml | 8 ++++---- .github/workflows/docker-publish.yml | 4 ++-- .github/workflows/release.yml | 10 +++++----- .github/workflows/scorecard.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/winget-scripts-test.yml | 4 ++-- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b039e4d..053ef9db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -112,7 +112,7 @@ jobs: working-directory: dashboard steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -145,7 +145,7 @@ jobs: working-directory: bridge steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -175,7 +175,7 @@ jobs: working-directory: bridge-app steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -209,7 +209,7 @@ jobs: working-directory: kiosk/wifi-portal steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -229,7 +229,7 @@ jobs: contents: read steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b404f763..35856769 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,7 +26,7 @@ jobs: language: [python, javascript-typescript] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 diff --git a/.github/workflows/dependency-health.yml b/.github/workflows/dependency-health.yml index 48dce41f..47ae65a6 100644 --- a/.github/workflows/dependency-health.yml +++ b/.github/workflows/dependency-health.yml @@ -18,7 +18,7 @@ jobs: run: working-directory: bridge steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -45,7 +45,7 @@ jobs: stagelinq_version: ${{ steps.versions.outputs.stagelinq }} alphatheta_version: ${{ steps.versions.outputs.alphatheta }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -83,7 +83,7 @@ jobs: stagelinq_stale: ${{ steps.check.outputs.stagelinq_stale }} alphatheta_stale: ${{ steps.check.outputs.alphatheta_stale }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 @@ -139,7 +139,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Ensure dependency-drift label exists run: gh label create dependency-drift --description "Upstream dependency API drift" --color "d93f0b" 2>/dev/null || true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 75ab4927..1c8c60e6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -25,7 +25,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 @@ -68,7 +68,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fb04986..fd1ba68e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Reject malformed tags env: TAG: ${{ github.ref_name }} @@ -66,7 +66,7 @@ jobs: echo "Skipping ${{ matrix.platform }} (not in: $PLATFORMS)" fi - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: steps.filter.outputs.skip != 'true' - name: Set up Node.js @@ -144,7 +144,7 @@ jobs: name: Bundle deploy scripts runs-on: ubuntu-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Create deploy bundle run: | @@ -172,7 +172,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -233,7 +233,7 @@ jobs: env: REF_NAME: ${{ github.ref_name }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Compute semver from tag shell: pwsh diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a87fb22b..9a13d534 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -27,7 +27,7 @@ jobs: id-token: write # publish_results signs the upload with an OIDC token steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 8ce81831..cefd0b1a 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,7 +50,7 @@ jobs: cache-scope: ci-web steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/winget-scripts-test.yml b/.github/workflows/winget-scripts-test.yml index ea170155..86ec6980 100644 --- a/.github/workflows/winget-scripts-test.yml +++ b/.github/workflows/winget-scripts-test.yml @@ -21,7 +21,7 @@ jobs: name: Bash unit tests (bats) runs-on: ubuntu-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install bats run: sudo apt-get update && sudo apt-get install -y bats - name: Run bats tests @@ -31,7 +31,7 @@ jobs: name: PowerShell unit tests (Pester) runs-on: windows-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Ensure Pester v5 is installed shell: pwsh run: | From e360d16f24e94a6b09150274801da14f90afdd42 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 15:46:22 -0700 Subject: [PATCH 02/18] [AI Engine] Browser-redirect OAuth flow when public registration program ships (#701) * docs(ai): clarify hosted OAuth provider gate Co-Authored-By: Codex * docs(ai): align OAuth dependency gate Co-Authored-By: Codex --------- Co-authored-by: Codex --- docs/superpowers/specs/2026-05-24-admin-ai-oauth-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-24-admin-ai-oauth-design.md b/docs/superpowers/specs/2026-05-24-admin-ai-oauth-design.md index 83ba8e05..77d0c755 100644 --- a/docs/superpowers/specs/2026-05-24-admin-ai-oauth-design.md +++ b/docs/superpowers/specs/2026-05-24-admin-ai-oauth-design.md @@ -758,9 +758,11 @@ Each item below is one GitHub issue, milestone `AI Engine Back-end Redesign`. **Scope:** Add OAuth-redirect adapter pattern (state cookie, callback route, encrypted refresh token storage). Reuse existing Beatport PKCE machinery as a template. New `adapters/_oauth.py` per provider. -**Acceptance criteria:** Spec gates implementation start until a public registration program exists. Tracking only until then. +**Acceptance criteria:** Spec gates implementation start until a provider supports the deployment model WrzDJ needs. Tracking only until then. -**Depends on:** External — public provider registration program. +**Provider status (2026-10-03):** OpenAI now documents ChatGPT-plan token sharing for open-source and locally hosted apps, including dynamic OAuth client registration. That program is not yet a trigger for WrzDJ's server-side callback: OpenAI's documented OSS flow requires a `127.0.0.1` loopback callback, while paid or remotely hosted apps must submit an interest form. Reassess when a provider publishes a documented registration path that supports a remotely hosted service callback, or when WrzDJ adopts a local client that can own the loopback callback. See [OpenAI's OSS token-sharing overview](https://developers.openai.com/siwc/token-sharing-open-source) and [registration and sign-in flow](https://developers.openai.com/siwc/token-sharing-open-source/sign-in). + +**Depends on:** External — a provider registration flow that supports remotely hosted callbacks, or a WrzDJ local client that can own the loopback callback. --- From b8a3b18f775b7ea9bcde2618f43e390b45af8aa0 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 15:49:47 -0700 Subject: [PATCH 03/18] refactor: split god-components / oversized routers along domain boundaries (no behavior change) (#702) * refactor(events): isolate CSV export routes Co-Authored-By: Codex * fix(events): encode export filenames safely Co-Authored-By: Codex --------- Co-authored-by: Codex --- server/app/api/events.py | 79 +++++--------------------------- server/app/api/events_exports.py | 73 +++++++++++++++++++++++++++++ server/tests/test_events.py | 21 +++++++++ 3 files changed, 105 insertions(+), 68 deletions(-) create mode 100644 server/app/api/events_exports.py diff --git a/server/app/api/events.py b/server/app/api/events.py index 49632f7d..88f6c30b 100644 --- a/server/app/api/events.py +++ b/server/app/api/events.py @@ -1,6 +1,5 @@ import json from datetime import UTC, datetime -from urllib.parse import quote from fastapi import ( APIRouter, @@ -14,9 +13,9 @@ UploadFile, status, ) -from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from app.api import events_exports from app.api.deps import ( get_current_active_user, get_current_user_optional, @@ -85,23 +84,15 @@ update_event, ) from app.services.event_bus import publish_event -from app.services.export import ( - export_play_history_to_csv, - export_requests_to_csv, - generate_export_filename, - generate_play_history_export_filename, -) from app.services.kiosk import is_trusted_kiosk_for_event from app.services.now_playing import ( get_manual_hide_setting, - get_play_history, set_now_playing_visibility, ) from app.services.request import ( accept_all_new_requests, bulk_delete_requests, create_request, - get_requests_for_event, reject_all_new_requests, ) from app.services.request_sort import ( @@ -119,6 +110,13 @@ router = APIRouter() +# Preserve the previous module-level exports for callers that imported these names. +MAX_EXPORT_PLAY_HISTORY = events_exports.MAX_EXPORT_PLAY_HISTORY +MAX_EXPORT_REQUESTS = events_exports.MAX_EXPORT_REQUESTS +_content_disposition = events_exports._content_disposition +export_event_csv = events_exports.export_event_csv +export_play_history_csv = events_exports.export_play_history_csv + def _to_naive_utc(dt: datetime) -> datetime: """Normalize an incoming datetime to naive UTC (matches the project's stored convention). @@ -137,20 +135,6 @@ def _to_naive_utc(dt: datetime) -> datetime: # FIXME: per-process cache — value drifts in multi-worker deployments until next request _llm_rate_limit_cache: dict[str, int] = {"value": 3} -# Maximum number of requests to export in a single CSV -# Set to 10,000 to prevent memory issues and excessive download times -MAX_EXPORT_REQUESTS = 10000 - -# Maximum number of play history entries to export in a single CSV -MAX_EXPORT_PLAY_HISTORY = 10000 - - -def _content_disposition(filename: str) -> str: - """Build an RFC 6266 Content-Disposition header value for a download.""" - safe_filename = filename.replace('"', '\\"') - ascii_filename = quote(filename, safe="") - return f"attachment; filename=\"{safe_filename}\"; filename*=UTF-8''{ascii_filename}" - def _get_base_url(request: Request | None) -> str | None: """Get the base URL for constructing public URLs.""" @@ -612,50 +596,6 @@ def get_display_settings( ) -@router.get("/{code}/export/csv") -@limiter.limit("5/minute") -def export_event_csv( - request: Request, - event: Event = Depends(get_owned_event), - db: Session = Depends(get_db), -) -> StreamingResponse: - """Export event requests as CSV. Owner can export regardless of event status.""" - # Get all requests for the event (no status filter, limited for safety) - requests = get_requests_for_event(db, event, status=None, since=None, limit=MAX_EXPORT_REQUESTS) - - # Generate CSV content - csv_content = export_requests_to_csv(event, requests) - filename = generate_export_filename(event) - - return StreamingResponse( - iter([csv_content]), - media_type="text/csv", - headers={"Content-Disposition": _content_disposition(filename)}, - ) - - -@router.get("/{code}/export/play-history/csv") -@limiter.limit("5/minute") -def export_play_history_csv( - request: Request, - event: Event = Depends(get_owned_event), - db: Session = Depends(get_db), -) -> StreamingResponse: - """Export play history as CSV. Owner can export regardless of event status.""" - # Get all play history entries for the event (limited for safety) - history_items, _ = get_play_history(db, event.id, limit=MAX_EXPORT_PLAY_HISTORY, offset=0) - - # Generate CSV content - csv_content = export_play_history_to_csv(event, history_items) - filename = generate_play_history_export_filename(event) - - return StreamingResponse( - iter([csv_content]), - media_type="text/csv", - headers={"Content-Disposition": _content_disposition(filename)}, - ) - - @router.post("/{code}/requests", response_model=RequestOut) @limiter.limit(lambda: f"{settings.request_rate_limit_per_minute}/minute") def submit_request( @@ -1389,3 +1329,6 @@ def delete_banner( delete_banner_from_event(db, event) return _event_to_out(event, request) + + +router.include_router(events_exports.router) diff --git a/server/app/api/events_exports.py b/server/app/api/events_exports.py new file mode 100644 index 00000000..70adc926 --- /dev/null +++ b/server/app/api/events_exports.py @@ -0,0 +1,73 @@ +"""CSV export routes for event requests and play history.""" + +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db, get_owned_event +from app.core.rate_limit import limiter +from app.models.event import Event +from app.services.export import ( + export_play_history_to_csv, + export_requests_to_csv, + generate_export_filename, + generate_play_history_export_filename, +) +from app.services.now_playing import get_play_history +from app.services.request import get_requests_for_event + +router = APIRouter() + +# Keep each export bounded to protect memory and response time. +MAX_EXPORT_REQUESTS = 10000 +MAX_EXPORT_PLAY_HISTORY = 10000 + + +def _content_disposition(filename: str) -> str: + """Build an RFC 6266 Content-Disposition header value for a download.""" + fallback = "".join( + character if 0x20 <= ord(character) <= 0x7E else "_" for character in filename + ) + safe_filename = fallback.replace("\\", "\\\\").replace('"', '\\"') + ascii_filename = quote(filename, safe="") + return f"attachment; filename=\"{safe_filename}\"; filename*=UTF-8''{ascii_filename}" + + +@router.get("/{code}/export/csv") +@limiter.limit("5/minute") +def export_event_csv( + request: Request, + event: Event = Depends(get_owned_event), + db: Session = Depends(get_db), +) -> StreamingResponse: + """Export event requests as CSV. Owner can export regardless of event status.""" + requests = get_requests_for_event(db, event, status=None, since=None, limit=MAX_EXPORT_REQUESTS) + csv_content = export_requests_to_csv(event, requests) + filename = generate_export_filename(event) + + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": _content_disposition(filename)}, + ) + + +@router.get("/{code}/export/play-history/csv") +@limiter.limit("5/minute") +def export_play_history_csv( + request: Request, + event: Event = Depends(get_owned_event), + db: Session = Depends(get_db), +) -> StreamingResponse: + """Export play history as CSV. Owner can export regardless of event status.""" + history_items, _ = get_play_history(db, event.id, limit=MAX_EXPORT_PLAY_HISTORY, offset=0) + csv_content = export_play_history_to_csv(event, history_items) + filename = generate_play_history_export_filename(event) + + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": _content_disposition(filename)}, + ) diff --git a/server/tests/test_events.py b/server/tests/test_events.py index 2ec96c4c..5addde30 100644 --- a/server/tests/test_events.py +++ b/server/tests/test_events.py @@ -556,6 +556,27 @@ def test_export_csv_success( assert "Export Artist" in content assert "Test note" in content + def test_export_csv_filename_header_safely_encodes_unicode_and_controls( + self, client: TestClient, auth_headers: dict, test_event: Event, db: Session + ): + test_event.name = "日本語 😄\r\nX-Injected: yes" + db.commit() + + response = client.get( + f"/api/events/{test_event.code}/export/csv", + headers=auth_headers, + ) + + assert response.status_code == 200 + disposition = response.headers["content-disposition"] + assert "\r" not in disposition + assert "\n" not in disposition + assert "x-injected" not in response.headers + assert "X-Injected: yes" not in disposition + assert "filename*=UTF-8''" in disposition + assert 'filename="' in disposition + assert all(ord(character) < 128 for character in disposition) + def test_export_csv_no_auth(self, client: TestClient, test_event: Event): """Test exporting without auth fails.""" response = client.get(f"/api/events/{test_event.code}/export/csv") From 6a68c7284fd70f7956031d4f985c8a91b75dcbd3 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 15:53:20 -0700 Subject: [PATCH 04/18] =?UTF-8?q?scale(tracks):=20plan=20dedicated=20infra?= =?UTF-8?q?=20for=20the=20global=20enriched-track=20catalog=20(replica/cac?= =?UTF-8?q?he=20=E2=86=92=20separate=20DB=20=E2=86=92=20enrichment=20serv?= =?UTF-8?q?=20(#703)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(tracks): define catalog scaling guardrails Co-Authored-By: Codex * fix: remove trailing whitespace from scaling plan Co-Authored-By: Codex * fix: clarify catalog scaling gates and data boundaries Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../2026-06-23-master-track-store-design.md | 1 + .../2026-10-03-track-catalog-scaling-plan.md | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-03-track-catalog-scaling-plan.md diff --git a/docs/superpowers/specs/2026-06-23-master-track-store-design.md b/docs/superpowers/specs/2026-06-23-master-track-store-design.md index 4fdaac05..d9d011c0 100644 --- a/docs/superpowers/specs/2026-06-23-master-track-store-design.md +++ b/docs/superpowers/specs/2026-06-23-master-track-store-design.md @@ -141,6 +141,7 @@ enrich track (request submit, or pool import) ## 11. Open questions / future +- Catalog growth, capacity guardrails, and the threshold for infrastructure extraction are defined in the [global track catalog scaling plan](2026-10-03-track-catalog-scaling-plan.md) (#546). - ReccoBeats fallback + batch backfill (quota path) — hook left in the cascade. - Lexicon measured-energy override (#526) — slots in at precedence 90. - Provenance-driven re-enrichment/TTL job — enabled by `fetched_at`, deferred. diff --git a/docs/superpowers/specs/2026-10-03-track-catalog-scaling-plan.md b/docs/superpowers/specs/2026-10-03-track-catalog-scaling-plan.md new file mode 100644 index 00000000..944ad0f3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-track-catalog-scaling-plan.md @@ -0,0 +1,65 @@ +# Global Track Catalog: Scaling Plan + +**Issue:** #546 · **Date:** 2026-10-03 +**Related:** [Master Song Store design](2026-06-23-master-track-store-design.md) · #539 / #540 +**Status:** Recommended operating plan; thresholds are initial guardrails, to be checked against a measured baseline. + +## Decision + +“Global” means shared by every DJ, event, and setbuilder **inside one WrzDJ deployment**. The `tracks` table remains in that deployment's PostgreSQL database. We do not operate a cross-deployment catalog today. If multiple WrzDJ deployments need the same canonical catalog, the target is a dedicated enrichment service that owns the catalog and exposes an API; sharing database credentials or directly connecting multiple applications to one table is not the target architecture. + +Do not move infrastructure because of row count alone. The master-store design's ISRC and signature lookups use unique B-tree indexes, and the catalog is append-mostly. Keep it on the primary PostgreSQL instance while measured catalog operations meet the guardrails below. The row and disk figures in the Capacity plan section are planning estimates from #546, not measured production data or latency promises. + +## Metrics and measurement + +Start collecting a 30-day baseline before making a scaling move. Report catalog-specific values separately from the rest of the application database: + +| Signal | How to measure | Initial action threshold | +|---|---|---| +| Track lookup latency | p99 database execution time for ISRC/signature lookup and end-to-end resolver p99, measured with application query spans or histograms | Investigate when DB-side p99 exceeds 25 ms, or end-to-end p99 exceeds 100 ms, for 7 days. These are review thresholds, not user-facing SLOs. | +| Primary database pressure | Host CPU and I/O wait; database read/write latency; database connection use; application pool acquisition p95 | Start mitigation review when CPU stays above 70%, I/O wait above 10%, connections above 70% of the configured limit, or pool acquisition p95 exceeds 100 ms for 15 minutes on 3 days in a week. Confirm catalog work contributes before moving it. | +| Request-path impact | p95/p99 latency and DB pool wait for request submission and event operations, compared with the prior 30-day baseline | Escalate if p99 regresses by 25% or more for 7 days and query/host evidence links the regression to catalog work. | +| Catalog footprint | `pg_total_relation_size('tracks')`, row count, growth rate, and `pg_stat_user_tables` dead tuples / vacuum / analyze history | Review capacity at 10M rows or 7 GB; plan the next capacity test at 100M rows or 70 GB. Size alone does not trigger a split. | +| Cache value (when enabled) | Hit rate, miss latency, invalidation count, stale-read rate, memory use | Keep a cache only if representative load tests show at least 50% lookup hit rate and a meaningful reduction in primary read load without stale metadata regressions. | + +Use PostgreSQL's `pg_stat_statements` for aggregate statement execution statistics, application query spans or histograms for p99, and relation-size functions for on-disk size. Track autovacuum/analyze activity because PostgreSQL derives maintenance triggers from table size and configurable thresholds; tune the `tracks` table only if observed maintenance lag or bloat warrants it. Do not infer hot-table pressure from row count alone. + +## Staged response + +1. **Single PostgreSQL instance (current).** Keep the existing unique B-tree lookups and default autovacuum behavior. Record the metrics above. At one million rows, continue on the current instance if the measured latency and contention guardrails remain healthy. +2. **Tune and reduce read load.** First inspect query plans and indexes. Add `pg_trgm` with a GIN index only when a real normalized-title/artist fuzzy-search query exists and an `EXPLAIN (ANALYZE, BUFFERS)` benchmark demonstrates a benefit; `dedupe_sig` is a hash and cannot support fuzzy matching. If lookups are read-heavy, try a bounded per-process cache with a short TTL and write-through invalidation after `upsert_track`; measure hit rate and stale-read behavior. Add Redis only if multiple API processes need a shared cache and the measured benefit justifies operating it. Tune per-table autovacuum/analyze thresholds only from observed churn and maintenance lag. +3. **Read replica or separate database.** Use a read replica when read traffic is the demonstrated source of primary contention and the lookup path can tolerate replication lag. Move `tracks` to a separate PostgreSQL instance when catalog writes, vacuum, or storage continue to harm the transactional request/event workload after cheaper measures, or when measured growth means the current database volume cannot maintain the 30% free-space reserve or meet a documented backup/restore objective. This is an application data-boundary change, not a transparent connection-string change; complete the prerequisites in the next section first. +4. **Dedicated enrichment service.** Extract ownership when cross-deployment sharing is a product requirement, or when independent scaling/deployment is needed after the database split. The service owns identity resolution, provenance precedence, enrichment scheduling, and storage; callers use an authenticated, versioned API. Do not introduce sharding before measured single-database limits require it. + +For workload-driven moves, require the relevant threshold to hold for its stated window, evidence that catalog activity contributes, and measurement of or a reason to rule out the lower-cost stage. Product or operational triggers follow their own evidence: cross-deployment sharing requires an explicit product requirement, while storage isolation requires a documented capacity or backup/restore objective. Neither requires a catalog-attributed latency regression. Recheck workload thresholds after the first 30 days of telemetry and after each infrastructure stage. + +## Data-boundary costs of a separate database + +The current ORM schema has no foreign key from `Request` or `SetPoolTrack` to `Track`: `Request.track_id` is not present, and `SetPoolTrack.track_id` is a namespaced string. However, the master-store design proposes local nullable foreign keys for both tables. Such foreign keys and SQL joins cannot cross PostgreSQL instances, so that part of the design must be revised before a split. Keep request and pool membership data in the application database, use an opaque catalog identifier or ISRC/signature in the service contract, and replace any future cross-table joins with bounded batch API reads. + +The current `get_track` and `upsert_track` functions take the application's SQLAlchemy `Session`. A separate instance needs a catalog client/storage boundary instead. It also removes shared transaction semantics: request enrichment currently commits request data before the best-effort catalog upsert; REST pool imports use similar commit-first behavior, while agent pool edits can currently include the catalog flush in their transaction and undo. Before extraction, define durable retry/idempotency for catalog writes, acceptable partial failure and stale-read behavior, and how agent undo interacts with a separately committed catalog. Plan and rehearse an export/backfill, consistency check, cutover, and rollback path. Do not advertise the move as transparent until these contracts are implemented. + +## Capacity plan + +Use these estimates from issue #546 as an initial envelope for the current row shape (typed nullable enrichment columns plus provenance and indexes). The estimate should be replaced with `pg_total_relation_size` from representative production data before purchasing capacity. + +| Catalog rows | Estimated table + indexes | Capacity action | +|---:|---:|---| +| 1M | Under 1 GB | No move by default; confirm metrics and backup capacity. | +| 10M | 6–7 GB | Run a representative lookup/upsert benchmark with a warm and cold cache. Ensure the database volume has at least 30% free space after catalog, application tables, WAL, and migration headroom. | +| 100M | 60–70 GB | Capacity-test a primary sized for at least 100 GB of catalog headroom, then add application data, WAL, backups, and operational reserve. Prefer a separate database only if the contention or isolation triggers above apply. | + +PostgreSQL caches active pages rather than requiring the whole catalog to fit in RAM. Benchmark with the expected working set and available memory; do not size RAM by multiplying row count by row width. Before either capacity milestone, measure actual heap/index size, growth, cache hit behavior, and backup/restore time. + +## Implementation boundaries + +- No schema, request-flow, or infrastructure behavior changes in this planning issue. +- Keep `get_track` and `upsert_track` as the storage boundary; application callers should not depend on replica/cache/database topology. +- A future fuzzy-matching change must have its own query contract and benchmark. A future cache must define key normalization, TTL, invalidation, and acceptable staleness before implementation. +- A cross-deployment catalog decision requires an explicit product requirement, tenant/privacy review, service ownership/on-call plan, and migration/availability design. + +## References + +- PostgreSQL [`pg_stat_statements`](https://www.postgresql.org/docs/current/pgstatstatements.html) reports statement planning and execution statistics. +- PostgreSQL [routine vacuuming](https://www.postgresql.org/docs/current/routine-vacuuming.html) and [vacuum configuration](https://www.postgresql.org/docs/current/runtime-config-vacuum.html) document autovacuum thresholds and per-table storage settings. +- PostgreSQL [`EXPLAIN`](https://www.postgresql.org/docs/current/using-explain.html) documents plan inspection and the limits of reported execution time. From 7896046211b23c3a3e5472072543c8382ac1a5d4 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 15:57:15 -0700 Subject: [PATCH 05/18] WrzDJSet: Bridge local-library readers (Rekordbox / Serato / Engine DJ) (#704) * feat(bridge): scan watched local audio folders Co-Authored-By: Codex * fix: harden watched-folder library scanning Co-Authored-By: Codex * fix(bridge): keep root scan errors visible Co-Authored-By: Codex * fix: preserve root scan errors and test disappearing folders Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../__tests__/watched-folder-reader.test.ts | 143 ++++++++++++++++++ bridge/src/local-library/types.ts | 14 ++ .../local-library/watched-folder-reader.ts | 139 +++++++++++++++++ .../2026-10-03-issue-404-watched-folder.md | 25 +++ ...6-10-03-issue-404-watched-folder-design.md | 53 +++++++ 5 files changed, 374 insertions(+) create mode 100644 bridge/src/__tests__/watched-folder-reader.test.ts create mode 100644 bridge/src/local-library/types.ts create mode 100644 bridge/src/local-library/watched-folder-reader.ts create mode 100644 docs/superpowers/plans/2026-10-03-issue-404-watched-folder.md create mode 100644 docs/superpowers/specs/2026-10-03-issue-404-watched-folder-design.md diff --git a/bridge/src/__tests__/watched-folder-reader.test.ts b/bridge/src/__tests__/watched-folder-reader.test.ts new file mode 100644 index 00000000..d5e72326 --- /dev/null +++ b/bridge/src/__tests__/watched-folder-reader.test.ts @@ -0,0 +1,143 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, sep } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WatchedFolderReader } from "../local-library/watched-folder-reader.js"; + +const temporaryRoots: string[] = []; + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "wrzdj-library-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("WatchedFolderReader", () => { + it("recursively lists supported audio files with stable relative IDs and filename metadata", async () => { + const root = await makeRoot(); + await mkdir(join(root, "Album")); + await writeFile(join(root, "Album", "DJ Example - Late Night.FLAC"), "audio"); + await writeFile(join(root, "ambient mix.mp3"), "audio"); + await writeFile(join(root, "cover.jpg"), "image"); + const aliasParent = await makeRoot(); + const rootAlias = join(aliasParent, "library"); + await symlink(root, rootAlias, "dir"); + const canonicalRoot = await realpath(rootAlias); + + const tracks = await new WatchedFolderReader(rootAlias).scan(); + + expect(tracks).toEqual([ + { + id: relative(canonicalRoot, join(canonicalRoot, "Album", "DJ Example - Late Night.FLAC")) + .split(sep) + .join("/"), + filePath: join(canonicalRoot, "Album", "DJ Example - Late Night.FLAC"), + title: "Late Night", + artist: "DJ Example", + extension: ".flac", + }, + { + id: "ambient mix.mp3", + filePath: join(canonicalRoot, "ambient mix.mp3"), + title: "ambient mix", + artist: null, + extension: ".mp3", + }, + ]); + }); + + it("searches current library tracks case-insensitively by artist and title", async () => { + const root = await makeRoot(); + await writeFile(join(root, "Soda Stereo - De Música Ligera.mp3"), "audio"); + await writeFile(join(root, "Ambient Loop.wav"), "audio"); + const reader = new WatchedFolderReader(root); + + await expect(reader.search("soda stereo")).resolves.toHaveLength(1); + await expect(reader.search("soda stereo de música ligera")).resolves.toHaveLength(1); + await expect(reader.search("loop")).resolves.toHaveLength(1); + await expect(reader.search(" ")).resolves.toHaveLength(2); + + await writeFile(join(root, "New Addition.ogg"), "audio"); + await expect(reader.search("new addition")).resolves.toHaveLength(1); + }); + + it("ignores audio-like sidecars and known trash or system directories", async () => { + const root = await makeRoot(); + await mkdir(join(root, "$RECYCLE.BIN")); + await mkdir(join(root, ".Trashes")); + await mkdir(join(root, ".Trash-1000")); + await mkdir(join(root, "System Volume Information")); + await writeFile(join(root, "._Artist - Song.mp3"), "sidecar"); + await writeFile(join(root, "$RECYCLE.BIN", "Deleted Song.mp3"), "deleted"); + await writeFile(join(root, ".Trashes", "Trashed Song.mp3"), "trashed"); + await writeFile(join(root, ".Trash-1000", "User Trashed Song.mp3"), "trashed"); + await writeFile(join(root, "System Volume Information", "System Audio.mp3"), "system"); + await writeFile(join(root, "Artist - Real Song.mp3"), "audio"); + + const tracks = await new WatchedFolderReader(root).scan(); + + expect(tracks.map(({ title }) => title)).toEqual(["Real Song"]); + }); + + it("matches accented queries against decomposed Unicode filenames", async () => { + const root = await makeRoot(); + await writeFile(join(root, "Cafe\u0301 - Mu\u0301sica.mp3"), "audio"); + + await expect(new WatchedFolderReader(root).search("Café Música")).resolves.toHaveLength(1); + }); + + it("does not follow symbolic links outside the selected folder", async () => { + const root = await makeRoot(); + const outside = await makeRoot(); + await writeFile(join(outside, "Outside Track.mp3"), "audio"); + await symlink(outside, join(root, "linked-folder"), "dir"); + await symlink(join(outside, "Outside Track.mp3"), join(root, "linked-track.mp3"), "file"); + + await expect(new WatchedFolderReader(root).scan()).resolves.toEqual([]); + }); + + it("surfaces filesystem errors with the root path and original cause", async () => { + const missingRoot = join(await makeRoot(), "missing"); + + await expect(new WatchedFolderReader(missingRoot).scan()).rejects.toMatchObject({ + message: expect.stringContaining(missingRoot), + cause: expect.objectContaining({ code: "ENOENT" }), + }); + }); + + it("fails if the selected root disappears after validation", async () => { + const root = await makeRoot(); + const missing = Object.assign(new Error("directory vanished"), { code: "ENOENT" }); + const fileSystem = { + realpath: async () => root, + stat: async () => ({ isDirectory: () => true }), + readdir: vi.fn().mockRejectedValue(missing), + }; + + await expect(new WatchedFolderReader(root, fileSystem).scan()).rejects.toMatchObject({ + message: expect.stringContaining(root), + cause: missing, + }); + }); + + it("skips a nested directory that disappears during scanning", async () => { + const root = await makeRoot(); + const missing = Object.assign(new Error("directory vanished"), { code: "ENOENT" }); + const fileSystem = { + realpath: async () => root, + stat: async () => ({ isDirectory: () => true }), + readdir: vi + .fn() + .mockResolvedValueOnce([ + { name: "vanishing", isDirectory: () => true, isFile: () => false }, + ]) + .mockRejectedValueOnce(missing), + }; + + await expect(new WatchedFolderReader(root, fileSystem).scan()).resolves.toEqual([]); + }); +}); diff --git a/bridge/src/local-library/types.ts b/bridge/src/local-library/types.ts new file mode 100644 index 00000000..036ed247 --- /dev/null +++ b/bridge/src/local-library/types.ts @@ -0,0 +1,14 @@ +export interface LocalLibraryTrack { + /** Stable path relative to the configured library root, with `/` separators. */ + readonly id: string; + /** Canonical absolute path for bridge-side access; omit from browser-facing payloads. */ + readonly filePath: string; + readonly title: string; + readonly artist: string | null; + readonly extension: string; +} + +export interface LocalLibraryReader { + scan(): Promise; + search(query: string): Promise; +} diff --git a/bridge/src/local-library/watched-folder-reader.ts b/bridge/src/local-library/watched-folder-reader.ts new file mode 100644 index 00000000..caa12845 --- /dev/null +++ b/bridge/src/local-library/watched-folder-reader.ts @@ -0,0 +1,139 @@ +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import { basename, extname, join, relative, sep } from "node:path"; +import type { LocalLibraryReader, LocalLibraryTrack } from "./types.js"; + +const AUDIO_EXTENSIONS = new Set([ + ".aac", + ".aif", + ".aiff", + ".alac", + ".flac", + ".m4a", + ".mp3", + ".ogg", + ".opus", + ".wav", + ".wma", +]); + +const IGNORED_DIRECTORY_NAMES = new Set([ + "$recycle.bin", + ".fseventsd", + ".spotlight-v100", + ".temporaryitems", + ".trashes", + "system volume information", +]); + +interface WatchedFolderFileSystem { + readdir(path: string, options: { withFileTypes: true }): Promise; + realpath(path: string): Promise; + stat(path: string): Promise<{ isDirectory(): boolean }>; +} + +const defaultFileSystem: WatchedFolderFileSystem = { readdir, realpath, stat }; + +export class WatchedFolderReader implements LocalLibraryReader { + constructor( + private readonly rootPath: string, + private readonly fileSystem: WatchedFolderFileSystem = defaultFileSystem, + ) {} + + async scan(): Promise { + let root: string; + try { + root = await this.fileSystem.realpath(this.rootPath); + const rootStats = await this.fileSystem.stat(root); + if (!rootStats.isDirectory()) { + throw new Error("configured path is not a directory"); + } + } catch (error) { + throw withPathContext("open library folder", this.rootPath, error); + } + + const tracks: LocalLibraryTrack[] = []; + await this.readDirectory(root, root, tracks); + return tracks.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)); + } + + async search(query: string): Promise { + const tracks = await this.scan(); + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return tracks; + + return tracks.filter((track) => + normalizeSearchText(`${track.artist ?? ""} ${track.title}`).includes(normalizedQuery), + ); + } + + private async readDirectory( + root: string, + directory: string, + tracks: LocalLibraryTrack[], + ): Promise { + let entries; + try { + entries = await this.fileSystem.readdir(directory, { withFileTypes: true }); + } catch (error) { + if (directory !== root && isMissingPath(error)) return; + throw withPathContext("read library directory", directory, error); + } + + for (const entry of entries) { + const filePath = join(directory, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirectory(entry.name)) continue; + await this.readDirectory(root, filePath, tracks); + } else if (entry.isFile()) { + if (entry.name.startsWith("._")) continue; + const extension = extname(entry.name).toLowerCase(); + if (!AUDIO_EXTENSIONS.has(extension)) continue; + tracks.push(toTrack(root, filePath, extension)); + } + } + } +} + +function normalizeSearchText(value: string): string { + return value.normalize("NFC").toLowerCase().trim(); +} + +function isIgnoredDirectory(name: string): boolean { + const normalizedName = name.toLowerCase(); + return ( + IGNORED_DIRECTORY_NAMES.has(normalizedName) || + normalizedName === ".trash" || + normalizedName.startsWith(".trash-") + ); +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function toTrack(root: string, filePath: string, extension: string): LocalLibraryTrack { + const filename = basename(filePath, extname(filePath)); + const separatorIndex = filename.indexOf(" - "); + const artist = separatorIndex > 0 ? filename.slice(0, separatorIndex).trim() : ""; + const title = (separatorIndex > 0 ? filename.slice(separatorIndex + 3) : filename).trim(); + const relativePath = relative(root, filePath).split(sep).join("/"); + + return { + id: relativePath, + filePath, + title: title || filename, + artist: artist || null, + extension, + }; +} + +function withPathContext(action: string, path: string, error: unknown): Error { + const detail = error instanceof Error ? error.message : String(error); + return new Error(`Could not ${action} "${path}": ${detail}`, { cause: error }); +} diff --git a/docs/superpowers/plans/2026-10-03-issue-404-watched-folder.md b/docs/superpowers/plans/2026-10-03-issue-404-watched-folder.md new file mode 100644 index 00000000..76f76f90 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-issue-404-watched-folder.md @@ -0,0 +1,25 @@ +# Plan: watched-folder reader + +## Interfaces + +- `LocalLibraryTrack`: root-relative `id`, absolute `filePath`, `title`, nullable `artist`, lowercase + extension. +- `LocalLibraryReader.scan(): Promise`. +- `LocalLibraryReader.search(query: string): Promise`. +- `WatchedFolderReader(rootPath)` recursively scans audio files and searches current results. + +## Steps + +1. Add boundary tests for recursive extension filtering, filename parsing, deterministic IDs, search, + symlink exclusion, and filesystem errors. Run the bridge test file and confirm red. +2. Add the interfaces and implement a recursive scanner using Node built-ins only. Keep file paths + within the canonical root, ignore symlinks, and surface contextual filesystem errors. +3. Run the focused test, bridge TypeScript build, and full bridge test suite; fix any failures. +4. Run `ak verify`, document the implementation in `.ak/why.md`, ship a draft PR, then run the + remaining review/CI/thread/receipt steps from `.ak/prompt.md`. + +## Review focus + +- Nested folders and mixed-case extensions produce one stable record per eligible file. +- Symlinked files and directories never escape the selected root. +- An unreadable or missing root fails with context rather than looking like an empty library. diff --git a/docs/superpowers/specs/2026-10-03-issue-404-watched-folder-design.md b/docs/superpowers/specs/2026-10-03-issue-404-watched-folder-design.md new file mode 100644 index 00000000..2ee23322 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-issue-404-watched-folder-design.md @@ -0,0 +1,53 @@ +# Issue #404 slice: watched-folder library scanner + +**Status:** Approved for the first implementation slice by the coordinating agent on 2026-10-03. + +## Goal + +Add a bridge-side reader that inventories audio files beneath a DJ-selected folder. The reader is a +small, dependency-free foundation for the local-library HTTP API in issue #404. Each scan reflects +the folder's current contents; this slice does not install a persistent OS watcher. + +## Data shape and interface + +`LocalLibraryTrack` contains a stable root-relative `id`, an absolute `filePath` for bridge-side +playback, parsed `title`, nullable `artist`, and lowercase `extension`. `LocalLibraryReader` exposes +`scan(): Promise` and `search(query: string): Promise`. + +`WatchedFolderReader(rootPath)` implements the reader. A recursive scan includes only known audio +extensions, ignores symbolic links, skips common OS trash/system folders and AppleDouble audio +sidecars, and returns deterministic path order. A basename in the form `Artist - Title.ext` +supplies artist and title; other basenames supply title only. IDs use the path relative to the +canonical root, and `filePath` is canonical and absolute. Search rescans, then performs +case-insensitive, NFC-normalized substring matching across combined artist and title. Empty search +returns the full scan. + +## Invariants and errors + +- Returned canonical file paths remain beneath the canonical selected root; symbolic links are not + followed. +- Unsupported files are absent, extension matching is case-insensitive, and IDs remain stable for a + file's relative path within that root. +- Known OS trash/system directories and `._` AppleDouble files are absent. A nested directory that + disappears during scanning is skipped; other filesystem failures reject with path context and the + original error as `cause`. +- The scanner does not read file contents or load third-party packages. + +The implementation uses Node's `fs/promises` directory entries and path utilities. These APIs are +documented in the [Node.js filesystem reference](https://nodejs.org/api/fs.html) and +[path reference](https://nodejs.org/api/path.html). + +## Files and call sites + +- `bridge/src/local-library/types.ts` — shared track and reader interfaces. +- `bridge/src/local-library/watched-folder-reader.ts` — scanner and search implementation. +- `bridge/src/__tests__/watched-folder-reader.test.ts` — public behavior tests using temporary + directories. +- No runtime call site exists yet; the local-library server and pool integration are later slices. + +## Excluded from this slice + +Rekordbox SQLite, Serato crates, Engine DJ DB, iTunes XML, persistent folder watching, localhost +HTTP/WebSocket endpoints, range streaming, playback position updates, pool UI, and streaming-track +deduplication remain open in issue #404. From 3235d5d96a4be1a368c6e8fd89b7677743e42101 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 16:01:10 -0700 Subject: [PATCH 06/18] research(setbuilder): LexiconDJ as an optional measured-energy source of truth for WrzDJSet (+ library sync) (#706) * docs(setbuilder): research Lexicon energy and pool import Co-Authored-By: Codex * docs(setbuilder): tighten Lexicon integration plan Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../plans/2026-10-03-lexicon-pool-import.md | 87 +++++++++++++ .../2026-10-03-issue-526-lexicondj-design.md | 119 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-03-lexicon-pool-import.md create mode 100644 docs/superpowers/specs/2026-10-03-issue-526-lexicondj-design.md diff --git a/docs/superpowers/plans/2026-10-03-lexicon-pool-import.md b/docs/superpowers/plans/2026-10-03-lexicon-pool-import.md new file mode 100644 index 00000000..b005df10 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-lexicon-pool-import.md @@ -0,0 +1,87 @@ +# Implementation plan — selected Lexicon tracks into a WrzDJSet pool + +**Parent research:** `docs/superpowers/specs/2026-10-03-issue-526-lexicondj-design.md` +**Related roadmap:** #442 Family 4 +**Scope:** Phase A only. Do not implement measured-energy matching while Lexicon's documented Track schema lacks ISRC and analyzer-origin fields. + +## Goal and invariants + +Let a DJ explicitly select Lexicon tracks and add them to one of their own WrzDJSet pools through the Lexicon plugin. Every imported row belongs to the destination set; the plugin cannot select another owner, create a guest request, or write metadata into the shared track store. + +1. A credential is issued to a DJ only from their authenticated WrzDJ account session. The server stores only its hash; it expires after 90 days and is revocable from WrzDJ account settings. +2. The import endpoint derives the actor from that credential, and independently checks that the destination set belongs to the actor. Request JSON never supplies an owner ID. +3. The v1 payload contains only validated title and artist. It omits Lexicon local IDs, ISRC, Energy, and other metadata. +4. Import is additive and atomic. It does not write `requests` or any field in the shared `Track` store. +5. Production plugin traffic goes only to a build-time fixed, allowlisted WrzDJ HTTPS origin. No runtime API-base URL is accepted alongside a bearer. +6. Requests are idempotent for seven days by credential + request UUID + canonical body hash. Reuse of the same UUID with a different body is rejected. +7. Lexicon title/artist signature dedupe can conflate different versions with identical names. The plugin must show this limitation before confirmation and identify skipped duplicates in its result. +8. Check the Terms in force and record that user-initiated plugin transfer is permitted before enabling the feature. Hold if unclear or prohibited. + +## Data shapes and interfaces + +Contract version is sent in `X-WrzDJ-Lexicon-Contract-Version: 1`. Check that header before parsing the strict body schema so old/new contracts get a readable compatibility error. + +```json +{ + "tracks": [ + { "title": "Track title", "artist": "Artist name" } + ] +} +``` + +Use a 200-track maximum and reject larger requests atomically; the plugin does not chunk or retry with a new UUID. It creates and persists a random request UUID with the pending payload before the POST, reuses both on a transport retry, and replaces the pending request only after receiving a successful response. A pending request older than seven days requires a fresh preview and UUID because its server receipt may have expired. The set ID is in the URL path and must pass the ordinary set-owner check. No arbitrary remote origin is accepted. + +Proposed boundaries (confirm route naming against existing setbuilder imports during implementation): + +- `POST /api/setbuilder/sets/{set_id}/imports/lexicon` — narrow Lexicon credential; import title/artist tracks. +- `POST /api/setbuilder/lexicon/credential/revoke` — same credential may revoke itself. +- Authenticated WrzDJ account settings — issue, list status/expiry, replace, and revoke the DJ's credential without the plugin. + +Create one credential per DJ. Re-pairing revokes the prior credential before returning the new opaque token exactly once. Persist token hash, owner, scope, created/expiry/revoked timestamps. The plugin stores the token in Lexicon private action storage; document that Lexicon does not document at-rest encryption for this store. For disconnect, call server revocation first and erase local storage only after success; if offline/failing, retain the token and offer retry. WrzDJ account settings remain the recovery/revocation path if the plugin is removed or loses its token. + +Store a seven-day idempotency receipt keyed by credential and request UUID, containing canonical body hash and response summary. Same key/hash replays the saved summary; same key/different hash returns 409. Expired receipts are removed opportunistically on import and by any existing periodic cleanup mechanism; do not store the submitted track names in the receipt. + +## Files and call sites + +- `server/app/api/setbuilder.py` — owner-checked Lexicon import route and narrow-token dependency, unless a focused adjacent router fits better. +- `server/app/schemas/setbuilder.py` — strict contract schema (title/artist only) and response schema. +- `server/app/services/setbuilder/pool.py` — Lexicon candidate conversion and existing additive import flow. Confirm that title/artist-only candidates cannot write any shared `Track` values. +- `server/app/models/user.py` and a new migration only if the existing credential model cannot hold a revocable per-DJ token hash. +- New focused pairing and idempotency models/migration only if no existing credential and idempotency patterns fit; never add raw bearer fields. +- `server/app/services/` — pairing, revoke, token verification, body hashing, and idempotency service, following current auth/rate-limit conventions. +- WrzDJ account settings API/UI — create/replace/revoke credential and display status/expiry; never redisplay raw token. +- `server/tests/` — pairing, auth, ownership, atomicity, idempotency, no-request-write, and no-shared-store-write regression tests. +- `lexicon-plugin/config.json` and action JS under `lexicon-plugin/` — selected-track-only permission, fixed production HTTPS host allowlist, preview/duplicate disclosure, one request UUID per pending payload, and compatibility/error handling. +- `bridge-app/` packaging is excluded. Manual plugin ZIP install is supported by Lexicon; installer work needs a separate issue after the contract stabilizes. + +## Ordered TDD tasks + +### 0. Verify legal and product prerequisites + +Before implementation, inspect the Terms that are then in force and record the specific evidence that permits the user-initiated plugin transfer to WrzDJ. The currently visible Terms page states a future effective date; it is not sufficient evidence. If current terms are unavailable, unclear, or prohibit this use, stop before implementing the data-transfer feature and ask the operator to resolve the vendor/license question. Do not contact the vendor without authorization. + +### 1. Confirm current import and auth patterns + +Read the REST pool-import routes and #524 implementation. Inspect current account credential and token-revocation patterns, rate limiting, and transactional pool import. Confirm title/artist-only imports do not call `upsert_track` with values; add an explicit guard if necessary. No client-supplied provenance is trusted. + +### 2. Pin API invariants with failing boundary tests + +Test authenticated credential issuance, one-time token disclosure, token hashing, one active token per DJ, 90-day expiry, server-side revocation, and recovery through account settings. Test contract-version header mismatch before body validation, unknown fields (including `energy`, `isrc`, `genre`, `bpm`, `key`, `duration_sec`) rejected, 200-row acceptance, 201-row atomic rejection, invalid/revoked/expired token rejection, another DJ's set unchanged, and all shared `Track`/`requests` rows untouched. + +Test that same credential + request UUID + body returns the same result without adding rows; same UUID with a changed body returns 409; expired idempotency receipts are removed; title/artist collisions are included in the preview/result and the import confirmation text warns that versions with identical names may dedupe. + +### 3. Implement pairing, strict API, atomic pool import, and idempotency + +Issue a random opaque credential only from an authenticated WrzDJ account session, hash it server-side, expire at 90 days, and revoke prior credentials on replacement. Add narrow middleware/route scopes and rate limits. The self-revoke route verifies the presented token but accepts no other action. Parse the version header before schema validation. Reject over-cap payloads without partial writes. Check ownership, use the existing additive import service, and save the receipt in the same transaction as the pool changes. Never store request track names in the idempotency record. + +### 4. Implement the account controls and Lexicon plugin + +The account UI can issue/replace/revoke credentials and display active/revoked state and expiry; show the raw token only once. The plugin prompts for the token and destination set ID, reads selected tracks only, displays title/artist and the identity/dedupe limitation, and requires confirmation. It uses the baked-in API origin, sends the version header and request UUID, and stores pending UUID+body before sending. On a timeout it offers retry with the same payload/UUID. On disconnect it revokes remotely before clearing local storage; retain local credentials after failures. Report a failed revoke clearly. + +### 5. Verify and split installer work + +Run focused backend tests, account UI lint/typechecks, and plugin packaging/type checks, then canonical repo CI checks. If bridge-app should bundle the ZIP, file a separate issue covering Windows/macOS paths, upgrade, removal, and reinstallation before changing its installer. + +## Deferred measured-energy slice + +No implementation task for Phase B is ready. Resume only after Lexicon provides a documented stable identity and Energy-origin field, or product approves a per-track user-confirmed mapping flow with a clear disclosure that the Energy field may be analyzer-, Find Tags-, or manually sourced. Then design the owner-scoped resolver, ensure it never writes the shared Track store, and exclude its values from community consensus unless explicitly voted on. diff --git a/docs/superpowers/specs/2026-10-03-issue-526-lexicondj-design.md b/docs/superpowers/specs/2026-10-03-issue-526-lexicondj-design.md new file mode 100644 index 00000000..e8a07d90 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-issue-526-lexicondj-design.md @@ -0,0 +1,119 @@ +# Issue #526 — LexiconDJ integration research and decision + +**Date:** 2026-10-03 +**Issue:** #526 (`research(setbuilder): LexiconDJ as an optional measured-energy source of truth for WrzDJSet (+ library sync)`) +**Status:** Research complete; pool-import implementation is a follow-up. Automatic measured-energy matching is blocked on supported identity and provenance fields from Lexicon. + +## 1. Decision summary + +Keep Lexicon integration inside WrzDJSet. The request-queue enrichment pipeline must not call Lexicon or depend on a user's local app. + +The current WrzDJ code already has two distinct energy paths: + +- `Track.energy` is the shared master-track-store field. Soundcharts can write measured audio features when its setting is enabled; `lexicon` is already reserved at precedence 90 in `services/tracks/provenance.py`. +- `TrackVibe.energy` is the global LLM cache, while `TrackVibeOverride` and community votes feed WrzDJSet's read-time own → community → LLM resolver. `SetPoolTrack.energy` is a separate, currently non-authoritative pool value used as a fallback by pass 1. + +The requested seamless match-and-enrich path is **not ready to implement safely**. Lexicon's current documented Track schema exposes integer Energy from 0 through 10, but does not document an ISRC field. Its Energy column also does not disclose whether its value came from audio analysis, Find Tags, or a manual edit. The plugin can read the same documented fields as the Local API, so moving the integration into a plugin does not solve either gap. + +Proceed with a separate, narrow `import_from_lexicon` follow-up for explicitly selected library tracks into the DJ's own set pool. The first version should import only title and artist, omit Energy and other carried metadata, and never write plugin-supplied values into the shared `Track` store. Do not call Lexicon Energy a verified measurement or infer ISRC. Revisit energy enrichment only after Lexicon provides documented stable identity and value-origin fields, or after a separately designed user-confirmed mapping flow can prove the target track and explain that Lexicon does not expose analysis provenance. + +## 2. Scope and boundaries + +**In scope** + +- Research the current WrzDJ energy, import, identity, and authorization paths. +- Compare the documented Lexicon Local API and plugin surfaces, plan gates, energy scale, and analysis constraints. +- Decide whether a plugin-driven library import and measured-energy enrichment can proceed safely. +- Define the smallest follow-up implementation slice and its security invariants. + +**Out of scope** + +- Any runtime or schema changes in this research PR. +- Lexicon calls from normal WrzDJ guest request enrichment. +- Local API polling from the bridge. The API is unauthenticated and listens on all interfaces. +- Automatic use of Lexicon Energy as an analysis-provenance-confirmed measurement. +- Cue-point sync, Lexicon BPM/key overrides, SonoVault, Lexicon CSV editing, or bundling an installer in bridge-app. + +## 3. Verified current state in WrzDJ + +The `tracks` store is shared across users and has per-field provenance. Its energy precedence already reserves `lexicon: 90`, above Soundcharts and the existing cloud metadata providers at 50, and above LLM at 10 (`server/app/services/tracks/provenance.py`). Request-time enrichment writes Soundcharts audio features only when `soundcharts_audio_features_enabled` is enabled and an ISRC is available (`server/app/services/sync/enrichment_pipeline.py`). That integration is shared-track enrichment and is not a safe place for an owner's private Lexicon import. + +WrzDJSet's vibe resolution is a separate system. It resolves the viewing DJ's explicit override, then community consensus, then the global LLM cache (`server/app/services/setbuilder/vibe_resolver.py`). Community consensus currently considers all override rows with values, so a new non-vote source must be explicitly excluded from community aggregation before it can be added. Pass 1 uses the resolved vibe energy when present and falls back to `SetPoolTrack.energy`; the pool column and the vibe cache are not interchangeable (`server/app/services/setbuilder/pass1_deterministic.py`). + +Existing pool import code accepts `PoolCandidate.energy`, writes it to a pool row, and may also write carried fields into the shared master track store (`server/app/services/setbuilder/pool.py`). Its provenance is derived from server-trusted Beatport/Tidal IDs; other client-supplied identities are marked `legacy`. A Lexicon plugin must not be allowed to claim the global `lexicon` source merely by posting an energy value. Any future Lexicon energy path must be owner-scoped, carry server-verified source provenance, and stay out of community votes unless the DJ explicitly votes on it. + +## 4. Lexicon evidence and answers to the issue's questions + +Research is based on Lexicon's official [Local API documentation](https://www.lexicondj.com/docs/developers/api), [plugin documentation](https://www.lexicondj.com/docs/developers/plugin), [analyzer manual](https://www.lexicondj.com/manual/analyzer), and [pricing feature list](https://www.lexicondj.com/pricing), accessed 2026-10-03. The API schema at `https://www.lexicondj.com/developer/api-docs.yaml` was inspected directly; no `isrc` field appears in its Track schema. + +### 4.1 Energy scale + +The documented Track schema defines `energy` as an integer with minimum 0 and maximum 10. Lexicon says its audio analyzer uses an absolute-based system and fills the Energy field. **No normalization is needed:** when a user elects to transfer the Energy field, the numeric mapping is identity, with WrzDJ validation still enforcing integer 0–10. + +This does not establish that any given value was produced by audio analysis. Lexicon also offers Find Tags, which fills the same Energy field with a different algorithm, and the API schema does not identify the method or whether the value was edited. For that reason, an imported value cannot currently be described as a confirmed measured result. + +### 4.2 Track identity / ISRC + +The documented API supports paginated track reads and exposes a Track schema, but that schema contains no ISRC field. The plugin docs state that plugins can read the same Local API fields. The issue's proposed ISRC match-and-enrich path therefore has no documented input key today. Do not rely on undocumented fields, database inspection, or reverse engineering. Title/artist matching is not strong enough to silently override energy for remasters, edits, versions, or similarly named recordings. + +### 4.3 Plan requirement + +The current pricing feature matrix marks Local API, Plugin Support, and Analyze BPM / Beatgrid / Key / Energy as unavailable on Free and available on both Essential and Ultimate. The integration therefore requires a paid Lexicon tier. The official material does not distinguish these capabilities between Essential and Ultimate. + +### 4.4 Local API risk and plugin suitability + +The Local API is disabled by default, has no authentication, and listens on every network interface. It exposes mutating endpoints as well as reads. This makes bridge polling a poor default, especially on venue or shared Wi-Fi. The documented plugin model supports per-action track read permissions and outbound GET/POST domain allowlists. A plugin posting directly to a fixed WrzDJ HTTPS API is topology-agnostic and avoids opening Lexicon's local API to other devices. It remains an opt-in, paid-client integration. + +Lexicon documents plugin ZIP installation under `Documents/Lexicon/Plugins`, selected/all track reading, paged all-track iteration, playlist reads, playlist creation, and outbound network requests. The initial implementation should be explicitly run by the DJ and read only selected tracks; do not scan the full library by default. The bridge-app installer is not needed for an initial user-installed ZIP and should be evaluated separately after the plugin contract is stable. + +### 4.5 Licensing / data handling + +The Terms page currently displays a version dated 2026-09-23 that takes effect 2026-10-23, and says the previous Terms remain in force until then. The previous version was not independently obtained for this research, so the exact current license posture remains unverified. The displayed upcoming version does not itself grant WrzDJ a license to redistribute Lexicon or library data. Keep the integration user-initiated: the DJ runs their own Lexicon, selects tracks, and sends only fields necessary for their own WrzDJSet pool. Never bundle Lexicon software or transmit the full library automatically. Before enabling data transfer, verify the Terms then in force and document that the user-initiated plugin transfer is permitted; if the terms do not answer this or appear to prohibit it, hold the implementation for an operator decision and vendor clarification. + +## 5. Chosen architecture for the follow-up + +### Phase A — selected-track pool import + +Add `import_from_lexicon` as a separate #442-family import source. The user selects one or more Lexicon tracks in the plugin, enters a destination WrzDJSet ID, reviews the import, and submits a bounded batch to a DJ-scoped WrzDJ endpoint over HTTPS. Import only title and artist; omit Energy, ISRC, and other metadata. Lexicon's local track ID is not submitted or persisted. + +The endpoint resolves its owner from a narrow, revocable credential paired to that DJ; it must not accept a client-supplied owner ID. Store only a one-way token hash server-side. The token may call only the Lexicon import endpoint and its own revoke endpoint; the user enters a destination set ID from WrzDJ rather than giving this token a set-listing permission. Issue it from an authenticated WrzDJ account session, show the opaque token once, and expire it after 90 days. The plugin may retain the bearer in Lexicon's documented private action storage; do not claim Lexicon encrypts that storage, and explain that local users with access to the Lexicon profile can access it. The account page must always provide token listing/revocation, independent of the plugin. + +Validate only title and artist in this version, cap a request at 200 rows, and reject an oversized request atomically rather than splitting it. Persist an idempotency receipt keyed by credential and a client-generated request UUID for seven days: same key and body replays the original result; same key with a different body returns 409. Existing pool title/artist signature dedupe may conflate different versions with identical names, so the plugin preview must state that limitation before confirmation and the response must identify skipped duplicates. The import is additive through existing pool services and never writes `requests` or the shared `Track` store. If the import is an agent mutation, it belongs in the closed allowlist, requires `rationale`, and gets a regression test that proves `requests` remain untouched, matching #524. + +The production plugin must use a build-time fixed WrzDJ HTTPS origin that matches its network permission allowlist. Do not accept a runtime API base URL while attaching the bearer. Self-hosted/custom origins need a separately built plugin with a matching allowlist; do not weaken the production package to support arbitrary hosts. + +### Phase B — owner-scoped Lexicon Energy (blocked) + +Do not implement until both identity and origin are solved. Lexicon must document a stable per-recording identifier usable by WrzDJ (prefer ISRC) and a way to distinguish analyzer output from Find Tags/manual values, or WrzDJ must design an explicit per-track user-confirmed mapping with clear limitations. Then store the value only in a per-DJ WrzDJSet energy layer; never let a plugin-authenticated payload write the global `Track.energy` field at Lexicon precedence. Resolution order should be explicit DJ edit → that DJ's accepted Lexicon value → community → LLM, with Lexicon observations excluded from community vote aggregation. Removal/revocation must reveal the next lower tier without changing another DJ's data. + +If Lexicon later exposes a documented value and identity contract, the existing `lexicon: 90` master-store tier can be reconsidered only if the value is proven suitable for global sharing. The current reservation alone is not authorization to write owner-provided values to the shared store. + +## 6. Follow-up acceptance criteria + +For Phase A: + +- Plugin action reads selected tracks only and presents a preview before upload. +- Narrow pairing credential is DJ-scoped, revocable, stored as a one-way hash server-side, expires after 90 days, and is accepted only by the import and self-revoke endpoints. +- Endpoint rejects unknown fields, oversized batches, invalid/expired credentials, and non-owned set IDs with safe errors; it imports only title and artist. +- Same idempotency key and body replay the result; reusing a key with different content returns 409. Over-cap requests fail atomically. +- Imported tracks are additive, deduplicated through established pool rules, and undoable where routed through the agent mutation flow. +- The import does not write `requests`, does not write shared `Track.energy`, does not claim ISRC, and has no effect on another DJ's pool or vibe. +- Plugin uses a build-time fixed allowlisted HTTPS origin and works without Lexicon Local API being enabled. +- Contract version is checked from a header before body schema validation; older/newer plugin versions fail with a user-readable compatibility message. +- Before enabling the feature, verify and record that the Terms then in force permit user-initiated transfer to WrzDJ; otherwise hold for operator/vendor clarification. + +Phase B is not accepted until Lexicon's documented fields meet §5 Phase B prerequisites and the owner-scoped resolver has tests for precedence, isolation, removal, and community exclusion. + +## 7. Open external dependency + +Ask Lexicon to document whether the Local API/plugin Track object can expose ISRC, and whether it can identify values produced by the audio analyzer separately from Find Tags and manual edits. Until that is answered in public documentation, automated existing-track enrichment remains blocked. + +## 8. Self-review + +- The issue's premise that SetPoolTrack/TrackVibe energy is a single LLM field is corrected: these are separate systems, and the shared Track energy store is separate again. +- The issue's Local API description is corrected: current documentation says all interfaces and read/write endpoints, not localhost-only/read-only. +- The numeric schema and analyzer semantics are kept distinct: 0–10 identity mapping is known; per-value analyzer provenance is not. +- The plugin is not treated as a way to recover undocumented API fields. +- The current Terms remain unverified; the upcoming version is not used as permission to ship a transfer feature. A licensing check is a release gate. + +🤖 Co-authored by Codex gpt-6-luna. From 0f6cedcbda5da7b1daf6ce936649afef01147a34 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 16:04:36 -0700 Subject: [PATCH 07/18] localizations (#707) * feat(dashboard): add English and Spanish language preference Co-Authored-By: Codex * fix: address localization review findings Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../app/(dj)/account/__tests__/page.test.tsx | 15 +++++ dashboard/app/(dj)/account/page.tsx | 20 ++++++ .../(dj)/dashboard/__tests__/page.test.tsx | 18 +++++- dashboard/app/(dj)/dashboard/page.tsx | 22 ++++--- dashboard/app/layout.tsx | 17 +++-- dashboard/lib/__tests__/locale.test.tsx | 52 +++++++++++++++ dashboard/lib/locale.tsx | 46 +++++++++++++ dashboard/lib/locales.ts | 64 +++++++++++++++++++ 8 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 dashboard/lib/__tests__/locale.test.tsx create mode 100644 dashboard/lib/locale.tsx create mode 100644 dashboard/lib/locales.ts diff --git a/dashboard/app/(dj)/account/__tests__/page.test.tsx b/dashboard/app/(dj)/account/__tests__/page.test.tsx index 7c79abe2..eb5c3ee9 100644 --- a/dashboard/app/(dj)/account/__tests__/page.test.tsx +++ b/dashboard/app/(dj)/account/__tests__/page.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import AccountPage from '../page'; +import { LocaleProvider } from '@/lib/locale'; const mockPush = vi.fn(); vi.mock('next/navigation', () => ({ @@ -45,6 +46,8 @@ vi.mock('@/lib/api', () => ({ describe('AccountPage', () => { beforeEach(() => { vi.clearAllMocks(); + document.documentElement.lang = 'en'; + document.cookie = 'wrzdj-locale=; Path=/; Max-Age=0'; mockGetMe.mockResolvedValue({ id: 1, username: 'testuser', @@ -72,6 +75,18 @@ describe('AccountPage', () => { }); }); + it('saves the selected dashboard language', async () => { + render(); + + const language = screen.getByLabelText('Dashboard language'); + fireEvent.change(language, { target: { value: 'es' } }); + + expect(document.cookie).toContain('wrzdj-locale=es'); + expect(document.documentElement.lang).toBe('en'); + expect(screen.getByLabelText('Idioma del panel')).toHaveValue('es'); + expect(screen.getByText('Idioma del panel').closest('label')).toHaveAttribute('lang', 'es'); + }); + it('submits password change with correct payload', async () => { mockChangePassword.mockResolvedValue({ status: 'ok', message: 'Updated' }); render(); diff --git a/dashboard/app/(dj)/account/page.tsx b/dashboard/app/(dj)/account/page.tsx index 0c497b5f..156d2144 100644 --- a/dashboard/app/(dj)/account/page.tsx +++ b/dashboard/app/(dj)/account/page.tsx @@ -7,8 +7,11 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth'; import { api } from '@/lib/api'; import AiProvidersSection from '@/components/AiProvidersSection'; +import { LOCALES, type Locale } from '@/lib/locales'; +import { useLocale } from '@/lib/locale'; export default function AccountPage() { + const { locale, setLocale, messages } = useLocale(); const router = useRouter(); const { isAuthenticated, isLoading } = useAuth(); @@ -124,6 +127,23 @@ export default function AccountPage() {

Account Settings

+
+

{messages.language.heading}

+ + +
+

Change Password

{passwordSuccess ? ( diff --git a/dashboard/app/(dj)/dashboard/__tests__/page.test.tsx b/dashboard/app/(dj)/dashboard/__tests__/page.test.tsx index 2047e8e7..9d810f91 100644 --- a/dashboard/app/(dj)/dashboard/__tests__/page.test.tsx +++ b/dashboard/app/(dj)/dashboard/__tests__/page.test.tsx @@ -1,6 +1,7 @@ import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import DashboardPage from '../page'; +import { LocaleProvider } from '@/lib/locale'; const mockPush = vi.fn(); vi.mock('next/navigation', () => ({ @@ -8,8 +9,8 @@ vi.mock('next/navigation', () => ({ })); vi.mock('next/link', () => ({ - default: ({ children, href }: { children: React.ReactNode; href: string }) => ( - {children} + default: ({ children, href, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} ), })); @@ -99,6 +100,8 @@ function mockEvent(overrides = {}) { describe('DashboardPage', () => { beforeEach(() => { vi.clearAllMocks(); + document.documentElement.lang = 'en'; + document.cookie = 'wrzdj-locale=; Path=/; Max-Age=0'; mockRole = 'dj'; mockIsAuthenticated = true; mockIsLoading = false; @@ -120,6 +123,17 @@ describe('DashboardPage', () => { expect(screen.getByRole('link', { name: 'Account' })).toHaveAttribute('href', '/account'); }); + it('uses the saved dashboard locale for primary actions', async () => { + vi.mocked(api.getEvents).mockResolvedValue([]); + render(); + + const createEvent = await screen.findByRole('button', { name: 'Crear evento' }); + expect(createEvent).toHaveAttribute('lang', 'es'); + expect(screen.getByRole('link', { name: 'Cuenta' })).toHaveAttribute('href', '/account'); + expect(screen.getByRole('link', { name: 'Creador de sets' })).toHaveAttribute('lang', 'es'); + expect(document.documentElement.lang).toBe('en'); + }); + it('renders Set Builder link to /setbuilder', async () => { vi.mocked(api.getEvents).mockResolvedValue([]); render(); diff --git a/dashboard/app/(dj)/dashboard/page.tsx b/dashboard/app/(dj)/dashboard/page.tsx index 484efe09..4cc8dc75 100644 --- a/dashboard/app/(dj)/dashboard/page.tsx +++ b/dashboard/app/(dj)/dashboard/page.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/lib/auth'; import { api } from '@/lib/api'; import type { Event, TidalStatus, BeatportStatus, ActivityLogEntry } from '@/lib/api-types'; import { useHelp } from '@/lib/help/HelpContext'; +import { useLocale } from '@/lib/locale'; import { HelpSpot } from '@/components/help/HelpSpot'; import { HelpButton } from '@/components/help/HelpButton'; import { OnboardingOverlay } from '@/components/help/OnboardingOverlay'; @@ -16,6 +17,7 @@ import { CollectionFieldset, collectionSchema } from '@/components/CollectionFie const PAGE_ID = 'dashboard'; export default function DashboardPage() { + const { locale, messages } = useLocale(); const { isAuthenticated, isLoading, role, logout } = useAuth(); const { hasSeenPage, startOnboarding } = useHelp(); const router = useRouter(); @@ -207,7 +209,7 @@ export default function DashboardPage() {
-

Dashboard

+

{messages.dashboard.title}

{role === 'admin' && ( @@ -217,31 +219,33 @@ export default function DashboardPage() { )} - - Set Builder + {messages.dashboard.setBuilder} - Bridge App + {messages.dashboard.bridgeApp} - - Account + + {messages.dashboard.account} -
diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index 51e2d4bc..3f509842 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -2,7 +2,10 @@ import type { Metadata } from 'next'; import { DM_Sans, Plus_Jakarta_Sans, JetBrains_Mono, Space_Grotesk } from 'next/font/google'; import { AuthProvider } from '@/lib/auth'; import { HelpProvider } from '@/lib/help/HelpContext'; +import { LocaleProvider } from '@/lib/locale'; +import { LOCALE_COOKIE_NAME, localeOrDefault } from '@/lib/locales'; import { ThemeProvider } from '@/lib/theme'; +import { cookies } from 'next/headers'; import './globals.css'; const dmSans = DM_Sans({ @@ -42,18 +45,24 @@ export const viewport = { viewportFit: 'cover' as const, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const cookieStore = await cookies(); + const savedLocale = cookieStore.get(LOCALE_COOKIE_NAME)?.value ?? null; + const initialLocale = localeOrDefault(savedLocale); + return ( - - {children} - + + + {children} + + diff --git a/dashboard/lib/__tests__/locale.test.tsx b/dashboard/lib/__tests__/locale.test.tsx new file mode 100644 index 00000000..bf647828 --- /dev/null +++ b/dashboard/lib/__tests__/locale.test.tsx @@ -0,0 +1,52 @@ +import { act, render, screen } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { LocaleProvider, useLocale } from '../locale'; +import { localeOrDefault } from '../locales'; + +function LocaleProbe() { + const { locale, setLocale, messages } = useLocale(); + return ( +
+ {locale} + {messages.dashboard.createEvent} + +
+ ); +} + +describe('LocaleProvider', () => { + beforeEach(() => { + document.documentElement.lang = 'en'; + document.cookie = 'wrzdj-locale=; Path=/; Max-Age=0'; + }); + + it('renders a saved locale immediately on the server and client', () => { + expect(renderToString()).toContain('Crear evento'); + render(); + + expect(screen.getByTestId('locale')).toHaveTextContent('es'); + expect(screen.getByText('Crear evento')).toBeInTheDocument(); + expect(document.documentElement.lang).toBe('en'); + }); + + it('falls back to English for an unsupported saved locale', async () => { + expect(localeOrDefault('fr')).toBe('en'); + render(); + + expect(screen.getByTestId('locale')).toHaveTextContent('en'); + expect(document.documentElement.lang).toBe('en'); + }); + + it('persists a user locale selection and updates translated messages', async () => { + render(); + + await act(async () => { + screen.getByRole('button', { name: 'Choose Spanish' }).click(); + }); + + expect(screen.getByText('Crear evento')).toBeInTheDocument(); + expect(document.cookie).toContain('wrzdj-locale=es'); + expect(document.documentElement.lang).toBe('en'); + }); +}); diff --git a/dashboard/lib/locale.tsx b/dashboard/lib/locale.tsx new file mode 100644 index 00000000..5ae75520 --- /dev/null +++ b/dashboard/lib/locale.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; +import { LOCALE_COOKIE_NAME, LOCALE_MESSAGES, type Locale } from './locales'; + +interface LocaleContextValue { + locale: Locale; + setLocale: (locale: Locale) => void; + messages: (typeof LOCALE_MESSAGES)[Locale]; +} + +const defaultValue: LocaleContextValue = { + locale: 'en', + setLocale: () => undefined, + messages: LOCALE_MESSAGES.en, +}; + +const LocaleContext = createContext(defaultValue); + +export function LocaleProvider({ + children, + initialLocale = 'en', +}: { + children: ReactNode; + initialLocale?: Locale; +}) { + const [locale, setLocaleState] = useState(initialLocale); + + const setLocale = useCallback((nextLocale: Locale) => { + setLocaleState(nextLocale); + try { + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${LOCALE_COOKIE_NAME}=${nextLocale}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`; + } catch { + // The in-memory preference still applies when cookies are unavailable. + } + }, []); + + const value = useMemo(() => ({ locale, setLocale, messages: LOCALE_MESSAGES[locale] }), [locale, setLocale]); + + return {children}; +} + +export function useLocale(): LocaleContextValue { + return useContext(LocaleContext); +} diff --git a/dashboard/lib/locales.ts b/dashboard/lib/locales.ts new file mode 100644 index 00000000..5bc34804 --- /dev/null +++ b/dashboard/lib/locales.ts @@ -0,0 +1,64 @@ +export const LOCALES = ['en', 'es'] as const; +export const LOCALE_COOKIE_NAME = 'wrzdj-locale'; + +export type Locale = (typeof LOCALES)[number]; + +export interface LocaleMessages { + dashboard: { + title: string; + createEvent: string; + setBuilder: string; + bridgeApp: string; + account: string; + logout: string; + }; + language: { + heading: string; + label: string; + english: string; + spanish: string; + }; +} + +export const LOCALE_MESSAGES: Record = { + en: { + dashboard: { + title: 'Dashboard', + createEvent: 'Create Event', + setBuilder: 'Set Builder', + bridgeApp: 'Bridge App', + account: 'Account', + logout: 'Logout', + }, + language: { + heading: 'Language', + label: 'Dashboard language', + english: 'English', + spanish: 'Spanish', + }, + }, + es: { + dashboard: { + title: 'Panel', + createEvent: 'Crear evento', + setBuilder: 'Creador de sets', + bridgeApp: 'Aplicación Bridge', + account: 'Cuenta', + logout: 'Cerrar sesión', + }, + language: { + heading: 'Idioma', + label: 'Idioma del panel', + english: 'Inglés', + spanish: 'Español', + }, + }, +}; + +export function isLocale(value: string | null): value is Locale { + return value !== null && LOCALES.includes(value as Locale); +} + +export function localeOrDefault(value: string | null): Locale { + return isLocale(value) ? value : 'en'; +} From c38858759d4c7c48d68cb0fa5cfdd56863f9dbe3 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 16:05:59 -0700 Subject: [PATCH 08/18] feat(bridge): add Serato crate writer (#708) * feat(bridge): add Serato crate writer Co-Authored-By: Codex * fix(bridge): validate and safely publish Serato crates Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../src/__tests__/serato-crate-writer.test.ts | 125 ++++++++++++++++++ bridge/src/plugins/serato-crate-writer.ts | 110 +++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 bridge/src/__tests__/serato-crate-writer.test.ts create mode 100644 bridge/src/plugins/serato-crate-writer.ts diff --git a/bridge/src/__tests__/serato-crate-writer.test.ts b/bridge/src/__tests__/serato-crate-writer.test.ts new file mode 100644 index 00000000..65eaa309 --- /dev/null +++ b/bridge/src/__tests__/serato-crate-writer.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + renderSeratoCrate, + writeSeratoCrate, +} from "../plugins/serato-crate-writer.js"; + +const directories: string[] = []; + +function createDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "serato-crate-test-")); + directories.push(directory); + return directory; +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("Serato crate writer", () => { + it("matches the known crate bytes without adding a UTF-16 terminator", () => { + const expected = Buffer.from( + "7672736e000000380031002e0030002f00530065007200610074006f00200053006300720061007400630068004c006900760065002000430072006100740065" + + "6f74726b0000001e7074726b00000016004d0075007300690063002f0061002e006d00700033", + "hex", + ); + + expect(renderSeratoCrate([{ volumeRelativePath: "Music/a.mp3" }])).toEqual(expected); + }); + + it("encodes non-ASCII volume-relative paths as UTF-16BE", () => { + const expectedPath = Buffer.from("0044004a002f6b21", "hex"); + const rendered = renderSeratoCrate([{ volumeRelativePath: "DJ/次" }]); + + expect(rendered.includes(expectedPath)).toBe(true); + }); + + it("writes a new crate and returns its path", async () => { + const directory = createDirectory(); + const tracks = [{ volumeRelativePath: "Music/Artist/Track.mp3" }]; + + const outputPath = await writeSeratoCrate(directory, "Summer Set", tracks); + + expect(outputPath).toBe(join(directory, "Summer Set.crate")); + expect(readFileSync(outputPath)).toEqual(renderSeratoCrate(tracks)); + expect(readdirSync(directory)).toEqual(["Summer Set.crate"]); + }); + + it("refuses to overwrite an existing crate and preserves its contents", async () => { + const directory = createDirectory(); + const outputPath = join(directory, "Summer Set.crate"); + writeFileSync(outputPath, "existing crate"); + + await expect( + writeSeratoCrate(directory, "Summer Set", [ + { volumeRelativePath: "Music/Artist/Track.mp3" }, + ]), + ).rejects.toThrow(); + + expect(readFileSync(outputPath, "utf8")).toBe("existing crate"); + expect(readdirSync(directory)).toEqual(["Summer Set.crate"]); + }); + + it("allows only one of two concurrent writes to create the same crate", async () => { + const directory = createDirectory(); + const tracks = [{ volumeRelativePath: "Music/Artist/Track.mp3" }]; + + const outcomes = await Promise.allSettled([ + writeSeratoCrate(directory, "Summer Set", tracks), + writeSeratoCrate(directory, "Summer Set", tracks), + ]); + + expect(outcomes.filter((outcome) => outcome.status === "fulfilled")).toHaveLength(1); + expect(outcomes.filter((outcome) => outcome.status === "rejected")).toHaveLength(1); + expect(readdirSync(directory)).toEqual(["Summer Set.crate"]); + expect(readFileSync(join(directory, "Summer Set.crate"))).toEqual( + renderSeratoCrate(tracks), + ); + }); + + it.each([ + "", + ".", + "..", + "../escape", + "folder/name", + "folder\\name", + "bad\nname", + "a:b", + "CON", + "NUL", + "COM1", + "CONIN$", + "safe%%nested", + "trailing.", + ])( + "rejects unsafe crate name %j", + async (crateName) => { + const directory = createDirectory(); + + await expect( + writeSeratoCrate(directory, crateName, [ + { volumeRelativePath: "Music/Artist/Track.mp3" }, + ]), + ).rejects.toThrow(); + + expect(readdirSync(directory)).toEqual([]); + }, + ); + + it.each([ + "", + "/Music/Artist/Track.mp3", + "Music/../outside.mp3", + "Music\\Artist\\Track.mp3", + "Music/Artist/\u0000Track.mp3", + ])("rejects unsafe volume-relative path %j", (volumeRelativePath) => { + expect(() => renderSeratoCrate([{ volumeRelativePath }])).toThrow(); + }); +}); diff --git a/bridge/src/plugins/serato-crate-writer.ts b/bridge/src/plugins/serato-crate-writer.ts new file mode 100644 index 00000000..926c7bf7 --- /dev/null +++ b/bridge/src/plugins/serato-crate-writer.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "crypto"; +import { link, open, stat, unlink } from "fs/promises"; +import { join } from "path"; + +const CRATE_VERSION = "1.0/Serato ScratchLive Crate"; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const WINDOWS_DEVICE_NAME = /^(?:con|conin\$|conout\$|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/iu; + +export interface SeratoCrateTrack { + /** Path relative to the root of the volume containing the audio file. */ + readonly volumeRelativePath: string; +} + +function validateCrateName(crateName: string): void { + if ( + !crateName || + crateName !== crateName.trim() || + crateName === "." || + crateName === ".." || + crateName.startsWith(".") || + crateName.endsWith(".") || + crateName.toLowerCase().endsWith(".crate") || + crateName.includes("%%") || + /[<>:"/\\|?*]/u.test(crateName) || + WINDOWS_DEVICE_NAME.test(crateName) || + CONTROL_CHARACTERS.test(crateName) + ) { + throw new TypeError("Crate name must be a plain file name without an extension"); + } +} + +function validateVolumeRelativePath(value: string): void { + const segments = value.split("/"); + if ( + !value || + value.startsWith("/") || + value.includes("\\") || + CONTROL_CHARACTERS.test(value) || + segments.some((segment) => !segment || segment === "." || segment === "..") + ) { + throw new TypeError("Track path must be a safe volume-relative POSIX path"); + } +} + +function encodeUtf16BE(value: string): Buffer { + const output = Buffer.alloc(value.length * 2); + for (let index = 0; index < value.length; index += 1) { + output.writeUInt16BE(value.charCodeAt(index), index * 2); + } + return output; +} + +function chunk(tag: "vrsn" | "otrk" | "ptrk", payload: Buffer): Buffer { + const header = Buffer.alloc(8); + header.write(tag, 0, 4, "ascii"); + header.writeUInt32BE(payload.length, 4); + return Buffer.concat([header, payload]); +} + +/** Render a Serato crate containing already-resolved local audio paths. */ +export function renderSeratoCrate(tracks: readonly SeratoCrateTrack[]): Buffer { + for (const track of tracks) { + validateVolumeRelativePath(track.volumeRelativePath); + } + + const entries = tracks.map(({ volumeRelativePath }) => + chunk("otrk", chunk("ptrk", encodeUtf16BE(volumeRelativePath))), + ); + return Buffer.concat([chunk("vrsn", encodeUtf16BE(CRATE_VERSION)), ...entries]); +} + +/** + * Create a new crate file without replacing an existing one. + * The completed temporary file is linked into place atomically. Existing crate + * names cause a conflict rather than replacing the destination. + */ +export async function writeSeratoCrate( + directory: string, + crateName: string, + tracks: readonly SeratoCrateTrack[], +): Promise { + validateCrateName(crateName); + const output = join(directory, `${crateName}.crate`); + const temporary = join(directory, `.${crateName}.${randomUUID()}.tmp`); + const bytes = renderSeratoCrate(tracks); + const directoryStat = await stat(directory); + if (!directoryStat.isDirectory()) { + throw new TypeError("Crate destination must be an existing directory"); + } + + const file = await open(temporary, "wx", 0o600); + try { + try { + await file.writeFile(bytes); + await file.sync(); + } finally { + await file.close(); + } + await link(temporary, output); + return output; + } finally { + try { + await unlink(temporary); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + throw error; + } + } + } +} From 92085dbef2f15047e46a16d70d7d89a07cb32e54 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 16:09:25 -0700 Subject: [PATCH 09/18] =?UTF-8?q?WrzDJSet:=20Collaboration=20=E2=80=94=20i?= =?UTF-8?q?nvite-link=20editors=20via=20SetCollaborator=20(#709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setbuilder): add collaborator invite links Co-Authored-By: Codex * fix(setbuilder): align collaborator invite index migration Co-Authored-By: Codex * fix(setbuilder): address collaboration invite review findings Co-Authored-By: Codex --------- Co-authored-by: Codex --- dashboard/lib/api-types.generated.ts | 248 ++++++++++++++ dashboard/lib/api-types.ts | 24 ++ dashboard/lib/api.ts | 25 ++ .../067_add_set_collaborator_invites.py | 46 +++ .../versions/068_unique_set_collaborators.py | 23 ++ server/app/api/setbuilder.py | 91 +++++ server/app/models/__init__.py | 2 + server/app/models/set.py | 2 + server/app/models/set_collaborator_invite.py | 27 ++ server/app/schemas/setbuilder.py | 24 ++ .../app/services/setbuilder/collaboration.py | 140 ++++++++ server/openapi.json | 317 ++++++++++++++++++ server/tests/test_setbuilder_collaboration.py | 189 +++++++++++ 13 files changed, 1158 insertions(+) create mode 100644 server/alembic/versions/067_add_set_collaborator_invites.py create mode 100644 server/alembic/versions/068_unique_set_collaborators.py create mode 100644 server/app/models/set_collaborator_invite.py create mode 100644 server/app/services/setbuilder/collaboration.py create mode 100644 server/tests/test_setbuilder_collaboration.py diff --git a/dashboard/lib/api-types.generated.ts b/dashboard/lib/api-types.generated.ts index e089b6ed..a7ab3818 100644 --- a/dashboard/lib/api-types.generated.ts +++ b/dashboard/lib/api-types.generated.ts @@ -2543,6 +2543,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/setbuilder/collaborator-invites/{token}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept Collaborator Invite + * @description Accept an invite with the recipient's active DJ account. + */ + post: operations["accept_collaborator_invite_api_setbuilder_collaborator_invites__token__accept_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/setbuilder/curve-templates": { parameters: { query?: never; @@ -2787,6 +2807,50 @@ export interface paths { patch?: never; trace?: never; }; + "/api/setbuilder/sets/{set_id}/collaborator-invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Collaborator Invites + * @description List invitation status for an owned set without exposing invite tokens. + */ + get: operations["list_collaborator_invites_api_setbuilder_sets__set_id__collaborator_invites_get"]; + put?: never; + /** + * Create Collaborator Invite + * @description Create a single-use editor or viewer invitation for an owned set. + */ + post: operations["create_collaborator_invite_api_setbuilder_sets__set_id__collaborator_invites_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/setbuilder/sets/{set_id}/collaborator-invites/{invite_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke Collaborator Invite + * @description Revoke an unused invitation for an owned set. + */ + delete: operations["revoke_collaborator_invite_api_setbuilder_sets__set_id__collaborator_invites__invite_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/setbuilder/sets/{set_id}/critique": { parameters: { query?: never; @@ -4462,6 +4526,63 @@ export interface components { /** New Password */ new_password: string; }; + /** CollaboratorAccepted */ + CollaboratorAccepted: { + /** + * Role + * @enum {string} + */ + role: "editor" | "viewer"; + /** Set Id */ + set_id: number; + }; + /** CollaboratorInviteCreate */ + CollaboratorInviteCreate: { + /** + * Role + * @enum {string} + */ + role: "editor" | "viewer"; + }; + /** CollaboratorInviteCreated */ + CollaboratorInviteCreated: { + /** + * Expires At + * Format: date-time + */ + expires_at: string; + /** + * Role + * @enum {string} + */ + role: "editor" | "viewer"; + /** Token */ + token: string; + }; + /** CollaboratorInviteOut */ + CollaboratorInviteOut: { + /** Accepted */ + accepted: boolean; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Expires At + * Format: date-time + */ + expires_at: string; + /** Id */ + id: number; + /** Revoked */ + revoked: boolean; + /** + * Role + * @enum {string} + */ + role: "editor" | "viewer"; + }; /** CollectEventPreview */ CollectEventPreview: { /** Banner Colors */ @@ -12046,6 +12167,37 @@ export interface operations { }; }; }; + accept_collaborator_invite_api_setbuilder_collaborator_invites__token__accept_post: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollaboratorAccepted"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_curve_templates_api_setbuilder_curve_templates_get: { parameters: { query?: never; @@ -12530,6 +12682,102 @@ export interface operations { }; }; }; + list_collaborator_invites_api_setbuilder_sets__set_id__collaborator_invites_get: { + parameters: { + query?: never; + header?: never; + path: { + set_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollaboratorInviteOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_collaborator_invite_api_setbuilder_sets__set_id__collaborator_invites_post: { + parameters: { + query?: never; + header?: never; + path: { + set_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CollaboratorInviteCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollaboratorInviteCreated"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + revoke_collaborator_invite_api_setbuilder_sets__set_id__collaborator_invites__invite_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + set_id: number; + invite_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; critique_set_api_setbuilder_sets__set_id__critique_post: { parameters: { query?: never; diff --git a/dashboard/lib/api-types.ts b/dashboard/lib/api-types.ts index dddcf8a5..a84e8ca0 100644 --- a/dashboard/lib/api-types.ts +++ b/dashboard/lib/api-types.ts @@ -193,6 +193,30 @@ export interface SetSummary { updated_at: string; } +export interface CollaboratorInviteCreate { + role: 'editor' | 'viewer'; +} + +export interface CollaboratorInviteCreated { + token: string; + role: 'editor' | 'viewer'; + expires_at: string; +} + +export interface CollaboratorInvite { + id: number; + role: 'editor' | 'viewer'; + created_at: string; + expires_at: string; + accepted: boolean; + revoked: boolean; +} + +export interface CollaboratorAccepted { + set_id: number; + role: 'editor' | 'viewer'; +} + export interface SetDetail extends SetSummary { vibe_theme: string | null; target_duration_sec: number | null; diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts index c86d0fd9..82f27956 100644 --- a/dashboard/lib/api.ts +++ b/dashboard/lib/api.ts @@ -30,6 +30,9 @@ import type { BridgeCommandResponse, BuildSetResponse, BuilderPlaylists, + CollaboratorAccepted, + CollaboratorInvite, + CollaboratorInviteCreated, DisplaySettingsResponse, PublicBridgeStatus, Event, @@ -985,6 +988,28 @@ class ApiClient { async revokeSetShare(setId: number): Promise { await this.rawFetch(`/api/setbuilder/sets/${setId}/share`, { method: 'DELETE' }); } + async createCollaboratorInvite( + setId: number, + role: 'editor' | 'viewer' + ): Promise { + return this.fetch(`/api/setbuilder/sets/${setId}/collaborator-invites`, { + method: 'POST', + body: JSON.stringify({ role }), + }); + } + async listCollaboratorInvites(setId: number): Promise { + return this.fetch(`/api/setbuilder/sets/${setId}/collaborator-invites`); + } + async revokeCollaboratorInvite(setId: number, inviteId: number): Promise { + await this.rawFetch(`/api/setbuilder/sets/${setId}/collaborator-invites/${inviteId}`, { + method: 'DELETE', + }); + } + async acceptCollaboratorInvite(token: string): Promise { + return this.fetch(`/api/setbuilder/collaborator-invites/${encodeURIComponent(token)}/accept`, { + method: 'POST', + }); + } /** Public, unauthenticated read-only view of a shared set. */ async getSharedSet(token: string): Promise { return this.publicFetch( diff --git a/server/alembic/versions/067_add_set_collaborator_invites.py b/server/alembic/versions/067_add_set_collaborator_invites.py new file mode 100644 index 00000000..7ab658c0 --- /dev/null +++ b/server/alembic/versions/067_add_set_collaborator_invites.py @@ -0,0 +1,46 @@ +"""Add single-use set collaborator invite links (issue #408). + +Revision ID: 067 +Revises: 066 +Create Date: 2026-10-03 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "067" +down_revision = "066" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "set_collaborator_invites", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("set_id", sa.Integer(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("accepted_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["set_id"], ["sets.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_set_collaborator_invites_set_id", "set_collaborator_invites", ["set_id"]) + op.create_index( + "ix_set_collaborator_invites_token_hash", + "set_collaborator_invites", + ["token_hash"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_set_collaborator_invites_token_hash", table_name="set_collaborator_invites") + op.drop_index("ix_set_collaborator_invites_set_id", table_name="set_collaborator_invites") + op.drop_table("set_collaborator_invites") diff --git a/server/alembic/versions/068_unique_set_collaborators.py b/server/alembic/versions/068_unique_set_collaborators.py new file mode 100644 index 00000000..a679b5ae --- /dev/null +++ b/server/alembic/versions/068_unique_set_collaborators.py @@ -0,0 +1,23 @@ +"""Prevent duplicate SetCollaborator memberships (issue #408). + +Revision ID: 068 +Revises: 067 +Create Date: 2026-10-03 +""" + +from alembic import op + +revision = "068" +down_revision = "067" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_unique_constraint( + "uq_set_collaborators_set_user", "set_collaborators", ["set_id", "user_id"] + ) + + +def downgrade() -> None: + op.drop_constraint("uq_set_collaborators_set_user", "set_collaborators", type_="unique") diff --git a/server/app/api/setbuilder.py b/server/app/api/setbuilder.py index 3e973f8b..e15461b0 100644 --- a/server/app/api/setbuilder.py +++ b/server/app/api/setbuilder.py @@ -43,6 +43,10 @@ BuildSetRequest, BuildSetResponse, BuiltinTemplateOut, + CollaboratorAccepted, + CollaboratorInviteCreate, + CollaboratorInviteCreated, + CollaboratorInviteOut, CommunityVibeOut, CritiqueFlagOut, CurvePointModel, @@ -111,6 +115,7 @@ from app.services.now_playing import get_now_playing from app.services.setbuilder import ( agent_history, + collaboration, curve, document_snapshot, export_common, @@ -142,6 +147,92 @@ def _get_owned_or_404(db: Session, set_id: int, user: User) -> Set: return set_obj +@router.post( + "/sets/{set_id}/collaborator-invites", + response_model=CollaboratorInviteCreated, + status_code=status.HTTP_201_CREATED, +) +@limiter.limit("10/minute") +def create_collaborator_invite( + set_id: int, + payload: CollaboratorInviteCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> CollaboratorInviteCreated: + """Create a single-use editor or viewer invitation for an owned set.""" + set_obj = _get_owned_or_404(db, set_id, current_user) + invite, token = collaboration.create_invite(db, set_obj.id, current_user, payload.role) + return CollaboratorInviteCreated(token=token, role=invite.role, expires_at=invite.expires_at) + + +@router.get("/sets/{set_id}/collaborator-invites", response_model=list[CollaboratorInviteOut]) +@limiter.limit("30/minute") +def list_collaborator_invites( + set_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> list[CollaboratorInviteOut]: + """List invitation status for an owned set without exposing invite tokens.""" + _get_owned_or_404(db, set_id, current_user) + return [ + CollaboratorInviteOut( + id=invite.id, + role=invite.role, + created_at=invite.created_at, + expires_at=invite.expires_at, + accepted=invite.accepted_at is not None, + revoked=invite.revoked_at is not None, + ) + for invite in collaboration.list_invites(db, set_id) + ] + + +@router.delete("/sets/{set_id}/collaborator-invites/{invite_id}", status_code=204) +@limiter.limit("30/minute") +def revoke_collaborator_invite( + set_id: int, + invite_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> None: + """Revoke an unused invitation for an owned set.""" + _get_owned_or_404(db, set_id, current_user) + invite = collaboration.get_invite_for_owner(db, set_id, invite_id) + if invite is None: + raise HTTPException(status_code=404, detail="Invite not found") + try: + collaboration.revoke_invite(db, invite) + except collaboration.InviteAlreadyAccepted as exc: + raise HTTPException( + status_code=409, detail="Invite was accepted; revoke the collaborator instead" + ) from exc + + +@router.post("/collaborator-invites/{token}/accept", response_model=CollaboratorAccepted) +@limiter.limit("10/minute") +def accept_collaborator_invite( + token: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> CollaboratorAccepted: + """Accept an invite with the recipient's active DJ account.""" + try: + collaborator = collaboration.accept_invite(db, token, current_user) + except collaboration.InviteUnavailable as exc: + raise HTTPException(status_code=410, detail="Invite is no longer available") from exc + except collaboration.AlreadyCollaborator as exc: + raise HTTPException(status_code=409, detail="Already a collaborator") from exc + except collaboration.OwnerCannotAcceptInvite as exc: + raise HTTPException( + status_code=409, detail="Set owners cannot accept collaborator invites" + ) from exc + return CollaboratorAccepted(set_id=collaborator.set_id, role=collaborator.role) + + def _transition_scores_out( scores: list[pass1_deterministic.TransitionScore], ) -> list[TransitionScoreOut]: diff --git a/server/app/models/__init__.py b/server/app/models/__init__.py index 26f28d53..734a157f 100644 --- a/server/app/models/__init__.py +++ b/server/app/models/__init__.py @@ -17,6 +17,7 @@ from app.models.search_cache import SearchCache from app.models.set import Set, SetCollaborator, SetCurvePoint, SetSlot from app.models.set_agent import SetAgentMessage, SetAgentSession +from app.models.set_collaborator_invite import SetCollaboratorInvite from app.models.set_pairing import SetPairing from app.models.set_pool import SetPoolSource, SetPoolTrack from app.models.set_taste_profile import SetTasteProfileReset @@ -49,6 +50,7 @@ "SetAgentMessage", "SetAgentSession", "SetCollaborator", + "SetCollaboratorInvite", "SetCurvePoint", "SetCurveTemplate", "SetPairing", diff --git a/server/app/models/set.py b/server/app/models/set.py index 70c054eb..b00096d9 100644 --- a/server/app/models/set.py +++ b/server/app/models/set.py @@ -19,6 +19,7 @@ Integer, String, Text, + UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -150,6 +151,7 @@ class SetCollaborator(Base): """Modeled v1, enforced v3.""" __tablename__ = "set_collaborators" + __table_args__ = (UniqueConstraint("set_id", "user_id", name="uq_set_collaborators_set_user"),) id: Mapped[int] = mapped_column(primary_key=True) set_id: Mapped[int] = mapped_column( diff --git a/server/app/models/set_collaborator_invite.py b/server/app/models/set_collaborator_invite.py new file mode 100644 index 00000000..e6c79bf6 --- /dev/null +++ b/server/app/models/set_collaborator_invite.py @@ -0,0 +1,27 @@ +"""Single-use invite links for SetBuilder collaborators.""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.time import utcnow +from app.models.base import Base + + +class SetCollaboratorInvite(Base): + __tablename__ = "set_collaborator_invites" + + id: Mapped[int] = mapped_column(primary_key=True) + set_id: Mapped[int] = mapped_column( + ForeignKey("sets.id", ondelete="CASCADE"), nullable=False, index=True + ) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + role: Mapped[str] = mapped_column(String(20), nullable=False) + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/server/app/schemas/setbuilder.py b/server/app/schemas/setbuilder.py index 47500269..8c83763a 100644 --- a/server/app/schemas/setbuilder.py +++ b/server/app/schemas/setbuilder.py @@ -28,6 +28,30 @@ class SetTargetUpdate(BaseModel): avg_transition_overlap_sec: int = Field(..., ge=0, le=32) +class CollaboratorInviteCreate(BaseModel): + role: Literal["editor", "viewer"] + + +class CollaboratorInviteCreated(BaseModel): + token: str + role: Literal["editor", "viewer"] + expires_at: datetime + + +class CollaboratorInviteOut(BaseModel): + id: int + role: Literal["editor", "viewer"] + created_at: datetime + expires_at: datetime + accepted: bool + revoked: bool + + +class CollaboratorAccepted(BaseModel): + set_id: int + role: Literal["editor", "viewer"] + + class SetSummary(BaseModel): """Set list item (no children).""" diff --git a/server/app/services/setbuilder/collaboration.py b/server/app/services/setbuilder/collaboration.py new file mode 100644 index 00000000..33b4295d --- /dev/null +++ b/server/app/services/setbuilder/collaboration.py @@ -0,0 +1,140 @@ +"""Invite-link creation and acceptance for SetBuilder collaboration.""" + +import hashlib +import re +import secrets +from datetime import timedelta + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.time import utcnow +from app.models.set import Set, SetCollaborator +from app.models.set_collaborator_invite import SetCollaboratorInvite +from app.models.user import User + +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{40,64}$") +_INVITE_LIFETIME = timedelta(days=7) + + +class InviteUnavailable(Exception): + """The invite is missing, expired, revoked, or already used.""" + + +class AlreadyCollaborator(Exception): + """The accepting user already collaborates on this set.""" + + +class OwnerCannotAcceptInvite(Exception): + """A set owner cannot join their own set as a collaborator.""" + + +class InviteAlreadyAccepted(Exception): + """An accepted invite cannot be revoked; revoke the collaborator instead.""" + + +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode("ascii")).hexdigest() + + +def create_invite( + db: Session, set_id: int, user: User, role: str +) -> tuple[SetCollaboratorInvite, str]: + token = secrets.token_urlsafe(32) + invite = SetCollaboratorInvite( + set_id=set_id, + token_hash=_token_hash(token), + role=role, + created_by=user.id, + expires_at=utcnow() + _INVITE_LIFETIME, + ) + db.add(invite) + db.commit() + db.refresh(invite) + return invite, token + + +def list_invites(db: Session, set_id: int) -> list[SetCollaboratorInvite]: + return ( + db.query(SetCollaboratorInvite) + .filter(SetCollaboratorInvite.set_id == set_id) + .order_by(SetCollaboratorInvite.id.desc()) + .all() + ) + + +def revoke_invite(db: Session, invite: SetCollaboratorInvite) -> None: + invite = ( + db.query(SetCollaboratorInvite) + .filter(SetCollaboratorInvite.id == invite.id) + .populate_existing() + .with_for_update() + .one() + ) + if invite.accepted_at is not None: + raise InviteAlreadyAccepted + if invite.revoked_at is None: + invite.revoked_at = utcnow() + db.commit() + + +def accept_invite(db: Session, token: str, user: User) -> SetCollaborator: + if not _TOKEN_RE.fullmatch(token): + raise InviteUnavailable + invite = ( + db.query(SetCollaboratorInvite) + .filter(SetCollaboratorInvite.token_hash == _token_hash(token)) + .with_for_update() + .one_or_none() + ) + now = utcnow() + if ( + invite is None + or invite.revoked_at is not None + or invite.accepted_at is not None + or invite.expires_at <= now + ): + raise InviteUnavailable + set_owner_id = db.query(Set.owner_id).filter(Set.id == invite.set_id).scalar() + if set_owner_id == user.id: + raise OwnerCannotAcceptInvite + exists = ( + db.query(SetCollaborator) + .filter(SetCollaborator.set_id == invite.set_id, SetCollaborator.user_id == user.id) + .first() + ) + if exists: + raise AlreadyCollaborator + collaborator = SetCollaborator( + set_id=invite.set_id, + user_id=user.id, + role=invite.role, + invited_by=invite.created_by, + ) + invite.accepted_at = now + db.add(collaborator) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + exists = ( + db.query(SetCollaborator) + .filter(SetCollaborator.set_id == invite.set_id, SetCollaborator.user_id == user.id) + .first() + ) + if exists: + raise AlreadyCollaborator from exc + raise + db.refresh(collaborator) + return collaborator + + +def get_invite_for_owner(db: Session, set_id: int, invite_id: int) -> SetCollaboratorInvite | None: + return ( + db.query(SetCollaboratorInvite) + .filter( + SetCollaboratorInvite.id == invite_id, + SetCollaboratorInvite.set_id == set_id, + ) + .one_or_none() + ) diff --git a/server/openapi.json b/server/openapi.json index 0b9f15fd..e8b32b32 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1975,6 +1975,117 @@ "title": "ChangePasswordRequest", "type": "object" }, + "CollaboratorAccepted": { + "properties": { + "role": { + "enum": [ + "editor", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "set_id": { + "title": "Set Id", + "type": "integer" + } + }, + "required": [ + "role", + "set_id" + ], + "title": "CollaboratorAccepted", + "type": "object" + }, + "CollaboratorInviteCreate": { + "properties": { + "role": { + "enum": [ + "editor", + "viewer" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "role" + ], + "title": "CollaboratorInviteCreate", + "type": "object" + }, + "CollaboratorInviteCreated": { + "properties": { + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "role": { + "enum": [ + "editor", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "token": { + "title": "Token", + "type": "string" + } + }, + "required": [ + "expires_at", + "role", + "token" + ], + "title": "CollaboratorInviteCreated", + "type": "object" + }, + "CollaboratorInviteOut": { + "properties": { + "accepted": { + "title": "Accepted", + "type": "boolean" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "revoked": { + "title": "Revoked", + "type": "boolean" + }, + "role": { + "enum": [ + "editor", + "viewer" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "accepted", + "created_at", + "expires_at", + "id", + "revoked", + "role" + ], + "title": "CollaboratorInviteOut", + "type": "object" + }, "CollectEventPreview": { "properties": { "banner_colors": { @@ -18187,6 +18298,54 @@ ] } }, + "/api/setbuilder/collaborator-invites/{token}/accept": { + "post": { + "description": "Accept an invite with the recipient's active DJ account.", + "operationId": "accept_collaborator_invite_api_setbuilder_collaborator_invites__token__accept_post", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "title": "Token", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollaboratorAccepted" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Accept Collaborator Invite", + "tags": [ + "setbuilder" + ] + } + }, "/api/setbuilder/curve-templates": { "get": { "description": "Built-in templates plus the current DJ's saved templates.", @@ -18898,6 +19057,164 @@ ] } }, + "/api/setbuilder/sets/{set_id}/collaborator-invites": { + "get": { + "description": "List invitation status for an owned set without exposing invite tokens.", + "operationId": "list_collaborator_invites_api_setbuilder_sets__set_id__collaborator_invites_get", + "parameters": [ + { + "in": "path", + "name": "set_id", + "required": true, + "schema": { + "title": "Set Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CollaboratorInviteOut" + }, + "title": "Response List Collaborator Invites Api Setbuilder Sets Set Id Collaborator Invites Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Collaborator Invites", + "tags": [ + "setbuilder" + ] + }, + "post": { + "description": "Create a single-use editor or viewer invitation for an owned set.", + "operationId": "create_collaborator_invite_api_setbuilder_sets__set_id__collaborator_invites_post", + "parameters": [ + { + "in": "path", + "name": "set_id", + "required": true, + "schema": { + "title": "Set Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollaboratorInviteCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollaboratorInviteCreated" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Create Collaborator Invite", + "tags": [ + "setbuilder" + ] + } + }, + "/api/setbuilder/sets/{set_id}/collaborator-invites/{invite_id}": { + "delete": { + "description": "Revoke an unused invitation for an owned set.", + "operationId": "revoke_collaborator_invite_api_setbuilder_sets__set_id__collaborator_invites__invite_id__delete", + "parameters": [ + { + "in": "path", + "name": "set_id", + "required": true, + "schema": { + "title": "Set Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "invite_id", + "required": true, + "schema": { + "title": "Invite Id", + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Revoke Collaborator Invite", + "tags": [ + "setbuilder" + ] + } + }, "/api/setbuilder/sets/{set_id}/critique": { "post": { "description": "Run pass 2 auto-critique through the LLM gateway.", diff --git a/server/tests/test_setbuilder_collaboration.py b/server/tests/test_setbuilder_collaboration.py new file mode 100644 index 00000000..b87301d7 --- /dev/null +++ b/server/tests/test_setbuilder_collaboration.py @@ -0,0 +1,189 @@ +"""Invite-link collaboration API coverage for issue #408.""" + +from app.models.set import Set, SetCollaborator +from app.services.auth import get_password_hash + + +def _login(client, username, password): + response = client.post("/api/auth/login", data={"username": username, "password": password}) + assert response.status_code == 200, response.json() + return {"Authorization": f"Bearer {response.json()['access_token']}"} + + +def _make_dj(db, username): + from app.models.user import User + + user = User(username=username, password_hash=get_password_hash("x" * 12), role="dj") + db.add(user) + db.commit() + return user + + +def test_owner_can_issue_invite_and_recipient_accepts_once(client, db, test_user, auth_headers): + existing_share_token = "s" * 43 + set_obj = Set(owner_id=test_user.id, name="Collab set", share_token=existing_share_token) + db.add(set_obj) + db.commit() + recipient = _make_dj(db, "collab-dj") + recipient_headers = _login(client, "collab-dj", "x" * 12) + + created = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "editor"}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + invite = created.json() + assert invite["role"] == "editor" + assert len(invite["token"]) >= 40 + assert invite["expires_at"] + + accepted = client.post( + f"/api/setbuilder/collaborator-invites/{invite['token']}/accept", + headers=recipient_headers, + ) + assert accepted.status_code == 200, accepted.text + assert accepted.json() == {"set_id": set_obj.id, "role": "editor"} + collaboration = db.query(SetCollaborator).one() + assert collaboration.user_id == recipient.id + assert collaboration.invited_by == test_user.id + + second_invite = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "viewer"}, + headers=auth_headers, + ) + assert second_invite.status_code == 201 + duplicate = client.post( + f"/api/setbuilder/collaborator-invites/{second_invite.json()['token']}/accept", + headers=recipient_headers, + ) + assert duplicate.status_code == 409 + assert db.query(SetCollaborator).filter_by(user_id=recipient.id).count() == 1 + + replay = client.post( + f"/api/setbuilder/collaborator-invites/{invite['token']}/accept", + headers=recipient_headers, + ) + assert replay.status_code == 410 + assert set_obj.sharing_mode == "private" + assert set_obj.share_token == existing_share_token + + owner = client.get( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", headers=auth_headers + ) + assert owner.status_code == 200 + assert owner.json()[0]["accepted"] is False + assert owner.json()[1]["accepted"] is True + assert "token" not in owner.json()[0] + + revoke_accepted = client.delete( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites/{owner.json()[1]['id']}", + headers=auth_headers, + ) + assert revoke_accepted.status_code == 409 + after_revoke = client.get( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", headers=auth_headers + ) + assert after_revoke.json()[1]["revoked"] is False + + +def test_owner_cannot_accept_own_invite(client, db, test_user, auth_headers): + from app.models.set_collaborator_invite import SetCollaboratorInvite + + set_obj = Set(owner_id=test_user.id, name="Owner invite") + db.add(set_obj) + db.commit() + created = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "viewer"}, + headers=auth_headers, + ) + assert created.status_code == 201 + + accepted = client.post( + f"/api/setbuilder/collaborator-invites/{created.json()['token']}/accept", + headers=auth_headers, + ) + assert accepted.status_code == 409 + invite = db.query(SetCollaboratorInvite).one() + assert invite.accepted_at is None + assert db.query(SetCollaborator).count() == 0 + + +def test_invite_creation_is_owner_only_and_role_is_validated(client, db, test_user, auth_headers): + set_obj = Set(owner_id=test_user.id, name="Private") + db.add(set_obj) + db.commit() + _make_dj(db, "other-owner") + other_headers = _login(client, "other-owner", "x" * 12) + + denied = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "viewer"}, + headers=other_headers, + ) + assert denied.status_code == 404 + + invalid = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "admin"}, + headers=auth_headers, + ) + assert invalid.status_code == 422 + + +def test_expired_invite_cannot_be_accepted(client, db, test_user, auth_headers): + from datetime import timedelta + + from app.core.time import utcnow + from app.models.set_collaborator_invite import SetCollaboratorInvite + + set_obj = Set(owner_id=test_user.id, name="Expired") + db.add(set_obj) + db.commit() + recipient = _make_dj(db, "expired-recipient") + headers = _login(client, "expired-recipient", "x" * 12) + created = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "viewer"}, + headers=auth_headers, + ) + assert created.status_code == 201 + token = created.json()["token"] + invite = db.query(SetCollaboratorInvite).one() + invite.expires_at = utcnow() - timedelta(seconds=1) + db.commit() + + accepted = client.post(f"/api/setbuilder/collaborator-invites/{token}/accept", headers=headers) + assert accepted.status_code == 410 + assert db.query(SetCollaborator).filter_by(user_id=recipient.id).count() == 0 + + +def test_owner_can_revoke_invite_and_token_is_stored_as_hash(client, db, test_user, auth_headers): + from app.models.set_collaborator_invite import SetCollaboratorInvite + + set_obj = Set(owner_id=test_user.id, name="Revoked") + db.add(set_obj) + db.commit() + created = client.post( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", + json={"role": "viewer"}, + headers=auth_headers, + ) + assert created.status_code == 201 + token = created.json()["token"] + invite = db.query(SetCollaboratorInvite).one() + assert invite.token_hash != token + + revoked = client.delete( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites/{invite.id}", + headers=auth_headers, + ) + assert revoked.status_code == 204 + assert invite.revoked_at is not None + + listed = client.get( + f"/api/setbuilder/sets/{set_obj.id}/collaborator-invites", headers=auth_headers + ) + assert listed.json()[0]["revoked"] is True From 44145c0605072da7720432331c7f639d6902a6ba Mon Sep 17 00:00:00 2001 From: thewrz Date: Sat, 3 Oct 2026 16:16:53 -0700 Subject: [PATCH 10/18] tracking: low / low-med refactor candidates from 2026-06-19 review (evaluate before implementing) (#712) * refactor(events): isolate CSV export routes Co-Authored-By: Codex * fix(events): encode export filenames safely Co-Authored-By: Codex * docs(refactor): evaluate low-impact candidates Co-Authored-By: Codex * fix: clarify evaluation findings Co-Authored-By: Codex --------- Co-authored-by: Codex --- ...026-10-03-refactor-candidate-evaluation.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/reviews/2026-10-03-refactor-candidate-evaluation.md diff --git a/docs/reviews/2026-10-03-refactor-candidate-evaluation.md b/docs/reviews/2026-10-03-refactor-candidate-evaluation.md new file mode 100644 index 00000000..9b0b0caa --- /dev/null +++ b/docs/reviews/2026-10-03-refactor-candidate-evaluation.md @@ -0,0 +1,32 @@ +# Low-impact refactor candidate evaluation + +This evaluates the six candidate topics in issue #507 against the +`feat/issue-507` tree, which is stacked on #506. Candidate 5 contains three +separate constants/page-size observations. The original handoff was validated +on 2026-06-19; several findings were already resolved by the current base. + +## Decisions + +| Candidate | Decision | Evidence and rationale | +|---|---|---| +| Kiosk pairing nonce cache | Follow-up #713 | The process-local cache matches today's single-worker startup, so cross-worker storage is conditional. However, in the current deployment two kiosks sharing one IP can overwrite each other's challenges, and a missing or incorrect nonce consumes the outstanding challenge. Issue #713 tracks preserving independent, IP-bound, single-use challenges; use shared storage if deployment moves to multiple workers. | +| LLM recommendation rate limit cache | Defer | The rate remains admin-tunable in the database and the route refreshes the local cache after SlowAPI evaluates it. This leaves one request on the old value after a setting change and different workers could diverge. Current startup is single-worker, so a shared cache/TTL is not justified yet. Revisit with multi-worker deployment or a request to make admin changes take effect immediately. | +| Bridge retry/backoff duplication | No further extraction | `bridge/src/bridge.ts` and `bridge-app/src/main/bridge-runner.ts` both import the shared `bridge/src/http-retry.ts` helpers/constants and shared `CircuitBreaker` through the `@bridge/*` alias. Their `postWithRetry` wrappers still own different lifecycle and authentication behavior. Extracting those wrappers would add abstraction around the remaining legitimate differences. | +| Setbuilder reorder/document math | Already resolved in current base | `reorderMath.ts` owns both `buildMovedIds` and `buildReorderedIds`; `documentMath.ts` owns `insertPoolTrackIntoDocument` and `lockSlotsInDocument`. Tests import the pure helpers from those modules. No additional change is needed. | +| Magic numbers | Select only CSV follow-up #711 | **210-second fallback:** the value appears in the backend deterministic builder and two frontend modules (`types.ts` and `poolRuntime.ts`). Sharing one value across Python and TypeScript would require generation or API wiring, not warranted for a stable fallback. **CSV caps:** `events_exports.py` bounds both exports to 10,000 rows, but normal CSV responses do not indicate truncation; #711 tracks an explicit signal and DJ-facing handling. **Public page size:** join and collect use `PAGE_SIZE = 100`; kiosk display starts at 100. These are UI fetch/growth sizes, while backend `DEFAULT_PAGE_SIZE` applies only when a query omits `limit`. Reusing a frontend constant would not align backend defaults, so defer the small cleanup. | +| Pydantic mutable list defaults | Already resolved in current base | `schemas/user.py` and all list fields in `schemas/recommendation.py` use `Field(default_factory=list)`. No behavior or style change remains. | + +## Follow-up boundary + +Selected follow-ups are kiosk challenge handling (#713) and visible CSV export +truncation (#711). The LLM cache's shared-store need remains conditional on +multi-worker deployment. The other items are already resolved or too small to +justify abstraction in this pass. + +## Validation + +This is an evaluation-only change. No application behavior or tests changed. +Evidence was checked in the current worktree at the paths named above and in +`server/scripts/start.sh`. + +🤖 Prepared by Codex gpt-6-luna. From 81993038b464aa564185e75170a44e246e38077d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:17:55 -0700 Subject: [PATCH 11/18] fix(deps): patch Next.js and frontend audit vulnerabilities (#699) * chore(deps): update dependency next to v16.3.6 [security] * fix(deps): patch frontend audit vulnerabilities Update brace-expansion overrides and undici alongside the Next.js security patch. Regenerate the npm lockfile; the dashboard audit now reports zero vulnerabilities. Co-Authored-By: Codex --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: thewrz Co-authored-by: Codex --- dashboard/package-lock.json | 152 ++++++++++++++++++------------------ dashboard/package.json | 8 +- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index acab9e8d..a92e9dca 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1388,15 +1388,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.6.tgz", + "integrity": "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.6.tgz", + "integrity": "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==", "cpu": [ "arm64" ], @@ -1410,9 +1410,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.6.tgz", + "integrity": "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==", "cpu": [ "x64" ], @@ -1426,9 +1426,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.6.tgz", + "integrity": "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==", "cpu": [ "arm64" ], @@ -1445,9 +1445,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.6.tgz", + "integrity": "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==", "cpu": [ "arm64" ], @@ -1464,9 +1464,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.6.tgz", + "integrity": "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==", "cpu": [ "x64" ], @@ -1483,9 +1483,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.6.tgz", + "integrity": "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==", "cpu": [ "x64" ], @@ -1502,9 +1502,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.6.tgz", + "integrity": "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==", "cpu": [ "arm64" ], @@ -1518,9 +1518,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.6.tgz", + "integrity": "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==", "cpu": [ "x64" ], @@ -1612,6 +1612,16 @@ "npm": ">=9.5.0" } }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@redocly/openapi-core/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -1625,16 +1635,6 @@ "node": ">=10" } }, - "node_modules/@redocly/openapi-core/node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", @@ -2293,6 +2293,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2309,19 +2322,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", @@ -2847,6 +2847,17 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -5311,17 +5322,6 @@ "node": "*" } }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5355,12 +5355,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "version": "16.3.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.6.tgz", + "integrity": "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5374,14 +5374,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", + "@next/swc-darwin-arm64": "16.3.6", + "@next/swc-darwin-x64": "16.3.6", + "@next/swc-linux-arm64-gnu": "16.3.6", + "@next/swc-linux-arm64-musl": "16.3.6", + "@next/swc-linux-x64-gnu": "16.3.6", + "@next/swc-linux-x64-musl": "16.3.6", + "@next/swc-win32-arm64-msvc": "16.3.6", + "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { @@ -6793,9 +6793,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { diff --git a/dashboard/package.json b/dashboard/package.json index 172cf564..52bd0aba 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -47,10 +47,10 @@ "vite": "^8.0.16", "js-yaml": "^4.3.2", "@babel/core": "^7.29.6", - "undici": "^7.29.0", - "brace-expansion@1": "1.1.18", - "brace-expansion@2": "2.1.4", - "brace-expansion@5": "5.0.9", + "undici": "^7.29.1", + "brace-expansion@1": "1.1.21", + "brace-expansion@2": "2.1.7", + "brace-expansion@5": "5.0.12", "browserslist": "~4.28.9" } } From 124d211d7469d4e1b395e34f69d5105f30cae6a4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:25:00 -0700 Subject: [PATCH 12/18] fix(deps): patch PyJWT and urllib3 security vulnerabilities (#697) * chore(deps): update dependency pyjwt to v2.14.0 [security] * fix(deps): enforce patched Python security dependencies Require PyJWT 2.15.1 and urllib3 2.8.0 for pip-based deployments and regenerate the uv lockfile. The full backend suite and dependency audit pass. Co-Authored-By: Codex --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: thewrz Co-authored-by: Codex --- server/pyproject.toml | 4 ++-- server/uv.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/pyproject.toml b/server/pyproject.toml index d505a341..ce74cfda 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ # Bumping transitives to direct deps to enforce floor. "Pillow>=12.3.0", # CVE-2026-25990 (banner upload); 2026-08 GHSA cluster (decoder OOB/DoS) "cryptography>=50.0.0", # GHSA-537c-gmf6-5ccf / CVE-2026-34180; GHSA-g6cj-pr64-35w5 (PKCS#7 Bleichenbacher) - "PyJWT>=2.13.0", # GHSA-xgmm/993g/w7vc/jq35/fhv5 (HS256 forgery, alg-allowlist bypass, SSRF, DoS) + "PyJWT>=2.15.1", # 2.14/2.15 security fixes plus padded-JWS compatibility fix "aiohttp>=3.14.3", # GHSA cluster (ws memory bypass, HTTP/1 pipeline DoS, CRLF, x-origin cookies); GHSA-cq5v-8q36-5273 "requests>=2.33.0", # CVE-2026-25645 (URL parsing) # SECURITY: minimum versions per 2026-05-07 audit. @@ -31,7 +31,7 @@ dependencies = [ "pyasn1>=0.6.4", # CVE-2026-30922 (transitive from cryptography chain) # SECURITY: minimum versions per 2026-05-19 audit. "idna>=3.15", # CVE-2026-45409 (transitive from requests/httpx) - "urllib3>=2.7.0", # CVE-2026-44431, CVE-2026-44432 (transitive from requests) + "urllib3>=2.8.0", # GHSA-8988/vxq7/gh4c (proxy TLS bypass, streaming memory/CPU DoS) "anthropic>=0.40.0", "resend>=2.0.0", "better-profanity>=0.7.0", diff --git a/server/uv.lock b/server/uv.lock index 63eb041d..9ea640ee 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -1993,11 +1993,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [[package]] @@ -2502,11 +2502,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -2859,7 +2859,7 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, - { name = "pyjwt", specifier = ">=2.13.0" }, + { name = "pyjwt", specifier = ">=2.15.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, @@ -2877,7 +2877,7 @@ requires-dist = [ { name = "sse-starlette", specifier = ">=2.0.0" }, { name = "starlette", specifier = ">=1.3.1" }, { name = "tidalapi", specifier = ">=0.7.0" }, - { name = "urllib3", specifier = ">=2.7.0" }, + { name = "urllib3", specifier = ">=2.8.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, ] provides-extras = ["dev"] From 7b0f40bc5719d786eac7bbce39b9f5e49a910424 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 4 Oct 2026 10:55:39 -0700 Subject: [PATCH 13/18] fix(security): remove vulnerable build tools from runtime images (#718) Refresh base images, remove bundled build tools after installation, and verify the standalone web runtime in CI. Refs #588. Co-Authored-By: Codex --- .github/workflows/ci.yml | 6 ++++- dashboard/Dockerfile | 6 ++++- scripts/test-web-image.sh | 46 +++++++++++++++++++++++++++++++++++++++ server/Dockerfile | 6 ++++- 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 scripts/test-web-image.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b039e4d..0b9b9d06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,6 +257,10 @@ jobs: NEXT_PUBLIC_API_URL=http://localhost:8000 platforms: linux/amd64 push: false - load: false + load: true + tags: wrzdj-web:smoke cache-from: type=gha,scope=ci-web cache-to: type=gha,mode=max,scope=ci-web + + - name: Test standalone web runtime + run: bash scripts/test-web-image.sh wrzdj-web:smoke diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index 348e6791..0bc2d03d 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-alpine@sha256:ef24c5053d50fdc3e4e56eb4e7ddb7861874ab0fdc797046ba897581deb8e868 AS base +FROM node:26-alpine@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80 AS base # Pull the Alpine security updates published since the pinned digest was built # (Trivy gate). Every later stage inherits the patched packages. Same trade-off # as server/Dockerfile: the layer is cached until Renovate bumps the digest. @@ -31,6 +31,10 @@ RUN npm run build FROM base AS runner WORKDIR /app +# The standalone server starts with node; npm and its bundled dependencies are +# build tools. Keep them in builder/deps only, outside the production image. +RUN npm uninstall --global npm + ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/scripts/test-web-image.sh b/scripts/test-web-image.sh new file mode 100644 index 00000000..fc2f6fcb --- /dev/null +++ b/scripts/test-web-image.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Regression for the runtime image at 124d211d: npm brought vulnerable build +# dependencies into the standalone server. Verify the actual shipped container. +set -euo pipefail + +image=${1:?Usage: test-web-image.sh IMAGE} +container=$(docker run --detach --network none \ + --env NEXT_PUBLIC_API_URL=http://localhost:8000 "$image") +trap ' + if [ "$?" -ne 0 ]; then + docker logs "$container" >&2 || true + fi + docker rm --force "$container" >/dev/null || true +' EXIT + +# The standalone app must start as an unprivileged user without build tools. +npm_paths=$(docker exec "$container" sh -c 'command -v npm || command -v npx || true') +if [ -n "$npm_paths" ]; then + echo 'The web runtime still exposes npm/npx and their build dependencies.' >&2 + exit 1 +fi +if ! docker exec "$container" test ! -d /usr/local/lib/node_modules/npm; then + echo 'The web runtime still contains npm package files.' >&2 + exit 1 +fi +runtime_uid=$(docker exec "$container" stat -c %u /proc/1) +if [ "$runtime_uid" = 0 ]; then + echo 'The web runtime must not run as root.' >&2 + exit 1 +fi + +# Exercise the real entrypoint and HTTP server; the container has no network +# access except loopback and is always removed, including on failure. +for ((attempt = 0; attempt < 30; attempt++)); do + if docker exec "$container" wget --quiet --timeout=2 --spider http://127.0.0.1:3000/; then + echo 'Web runtime smoke test passed.' + exit 0 + fi + if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]; then + break + fi + sleep 1 +done + +echo 'The web runtime did not serve HTTP successfully within 30 attempts.' >&2 +exit 1 diff --git a/server/Dockerfile b/server/Dockerfile index 8dd60fb0..a83f27d6 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 +FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151 WORKDIR /app @@ -20,6 +20,10 @@ COPY pyproject.toml . RUN pip install --no-cache-dir --upgrade "pip>=26.1" "setuptools>=83.0.0" RUN pip install --no-cache-dir -e . +# Installation is complete; pip's vendored libraries are not runtime dependencies. +# Remove pip itself after it has installed the application and its dependencies. +RUN python -m pip uninstall --yes pip + # Copy application code COPY . . From 3d1785611949944cf32b49f3481a0ab0184ef4ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:07:42 -0700 Subject: [PATCH 14/18] fix(deps): patch bridge security dependencies (#715) Update ip-address, fast-uri, brace-expansion and Undici without protocol or packaging major changes. Refs #588. Co-Authored-By: Codex --- bridge-app/package-lock.json | 132 +++++++++++++++++------------------ bridge-app/package.json | 12 ++-- bridge/package-lock.json | 6 +- bridge/package.json | 2 +- 4 files changed, 76 insertions(+), 76 deletions(-) diff --git a/bridge-app/package-lock.json b/bridge-app/package-lock.json index 725ce99c..5472e283 100644 --- a/bridge-app/package-lock.json +++ b/bridge-app/package-lock.json @@ -887,6 +887,16 @@ "node": ">=12.13.0" } }, + "node_modules/@electron/node-gyp/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@electron/node-gyp/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -921,16 +931,6 @@ "node": ">=10" } }, - "node_modules/@electron/node-gyp/node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@electron/notarize": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", @@ -1086,6 +1086,16 @@ "node": ">=16.4" } }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@electron/universal/node_modules/fs-extra": { "version": "11.3.3", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", @@ -1117,16 +1127,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/universal/node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@electron/windows-sign": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", @@ -1263,6 +1263,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -1279,19 +1292,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", @@ -3392,6 +3392,17 @@ "stream-buffers": "~2.2.0" } }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3510,6 +3521,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/cacache/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -3554,16 +3575,6 @@ "node": ">=10" } }, - "node_modules/cacache/node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/cacheable": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", @@ -4824,9 +4835,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5199,9 +5210,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -5964,9 +5975,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" @@ -7152,17 +7163,6 @@ "node": "*" } }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -9958,9 +9958,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { diff --git a/bridge-app/package.json b/bridge-app/package.json index 7ee6a83c..a81a1d16 100644 --- a/bridge-app/package.json +++ b/bridge-app/package.json @@ -45,21 +45,21 @@ "node": ">=22.0.0" }, "overrides": { - "fast-uri": "^3.1.7", + "fast-uri": "^3.1.8", "tar": "^7.5.18", "tmp": "^0.2.7", "@tootallnate/once": "^3.0.1", "ip": "^2.0.1", - "ip-address": "^10.1.1", + "ip-address": "^10.7.3", "picomatch@2": "^2.3.2", "picomatch@4": "^4.0.4", "postcss": "^8.5.23", "lodash": "^4.18.1", "@xmldom/xmldom": "^0.9.0", "vite": "^8.0.16", - "brace-expansion@1": "1.1.18", - "brace-expansion@2": "2.1.4", - "brace-expansion@5": "5.0.9", - "undici": "^7.29.0" + "brace-expansion@1": "1.1.21", + "brace-expansion@2": "2.1.7", + "brace-expansion@5": "5.0.12", + "undici": "^7.30.0" } } diff --git a/bridge/package-lock.json b/bridge/package-lock.json index d724e70f..532787f4 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1730,9 +1730,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/bridge/package.json b/bridge/package.json index 1cb4210f..1528c439 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -30,7 +30,7 @@ }, "overrides": { "ip": "^2.0.1", - "ip-address": "^10.1.1", + "ip-address": "^10.7.3", "lodash": "^4.18.1", "vite": "^8.0.16", "esbuild": "^0.28.1" From 63b1a81955284d0320e9e943ed995a2fcaa3ac65 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 4 Oct 2026 11:27:16 -0700 Subject: [PATCH 15/18] fix(security): install API runtime from verified lockfile Refs #588. Verify the actual image dependency graph and startup in CI. Co-Authored-By: Codex --- .github/workflows/ci.yml | 6 +- .../plans/2026-10-04-api-locked-runtime.md | 57 ++++++++++++ .../specs/2026-10-04-api-locked-runtime.md | 21 +++++ scripts/test-api-image.sh | 91 +++++++++++++++++++ server/Dockerfile | 26 ++++-- server/pyproject.toml | 4 + server/uv.lock | 8 ++ 7 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-04-api-locked-runtime.md create mode 100644 docs/superpowers/specs/2026-10-04-api-locked-runtime.md create mode 100644 scripts/test-api-image.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b9b9d06..7b015ab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,10 +245,14 @@ jobs: context: ./server platforms: linux/amd64 push: false - load: false + load: true + tags: wrzdj-api:smoke cache-from: type=gha,scope=ci-api cache-to: type=gha,mode=max,scope=ci-api + - name: Test locked API runtime + run: bash scripts/test-api-image.sh wrzdj-api:smoke + - name: Build frontend image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: diff --git a/docs/superpowers/plans/2026-10-04-api-locked-runtime.md b/docs/superpowers/plans/2026-10-04-api-locked-runtime.md new file mode 100644 index 00000000..531fd1e9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-api-locked-runtime.md @@ -0,0 +1,57 @@ +# API Locked Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship exactly the locked production Python dependencies and validate real API startup. + +**Architecture:** Export hashed requirements using temporary digest-pinned uv and install +artifacts with required hashes and a locked backend for source-only pyaes and ratelimit. Independently export the source lock during an isolated +container smoke test, compare versions, then exercise migrations and HTTP health. + +**Tech Stack:** Docker, uv 0.12.23, Python 3.14, Bash, PostgreSQL 16. + +**Spec:** docs/superpowers/specs/2026-10-04-api-locked-runtime.md + +## Global Constraints + +- No pip or uv in runtime; non-root real entrypoint; coverage >=85%. +- Lock hashes required; source builds must use the locked backend without isolation; retain security scans. +- No host ports or live databases; always clean up owned test resources. + +## Review Focus + +- Architecture-specific wheels: verify arm64 resolution as well as amd64 image build. +- Wrong installed versions: smoke against the old image must fail with drift details. +- Missing distributions and marker exclusions: checker reports missing applicable packages. +- Entrypoint failure: smoke must fail promptly and remove owned resources. +- Stale lock or tampered artifacts: locked export and hash-required installation fail closed. + +### Task 1: Lock the API image and validate its runtime + +**Files:** Modify server/Dockerfile, server/pyproject.toml, server/uv.lock and .github/workflows/ci.yml; create scripts/test-api-image.sh. + +**Interfaces:** +- Consumes: server/pyproject.toml, server/uv.lock, the image's ./scripts/start.sh. +- Produces: `bash scripts/test-api-image.sh IMAGE`, exit 0 only for a matching locked + graph, no installers, non-root process and successful HTTP /health after migrations. + +- [x] Step 1: Write the smoke test. Independently export source requirements without hashes + from pinned uv offline, evaluate markers and compare installed versions. Use an internal + Docker network with disposable PostgreSQL and API containers, bounded readiness probes, + and EXIT cleanup with logs on failure. +- [x] Step 2: Run `bash scripts/test-api-image.sh wrzdj-api:sweep-patched`. + Expected: exit 1 with actual installed-version mismatches (regression at 3d178561). +- [x] Step 3: Replace floating pip installation with pinned temporary uv mount, locked + hashed build-group and runtime exports; preinstall locked setuptools, then use + `uv pip install --system --require-hashes --no-deps --no-build-isolation` for runtime. + Remove setuptools afterward. + Remove base pip using its supported uninstall operation. CI loads the built backend + image as wrzdj-api:smoke and runs the test. +- [x] Step 4: Build wrzdj-api:locked; run smoke, backend lint/format/Bandit/full pytest, + migration drift, Trivy and arm64 artifact resolution (pyaes/ratelimit use locked source). Expected: all pass, coverage >=85%, + zero fixable HIGH/CRITICAL vulnerabilities. Exercise a broken-entrypoint fixture and + confirm failure plus cleanup. Review locked-export and hash enforcement behavior. +- [ ] Step 5: Commit with Conventional Commit and Codex attribution; run task-done with + `bash scripts/test-api-image.sh wrzdj-api:locked`. Expected: pass. Push draft PR referencing + #588, verify latest-head technical CI, run exactly one final cross-provider review, fix + verified findings, then merge using the tested head SHA and recheck finding 129. diff --git a/docs/superpowers/specs/2026-10-04-api-locked-runtime.md b/docs/superpowers/specs/2026-10-04-api-locked-runtime.md new file mode 100644 index 00000000..73c706af --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-api-locked-runtime.md @@ -0,0 +1,21 @@ +# API locked runtime + +The API Dockerfile at 3d178561 ignores uv.lock and installs floating version floors. +A comparison of its installed production dependencies with the lock found 44 mismatches +or missing packages. Scorecard finding 129 identifies the unhashed installation. + +Install production dependencies from the committed lock with enforced artifact hashes. +Use a digest-pinned temporary uv binary; do not ship uv or pip. For the source-only pyaes and ratelimit packages, install a hash-verified locked setuptools +build group first and disable build isolation; remove the backend after installation. +This prevents source builds from downloading dependencies outside the lock. Run application source from /app; +no application code consumes installed wrzdj-server distribution metadata. +Keep the existing non-root entrypoint, migrations, bootstrap and health behavior. + +Add a CI image smoke test that independently exports the source lock, checks installed +versions with environment markers, and exercises startup against disposable PostgreSQL. +No host ports or live databases; clean up containers, network and temp files on failures. +Retain the 85% backend coverage gate and existing security scans. Verify installation support for both amd64 and arm64. Track this build/dependency work through dashboard #588. + +Research: https://docs.astral.sh/uv/guides/integration/docker/ and +https://docs.astral.sh/uv/reference/cli/ document temporary binary mounts, locked exports, +required hashes and wheel-only installation. diff --git a/scripts/test-api-image.sh b/scripts/test-api-image.sh new file mode 100644 index 00000000..37f65732 --- /dev/null +++ b/scripts/test-api-image.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Regression for 3d178561: the production image ignored server/uv.lock. +set -euo pipefail + +image=${1:?Usage: test-api-image.sh IMAGE} +root=$(cd "$(dirname "$0")/.." && pwd) +scratch=$(mktemp -d) +network= +database= +container= +id= +trap ' + if [ "$?" -ne 0 ]; then + for id in "$container" "$database"; do + if [ -n "$id" ]; then docker logs "$id" >&2 || true; fi + done + fi + for id in "$container" "$database"; do + if [ -n "$id" ]; then docker rm --force "$id" >/dev/null || true; fi + done + if [ -n "$network" ]; then docker network rm "$network" >/dev/null || true; fi + rm -rf "$scratch" +' EXIT + +# Export independently from the checkout, not a manifest supplied by the image. +# Frozen/offline needs no Python interpreter in the distroless uv container. +docker run --rm --network none --volume "$root/server:/work:ro" --workdir /work \ + ghcr.io/astral-sh/uv:0.12.23@sha256:61d393e44e249f2e4b526b6c7ddcecce245946826e608e11c93ad4f5bba55b21 \ + export --frozen --no-dev --no-emit-project --no-hashes --offline > "$scratch/requirements.txt" + +docker run --rm --interactive --network none --entrypoint python \ + --volume "$scratch/requirements.txt:/tmp/expected-requirements.txt:ro" "$image" - <<'PY' +import importlib.metadata +import importlib.util +from pathlib import Path +import shutil + +from packaging.requirements import Requirement + +errors = [] +checked = 0 +for line in Path("/tmp/expected-requirements.txt").read_text().splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + requirement = Requirement(line) + if requirement.marker and not requirement.marker.evaluate(): + continue + checked += 1 + try: + installed = importlib.metadata.version(requirement.name) + except importlib.metadata.PackageNotFoundError: + installed = None + if installed is None or installed not in requirement.specifier: + errors.append(f"{requirement}: installed {installed or 'MISSING'}") +if not checked: + errors.append("No production dependencies were checked") +for installer in ("pip", "uv"): + if importlib.util.find_spec(installer) or shutil.which(installer): + errors.append(f"The runtime contains {installer}") +if errors: + raise SystemExit("\n".join(errors)) +print(f"All {checked} production dependencies match the lock; no installers remain.") +PY + +# No published ports and no route to external services from either container. +network=$(docker network create --internal "wrzdj-api-smoke-$(basename "$scratch")") +database=$(docker run --detach --network "$network" --network-alias database \ + --env POSTGRES_USER=wrzdj --env POSTGRES_PASSWORD=wrzdj --env POSTGRES_DB=wrzdj_test \ + postgres:16@sha256:71e27bf60b70bded003791b5573f8b808365613f341df20ffcf0c1ed7bc13ddf) +container=$(docker run --detach --network "$network" \ + --env DATABASE_URL=postgresql+psycopg://wrzdj:wrzdj@database:5432/wrzdj_test \ + --env JWT_SECRET=test-secret-key --env ENV=development "$image") +runtime_uid=$(docker exec "$container" stat -c %u /proc/1) +if [ "$runtime_uid" = 0 ]; then + echo 'The API runtime must not run as root.' >&2 + exit 1 +fi + +for ((attempt = 0; attempt < 60; attempt++)); do + if docker exec "$container" curl --fail --silent --max-time 2 \ + http://127.0.0.1:8000/health >/dev/null; then + echo 'API runtime startup, migrations and health check passed.' + exit 0 + fi + if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]; then + break + fi + sleep 1 +done +echo 'The API runtime did not become healthy within 60 attempts.' >&2 +exit 1 diff --git a/server/Dockerfile b/server/Dockerfile index a83f27d6..59fac014 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -15,16 +15,22 @@ RUN apt-get update && apt-get upgrade -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install dependencies -COPY pyproject.toml . -RUN pip install --no-cache-dir --upgrade "pip>=26.1" "setuptools>=83.0.0" -RUN pip install --no-cache-dir -e . - -# Installation is complete; pip's vendored libraries are not runtime dependencies. -# Remove pip itself after it has installed the application and its dependencies. -RUN python -m pip uninstall --yes pip - -# Copy application code +# Install the committed production graph with artifact hash verification. pyaes +# and ratelimit publish only source: preinstall their locked backend, without isolation, +# so source builds cannot download additional, unpinned build dependencies. +# Mount uv for this step so neither it nor its cache enters the runtime image. +COPY pyproject.toml uv.lock ./ +RUN --mount=from=ghcr.io/astral-sh/uv:0.12.23@sha256:61d393e44e249f2e4b526b6c7ddcecce245946826e608e11c93ad4f5bba55b21,source=/uv,target=/bin/uv \ + export UV_NO_CACHE=1 \ + && uv export --quiet --locked --only-group build --no-emit-project --output-file requirements.build \ + && uv pip install --system --no-cache --require-hashes --no-deps --only-binary :all: -r requirements.build \ + && uv export --quiet --locked --no-dev --no-emit-project --output-file requirements.lock \ + && uv pip install --system --no-cache --require-hashes --no-deps --no-build-isolation \ + --only-binary :all: --no-binary pyaes,ratelimit -r requirements.lock \ + && uv pip uninstall --system setuptools \ + && python -m pip uninstall --yes pip + +# Run application source directly from /app; no editable installation is needed. COPY . . # Make start script executable diff --git a/server/pyproject.toml b/server/pyproject.toml index ce74cfda..a5aa4559 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -61,6 +61,10 @@ dev = [ requires = ["setuptools>=83.0.0"] build-backend = "setuptools.build_meta" +[dependency-groups] +# pyaes and ratelimit publish only sdists; lock their backend separately from runtime. +build = ["setuptools==83.0.0"] + [tool.setuptools.packages.find] where = ["."] diff --git a/server/uv.lock b/server/uv.lock index 9ea640ee..071344c6 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -2834,6 +2834,11 @@ dev = [ { name = "setuptools" }, ] +[package.dev-dependencies] +build = [ + { name = "setuptools" }, +] + [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, @@ -2882,6 +2887,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +build = [{ name = "setuptools", specifier = "==83.0.0" }] + [[package]] name = "yarl" version = "1.23.0" From 7f7ba9f75ad774925751616ffcd5f544da82c966 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 4 Oct 2026 11:43:07 -0700 Subject: [PATCH 16/18] fix(security): reject extra runtime packages and allow build updates Address the final PR719 review with resolver and actual-image regression evidence. Co-Authored-By: Codex --- scripts/test-api-image.sh | 12 ++++++++++++ server/pyproject.toml | 2 +- server/uv.lock | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/test-api-image.sh b/scripts/test-api-image.sh index 37f65732..3d08046c 100644 --- a/scripts/test-api-image.sh +++ b/scripts/test-api-image.sh @@ -36,9 +36,11 @@ from pathlib import Path import shutil from packaging.requirements import Requirement +from packaging.utils import canonicalize_name errors = [] checked = 0 +expected_names = set() for line in Path("/tmp/expected-requirements.txt").read_text().splitlines(): if not line.strip() or line.lstrip().startswith("#"): continue @@ -46,6 +48,7 @@ for line in Path("/tmp/expected-requirements.txt").read_text().splitlines(): if requirement.marker and not requirement.marker.evaluate(): continue checked += 1 + expected_names.add(canonicalize_name(requirement.name)) try: installed = importlib.metadata.version(requirement.name) except importlib.metadata.PackageNotFoundError: @@ -54,6 +57,15 @@ for line in Path("/tmp/expected-requirements.txt").read_text().splitlines(): errors.append(f"{requirement}: installed {installed or 'MISSING'}") if not checked: errors.append("No production dependencies were checked") +# Regression for 63b1a819: matching required versions also allowed extra build +# dependencies and a reintroduced editable application installation to pass. +installed_names = { + canonicalize_name(distribution.metadata["Name"]) + for distribution in importlib.metadata.distributions() +} +unexpected = installed_names - expected_names +if unexpected: + errors.append(f"Unexpected installed distributions: {', '.join(sorted(unexpected))}") for installer in ("pip", "uv"): if importlib.util.find_spec(installer) or shutil.which(installer): errors.append(f"The runtime contains {installer}") diff --git a/server/pyproject.toml b/server/pyproject.toml index a5aa4559..e9b8eedd 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -63,7 +63,7 @@ build-backend = "setuptools.build_meta" [dependency-groups] # pyaes and ratelimit publish only sdists; lock their backend separately from runtime. -build = ["setuptools==83.0.0"] +build = ["setuptools>=83.0.0"] [tool.setuptools.packages.find] where = ["."] diff --git a/server/uv.lock b/server/uv.lock index 071344c6..526bd149 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -2888,7 +2888,7 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -build = [{ name = "setuptools", specifier = "==83.0.0" }] +build = [{ name = "setuptools", specifier = ">=83.0.0" }] [[package]] name = "yarl" From 0a3d3813ec61c7e0788375f30cc4c48136032bf3 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 4 Oct 2026 12:27:02 -0700 Subject: [PATCH 17/18] refactor(events): remove unused private export alias (#720) Remove the unused compatibility alias flagged by Code Quality finding 229. Both CSV routes keep using the helper in events_exports. Co-authored-by: Codex --- server/app/api/events.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/app/api/events.py b/server/app/api/events.py index 88f6c30b..87793496 100644 --- a/server/app/api/events.py +++ b/server/app/api/events.py @@ -113,7 +113,6 @@ # Preserve the previous module-level exports for callers that imported these names. MAX_EXPORT_PLAY_HISTORY = events_exports.MAX_EXPORT_PLAY_HISTORY MAX_EXPORT_REQUESTS = events_exports.MAX_EXPORT_REQUESTS -_content_disposition = events_exports._content_disposition export_event_csv = events_exports.export_event_csv export_play_history_csv = events_exports.export_play_history_csv From 4840d2fe22683191d94e0f7bcfb79bfc0750da99 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 4 Oct 2026 12:36:05 -0700 Subject: [PATCH 18/18] chore(ci): update compatible setup and coverage actions to v7 Group the setup-node, setup-python and Codecov releases with the checkout update. Preserve workflow inputs and commit-SHA pinning. Co-Authored-By: Codex --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/dependency-health.yml | 6 +++--- .github/workflows/release.yml | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4edb6f71..257fcf00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: "pip" @@ -98,7 +98,7 @@ jobs: run: alembic upgrade head && alembic check - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: files: server/coverage.xml flags: backend @@ -115,7 +115,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" cache: "npm" @@ -148,7 +148,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" cache: "npm" @@ -178,7 +178,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" cache: "npm" @@ -212,7 +212,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" diff --git a/.github/workflows/dependency-health.yml b/.github/workflows/dependency-health.yml index 47ae65a6..922c72ec 100644 --- a/.github/workflows/dependency-health.yml +++ b/.github/workflows/dependency-health.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" cache: "npm" @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" cache: "npm" @@ -86,7 +86,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd1ba68e..bb9050fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: - name: Set up Node.js if: steps.filter.outputs.skip != 'true' - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22"