From e34d815bd0869dde665d756145151dc62d31df43 Mon Sep 17 00:00:00 2001 From: Amit Breuer Date: Wed, 23 Sep 2026 20:16:58 +0300 Subject: [PATCH] fix: remove Party host allowlist --- README.md | 2 ++ docs/party-queue.md | 21 ++++++----- projects/api/.env.party.example | 2 -- projects/api/src/party/auth.ts | 12 ------- projects/api/src/party/config.test.ts | 5 ++- projects/api/src/party/config.ts | 1 - projects/api/tests/party.integration.test.mjs | 35 +++++++++++++++++-- .../mini-app/src/features/party/Party.tsx | 2 +- .../mini-app/src/features/party/messages.ts | 2 +- .../mini-app/tests/party-preview.test.mjs | 3 ++ tests/party.browser.mjs | 3 ++ 11 files changed, 57 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 2998a0a..fefd691 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ delete and regenerate the lockfile or upgrade packages as a first response. 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. +There is no Brewtify host allowlist. Hosts need Spotify app access, playback +authorization and Premium; Spotify's Development Mode restrictions still apply. ## iTunes catalog evaluation CLI diff --git a/docs/party-queue.md b/docs/party-queue.md index c605dc4..120f6f8 100644 --- a/docs/party-queue.md +++ b/docs/party-queue.md @@ -39,9 +39,9 @@ files. Terraform is a reviewed provisioning artifact, **not an applied deploymen 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. +- Spotify app access for the intended hosts (check the actual app's current + Development Mode access restrictions and authorization allocation), 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 @@ -66,7 +66,10 @@ Required before real pilot traffic: 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. +or an authentication bypass. Brewtify has no host allowlist: when Party is enabled, +any account that can authorize the Spotify app can host after confirming Premium. +Spotify's provider-owned access restrictions still apply; removing Brewtify's list +does not expand the app's Spotify access or enable Party in production. ## Database migration @@ -115,7 +118,7 @@ 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) and the host allowlist through Secret Manager. Existing encryption +32-byte hex) through Secret Manager. No host-list configuration is required. Existing encryption and Spotify client configuration are reused; Library token rows are not. Apple team/key IDs, signing PEMs and developer tokens are no longer used and can be removed from Party configuration when retiring the old revision. @@ -156,6 +159,8 @@ 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. +Spotify profile identity and account locks still enforce one active room per account; +opening host access does not relax credential isolation, throttles or the 12-hour TTL. Party token refresh uses database serialization and preserves rotated refresh tokens. Explicit revocation deletes Party credentials, not Library tokens. @@ -173,7 +178,7 @@ 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. +and ingress-level abuse controls before increasing traffic. The maintenance job deletes expired rooms and all linked requests/candidates/jobs/ attempts/memberships in batches of 100, and expired sessions/auth flows/throttles/ @@ -216,7 +221,7 @@ quotes approximately 20 requests/minute, subject to change. Existing shared database submission throttles and durable jobs remain, but **no global iTunes quota or lookup cache is implemented**. Repeated lookups and different service instances can collectively exceed that estimate; do not claim quota compliance -or widen the private pilot without measuring traffic and addressing that limit. +or increase traffic without measuring it and addressing that limit. Each resolve attempt makes one lookup. Explicit 429s retain `Retry-After` in the durable job (60 seconds if absent/invalid), with `itunes_rate_limited` receipts; network/5xx/invalid-response errors follow bounded read retries. No in-process @@ -323,7 +328,7 @@ 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. +actual Spotify-authorized Premium 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 diff --git a/projects/api/.env.party.example b/projects/api/.env.party.example index 793a229..4cece9f 100644 --- a/projects/api/.env.party.example +++ b/projects/api/.env.party.example @@ -3,8 +3,6 @@ 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 song links use free iTunes Store lookup; no Apple credentials. diff --git a/projects/api/src/party/auth.ts b/projects/api/src/party/auth.ts index afb1c58..148bcba 100644 --- a/projects/api/src/party/auth.ts +++ b/projects/api/src/party/auth.ts @@ -281,18 +281,6 @@ export async function finishAuthorization( '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) => diff --git a/projects/api/src/party/config.test.ts b/projects/api/src/party/config.test.ts index 101c7c1..b786b2c 100644 --- a/projects/api/src/party/config.test.ts +++ b/projects/api/src/party/config.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { checkPrerequisites, PartyError } from './config'; -test('Party prerequisites require no Apple credentials but retain Spotify and infrastructure gates', t => { +test('Party prerequisites require no host list or Apple credentials but retain Spotify and infrastructure gates', t => { const values: Record = { PARTY_PUBLIC_ORIGIN: 'https://party.test', PARTY_TELEGRAM_BOT_USERNAME: 'test_party_bot', @@ -12,7 +12,6 @@ test('Party prerequisites require no Apple credentials but retain Spotify and in PARTY_IDENTITY_KEY: 'cd'.repeat(32), SPOTIFY_CLIENT_ID: 'test-client', PARTY_SPOTIFY_REDIRECT_URI: 'https://party.test/api/party/auth/callback', - PARTY_HOST_ALLOWLIST: 'host', PARTY_TASKS_PROJECT: 'test', PARTY_TASKS_LOCATION: 'test', PARTY_TASKS_QUEUE: 'test', @@ -32,7 +31,7 @@ test('Party prerequisites require no Apple credentials but retain Spotify and in }); } assert.doesNotThrow(checkPrerequisites); - for (const name of ['SPOTIFY_CLIENT_ID', 'PARTY_HOST_ALLOWLIST', 'PARTY_TASKS_QUEUE', 'PARTY_IDENTITY_KEY']) { + for (const name of ['SPOTIFY_CLIENT_ID', 'PARTY_TASKS_QUEUE', 'PARTY_IDENTITY_KEY']) { delete process.env[name]; assert.throws(checkPrerequisites, (error: unknown) => error instanceof PartyError && error.code === 'configuration_required' && error.message.includes(name)); diff --git a/projects/api/src/party/config.ts b/projects/api/src/party/config.ts index 8ce2e83..68fb14a 100644 --- a/projects/api/src/party/config.ts +++ b/projects/api/src/party/config.ts @@ -68,7 +68,6 @@ export function checkPrerequisites(): void { 'PARTY_IDENTITY_KEY', 'SPOTIFY_CLIENT_ID', 'PARTY_SPOTIFY_REDIRECT_URI', - 'PARTY_HOST_ALLOWLIST', 'PARTY_TASKS_PROJECT', 'PARTY_TASKS_LOCATION', 'PARTY_TASKS_QUEUE', diff --git a/projects/api/tests/party.integration.test.mjs b/projects/api/tests/party.integration.test.mjs index e2f7058..29b6c12 100644 --- a/projects/api/tests/party.integration.test.mjs +++ b/projects/api/tests/party.integration.test.mjs @@ -22,7 +22,6 @@ Object.assign(process.env, { TELEGRAM_BOT_TOKEN: '123456:test', SPOTIFY_CLIENT_ID: 'test-client', PARTY_SPOTIFY_REDIRECT_URI: `${origin}/api/party/auth/callback`, - PARTY_HOST_ALLOWLIST: 'test-spotify', PARTY_TASKS_PROJECT: 'test', PARTY_TASKS_LOCATION: 'test', PARTY_TASKS_QUEUE: 'test', @@ -105,7 +104,7 @@ before(async () => { { env: process.env } ); mock.method(SpotifyClient.prototype, 'profile', async () => ({ - id: 'test-spotify', + id: 'previously-unlisted-spotify', })); mock.method(SpotifyClient.prototype, 'exchange', async () => tokens); mock.method(SpotifyClient.prototype, 'refresh', async () => ({ @@ -351,8 +350,37 @@ test('OAuth browser state mismatch, cancellation, expiration and one-use tickets 400 ); }); -test('external browser PKCE returns to verified principal without shared cookies; callback replay denied', async () => { +test('OAuth still rejects missing playback permission and refresh credentials', async () => { + for (const [response, expected] of [ + [{ ...tokens, scopes: [] }, 'insufficient_scope'], + [{ ...tokens, refreshToken: undefined }, 'provider_response'], + ]) { + SpotifyClient.prototype.exchange.mock.mockImplementationOnce(async () => response); + const start = await call('/auth/start', outsider, { premiumConfirmed: true }); + assert.equal(start.response.status, 200); + const link = new URL(start.data.authorizationUrl); + const launched = await call(`${link.pathname.replace('/api/party', '')}${link.search}`); + assert.equal(launched.response.status, 302); + const state = new URL(launched.response.headers.get('location')).searchParams.get('state'); + const browserCookie = launched.response.headers.get('set-cookie').split(';')[0]; + const finish = await call(`/auth/callback?state=${state}&code=test`, null, undefined, { + Cookie: browserCookie, + }); + assert.equal(finish.data.error.code, expected); + assert.equal((await call('/auth/status', outsider)).data.status, 'failed'); + assert.equal((await call('/session', outsider)).data.hostConnected, false); + assert.equal((await store.rows('SELECT * FROM party_host_sessions')).length, 0); + } +}); +test('previously unlisted Spotify account completes browser PKCE and host setup without a host list; callback replay denied', async () => { const { state, browserCookie } = await authorize(host); + const { identity } = require('../dist/party/security.js'); + const [stored] = await store.rows('SELECT * FROM party_host_sessions'); + assert.equal(stored.account_key, identity('spotify:previously-unlisted-spotify')); + assert.deepEqual(stored.scopes, scopes); + assert.notEqual(stored.encrypted_access_token, tokens.accessToken); + assert.notEqual(stored.encrypted_refresh_token, tokens.refreshToken); + assert.equal((await call('/session', host)).data.hostConnected, true); assert.equal((await call('/auth/status', outsider)).data.status, 'failed'); assert.equal((await call('/session', outsider)).data.hostConnected, false); assert.equal( @@ -365,6 +393,7 @@ test('external browser PKCE returns to verified principal without shared cookies ); assert.equal(queueCalls, 0, 'setup must not enqueue a Premium probe'); ({ room, inviteUrl: invitation } = await makeRoom(host)); + assert.ok(new Date(room.expiresAt).getTime() <= Date.now() + 12 * 3600_000); assert.equal( (await call('/rooms', host, {})).data.room.id, room.id, diff --git a/projects/mini-app/src/features/party/Party.tsx b/projects/mini-app/src/features/party/Party.tsx index fe00331..dd52b04 100644 --- a/projects/mini-app/src/features/party/Party.tsx +++ b/projects/mini-app/src/features/party/Party.tsx @@ -172,7 +172,7 @@ export default function Party({ config, initialSecret, onInviteConsumed }: {

Bring everyone’s songs together

Friends join, paste Spotify or Apple Music song links, and add songs straight to your Spotify queue.

-

Private pilot · Spotify hosts must be allowlisted. Party needs separate playback permission, not your Library login. Rooms expire after 12 hours.

+

Host with a Spotify account authorized for this app. Spotify app access restrictions still apply. Party needs separate playback permission, not your Library login. Rooms expire after 12 hours.

{!session.hostConnected && } {authStatus === 'pending' && } diff --git a/projects/mini-app/src/features/party/messages.ts b/projects/mini-app/src/features/party/messages.ts index 7d0a47c..13832e1 100644 --- a/projects/mini-app/src/features/party/messages.ts +++ b/projects/mini-app/src/features/party/messages.ts @@ -9,7 +9,7 @@ const messages: Record = { insufficient_scope: 'Spotify playback permission is missing. The host needs to reconnect Party Spotify and grant playback access.', device_unavailable: 'Spotify has no available active playback. The host should open Spotify, start playing music, then tap Try again.', rate_limited: 'Spotify is rate-limiting requests. Approved requests are waiting for a safe retry.', - forbidden: 'Spotify denied playback access. Check the pilot allowlist and device restrictions; this does not necessarily mean Premium is missing.', + forbidden: 'Spotify denied playback access. Check Spotify app access and device restrictions; this does not necessarily mean Premium is missing.', itunes_rate_limited: 'iTunes Store is limiting song lookups. Matching will wait before retrying.', itunes_unavailable: 'iTunes Store lookup is temporarily unavailable. Try again later; this is not a confirmed no-match.', itunes_invalid_response: 'iTunes Store returned unexpected song metadata. The host can retry later or use a Spotify song link.', diff --git a/projects/mini-app/tests/party-preview.test.mjs b/projects/mini-app/tests/party-preview.test.mjs index 33999e3..3a5baf2 100644 --- a/projects/mini-app/tests/party-preview.test.mjs +++ b/projects/mini-app/tests/party-preview.test.mjs @@ -37,6 +37,9 @@ test('provider failures have actionable labels without telling guests to log in' assert.match(partyFailureMessage('unauthorized'), /reconnect Party Spotify/); assert.match(partyFailureMessage('premium_required'), /Premium/); assert.match(partyFailureMessage('insufficient_scope'), /grant playback access/); + assert.match(partyFailureMessage('forbidden'), /Spotify app access and device restrictions/); + assert.match(partyFailureMessage('forbidden'), /does not necessarily mean Premium is missing/); + assert.doesNotMatch(partyFailureMessage('forbidden'), /allowlist|pilot/i); assert.match(partyFailureMessage('device_unavailable'), /start playing music, then tap Try again/); assert.match(partyFailureMessage('delivery_settling'), /two-minute safety window/); assert.equal(partyFailureMessage('recording_changed'), 'Spotify recording details changed. Resolve the request again and approve the version before adding.'); diff --git a/tests/party.browser.mjs b/tests/party.browser.mjs index 9d78a2f..bb8f00b 100644 --- a/tests/party.browser.mjs +++ b/tests/party.browser.mjs @@ -417,6 +417,9 @@ test('hosts go from authorization directly to a room, resume safely, and can ret }); await page.goto(`${base}/app/?section=party`); if (scenario === 'new-host' || scenario === 'authorization-failure') { + await page.getByText('Host with a Spotify account authorized for this app.', { exact: false }).waitFor(); + assert.equal(await page.getByText(/allowlisted|private pilot/i).count(), 0); + assert.equal(await page.getByRole('button', { name: 'Start party', exact: true }).isDisabled(), true); await page.getByRole('checkbox', { name: 'I have Spotify Premium and will host playback.' }).check(); await page.getByRole('button', { name: 'Start party', exact: true }).click(); } else if (scenario === 'reconnect') {