From d0b239f45117b24cf8827ec01df4270394ba0491 Mon Sep 17 00:00:00 2001 From: Amit Breuer Date: Tue, 22 Sep 2026 15:39:25 +0300 Subject: [PATCH 1/6] feat: add feature-gated Telegram party queue pilot --- .dockerignore | 1 + .gcloudignore | 1 + .github/workflows/cloud-run-deploy.yml | 1 + .github/workflows/party-checks.yml | 19 + Dockerfile | 12 +- README.md | 6 + deploy/party/.gitignore | 6 + deploy/party/.terraform.lock.hcl | 22 + deploy/party/main.tf | 127 ++ docs/party-queue.md | 222 +++ package-lock.json | 1711 ++++++++++++++++- package.json | 4 +- projects/api/.env.party.example | 18 + projects/api/package.json | 7 +- .../migration.sql | 51 + .../20260922120000_party/migration.sql | 74 + .../api/prisma/migrations/migration_lock.toml | 1 + projects/api/prisma/schema.prisma | 148 ++ projects/api/src/bot.ts | 19 + projects/api/src/middleware/request-logger.ts | 12 +- projects/api/src/party/auth.ts | 414 ++++ projects/api/src/party/catalog.test.ts | 277 +++ projects/api/src/party/catalog.ts | 284 +++ projects/api/src/party/config.ts | 99 + projects/api/src/party/jobs.ts | 499 +++++ projects/api/src/party/rooms.ts | 633 ++++++ projects/api/src/party/routes.ts | 306 +++ projects/api/src/party/security.test.ts | 60 + projects/api/src/party/security.ts | 190 ++ projects/api/src/party/store.ts | 79 + projects/api/src/server.ts | 16 +- projects/api/src/services/spotify.ts | 14 +- projects/api/tests/party.integration.test.mjs | 898 +++++++++ projects/mini-app/README.md | 69 + projects/mini-app/dev/party-preview.ts | 21 + projects/mini-app/package.json | 6 +- projects/mini-app/src/App.tsx | 138 +- .../mini-app/src/components/BottomTabs.tsx | 7 +- projects/mini-app/src/components/Profile.tsx | 5 +- .../mini-app/src/features/library/Library.tsx | 121 ++ .../features/library/LibraryErrorBoundary.tsx | 28 + .../src/features/party/DevicePicker.tsx | 26 + .../mini-app/src/features/party/Party.tsx | 239 +++ .../src/features/party/RequestCard.tsx | 74 + projects/mini-app/src/features/party/Room.tsx | 213 ++ projects/mini-app/src/features/party/api.ts | 85 + .../mini-app/src/features/party/messages.ts | 28 + .../mini-app/src/features/party/usePolling.ts | 56 + projects/mini-app/src/index.css | 89 + projects/mini-app/src/lib/navigation.ts | 52 + projects/mini-app/src/lib/telegram.ts | 33 + projects/mini-app/tests/navigation.test.mjs | 70 + projects/mini-app/tests/party-api.test.mjs | 86 + .../mini-app/tests/party-preview.test.mjs | 46 + projects/mini-app/vite.config.ts | 24 +- projects/shared/src/index.ts | 1 + projects/shared/src/party.ts | 85 + projects/spotify/README.md | 64 + projects/spotify/package.json | 21 + projects/spotify/src/index.test.ts | 310 +++ projects/spotify/src/index.ts | 411 ++++ projects/spotify/src/network.test.ts | 94 + projects/spotify/src/network.ts | 66 + projects/spotify/tsconfig.json | 10 + tests/party.browser.mjs | 214 +++ 65 files changed, 8877 insertions(+), 146 deletions(-) create mode 100644 .github/workflows/party-checks.yml create mode 100644 deploy/party/.gitignore create mode 100644 deploy/party/.terraform.lock.hcl create mode 100644 deploy/party/main.tf create mode 100644 docs/party-queue.md create mode 100644 projects/api/.env.party.example create mode 100644 projects/api/prisma/migrations/20260922000000_library_baseline/migration.sql create mode 100644 projects/api/prisma/migrations/20260922120000_party/migration.sql create mode 100644 projects/api/prisma/migrations/migration_lock.toml create mode 100644 projects/api/src/party/auth.ts create mode 100644 projects/api/src/party/catalog.test.ts create mode 100644 projects/api/src/party/catalog.ts create mode 100644 projects/api/src/party/config.ts create mode 100644 projects/api/src/party/jobs.ts create mode 100644 projects/api/src/party/rooms.ts create mode 100644 projects/api/src/party/routes.ts create mode 100644 projects/api/src/party/security.test.ts create mode 100644 projects/api/src/party/security.ts create mode 100644 projects/api/src/party/store.ts create mode 100644 projects/api/tests/party.integration.test.mjs create mode 100644 projects/mini-app/dev/party-preview.ts create mode 100644 projects/mini-app/src/features/library/Library.tsx create mode 100644 projects/mini-app/src/features/library/LibraryErrorBoundary.tsx create mode 100644 projects/mini-app/src/features/party/DevicePicker.tsx create mode 100644 projects/mini-app/src/features/party/Party.tsx create mode 100644 projects/mini-app/src/features/party/RequestCard.tsx create mode 100644 projects/mini-app/src/features/party/Room.tsx create mode 100644 projects/mini-app/src/features/party/api.ts create mode 100644 projects/mini-app/src/features/party/messages.ts create mode 100644 projects/mini-app/src/features/party/usePolling.ts create mode 100644 projects/mini-app/src/lib/navigation.ts create mode 100644 projects/mini-app/src/lib/telegram.ts create mode 100644 projects/mini-app/tests/navigation.test.mjs create mode 100644 projects/mini-app/tests/party-api.test.mjs create mode 100644 projects/mini-app/tests/party-preview.test.mjs create mode 100644 projects/shared/src/party.ts create mode 100644 projects/spotify/README.md create mode 100644 projects/spotify/package.json create mode 100644 projects/spotify/src/index.test.ts create mode 100644 projects/spotify/src/index.ts create mode 100644 projects/spotify/src/network.test.ts create mode 100644 projects/spotify/src/network.ts create mode 100644 projects/spotify/tsconfig.json create mode 100644 tests/party.browser.mjs diff --git a/.dockerignore b/.dockerignore index 0d490b5..9962e47 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,4 @@ dist projects/ui scripts docs +deploy diff --git a/.gcloudignore b/.gcloudignore index 0d490b5..9962e47 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -10,3 +10,4 @@ dist projects/ui scripts docs +deploy diff --git a/.github/workflows/cloud-run-deploy.yml b/.github/workflows/cloud-run-deploy.yml index d048fca..a07b9c2 100644 --- a/.github/workflows/cloud-run-deploy.yml +++ b/.github/workflows/cloud-run-deploy.yml @@ -39,6 +39,7 @@ jobs: source: . env_vars: | NODE_ENV=production + PARTY_ENABLED=${{ vars.PARTY_ENABLED || 'false' }} secrets: | DATABASE_URL=DATABASE_URL:latest SPOTIFY_CLIENT_ID=SPOTIFY_CLIENT_ID:latest diff --git a/.github/workflows/party-checks.yml b/.github/workflows/party-checks.yml new file mode 100644 index 0000000..76578b9 --- /dev/null +++ b/.github/workflows/party-checks.yml @@ -0,0 +1,19 @@ +name: Party checks +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run build + - run: npm test --workspace=@brewtify/spotify && npm test --workspace=api && npm run test:integration --workspace=api + - run: npm run lint:party --workspace=mini-app && npm test --workspace=mini-app diff --git a/Dockerfile b/Dockerfile index 1ac0ac2..13a0ead 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,20 +10,26 @@ COPY projects/api/prisma ./projects/api/prisma/ COPY projects/api/prisma.config.ts ./projects/api/prisma.config.ts COPY projects/shared/package.json projects/shared/tsconfig.json ./projects/shared/ COPY projects/shared/src ./projects/shared/src/ +COPY projects/spotify/package.json projects/spotify/tsconfig.json ./projects/spotify/ +COPY projects/spotify/src ./projects/spotify/src/ COPY projects/tap/package.json projects/tap/tsconfig.json ./projects/tap/ COPY projects/tap/src ./projects/tap/src/ COPY projects/mini-app/package.json projects/mini-app/tsconfig.json projects/mini-app/tsconfig.app.json projects/mini-app/tsconfig.node.json ./projects/mini-app/ COPY projects/mini-app/index.html projects/mini-app/vite.config.ts ./projects/mini-app/ COPY projects/mini-app/src ./projects/mini-app/src/ COPY projects/mini-app/public ./projects/mini-app/public/ +COPY projects/mini-app/dev ./projects/mini-app/dev/ # Install all dependencies (including devDependencies for build) -RUN npm ci --workspace=projects/api --workspace=projects/shared --workspace=projects/tap --workspace=projects/mini-app --include-workspace-root +RUN npm ci --workspace=projects/api --workspace=projects/shared --workspace=projects/spotify --workspace=projects/tap --workspace=projects/mini-app --include-workspace-root # Build shared package WORKDIR /app/projects/shared RUN npx tsc +WORKDIR /app/projects/spotify +RUN npx tsc + # Build tap package WORKDIR /app/projects/tap RUN npx tsc @@ -49,12 +55,14 @@ WORKDIR /app COPY package.json package-lock.json ./ COPY projects/api/package.json ./projects/api/ COPY projects/shared/package.json ./projects/shared/ +COPY projects/spotify/package.json ./projects/spotify/ COPY projects/tap/package.json ./projects/tap/ COPY --from=builder /app/projects/shared/dist ./projects/shared/dist/ +COPY --from=builder /app/projects/spotify/dist ./projects/spotify/dist/ COPY --from=builder /app/projects/tap/dist ./projects/tap/dist/ # Install production dependencies only -RUN npm ci --workspace=projects/api --workspace=projects/shared --workspace=projects/tap --include-workspace-root --omit=dev +RUN npm ci --workspace=projects/api --workspace=projects/shared --workspace=projects/spotify --workspace=projects/tap --include-workspace-root --omit=dev # Copy built output (includes generated Prisma client in dist/generated/) COPY --from=builder /app/projects/api/dist ./projects/api/dist/ diff --git a/README.md b/README.md index 62dd970..6e4ec88 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # Brewtify Playlists Brewery for Spotify + +## Party Queue pilot + +The existing Telegram Mini App includes a feature-gated cross-service Party Queue. +See [setup, privacy, deployment and release gates](docs/party-queue.md) before enabling +`PARTY_ENABLED`. Library credentials and playback-only Party authorization are separate. diff --git a/deploy/party/.gitignore b/deploy/party/.gitignore new file mode 100644 index 0000000..fca97e2 --- /dev/null +++ b/deploy/party/.gitignore @@ -0,0 +1,6 @@ +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +*.tfplan +crash.log diff --git a/deploy/party/.terraform.lock.hcl b/deploy/party/.terraform.lock.hcl new file mode 100644 index 0000000..78b8795 --- /dev/null +++ b/deploy/party/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.50.0" + constraints = "~> 6.0" + hashes = [ + "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", + "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", + "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", + "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", + "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", + "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", + "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", + "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", + "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", + "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", + "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", + ] +} diff --git a/deploy/party/main.tf b/deploy/party/main.tf new file mode 100644 index 0000000..d8920e5 --- /dev/null +++ b/deploy/party/main.tf @@ -0,0 +1,127 @@ +terraform { + required_version = ">= 1.6" + required_providers { + google = { source = "hashicorp/google", version = "~> 6.0" } + } +} + +variable "project_id" { type = string } +variable "region" { type = string } +variable "service_url" { type = string } +variable "runtime_service_account" { type = string } +variable "cloud_run_service" { type = string } +variable "scheduler_region" { + type = string + description = "Cloud Scheduler-supported region; need not match Cloud Run." +} + +provider "google" { + project = var.project_id + region = var.region +} +data "google_project" "current" {} + +resource "google_project_service" "tasks" { + service = "cloudtasks.googleapis.com" + disable_on_destroy = false +} +resource "google_project_service" "scheduler" { + service = "cloudscheduler.googleapis.com" + disable_on_destroy = false +} +resource "google_service_account" "party_tasks" { + account_id = "brewtify-party-tasks" + display_name = "Brewtify Party internal task identity" +} +resource "google_cloud_tasks_queue" "party" { + name = "brewtify-party" + location = var.region + depends_on = [google_project_service.tasks] + rate_limits { + max_concurrent_dispatches = 5 + max_dispatches_per_second = 5 + } + retry_config { + max_attempts = 8 + min_backoff = "5s" + max_backoff = "120s" + max_doublings = 4 + max_retry_duration = "1800s" + } +} +resource "google_cloud_tasks_queue_iam_member" "enqueue" { + project = var.project_id + location = var.region + name = google_cloud_tasks_queue.party.name + role = "roles/cloudtasks.enqueuer" + member = "serviceAccount:${var.runtime_service_account}" +} +resource "google_service_account_iam_member" "act_as" { + service_account_id = google_service_account.party_tasks.name + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:${var.runtime_service_account}" +} +resource "google_service_account_iam_member" "tasks_token" { + service_account_id = google_service_account.party_tasks.name + role = "roles/iam.serviceAccountTokenCreator" + member = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-cloudtasks.iam.gserviceaccount.com" + depends_on = [google_project_service.tasks] +} +resource "google_service_account_iam_member" "scheduler_token" { + service_account_id = google_service_account.party_tasks.name + role = "roles/iam.serviceAccountTokenCreator" + member = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-cloudscheduler.iam.gserviceaccount.com" + depends_on = [google_project_service.scheduler] +} +resource "google_cloud_run_service_iam_member" "invoker" { + project = var.project_id + location = var.region + service = var.cloud_run_service + role = "roles/run.invoker" + member = "serviceAccount:${google_service_account.party_tasks.email}" +} +resource "google_cloud_scheduler_job" "maintenance" { + name = "brewtify-party-maintenance" + region = var.scheduler_region + schedule = "* * * * *" + time_zone = "Etc/UTC" + paused = true + depends_on = [google_project_service.scheduler] + attempt_deadline = "180s" + retry_config { + retry_count = 3 + min_backoff_duration = "5s" + max_backoff_duration = "60s" + } + http_target { + uri = "${var.service_url}/internal/party/maintenance" + http_method = "POST" + headers = { "Content-Type" = "application/json" } + body = base64encode("{}") + oidc_token { + service_account_email = google_service_account.party_tasks.email + audience = var.service_url + } + } +} + +# Cloud Run's request log otherwise stores full callback and launch query strings. +# Audit any additional log sinks/proxies separately before enabling real traffic. +resource "google_logging_project_exclusion" "party_capabilities" { + name = "brewtify-party-capability-urls" + description = "Do not retain OAuth or Telegram launch capabilities in request URLs." + filter = "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"${var.cloud_run_service}\" AND (httpRequest.requestUrl:\"/api/party/auth/\" OR httpRequest.requestUrl:\"/app?\")" +} + +output "party_environment" { + value = { + PARTY_ENABLED = "false" + PARTY_PUBLIC_ORIGIN = var.service_url + PARTY_SPOTIFY_REDIRECT_URI = "${var.service_url}/api/party/auth/callback" + PARTY_TASKS_PROJECT = var.project_id + PARTY_TASKS_LOCATION = var.region + PARTY_TASKS_QUEUE = google_cloud_tasks_queue.party.name + PARTY_TASKS_SERVICE_ACCOUNT = google_service_account.party_tasks.email + PARTY_TASKS_AUDIENCE = var.service_url + } +} diff --git a/docs/party-queue.md b/docs/party-queue.md new file mode 100644 index 0000000..8eb30c1 --- /dev/null +++ b/docs/party-queue.md @@ -0,0 +1,222 @@ +# Cross-service Party Queue pilot + +Party extends `/app` and the existing bot. Library retains its own login and +Playlists/Artists views. Party requests append to the host's Spotify playback +queue, never to a playlist or Spotify Jam. A successful command means **accepted, +not played**. Spotify and other controllers can affect eventual playback order. + +## Release gate + +`PARTY_ENABLED` defaults to false. Existing Library navigation and bot behavior +remain available without new secrets, schema access, or provider initialization. +The deploy workflow reads the repository variable `PARTY_ENABLED`, defaulting to +false. Keep it false until every gate below is verified on a separate test service. +No cloud resources, bot settings or Spotify playback are changed by adding these +files. Terraform is a reviewed provisioning artifact, **not an applied deployment**. + +Required before real pilot traffic: + +- A Spotify Development Mode host allowlist (check the actual app's current + authorization allocation, commonly five), a deliberately selected Premium + host, and the exact dedicated callback URI registered on the app. +- Minimal-scope `/me` identity, host-token catalog/playability/relinking, + search (limit ten), device discovery and queue access verified with that app. + Party requests only `user-read-playback-state user-modify-playback-state`. + The Premium checkbox is an explicit requirement, not proof from a removed + profile property. Only an explicit provider Premium error is labeled Premium. +- Apple Music developer team ID, key ID and ES256 signing private key. No + Music User Token, Apple ID or guest provider authorization is used. +- PostgreSQL migrations, direct/session-pinned connections supporting **session + advisory locks** (do not use PgBouncer/Neon transaction pooling for Party). +- Cloud Tasks queue, runtime enqueue/actAs grants, OIDC identity and audience, + Scheduler maintenance every minute, Secret Manager access, and log exclusions. +- The actual Telegram bot username and Main Mini App configured in BotFather + on a **test bot**. Never replace the live bot's URL for development. +- Real HTTPS Android/iOS/Desktop Telegram checks of signed `initData`, Secure + HttpOnly cookies, invitations, BackButton and external-browser OAuth return. + A local browser preview is not proof of any of these integrations. +- An explicitly authorized deliberate song for the final queue test. Setup never + enqueues a surprise eligibility probe. + +Missing configuration returns an actionable error; it is never a catalog no-match +or an authentication bypass. The server must not be opened to public host signup. + +## Database migration + +This repository previously used Prisma without a migration history. The new +`20260922000000_library_baseline` captures the unchanged Library schema. + +For a **new isolated database**: + +```sh +npm ci +npm run build +cd projects/api +DATABASE_URL='' npx prisma migrate deploy +``` + +For an **existing** database, first compare its schema to the Library baseline, +take a backup and confirm that it matches. Only then, with explicit operator +approval, mark that baseline applied and deploy the additive Party migration: + +```sh +npx prisma migrate resolve --applied 20260922000000_library_baseline +npx prisma migrate deploy +``` + +Never run `db push`, reset, or automatically mark an unknown schema as baselined. +Keep the custom partial unique indexes, status checks, approval sequence and +request-revision trigger in subsequent migrations. The trigger gives bounded +cursor polling a monotonic cursor even when multiple changes share a millisecond. + +## Provisioning and deployment + +`deploy/party/main.tf` provisions a queue, least-privilege service identity, +invocation/token grants, URL-log exclusions and a **paused** Scheduler job. +Choose supported Cloud Tasks/Scheduler regions; they need not both be the Cloud +Run region. Review `terraform plan`, then obtain approval before `terraform apply`. +The provisioning caller needs permission to act as the new Scheduler identity. +Service-agent IAM grants can require the APIs' managed service identities to be +created first in a newly provisioned project. + +Take environment values from the Terraform `party_environment` output and +`projects/api/.env.party.example`. Configure these on the isolated Cloud Run +revision, with `PARTY_ENABLED=false`. Bind `PARTY_IDENTITY_KEY` (independent random +32-byte hex), `APPLE_MUSIC_PRIVATE_KEY`, and the host allowlist through Secret +Manager. The Apple team/key IDs may be environment values. Existing encryption +and Spotify client configuration are reused; Library token rows are not. +Keep the Apple PEM server-side; never use a `VITE_` variable for secrets. + +Use explicit Cloud Run `--update-env-vars` / `--update-secrets` for these settings, +not replacement of existing Library configuration. The existing deploy action's +merge behavior retains provisioned Party settings. After migrations, verify a +correct-audience OIDC request to `/internal/party/maintenance` succeeds and an +unsigned or wrong-service-account request fails even on the public Cloud Run +service. Resume the Scheduler job only after that check; leave cleanup enabled +even when Party intake is disabled. Set the repository `PARTY_ENABLED` variable +to `true` only after the release gates pass, then deploy the approved revision. +`PARTY_AUTO_ENABLED=true` is a separate opt-in after operational abuse checks. + +Cloud Tasks delivers `POST /internal/party/jobs`. Scheduler calls +`POST /internal/party/maintenance`. Both verify Google ID tokens, exact audience, +verified email and the configured service account; task-looking headers confer +no authority. Do not create public cron-secret alternatives for these routes. +Queue dispatch and handler retries do not themselves establish Spotify idempotency. + +## Authorization and privacy + +Party never trusts `initDataUnsafe`, a raw Telegram-ID header or a development ID. +A signed launch is accepted for five minutes (30-second future skew) and is +single-use except reuse from its already authenticated cookie. Sessions last +one hour; reopen from Telegram to renew. Sessions store keyed principal hashes, +not Telegram names/IDs. Guests have room-scoped keyed pseudonyms and see only +their own receipts. Party does not create Library `User` rows. + +Start party creates a ten-minute authorization transaction bound to the verified +Mini App session/principal. A one-use launch ticket establishes a separate external +browser cookie, random OAuth state and S256 verifier. Callback consumption is +atomic. The Mini App polls its transaction with its own cookie; it does not rely +on the browser sharing cookies. Return-to-Telegram links grant no host privileges. +Cancellation/replay/expiry require a new explicit Start. Abandoned connected +setup credentials expire after 30 minutes. Active rooms have a fixed 12-hour TTL. + +Party token refresh uses database serialization and preserves rotated refresh +tokens. Explicit revocation deletes Party credentials, not Library tokens. +Closing/disconnecting deletes Party credentials immediately and cancels unsent +work. A command already in flight or in Spotify's queue cannot be recalled. +Switching tabs and Library logout do not disconnect Party. + +All mutation APIs require the configured exact HTTPS Origin and a session CSRF +token. Cookies are `Secure`, `HttpOnly`, `SameSite=Lax` and host-only. Failure in +Telegram's cookie storage is a release blocker, never a reason to expose provider +tokens. The app uses text rendering and bounded request bodies/input lengths. +Database-backed throttles are shared across instances and fail closed. Bootstrap +and authenticated-request IP throttling use the socket peer address, **not** +caller-controlled forwarded headers; behind +Cloud Run that may be a shared proxy, so its broad ceiling is intentionally high. +Verified-principal/session/room throttles supply the more selective controls. +Do not blindly enable Express `trust proxy=true`. Review trusted edge topology +and ingress-level abuse controls before widening the private pilot. + +The maintenance job deletes expired rooms and all linked requests/candidates/jobs/ +attempts/memberships in batches of 100, and expired sessions/auth flows/throttles/ +credentials in batches of 500. Every operational path enforces expiry immediately; +physical deletion normally follows on the next minute tick, longer under backlog +or outage. Monitor oldest expired row/outbox age and alert when above five minutes. +Set a short operational log retention (recommended seven days), check every +proxy/log sink for full URLs, and document the actual Neon/managed backup/PITR +retention in the deployment runbook. Physical deletion does **not** synchronously +erase managed backups. Restore procedures must immediately run expiry cleanup. + +## Delivery and failure handling + +Committed submissions and approvals create database outbox jobs. Scheduler +dispatches bounded named Cloud Tasks batches and repairs stale dispatch records. +Named-task deduplication handles successful task creation followed by an uncertain +DB update. Worst-case normal submission latency includes the one-minute scheduler +tick; visible Mini App polling is not a background worker. + +Session-pinned PostgreSQL advisory locks serialize an account across processes +and instances, including token refresh, moderation, close and delivery. One active +room per account is also enforced by a partial unique index. A sending attempt is +committed **before** the sole Spotify queue POST. Device revalidation never +transfers playback or chooses a different device. Direct links reject relinking +or missing playability evidence. Approval order applies in approval mode; eligible +auto-mode requests use submission order. Pending/unapproved songs do not block +approved requests. + +Spotify 204 is success without JSON decoding. Explicit 429 responses persist +`Retry-After` in the job, with no in-process sleeping. Read failures have bounded +durable retries. A timeout, uncertain 5xx or unrecorded sending attempt becomes +`failed / delivery_unknown`, blocks the room and is never automatically repeated. +Recovery does not infer idempotency by looking at the current queue. Hosts must +acknowledge the uncertainty before resuming; intentional retry requires a separate +duplicate-risk confirmation and a new attempt. A two-minute settling window blocks +acknowledgement/retry while an abandoned bounded HTTP operation could still be in +flight. Unknown is not "definitely failed". + +## Verification + +```sh +npm run build +npm test --workspace=@brewtify/spotify +npm test --workspace=api +npm run test:integration --workspace=api +npm run lint --workspace=mini-app +npm run lint:party --workspace=mini-app +npm test --workspace=mini-app +``` + +The integration suite starts an isolated **native PostgreSQL** binary from the +development-only `embedded-postgres` package, on a free loopback port in a named +temporary `.data/party-test-*` directory, and stops/removes that cluster afterward. +It never contacts `DATABASE_URL` from your environment. It exercises real SQL, +advisory locks (including another Node process), transaction/cascade behavior and +HTTP auth APIs; Spotify provider responses are fixtures, **not live proof**. +Root-only environments must use a non-root test user; the suite never creates +OS users or changes shared database services. + +The frontend README describes the development-only visual fixture and unique +preview port. Production party routes have no fixture identity. Real Telegram +tests require an HTTPS staging deployment/test bot, signed fresh launch data, +secure-cookie behavior, the external browser with a distinct cookie jar and the +actual allowlisted host. Record those results separately before enabling the pilot. + +With the visual fixture running at `http://127.0.0.1:5197` and Google Chrome +installed, `npm run test:party-browser` verifies mobile layout, no Library fetch +on Party entry, navigation during Library loading/failure, the disabled gate, and +the host moderation/CSRF contract using explicitly stubbed browser fixtures. +Override `PARTY_PREVIEW_URL` or `CHROME_PATH` for another local preview/browser. +The full legacy frontend lint currently has 27 existing errors in untouched files; +the focused `lint:party` gate covers changed frontend code without suppressing them. + +### Implementation verification (2026-09-22) + +The isolated worktree passed all five workspace builds, 39 provider/transport +tests, 15 catalog/Telegram tests, 15 native PostgreSQL/HTTP integration scenarios, +16 frontend tests, four headless Chrome browser checks, targeted frontend lint, +and Terraform initialization/validation without applying infrastructure. +The PostgreSQL suite runs both migrations and verifies zero Prisma schema drift. +Docker image execution was not available because the local Docker daemon was not +running. No real provider authorization, Telegram client session, Cloud Tasks +dispatch, production migration, or Spotify queue write was performed. diff --git a/package-lock.json b/package-lock.json index d7adbe7..6431ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/express": "^5.0.6", "@types/node": "^24.1.0", "nodemon": "^3.1.10", + "playwright-core": "^1.58.2", "prettier": "^3.6.2", "ts-node": "^10.9.2", "turbo": "^2.5.5", @@ -304,6 +305,10 @@ "resolved": "projects/shared", "link": true }, + "node_modules/@brewtify/spotify": { + "resolved": "projects/spotify", + "link": true + }, "node_modules/@brewtify/tap": { "resolved": "projects/tap", "link": true @@ -351,6 +356,150 @@ "@electric-sql/pglite": "0.4.1" } }, + "node_modules/@embedded-postgres/darwin-arm64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/darwin-arm64/-/darwin-arm64-18.4.0-beta.17.tgz", + "integrity": "sha512-Kg1ZMNFzkVIJs3g4V2UYEc5X005km9rGinxFBH8R1sOU2Rblvz4pt2Uf93Pu8Rk273Ue9HIdrbMPpgUqwjUi0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/darwin-x64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/darwin-x64/-/darwin-x64-18.4.0-beta.17.tgz", + "integrity": "sha512-4tShSYWMxUQTSWoQAdLnHISvRj6L9gTch9/31ASJPg6wgyN64LDRwMcOINEWybM7N0ropJ3nTYhFT3UX1bKY5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-arm": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-arm/-/linux-arm-18.4.0-beta.17.tgz", + "integrity": "sha512-VuD1nFasN7yG57zpJcp6MBXM0nXqGfb8GBQV+f1FGJ17+VMNYLIFLhFp99UlY4xnWG74FQC4NPYw8eCwSTJ6qg==", + "cpu": [ + "arm" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-arm64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-arm64/-/linux-arm64-18.4.0-beta.17.tgz", + "integrity": "sha512-TIzjtGnDGSD/LbdW0OWCEcbI+YVT0WKSfSr20TCkkP4UR8zeKe+QCZbO0/CM4hS9EWyZ4cDebjRvpRc3iMi6wg==", + "cpu": [ + "arm64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-ia32": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-ia32/-/linux-ia32-18.4.0-beta.17.tgz", + "integrity": "sha512-aSRe4kknqRHuedtpo/rDIaqa8r9VrKIgW9p5FkJyIFqUUkIkxRqND1dg8xrDRREcSUXRJKS0x+j/AuxflgYIfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-ppc64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-ppc64/-/linux-ppc64-18.4.0-beta.17.tgz", + "integrity": "sha512-zO2RRUFdKRPplrdhmglG39VXJZzDXu3P3ONAG1sFPhGh89vbk24Btd5P7uOmQLIpWehzL0s+UqUWoeE/ZUKvgw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-x64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-x64/-/linux-x64-18.4.0-beta.17.tgz", + "integrity": "sha512-jVw/MdDtIX/vICH/DKIe6/mHpiCggdx6QVyza4vt/NbcZFsL0KhwglF6F1Koqx3gRBZ9XtN+vi63EsqSyqOSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/windows-x64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/windows-x64/-/windows-x64-18.4.0-beta.17.tgz", + "integrity": "sha512-AwRerliA4IGWyW5jWBvHt5vidVUSB0QQW9Tt2y7ScnmifnCb/awfxZr4BkBSTv+gNt8Djcddqs+xSF5Z6/CkTg==", + "cpu": [ + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -926,12 +1075,55 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@google-cloud/tasks": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/tasks/-/tasks-6.3.0.tgz", + "integrity": "sha512-80I5otVKL+cWY2fzMVs7vj5qOQ+JDCxO7Kem90P33JsR6EZDg0dRquHkSqsNH5NbAEOW9g1WAq9O5S8jPiE14g==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@grammyjs/types": { "version": "3.27.3", "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.27.3.tgz", "integrity": "sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg==", "license": "MIT" }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hono/node-server": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", @@ -1011,6 +1203,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1077,6 +1286,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", @@ -1112,6 +1331,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", @@ -1361,6 +1590,63 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2540,6 +2826,16 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -2895,6 +3191,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2912,6 +3217,30 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2937,6 +3266,16 @@ "dev": true, "license": "MIT" }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -2951,7 +3290,26 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -2983,6 +3341,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3078,6 +3445,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3175,6 +3548,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -3234,17 +3616,122 @@ "fsevents": "~2.3.2" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "devOptional": true, "license": "MIT" }, @@ -3323,7 +3810,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3341,6 +3827,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3358,6 +3853,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3427,6 +3931,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3453,6 +3963,33 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3477,6 +4014,36 @@ "dev": true, "license": "ISC" }, + "node_modules/embedded-postgres": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/embedded-postgres/-/embedded-postgres-18.4.0-beta.17.tgz", + "integrity": "sha512-ORT/A1YiO6ecpRHpyIIBgyCR/8ASqqYZuFw11aX9PkqTX3FUM5+9sNOXY1mGIixxsm2TWSIA5WghJEk7A5ZxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "pg": "^8.7.3" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@embedded-postgres/darwin-arm64": "^18.4.0-beta.17", + "@embedded-postgres/darwin-x64": "^18.4.0-beta.17", + "@embedded-postgres/linux-arm": "^18.4.0-beta.17", + "@embedded-postgres/linux-arm64": "^18.4.0-beta.17", + "@embedded-postgres/linux-ia32": "^18.4.0-beta.17", + "@embedded-postgres/linux-ppc64": "^18.4.0-beta.17", + "@embedded-postgres/linux-x64": "^18.4.0-beta.17", + "@embedded-postgres/windows-x64": "^18.4.0-beta.17" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -3496,6 +4063,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.4.tgz", @@ -3603,7 +4179,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3897,6 +4472,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -3958,6 +4539,29 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4047,7 +4651,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -4060,6 +4663,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4101,6 +4716,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -4121,6 +4782,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4175,6 +4845,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -4188,6 +4879,30 @@ "node": ">= 6" } }, + "node_modules/glob/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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "17.6.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", @@ -4201,6 +4916,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4248,6 +5047,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -4329,6 +5141,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -4336,6 +5161,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4417,6 +5255,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4457,9 +5304,23 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -4489,6 +5350,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4523,6 +5393,27 @@ "node": ">=6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4812,11 +5703,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/lru-cache": { @@ -4959,6 +5855,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -5039,6 +5944,26 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5127,6 +6052,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5245,6 +6179,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5258,7 +6207,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5268,12 +6216,33 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -5427,6 +6396,28 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -5587,6 +6578,41 @@ "devOptional": true, "license": "ISC" }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5634,6 +6660,197 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -5705,6 +6922,20 @@ "react": "^19.2.6" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5728,6 +6959,15 @@ "url": "https://github.com/sponsors/remeda" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5738,6 +6978,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -5748,6 +6994,34 @@ "node": ">= 4" } }, + "node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -5837,6 +7111,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5913,6 +7207,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5923,7 +7223,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5936,7 +7235,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6018,7 +7316,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -6084,6 +7381,132 @@ "devOptional": true, "license": "MIT" }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -6116,6 +7539,39 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6460,6 +7916,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6516,6 +7981,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -6646,6 +8117,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -6666,7 +8146,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6678,6 +8157,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6688,6 +8173,97 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6703,6 +8279,15 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6724,6 +8309,74 @@ "node": ">= 14.6" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -6785,15 +8438,19 @@ "version": "1.0.0", "dependencies": { "@brewtify/shared": "*", + "@brewtify/spotify": "*", "@brewtify/tap": "*", + "@google-cloud/tasks": "^6.2.1", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@upstash/redis": "^1.38.0", + "google-auth-library": "^10.3.0", "p-queue": "^9.3.0", "pg": "^8.21.0" }, "devDependencies": { "@types/pg": "^8.20.0", + "embedded-postgres": "18.4.0-beta.17", "prisma": "^7.8.0" } }, @@ -6803,6 +8460,7 @@ "@brewtify/shared": "*", "@tailwindcss/vite": "^4.3.0", "@telegram-apps/sdk-react": "^3.3.9", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", "tailwindcss": "^4.3.0" @@ -6810,6 +8468,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.12.3", + "@types/qrcode": "^1.5.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -7472,6 +9131,14 @@ "typescript": "^5.0.0" } }, + "projects/spotify": { + "name": "@brewtify/spotify", + "version": "1.0.0", + "dependencies": { + "@brewtify/shared": "*", + "undici": "^7.16.0" + } + }, "projects/tap": { "name": "@brewtify/tap", "version": "1.0.0", diff --git a/package.json b/package.json index b787733..0119fd4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "packageManager": "npm@10.9.2", "scripts": { "dev": "turbo run dev", - "build": "turbo run build" + "build": "turbo run build", + "test:party-browser": "node --test tests/party.browser.mjs" }, "keywords": [], "author": "", @@ -22,6 +23,7 @@ "@types/node": "^24.1.0", "nodemon": "^3.1.10", "prettier": "^3.6.2", + "playwright-core": "^1.58.2", "ts-node": "^10.9.2", "turbo": "^2.5.5", "typescript": "^5.8.3", diff --git a/projects/api/.env.party.example b/projects/api/.env.party.example new file mode 100644 index 0000000..bde7a2e --- /dev/null +++ b/projects/api/.env.party.example @@ -0,0 +1,18 @@ +# Disabled by default; no production values or credentials belong in this file. +PARTY_ENABLED=false +PARTY_AUTO_ENABLED=false +PARTY_PUBLIC_ORIGIN=https://YOUR-TEST-SERVICE.run.app +PARTY_SPOTIFY_REDIRECT_URI=https://YOUR-TEST-SERVICE.run.app/api/party/auth/callback +PARTY_TELEGRAM_BOT_USERNAME=YOUR_TEST_BOT +# Spotify account IDs, not email addresses or Telegram IDs. +PARTY_HOST_ALLOWLIST= +# Independent 32-byte hex HMAC key; store in Secret Manager. +PARTY_IDENTITY_KEY= +APPLE_MUSIC_TEAM_ID= +APPLE_MUSIC_KEY_ID= +APPLE_MUSIC_PRIVATE_KEY= +PARTY_TASKS_PROJECT= +PARTY_TASKS_LOCATION= +PARTY_TASKS_QUEUE=brewtify-party +PARTY_TASKS_SERVICE_ACCOUNT= +PARTY_TASKS_AUDIENCE=https://YOUR-TEST-SERVICE.run.app diff --git a/projects/api/package.json b/projects/api/package.json index 461aa81..2108d35 100644 --- a/projects/api/package.json +++ b/projects/api/package.json @@ -7,18 +7,23 @@ "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "db:studio": "prisma studio", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test dist/party/*.test.js", + "test:integration": "node --test tests/party.integration.test.mjs" }, "devDependencies": { "@types/pg": "^8.20.0", + "embedded-postgres": "18.4.0-beta.17", "prisma": "^7.8.0" }, "dependencies": { "@brewtify/shared": "*", + "@brewtify/spotify": "*", "@brewtify/tap": "*", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@upstash/redis": "^1.38.0", + "@google-cloud/tasks": "^6.2.1", + "google-auth-library": "^10.3.0", "p-queue": "^9.3.0", "pg": "^8.21.0" } diff --git a/projects/api/prisma/migrations/20260922000000_library_baseline/migration.sql b/projects/api/prisma/migrations/20260922000000_library_baseline/migration.sql new file mode 100644 index 0000000..a85a9f0 --- /dev/null +++ b/projects/api/prisma/migrations/20260922000000_library_baseline/migration.sql @@ -0,0 +1,51 @@ +CREATE SCHEMA IF NOT EXISTS "public"; +CREATE TABLE "users" ( + "id" SERIAL NOT NULL, + "telegram_user_id" TEXT NOT NULL, + "telegram_username" TEXT, + "spotify_user_id" TEXT, + "encrypted_access_token" TEXT, + "encrypted_refresh_token" TEXT, + "token_expires_at" BIGINT, + "encryption_salt" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); +CREATE TABLE "playlists" ( + "id" TEXT NOT NULL, + "user_id" INTEGER NOT NULL, + "spotify_playlist_id" TEXT NOT NULL, + "name" TEXT, + "artist_ids" TEXT[], + "track_count" INTEGER NOT NULL DEFAULT 50, + "weights" JSONB, + "era_preference" INTEGER NOT NULL DEFAULT 50, + "era_preferences" JSONB, + "schedule" TEXT, + "next_update_at" TIMESTAMP(3), + "last_updated_at" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'active', + "failure_count" INTEGER NOT NULL DEFAULT 0, + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "playlists_pkey" PRIMARY KEY ("id") +); +CREATE TABLE "user_preferences" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "preferred_genres" TEXT[], + "preferred_moods" TEXT[], + "favorite_artist_ids" TEXT[], + "max_tracks_per_artist" INTEGER NOT NULL DEFAULT 5, + "exclude_explicit" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "users_telegram_user_id_key" ON "users"("telegram_user_id"); +CREATE INDEX "idx_due_updates" ON "playlists"("next_update_at"); +CREATE UNIQUE INDEX "playlists_user_id_spotify_playlist_id_key" ON "playlists"("user_id", "spotify_playlist_id"); +CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id"); +ALTER TABLE "playlists" ADD CONSTRAINT "playlists_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/projects/api/prisma/migrations/20260922120000_party/migration.sql b/projects/api/prisma/migrations/20260922120000_party/migration.sql new file mode 100644 index 0000000..d75cf7b --- /dev/null +++ b/projects/api/prisma/migrations/20260922120000_party/migration.sql @@ -0,0 +1,74 @@ +CREATE TABLE party_mini_app_sessions ( + id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, init_hash TEXT NOT NULL UNIQUE, + principal TEXT NOT NULL, csrf TEXT NOT NULL, expires_at TIMESTAMP(3) NOT NULL +); +CREATE INDEX party_mini_app_sessions_expires_at_idx ON party_mini_app_sessions(expires_at); +CREATE TABLE party_authorizations ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES party_mini_app_sessions(id) ON DELETE CASCADE, + principal TEXT NOT NULL, ticket_hash TEXT NOT NULL UNIQUE, state_hash TEXT UNIQUE, + browser_hash TEXT, encrypted_verifier TEXT NOT NULL, salt TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','launched','consuming','complete','failed')), + error TEXT, expires_at TIMESTAMP(3) NOT NULL, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX party_authorizations_session_id_created_at_idx ON party_authorizations(session_id, created_at); +CREATE TABLE party_host_sessions ( + id TEXT PRIMARY KEY, principal TEXT NOT NULL UNIQUE, account_key TEXT NOT NULL UNIQUE, + encrypted_access_token TEXT NOT NULL, encrypted_refresh_token TEXT NOT NULL, salt TEXT NOT NULL, + scopes TEXT[] NOT NULL, token_expires_at TIMESTAMP(3) NOT NULL, expires_at TIMESTAMP(3) NOT NULL +); +CREATE TABLE party_rooms ( + id TEXT PRIMARY KEY, owner TEXT NOT NULL, account_key TEXT NOT NULL, + join_hash TEXT NOT NULL UNIQUE, encrypted_join TEXT NOT NULL, salt TEXT NOT NULL, + device_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','locked','closed','expired')), + mode TEXT NOT NULL DEFAULT 'host_approval' CHECK (mode IN ('host_approval','auto')), + blocked_reason TEXT, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP(3) NOT NULL +); +CREATE UNIQUE INDEX party_one_active_room ON party_rooms(account_key) WHERE status IN ('open','locked'); +CREATE INDEX party_rooms_expires_at_idx ON party_rooms(expires_at); +CREATE TABLE party_memberships ( + room_id TEXT NOT NULL REFERENCES party_rooms(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES party_mini_app_sessions(id) ON DELETE CASCADE, + participant TEXT NOT NULL, PRIMARY KEY(room_id, session_id) +); +CREATE SEQUENCE party_approval_order; +CREATE TABLE party_requests ( + id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES party_rooms(id) ON DELETE CASCADE, + participant TEXT NOT NULL, submission_key TEXT NOT NULL, display_name TEXT NOT NULL, + source_url TEXT NOT NULL, source JSONB, selected JSONB, confidence TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','matched','needs_review','approved','added','unavailable','rejected','failed')), + failure_code TEXT, sequence BIGSERIAL UNIQUE NOT NULL, revision BIGSERIAL UNIQUE NOT NULL, approved_order BIGINT, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(room_id,participant,submission_key) +); +CREATE INDEX party_requests_room_id_updated_at_id_idx ON party_requests(room_id,updated_at,id); +CREATE FUNCTION party_request_revision() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.revision := nextval('party_requests_revision_seq'); + NEW.updated_at := clock_timestamp(); + RETURN NEW; +END +$$; +CREATE TRIGGER party_request_changed BEFORE UPDATE ON party_requests FOR EACH ROW EXECUTE FUNCTION party_request_revision(); +CREATE TABLE party_match_candidates ( + request_id TEXT NOT NULL REFERENCES party_requests(id) ON DELETE CASCADE, + track_id TEXT NOT NULL, metadata JSONB NOT NULL, PRIMARY KEY(request_id,track_id) +); +CREATE TABLE party_jobs ( + id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES party_rooms(id) ON DELETE CASCADE, + request_id TEXT NOT NULL REFERENCES party_requests(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('resolve','deliver')), status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','done')), + generation INTEGER NOT NULL DEFAULT 0, failures INTEGER NOT NULL DEFAULT 0, + due_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, dispatched_at TIMESTAMP(3), + UNIQUE(request_id,kind) +); +CREATE INDEX party_jobs_status_due_at_idx ON party_jobs(status,due_at); +CREATE TABLE party_delivery_attempts ( + id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES party_requests(id) ON DELETE CASCADE, + room_id TEXT NOT NULL REFERENCES party_rooms(id) ON DELETE CASCADE, + outcome TEXT NOT NULL DEFAULT 'sending' CHECK(outcome IN ('sending','accepted','rejected','unknown')), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX party_one_sending_per_room ON party_delivery_attempts(room_id) WHERE outcome='sending'; +CREATE INDEX party_delivery_attempts_room_id_outcome_idx ON party_delivery_attempts(room_id,outcome); +CREATE TABLE party_throttles (key TEXT PRIMARY KEY, hits INTEGER NOT NULL, expires_at TIMESTAMP(3) NOT NULL); +CREATE INDEX party_throttles_expires_at_idx ON party_throttles(expires_at); diff --git a/projects/api/prisma/migrations/migration_lock.toml b/projects/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/projects/api/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/projects/api/prisma/schema.prisma b/projects/api/prisma/schema.prisma index 75cc502..e6e2dca 100644 --- a/projects/api/prisma/schema.prisma +++ b/projects/api/prisma/schema.prisma @@ -68,3 +68,151 @@ model UserPreferences { @@map("user_preferences") } + +model PartyMiniAppSession { + id String @id + tokenHash String @unique @map("token_hash") + initHash String @unique @map("init_hash") + principal String + csrf String + expiresAt DateTime @map("expires_at") + authorizations PartyAuthorization[] + memberships PartyMembership[] + @@index([expiresAt]) + @@map("party_mini_app_sessions") +} + +model PartyAuthorization { + id String @id + sessionId String @map("session_id") + principal String + ticketHash String @unique @map("ticket_hash") + stateHash String? @unique @map("state_hash") + browserHash String? @map("browser_hash") + encryptedVerifier String @map("encrypted_verifier") + salt String + status String @default("pending") + error String? + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + session PartyMiniAppSession @relation(fields: [sessionId], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@index([sessionId, createdAt]) + @@map("party_authorizations") +} + +model PartyHostSession { + id String @id + principal String @unique + accountKey String @unique @map("account_key") + encryptedAccessToken String @map("encrypted_access_token") + encryptedRefreshToken String @map("encrypted_refresh_token") + salt String + scopes String[] + tokenExpiresAt DateTime @map("token_expires_at") + expiresAt DateTime @map("expires_at") + @@map("party_host_sessions") +} + +model PartyRoom { + id String @id + owner String + accountKey String @map("account_key") + joinHash String @unique @map("join_hash") + encryptedJoin String @map("encrypted_join") + salt String + deviceId String @map("device_id") + status String @default("open") + mode String @default("host_approval") + blockedReason String? @map("blocked_reason") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + memberships PartyMembership[] + requests PartyRequest[] + jobs PartyJob[] + attempts PartyDeliveryAttempt[] + @@index([expiresAt]) + @@map("party_rooms") +} + +model PartyMembership { + roomId String @map("room_id") + sessionId String @map("session_id") + participant String + room PartyRoom @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + session PartyMiniAppSession @relation(fields: [sessionId], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@id([roomId, sessionId]) + @@map("party_memberships") +} + +model PartyRequest { + id String @id + roomId String @map("room_id") + participant String + submissionKey String @map("submission_key") + displayName String @map("display_name") + sourceUrl String @map("source_url") + source Json? + selected Json? + confidence String? + status String @default("pending") + failureCode String? @map("failure_code") + sequence BigInt @unique @default(autoincrement()) + revision BigInt @unique @default(autoincrement()) + approvedOrder BigInt? @map("approved_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") + room PartyRoom @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + candidates PartyMatchCandidate[] + jobs PartyJob[] + attempts PartyDeliveryAttempt[] + @@unique([roomId, participant, submissionKey]) + @@index([roomId, updatedAt, id]) + @@map("party_requests") +} + +model PartyMatchCandidate { + requestId String @map("request_id") + trackId String @map("track_id") + metadata Json + request PartyRequest @relation(fields: [requestId], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@id([requestId, trackId]) + @@map("party_match_candidates") +} + +model PartyJob { + id String @id + roomId String @map("room_id") + requestId String @map("request_id") + kind String + status String @default("pending") + generation Int @default(0) + failures Int @default(0) + dueAt DateTime @default(now()) @map("due_at") + dispatchedAt DateTime? @map("dispatched_at") + room PartyRoom @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + request PartyRequest @relation(fields: [requestId], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@unique([requestId, kind]) + @@index([status, dueAt]) + @@map("party_jobs") +} + +model PartyDeliveryAttempt { + id String @id + requestId String @map("request_id") + roomId String @map("room_id") + outcome String @default("sending") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") + room PartyRoom @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + request PartyRequest @relation(fields: [requestId], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@index([roomId, outcome]) + @@map("party_delivery_attempts") +} + +model PartyThrottle { + key String @id + hits Int + expiresAt DateTime @map("expires_at") + @@index([expiresAt]) + @@map("party_throttles") +} diff --git a/projects/api/src/bot.ts b/projects/api/src/bot.ts index e5fc3c0..4e82186 100644 --- a/projects/api/src/bot.ts +++ b/projects/api/src/bot.ts @@ -27,6 +27,11 @@ export function createBot() { // Register username for all tap notifications and persist to DB bot.use(async (ctx, next) => { + // Party is a launch-only flow; do not persist or instrument guest identities. + if (/^\/party(?:@\w+)?(?:\s|$)/i.test(ctx.message?.text ?? '')) { + await next(); + return; + } if (ctx.from) { const userId = ctx.from.id.toString(); const username = ctx.from.username || ctx.from.first_name; @@ -53,6 +58,7 @@ export function createBot() { }); bot.command('help', async (ctx) => { + const partyEnabled = process.env.PARTY_ENABLED === 'true' && process.env.PARTY_TELEGRAM_BOT_USERNAME; await ctx.reply( '🎵 *Brewtify Bot*\n\n' + 'Available commands:\n' + @@ -60,6 +66,7 @@ export function createBot() { '/help \\- Show this help message\n' + '/ping \\- Check if the bot is alive\n' + '/login \\- Connect your Spotify account\n' + + (partyEnabled ? '/party \\- Open Party in Telegram \\(Library login is separate\\)\n' : '') + '/playlists \\- List your Spotify playlists\n' + '/schedule \\- Set auto\\-update schedule\n' + '/pause \\- Pause a playlist schedule\n' + @@ -73,6 +80,18 @@ export function createBot() { await ctx.reply('🏓 Pong!'); }); + bot.command('party', async (ctx) => { + const username = process.env.PARTY_TELEGRAM_BOT_USERNAME?.replace(/^@/, ''); + if (process.env.PARTY_ENABLED !== 'true' || !username || !/^[A-Za-z0-9_]+$/.test(username)) { + await ctx.reply('Party is not available yet. Your existing Brewtify Library commands are unchanged.'); + return; + } + await ctx.reply( + 'Open Party in the Brewtify Mini App to host or join. Guests need no music-provider login. Requests and moderation stay in the Mini App.', + { reply_markup: { inline_keyboard: [[{ text: 'Open Party', url: `https://t.me/${username}?startapp=party` }]] } }, + ); + }); + bot.command('login', async (ctx) => { const telegramUserId = ctx.from?.id.toString(); if (!telegramUserId) { diff --git a/projects/api/src/middleware/request-logger.ts b/projects/api/src/middleware/request-logger.ts index 8a18ca0..7788ea1 100644 --- a/projects/api/src/middleware/request-logger.ts +++ b/projects/api/src/middleware/request-logger.ts @@ -8,20 +8,22 @@ export function requestLogger(req: Request, res: Response, next: NextFunction) { res.on('finish', () => { const duration = Date.now() - start; + const party = req.path.startsWith('/api/party') || req.path.startsWith('/internal/party'); + const safePath = party ? '/party/[redacted]' : req.path; const meta = { method: req.method, - path: req.path, + path: safePath, status: res.statusCode, duration_ms: duration, - user: req.headers['x-telegram-user-id'] as string | undefined, + user: party ? undefined : req.headers['x-telegram-user-id'] as string | undefined, }; if (res.statusCode >= 500) { - log.error(`${req.method} ${req.path} ${res.statusCode}`, meta); + log.error(`${req.method} ${safePath} ${res.statusCode}`, meta); } else if (res.statusCode >= 400) { - log.warn(`${req.method} ${req.path} ${res.statusCode}`, meta); + log.warn(`${req.method} ${safePath} ${res.statusCode}`, meta); } else if (req.path !== '/health') { - log.info(`${req.method} ${req.path} ${res.statusCode}`, meta); + log.info(`${req.method} ${safePath} ${res.statusCode}`, meta); } }); diff --git a/projects/api/src/party/auth.ts b/projects/api/src/party/auth.ts new file mode 100644 index 0000000..238082e --- /dev/null +++ b/projects/api/src/party/auth.ts @@ -0,0 +1,414 @@ +import { randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; +import type { PoolClient } from 'pg'; +import { SpotifyClient } from '@brewtify/spotify'; +import { decrypt, encrypt, generateSalt } from '../services/encryption'; +import { PartyError, partyOrigin, required, telegramUrl } from './config'; +import { database, hostLock, rows, transaction } from './store'; +import { + BROWSER_COOKIE, + SESSION_COOKIE, + checkOrigin, + constantEqual, + cookie, + cookieOptions, + hash, + identity, + secret, + verifyTelegram, + type MiniSession, +} from './security'; + +export function spotify(): SpotifyClient { + return new SpotifyClient({ + clientId: required('SPOTIFY_CLIENT_ID'), + redirectUri: required('PARTY_SPOTIFY_REDIRECT_URI'), + }); +} +export interface HostSession { + id: string; + principal: string; + account_key: string; + encrypted_access_token: string; + encrypted_refresh_token: string; + salt: string; + scopes: string[]; + token_expires_at: Date; + expires_at: Date; +} + +export async function bootstrap( + req: Request, + res: Response +): Promise { + checkOrigin(req); + const initData = + typeof req.body?.initData === 'string' ? req.body.initData : ''; + const verified = verifyTelegram(initData, required('TELEGRAM_BOT_TOKEN')); + const initHash = verified.launchHash; + return transaction(async (client) => { + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [ + `party-init:${initHash}`, + ]); + const [existing] = await rows( + 'SELECT * FROM party_mini_app_sessions WHERE init_hash=$1', + [initHash], + client + ); + if (existing) { + if ( + existing.expires_at > new Date() && + constantEqual(existing.token_hash, hash(cookie(req, SESSION_COOKIE))) + ) + return existing; + throw new PartyError( + 401, + 'launch_replayed', + 'Reopen the Mini App in Telegram for a fresh signed launch.' + ); + } + const token = secret(); + const result: MiniSession = { + id: randomUUID(), + principal: identity(`telegram:${verified.id}`), + csrf: secret(), + expires_at: new Date(Date.now() + 3600_000), + }; + await client.query( + 'INSERT INTO party_mini_app_sessions (id,token_hash,init_hash,principal,csrf,expires_at) VALUES ($1,$2,$3,$4,$5,$6)', + [ + result.id, + hash(token), + initHash, + result.principal, + result.csrf, + result.expires_at, + ] + ); + res.cookie(SESSION_COOKIE, token, { ...cookieOptions, maxAge: 3600_000 }); + return result; + }); +} + +export async function hostFor( + principal: string, + client?: PoolClient +): Promise { + const [host] = await rows( + 'SELECT * FROM party_host_sessions WHERE principal=$1 AND expires_at>now()', + [principal], + client + ); + if (!host) + throw new PartyError( + 401, + 'host_reconnect', + 'The host must connect Spotify for Party.' + ); + return host; +} + +// Call only while holding the account's hostLock, including refresh and room writes. +export async function accessToken( + host: HostSession, + client: PoolClient +): Promise { + if (host.expires_at <= new Date()) + throw new PartyError(401, 'host_reconnect', 'Party authorization expired.'); + if (host.token_expires_at.getTime() > Date.now() + 60_000) + return decrypt(host.encrypted_access_token, host.salt); + try { + const next = await spotify().refresh( + decrypt(host.encrypted_refresh_token, host.salt) + ); + const refresh = next.refreshToken + ? encrypt(next.refreshToken, host.salt) + : host.encrypted_refresh_token; + await client.query( + `UPDATE party_host_sessions SET encrypted_access_token=$2,encrypted_refresh_token=$3,token_expires_at=$4 WHERE id=$1`, + [ + host.id, + encrypt(next.accessToken, host.salt), + refresh, + new Date(Date.now() + next.expiresIn * 1000), + ] + ); + return next.accessToken; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'invalid_grant' + ) { + await transaction(async (tx) => { + await tx.query('DELETE FROM party_host_sessions WHERE id=$1', [ + host.id, + ]); + await tx.query( + "UPDATE party_rooms SET blocked_reason='host_reconnect' WHERE account_key=$1 AND status IN ('open','locked')", + [host.account_key] + ); + }, client); + throw new PartyError( + 401, + 'host_reconnect', + 'Spotify authorization was revoked. Reconnect Party.' + ); + } + throw error; + } +} + +interface Authorization { + id: string; + session_id: string; + principal: string; + encrypted_verifier: string; + salt: string; + status: string; + error: string | null; + browser_hash: string | null; + expires_at: Date; +} + +export async function startAuthorization(who: MiniSession): Promise { + const ticket = secret(); + const verifier = secret(); + const salt = generateSalt(); + await hostLock(`principal:${who.principal}`, (lock) => + transaction(async (client) => { + await client.query( + 'SELECT pg_advisory_xact_lock(hashtextextended($1,0))', + [`party-auth:${who.id}`] + ); + await client.query( + "UPDATE party_authorizations SET status='failed',error='superseded',encrypted_verifier='' WHERE session_id=$1 AND status IN ('pending','launched')", + [who.id] + ); + const [consuming] = await rows( + 'SELECT id FROM party_authorizations WHERE session_id=$1 AND status=$2 AND expires_at>now()', + [who.id, 'consuming'], + client + ); + if (consuming) + throw new PartyError( + 409, + 'authorization_busy', + 'Spotify authorization is completing. Please wait.' + ); + await client.query( + `INSERT INTO party_authorizations (id,session_id,principal,ticket_hash,encrypted_verifier,salt,expires_at) + VALUES ($1,$2,$3,$4,$5,$6,now()+interval '10 minutes')`, + [ + randomUUID(), + who.id, + who.principal, + hash(ticket), + encrypt(verifier, salt), + salt, + ] + ); + }, lock) + ); + return `${partyOrigin()}/api/party/auth/launch?ticket=${ticket}`; +} + +export async function launchAuthorization( + req: Request, + res: Response +): Promise { + const ticket = typeof req.query.ticket === 'string' ? req.query.ticket : ''; + const state = secret(); + const browser = secret(); + const [flow] = await rows( + `UPDATE party_authorizations SET status='launched',state_hash=$2,browser_hash=$3 + WHERE ticket_hash=$1 AND status='pending' AND expires_at>now() RETURNING *`, + [hash(ticket), hash(state), hash(browser)] + ); + if (!flow) + throw new PartyError( + 400, + 'authorization_expired', + 'Authorization link expired or was already opened. Start again in Telegram.' + ); + const challenge = Buffer.from( + hash(decrypt(flow.encrypted_verifier, flow.salt)), + 'hex' + ).toString('base64url'); + res.cookie(BROWSER_COOKIE, browser, { ...cookieOptions, maxAge: 600_000 }); + res.redirect(spotify().authorizationUrl({ state, codeChallenge: challenge })); +} + +export async function finishAuthorization( + req: Request, + res: Response +): Promise { + const state = typeof req.query.state === 'string' ? req.query.state : ''; + const browser = cookie(req, BROWSER_COOKIE); + const [flow] = await rows( + `UPDATE party_authorizations SET status='consuming' + WHERE state_hash=$1 AND browser_hash=$2 AND status='launched' AND expires_at>now() RETURNING *`, + [hash(state), hash(browser)] + ); + if (!flow || !browser) + throw new PartyError( + 400, + 'authorization_expired', + 'Authorization expired or browser binding was lost. Start again from Telegram.' + ); + res.clearCookie(BROWSER_COOKIE, cookieOptions); + try { + if (req.query.error || typeof req.query.code !== 'string') + throw new PartyError( + 400, + 'authorization_cancelled', + 'Spotify authorization was cancelled.' + ); + const tokens = await spotify().exchange( + req.query.code, + decrypt(flow.encrypted_verifier, flow.salt) + ); + const scopes = ['user-modify-playback-state', 'user-read-playback-state']; + if (!scopes.every((scope) => tokens.scopes.includes(scope))) + throw new PartyError( + 403, + 'insufficient_scope', + 'Playback permissions were not granted.' + ); + if (!tokens.refreshToken) + throw new PartyError( + 502, + 'provider_response', + 'Spotify did not return a refresh token.' + ); + const profile = await spotify().profile(tokens.accessToken); + if ( + !required('PARTY_HOST_ALLOWLIST') + .split(',') + .map((value) => value.trim()) + .includes(profile.id) + ) { + throw new PartyError( + 403, + 'host_not_allowlisted', + 'This Spotify account is not in the private host pilot.' + ); + } + const account = identity(`spotify:${profile.id}`); + await hostLock(`principal:${flow.principal}`, () => + hostLock(account, (client) => + transaction(async (tx) => { + const [activeFlow] = await rows( + 'SELECT * FROM party_authorizations WHERE id=$1 AND status=$2 AND expires_at>now() FOR UPDATE', + [flow.id, 'consuming'], + tx + ); + if (!activeFlow) + throw new PartyError( + 401, + 'authorization_expired', + 'Authorization was disconnected or expired.' + ); + await tx.query( + 'DELETE FROM party_rooms WHERE account_key=$1 AND expires_at<=now()', + [account] + ); + await tx.query( + 'DELETE FROM party_host_sessions WHERE account_key=$1 AND expires_at<=now()', + [account] + ); + const [other] = await rows( + 'SELECT * FROM party_host_sessions WHERE account_key=$1 OR principal=$2', + [account, flow.principal], + tx + ); + if ( + other && + (other.principal !== flow.principal || + other.account_key !== account) + ) { + throw new PartyError( + 409, + 'account_in_use', + 'Disconnect the existing Party authorization before connecting another host.' + ); + } + const [room] = await rows<{ owner: string; expires_at: Date }>( + "SELECT owner,expires_at FROM party_rooms WHERE account_key=$1 AND status IN ('open','locked')", + [account], + tx + ); + if (room && room.owner !== flow.principal) + throw new PartyError( + 409, + 'account_in_use', + 'This Spotify account already hosts a party.' + ); + const salt = generateSalt(); + await tx.query( + `INSERT INTO party_host_sessions (id,principal,account_key,encrypted_access_token,encrypted_refresh_token,salt,scopes,token_expires_at,expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT(principal) DO UPDATE SET encrypted_access_token=EXCLUDED.encrypted_access_token, + encrypted_refresh_token=EXCLUDED.encrypted_refresh_token,salt=EXCLUDED.salt,scopes=EXCLUDED.scopes, + token_expires_at=EXCLUDED.token_expires_at,expires_at=EXCLUDED.expires_at`, + [ + randomUUID(), + flow.principal, + account, + encrypt(tokens.accessToken, salt), + encrypt(tokens.refreshToken!, salt), + salt, + tokens.scopes, + new Date(Date.now() + tokens.expiresIn * 1000), + room?.expires_at ?? new Date(Date.now() + 1800_000), + ] + ); + await tx.query( + "UPDATE party_authorizations SET status='complete',encrypted_verifier='' WHERE id=$1", + [flow.id] + ); + await tx.query( + "UPDATE party_rooms SET blocked_reason=NULL WHERE account_key=$1 AND blocked_reason IN ('host_reconnect','unauthorized','insufficient_scope','premium_required','forbidden')", + [account] + ); + }, client) + ) + ); + res + .type('html') + .send( + `Party connected

