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
31 changes: 27 additions & 4 deletions docs/party-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ Required before real pilot traffic:
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.
Start party opens OAuth when authorization is needed, without a Premium checkbox
or client declaration. Connected hosts reuse their authorization. Spotify still
enforces Premium for playback; only an explicit provider Premium error is labeled Premium.
- Free iTunes Store exact-ID lookup reachable from the service for supported
Apple Music song links. No Apple Developer membership, keys, tokens, Apple ID
or guest provider authorization is required. Coverage is not all Apple Music;
Expand All @@ -67,7 +68,7 @@ Required before real pilot traffic:

Missing configuration returns an actionable error; it is never a catalog no-match
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.
any account that can authorize the Spotify app can start a party.
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.

Expand Down Expand Up @@ -241,12 +242,34 @@ publish search results in the room feed.

Canonical Spotify track URLs, share query parameters, and localized `intl-*`
track paths normalize to the same track ID. Direct lookup and pre-delivery
revalidation both request `market=from_token`; search also uses that host market.
revalidation both request `market=from_token`. Search omits `market`, using the
country associated with the host's user token, as documented by
[Spotify search](https://developer.spotify.com/documentation/web-api/reference/search).
Read-only probes on 2026-09-23 confirmed search with `market=from_token` returned
403 "Insufficient client scope" with Party's playback-only grant, while the same
search without it returned 200 with positive playability. Direct track lookup
with `market=from_token` also returned 200. No additional scope is requested.
This requests positive playability evidence rather than assuming an omitted
`is_playable` means playable. Relinking, restrictions, unknown playability, and
recording drift still prevent delivery. A passing mocked flow is not live
Spotify acceptance; previously unavailable requests are not automatically retried.

Catalog 403 errors have separate codes (`catalog_insufficient_scope`,
`catalog_forbidden`), not misleading playback-permission instructions. A failed
catalog job does not block the room's playback or retry indefinitely.

Queue acceptance remains the documented
[204 response](https://developer.spotify.com/documentation/web-api/reference/add-to-queue).
The reported unknown deliveries could not be attributed to a specific provider
failure from the existing logs; an internal job HTTP 204 is not evidence of a
Spotify HTTP 204. Queue failures now log only operation, outcome, code, HTTP status,
phase, an allowlisted transport error code, and the uncertainty flag. No tokens,
URLs, song/user identifiers, headers, or provider bodies are logged. Timeouts,
5xx responses, redirects, and unexpected success responses remain uncertain,
never automatically repeated. These diagnostics require deployment and a separately
authorized user action to establish the remaining live queue failure; no diagnostic
queue commands or automatic retries of existing unknown requests were performed.

The host's **Invite friends** card starts collapsed. Click or keyboard-activate
its native disclosure to reveal the same QR code, selectable invitation URL,
and copy action. Guests and ended parties do not show invitation controls.
Expand Down
26 changes: 25 additions & 1 deletion projects/api/src/party/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ import {
type SongRequest,
} from './rooms';
import { hostLock, rows, transaction } from './store';
import { createLogger } from '../utils/logger';

const log = createLogger('party-jobs');

function logProviderFailure(error: unknown, operation: string, outcome?: string): void {
log.warn('Party provider operation failed', {
operation, outcome, code: errorCode(error),
status: error instanceof SpotifyError || error instanceof CatalogError ? error.status : undefined,
phase: error instanceof SpotifyError ? error.diagnostics.phase : undefined,
transportCode: error instanceof SpotifyError ? error.diagnostics.transportCode : undefined,
unknownDelivery: error instanceof SpotifyError ? error.unknownDelivery : undefined,
});
}

export interface Job {
id: string;
Expand Down Expand Up @@ -280,6 +293,7 @@ async function deliver(
await spotify().enqueue(token, request.selected.id);
} catch (error) {
const outcome = deliveryOutcome(error);
logProviderFailure(error, 'enqueue', outcome);
await transaction(async (tx) => {
const [recorded] = await rows(
`UPDATE party_delivery_attempts SET outcome=$2,updated_at=now()
Expand Down Expand Up @@ -377,7 +391,11 @@ export async function runJob(id: string, generation: number): Promise<void> {
[room.id],
client
);
if (sending) throw error;
if (sending) {
logProviderFailure(error, 'delivery_persistence');
throw error;
}
logProviderFailure(error, job.kind === 'resolve' ? 'resolve' : 'delivery_preflight');
const code = errorCode(error);
if (error instanceof PartyError && error.status === 410)
return done(client, job);
Expand All @@ -403,6 +421,12 @@ export async function runJob(id: string, generation: number): Promise<void> {
[request.id, code]
);
await done(tx, job);
} else if (code === 'catalog_insufficient_scope' || code === 'catalog_forbidden') {
await tx.query(
"UPDATE party_requests SET status='failed',failure_code=$2 WHERE id=$1",
[request.id, code]
);
await done(tx, job);
} else if (
(error instanceof PartyError &&
code === 'host_reconnect') ||
Expand Down
14 changes: 6 additions & 8 deletions projects/api/src/party/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ export const partyErrorHandler: ErrorRequestHandler = (
/^[A-Z0-9]{5}$/.test(String(error.code))
? String(error.code)
: undefined;
log.warn('Party operation failed', { code, status, storageCode });
log.warn('Party operation failed', {
code, status, storageCode,
phase: error instanceof SpotifyError ? error.diagnostics.phase : undefined,
transportCode: error instanceof SpotifyError ? error.diagnostics.transportCode : undefined,
});
res
.status(status)
.json({
Expand Down Expand Up @@ -194,13 +198,7 @@ function who(res: express.Response): MiniSession {
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.'
);
partyRoutes.post('/auth/start', async (_req, res) => {
await throttle(`auth:${who(res).principal}`, 5, 300);
res.json({ authorizationUrl: await startAuthorization(who(res)) });
});
Expand Down
104 changes: 94 additions & 10 deletions projects/api/tests/party.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async function guest(id) {
};
}
async function authorize(user) {
const start = await call('/auth/start', user, { premiumConfirmed: true });
const start = await call('/auth/start', user, {});
assert.equal(start.response.status, 200, JSON.stringify(start.data));
const link = new URL(start.data.authorizationUrl);
const launch = await call(
Expand Down Expand Up @@ -282,15 +282,16 @@ test('signed sessions reject raw IDs, origin forgery, tampering and launch repla
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'
(await call('/auth/start', host, {})).response.status,
200,
'Starting OAuth requires no Premium declaration'
);
assert.equal(
(await store.rows('SELECT * FROM users')).length,
Expand All @@ -307,7 +308,7 @@ test('signed sessions reject raw IDs, origin forgery, tampering and launch repla
);
});
test('OAuth browser state mismatch, cancellation, expiration and one-use tickets', async () => {
const start = await call('/auth/start', outsider, { premiumConfirmed: true });
const start = await call('/auth/start', outsider, {});
const link = new URL(start.data.authorizationUrl);
const route = `${link.pathname.replace('/api/party', '')}${link.search}`;
const launched = await call(route);
Expand Down Expand Up @@ -338,7 +339,7 @@ test('OAuth browser state mismatch, cancellation, expiration and one-use tickets
(await call('/auth/status', outsider)).data.error,
'authorization_cancelled'
);
const next = await call('/auth/start', outsider, { premiumConfirmed: true });
const next = await call('/auth/start', outsider, {});
const nextLink = new URL(next.data.authorizationUrl);
await store.rows(
"UPDATE party_authorizations SET expires_at=now()-interval '1 second' WHERE status='pending'"
Expand All @@ -358,7 +359,7 @@ test('OAuth still rejects missing playback permission and refresh credentials',
[{ ...tokens, refreshToken: undefined }, 'provider_response'],
]) {
SpotifyClient.prototype.exchange.mock.mockImplementationOnce(async () => response);
const start = await call('/auth/start', outsider, { premiumConfirmed: true });
const start = await call('/auth/start', outsider, {});
assert.equal(start.response.status, 200);
const link = new URL(start.data.authorizationUrl);
const launched = await call(`${link.pathname.replace('/api/party', '')}${link.search}`);
Expand Down Expand Up @@ -418,7 +419,7 @@ test('previously unlisted Spotify account completes browser PKCE and host setup
);
});
test('same Spotify account cannot be claimed by another Telegram principal', async () => {
const start = await call('/auth/start', member, { premiumConfirmed: true });
const start = await call('/auth/start', member, {});
const link = new URL(start.data.authorizationUrl);
const launched = await call(
`${link.pathname.replace('/api/party', '')}${link.search}`
Expand Down Expand Up @@ -1237,6 +1238,88 @@ test('read-only room search and explicit submitter selection preserve authorizat
assert.equal((await call(`${path}/selections`, member, selection)).data.error.code, 'room_expired');
});

test('catalog permission failures never masquerade as playback failures or block unrelated delivery', async t => {
await store.rows('DELETE FROM party_throttles');
await authorize(host);
const { room: active } = await makeRoom(host);
assert.equal((await call(`/rooms/${active.id}/action`, host, { action: 'mode', mode: 'auto' })).response.status, 200);
const before = queueCalls;
for (const kind of ['resolve', 'deliver']) {
for (const code of ['catalog_insufficient_scope', 'catalog_forbidden']) {
const id = await song(host, active.id);
if (kind === 'deliver') await runPending(id, 'resolve');
const failing = t.mock.method(SpotifyClient.prototype, 'track', async () => { throw new SpotifyError(code, 403); });
await runPending(id, kind);
await runPending(id, kind);
failing.mock.restore();
const [receipt] = await store.rows('SELECT status,failure_code FROM party_requests WHERE id=$1', [id]);
assert.deepEqual(receipt, { status: 'failed', failure_code: code });
assert.equal((await store.rows('SELECT blocked_reason FROM party_rooms WHERE id=$1', [active.id]))[0].blocked_reason, null);
assert.equal((await store.rows('SELECT id FROM party_delivery_attempts WHERE request_id=$1', [id])).length, 0);
}
}
assert.equal(queueCalls, before);
const good = await song(host, active.id);
await runPending(good, 'resolve');
await runPending(good, 'deliver');
assert.equal(queueCalls, before + 1);
await call(`/rooms/${active.id}/action`, host, { action: 'close' });
});

test('unexpected queue responses persist one unknown attempt and safe phase diagnostics without resending', async t => {
await store.rows('DELETE FROM party_throttles');
await authorize(host);
const originalFetch = globalThis.fetch;
t.mock.method(SpotifyClient.prototype, 'enqueue', spotifyEnqueue);
const logs = [];
t.mock.method(console, 'warn', line => logs.push(line));
for (const response of [
() => new Response(null, { status: 202 }),
() => new Response('private provider body', { status: 200 }),
() => { throw new TypeError('private token URL', { cause: Object.assign(new Error('private host'), { code: 'ECONNRESET' }) }); },
]) {
const { room: active } = await makeRoom(host);
assert.equal((await call(`/rooms/${active.id}/action`, host, { action: 'mode', mode: 'auto' })).response.status, 200);
const id = await song(host, active.id);
await runPending(id, 'resolve');
let writes = 0;
const mocked = t.mock.method(globalThis, 'fetch', async (input, options) => {
const url = new URL(String(input));
if (url.origin === base) return originalFetch(input, options);
assert.equal(url.origin, 'https://api.spotify.com');
assert.equal(url.pathname, '/v1/me/player/queue');
assert.equal(options.method, 'POST');
writes++;
return response();
});
await runPending(id, 'deliver');
await runPending(id, 'deliver');
assert.equal(writes, 1);
assert.deepEqual(await store.rows('SELECT outcome FROM party_delivery_attempts WHERE request_id=$1', [id]), [{ outcome: 'unknown' }]);
assert.deepEqual(await store.rows('SELECT status,failure_code FROM party_requests WHERE id=$1', [id]), [{ status: 'failed', failure_code: 'delivery_unknown' }]);
assert.equal((await store.rows('SELECT blocked_reason FROM party_rooms WHERE id=$1', [active.id]))[0].blocked_reason, 'delivery_unknown');
const retry = await call(`/rooms/${active.id}/requests/${id}/action`, host, { action: 'retry' });
assert.equal(retry.data.error.code, 'duplicate_risk_confirmation_required');
assert.equal(writes, 1);
await call(`/rooms/${active.id}/action`, host, { action: 'close' });
mocked.mock.restore();
// Closing disconnects the host; each new room needs a fresh authorization.
await store.rows('DELETE FROM party_throttles');
await authorize(host);
}
const diagnostics = logs.filter(line => line.includes('Party provider operation failed')).map(line => JSON.parse(line.slice(line.indexOf('{'))));
assert.deepEqual(diagnostics.map(item => item.phase), ['response_status', 'response_decode', 'request']);
assert.deepEqual(diagnostics.map(item => item.status), [202, 200, undefined]);
assert.equal(diagnostics[2].transportCode, 'ECONNRESET');
for (const item of diagnostics) {
assert.equal(item.operation, 'enqueue');
assert.equal(item.outcome, 'unknown');
assert.equal(item.unknownDelivery, true);
assert.ok(Object.keys(item).every(key => ['operation', 'outcome', 'code', 'status', 'phase', 'transportCode', 'unknownDelivery'].includes(key)));
}
assert.doesNotMatch(logs.join('\\n'), /private provider body|private token URL|private host|Bearer|encrypted_access_token/);
});

test('selected search result reaches one mocked 204 with host-market revalidation, failing closed on changed evidence', async t => {
await store.rows('DELETE FROM party_throttles');
await authorize(host);
Expand All @@ -1261,7 +1344,8 @@ test('selected search result reaches one mocked 204 with host-market revalidatio
}
assert.equal(url.origin, 'https://api.spotify.com');
if (url.pathname === '/v1/search') {
metadataMarkets.push(url.searchParams.get('market'));
if (url.searchParams.has('market'))
return Response.json({ error: { message: 'Insufficient client scope' } }, { status: 403 });
return Response.json({ tracks: { items: [track] } });
}
if (url.pathname === `/v1/tracks/${trackId}`) {
Expand Down Expand Up @@ -1305,7 +1389,7 @@ test('selected search result reaches one mocked 204 with host-market revalidatio
assert.deepEqual(attempts, failureCode ? [] : [{ outcome: 'accepted' }]);
assert.equal(sends, 1, 'only positive unchanged evidence sends, and a completed job never resends');
}
assert.equal(metadataMarkets.length, 12);
assert.equal(metadataMarkets.length, 6);
assert.ok(metadataMarkets.every(market => market === 'from_token'));
await call(`${route}/action`, host, { action: 'close' });
});
Expand Down
4 changes: 1 addition & 3 deletions projects/mini-app/dev/PartyPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export default function PartyPreview() {
const initial = new URLSearchParams(window.location.search).get('demo');
const [view, setView] = useState<View>(initial === 'guest' ? 'guest' : initial === 'start' || initial === 'setup' ? 'start' : 'host');
const [reset, setReset] = useState(0);
const [premium, setPremium] = useState(false);
const [scenario, setScenario] = useState<DemoScenario>('match');

function show(next: View) {
Expand Down Expand Up @@ -54,8 +53,7 @@ export default function PartyPreview() {
{view === 'start' ? (
<section className="party-card party-stack">
<h2>Start a party</h2>
<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={!premium} onClick={() => show('host')}>Start party</button>
<button onClick={() => show('host')}>Start party</button>
</section>
) : <DemoRoom key={`${view}-${reset}-${scenario}`} isHost={view === 'host'} scenario={scenario} onReconnect={() => show('start')} />}
</main>
Expand Down
Loading
Loading