Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 13 additions & 8 deletions docs/party-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions projects/api/.env.party.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 0 additions & 12 deletions projects/api/src/party/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
5 changes: 2 additions & 3 deletions projects/api/src/party/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {
PARTY_PUBLIC_ORIGIN: 'https://party.test',
PARTY_TELEGRAM_BOT_USERNAME: 'test_party_bot',
Expand All @@ -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',
Expand All @@ -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));
Expand Down
1 change: 0 additions & 1 deletion projects/api/src/party/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 32 additions & 3 deletions projects/api/tests/party.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => ({
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion projects/mini-app/src/features/party/Party.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export default function Party({ config, initialSecret, onInviteConsumed }: {
<section className="party-card party-stack">
<h2>Bring everyone’s songs together</h2>
<p>Friends join, paste Spotify or Apple Music song links, and add songs straight to your Spotify queue.</p>
<p className="party-muted">Private pilot · Spotify hosts must be allowlisted. Party needs separate playback permission, not your Library login. Rooms expire after 12 hours.</p>
<p className="party-muted">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.</p>
{!session.hostConnected && <label className="party-check"><input type="checkbox" checked={premium} onChange={(event) => setPremium(event.target.checked)} />I have Spotify Premium and will host playback.</label>}
<button disabled={busy || (!premium && !session.hostConnected) || authStatus === 'pending'} onClick={() => void start()}>Start party</button>
{authStatus === 'pending' && <button className="party-secondary" onClick={() => setView('connecting')}>Authorization in progress</button>}
Expand Down
2 changes: 1 addition & 1 deletion projects/mini-app/src/features/party/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const messages: Record<string, string> = {
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.',
Expand Down
3 changes: 3 additions & 0 deletions projects/mini-app/tests/party-preview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
3 changes: 3 additions & 0 deletions tests/party.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Loading