Spotify connected for Party

Return to the Telegram window where you started. No song has been queued.

Return to Brewtify` + ); + } catch (error) { + const code = + error instanceof PartyError + ? error.code + : 'provider_authorization_failed'; + await database().query( + "UPDATE party_authorizations SET status='failed',error=$2,encrypted_verifier='' WHERE id=$1", + [flow.id, code] + ); + throw error; + } +} + +export async function authorizationStatus( + who: MiniSession +): Promise<{ status: string; error?: string }> { + const [flow] = await rows( + 'SELECT * FROM party_authorizations WHERE session_id=$1 AND principal=$2 ORDER BY created_at DESC LIMIT 1', + [who.id, who.principal] + ); + if (!flow) return { status: 'idle' }; + if (flow.expires_at <= new Date()) + return { status: 'failed', error: 'authorization_expired' }; + return { + status: + flow.status === 'complete' + ? 'complete' + : flow.status === 'failed' + ? 'failed' + : 'pending', + ...(flow.error ? { error: flow.error } : {}), + }; +} diff --git a/projects/api/src/party/catalog.test.ts b/projects/api/src/party/catalog.test.ts new file mode 100644 index 0000000..1c10703 --- /dev/null +++ b/projects/api/src/party/catalog.test.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import { test } from 'node:test'; +import type { PartyTrack } from '@brewtify/shared'; +import { SpotifyError, type SpotifyTrack } from '@brewtify/spotify'; +import { + CatalogError, matchCandidates, parseSongLink, resolveSong, type CatalogSpotifyClient, +} from './catalog'; + +const ID = '0123456789ABCDEFGHIJKL'; +const OTHER = 'ABCDEFGHIJKLMNOPQRSTUV'; +const source = (overrides: Partial = {}): PartyTrack => ({ + id: '123', title: 'The Song', artist: 'The Artist', album: 'The Album', + durationMs: 200_000, explicit: false, isrc: 'USABC1234567', + url: 'https://music.apple.com/us/song/123', ...overrides, +}); +const track = (overrides: Partial = {}): SpotifyTrack => ({ + id: ID, name: 'The Song', artists: [{ name: 'The Artist' }], album: { name: 'The Album' }, + duration_ms: 200_000, explicit: false, is_playable: true, + external_ids: { isrc: 'USABC1234567' }, ...overrides, +}); +const spotify = (overrides: Partial = {}): CatalogSpotifyClient => ({ + track: async () => track(), + search: async () => [track()], + ...overrides, +}); + +test('strict parser accepts canonical/localized Spotify and individual Apple forms', () => { + for (const input of [ + `spotify:track:${ID}`, `https://open.spotify.com/track/${ID}`, + ` https://open.spotify.com/track/${ID}?si=share `, + `https://open.spotify.com/intl-de/track/${ID}`, + `https://open.spotify.com/intl-pt-BR/track/${ID}/`, + ]) assert.deepEqual(parseSongLink(input), { provider: 'spotify', id: ID, url: `https://open.spotify.com/track/${ID}` }); + for (const input of [ + 'https://music.apple.com/us/song/123', + 'https://music.apple.com/us/song/the-song/123?l=en', + 'https://music.apple.com/us/album/the-album/456?i=123&l=en', + 'https://music.apple.com/us/album/456?i=123', + ]) assert.deepEqual(parseSongLink(input), { + provider: 'apple_music', id: '123', storefront: 'us', url: 'https://music.apple.com/us/song/123', + }); +}); + +test('parser rejects hostile, normalized, unsupported, ambiguous or malformed inputs', () => { + const inputs = [ + '', 'https://music.apple.com/us/album/456', 'https://music.apple.com/us/album/a/456?i=123&i=123', + 'https://music.apple.com/us/album/a/456?i=123&%69=124', + 'https://music.apple.com/us/album/a/456?i=', 'https://music.apple.com/us/album/a/456?i=-123', + 'https://music.apple.com/us/album/a/456?i=1.2', 'https://music.apple.com/us/album/a/456?i=0123', + 'https://music.apple.com/us/song/a/123?i=124', 'https://music.apple.com/us/song/a%2fb/123', + 'https://music.apple.com/us/song/a%5cb/123', 'https://music.apple.com/us/song/%00/123', + 'https://music.apple.com/us/song/%GG/123', 'https://music.apple.com/USA/song/123', + 'https://music.apple.com/us/song/0', 'https://music.apple.com/us/song/123/extra', + 'https://music.apple.com/us/playlist/foo/123', 'https://music.apple.com/us/song/123#fragment', + `http://open.spotify.com/track/${ID}`, `https://open.spotify.com.evil.example/track/${ID}`, + `https://evil.example@open.spotify.com/track/${ID}`, `https://open.spotify.com@evil.example/track/${ID}`, + `https://open.spotify.com:8443/track/${ID}`, `https://open.spotify.com:443/track/${ID}`, + `https://open.spotify.com./track/${ID}`, `https://open.spotify.com/artist/${ID}`, + `https://open.spotify.com/album/${ID}`, `https://open.spotify.com/embed/track/${ID}`, + `https://open.spotify.com/intl-foo/track/${ID}`, `https://open.spotify.com/de/track/${ID}`, + `https://open.spotify.com/track/${ID}/extra`, 'https://open.spotify.com/track/123', + `https://open.spotify.com/track/%30${ID.slice(1)}`, `https://open.spotify.com/./track/${ID}`, + `https://open.spotify.com/a/../track/${ID}`, `https://open.spotify.com/%2e/track/${ID}`, + `https://open.spotify.com\\track\\${ID}`, `https://open.spotify.com/\ttrack/${ID}`, + `https://open.spotify.com/track/${ID}\n?si=x`, `https://open.spotify.com/track/${ID}#x`, + `https://spotify.link/${ID}`, `https://127.0.0.1/track/${ID}`, `https://[::1]/track/${ID}`, + `spotify:album:${ID}`, `spotify:track:${ID}:extra`, 'x'.repeat(2049), + ]; + for (const input of inputs) assert.throws(() => parseSongLink(input), (error: unknown) => + error instanceof CatalogError && error.code === 'invalid_song_link', input); +}); + +test('unique recording with complete evidence matches exact, without ISRC needs full album evidence', () => { + const exact = matchCandidates(source(), [track()]); + assert.equal(exact.confidence, 'exact'); + assert.equal(exact.selected?.id, ID); + assert.ok(exact.selected?.evidence.includes('same_isrc')); + assert.equal(matchCandidates(source({ isrc: undefined }), [track({ external_ids: undefined })]).confidence, 'high'); + assert.equal(matchCandidates(source({ isrc: undefined }), [ + track({ external_ids: undefined, album: { name: 'Greatest Hits' } }), + ]).confidence, 'ambiguous'); +}); + +test('same ISRC never overrides version, language, explicitness, duration or artist contradictions', () => { + const variants: Partial[] = [ + { name: 'The Song (Live)' }, { name: 'The Song - 2024 Remaster' }, { name: 'The Song (DJ Remix)' }, + { name: 'The Song - Radio Edit' }, { name: 'The Song - Spanish Version' }, { name: 'The Song (Clean)' }, + { name: 'The Song - Instrumental' }, { name: 'The Song - Acoustic' }, { name: 'The Song - Sped Up' }, + { name: 'The Song - Extended Mix' }, { album: { name: 'The Album - Live' } }, + { album: { name: 'The Album (Remastered)' } }, { explicit: true }, { duration_ms: 202_001 }, + { duration_ms: 197_999 }, { artists: [{ name: 'Cover Artist' }] }, { external_ids: { isrc: 'GBXYZ7654321' } }, + ]; + for (const variant of variants) { + const result = matchCandidates(source(), [track(variant)]); + assert.equal(result.confidence, 'no_match', JSON.stringify(variant)); + assert.equal(result.selected, undefined); + } + assert.equal(matchCandidates(source({ title: 'The Song (Live)' }), [track()]).confidence, 'no_match'); + assert.equal(matchCandidates(source({ title: 'The Song - Alice Remix' }), [ + track({ name: 'The Song - Bob Remix' }), + ]).confidence, 'no_match'); + assert.equal(matchCandidates(source({ album: 'The Album - 2020 Remaster' }), [ + track({ album: { name: 'The Album - 2024 Remaster' } }), + ]).confidence, 'no_match'); +}); + +test('duration tolerance boundaries are deterministic and missing critical evidence never auto-selects', () => { + for (const duration of [198_000, 202_000]) assert.equal( + matchCandidates(source(), [track({ duration_ms: duration })]).confidence, 'exact', + ); + for (const candidate of [ + track({ duration_ms: undefined }), track({ explicit: undefined }), + track({ album: { name: '' } }), track({ external_ids: undefined }), + ]) { + const result = matchCandidates(source(), [candidate]); + assert.equal(result.confidence, 'ambiguous'); + assert.equal(result.selected, undefined); + } + for (const input of [source({ explicit: null }), source({ durationMs: 0 }), source({ album: '' }), source({ isrc: undefined })]) { + assert.equal(matchCandidates(input, [track()]).confidence, 'ambiguous'); + } +}); + +test('duplicate track IDs deduplicate; distinct editions and incomplete alternatives remain ambiguous', () => { + assert.equal(matchCandidates(source(), [track(), track()]).confidence, 'exact'); + assert.equal(matchCandidates(source(), [track(), track({ explicit: true })]).confidence, 'ambiguous'); + assert.equal(matchCandidates(source(), [track(), track({ is_playable: false })]).confidence, 'ambiguous'); + const result = matchCandidates(source(), [track(), track({ id: OTHER })]); + assert.equal(result.confidence, 'ambiguous'); + assert.equal(result.selected, undefined); + assert.equal(result.candidates.length, 2); + assert.equal(matchCandidates(source(), [track(), track({ id: OTHER, explicit: undefined })]).confidence, 'ambiguous'); + const many = Array.from({ length: 20 }, (_, index) => track({ id: String(index).padStart(22, '0') })); + assert.equal(matchCandidates(source(), many).candidates.length, 10); + assert.equal(matchCandidates(source(), Array(21).fill(track())).confidence, 'ambiguous'); +}); + +test('direct Spotify requires same requested ID, positive playability and no relinking/restrictions', async () => { + assert.equal((await resolveSong(`spotify:track:${ID}`, 'host', spotify())).confidence, 'exact'); + for (const variant of [ + { id: OTHER }, { is_playable: undefined }, { is_playable: false }, + { linked_from: { id: OTHER } }, { restrictions: { reason: 'market' } }, { is_local: true }, + ]) { + const result = await resolveSong(`spotify:track:${ID}`, 'host', spotify({ track: async () => track(variant) })); + assert.equal(result.confidence, 'no_match'); + assert.equal(result.selected, undefined); + assert.equal(result.source.id, ID); + } +}); + +test('Spotify outages and auth failures propagate, only explicit missing track becomes no-match', async () => { + for (const code of ['network', 'rate_limited', 'unauthorized', 'provider_unavailable', 'invalid_response'] as const) { + const error = new SpotifyError(code); + await assert.rejects(resolveSong(`spotify:track:${ID}`, 'host', spotify({ track: async () => { throw error; } })), error); + } + assert.equal((await resolveSong(`spotify:track:${ID}`, 'host', spotify({ + track: async () => { throw new SpotifyError('not_found', 404); }, + }))).confidence, 'no_match'); +}); + +const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); +const withAppleConfiguration = (t: { after: (fn: () => void) => void }) => { + const names = ['APPLE_MUSIC_TEAM_ID', 'APPLE_MUSIC_KEY_ID', 'APPLE_MUSIC_PRIVATE_KEY']; + const original = names.map(name => process.env[name]); + process.env.APPLE_MUSIC_TEAM_ID = 'ABCDEFGHIJ'; + process.env.APPLE_MUSIC_KEY_ID = '0123456789'; + process.env.APPLE_MUSIC_PRIVATE_KEY = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(); + t.after(() => names.forEach((name, index) => { + if (original[index] === undefined) delete process.env[name]; + else process.env[name] = original[index]; + })); +}; +const appleBody = (attributes: Record = {}) => ({ + data: [{ id: '123', type: 'songs', attributes: { + name: 'The Song', artistName: 'The Artist', albumName: 'The Album', + durationInMillis: 200_000, contentRating: 'clean', isrc: 'USABC1234567', + artwork: { url: 'https://is1-ssl.mzstatic.com/image/{w}x{h}bb.jpg' }, + ...attributes, + } }], +}); + +test('Apple adapter signs official ES256 JWT, never sends user token, searches ISRC then constrained title', async t => { + withAppleConfiguration(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(String(input)); + calls++; + assert.equal(url.toString(), 'https://api.music.apple.com/v1/catalog/us/songs/123'); + assert.equal(init?.redirect, 'manual'); + const headers = init?.headers as Record; + assert.equal(headers['Music-User-Token'], undefined); + const jwt = headers.Authorization.slice(7).split('.'); + const head = JSON.parse(Buffer.from(jwt[0], 'base64url').toString()); + const claims = JSON.parse(Buffer.from(jwt[1], 'base64url').toString()); + assert.equal(head.alg, 'ES256'); + assert.equal(head.kid, '0123456789'); + assert.equal(claims.iss, 'ABCDEFGHIJ'); + assert.equal(claims.exp - claims.iat, 300); + assert.equal(verify('sha256', Buffer.from(jwt.slice(0, 2).join('.')), + { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(jwt[2], 'base64url')), true); + return new Response(JSON.stringify(appleBody())); + }); + const queries: string[] = []; + const result = await resolveSong('https://music.apple.com/us/album/album/456?i=123', 'host-token', spotify({ + search: async (token, query) => { assert.equal(token, 'host-token'); queries.push(query); return [track()]; }, + })); + assert.equal(result.confidence, 'exact'); + assert.deepEqual(queries, ['isrc:USABC1234567', 'track:"the song" artist:"the artist"']); + assert.equal(result.source.artwork, 'https://is1-ssl.mzstatic.com/image/300x300bb.jpg'); + assert.equal(calls, 1); +}); + +test('Apple missing/invalid credentials are configuration errors, never empty results', async t => { + withAppleConfiguration(t); + delete process.env.APPLE_MUSIC_TEAM_ID; + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), + (error: unknown) => error instanceof CatalogError && error.code === 'apple_configuration'); + process.env.APPLE_MUSIC_TEAM_ID = 'ABCDEFGHIJ'; + process.env.APPLE_MUSIC_PRIVATE_KEY = 'invalid'; + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), + (error: unknown) => error instanceof CatalogError && error.code === 'apple_configuration'); +}); + +test('Apple omitted rating/duration are unknown, not fabricated clean/playtime evidence', async t => { + withAppleConfiguration(t); + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(appleBody({ + contentRating: undefined, durationInMillis: undefined, + })))); + const result = await resolveSong('https://music.apple.com/us/song/123', 'host', spotify()); + assert.equal(result.source.explicit, null); + assert.equal(result.source.durationMs, 0); + assert.equal(result.confidence, 'ambiguous'); + assert.equal(result.selected, undefined); +}); + +test('Apple status/error mapping and malformed payloads remain distinct from no match', async t => { + withAppleConfiguration(t); + const cases = [ + { status: 401, code: 'apple_unauthorized' }, { status: 403, code: 'apple_unauthorized' }, + { status: 429, code: 'apple_rate_limited' }, { status: 503, code: 'apple_unavailable' }, + { status: 302, code: 'apple_invalid_response' }, { status: 400, code: 'apple_rejected' }, + { status: 200, code: 'apple_invalid_response' }, + ]; + for (const fixture of cases) { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response('{}', { + status: fixture.status, headers: { 'Retry-After': '17', Location: 'http://127.0.0.1/' }, + })); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), (error: unknown) => { + assert.ok(error instanceof CatalogError); + assert.equal(error.code, fixture.code); + if (fixture.status === 429) assert.equal(error.retryAfterSeconds, 17); + return true; + }); + mocked.mock.restore(); + } + const network = t.mock.method(globalThis, 'fetch', async () => { throw new Error('offline'); }); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), + (error: unknown) => error instanceof CatalogError && error.code === 'apple_unavailable'); + network.mock.restore(); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); + assert.equal((await resolveSong('https://music.apple.com/us/song/123', 'host', spotify())).confidence, 'no_match'); +}); + +test('Apple returned ID mismatch and Spotify search outage are never no-match', async t => { + withAppleConfiguration(t); + const body = appleBody(); + body.data[0].id = '456'; + const response = t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(body))); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), + (error: unknown) => error instanceof CatalogError && error.code === 'apple_invalid_response'); + response.mock.restore(); + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(appleBody()))); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify({ + search: async () => { throw new SpotifyError('provider_unavailable', 503); }, + })), (error: unknown) => error instanceof SpotifyError && error.code === 'provider_unavailable'); +}); diff --git a/projects/api/src/party/catalog.ts b/projects/api/src/party/catalog.ts new file mode 100644 index 0000000..2ea79ec --- /dev/null +++ b/projects/api/src/party/catalog.ts @@ -0,0 +1,284 @@ +import { createPrivateKey, sign } from 'node:crypto'; +import type { PartyCandidate, PartyConfidence, PartyTrack } from '@brewtify/shared'; +import { + providerRequest, ProviderTransportError, SPOTIFY_TRACK_ID, SpotifyError, + trackEligibility, type SpotifyTrack, +} from '@brewtify/spotify'; + +export { assertTrackEligible, trackEligibility } from '@brewtify/spotify'; + +export interface SongLink { + provider: 'spotify' | 'apple_music'; + id: string; + storefront?: string; + url: string; +} + +export class CatalogError extends Error { + constructor( + public readonly code: 'invalid_song_link' | 'apple_configuration' | 'apple_unauthorized' + | 'apple_rate_limited' | 'apple_unavailable' | 'apple_invalid_response' | 'apple_rejected', + public readonly status?: number, + public readonly retryAfterSeconds?: number, + ) { + super(`Song catalog failed: ${code}`); + this.name = 'CatalogError'; + } +} + +const APPLE_ID = /^[1-9]\d{0,19}$/; +const ISRC = /^[A-Z]{2}[A-Z0-9]{3}\d{7}$/; +export const MATCH_DURATION_TOLERANCE_MS = 2_000; +export const MAX_MATCH_CANDIDATES = 10; + +export function parseSongLink(input: string): SongLink { + const fail = (): never => { throw new CatalogError('invalid_song_link'); }; + if (typeof input !== 'string' || input.length > 2048) return fail(); + const text = input.trim(); + const uri = /^spotify:track:([A-Za-z0-9]{22})$/.exec(text); + if (uri) return { provider: 'spotify', id: uri[1], url: `https://open.spotify.com/track/${uri[1]}` }; + // URL parsers normalize backslashes, controls and dot paths; reject them before parsing. + if (!text || /[\u0000-\u0020\u007f\\]/.test(text) || !/^https:\/\//i.test(text)) return fail(); + let url: URL; + try { url = new URL(text); } catch { return fail(); } + if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash) return fail(); + const authority = text.slice(text.indexOf('://') + 3).split(/[/?#]/, 1)[0]; + if (!/^(open\.spotify\.com|music\.apple\.com)$/i.test(authority)) return fail(); + const rawPath = text.slice(text.indexOf(authority) + authority.length).split(/[?#]/, 1)[0]; + if (rawPath !== url.pathname || /(?:^|\/)\.{1,2}(?:\/|$)/.test(rawPath)) return fail(); + if (url.hostname === 'open.spotify.com') { + const match = /^\/(?:intl-[a-z]{2}(?:-[A-Z]{2})?\/)?track\/([A-Za-z0-9]{22})\/?$/.exec(url.pathname); + if (!match) return fail(); + return { provider: 'spotify', id: match[1], url: `https://open.spotify.com/track/${match[1]}` }; + } + if (url.hostname !== 'music.apple.com') return fail(); + const match = /^\/([a-z]{2})\/(song|album)\/(?:([^/]+)\/)?([1-9]\d{0,19})\/?$/.exec(url.pathname); + if (!match) return fail(); + if (match[3]) { + try { + if (/[/\\\u0000-\u001f\u007f]/.test(decodeURIComponent(match[3]))) return fail(); + } catch { return fail(); } + } + const ids = url.searchParams.getAll('i'); + let id = match[4]; + if (match[2] === 'album') { + if (ids.length !== 1 || !APPLE_ID.test(ids[0])) return fail(); + id = ids[0]; + } else if (ids.length > 0) { + // A song route with a second identifier is ambiguous, even if they happen to agree. + return fail(); + } + return { + provider: 'apple_music', id, storefront: match[1], + url: `https://music.apple.com/${match[1]}/song/${id}`, + }; +} + +function record(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record : {}; +} + +function appleDeveloperToken(): string { + const teamId = process.env.APPLE_MUSIC_TEAM_ID; + const keyId = process.env.APPLE_MUSIC_KEY_ID; + const privateKey = process.env.APPLE_MUSIC_PRIVATE_KEY; + if (!teamId || !keyId || !privateKey || !/^[A-Z0-9]{10}$/.test(teamId) || !/^[A-Z0-9]{10}$/.test(keyId)) { + throw new CatalogError('apple_configuration'); + } + try { + const key = createPrivateKey(privateKey.replace(/\\n/g, '\n')); + if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') { + throw new Error('Expected a P-256 signing key'); + } + const now = Math.floor(Date.now() / 1000); + const encode = (data: unknown) => Buffer.from(JSON.stringify(data)).toString('base64url'); + const unsigned = `${encode({ alg: 'ES256', kid: keyId })}.${encode({ iss: teamId, iat: now, exp: now + 300 })}`; + return `${unsigned}.${sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`; + } catch { + throw new CatalogError('apple_configuration'); + } +} + +function safeArtwork(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length > 2048) return undefined; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.port) return undefined; + // Only artwork CDNs, never a submitted or arbitrary provider-controlled URL. + if (!(url.hostname.endsWith('.mzstatic.com') || url.hostname === 'i.scdn.co')) return undefined; + return url.toString(); + } catch { return undefined; } +} + +async function appleSong(link: SongLink): Promise { + const token = appleDeveloperToken(); + let response; + try { + response = await providerRequest('apple', `catalog/${link.storefront}/songs/${link.id}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + } catch (error) { + if (error instanceof ProviderTransportError) { + throw new CatalogError(error.code === 'network' ? 'apple_unavailable' : 'apple_invalid_response', error.status); + } + throw error; + } + if (response.status === 404) return undefined; + if (response.status === 401 || response.status === 403) throw new CatalogError('apple_unauthorized', response.status); + if (response.status === 429) throw new CatalogError('apple_rate_limited', 429, response.retryAfterSeconds ?? 60); + if (response.status >= 500) throw new CatalogError('apple_unavailable', response.status); + if (response.status !== 200) throw new CatalogError('apple_rejected', response.status); + const data = record(response.body).data; + if (!Array.isArray(data) || data.length > 1) throw new CatalogError('apple_invalid_response'); + if (!data.length) return undefined; + const song = record(data[0]); + const attributes = record(song.attributes); + if (song.id !== link.id || song.type !== 'songs' + || typeof attributes.name !== 'string' || !attributes.name || attributes.name.length > 1000 + || typeof attributes.artistName !== 'string' || !attributes.artistName || attributes.artistName.length > 1000 + || typeof attributes.albumName !== 'string' || attributes.albumName.length > 1000 + || (attributes.durationInMillis !== undefined && (typeof attributes.durationInMillis !== 'number' + || !Number.isFinite(attributes.durationInMillis) || attributes.durationInMillis <= 0)) + || (attributes.contentRating !== undefined && !['explicit', 'clean'].includes(String(attributes.contentRating)))) { + throw new CatalogError('apple_invalid_response'); + } + if (attributes.isrc !== undefined && (typeof attributes.isrc !== 'string' || !ISRC.test(attributes.isrc.toUpperCase()))) { + throw new CatalogError('apple_invalid_response'); + } + const rawArtwork = record(attributes.artwork).url; + return { + id: link.id, title: attributes.name, artist: attributes.artistName, album: attributes.albumName, + durationMs: typeof attributes.durationInMillis === 'number' ? attributes.durationInMillis : 0, + // Apple omits contentRating on unrated tracks; omission is not evidence of "clean". + explicit: attributes.contentRating === 'explicit' ? true : attributes.contentRating === 'clean' ? false : null, + isrc: typeof attributes.isrc === 'string' && ISRC.test(attributes.isrc.toUpperCase()) ? attributes.isrc.toUpperCase() : undefined, + url: link.url, + artwork: safeArtwork(typeof rawArtwork === 'string' ? rawArtwork.replace('{w}', '300').replace('{h}', '300') : undefined), + }; +} + +export interface CatalogSpotifyClient { + track(token: string, id: string): Promise; + search(token: string, query: string): Promise; +} + +export interface SongResolution { + source: PartyTrack; + candidates: PartyCandidate[]; + selected?: PartyCandidate; + confidence: PartyConfidence; +} + +function spotifyMetadata(track: SpotifyTrack): PartyTrack { + return { + id: track.id, title: track.name, artist: track.artists.map(artist => artist.name).join(', '), + album: track.album.name, durationMs: track.duration_ms ?? 0, explicit: track.explicit ?? null, + isrc: track.external_ids?.isrc?.toUpperCase(), + url: `https://open.spotify.com/track/${track.id}`, + artwork: safeArtwork(track.album.images?.[0]?.url), + }; +} + +function normalized(text: string): string { + // No qualifier removal, transliteration or fuzzy artist/title similarity. + return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); +} + +const VERSION = /\b(live|remaster(?:ed)?|remix(?:ed)?|mix|radio|edit|acoustic|instrumental|karaoke|demo|mono|stereo|extended|clean|explicit|censored|uncensored|sped|slowed|reverb|version|english|spanish|french|german|italian|portuguese|japanese|korean|chinese|hebrew|arabic)\b/; + +function versionsContradict(source: PartyTrack, candidate: PartyTrack): boolean { + if (normalized(source.title) !== normalized(candidate.title)) return true; + const sourceAlbum = normalized(source.album); + const candidateAlbum = normalized(candidate.album); + // A version-bearing album supplies recording evidence even when the title does not. + return sourceAlbum !== candidateAlbum && (VERSION.test(sourceAlbum) || VERSION.test(candidateAlbum)); +} + +export function matchCandidates(source: PartyTrack, tracks: SpotifyTrack[]): SongResolution { + const candidates: PartyCandidate[] = []; + const safe: { candidate: PartyCandidate; confidence: 'exact' | 'high' }[] = []; + const seen = new Set(); + const signatures = new Map(); + const inconsistent = new Set(); + const boundedTracks = tracks.slice(0, MAX_MATCH_CANDIDATES * 2); + for (const track of boundedTracks) { + const signature = JSON.stringify([ + spotifyMetadata(track), track.is_playable, track.restrictions, track.linked_from, track.is_local, + ]); + const previous = signatures.get(track.id); + if (previous !== undefined && previous !== signature) inconsistent.add(track.id); + signatures.set(track.id, signature); + } + for (const track of boundedTracks) { + if (seen.has(track.id) || !trackEligibility(track).eligible) continue; + seen.add(track.id); + const candidate = spotifyMetadata(track); + if (versionsContradict(source, candidate)) continue; + if (!source.artist || normalized(source.artist) !== normalized(candidate.artist)) continue; + if (source.explicit !== null && candidate.explicit !== null && source.explicit !== candidate.explicit) continue; + if (source.durationMs > 0 && candidate.durationMs > 0 + && Math.abs(source.durationMs - candidate.durationMs) > MATCH_DURATION_TOLERANCE_MS) continue; + const sourceIsrc = source.isrc && ISRC.test(source.isrc) ? source.isrc : undefined; + const candidateIsrc = candidate.isrc && ISRC.test(candidate.isrc) ? candidate.isrc : undefined; + if (sourceIsrc && candidateIsrc && sourceIsrc !== candidateIsrc) continue; + const evidence = ['same_title_and_artist', 'playable']; + const sameIsrc = Boolean(sourceIsrc && candidateIsrc && sourceIsrc === candidateIsrc); + if (sameIsrc) evidence.push('same_isrc'); + const durationKnown = source.durationMs > 0 && candidate.durationMs > 0; + if (durationKnown) evidence.push('duration_within_2000ms'); + const explicitKnown = source.explicit !== null && candidate.explicit !== null; + if (explicitKnown) evidence.push('same_explicitness'); + const albumKnown = Boolean(source.album && candidate.album); + const sameAlbum = albumKnown && normalized(source.album) === normalized(candidate.album); + if (sameAlbum) evidence.push('same_album'); + const result = { ...candidate, evidence }; + candidates.push(result); + if (inconsistent.has(track.id)) { + evidence.push('inconsistent_provider_metadata'); + continue; + } + // Missing recording ID evidence cannot be silently filled by another recording. + const isrcEvidenceComplete = Boolean(sameIsrc || (!source.isrc && !candidate.isrc)); + if (durationKnown && explicitKnown && albumKnown && isrcEvidenceComplete && (sameIsrc || sameAlbum)) { + safe.push({ candidate: result, confidence: sameIsrc ? 'exact' : 'high' }); + } else { + evidence.push('incomplete_evidence'); + } + } + const bounded = candidates.slice(0, MAX_MATCH_CANDIDATES); + // No fuzzy scoring or arbitrary tie-breaking: every viable alternative prevents auto-selection. + if (tracks.length <= MAX_MATCH_CANDIDATES * 2 && candidates.length === 1 && safe.length === 1) { + return { source, candidates: bounded, selected: safe[0].candidate, confidence: safe[0].confidence }; + } + return { source, candidates: bounded, confidence: candidates.length ? 'ambiguous' : 'no_match' }; +} + +function unavailableSource(link: SongLink): PartyTrack { + return { id: link.id, title: 'Unavailable song', artist: '', album: '', durationMs: 0, explicit: null, url: link.url }; +} + +export async function resolveSong(input: string, token: string, spotifyClient: CatalogSpotifyClient): Promise { + const link = parseSongLink(input); + if (link.provider === 'spotify') { + let track: SpotifyTrack; + try { track = await spotifyClient.track(token, link.id); } catch (error) { + if (error instanceof SpotifyError && error.code === 'not_found') { + return { source: unavailableSource(link), candidates: [], confidence: 'no_match' }; + } + throw error; + } + const source = { ...spotifyMetadata(track), id: link.id, url: link.url }; + if (!trackEligibility(track, link.id).eligible) return { source, candidates: [], confidence: 'no_match' }; + const selected = { ...source, evidence: ['direct_spotify_id', 'playable', 'not_relinked'] }; + return { source, candidates: [selected], selected, confidence: 'exact' }; + } + const source = await appleSong(link); + if (!source) return { source: unavailableSource(link), candidates: [], confidence: 'no_match' }; + const tracks: SpotifyTrack[] = []; + if (source.isrc) tracks.push(...await spotifyClient.search(token, `isrc:${source.isrc}`)); + // Quote boundaries and field operators cannot come from provider metadata. + const searchText = (text: string) => normalized(text).slice(0, 250); + tracks.push(...await spotifyClient.search(token, `track:"${searchText(source.title)}" artist:"${searchText(source.artist)}"`)); + return matchCandidates(source, tracks); +} diff --git a/projects/api/src/party/config.ts b/projects/api/src/party/config.ts new file mode 100644 index 0000000..df90c26 --- /dev/null +++ b/projects/api/src/party/config.ts @@ -0,0 +1,99 @@ +export function partyEnabled(): boolean { + return process.env.PARTY_ENABLED === 'true'; +} + +export function required(name: string): string { + const value = process.env[name]?.trim(); + if (!value) + throw new PartyError( + 503, + 'configuration_required', + `Party configuration missing: ${name}` + ); + return value; +} + +export class PartyError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public retryAfter?: number + ) { + super(message); + } +} + +export function partyOrigin(): string { + const url = new URL(required('PARTY_PUBLIC_ORIGIN')); + if ( + url.protocol !== 'https:' || + url.pathname !== '/' || + url.search || + url.hash || + url.username || + url.password + ) { + throw new PartyError( + 503, + 'configuration_required', + 'Party requires an HTTPS public origin.' + ); + } + return url.origin; +} + +export function telegramUrl(payload = 'party'): string { + const username = required('PARTY_TELEGRAM_BOT_USERNAME'); + if ( + !/^[A-Za-z0-9_]{5,32}$/.test(username) || + !/^[A-Za-z0-9_-]{1,64}$/.test(payload) + ) { + throw new PartyError( + 503, + 'configuration_required', + 'Invalid Telegram Mini App configuration.' + ); + } + return `https://t.me/${username}?startapp=${payload}`; +} + +export function checkPrerequisites(): void { + partyOrigin(); + telegramUrl(); + for (const name of [ + 'DATABASE_URL', + 'ENCRYPTION_KEY', + 'TELEGRAM_BOT_TOKEN', + 'PARTY_IDENTITY_KEY', + 'SPOTIFY_CLIENT_ID', + 'PARTY_SPOTIFY_REDIRECT_URI', + 'PARTY_HOST_ALLOWLIST', + 'APPLE_MUSIC_TEAM_ID', + 'APPLE_MUSIC_KEY_ID', + 'APPLE_MUSIC_PRIVATE_KEY', + 'PARTY_TASKS_PROJECT', + 'PARTY_TASKS_LOCATION', + 'PARTY_TASKS_QUEUE', + 'PARTY_TASKS_SERVICE_ACCOUNT', + 'PARTY_TASKS_AUDIENCE', + ]) + required(name); + if (!/^[a-f0-9]{64}$/i.test(required('PARTY_IDENTITY_KEY'))) { + throw new PartyError( + 503, + 'configuration_required', + 'PARTY_IDENTITY_KEY must be a 32-byte hex key.' + ); + } + if ( + required('PARTY_SPOTIFY_REDIRECT_URI') !== + `${partyOrigin()}/api/party/auth/callback` + ) { + throw new PartyError( + 503, + 'configuration_required', + 'Party callback must use its dedicated public origin.' + ); + } +} diff --git a/projects/api/src/party/jobs.ts b/projects/api/src/party/jobs.ts new file mode 100644 index 0000000..e94647b --- /dev/null +++ b/projects/api/src/party/jobs.ts @@ -0,0 +1,499 @@ +import { randomUUID } from 'node:crypto'; +import { CloudTasksClient } from '@google-cloud/tasks'; +import { OAuth2Client } from 'google-auth-library'; +import type { PoolClient } from 'pg'; +import { assertSelectedRecording, SpotifyError } from '@brewtify/spotify'; +import { accessToken, hostFor, spotify } from './auth'; +import { CatalogError, resolveSong } from './catalog'; +import { PartyError, partyOrigin, required } from './config'; +import { + createJob, + getRoom, + requireLive, + validateDevice, + type Room, + type SongRequest, +} from './rooms'; +import { hostLock, rows, transaction } from './store'; + +export interface Job { + id: string; + room_id: string; + request_id: string; + kind: 'resolve' | 'deliver'; + status: 'pending' | 'done'; + generation: number; + failures: number; + due_at: Date; +} + +const oidc = new OAuth2Client(); +export async function verifyInternal( + bearer: string | undefined +): Promise { + if (!bearer?.startsWith('Bearer ')) + throw new PartyError( + 401, + 'internal_auth_required', + 'OIDC authentication required.' + ); + let payload; + try { + const ticket = await oidc.verifyIdToken({ + idToken: bearer.slice(7), + audience: required('PARTY_TASKS_AUDIENCE'), + }); + payload = ticket.getPayload(); + } catch { + throw new PartyError( + 401, + 'internal_auth_invalid', + 'Invalid OIDC identity.' + ); + } + if ( + !payload || + payload.email_verified !== true || + payload.email !== required('PARTY_TASKS_SERVICE_ACCOUNT') + ) { + throw new PartyError( + 403, + 'internal_identity_rejected', + 'This service identity is not authorized.' + ); + } +} + +export async function dispatchOutbox(): Promise { + const tasks = new CloudTasksClient(); + const parent = tasks.queuePath( + required('PARTY_TASKS_PROJECT'), + required('PARTY_TASKS_LOCATION'), + required('PARTY_TASKS_QUEUE') + ); + const jobs = + await rows(`UPDATE party_jobs SET generation=generation+1,dispatched_at=NULL + WHERE id IN (SELECT id FROM party_jobs WHERE status='pending' AND dispatched_at(`SELECT j.* FROM party_jobs j JOIN party_rooms r ON r.id=j.room_id + WHERE j.status='pending' AND j.dispatched_at IS NULL AND r.expires_at>now() AND r.status IN ('open','locked') + ORDER BY j.due_at,j.id LIMIT 25`); + let count = 0; + try { + for (const job of pending) { + try { + await tasks.createTask( + { + parent, + task: { + name: `${parent}/tasks/party-${job.id}-${job.generation}`, + scheduleTime: { + seconds: Math.floor( + Math.max(Date.now(), job.due_at.getTime()) / 1000 + ), + }, + dispatchDeadline: { seconds: 120 }, + httpRequest: { + httpMethod: 'POST', + url: `${partyOrigin()}/internal/party/jobs`, + headers: { 'Content-Type': 'application/json' }, + body: Buffer.from( + JSON.stringify({ jobId: job.id, generation: job.generation }) + ).toString('base64'), + oidcToken: { + serviceAccountEmail: required('PARTY_TASKS_SERVICE_ACCOUNT'), + audience: required('PARTY_TASKS_AUDIENCE'), + }, + }, + }, + }, + { timeout: 5000 } + ); + } catch (error) { + if ( + !error || + typeof error !== 'object' || + !('code' in error) || + error.code !== 6 + ) + throw error; + } + await rows( + 'UPDATE party_jobs SET dispatched_at=now() WHERE id=$1 AND generation=$2 AND status=$3', + [job.id, job.generation, 'pending'] + ); + count++; + } + } finally { + await tasks.close(); + } + return count + jobs.length; +} + +export async function reschedule( + client: PoolClient, + job: Job, + seconds: number, + failure = false +): Promise { + await client.query( + `UPDATE party_jobs SET due_at=now()+($2 * interval '1 second'),dispatched_at=NULL,generation=generation+1, + failures=failures+$3 WHERE id=$1 AND status='pending'`, + [job.id, Math.max(1, seconds), failure ? 1 : 0] + ); +} +async function done(client: PoolClient, job: Job): Promise { + await client.query("UPDATE party_jobs SET status='done' WHERE id=$1", [ + job.id, + ]); +} + +export function deliveryOutcome( + error: unknown +): 'unknown' | 'rate_limited' | 'rejected' { + if (error instanceof SpotifyError) { + if (error.unknownDelivery || (error.status ?? 0) >= 500) return 'unknown'; + if (error.status === 429) return 'rate_limited'; + return 'rejected'; + } + return 'unknown'; +} +function errorCode(error: unknown): string { + return error instanceof PartyError || + error instanceof SpotifyError || + error instanceof CatalogError + ? error.code + : 'provider_unavailable'; +} + +// Obtaining the account lock with a persisted sending record means the previous owner +// never recorded an outcome. It is not evidence that the remote command was not sent. +export async function recoverSending( + client: PoolClient, + room: Room +): Promise { + return transaction(async (tx) => { + const attempts = await rows<{ id: string; request_id: string }>( + "UPDATE party_delivery_attempts SET outcome='unknown',updated_at=now() WHERE room_id=$1 AND outcome='sending' RETURNING *", + [room.id], + tx + ); + if (!attempts.length) return false; + await tx.query( + "UPDATE party_rooms SET blocked_reason='delivery_unknown' WHERE id=$1", + [room.id] + ); + for (const attempt of attempts) { + await tx.query( + "UPDATE party_requests SET status='failed',failure_code='delivery_unknown' WHERE id=$1", + [attempt.request_id] + ); + await tx.query( + "UPDATE party_jobs SET status='done' WHERE request_id=$1 AND kind='deliver'", + [attempt.request_id] + ); + } + return true; + }, client); +} + +async function resolve( + client: PoolClient, + room: Room, + job: Job, + request: SongRequest +): Promise { + if (request.status !== 'pending') return done(client, job); + const token = await accessToken(await hostFor(room.owner, client), client); + const match = await resolveSong(request.source_url, token, spotify()); + await transaction(async (tx) => { + requireLive(await getRoom(room.id, tx)); + await tx.query('DELETE FROM party_match_candidates WHERE request_id=$1', [ + request.id, + ]); + for (const candidate of match.candidates) { + await tx.query( + `INSERT INTO party_match_candidates (request_id,track_id,metadata) VALUES ($1,$2,$3) + ON CONFLICT(request_id,track_id) DO UPDATE SET metadata=EXCLUDED.metadata`, + [request.id, candidate.id, candidate] + ); + } + const auto = + request.failure_code !== 'recording_changed' && + room.mode === 'auto' && + process.env.PARTY_AUTO_ENABLED === 'true' && + (match.confidence === 'exact' || match.confidence === 'high') && + !!match.selected; + const status = + match.confidence === 'no_match' + ? 'unavailable' + : auto + ? 'approved' + : match.selected + ? 'matched' + : 'needs_review'; + await tx.query( + `UPDATE party_requests SET source=$2,selected=$3,confidence=$4,status=$5,failure_code=NULL, + approved_order=CASE WHEN $5='approved' THEN nextval('party_approval_order') ELSE NULL END WHERE id=$1`, + [ + request.id, + match.source, + match.selected ?? null, + match.confidence, + status, + ] + ); + if (auto) await createJob(tx, room.id, request.id, 'deliver'); + await done(tx, job); + }, client); +} + +async function deliver( + client: PoolClient, + room: Room, + job: Job, + request: SongRequest +): Promise { + if (request.status !== 'approved' || !request.selected) + return done(client, job); + if (room.blocked_reason) return reschedule(client, job, 60); + const [first] = await rows<{ id: string }>( + `SELECT id FROM party_requests WHERE room_id=$1 AND status='approved' + ORDER BY CASE WHEN $2='auto' THEN sequence ELSE approved_order END,sequence LIMIT 1`, + [room.id, room.mode], + client + ); + if (first?.id !== request.id) return reschedule(client, job, 5); + const token = await accessToken(await hostFor(room.owner, client), client); + const track = await spotify().track(token, request.selected.id); + assertSelectedRecording(track, request.selected); + await validateDevice(token, room.device_id); + const attempt = randomUUID(); + await transaction(async (tx) => { + requireLive(await getRoom(room.id, tx)); + await tx.query( + 'INSERT INTO party_delivery_attempts (id,room_id,request_id) VALUES ($1,$2,$3)', + [attempt, room.id, request.id] + ); + }, client); + // Nothing before this commit can have sent a queue command. Nothing after it is + // automatically retryable unless Spotify explicitly rejects the command. + try { + await spotify().enqueue(token, request.selected.id, room.device_id); + } catch (error) { + const outcome = deliveryOutcome(error); + await transaction(async (tx) => { + const [recorded] = await rows( + `UPDATE party_delivery_attempts SET outcome=$2,updated_at=now() + WHERE id=$1 AND outcome='sending' RETURNING id`, + [attempt, outcome === 'unknown' ? 'unknown' : 'rejected'], + tx + ); + if (!recorded) return; + if (outcome === 'rate_limited') { + await tx.query( + "UPDATE party_requests SET failure_code='rate_limited' WHERE id=$1", + [request.id] + ); + await reschedule( + tx, + job, + error instanceof SpotifyError ? (error.retryAfterSeconds ?? 60) : 60 + ); + } else { + const code = + outcome === 'unknown' ? 'delivery_unknown' : errorCode(error); + await tx.query( + "UPDATE party_requests SET status='failed',failure_code=$2 WHERE id=$1", + [request.id, code] + ); + if ( + outcome === 'unknown' || + (error instanceof SpotifyError && + [401, 403, 404].includes(error.status ?? 0)) + ) { + await tx.query( + 'UPDATE party_rooms SET blocked_reason=$2 WHERE id=$1', + [room.id, code] + ); + } + await done(tx, job); + } + }, client); + return; + } + await transaction(async (tx) => { + const [recorded] = await rows( + "UPDATE party_delivery_attempts SET outcome='accepted',updated_at=now() WHERE id=$1 AND outcome='sending' RETURNING id", + [attempt], + tx + ); + if (!recorded) return; + await tx.query( + "UPDATE party_requests SET status='added',failure_code=NULL WHERE id=$1", + [request.id] + ); + await done(tx, job); + }, client); +} + +export async function runJob(id: string, generation: number): Promise { + const [original] = await rows('SELECT * FROM party_jobs WHERE id=$1', [ + id, + ]); + if ( + !original || + original.status === 'done' || + original.generation !== generation + ) + return; + const [room] = await rows('SELECT * FROM party_rooms WHERE id=$1', [ + original.room_id, + ]); + if (!room) return; + await hostLock(room.account_key, async (client) => { + await recoverSending(client, room); + const [job] = await rows( + 'SELECT * FROM party_jobs WHERE id=$1', + [id], + client + ); + if (!job || job.status === 'done' || job.generation !== generation) return; + if (job.due_at > new Date()) + throw new PartyError(429, 'task_not_due', 'Task is not due.', 5); + const [request] = await rows( + 'SELECT * FROM party_requests WHERE id=$1', + [job.request_id], + client + ); + if (!request) return done(client, job); + try { + const fresh = await getRoom(room.id, client); + requireLive(fresh); + if (job.kind === 'resolve') await resolve(client, fresh, job, request); + else await deliver(client, fresh, job, request); + } catch (error) { + // DB failure after sending must leave the durable marker for reconciliation. + const [sending] = await rows( + "SELECT id FROM party_delivery_attempts WHERE room_id=$1 AND outcome='sending'", + [room.id], + client + ); + if (sending) throw error; + const code = errorCode(error); + if (error instanceof PartyError && error.status === 410) + return done(client, job); + await transaction(async (tx) => { + if ( + (error instanceof SpotifyError || error instanceof CatalogError) && + error.status === 429 + ) { + await tx.query( + "UPDATE party_requests SET failure_code='rate_limited' WHERE id=$1", + [request.id] + ); + await reschedule(tx, job, error.retryAfterSeconds ?? 60); + } else if (code === 'recording_changed') { + await tx.query( + "UPDATE party_requests SET status='failed',failure_code=$2 WHERE id=$1", + [request.id, code] + ); + await done(tx, job); + } else if (code === 'track_unavailable') { + await tx.query( + "UPDATE party_requests SET status='unavailable',failure_code=$2 WHERE id=$1", + [request.id, code] + ); + await done(tx, job); + } else if ( + (error instanceof PartyError && + ['device_confirmation_required', 'host_reconnect'].includes( + code + )) || + (error instanceof SpotifyError && + [401, 403].includes(error.status ?? 0)) + ) { + await tx.query( + 'UPDATE party_rooms SET blocked_reason=$2 WHERE id=$1', + [room.id, code] + ); + await tx.query( + 'UPDATE party_requests SET failure_code=$2 WHERE id=$1', + [request.id, code] + ); + await reschedule(tx, job, 60); + } else if ( + job.failures < 4 && + (!(error instanceof PartyError) || error.status >= 500) + ) { + await reschedule( + tx, + job, + Math.min(120, 5 * 2 ** job.failures) + + Math.floor(Math.random() * 5), + true + ); + } else { + await tx.query( + "UPDATE party_requests SET status='failed',failure_code=$2 WHERE id=$1", + [request.id, code] + ); + await done(tx, job); + } + }, client); + } + }); +} + +export async function cleanup(): Promise { + const expired = await rows( + 'SELECT * FROM party_rooms WHERE expires_at<=now() ORDER BY expires_at LIMIT 100' + ); + let count = 0; + for (const room of expired) { + try { + await hostLock(room.account_key, (client) => + transaction(async (tx) => { + await tx.query( + 'DELETE FROM party_host_sessions WHERE account_key=$1 AND expires_at<=now()', + [room.account_key] + ); + await tx.query( + 'DELETE FROM party_rooms WHERE id=$1 AND expires_at<=now()', + [room.id] + ); + }, client) + ); + count++; + } catch (error) { + if (!(error instanceof PartyError && error.code === 'host_busy')) + throw error; + } + } + for (const table of [ + 'party_authorizations', + 'party_mini_app_sessions', + 'party_throttles', + 'party_host_sessions', + ]) { + const key = table === 'party_throttles' ? 'key' : 'id'; + await rows( + `DELETE FROM ${table} WHERE ${key} IN (SELECT ${key} FROM ${table} WHERE expires_at<=now() LIMIT 500)` + ); + } + const abandoned = + await rows(`SELECT DISTINCT r.* FROM party_rooms r JOIN party_delivery_attempts a ON a.room_id=r.id + WHERE a.outcome='sending' AND a.created_at + recoverSending(client, room) + ); + } catch (error) { + if (!(error instanceof PartyError && error.code === 'host_busy')) + throw error; + } + } + return count; +} diff --git a/projects/api/src/party/rooms.ts b/projects/api/src/party/rooms.ts new file mode 100644 index 0000000..01fec9b --- /dev/null +++ b/projects/api/src/party/rooms.ts @@ -0,0 +1,633 @@ +import { randomUUID } from 'node:crypto'; +import type { PoolClient } from 'pg'; +import type { + PartyCandidate, + PartyConfidence, + PartyFeed, + PartyMode, + PartyRequestDto, + PartyRequestStatus, + PartyRoomDto, + PartyRoomStatus, + PartyTrack, +} from '@brewtify/shared'; +import { decrypt, encrypt, generateSalt } from '../services/encryption'; +import { accessToken, hostFor, spotify } from './auth'; +import { PartyError, telegramUrl } from './config'; +import { parseSongLink } from './catalog'; +import { hash, identity, secret, throttle, type MiniSession } from './security'; +import { hostLock, rows, transaction } from './store'; + +export interface Room { + id: string; + owner: string; + account_key: string; + join_hash: string; + encrypted_join: string; + salt: string; + device_id: string; + status: PartyRoomStatus; + mode: PartyMode; + blocked_reason: string | null; + expires_at: Date; +} +export interface SongRequest { + id: string; + room_id: string; + participant: string; + submission_key: string; + display_name: string; + source_url: string; + source: PartyTrack | null; + selected: PartyCandidate | null; + confidence: PartyConfidence | null; + status: PartyRequestStatus; + failure_code: string | null; + created_at: Date; + updated_at: Date; + sequence: string; + revision: string; +} + +export function roomDto(room: Room, who: MiniSession): PartyRoomDto { + return { + id: room.id, + status: room.expires_at <= new Date() ? 'expired' : room.status, + mode: room.mode, + expiresAt: room.expires_at.toISOString(), + deviceId: room.owner === who.principal ? room.device_id : '', + blockedReason: room.blocked_reason, + isHost: room.owner === who.principal, + }; +} +export async function getRoom(id: string, client?: PoolClient): Promise { + const [room] = await rows( + 'SELECT * FROM party_rooms WHERE id=$1', + [id], + client + ); + if (!room || room.expires_at <= new Date()) + throw new PartyError(410, 'room_expired', 'This party has expired.'); + return room; +} +export function requireOwner(room: Room, who: MiniSession): void { + if (room.owner !== who.principal) + throw new PartyError( + 403, + 'host_required', + 'Only this party host can do that.' + ); +} +export async function requireMember( + room: Room, + who: MiniSession, + client?: PoolClient +): Promise { + const participant = identity(`room:${room.id}:${who.principal}`); + if (room.owner === who.principal) return participant; + const [membership] = await rows( + 'SELECT participant FROM party_memberships WHERE room_id=$1 AND session_id=$2', + [room.id, who.id], + client + ); + if (!membership) + throw new PartyError( + 403, + 'invitation_required', + 'Join this party using its invitation.' + ); + return participant; +} +export function requireLive(room: Room): void { + if ( + room.status === 'closed' || + room.status === 'expired' || + room.expires_at <= new Date() + ) { + throw new PartyError( + 410, + 'room_closed', + 'This party is closed. Already queued songs remain in Spotify.' + ); + } +} +export async function currentRoom(who: MiniSession): Promise { + const [room] = await rows( + `SELECT r.* FROM party_rooms r WHERE r.expires_at>now() + AND (r.owner=$1 OR EXISTS (SELECT 1 FROM party_memberships m WHERE m.room_id=r.id AND m.session_id=$2)) + ORDER BY (r.owner=$1 AND r.status IN ('open','locked')) DESC,r.created_at DESC LIMIT 1`, + [who.principal, who.id] + ); + return room; +} +export async function invite(room: Room, who: MiniSession): Promise { + requireOwner(room, who); + requireLive(room); + return telegramUrl(`p_${decrypt(room.encrypted_join, room.salt)}`); +} +export async function devices(who: MiniSession) { + const host = await hostFor(who.principal); + return hostLock(host.account_key, async (client) => { + const fresh = await hostFor(who.principal, client); + return spotify().devices(await accessToken(fresh, client)); + }); +} +export async function validateDevice( + token: string, + deviceId: string +): Promise { + const available = await spotify().devices(token); + const selected = available.find((device) => device.id === deviceId); + if ( + !selected || + !selected.isActive || + selected.isRestricted || + available.some((device) => device.isActive && device.id !== deviceId) + ) { + throw new PartyError( + 409, + 'device_confirmation_required', + 'Open Spotify, start playback on your speaker, and confirm its active unrestricted device.' + ); + } +} +export async function createRoom( + who: MiniSession, + deviceId: string +): Promise<{ room: PartyRoomDto; inviteUrl: string }> { + const host = await hostFor(who.principal); + return hostLock(host.account_key, async (client) => { + const fresh = await hostFor(who.principal, client); + await validateDevice(await accessToken(fresh, client), deviceId); + return transaction(async (tx) => { + await tx.query( + 'DELETE FROM party_rooms WHERE account_key=$1 AND expires_at<=now()', + [host.account_key] + ); + const [existing] = await rows( + "SELECT * FROM party_rooms WHERE account_key=$1 AND status IN ('open','locked')", + [host.account_key], + tx + ); + if (existing) + throw new PartyError( + 409, + 'room_already_exists', + 'This Spotify host already has an active party.' + ); + const join = secret(); + const salt = generateSalt(); + const [room] = await rows( + `INSERT INTO party_rooms (id,owner,account_key,join_hash,encrypted_join,salt,device_id,expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,now()+interval '12 hours') RETURNING *`, + [ + randomUUID(), + who.principal, + host.account_key, + hash(join), + encrypt(join, salt), + salt, + deviceId, + ], + tx + ); + await tx.query( + 'UPDATE party_host_sessions SET expires_at=$2 WHERE id=$1', + [fresh.id, room.expires_at] + ); + return { room: roomDto(room, who), inviteUrl: telegramUrl(`p_${join}`) }; + }, client); + }); +} +export async function joinRoom( + who: MiniSession, + join: string +): Promise { + if (!/^[\w-]{43}$/.test(join)) + throw new PartyError( + 400, + 'invalid_invitation', + 'Paste a valid Party invitation.' + ); + const [room] = await rows( + 'SELECT * FROM party_rooms WHERE join_hash=$1 AND expires_at>now()', + [hash(join)] + ); + if (!room) + throw new PartyError( + 410, + 'room_expired', + 'Invitation is invalid or expired.' + ); + requireLive(room); + await rows( + `INSERT INTO party_memberships (room_id,session_id,participant) VALUES ($1,$2,$3) + ON CONFLICT DO NOTHING`, + [room.id, who.id, identity(`room:${room.id}:${who.principal}`)] + ); + return roomDto(room, who); +} + +export async function createJob( + client: PoolClient, + roomId: string, + requestId: string, + kind: 'resolve' | 'deliver' +): Promise { + await client.query( + `INSERT INTO party_jobs (id,room_id,request_id,kind) VALUES ($1,$2,$3,$4) + ON CONFLICT(request_id,kind) DO UPDATE SET status='pending',generation=party_jobs.generation+1,due_at=now(),dispatched_at=NULL,failures=0`, + [randomUUID(), roomId, requestId, kind] + ); +} +export async function submit( + who: MiniSession, + roomId: string, + displayName: string, + url: string, + submissionKey: string +): Promise<{ id: string }> { + const name = displayName.trim(); + if (!name || name.length > 40 || /[\u0000-\u001f\u007f]/.test(name)) + throw new PartyError( + 400, + 'invalid_name', + 'Use a display name of 1 to 40 characters.' + ); + if (!/^[a-zA-Z0-9_-]{16,80}$/.test(submissionKey)) + throw new PartyError( + 400, + 'invalid_submission_key', + 'A unique submission key is required.' + ); + const parsed = parseSongLink(url); + const room = await getRoom(roomId); + const participant = await requireMember(room, who); + await throttle(`submit:${room.id}:${participant}`, 8, 60); + await throttle(`submit-room:${room.id}`, 80, 60); + return hostLock(room.account_key, (client) => + transaction(async (tx) => { + const fresh = await getRoom(roomId, tx); + requireLive(fresh); + const [existing] = await rows( + 'SELECT * FROM party_requests WHERE room_id=$1 AND participant=$2 AND submission_key=$3', + [roomId, participant, submissionKey], + tx + ); + if (existing) { + if ( + existing.source_url !== parsed.url || + existing.display_name !== name + ) + throw new PartyError( + 409, + 'submission_conflict', + 'This submission key was already used.' + ); + return { id: existing.id }; + } + if (fresh.status !== 'open') + throw new PartyError( + 409, + 'room_locked', + 'The host has paused new requests.' + ); + const id = randomUUID(); + await tx.query( + `INSERT INTO party_requests (id,room_id,participant,submission_key,display_name,source_url) + VALUES ($1,$2,$3,$4,$5,$6)`, + [id, roomId, participant, submissionKey, name, parsed.url] + ); + await createJob(tx, roomId, id, 'resolve'); + return { id }; + }, client) + ); +} + +export async function feed( + who: MiniSession, + roomId: string, + cursor: string +): Promise { + const room = await getRoom(roomId); + const participant = await requireMember(room, who); + if (!/^\d{1,18}$/.test(cursor)) + throw new PartyError(400, 'invalid_cursor', 'Invalid request cursor.'); + const requests = await rows( + `SELECT * FROM party_requests WHERE room_id=$1 AND revision>$2 + AND ($3::boolean OR participant=$4) ORDER BY revision LIMIT 50`, + [roomId, cursor, room.owner === who.principal, participant] + ); + const candidates = requests.length + ? await rows<{ request_id: string; metadata: PartyCandidate }>( + 'SELECT request_id,metadata FROM party_match_candidates WHERE request_id=ANY($1::text[])', + [requests.map((request) => request.id)] + ) + : []; + const dto: PartyRequestDto[] = requests.map((request) => ({ + id: request.id, + displayName: request.display_name, + sourceUrl: request.source_url, + source: request.source ?? undefined, + selected: request.selected ?? undefined, + candidates: + room.owner === who.principal + ? candidates + .filter((candidate) => candidate.request_id === request.id) + .map((candidate) => candidate.metadata) + : [], + confidence: request.confidence, + status: request.status, + failureCode: request.failure_code, + createdAt: request.created_at.toISOString(), + updatedAt: request.updated_at.toISOString(), + })); + return { + room: roomDto(room, who), + requests: dto, + nextCursor: requests.at(-1)?.revision ?? cursor, + }; +} + +export async function closeRoom(client: PoolClient, room: Room): Promise { + await client.query( + "UPDATE party_rooms SET status='closed',encrypted_join='',blocked_reason=NULL WHERE id=$1", + [room.id] + ); + await client.query( + "UPDATE party_requests SET status='rejected',failure_code='room_closed' WHERE room_id=$1 AND status IN ('pending','matched','needs_review','approved')", + [room.id] + ); + await client.query("UPDATE party_jobs SET status='done' WHERE room_id=$1", [ + room.id, + ]); + await client.query( + 'DELETE FROM party_host_sessions WHERE account_key=$1 AND principal=$2', + [room.account_key, room.owner] + ); + await client.query( + "UPDATE party_authorizations SET status='failed',error='disconnected',encrypted_verifier='' WHERE principal=$1", + [room.owner] + ); +} +export async function disconnect(who: MiniSession): Promise { + await hostLock(`principal:${who.principal}`, async () => { + const [host] = await rows<{ account_key: string }>( + 'SELECT account_key FROM party_host_sessions WHERE principal=$1', + [who.principal] + ); + const [room] = await rows( + "SELECT * FROM party_rooms WHERE owner=$1 AND status IN ('open','locked')", + [who.principal] + ); + await hostLock( + host?.account_key ?? room?.account_key ?? who.principal, + (client) => + transaction(async (tx) => { + if (room) await closeRoom(tx, room); + await tx.query('DELETE FROM party_host_sessions WHERE principal=$1', [ + who.principal, + ]); + await tx.query( + "UPDATE party_authorizations SET status='failed',error='disconnected',encrypted_verifier='' WHERE principal=$1", + [who.principal] + ); + }, client) + ); + }); +} + +export async function roomAction( + who: MiniSession, + id: string, + action: string, + options: { deviceId?: string; mode?: string } +): Promise { + const room = await getRoom(id); + requireOwner(room, who); + await hostLock(room.account_key, async (client) => { + const fresh = await getRoom(id, client); + requireLive(fresh); + if (action === 'device') { + if (!options.deviceId) + throw new PartyError( + 400, + 'device_required', + 'Select an active device.' + ); + await validateDevice( + await accessToken(await hostFor(who.principal, client), client), + options.deviceId + ); + } + await transaction(async (tx) => { + if (action === 'close') return closeRoom(tx, fresh); + if (action === 'lock' || action === 'unlock') { + await tx.query('UPDATE party_rooms SET status=$2 WHERE id=$1', [ + id, + action === 'lock' ? 'locked' : 'open', + ]); + } else if (action === 'mode') { + if (options.mode !== 'host_approval' && options.mode !== 'auto') + throw new PartyError( + 400, + 'invalid_mode', + 'Choose approval or auto-add.' + ); + if ( + options.mode === 'auto' && + process.env.PARTY_AUTO_ENABLED !== 'true' + ) + throw new PartyError( + 403, + 'auto_disabled', + 'Auto-add is not enabled for this pilot.' + ); + await tx.query('UPDATE party_rooms SET mode=$2 WHERE id=$1', [ + id, + options.mode, + ]); + } else if (action === 'device') { + if (fresh.blocked_reason === 'delivery_unknown') + throw new PartyError( + 409, + 'delivery_unknown', + 'Acknowledge the uncertain queue command first.' + ); + await tx.query( + 'UPDATE party_rooms SET device_id=$2,blocked_reason=NULL WHERE id=$1', + [id, options.deviceId] + ); + } else if (action === 'acknowledge_unknown') { + const [recent] = await rows( + `SELECT id FROM party_delivery_attempts WHERE room_id=$1 AND outcome IN ('sending','unknown') + AND created_at>now()-interval '2 minutes' LIMIT 1`, + [id], + tx + ); + if (recent) + throw new PartyError( + 409, + 'delivery_settling', + 'Wait two minutes for the in-flight command to settle before acknowledging uncertainty.' + ); + await tx.query( + "UPDATE party_rooms SET blocked_reason=NULL WHERE id=$1 AND blocked_reason='delivery_unknown'", + [id] + ); + } else + throw new PartyError(400, 'invalid_action', 'Unknown room action.'); + await tx.query( + "UPDATE party_jobs SET due_at=now(),dispatched_at=NULL,generation=generation+1 WHERE room_id=$1 AND kind='deliver' AND status='pending'", + [id] + ); + }, client); + }); +} + +export async function requestAction( + who: MiniSession, + roomId: string, + requestId: string, + action: string, + options: { candidateId?: string; confirmDuplicateRisk?: boolean } +): Promise { + const room = await getRoom(roomId); + requireOwner(room, who); + await hostLock(room.account_key, (client) => + transaction(async (tx) => { + const fresh = await getRoom(roomId, tx); + requireLive(fresh); + const [request] = await rows( + 'SELECT * FROM party_requests WHERE id=$1 AND room_id=$2 FOR UPDATE', + [requestId, roomId], + tx + ); + if (!request) + throw new PartyError(404, 'request_not_found', 'Request not found.'); + if (action === 'reject') { + if (['added', 'rejected'].includes(request.status)) + throw new PartyError( + 409, + 'invalid_transition', + 'This request cannot be removed from Spotify.' + ); + const [sending] = await rows( + "SELECT id FROM party_delivery_attempts WHERE request_id=$1 AND outcome='sending'", + [requestId], + tx + ); + if (sending) + throw new PartyError( + 409, + 'delivery_unknown', + 'Wait for the uncertain delivery to be reconciled.' + ); + await tx.query( + "UPDATE party_requests SET status='rejected' WHERE id=$1", + [requestId] + ); + await tx.query( + "UPDATE party_jobs SET status='done' WHERE request_id=$1", + [requestId] + ); + return; + } + if (action === 'select') { + if (!['needs_review', 'matched'].includes(request.status)) + throw new PartyError( + 409, + 'invalid_transition', + 'A version cannot be selected for this request.' + ); + const [candidate] = await rows<{ metadata: PartyCandidate }>( + 'SELECT metadata FROM party_match_candidates WHERE request_id=$1 AND track_id=$2', + [requestId, options.candidateId ?? ''], + tx + ); + if (!candidate) + throw new PartyError( + 400, + 'invalid_candidate', + 'Choose one of the proposed Spotify versions.' + ); + await tx.query( + "UPDATE party_requests SET selected=$2,status='matched' WHERE id=$1", + [requestId, candidate.metadata] + ); + return; + } + if (action === 'retry') { + if (request.status !== 'failed') + throw new PartyError( + 409, + 'invalid_transition', + 'Only failed requests can be retried.' + ); + if ( + request.failure_code === 'delivery_unknown' && + options.confirmDuplicateRisk !== true + ) { + throw new PartyError( + 409, + 'duplicate_risk_confirmation_required', + 'Spotify may already have queued this song. Explicitly confirm the duplicate risk.' + ); + } + if (request.failure_code === 'delivery_unknown') { + const [recent] = await rows( + `SELECT id FROM party_delivery_attempts WHERE request_id=$1 AND outcome='unknown' + AND created_at>now()-interval '2 minutes' LIMIT 1`, + [requestId], + tx + ); + if (recent) + throw new PartyError( + 409, + 'delivery_settling', + 'Wait two minutes before intentionally retrying an uncertain queue command.' + ); + } + const [sending] = await rows( + "SELECT id FROM party_delivery_attempts WHERE room_id=$1 AND outcome='sending'", + [roomId], + tx + ); + if (sending) + throw new PartyError( + 409, + 'delivery_unknown', + 'Wait for in-flight recovery before retrying.' + ); + if (!request.selected || request.failure_code === 'recording_changed') { + await tx.query( + "UPDATE party_requests SET status='pending',selected=NULL,failure_code=$2 WHERE id=$1", + [ + requestId, + request.failure_code === 'recording_changed' + ? 'recording_changed' + : null, + ] + ); + await createJob(tx, roomId, requestId, 'resolve'); + return; + } + } else if ( + action !== 'approve' || + request.status !== 'matched' || + !request.selected + ) { + throw new PartyError( + 409, + 'invalid_transition', + 'Choose a Spotify version before approving this request.' + ); + } + await tx.query( + "UPDATE party_requests SET status='approved',failure_code=NULL,approved_order=nextval('party_approval_order') WHERE id=$1", + [requestId] + ); + await createJob(tx, roomId, requestId, 'deliver'); + }, client) + ); +} diff --git a/projects/api/src/party/routes.ts b/projects/api/src/party/routes.ts new file mode 100644 index 0000000..285045c --- /dev/null +++ b/projects/api/src/party/routes.ts @@ -0,0 +1,306 @@ +import express, { + Router, + type ErrorRequestHandler, + type Request, +} from 'express'; +import { SpotifyError } from '@brewtify/spotify'; +import { createLogger } from '../utils/logger'; +import { CatalogError } from './catalog'; +import { + authorizationStatus, + bootstrap, + finishAuthorization, + launchAuthorization, + startAuthorization, +} from './auth'; +import { + checkPrerequisites, + partyEnabled, + PartyError, + telegramUrl, +} from './config'; +import { cleanup, dispatchOutbox, runJob, verifyInternal } from './jobs'; +import { + createRoom, + currentRoom, + devices, + disconnect, + feed, + getRoom, + invite, + joinRoom, + requestAction, + roomAction, + roomDto, + submit, +} from './rooms'; +import { + checkCsrf, + checkOrigin, + session, + throttle, + type MiniSession, +} from './security'; +import { rows } from './store'; + +const log = createLogger('party'); +function field(req: Request, key: string, max = 2048): string { + const value: unknown = req.body?.[key]; + if (typeof value !== 'string' || !value || value.length > max) + throw new PartyError(400, 'invalid_input', `Invalid ${key}.`); + return value; +} +function param(req: Request, key: string): string { + const value = req.params[key]; + if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(value)) + throw new PartyError(400, 'invalid_input', 'Invalid resource identifier.'); + return value; +} +async function summary(who: MiniSession) { + const room = await currentRoom(who); + const [host] = await rows( + 'SELECT id FROM party_host_sessions WHERE principal=$1 AND expires_at>now()', + [who.principal] + ); + return { + csrfToken: who.csrf, + hostConnected: !!host, + room: room ? roomDto(room, who) : null, + }; +} + +export const partyErrorHandler: ErrorRequestHandler = ( + error: unknown, + _req, + res, + _next +) => { + if ( + error && + typeof error === 'object' && + 'type' in error && + ['entity.too.large', 'entity.parse.failed'].includes(String(error.type)) + ) { + const large = error.type === 'entity.too.large'; + res + .status(large ? 413 : 400) + .json({ + error: { + code: large ? 'body_too_large' : 'invalid_json', + message: 'Send a small valid JSON request.', + }, + }); + return; + } + const known = + error instanceof PartyError || + error instanceof SpotifyError || + error instanceof CatalogError; + const status = + error instanceof CatalogError + ? error.code === 'invalid_song_link' + ? 400 + : error.code === 'apple_rate_limited' + ? 429 + : 503 + : known && + error.status !== undefined && + error.status >= 400 && + error.status < 600 + ? error.status + : 503; + const code = known ? error.code : 'party_unavailable'; + const retryAfter = + error instanceof PartyError + ? error.retryAfter + : error instanceof SpotifyError || error instanceof CatalogError + ? error.retryAfterSeconds + : undefined; + if (retryAfter) res.setHeader('Retry-After', String(retryAfter)); + const storageCode = + !known && + error && + typeof error === 'object' && + 'code' in error && + /^[A-Z0-9]{5}$/.test(String(error.code)) + ? String(error.code) + : undefined; + log.warn('Party operation failed', { code, status, storageCode }); + res + .status(status) + .json({ + error: { + code, + message: known + ? error.message + : 'Party is temporarily unavailable. Please try again.', + }, + }); +}; + +export const partyRoutes = Router(); +partyRoutes.use((_req, res, next) => { + res.set({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + }); + next(); +}); +partyRoutes.get('/config', (_req, res) => { + res.json({ + enabled: partyEnabled(), + telegramUrl: + partyEnabled() && process.env.PARTY_TELEGRAM_BOT_USERNAME + ? telegramUrl() + : null, + autoEnabled: partyEnabled() && process.env.PARTY_AUTO_ENABLED === 'true', + }); +}); +partyRoutes.use((_req, _res, next) => { + if (!partyEnabled()) + throw new PartyError(404, 'party_disabled', 'Party is not enabled.'); + checkPrerequisites(); + next(); +}); +partyRoutes.use(express.json({ limit: '12kb' })); +partyRoutes.get('/auth/launch', launchAuthorization); +partyRoutes.get('/auth/callback', finishAuthorization); +partyRoutes.post('/session', async (req, res) => { + checkOrigin(req); + // The default socket address is deliberately used, never an untrusted forwarded header. + await throttle( + `bootstrap-ip:${req.socket.remoteAddress ?? 'unknown'}`, + 1200, + 60 + ); + res.json(await summary(await bootstrap(req, res))); +}); +partyRoutes.use(async (req, res, next) => { + const who = await session(req); + if (req.method !== 'GET') checkCsrf(req, who); + await throttle( + `request-ip:${req.socket.remoteAddress ?? 'unknown'}`, + 1200, + 60 + ); + await throttle(`session:${who.id}`, 180, 60); + res.locals.partySession = who; + next(); +}); +function who(res: express.Response): MiniSession { + return res.locals.partySession; +} +partyRoutes.get('/session', async (_req, res) => { + res.json(await summary(who(res))); +}); +partyRoutes.post('/auth/start', async (req, res) => { + if (req.body?.premiumConfirmed !== true) + throw new PartyError( + 400, + 'premium_confirmation_required', + 'A Spotify Premium host is required. No test song will be queued.' + ); + await throttle(`auth:${who(res).principal}`, 5, 300); + res.json({ authorizationUrl: await startAuthorization(who(res)) }); +}); +partyRoutes.get('/auth/status', async (_req, res) => { + res.json(await authorizationStatus(who(res))); +}); +partyRoutes.post('/disconnect', async (_req, res) => { + await disconnect(who(res)); + res.json({ ok: true }); +}); +partyRoutes.get('/devices', async (_req, res) => { + res.json({ devices: await devices(who(res)) }); +}); +partyRoutes.post('/rooms', async (req, res) => { + if (req.body?.mode && req.body.mode !== 'host_approval') + throw new PartyError( + 400, + 'approval_default', + 'Start in host-approval mode.' + ); + res.status(201).json(await createRoom(who(res), field(req, 'deviceId', 256))); +}); +partyRoutes.post('/join', async (req, res) => { + await throttle(`join:${who(res).principal}`, 12, 60); + res.json({ room: await joinRoom(who(res), field(req, 'secret', 64)) }); +}); +partyRoutes.get('/rooms/:id/invite', async (req, res) => { + res.json({ + inviteUrl: await invite(await getRoom(param(req, 'id')), who(res)), + }); +}); +partyRoutes.get('/rooms/:id/requests', async (req, res) => { + res.json( + await feed( + who(res), + param(req, 'id'), + typeof req.query.cursor === 'string' ? req.query.cursor : '0' + ) + ); +}); +partyRoutes.post('/rooms/:id/requests', async (req, res) => { + res + .status(202) + .json( + await submit( + who(res), + param(req, 'id'), + field(req, 'displayName', 40), + field(req, 'url'), + field(req, 'submissionKey', 80) + ) + ); +}); +partyRoutes.post('/rooms/:id/action', async (req, res) => { + await roomAction(who(res), param(req, 'id'), field(req, 'action', 32), { + deviceId: + typeof req.body?.deviceId === 'string' + ? field(req, 'deviceId', 256) + : undefined, + mode: + typeof req.body?.mode === 'string' ? field(req, 'mode', 32) : undefined, + }); + res.json({ ok: true }); +}); +partyRoutes.post('/rooms/:id/requests/:requestId/action', async (req, res) => { + await requestAction( + who(res), + param(req, 'id'), + param(req, 'requestId'), + field(req, 'action', 32), + { + candidateId: + typeof req.body?.candidateId === 'string' + ? field(req, 'candidateId', 64) + : undefined, + confirmDuplicateRisk: req.body?.confirmDuplicateRisk === true, + } + ); + res.json({ ok: true }); +}); +partyRoutes.use(partyErrorHandler); + +export const internalPartyRoutes = Router(); +internalPartyRoutes.use(express.json({ limit: '2kb' })); +internalPartyRoutes.use(async (req, _res, next) => { + await verifyInternal(req.get('Authorization')); + next(); +}); +internalPartyRoutes.post('/jobs', async (req, res) => { + if (!partyEnabled()) + throw new PartyError(503, 'party_disabled', 'Party processing is paused.'); + checkPrerequisites(); + if (!Number.isSafeInteger(req.body?.generation) || req.body.generation < 0) + throw new PartyError(400, 'invalid_job', 'Invalid task generation.'); + await runJob(field(req, 'jobId', 80), req.body.generation); + res.status(204).end(); +}); +internalPartyRoutes.post('/maintenance', async (_req, res) => { + const deleted = await cleanup(); + const dispatched = partyEnabled() ? await dispatchOutbox() : 0; + res.json({ deleted, dispatched }); +}); +internalPartyRoutes.use(partyErrorHandler); diff --git a/projects/api/src/party/security.test.ts b/projects/api/src/party/security.test.ts new file mode 100644 index 0000000..225f02d --- /dev/null +++ b/projects/api/src/party/security.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { constantEqual, verifyTelegram } from './security'; + +const token = '123456:test-token'; +const now = 1_800_000_000_000; +function sign(fields: Record): string { + const data = Object.entries(fields) + .sort(([a], [b]) => a.localeCompare(b, 'en')) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + const key = createHmac('sha256', 'WebAppData').update(token).digest(); + return new URLSearchParams({ + ...fields, + hash: createHmac('sha256', key).update(data).digest('hex'), + }).toString(); +} +test('Telegram signature binds user, launch destination, bot and timestamp', () => { + const fields = { + user: JSON.stringify({ id: 12345, first_name: 'Guest' }), + auth_date: String(now / 1000), + start_param: 'p_invitation', + }; + const signed = sign(fields); + assert.equal(verifyTelegram(signed, token, now).id, '12345'); + assert.throws(() => + verifyTelegram(signed.replace('12345', '12346'), token, now) + ); + assert.throws(() => + verifyTelegram(signed.replace('p_invitation', 'p_other'), token, now) + ); + assert.throws(() => verifyTelegram(signed, 'other-bot', now)); + assert.throws(() => + verifyTelegram(`${signed}&auth_date=${now / 1000}`, token, now) + ); + assert.throws(() => + verifyTelegram( + sign({ ...fields, auth_date: String(now / 1000 - 301) }), + token, + now + ) + ); + assert.throws(() => + verifyTelegram( + sign({ ...fields, auth_date: String(now / 1000 + 31) }), + token, + now + ) + ); + assert.throws(() => + verifyTelegram(sign({ ...fields, user: '{"id":"12345"}' }), token, now) + ); + assert.throws(() => verifyTelegram('', token, now)); +}); +test('constant equality rejects changed lengths and content', () => { + assert.equal(constantEqual('abc', 'abc'), true); + assert.equal(constantEqual('abc', 'abcd'), false); + assert.equal(constantEqual('abc', 'abd'), false); +}); diff --git a/projects/api/src/party/security.ts b/projects/api/src/party/security.ts new file mode 100644 index 0000000..9fbbe0b --- /dev/null +++ b/projects/api/src/party/security.ts @@ -0,0 +1,190 @@ +import { + createHash, + createHmac, + randomBytes, + timingSafeEqual, +} from 'node:crypto'; +import type { Request } from 'express'; +import type { PoolClient } from 'pg'; +import { PartyError, partyOrigin, required } from './config'; +import { rows } from './store'; + +export const secret = () => randomBytes(32).toString('base64url'); +export const hash = (value: string) => + createHash('sha256').update(value).digest('hex'); +export function identity(value: string): string { + return createHmac( + 'sha256', + Buffer.from(required('PARTY_IDENTITY_KEY'), 'hex') + ) + .update(value) + .digest('hex'); +} +export function constantEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +export function verifyTelegram( + initData: string, + botToken: string, + now = Date.now() +): { id: string; authDate: number; launchHash: string } { + if (!initData || initData.length > 8192) + throw new PartyError( + 401, + 'telegram_required', + 'Open Party inside Telegram.' + ); + const params = new URLSearchParams(initData); + const seen = new Set(); + for (const [key] of params) { + if (seen.has(key)) + throw new PartyError( + 401, + 'invalid_telegram', + 'Invalid Telegram launch data.' + ); + seen.add(key); + } + const supplied = params.get('hash') ?? ''; + if (!/^[a-f0-9]{64}$/i.test(supplied)) + throw new PartyError( + 401, + 'invalid_telegram', + 'Invalid Telegram signature.' + ); + params.delete('hash'); + const check = [...params] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + const key = createHmac('sha256', 'WebAppData').update(botToken).digest(); + const expected = createHmac('sha256', key).update(check).digest('hex'); + if (!constantEqual(expected, supplied.toLowerCase())) + throw new PartyError( + 401, + 'invalid_telegram', + 'Invalid Telegram signature.' + ); + const date = params.get('auth_date') ?? ''; + const authDate = Number(date); + if ( + !/^\d+$/.test(date) || + !Number.isSafeInteger(authDate) || + authDate > now / 1000 + 30 || + authDate < now / 1000 - 300 + ) { + throw new PartyError( + 401, + 'telegram_expired', + 'Reopen the Mini App from Telegram to renew your session.' + ); + } + let user: unknown; + try { + user = JSON.parse(params.get('user') ?? 'null'); + } catch { + throw new PartyError(401, 'invalid_telegram', 'Invalid Telegram user.'); + } + if ( + !user || + typeof user !== 'object' || + !('id' in user) || + typeof user.id !== 'number' || + !Number.isSafeInteger(user.id) || + user.id <= 0 + ) { + throw new PartyError(401, 'invalid_telegram', 'Telegram user is required.'); + } + return { id: String(user.id), authDate, launchHash: expected }; +} + +export function cookie(req: Request, name: string): string { + const entries = (req.headers.cookie ?? '') + .split(';') + .map((value) => value.trim()); + const matches = entries.filter((value) => value.startsWith(`${name}=`)); + return matches.length === 1 ? matches[0].slice(name.length + 1) : ''; +} + +export interface MiniSession { + id: string; + principal: string; + csrf: string; + expires_at: Date; +} +export const SESSION_COOKIE = '__Host-party'; +export const BROWSER_COOKIE = '__Host-party-oauth'; +export const cookieOptions = { + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + path: '/', +}; + +export async function session(req: Request): Promise { + const token = cookie(req, SESSION_COOKIE); + if (!/^[\w-]{43}$/.test(token)) + throw new PartyError( + 401, + 'telegram_required', + 'Open Party inside Telegram.' + ); + const [result] = await rows( + 'SELECT id, principal, csrf, expires_at FROM party_mini_app_sessions WHERE token_hash=$1 AND expires_at>now()', + [hash(token)] + ); + if (!result) + throw new PartyError( + 401, + 'session_expired', + 'Reopen Party in Telegram to renew your session.' + ); + return result; +} + +export function checkOrigin(req: Request): void { + if (req.headers.origin !== partyOrigin()) + throw new PartyError( + 403, + 'origin_rejected', + 'Open Party from its configured Mini App.' + ); +} +export function checkCsrf(req: Request, value: MiniSession): void { + checkOrigin(req); + if (!constantEqual(req.get('X-Party-CSRF') ?? '', value.csrf)) { + throw new PartyError( + 403, + 'csrf_rejected', + 'Refresh the Party session and try again.' + ); + } +} + +export async function throttle( + key: string, + max: number, + seconds: number, + client?: PoolClient +): Promise { + const [result] = await rows<{ hits: number }>( + ` + INSERT INTO party_throttles (key, hits, expires_at) VALUES ($1,1,now()+($2 * interval '1 second')) + ON CONFLICT (key) DO UPDATE SET + hits=CASE WHEN party_throttles.expires_at<=now() THEN 1 ELSE party_throttles.hits+1 END, + expires_at=CASE WHEN party_throttles.expires_at<=now() THEN EXCLUDED.expires_at ELSE party_throttles.expires_at END + RETURNING hits`, + [identity(key), seconds], + client + ); + if (result.hits > max) + throw new PartyError( + 429, + 'rate_limited', + 'Please wait before trying again.', + seconds + ); +} diff --git a/projects/api/src/party/store.ts b/projects/api/src/party/store.ts new file mode 100644 index 0000000..cea2db9 --- /dev/null +++ b/projects/api/src/party/store.ts @@ -0,0 +1,79 @@ +import { Pool, PoolClient, QueryResultRow } from 'pg'; +import { required, PartyError } from './config'; + +let pool: Pool | undefined; +export function database(): Pool { + return (pool ??= new Pool({ + connectionString: required('DATABASE_URL'), + max: 8, + connectionTimeoutMillis: 5000, + })); +} + +export async function rows( + sql: string, + values: unknown[] = [], + client?: PoolClient +): Promise { + return (await (client ?? database()).query(sql, values)).rows; +} + +export async function transaction( + fn: (client: PoolClient) => Promise, + existing?: PoolClient +): Promise { + const client = existing ?? (await database().connect()); + try { + await client.query('BEGIN'); + const value = await fn(client); + await client.query('COMMIT'); + return value; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + if (!existing) client.release(); + } +} + +// Session locks survive the commit that records "sending" before the remote write. +export async function hostLock( + key: string, + fn: (client: PoolClient) => Promise +): Promise { + const client = await database().connect(); + let acquired = false; + let connectionFailed = false; + const onError = () => { + connectionFailed = true; + }; + client.on('error', onError); + try { + const [lock] = await rows<{ locked: boolean }>( + 'SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked', + [`party:${key}`], + client + ); + acquired = lock.locked; + if (!acquired) + throw new PartyError( + 409, + 'host_busy', + 'Another host operation is in progress. Please retry.', + 2 + ); + return await fn(client); + } finally { + try { + if (acquired && !connectionFailed) + await client.query( + 'SELECT pg_advisory_unlock(hashtextextended($1, 0))', + [`party:${key}`] + ); + } catch { + connectionFailed = true; + } + client.off('error', onError); + client.release(connectionFailed); + } +} diff --git a/projects/api/src/server.ts b/projects/api/src/server.ts index 6ae57d6..6c90903 100644 --- a/projects/api/src/server.ts +++ b/projects/api/src/server.ts @@ -7,6 +7,8 @@ import { spotifyRoutes } from './routes/spotify'; import { cronRoutes } from './routes/cron'; import { requestLogger } from './middleware/request-logger'; import { env } from './utils/env'; +import { partyRoutes, internalPartyRoutes } from './party/routes'; +import { partyEnabled } from './party/config'; const ALLOWED_ORIGINS = [ 'https://brewtify-133698158612.me-west1.run.app', @@ -32,12 +34,22 @@ export function createServer() { }, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Telegram-User-Id'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Telegram-User-Id', 'X-Party-CSRF'], exposedHeaders: ['Content-Type'], })); - app.use(express.json()); app.use(requestLogger); + app.use((_req, res, next) => { + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (partyEnabled() && process.env.NODE_ENV === 'production' && (_req.path.startsWith('/app') || _req.path.startsWith('/api/party'))) { + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.scdn.co https://*.spotifycdn.com https://*.mzstatic.com; connect-src 'self'; frame-ancestors https://web.telegram.org https://*.telegram.org; base-uri 'none'; object-src 'none'; form-action 'self'"); + } + next(); + }); + app.use('/api/party', partyRoutes); + app.use('/internal/party', internalPartyRoutes); + app.use(express.json()); app.use(healthRoutes); app.use(authRoutes); diff --git a/projects/api/src/services/spotify.ts b/projects/api/src/services/spotify.ts index 4f8d4ea..7a111ba 100644 --- a/projects/api/src/services/spotify.ts +++ b/projects/api/src/services/spotify.ts @@ -3,6 +3,7 @@ import { SpotifyTokens, UserProfile, Playlist, Artist, Track, Album } from '../t import { redisCacheService, TTL } from './redis-cache'; import { createLogger } from '../utils/logger'; import PQueue from 'p-queue'; +import { buildSpotifyAuthorizationUrl } from '@brewtify/spotify'; const log = createLogger('spotify-service'); @@ -38,17 +39,14 @@ export class SpotifyService { 'playlist-modify-public', 'user-follow-read', 'user-follow-modify', - ].join(' '); + ]; - const params = new URLSearchParams({ - client_id: this.clientId, - response_type: 'code', - redirect_uri: this.redirectUri, - scope: scopes, + return buildSpotifyAuthorizationUrl({ + clientId: this.clientId, + redirectUri: this.redirectUri, + scopes, state, }); - - return `${SPOTIFY_ACCOUNTS_BASE}/authorize?${params}`; } async exchangeCode(code: string): Promise { diff --git a/projects/api/tests/party.integration.test.mjs b/projects/api/tests/party.integration.test.mjs new file mode 100644 index 0000000..ee1b5c4 --- /dev/null +++ b/projects/api/tests/party.integration.test.mjs @@ -0,0 +1,898 @@ +import { after, before, test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac, randomUUID } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { spawn, execFile } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import EmbeddedPostgres from 'embedded-postgres'; + +const require = createRequire(import.meta.url); +const express = require('express'); +const { SpotifyClient, SpotifyError } = require('@brewtify/spotify'); +const origin = 'https://party.test'; +Object.assign(process.env, { + PARTY_ENABLED: 'true', + PARTY_PUBLIC_ORIGIN: origin, + PARTY_TELEGRAM_BOT_USERNAME: 'test_party_bot', + PARTY_IDENTITY_KEY: 'ab'.repeat(32), + ENCRYPTION_KEY: 'cd'.repeat(32), + TELEGRAM_BOT_TOKEN: '123456:test', + SPOTIFY_CLIENT_ID: 'test-client', + PARTY_SPOTIFY_REDIRECT_URI: `${origin}/api/party/auth/callback`, + PARTY_HOST_ALLOWLIST: 'test-spotify', + APPLE_MUSIC_TEAM_ID: 'test', + APPLE_MUSIC_KEY_ID: 'test', + APPLE_MUSIC_PRIVATE_KEY: 'test-not-used', + PARTY_TASKS_PROJECT: 'test', + PARTY_TASKS_LOCATION: 'test', + PARTY_TASKS_QUEUE: 'test', + PARTY_TASKS_SERVICE_ACCOUNT: 'tasks@test.invalid', + PARTY_TASKS_AUDIENCE: origin, +}); +let pg, server, base, store, jobs; +let queueCalls = 0; +let queueFailure; +let providerDevices = [ + { id: 'speaker', name: 'Speaker', isActive: true, isRestricted: false }, +]; +const trackId = '0123456789ABCDEFGHIJKL'; +const scopes = ['user-modify-playback-state', 'user-read-playback-state']; +const tokens = { + accessToken: 'access', + refreshToken: 'refresh', + expiresIn: 3600, + scopes, +}; +const track = { + id: trackId, + name: 'Test Song', + title: 'Test Song', + artist: 'Artist', + album: { name: 'Album', images: [] }, + artists: [{ name: 'Artist' }], + duration_ms: 180000, + explicit: false, + is_playable: true, + external_ids: { isrc: 'USABC2400001' }, + external_urls: { spotify: `https://open.spotify.com/track/${trackId}` }, +}; +async function freePort() { + const socket = createServer(); + await new Promise((resolve) => socket.listen(0, '127.0.0.1', resolve)); + const port = socket.address().port; + await new Promise((resolve) => socket.close(resolve)); + return port; +} +before(async () => { + const port = await freePort(); + const dir = path.resolve('.data', `party-test-${randomUUID()}`); + await mkdir(path.dirname(dir), { recursive: true }); + pg = new EmbeddedPostgres({ + databaseDir: dir, + user: 'party_test', + password: 'local-test-only', + port, + persistent: false, + createPostgresUser: false, + postgresFlags: ['-h', '127.0.0.1'], + onLog() {}, + onError(message) { + if (/FATAL|ERROR/.test(String(message))) console.error(message); + }, + }); + await pg.initialise(); + await pg.start(); + await pg.createDatabase('party_test'); + process.env.DATABASE_URL = `postgresql://party_test:local-test-only@127.0.0.1:${port}/party_test`; + store = require('../dist/party/store.js'); + const prismaCli = path.join( + path.dirname(require.resolve('prisma/package.json')), + 'build/index.js' + ); + await promisify(execFile)( + process.execPath, + [prismaCli, 'migrate', 'deploy'], + { env: process.env } + ); + await promisify(execFile)( + process.execPath, + [ + prismaCli, + 'migrate', + 'diff', + '--from-config-datasource', + '--to-schema', + 'prisma/schema.prisma', + '--exit-code', + ], + { env: process.env } + ); + mock.method(SpotifyClient.prototype, 'profile', async () => ({ + id: 'test-spotify', + })); + mock.method(SpotifyClient.prototype, 'exchange', async () => tokens); + mock.method(SpotifyClient.prototype, 'refresh', async () => ({ + ...tokens, + accessToken: 'rotated', + refreshToken: 'rotated-refresh', + })); + mock.method(SpotifyClient.prototype, 'devices', async () => providerDevices); + mock.method(SpotifyClient.prototype, 'track', async () => track); + mock.method(SpotifyClient.prototype, 'enqueue', async () => { + queueCalls++; + if (queueFailure) throw queueFailure; + }); + const { + partyRoutes, + internalPartyRoutes, + } = require('../dist/party/routes.js'); + jobs = require('../dist/party/jobs.js'); + const app = express(); + app.use('/api/party', partyRoutes); + app.use('/internal/party', internalPartyRoutes); + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + base = `http://127.0.0.1:${server.address().port}`; +}); +after(async () => { + mock.restoreAll(); + if (server) await new Promise((resolve) => server.close(resolve)); + if (store) await store.database().end(); + if (pg) await pg.stop(); +}); +function signed(id, extra = {}) { + const fields = { + auth_date: String(Math.floor(Date.now() / 1000)), + user: JSON.stringify({ id }), + query_id: randomUUID(), + ...extra, + }; + const data = Object.entries(fields) + .sort(([a], [b]) => a.localeCompare(b, 'en')) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + const key = createHmac('sha256', 'WebAppData') + .update(process.env.TELEGRAM_BOT_TOKEN) + .digest(); + return new URLSearchParams({ + ...fields, + hash: createHmac('sha256', key).update(data).digest('hex'), + }).toString(); +} +async function call(route, user, body, extra = {}) { + const response = await fetch(`${base}/api/party${route}`, { + method: body === undefined ? 'GET' : 'POST', + redirect: 'manual', + headers: { + Origin: origin, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(user ? { Cookie: user.cookie, 'X-Party-CSRF': user.csrf } : {}), + ...extra, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = text; + } + return { response, data }; +} +async function guest(id) { + const launch = signed(id); + const { response, data } = await call('/session', null, { initData: launch }); + assert.equal(response.status, 200, JSON.stringify(data)); + assert.match( + response.headers.get('set-cookie'), + /HttpOnly; Secure; SameSite=Lax/ + ); + return { + cookie: response.headers.get('set-cookie').split(';')[0], + csrf: data.csrfToken, + launch, + }; +} +async function authorize(user) { + const start = await call('/auth/start', user, { premiumConfirmed: true }); + assert.equal(start.response.status, 200, JSON.stringify(start.data)); + const link = new URL(start.data.authorizationUrl); + const launch = await call( + `${link.pathname.replace('/api/party', '')}${link.search}`, + null + ); + assert.equal(launch.response.status, 302); + const oauth = new URL(launch.response.headers.get('location')); + assert.equal(oauth.searchParams.get('scope'), scopes.join(' ')); + const state = oauth.searchParams.get('state'); + const browserCookie = launch.response.headers.get('set-cookie').split(';')[0]; + const result = await call( + `/auth/callback?state=${state}&code=test`, + null, + undefined, + { Cookie: browserCookie } + ); + assert.equal(result.response.status, 200, JSON.stringify(result.data)); + assert.equal((await call('/auth/status', user)).data.status, 'complete'); + return { state, browserCookie }; +} +async function makeRoom(user) { + const result = await call('/rooms', user, { + deviceId: 'speaker', + mode: 'host_approval', + }); + assert.equal(result.response.status, 201, JSON.stringify(result.data)); + return result.data; +} +async function runPending(requestId, kind) { + const [job] = await store.rows( + 'SELECT * FROM party_jobs WHERE request_id=$1 AND kind=$2', + [requestId, kind] + ); + assert.ok(job); + await jobs.runJob(job.id, job.generation); +} +async function song(user, roomId) { + const result = await call(`/rooms/${roomId}/requests`, user, { + displayName: 'Guest', + url: `https://open.spotify.com/track/${trackId}`, + submissionKey: randomUUID(), + }); + assert.equal(result.response.status, 202, JSON.stringify(result.data)); + return result.data.id; +} +let host, member, outsider, room, invitation; +test('signed sessions reject raw IDs, origin forgery, tampering and launch replay', async () => { + assert.equal( + (await call('/session', null, {}, { 'X-Telegram-User-Id': '123' })).response + .status, + 401 + ); + assert.equal( + ( + await call( + '/session', + null, + { initData: signed(123) }, + { Origin: 'https://evil.test' } + ) + ).response.status, + 403 + ); + host = await guest(100); + member = await guest(200); + outsider = await guest(300); + assert.equal( + (await call('/session', null, { initData: host.launch })).data.error.code, + 'launch_replayed' + ); + const reordered = new URLSearchParams( + [...new URLSearchParams(host.launch)].reverse() + ).toString(); + assert.equal( + (await call('/session', null, { initData: reordered })).data.error.code, + 'launch_replayed' + ); + assert.equal( + (await call('/session', host, { initData: host.launch })).response.status, + 200 + ); + assert.equal( + ( + await call( + '/auth/start', + host, + { premiumConfirmed: true }, + { 'X-Party-CSRF': 'wrong' } + ) + ).response.status, + 403 + ); + assert.equal( + (await call('/auth/start', host, {})).data.error.code, + 'premium_confirmation_required' + ); + assert.equal( + (await store.rows('SELECT * FROM users')).length, + 0, + 'Party must not create persistent Library users' + ); + assert.equal( + ( + await store.rows( + 'SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL' + ) + ).length, + 2 + ); +}); +test('OAuth browser state mismatch, cancellation, expiration and one-use tickets', async () => { + const start = await call('/auth/start', outsider, { premiumConfirmed: true }); + const link = new URL(start.data.authorizationUrl); + const route = `${link.pathname.replace('/api/party', '')}${link.search}`; + const launched = await call(route); + assert.equal((await call(route)).response.status, 400); + const state = new URL( + launched.response.headers.get('location') + ).searchParams.get('state'); + const browserCookie = launched.response.headers + .get('set-cookie') + .split(';')[0]; + assert.equal( + (await call(`/auth/callback?state=${state}&code=test`, host)).response + .status, + 400 + ); + assert.equal( + ( + await call( + `/auth/callback?state=${state}&error=access_denied`, + null, + undefined, + { Cookie: browserCookie } + ) + ).response.status, + 400 + ); + assert.equal( + (await call('/auth/status', outsider)).data.error, + 'authorization_cancelled' + ); + const next = await call('/auth/start', outsider, { premiumConfirmed: true }); + const nextLink = new URL(next.data.authorizationUrl); + await store.rows( + "UPDATE party_authorizations SET expires_at=now()-interval '1 second' WHERE status='pending'" + ); + assert.equal( + ( + await call( + `${nextLink.pathname.replace('/api/party', '')}${nextLink.search}` + ) + ).response.status, + 400 + ); +}); +test('external browser PKCE returns to verified principal without shared cookies; callback replay denied', async () => { + const { state, browserCookie } = await authorize(host); + assert.equal((await call('/auth/status', outsider)).data.status, 'failed'); + assert.equal((await call('/session', outsider)).data.hostConnected, false); + assert.equal( + ( + await call(`/auth/callback?state=${state}&code=test`, null, undefined, { + Cookie: browserCookie, + }) + ).response.status, + 400 + ); + assert.equal(queueCalls, 0, 'setup must not enqueue a Premium probe'); + ({ room, inviteUrl: invitation } = await makeRoom(host)); + assert.equal( + (await call('/rooms', host, { deviceId: 'speaker' })).data.error.code, + 'room_already_exists' + ); + const secret = new URL(invitation).searchParams.get('startapp').slice(2); + assert.equal((await call('/join', member, { secret })).response.status, 200); + assert.equal( + (await call(`/rooms/${room.id}/requests`, outsider)).response.status, + 403 + ); + assert.equal( + (await call(`/rooms/${room.id}/action`, member, { action: 'close' })) + .response.status, + 403 + ); + assert.equal( + (await call(`/rooms/${room.id}/invite`, member)).response.status, + 403 + ); +}); +test('same Spotify account cannot be claimed by another Telegram principal', async () => { + const start = await call('/auth/start', member, { premiumConfirmed: true }); + const link = new URL(start.data.authorizationUrl); + const launched = await call( + `${link.pathname.replace('/api/party', '')}${link.search}` + ); + const state = new URL( + launched.response.headers.get('location') + ).searchParams.get('state'); + const cookie = launched.response.headers.get('set-cookie').split(';')[0]; + const finish = await call( + `/auth/callback?state=${state}&code=test`, + null, + undefined, + { Cookie: cookie } + ); + assert.equal(finish.data.error.code, 'account_in_use'); + assert.equal((await call('/session', member)).data.hostConnected, false); + const returningHost = await guest(100); + assert.equal((await call('/session', returningHost)).data.room.id, room.id); + assert.equal((await call('/session', returningHost)).data.room.isHost, true); +}); +test('refresh is serialized, preserves rotation, and never overwrites Library credentials', async () => { + const { decrypt } = require('../dist/services/encryption.js'); + await store.rows( + "UPDATE party_host_sessions SET token_expires_at=now()-interval '1 second'" + ); + const first = await call('/devices', host); + const second = await call('/devices', host); + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 200); + assert.equal(SpotifyClient.prototype.refresh.mock.callCount(), 1); + const [stored] = await store.rows('SELECT * FROM party_host_sessions'); + assert.equal( + decrypt(stored.encrypted_refresh_token, stored.salt), + 'rotated-refresh' + ); + assert.equal((await store.rows('SELECT * FROM users')).length, 0); +}); +test('idempotent submission, own receipts, approval and successful queue acceptance', async () => { + assert.equal( + ( + await call(`/rooms/${room.id}/requests`, host, { + displayName: 'Host', + url: 'https://evil.test/song', + submissionKey: randomUUID(), + }) + ).response.status, + 400 + ); + const key = randomUUID(); + const body = { + displayName: 'Guest', + url: `https://open.spotify.com/track/${trackId}`, + submissionKey: key, + }; + const first = await call(`/rooms/${room.id}/requests`, member, body); + const repeated = await call(`/rooms/${room.id}/requests`, member, body); + assert.equal(first.data.id, repeated.data.id); + assert.equal( + ( + await call(`/rooms/${room.id}/requests`, member, { + ...body, + displayName: 'Changed', + }) + ).response.status, + 409 + ); + const id = first.data.id; + await runPending(id, 'resolve'); + let receipt = ( + await call(`/rooms/${room.id}/requests`, member) + ).data.requests.find((r) => r.id === id); + assert.equal(receipt.status, 'matched'); + assert.equal(queueCalls, 0); + assert.equal( + ( + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'approve', + }) + ).response.status, + 200 + ); + const [job] = await store.rows( + "SELECT * FROM party_jobs WHERE request_id=$1 AND kind='deliver'", + [id] + ); + await jobs.runJob(job.id, job.generation); + await jobs.runJob(job.id, job.generation); + receipt = ( + await call(`/rooms/${room.id}/requests`, member) + ).data.requests.find((r) => r.id === id); + assert.equal(receipt.status, 'added'); + assert.equal(queueCalls, 1, 'task redelivery must not repeat the command'); + const hostRequest = await song(host, room.id); + const guestFeed = (await call(`/rooms/${room.id}/requests`, member)).data; + assert.ok(!guestFeed.requests.some((r) => r.id === hostRequest)); + assert.equal(guestFeed.room.deviceId, ''); +}); +test('outbox creation failures stay recoverable and named-task replay is idempotent', async () => { + const { CloudTasksClient } = require('@google-cloud/tasks'); + const names = []; + const create = mock.method( + CloudTasksClient.prototype, + 'createTask', + async (request) => { + names.push(request.task.name); + const body = JSON.parse( + Buffer.from(request.task.httpRequest.body, 'base64').toString() + ); + assert.ok(body.jobId); + assert.equal(request.task.httpRequest.oidcToken.audience, origin); + assert.equal( + request.task.httpRequest.oidcToken.serviceAccountEmail, + 'tasks@test.invalid' + ); + throw new Error('simulated task service outage'); + } + ); + const close = mock.method( + CloudTasksClient.prototype, + 'close', + async () => {} + ); + try { + await assert.rejects(jobs.dispatchOutbox(), /simulated/); + assert.ok( + ( + await store.rows( + "SELECT * FROM party_jobs WHERE status='pending' AND dispatched_at IS NULL" + ) + ).length + ); + create.mock.mockImplementation(async (request) => { + names.push(request.task.name); + const duplicate = new Error('Already exists'); + duplicate.code = 6; + throw duplicate; + }); + await jobs.dispatchOutbox(); + assert.equal(names[0], names[1], 'create retry uses identical task name'); + assert.ok( + ( + await store.rows( + "SELECT * FROM party_jobs WHERE status='pending' AND dispatched_at IS NOT NULL" + ) + ).length + ); + } finally { + create.mock.restore(); + close.mock.restore(); + } +}); +test('locked accepts moderation, not submissions; device changes pause without transfer', async () => { + const id = await song(member, room.id); + await runPending(id, 'resolve'); + await call(`/rooms/${room.id}/action`, host, { action: 'lock' }); + const blocked = await call(`/rooms/${room.id}/requests`, host, { + displayName: 'Host', + url: `https://open.spotify.com/track/${trackId}`, + submissionKey: randomUUID(), + }); + assert.equal(blocked.data.error.code, 'room_locked'); + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'approve', + }); + providerDevices = [ + { id: 'different', name: 'Different', isActive: true, isRestricted: false }, + ]; + const before = queueCalls; + await runPending(id, 'deliver'); + assert.equal(queueCalls, before); + assert.equal( + (await call(`/rooms/${room.id}/requests`, host)).data.room.blockedReason, + 'device_confirmation_required' + ); + providerDevices = [ + { id: 'speaker', name: 'Speaker', isActive: true, isRestricted: false }, + ]; + await call(`/rooms/${room.id}/action`, host, { + action: 'device', + deviceId: 'speaker', + }); + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 1); + await call(`/rooms/${room.id}/action`, host, { action: 'unlock' }); +}); +test('429 rejection persists retry timing; timeout never automatically repeats', async () => { + const id = await song(host, room.id); + await runPending(id, 'resolve'); + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'approve', + }); + queueFailure = new SpotifyError('rate_limited', 429, 75); + const before = queueCalls; + await runPending(id, 'deliver'); + let [job] = await store.rows( + "SELECT * FROM party_jobs WHERE request_id=$1 AND kind='deliver'", + [id] + ); + assert.ok(job.due_at.getTime() > Date.now() + 70000); + await assert.rejects(jobs.runJob(job.id, job.generation)); + assert.equal(queueCalls, before + 1); + await store.rows('UPDATE party_jobs SET due_at=now() WHERE id=$1', [job.id]); + queueFailure = new SpotifyError( + 'provider_unavailable', + undefined, + undefined, + true + ); + await runPending(id, 'deliver'); + queueFailure = undefined; + const receipt = ( + await call(`/rooms/${room.id}/requests`, host) + ).data.requests.find((r) => r.id === id); + assert.equal(receipt.status, 'failed'); + assert.equal(receipt.failureCode, 'delivery_unknown'); + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 2); + assert.equal( + ( + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'retry', + }) + ).data.error.code, + 'duplicate_risk_confirmation_required' + ); + assert.equal( + ( + await call(`/rooms/${room.id}/action`, host, { + action: 'acknowledge_unknown', + }) + ).data.error.code, + 'delivery_settling' + ); + await store.rows( + "UPDATE party_delivery_attempts SET created_at=now()-interval '3 minutes' WHERE request_id=$1", + [id] + ); + assert.equal( + ( + await call(`/rooms/${room.id}/action`, host, { + action: 'acknowledge_unknown', + }) + ).response.status, + 200 + ); +}); +test('crash marker becomes unknown, not a fresh delivery; stale tasks are fenced', async () => { + const id = await song(host, room.id); + await runPending(id, 'resolve'); + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'approve', + }); + await store.rows( + 'INSERT INTO party_delivery_attempts(id,room_id,request_id) VALUES($1,$2,$3)', + [randomUUID(), room.id, id] + ); + const before = queueCalls; + await runPending(id, 'deliver'); + assert.equal(queueCalls, before); + const [receipt] = await store.rows( + 'SELECT * FROM party_requests WHERE id=$1', + [id] + ); + assert.equal(receipt.failure_code, 'delivery_unknown'); + assert.equal( + (await call(`/rooms/${room.id}/requests`, host)).data.room.blockedReason, + 'delivery_unknown' + ); +}); +test('PostgreSQL locks exclude another process and release after connection death', async () => { + const script = `const {hostLock}=require('./dist/party/store.js'); hostLock('cross-process',async()=>{console.log('LOCKED'); await new Promise(()=>{});});`; + const child = spawn(process.execPath, ['-e', script], { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await new Promise((resolve, reject) => { + child.stdout.once('data', (data) => + String(data).includes('LOCKED') + ? resolve() + : reject(new Error(String(data))) + ); + child.once('error', reject); + child.once('exit', (code) => reject(new Error(`child exited ${code}`))); + }); + await assert.rejects( + store.hostLock('cross-process', async () => {}), + { code: 'host_busy' } + ); + } finally { + child.kill('SIGTERM'); + await new Promise((resolve) => child.once('exit', resolve)); + } + await store.hostLock('cross-process', async () => {}); +}); +test('OIDC audience and verified configured identity are mandatory', async () => { + const { OAuth2Client } = require('google-auth-library'); + const verify = mock.method( + OAuth2Client.prototype, + 'verifyIdToken', + async (options) => { + assert.equal(options.audience, origin); + return { + getPayload: () => ({ + email: 'attacker@test.invalid', + email_verified: true, + }), + }; + } + ); + try { + await assert.rejects(jobs.verifyInternal('Bearer fake'), { + code: 'internal_identity_rejected', + }); + verify.mock.mockImplementation(async () => ({ + getPayload: () => ({ + email: 'tasks@test.invalid', + email_verified: false, + }), + })); + await assert.rejects(jobs.verifyInternal('Bearer fake'), { + code: 'internal_identity_rejected', + }); + verify.mock.mockImplementation(async () => ({ + getPayload: () => ({ email: 'tasks@test.invalid', email_verified: true }), + })); + await jobs.verifyInternal('Bearer verified-fixture'); + } finally { + verify.mock.restore(); + } +}); +test('auto mode requires opt-in and recording drift requires fresh manual approval', async () => { + assert.equal( + ( + await call(`/rooms/${room.id}/action`, host, { + action: 'mode', + mode: 'auto', + }) + ).data.error.code, + 'auto_disabled' + ); + process.env.PARTY_AUTO_ENABLED = 'true'; + await store.rows( + "UPDATE party_delivery_attempts SET created_at=now()-interval '3 minutes' WHERE outcome='unknown'" + ); + await call(`/rooms/${room.id}/action`, host, { + action: 'acknowledge_unknown', + }); + assert.equal( + ( + await call(`/rooms/${room.id}/action`, host, { + action: 'mode', + mode: 'auto', + }) + ).response.status, + 200 + ); + const id = await song(host, room.id); + await runPending(id, 'resolve'); + let [receipt] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [ + id, + ]); + assert.equal(receipt.status, 'approved'); + const original = track.name; + const before = queueCalls; + try { + track.name = 'Test Song - Remastered'; + await runPending(id, 'deliver'); + [receipt] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [ + id, + ]); + assert.equal(receipt.failure_code, 'recording_changed'); + assert.equal(queueCalls, before); + assert.equal( + ( + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'retry', + }) + ).response.status, + 200 + ); + await runPending(id, 'resolve'); + [receipt] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [ + id, + ]); + assert.equal( + receipt.status, + 'matched', + 're-resolved recording cannot auto-enqueue after metadata changed' + ); + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'approve', + }); + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 1); + } finally { + track.name = original; + await call(`/rooms/${room.id}/action`, host, { + action: 'mode', + mode: 'host_approval', + }); + process.env.PARTY_AUTO_ENABLED = 'false'; + } +}); +test('close races do not interrupt or repeat an already in-flight command', async () => { + await store.rows( + "UPDATE party_delivery_attempts SET created_at=now()-interval '3 minutes' WHERE outcome='unknown'" + ); + await call(`/rooms/${room.id}/action`, host, { + action: 'acknowledge_unknown', + }); + const id = await song(host, room.id); + await runPending(id, 'resolve'); + await call(`/rooms/${room.id}/requests/${id}/action`, host, { + action: 'approve', + }); + let release; + let started; + const entered = new Promise((resolve) => { + started = resolve; + }); + SpotifyClient.prototype.enqueue.mock.mockImplementation(async () => { + queueCalls++; + started(); + await new Promise((resolve) => { + release = resolve; + }); + }); + const running = runPending(id, 'deliver'); + await entered; + try { + assert.equal( + (await call(`/rooms/${room.id}/action`, host, { action: 'close' })).data + .error.code, + 'host_busy' + ); + const [attempt] = await store.rows( + 'SELECT * FROM party_delivery_attempts WHERE request_id=$1', + [id] + ); + assert.equal(attempt.outcome, 'sending'); + } finally { + release(); + await running; + } + const [receipt] = await store.rows( + 'SELECT * FROM party_requests WHERE id=$1', + [id] + ); + assert.equal(receipt.status, 'added'); +}); +test('closing deletes credentials, cancels outbox and expiry cascades retained history', async () => { + const response = await call(`/rooms/${room.id}/action`, host, { + action: 'close', + }); + assert.equal(response.response.status, 200, JSON.stringify(response.data)); + assert.equal( + (await store.rows('SELECT * FROM party_host_sessions')).length, + 0 + ); + assert.equal( + (await store.rows("SELECT * FROM party_jobs WHERE status='pending'")) + .length, + 0 + ); + assert.equal((await call('/session', host)).data.hostConnected, false); + await store.rows( + "UPDATE party_rooms SET expires_at=now()-interval '1 second' WHERE id=$1", + [room.id] + ); + assert.equal( + (await call(`/rooms/${room.id}/requests`, member)).response.status, + 410 + ); + await jobs.cleanup(); + for (const table of [ + 'party_rooms', + 'party_requests', + 'party_match_candidates', + 'party_jobs', + 'party_delivery_attempts', + 'party_memberships', + ]) { + assert.equal((await store.rows(`SELECT * FROM ${table}`)).length, 0, table); + } + const internal = await fetch(`${base}/internal/party/jobs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CloudTasks-TaskName': 'fake', + }, + body: '{}', + }); + assert.equal(internal.status, 401); + process.env.PARTY_ENABLED = 'false'; + assert.deepEqual((await call('/config')).data, { + enabled: false, + telegramUrl: null, + autoEnabled: false, + }); + assert.equal((await call('/session', host)).response.status, 404); +}); diff --git a/projects/mini-app/README.md b/projects/mini-app/README.md index 7dbf7eb..69b8335 100644 --- a/projects/mini-app/README.md +++ b/projects/mini-app/README.md @@ -1,5 +1,74 @@ # React + TypeScript + Vite +## Brewtify Library and Party + +The existing Mini App is still served at `/app`. `GET /api/party/config` controls +the Party rollout. When disabled (or unavailable), the original Library UI and +Playlists/Artists navigation remain. When enabled, Library and Party are lazy +feature boundaries: visiting Party never mounts Library or fetches its profile. +Library logout and Party disconnect are separate. +An error boundary around lazy Library content also catches import/render +failures without removing the section navigation. Vite explicitly prebundles +the linked CommonJS `@brewtify/shared` workspace for development-browser imports. + +Party supports `?section=party`, Telegram `startapp=party`, and private +`startapp=p_` invitations. Browser visitors are directed to Telegram. +There is no development identity bypass: Party bootstraps with signed +`Telegram.WebApp.initData`, uses same-origin session cookies, and sends +`X-Party-CSRF` on mutations. Provider credentials never enter the Mini App. + +Only explicit host setup starts Spotify consent, after Premium confirmation. +OAuth opens via Telegram `openLink`; the original Mini App polls its own +authorization transaction rather than assuming the external browser shares +cookies. Reopening resumes from the session and authorization endpoints. +Device selection never transfers playback. Unknown delivery outcomes require +explicit duplicate-risk confirmation before a retry. + +Feeds poll while visible, merge changed receipts by ID using the returned +cursor, honor `Retry-After`, back off on transient failures, and stop after +closure/expiry or a terminal authorization/membership error. QR invitations +are generated locally with `qrcode`, not through a third-party service. +Guest receipt visibility and all host permissions are enforced by the API. + +Validation: + +```sh +npm test --workspace=mini-app +npm run lint:party --workspace=mini-app +npm run lint --workspace=mini-app +npm run build --workspace=mini-app +``` + +Node's built-in tests cover launch/navigation rules, invitation parsing, lazy +Library boundary regressions, changed-feed merging, backoff, signed bootstrap, +cookie/CSRF requests, and non-replayed writes. Real Telegram WebView cookies, +external OAuth return, device selection, and actual Spotify delivery still +require an authorized integration pilot. + +On re-entry, Party first restores `GET /api/party/session`; only a 401 triggers +signed-launch bootstrap. Sessions last one hour, while new signed launches +must be fresh within five minutes. After session expiry, reopen Telegram for +fresh launch data. Feed cursors are opaque revision strings: an unchanged +cursor on an empty page waits for the normal interval, not immediate polling. + +### Credential-free navigation preview + +```sh +VITE_PARTY_PREVIEW=true npm run dev --workspace=mini-app -- --host 127.0.0.1 --port 5174 +``` + +Open `http://127.0.0.1:5174/app/?section=party` for the Open-in-Telegram +explanation, or `http://127.0.0.1:5174/app/?section=library` for the Library +boundary (an error is expected without its API). Both main tabs remain usable. +The Vite **development-server-only** fixture responds solely to +`GET /api/party/config`. It never creates a session, simulates identity, +intercepts provider authorization, or handles mutations. It is absent from +production builds and `vite preview`, even if the environment flag is set. +Do not treat this visual fixture as a Telegram authentication or host-flow test. + +`lint:party` covers all changed frontend source/config files; full `lint` also +reports the repository’s unrelated legacy findings. + This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: diff --git a/projects/mini-app/dev/party-preview.ts b/projects/mini-app/dev/party-preview.ts new file mode 100644 index 0000000..2bcaf8e --- /dev/null +++ b/projects/mini-app/dev/party-preview.ts @@ -0,0 +1,21 @@ +import type { Plugin } from 'vite'; + +export function partyPreviewPlugins(command: 'build' | 'serve', enabled: boolean): Plugin[] { + if (command !== 'serve' || !enabled) return []; + return [{ + name: 'party-navigation-preview', + apply: 'serve', + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.method !== 'GET' || request.url?.split('?')[0] !== '/api/party/config') { + next(); + return; + } + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.setHeader('Cache-Control', 'no-store'); + response.end(JSON.stringify({ enabled: true, telegramUrl: null, autoEnabled: false })); + }); + }, + }]; +} diff --git a/projects/mini-app/package.json b/projects/mini-app/package.json index 81e4e08..40e93b8 100644 --- a/projects/mini-app/package.json +++ b/projects/mini-app/package.json @@ -7,12 +7,15 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "lint:party": "eslint src/App.tsx src/features src/lib/navigation.ts src/lib/telegram.ts src/components/BottomTabs.tsx src/components/Profile.tsx dev/party-preview.ts vite.config.ts", + "preview": "vite preview", + "test": "node --experimental-strip-types --test tests/*.test.mjs" }, "dependencies": { "@brewtify/shared": "*", "@tailwindcss/vite": "^4.3.0", "@telegram-apps/sdk-react": "^3.3.9", + "qrcode": "^1.5.4", "react": "^19.2.6", "react-dom": "^19.2.6", "tailwindcss": "^4.3.0" @@ -20,6 +23,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.12.3", + "@types/qrcode": "^1.5.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/projects/mini-app/src/App.tsx b/projects/mini-app/src/App.tsx index 2a6a335..6d74a5b 100644 --- a/projects/mini-app/src/App.tsx +++ b/projects/mini-app/src/App.tsx @@ -1,108 +1,62 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Profile } from './components/Profile'; -import { PlaylistList } from './components/PlaylistList'; -import { CreatePlaylist } from './components/CreatePlaylist'; -import { PlaylistDetail } from './components/PlaylistDetail'; -import { ArtistsPage } from './components/ArtistsPage'; -import { BottomTabs } from './components/BottomTabs'; -import { LoginScreen } from './components/LoginScreen'; -import { ErrorScreen } from './components/ErrorScreen'; -import { PlusIcon } from './components/Icons'; -import { fetchProfile } from './lib/api'; -import type { UserProfile } from './lib/types'; +import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; +import type { PartyConfigDto } from '@brewtify/shared'; +import { initialNavigation, sectionUrl } from './lib/navigation'; +import type { Section } from './lib/navigation'; +import { telegram } from './lib/telegram'; +import { LibraryErrorBoundary } from './features/library/LibraryErrorBoundary'; -type Tab = 'playlists' | 'artists'; +const Library = lazy(() => import('./features/library/Library')); +const Party = lazy(() => import('./features/party/Party')); +const disabled: PartyConfigDto = { enabled: false, telegramUrl: null, autoEnabled: false }; export default function App() { - const [refreshKey, setRefreshKey] = useState(0); - const [view, setView] = useState<'loading' | 'login' | 'home' | 'create' | 'detail' | 'error'>('loading'); - const [activeTab, setActiveTab] = useState('playlists'); - const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); - const [profile, setProfile] = useState(null); + const [launch] = useState(() => initialNavigation(window.location.search, telegram()?.initData)); + const [section, setSection] = useState
(launch.section); + const [pendingSecret, setPendingSecret] = useState(launch.secret); + const [config, setConfig] = useState(null); + const consumeInvite = useCallback(() => setPendingSecret(null), []); useEffect(() => { - fetchProfile() - .then((p) => { - setProfile(p); - setView('home'); + const controller = new AbortController(); + fetch('/api/party/config', { credentials: 'same-origin', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]) }) + .then(async (response) => { + if (!response.ok) throw new Error('Party unavailable'); + return response.json() as Promise; }) - .catch((err) => { - if ((err as any).status === 401) { - setView('login'); - } else { - setView('error'); - } - }); + .then(setConfig) + .catch(() => { if (!controller.signal.aborted) setConfig(disabled); }); + telegram()?.ready?.(); + telegram()?.expand?.(); + return () => controller.abort(); }, []); - const handleLogout = useCallback(() => { - setView('login'); + useEffect(() => { + const onPop = () => setSection(initialNavigation(window.location.search).section); + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); }, []); - const handlePlaylistCreated = () => { - setRefreshKey((k) => k + 1); - setView('home'); - }; - - const handlePlaylistClick = (playlistId: string) => { - setSelectedPlaylistId(playlistId); - setView('detail'); - }; - - if (view === 'loading') { - return ( -
-
Loading...
-
- ); - } - - if (view === 'login') { - return ; - } - - if (view === 'error') { - return ; - } - - if (view === 'create') { - return setView('home')} />; - } - - if (view === 'detail' && selectedPlaylistId) { - return ( - { setView('home'); setRefreshKey((k) => k + 1); }} - /> - ); + function navigate(next: Section) { + setSection(next); + window.history.pushState(null, '', sectionUrl(window.location.href, next)); } + if (!config) return
Loading Brewtify…
; return ( -
-
- -
- -
-
- setView('create')} /> -
- - {/* Floating create button */} - -
- -
- -
- - +
+ Loading…
}> + {config.enabled && section === 'party' + ? + :
+ +
} + + {config.enabled && ( + + )}
); } diff --git a/projects/mini-app/src/components/BottomTabs.tsx b/projects/mini-app/src/components/BottomTabs.tsx index bbb940e..52d3745 100644 --- a/projects/mini-app/src/components/BottomTabs.tsx +++ b/projects/mini-app/src/components/BottomTabs.tsx @@ -5,14 +5,16 @@ type Tab = 'playlists' | 'artists'; interface BottomTabsProps { activeTab: Tab; onTabChange: (tab: Tab) => void; + inline?: boolean; } -export function BottomTabs({ activeTab, onTabChange }: BottomTabsProps) { +export function BottomTabs({ activeTab, onTabChange, inline = false }: BottomTabsProps) { return ( -
} diff --git a/projects/mini-app/src/features/party/api.ts b/projects/mini-app/src/features/party/api.ts index 09bd705..fba71d3 100644 --- a/projects/mini-app/src/features/party/api.ts +++ b/projects/mini-app/src/features/party/api.ts @@ -69,7 +69,7 @@ export class PartyClient { export function errorText(error: unknown): string { if (error instanceof PartyError && ( - ['host_reconnect', 'unauthorized', 'premium_required', 'insufficient_scope', 'device_unavailable', 'device_confirmation_required', 'delivery_settling'].includes(error.code) + ['host_reconnect', 'unauthorized', 'premium_required', 'insufficient_scope', 'device_unavailable', 'delivery_settling'].includes(error.code) || error.code.startsWith('apple_') )) { return partyFailureMessage(error.code); diff --git a/projects/mini-app/src/features/party/messages.ts b/projects/mini-app/src/features/party/messages.ts index 81160dc..79fb53d 100644 --- a/projects/mini-app/src/features/party/messages.ts +++ b/projects/mini-app/src/features/party/messages.ts @@ -7,10 +7,7 @@ const messages: Record = { reconnect_required: 'Reconnect Party Spotify to restore playback permission.', premium_required: 'Spotify confirmed Premium is required. Check the host subscription before continuing.', insufficient_scope: 'Spotify playback permission is missing. The host needs to reconnect Party Spotify and grant playback access.', - device_changed: 'A different Spotify device is active. The host needs to confirm the intended active device.', - device_unavailable: 'The chosen Spotify device is unavailable. Start playback in Spotify, then refresh and confirm an active device.', - device_restricted: 'Spotify restricts playback control on this device. Choose another active device.', - device_confirmation_required: 'Start playback on the intended Spotify device, then refresh and confirm that active, unrestricted device.', + device_unavailable: 'Spotify has no available active playback. The host should open Spotify, start playing music, then tap Try again.', rate_limited: 'Spotify is rate-limiting requests. Approved requests are waiting for a safe retry.', forbidden: 'Spotify denied playback access. Check the pilot allowlist and device restrictions; this does not necessarily mean Premium is missing.', apple_configuration: 'Apple Music catalog access is not configured. The Brewtify operator must configure it; guests do not need to sign in.', @@ -24,5 +21,5 @@ const messages: Record = { }; export function partyFailureMessage(code: string): string { - return messages[code] ?? `Party needs attention: ${code.replaceAll('_', ' ')}. Check the host’s Spotify connection and selected device.`; + return messages[code] ?? `Party needs attention: ${code.replaceAll('_', ' ')}. Check the host’s Spotify connection and playback.`; } diff --git a/projects/mini-app/tests/party-preview.test.mjs b/projects/mini-app/tests/party-preview.test.mjs index 23a1c16..d5f48ac 100644 --- a/projects/mini-app/tests/party-preview.test.mjs +++ b/projects/mini-app/tests/party-preview.test.mjs @@ -37,7 +37,7 @@ test('provider failures have actionable labels without telling guests to log in' assert.match(partyFailureMessage('unauthorized'), /reconnect Party Spotify/); assert.match(partyFailureMessage('premium_required'), /Premium/); assert.match(partyFailureMessage('insufficient_scope'), /grant playback access/); - assert.match(partyFailureMessage('device_unavailable'), /active device/); + assert.match(partyFailureMessage('device_unavailable'), /start playing music, then tap Try again/); assert.match(partyFailureMessage('delivery_settling'), /two-minute safety window/); assert.equal(partyFailureMessage('recording_changed'), 'Spotify recording details changed. Resolve the request again and approve the version before adding.'); for (const code of ['apple_configuration', 'apple_unauthorized', 'apple_rate_limited', 'apple_unavailable', 'apple_invalid_response', 'apple_rejected']) { diff --git a/projects/shared/src/party.ts b/projects/shared/src/party.ts index 04c9379..ee101b0 100644 --- a/projects/shared/src/party.ts +++ b/projects/shared/src/party.ts @@ -46,7 +46,6 @@ export interface PartyRoomDto { status: PartyRoomStatus; mode: PartyMode; expiresAt: string; - deviceId: string; blockedReason: string | null; isHost: boolean; } diff --git a/projects/spotify/src/index.test.ts b/projects/spotify/src/index.test.ts index 6942a3e..6f52eef 100644 --- a/projects/spotify/src/index.test.ts +++ b/projects/spotify/src/index.test.ts @@ -39,6 +39,7 @@ test('shared authorization builder preserves legacy scope configuration and exac }); test('PKCE authorization requests only playback scopes', () => { + assert.deepEqual(PARTY_SPOTIFY_SCOPES, ['user-modify-playback-state']); const url = new URL(client().authorizationUrl({ state: 'random-state', codeChallenge: 'a'.repeat(43) })); assert.equal(url.origin, 'https://accounts.spotify.com'); assert.equal(url.searchParams.get('scope'), PARTY_SPOTIFY_SCOPES.join(' ')); @@ -53,7 +54,7 @@ test('PKCE exchange and refresh preserve rotated credentials without client secr const calls: { url: string; init?: RequestInit }[] = []; t.mock.method(globalThis, 'fetch', async (url: Parameters[0], init?: RequestInit) => { calls.push({ url: url.toString(), init }); - return json({ access_token: 'access', refresh_token: 'rotated', expires_in: 3600, scope: 'user-modify-playback-state user-read-playback-state' }); + return json({ access_token: 'access', refresh_token: 'rotated', expires_in: 3600, scope: 'user-modify-playback-state' }); }); const tokens = await client().exchange('code', 'v'.repeat(43)); assert.deepEqual(tokens, { accessToken: 'access', refreshToken: 'rotated', expiresIn: 3600, scopes: [...PARTY_SPOTIFY_SCOPES] }); @@ -88,6 +89,25 @@ test('204 enqueue is one POST with query parameters and no JSON decoding', async assert.equal(count, 1); }); +test('enqueue uses active playback when device ID is omitted and rejects invalid explicit targets', async t => { + let calls = 0; + t.mock.method(globalThis, 'fetch', async (input: Parameters[0], init?: RequestInit) => { + calls++; + const url = new URL(String(input)); + assert.equal(url.pathname, '/v1/me/player/queue'); + assert.equal(url.searchParams.has('device_id'), false); + assert.equal(url.searchParams.get('uri'), `spotify:track:${ID}`); + assert.equal(init?.method, 'POST'); + return new Response(null, { status: 204 }); + }); + await client().enqueue('token', ID); + for (const device of ['', 'bad device', 'x'.repeat(257)]) { + await assert.rejects(client().enqueue('token', ID, device), { code: 'invalid_input' }); + } + await assert.rejects(client().enqueue('token', 'invalid'), { code: 'invalid_input' }); + assert.equal(calls, 1); +}); + test('every queue error performs exactly one write, with explicit uncertainty classification', async t => { const cases: { status?: number; body?: unknown; code: string; unknown: boolean }[] = [ { status: 429, body: { error: { message: 'rate limited' } }, code: 'rate_limited', unknown: false }, @@ -111,7 +131,7 @@ test('every queue error performs exactly one write, with explicit uncertainty cl if (fixture.status === undefined) throw new TypeError('connection reset'); return json(fixture.body, fixture.status, { 'Retry-After': '23', Location: 'http://127.0.0.1' }); }); - await assert.rejects(client().enqueue('token', ID, 'speaker'), (error: unknown) => { + await assert.rejects(client().enqueue('token', ID), (error: unknown) => { assert.ok(error instanceof SpotifyError); assert.equal(error.code, fixture.code); assert.equal(error.unknownDelivery, fixture.unknown); diff --git a/projects/spotify/src/index.ts b/projects/spotify/src/index.ts index 599b86b..76a117c 100644 --- a/projects/spotify/src/index.ts +++ b/projects/spotify/src/index.ts @@ -2,7 +2,7 @@ import type { PartyCandidate, PartyDevice } from '@brewtify/shared'; import { Agent } from 'undici'; import { createProviderLookup } from './network'; -export const PARTY_SPOTIFY_SCOPES = ['user-modify-playback-state', 'user-read-playback-state'] as const; +export const PARTY_SPOTIFY_SCOPES = ['user-modify-playback-state'] as const; export const SPOTIFY_TRACK_ID = /^[A-Za-z0-9]{22}$/; const ISRC = /^[A-Z]{2}[A-Z0-9]{3}\d{7}$/i; export const PROVIDER_DEADLINE_MS = 8_000; @@ -353,11 +353,12 @@ export class SpotifyClient { return items.map(decodeTrack); } - async enqueue(token: string, trackId: string, deviceId: string): Promise { - if (!SPOTIFY_TRACK_ID.test(trackId) || !deviceId || deviceId.length > 256 || /[\u0000-\u0020]/.test(deviceId)) { + async enqueue(token: string, trackId: string, deviceId?: string): Promise { + if (!SPOTIFY_TRACK_ID.test(trackId) || (deviceId !== undefined && (!deviceId || deviceId.length > 256 || /[\u0000-\u0020]/.test(deviceId)))) { throw new SpotifyError('invalid_input'); } - const params = new URLSearchParams({ uri: `spotify:track:${trackId}`, device_id: deviceId }); + const params = new URLSearchParams({ uri: `spotify:track:${trackId}` }); + if (deviceId !== undefined) params.set('device_id', deviceId); await this.request('spotify', `me/player/queue?${params}`, { method: 'POST', headers: this.headers(token), }, 'queue'); diff --git a/tests/party.browser.mjs b/tests/party.browser.mjs index 20e65c8..b8159db 100644 --- a/tests/party.browser.mjs +++ b/tests/party.browser.mjs @@ -24,7 +24,7 @@ async function previewPage(options) { return page; } -test('interactive demo renders host, guest and setup with no API traffic', async () => { +test('interactive demo renders host, guest and direct start with no API traffic', async () => { const page = await previewPage({ viewport: { width: 390, height: 844 } }); const requests = []; const errors = []; @@ -83,9 +83,11 @@ test('interactive demo renders host, guest and setup with no API traffic', async await page.getByRole('heading', { name: 'Your sample song', exact: true }).waitFor(); assert.equal(await page.getByRole('article').count(), 2); assert.equal(await page.getByText('Added to host’s Spotify queue', { exact: true }).count(), 0); - await page.getByRole('button', { name: 'Host setup', exact: true }).click(); - await page.getByLabel('Active Spotify device', { exact: true }).selectOption('living-room'); - await page.getByRole('button', { name: 'Create demo party', exact: true }).click(); + await page.getByRole('button', { name: 'Start screen', exact: true }).click(); + assert.equal(await page.getByRole('heading', { name: 'Host setup', exact: true }).count(), 0); + assert.equal(await page.getByLabel('Active Spotify device', { exact: true }).count(), 0); + await page.getByRole('checkbox', { name: 'I have Spotify Premium and will host playback.' }).check(); + await page.getByRole('button', { name: 'Start party', exact: true }).click(); await page.getByRole('button', { name: 'End party', exact: true }).waitFor(); page.once('dialog', dialog => dialog.dismiss()); await page.getByRole('button', { name: 'End party', exact: true }).click(); @@ -208,7 +210,6 @@ test('recording confirmation and nameless submissions retain the Party CSRF cont status: 'open', mode: 'host_approval', expiresAt: new Date(Date.now() + 3600000).toISOString(), - deviceId: 'speaker', blockedReason: null, isHost: true, }; @@ -303,3 +304,123 @@ test('recording confirmation and nameless submissions retain the Party CSRF cont assert.deepEqual(failures, []); await page.close(); }); + +test('hosts go from authorization directly to a room, resume safely, and can retry a failed creation', async () => { + for (const scenario of ['new-host', 'authorization-complete', 'already-connected', 'reconnect', 'creation-failure', 'authorization-failure']) { + const page = await previewPage({ viewport: { width: 390, height: 844 } }); + try { + const roomFixture = { id: 'start-room', status: 'open', mode: 'auto', expiresAt: new Date(Date.now() + 3600000).toISOString(), blockedReason: null, isHost: true }; + let connected = ['authorization-complete', 'already-connected', 'reconnect', 'creation-failure'].includes(scenario); + let room = scenario === 'reconnect' ? { ...roomFixture, blockedReason: 'unauthorized' } : null; + let auth = scenario === 'authorization-complete' ? 'complete' : 'idle'; + let creations = 0; + let authorizations = 0; + const unexpected = []; + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('https://telegram.org/js/telegram-web-app.js', route => route.fulfill({ + contentType: 'text/javascript', + body: "window.Telegram={WebApp:{initData:'browser-fixture-not-valid-auth',ready(){},expand(){},openLink(url){window.openedOAuth=url},BackButton:{show(){},hide(){},onClick(){},offClick(){}},onEvent(){},offEvent(){}}};", + })); + await page.route('**/api/party/**', async route => { + const http = route.request(); + const path = new URL(http.url()).pathname; + let status = 200; + let data; + if (http.method() === 'POST') assert.equal(http.headers()['x-party-csrf'], 'start-csrf'); + if (path.endsWith('/config')) data = { enabled: true, autoEnabled: true, telegramUrl: null }; + else if (path.endsWith('/session')) data = { csrfToken: 'start-csrf', hostConnected: connected, room }; + else if (path.endsWith('/auth/status')) data = { status: auth, ...(auth === 'failed' ? { error: 'authorization_cancelled' } : {}) }; + else if (path.endsWith('/auth/start')) { + assert.deepEqual(http.postDataJSON(), { premiumConfirmed: true }); + authorizations++; + auth = scenario === 'authorization-failure' ? 'failed' : 'complete'; + connected = auth === 'complete'; + if (room) room.blockedReason = null; + data = { authorizationUrl: 'https://example.invalid/test-oauth' }; + } else if (path.endsWith('/rooms')) { + assert.deepEqual(http.postDataJSON(), {}); + creations++; + room = roomFixture; + if (scenario === 'creation-failure' && creations === 1) { + status = 503; + data = { error: { code: 'party_unavailable', message: 'Temporary creation failure' } }; + } else data = { room, inviteUrl: 'https://example.invalid/invite' }; + } else if (path.endsWith('/invite')) data = { inviteUrl: 'https://example.invalid/invite' }; + else if (path.endsWith('/requests')) data = { room, requests: [], nextCursor: '1' }; + else { + unexpected.push(path); + status = 500; + data = { error: { code: 'unexpected', message: path } }; + } + await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) }); + }); + await page.goto(`${base}/app/?section=party`); + if (scenario === 'new-host' || scenario === 'authorization-failure') { + await page.getByRole('checkbox', { name: 'I have Spotify Premium and will host playback.' }).check(); + await page.getByRole('button', { name: 'Start party', exact: true }).click(); + } else if (scenario === 'reconnect') { + await page.getByRole('button', { name: 'Reconnect Spotify', exact: true }).click(); + } else if (scenario === 'creation-failure') { + await page.getByRole('alert').getByText('Temporary creation failure', { exact: true }).waitFor(); + await page.waitForTimeout(3200); + assert.equal(creations, 1, 'room creation failure must not become an automatic mutation retry loop'); + await page.getByRole('button', { name: 'Start party', exact: true }).click(); + } + if (scenario === 'authorization-failure') { + await page.getByRole('alert').getByText('authorization cancelled', { exact: true }).waitFor(); + assert.equal(creations, 0); + } else { + await page.getByRole('button', { name: 'End party', exact: true }).waitFor(); + assert.equal(creations, scenario === 'reconnect' ? 0 : scenario === 'creation-failure' ? 2 : 1, scenario); + await page.reload(); + await page.getByRole('button', { name: 'End party', exact: true }).waitFor(); + assert.equal(creations, scenario === 'reconnect' ? 0 : scenario === 'creation-failure' ? 2 : 1, 'reloading an active party must not recreate it'); + } + assert.equal(authorizations, ['new-host', 'reconnect', 'authorization-failure'].includes(scenario) ? 1 : 0); + assert.equal(await page.getByRole('heading', { name: 'Host setup', exact: true }).count(), 0); + assert.equal(await page.getByRole('combobox').count(), 0); + assert.deepEqual(unexpected, [], 'no device endpoint or other unexpected request'); + assert.deepEqual(errors, []); + } finally { await page.close(); } + } +}); + +test('playback-unavailable recovery is an explicit host action without device selection', async () => { + const page = await previewPage(); + try { + const room = { id: 'recovery-room', status: 'open', mode: 'auto', expiresAt: new Date(Date.now() + 3600000).toISOString(), blockedReason: 'device_unavailable', isHost: true }; + let actions = 0; + const unexpected = []; + await page.route('https://telegram.org/js/telegram-web-app.js', route => route.fulfill({ + contentType: 'text/javascript', + body: "window.Telegram={WebApp:{initData:'browser-fixture-not-valid-auth',ready(){},expand(){},BackButton:{show(){},hide(){},onClick(){},offClick(){}},onEvent(){},offEvent(){}}};", + })); + await page.route('**/api/party/**', async route => { + const http = route.request(); + const path = new URL(http.url()).pathname; + let data; + if (path.endsWith('/config')) data = { enabled: true, autoEnabled: true, telegramUrl: null }; + else if (path.endsWith('/session')) data = { csrfToken: 'retry-csrf', hostConnected: true, room }; + else if (path.endsWith('/auth/status')) data = { status: 'idle' }; + else if (path.endsWith('/invite')) data = { inviteUrl: 'https://example.invalid/invite' }; + else if (path.endsWith('/requests')) data = { room, requests: [], nextCursor: '1' }; + else if (path.endsWith('/action')) { + assert.equal(http.headers()['x-party-csrf'], 'retry-csrf'); + assert.deepEqual(http.postDataJSON(), { action: 'resume_playback' }); + actions++; + room.blockedReason = null; + data = { ok: true }; + } else { unexpected.push(path); data = {}; } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(data) }); + }); + await page.goto(`${base}/app/?section=party`); + await page.getByText(/start playing music, then tap Try again/).waitFor(); + assert.equal(actions, 0); + assert.equal(await page.getByRole('combobox').count(), 0); + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await page.getByRole('button', { name: 'Try again', exact: true }).waitFor({ state: 'hidden' }); + assert.equal(actions, 1); + assert.deepEqual(unexpected, []); + } finally { await page.close(); } +});