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..7a58fc1 --- /dev/null +++ b/docs/party-queue.md @@ -0,0 +1,260 @@ +# 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. + +New parties automatically add exact/high-confidence matches. Guests paste a +song link and submit without entering a display name; the API stores a generic +`Guest` label, not a Telegram name. The room has no settings or approval panel. +Hosts share the invitation and can end the party (which deletes its credentials). +Uncertain recordings still require an explicit host version choice, which queues +the chosen version without a second approval. Recovery controls appear only +when playback needs attention. Existing approval-mode rooms retain their mode; +the API still accepts that mode for older clients. + +Start party authorizes Spotify when needed, then opens the room automatically. +There is no device-selection screen or separate create step. Queue commands use +the host's token and omit `device_id`, so Spotify targets that account's active +playback. Switching devices in Spotify intentionally moves subsequent additions +with it; Brewtify never transfers playback. This replaces the original +device-pinning behavior for all rooms, including existing ones. + +## 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), and active-playback queue access verified with that app. + Party now requests only `user-modify-playback-state`; device discovery and its + read-playback scope are no longer used. Existing grants with the extra read + scope remain compatible until revoked or reauthorized. + 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. Starting a party 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 +``` + +The later `20260923093000_party_active_playback` migration drops the retired +Party device column and normalizes legacy device-blocked states. Keep Party +disabled and drain/pause old Party workers while applying this migration and +deploying the matching API revision; old device-pinning code is incompatible +with the removed column. Library data is unchanged. + +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. +Auto-add is now the default; `PARTY_AUTO_ENABLED` is retired. Verify abuse +controls as part of the `PARTY_ENABLED` release gate rather than a second opt-in. + +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. Connected credentials +not yet attached to a room expire after 30 minutes. Active rooms have a fixed +12-hour TTL. Creating a room again for its verified owner returns the existing +room and invitation without extending the TTL or duplicating the room. + +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. No device ID is discovered, +stored or sent by Party; Spotify resolves active playback. 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. + +An explicit queue 404 is a known rejection, not an uncertain delivery. It pauses +the room and asks the host to start music in Spotify and tap **Try again**. +That CSRF-protected owner action resumes only playback-unavailable failures +and pending jobs; it cannot bypass an unknown-delivery block. No automatic +repeat occurs while playback is unavailable, and no test track is queued. + +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. Add `&demo=host` to `/app/?section=party` to use the interactive, +sample-data Host/Guest/Start screens; these reuse the real room components with +a browser-local transport that never calls Party APIs or providers. Production +builds exclude this demo. 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 slim sample flow, nameless submission/CSRF contract, direct start after +OAuth, reload/reconnect, failed-creation recovery and explicit playback retry +using browser fixtures, not real provider authentication. +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, updated 2026-09-23) + +The isolated worktree passed all five workspace builds, 40 provider/transport +tests, 15 catalog/Telegram tests, 16 native PostgreSQL/HTTP integration scenarios, +17 frontend tests, seven headless Chrome browser checks, targeted frontend lint, +and Terraform initialization/validation without applying infrastructure. +The PostgreSQL suite runs all four 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..d227c95 --- /dev/null +++ b/projects/api/.env.party.example @@ -0,0 +1,17 @@ +# Disabled by default; no production values or credentials belong in this file. +PARTY_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/20260923090000_party_auto_default/migration.sql b/projects/api/prisma/migrations/20260923090000_party_auto_default/migration.sql new file mode 100644 index 0000000..52731ae --- /dev/null +++ b/projects/api/prisma/migrations/20260923090000_party_auto_default/migration.sql @@ -0,0 +1 @@ +ALTER TABLE party_rooms ALTER COLUMN mode SET DEFAULT 'auto'; diff --git a/projects/api/prisma/migrations/20260923093000_party_active_playback/migration.sql b/projects/api/prisma/migrations/20260923093000_party_active_playback/migration.sql new file mode 100644 index 0000000..2a46072 --- /dev/null +++ b/projects/api/prisma/migrations/20260923093000_party_active_playback/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE party_rooms DROP COLUMN device_id; +UPDATE party_rooms SET blocked_reason='device_unavailable' +WHERE blocked_reason IN ('device_confirmation_required', 'device_changed', 'device_restricted'); +UPDATE party_requests SET failure_code='device_unavailable' +WHERE failure_code IN ('device_confirmation_required', 'device_changed', 'device_restricted'); 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..f1c0d0c 100644 --- a/projects/api/prisma/schema.prisma +++ b/projects/api/prisma/schema.prisma @@ -68,3 +68,150 @@ 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 + status String @default("open") + mode String @default("auto") + 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..afb1c58 --- /dev/null +++ b/projects/api/src/party/auth.ts @@ -0,0 +1,413 @@ +import { randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; +import type { PoolClient } from 'pg'; +import { PARTY_SPOTIFY_SCOPES, 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) + ); + if (!PARTY_SPOTIFY_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..8b88dcf --- /dev/null +++ b/projects/api/src/party/jobs.ts @@ -0,0 +1,494 @@ +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, + 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' && + (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); + 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); + } 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 && + code === 'host_reconnect') || + (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..c392522 --- /dev/null +++ b/projects/api/src/party/rooms.ts @@ -0,0 +1,591 @@ +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 { hostFor } 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; + 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(), + 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 createRoom( + who: MiniSession, + mode: PartyMode = 'auto' +): 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); + 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) { + requireOwner(existing, who); + return { room: roomDto(existing, who), inviteUrl: await invite(existing, who) }; + } + const join = secret(); + const salt = generateSalt(); + const [room] = await rows( + `INSERT INTO party_rooms (id,owner,account_key,join_hash,encrypted_join,salt,mode,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, + mode, + ], + 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: { 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); + 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.' + ); + await tx.query('UPDATE party_rooms SET mode=$2 WHERE id=$1', [ + id, + options.mode, + ]); + } else if (action === 'resume_playback') { + if (fresh.blocked_reason !== 'device_unavailable') + throw new PartyError( + 409, + 'invalid_transition', + 'Only playback-unavailable failures can be resumed here.' + ); + await tx.query( + 'UPDATE party_rooms SET blocked_reason=NULL WHERE id=$1', + [id] + ); + const rejected = await rows<{ id: string }>( + `UPDATE party_requests SET status='approved',failure_code=NULL + WHERE room_id=$1 AND status='failed' AND failure_code='device_unavailable' RETURNING id`, + [id], + tx + ); + for (const request of rejected) await createJob(tx, id, request.id, 'deliver'); + } 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.' + ); + const status = fresh.mode === 'auto' ? 'approved' : 'matched'; + await tx.query( + `UPDATE party_requests SET selected=$2,status=$3, + approved_order=CASE WHEN $3='approved' THEN nextval('party_approval_order') ELSE NULL END WHERE id=$1`, + [requestId, candidate.metadata, status] + ); + if (status === 'approved') await createJob(tx, roomId, requestId, 'deliver'); + 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..64ada67 --- /dev/null +++ b/projects/api/src/party/routes.ts @@ -0,0 +1,299 @@ +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, + 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(), + }); +}); +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.post('/rooms', async (req, res) => { + const mode: unknown = req.body?.mode ?? 'auto'; + if (mode !== 'auto' && mode !== 'host_approval') + throw new PartyError( + 400, + 'invalid_mode', + 'Invalid party mode.' + ); + res.status(201).json(await createRoom(who(res), mode)); +}); +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'), + req.body?.displayName === undefined ? 'Guest' : 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), { + 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..0af960d --- /dev/null +++ b/projects/api/tests/party.integration.test.mjs @@ -0,0 +1,931 @@ +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; +const trackId = '0123456789ABCDEFGHIJKL'; +const scopes = ['user-modify-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 () => { assert.fail('Party must not discover or select devices'); }); + mock.method(SpotifyClient.prototype, 'track', async () => track); + mock.method(SpotifyClient.prototype, 'enqueue', async (_token, _track, deviceId) => { + assert.equal(deviceId, undefined, 'Spotify targets the host account active playback'); + 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, { + 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, + 4 + ); +}); +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, {})).data.room.id, + room.id, + 'creation retries must resume the existing host room' + ); + 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'); + const { accessToken, hostFor } = require('../dist/party/auth.js'); + await store.rows( + "UPDATE party_host_sessions SET token_expires_at=now()-interval '1 second'" + ); + const [owner] = await store.rows('SELECT principal,account_key FROM party_host_sessions'); + const readToken = () => store.hostLock(owner.account_key, async client => accessToken(await hostFor(owner.principal, client), client)); + assert.equal(await readToken(), 'rotated'); + assert.equal(await readToken(), 'rotated'); + 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('deviceId' in guestFeed.room, false); +}); +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; unavailable playback requires explicit resume without device discovery', 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', + }); + queueFailure = new SpotifyError('device_unavailable', 404); + const before = queueCalls; + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 1); + assert.equal( + (await call(`/rooms/${room.id}/requests`, host)).data.room.blockedReason, + 'device_unavailable' + ); + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 1); + assert.equal((await call(`/rooms/${room.id}/action`, member, { action: 'resume_playback' })).response.status, 403); + queueFailure = undefined; + assert.equal((await call(`/rooms/${room.id}/action`, host, { action: 'resume_playback' })).response.status, 200); + await runPending(id, 'deliver'); + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 2); + assert.equal(SpotifyClient.prototype.devices.mock.callCount(), 0); + 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'); + assert.equal((await call(`/rooms/${room.id}/action`, host, { action: 'resume_playback' })).response.status, 409); + 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 still requires fresh recording confirmation after metadata drift', 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', + }); + 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', + }); + } +}); +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); +}); + +test('new parties auto-add unnamed guest submissions and selected versions without approval', async () => { + process.env.PARTY_ENABLED = 'true'; + SpotifyClient.prototype.enqueue.mock.mockImplementation(async () => { queueCalls++; }); + await authorize(host); + const created = await call('/rooms', host, {}); + assert.equal(created.response.status, 201, JSON.stringify(created.data)); + const active = created.data.room; + assert.equal(active.mode, 'auto'); + assert.equal('deviceId' in active, false); + assert.equal(SpotifyClient.prototype.devices.mock.callCount(), 0); + const resumed = await call('/rooms', host, {}); + assert.equal(resumed.data.room.id, active.id); + assert.equal(resumed.data.inviteUrl, created.data.inviteUrl); + assert.equal((await store.rows("SELECT id FROM party_rooms WHERE status='open'")).length, 1); + assert.equal((await call('/config')).data.autoEnabled, true); + const secret = new URL(created.data.inviteUrl).searchParams.get('startapp').slice(2); + await call('/join', member, { secret }); + const body = { url: `https://open.spotify.com/track/${trackId}`, submissionKey: randomUUID() }; + const first = await call(`/rooms/${active.id}/requests`, member, body); + assert.equal(first.response.status, 202, JSON.stringify(first.data)); + assert.equal((await call(`/rooms/${active.id}/requests`, member, body)).data.id, first.data.id); + const before = queueCalls; + await runPending(first.data.id, 'resolve'); + const [resolved] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [first.data.id]); + assert.equal(resolved.display_name, 'Guest'); + assert.equal(resolved.status, 'approved'); + await runPending(first.data.id, 'deliver'); + await runPending(first.data.id, 'deliver'); + assert.equal(queueCalls, before + 1); + const receipt = (await call(`/rooms/${active.id}/requests`, member)).data.requests[0]; + assert.equal(receipt.status, 'added'); + + // Seed an ambiguous resolver result to exercise the version-choice transition. + const second = await call(`/rooms/${active.id}/requests`, member, { ...body, submissionKey: randomUUID() }); + await store.rows("UPDATE party_requests SET status='needs_review',confidence='ambiguous' WHERE id=$1", [second.data.id]); + await store.rows('INSERT INTO party_match_candidates(request_id,track_id,metadata) VALUES($1,$2,$3)', [second.data.id, trackId, resolved.selected]); + assert.equal((await call(`/rooms/${active.id}/requests/${second.data.id}/action`, member, { action: 'select', candidateId: trackId })).response.status, 403); + assert.equal((await call(`/rooms/${active.id}/requests/${second.data.id}/action`, host, { action: 'select', candidateId: 'invalid' })).response.status, 400); + assert.equal(queueCalls, before + 1, 'ambiguous songs must not silently enqueue'); + const selected = await call(`/rooms/${active.id}/requests/${second.data.id}/action`, host, { action: 'select', candidateId: trackId }); + assert.equal(selected.response.status, 200, JSON.stringify(selected.data)); + await runPending(second.data.id, 'deliver'); + assert.equal(queueCalls, before + 2, 'choosing a version does not require a second approval'); + await call(`/rooms/${active.id}/action`, host, { action: 'close' }); +}); diff --git a/projects/mini-app/README.md b/projects/mini-app/README.md index 7dbf7eb..dbf9d0c 100644 --- a/projects/mini-app/README.md +++ b/projects/mini-app/README.md @@ -1,5 +1,79 @@ # 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. + +Start party starts Spotify consent when needed, 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. After consent, the room opens automatically; there is no device picker +or separate create step. Reopening resumes unfinished creation or the existing +room. Room creation is idempotent for the same verified host; failures have an +explicit Start party retry rather than a background mutation-retry loop. +Queue commands omit `device_id` and follow the host's active Spotify playback. +An explicit no-active-playback rejection shows a host-only Try again action +after starting music in Spotify. 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, active-playback targeting, 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: @@ -71,3 +145,28 @@ export default defineConfig([ }, ]) ``` +# Interactive Party demo + +With `VITE_PARTY_PREVIEW=true` on the Vite development server, open +`/app/?section=party&demo=host` to review the actual slim host room and song +components with sample songs. The preview toolbar switches to Guest view and +Start screen (`demo=start`; old `demo=setup` links also open this screen). +The sample Start party button opens the host room directly, simulating consent +without a device picker. Guests only paste a song link and choose Add song; the demo shows +it added immediately with no approval or display-name form. There is no room +settings panel. Start party and End party update local sample state. +Reset samples restores the example songs. + +The host room uses an accessible end-party icon. Both host and guest rooms omit +the Party title and welcome card, and use the same compact song rows containing +artwork, title, artist and album, without success labels, provider links or +descriptive copy. Pending, failed and ambiguous songs retain the information and +controls needed to avoid silent failures or selecting the wrong recording. +Guests still see only their own songs and never receive host controls. + +This is a clearly labeled browser-only demo, not a Telegram session. It never +calls Party APIs, authorizes a provider or queues music. QR links use +`example.invalid` and are not invitations. Both the development flag and `demo` +query parameter are required; the demo is excluded from production builds even +if the preview flag is set during a build. The normal preview without `demo` +still shows the real Open in Telegram boundary. diff --git a/projects/mini-app/dev/PartyPreview.tsx b/projects/mini-app/dev/PartyPreview.tsx new file mode 100644 index 0000000..d9d6d67 --- /dev/null +++ b/projects/mini-app/dev/PartyPreview.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Room } from '../src/features/party/Room'; +import { PartyDemoClient } from './party-demo'; + +type View = 'host' | 'guest' | 'start'; + +function DemoRoom({ isHost, onReconnect }: { isHost: boolean; onReconnect: () => void }) { + const [client] = useState(() => new PartyDemoClient(isHost)); + const [room, setRoom] = useState(client.room); + return ; +} + +export default function PartyPreview() { + const initial = new URLSearchParams(window.location.search).get('demo'); + const [view, setView] = useState(initial === 'guest' ? 'guest' : initial === 'start' || initial === 'setup' ? 'start' : 'host'); + const [reset, setReset] = useState(0); + const [premium, setPremium] = useState(false); + + function show(next: View) { + setView(next); + setReset(value => value + 1); + const url = new URL(window.location.href); + url.searchParams.set('demo', next); + window.history.replaceState(null, '', url); + } + + return ( +
+
+ + {view === 'start' ? ( +
+

Start a party

+ + +
+ ) : show('start')} />} +
+
+ ); +} diff --git a/projects/mini-app/dev/party-demo.ts b/projects/mini-app/dev/party-demo.ts new file mode 100644 index 0000000..950a0fb --- /dev/null +++ b/projects/mini-app/dev/party-demo.ts @@ -0,0 +1,87 @@ +import type { PartyCandidate, PartyRequestDto, PartyRoomDto } from '@brewtify/shared'; +import { PartyClient, PartyError } from '../src/features/party/api.ts'; + +function artwork(color: string, accent: string): string { + return `data:image/svg+xml,${encodeURIComponent(``)}`; +} + +const tracks: PartyCandidate[] = [ + { id: 'demo-afterglow', title: 'Afterglow', artist: 'Northern Lines', album: 'Blue Hour', durationMs: 218000, explicit: false, url: 'https://example.invalid/demo/afterglow', artwork: artwork('#25345d', '#e59b81'), evidence: ['Same recording', 'Matching duration and version'] }, + { id: 'demo-night-drive', title: 'Night Drive', artist: 'The Satellites', album: 'City Lights', durationMs: 192000, explicit: false, url: 'https://example.invalid/demo/night-drive', artwork: artwork('#443352', '#b476c7'), evidence: ['Title and artist match', 'Host version selection required'] }, + { id: 'demo-night-drive-live', title: 'Night Drive - Live', artist: 'The Satellites', album: 'Live at the Observatory', durationMs: 238000, explicit: false, url: 'https://example.invalid/demo/night-drive-live', artwork: artwork('#443352', '#b476c7'), evidence: ['Live version', 'Longer recording'] }, + { id: 'demo-sunrise', title: 'Sunrise Again', artist: 'Sunday Club', album: 'Slow Mornings', durationMs: 204000, explicit: false, url: 'https://example.invalid/demo/sunrise', artwork: artwork('#765035', '#f6cf82'), evidence: ['Exact recording'] }, +]; + +function sampleRequests(): PartyRequestDto[] { + const date = new Date().toISOString(); + return [ + { id: 'demo-request-1', displayName: 'Guest', sourceUrl: tracks[0].url, selected: tracks[0], candidates: [], confidence: 'exact', status: 'added', failureCode: null, createdAt: date, updatedAt: date }, + { id: 'demo-request-2', displayName: 'Guest', sourceUrl: 'https://music.apple.com/us/song/demo/123456789', source: tracks[1], selected: tracks[1], candidates: [], confidence: 'high', status: 'added', failureCode: null, createdAt: date, updatedAt: date }, + { id: 'demo-request-3', displayName: 'You', sourceUrl: tracks[3].url, selected: tracks[3], candidates: [], confidence: 'exact', status: 'added', failureCode: null, createdAt: date, updatedAt: date }, + ]; +} + +// This transport never calls fetch or delegates to the authenticated Party client. +export class PartyDemoClient extends PartyClient { + room: PartyRoomDto; + private requests = sampleRequests(); + private revision = 1; + + constructor(isHost: boolean) { + super(); + this.room = { + id: 'demo-room', status: 'open', mode: 'auto', + expiresAt: new Date(Date.now() + 12 * 3600_000).toISOString(), + blockedReason: null, isHost, + }; + } + + override async request(path: string, body?: unknown, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const route = path.split('?')[0]; + const input = body && typeof body === 'object' ? body as Record : {}; + let result: unknown; + if (route === '/rooms/demo-room/invite' && body === undefined) { + result = { inviteUrl: 'https://example.invalid/party-demo-not-a-real-invitation' }; + } else if (route === '/rooms/demo-room/requests' && body === undefined) { + result = { room: this.room, requests: this.room.isHost ? this.requests : this.requests.filter(request => request.displayName === 'You'), nextCursor: String(this.revision) }; + } else if (route === '/rooms/demo-room/requests' && body !== undefined) { + if (this.room.status !== 'open') throw new PartyError('This demo party is not accepting songs.'); + if (typeof input.url !== 'string' || !/^https:\/\/(open\.spotify\.com|music\.apple\.com)\//.test(input.url)) { + throw new PartyError('Paste a Spotify or Apple Music song link. Demo mode never looks it up.'); + } + const id = crypto.randomUUID(); + this.requests.push({ + id, displayName: 'You', + sourceUrl: input.url, selected: { ...tracks[0], title: 'Your sample song', artist: 'Demo catalog - not looked up' }, + candidates: [], confidence: 'exact', status: 'added', failureCode: null, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + result = { id }; + } else if (route === '/rooms/demo-room/action' && body !== undefined && this.room.isHost) { + if (input.action === 'lock') this.room.status = 'locked'; + else if (input.action === 'unlock') this.room.status = 'open'; + else if (input.action === 'close') this.room.status = 'closed'; + else if (input.action === 'mode' && (input.mode === 'auto' || input.mode === 'host_approval')) this.room.mode = input.mode; + else throw new PartyError('That action is unavailable in this local demo.'); + result = { ok: true }; + } else if (/^\/rooms\/demo-room\/requests\/[^/]+\/action$/.test(route) && body !== undefined && this.room.isHost) { + const request = this.requests.find(item => item.id === route.split('/')[4]); + if (!request) throw new PartyError('Demo request not found.'); + if (input.action === 'approve' && request.selected) request.status = 'added'; + else if (input.action === 'reject') request.status = 'rejected'; + else if (input.action === 'select') { + const candidate = request.candidates.find(item => item.id === input.candidateId); + if (!candidate) throw new PartyError('Choose one of the sample versions.'); + request.selected = candidate; + request.status = 'added'; + } else throw new PartyError('That action is unavailable in this local demo.'); + request.updatedAt = new Date(Math.max(Date.now(), Date.parse(request.updatedAt) + 1)).toISOString(); + result = { ok: true }; + } else { + throw new PartyError('Demo mode does not call real Party APIs or authorize Spotify.'); + } + if (body !== undefined) this.revision++; + return structuredClone(result) as T; + } +} 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..9b3a266 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 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..18134ee 100644 --- a/projects/mini-app/src/App.tsx +++ b/projects/mini-app/src/App.tsx @@ -1,108 +1,72 @@ -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'; - -type Tab = 'playlists' | 'artists'; +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'; + +const Library = lazy(() => import('./features/library/Library')); +const Party = lazy(() => import('./features/party/Party')); +const PartyPreview = import.meta.env.DEV && import.meta.env.VITE_PARTY_PREVIEW === 'true' + ? lazy(() => import('../dev/PartyPreview')) + : null; +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); + if (PartyPreview && new URLSearchParams(window.location.search).has('demo')) { + return Loading demo…}>; + } + return ; +} + +function AppContent() { + 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 ( -