diff --git a/docs/party-queue.md b/docs/party-queue.md index 6909853..e921824 100644 --- a/docs/party-queue.md +++ b/docs/party-queue.md @@ -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; @@ -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. @@ -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. diff --git a/projects/api/src/party/jobs.ts b/projects/api/src/party/jobs.ts index 8230589..65fb505 100644 --- a/projects/api/src/party/jobs.ts +++ b/projects/api/src/party/jobs.ts @@ -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; @@ -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() @@ -377,7 +391,11 @@ export async function runJob(id: string, generation: number): Promise { [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); @@ -403,6 +421,12 @@ export async function runJob(id: string, generation: number): Promise { [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') || diff --git a/projects/api/src/party/routes.ts b/projects/api/src/party/routes.ts index 3869d5f..0e419f3 100644 --- a/projects/api/src/party/routes.ts +++ b/projects/api/src/party/routes.ts @@ -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({ @@ -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)) }); }); diff --git a/projects/api/tests/party.integration.test.mjs b/projects/api/tests/party.integration.test.mjs index 289dbde..0788470 100644 --- a/projects/api/tests/party.integration.test.mjs +++ b/projects/api/tests/party.integration.test.mjs @@ -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( @@ -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, @@ -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); @@ -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'" @@ -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}`); @@ -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}` @@ -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); @@ -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}`) { @@ -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' }); }); diff --git a/projects/mini-app/dev/PartyPreview.tsx b/projects/mini-app/dev/PartyPreview.tsx index 4100067..9e2ce49 100644 --- a/projects/mini-app/dev/PartyPreview.tsx +++ b/projects/mini-app/dev/PartyPreview.tsx @@ -17,7 +17,6 @@ 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); const [scenario, setScenario] = useState('match'); function show(next: View) { @@ -54,8 +53,7 @@ export default function PartyPreview() { {view === 'start' ? (

Start a party

- - +
) : show('start')} />} diff --git a/projects/mini-app/src/features/party/Party.tsx b/projects/mini-app/src/features/party/Party.tsx index dd52b04..e1c5d44 100644 --- a/projects/mini-app/src/features/party/Party.tsx +++ b/projects/mini-app/src/features/party/Party.tsx @@ -18,7 +18,6 @@ export default function Party({ config, initialSecret, onInviteConsumed }: { const [view, setView] = useState<'loading' | 'landing' | 'connecting' | 'room'>('loading'); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); - const [premium, setPremium] = useState(false); const [authStatus, setAuthStatus] = useState('idle'); const [joinInput, setJoinInput] = useState(initialSecret ? `p_${initialSecret}` : ''); const [bootKey, setBootKey] = useState(0); @@ -112,14 +111,14 @@ export default function Party({ config, initialSecret, onInviteConsumed }: { } async function start(forceAuthorization = false) { - if (!session || (!premium && !session.hostConnected && !forceAuthorization)) return; + if (!session) return; await run(async () => { setView('connecting'); if (session?.hostConnected && !forceAuthorization) { await openHostRoom(session); return; } - const auth = await client.request<{ authorizationUrl: string }>('/auth/start', { premiumConfirmed: true }); + const auth = await client.request<{ authorizationUrl: string }>('/auth/start', {}); setAuthStatus('pending'); const webApp = telegram(); if (webApp?.openLink) webApp.openLink(auth.authorizationUrl); @@ -152,9 +151,7 @@ export default function Party({ config, initialSecret, onInviteConsumed }: {

Open Party in Telegram

-

Hosts and guests use the Brewtify Mini App in Telegram. Your Telegram launch is verified; guests do not need a Spotify or Apple Music login.

{telegramUrl ? Open in Telegram :

Open your Brewtify bot and send /party. Telegram launch configuration is currently unavailable.

} -

Browser-only participation is not supported. Library connection is separate.

); @@ -170,16 +167,12 @@ export default function Party({ config, initialSecret, onInviteConsumed }: { <> {session.room && }
-

Bring everyone’s songs together

-

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

-

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 && } - +

Start a party

+ {authStatus === 'pending' && }
{ event.preventDefault(); void join(); }}>

Join a party

-

No music account or subscription needed as a guest.

setJoinInput(event.target.value)} placeholder="https://t.me/…?startapp=p_…" required /> @@ -191,7 +184,7 @@ export default function Party({ config, initialSecret, onInviteConsumed }: {

Connecting Spotify

{authStatus === 'pending' ? ( <> -

Complete Spotify authorization in the browser, then return here. We’ll check for completion while this app is visible.

+

Connect Spotify in your browser, then return here.

) :

Opening your party…

} @@ -203,7 +196,6 @@ export default function Party({ config, initialSecret, onInviteConsumed }: { onReconnect={() => void start(true)} /> )} - {view !== 'room' &&
Songs go to Spotify’s queue, not a playlist. Leaving this tab does not end the party.
} ); } diff --git a/projects/mini-app/src/features/party/api.ts b/projects/mini-app/src/features/party/api.ts index b2c4d4c..354f7f2 100644 --- a/projects/mini-app/src/features/party/api.ts +++ b/projects/mini-app/src/features/party/api.ts @@ -73,7 +73,7 @@ export function errorText(error: unknown): string { } if (error instanceof PartyError && ( ['host_reconnect', 'unauthorized', 'premium_required', 'insufficient_scope', 'device_unavailable', 'delivery_settling'].includes(error.code) - || error.code.startsWith('itunes_') + || error.code.startsWith('itunes_') || error.code.startsWith('catalog_') )) { return partyFailureMessage(error.code); } diff --git a/projects/mini-app/src/features/party/messages.ts b/projects/mini-app/src/features/party/messages.ts index 13832e1..5137bc3 100644 --- a/projects/mini-app/src/features/party/messages.ts +++ b/projects/mini-app/src/features/party/messages.ts @@ -1,12 +1,14 @@ const messages: Record = { - delivery_unknown: 'A queue command may already have reached Spotify. Automatic additions are paused until the host acknowledges this uncertainty.', + delivery_unknown: 'Spotify may already have queued this song. Additions are paused; the host must review before continuing.', delivery_settling: 'A previous Spotify queue command is still settling. Wait for the two-minute safety window before acknowledging or retrying. The song may already be queued; retrying afterward can still create a duplicate.', recording_changed: 'Spotify recording details changed. Resolve the request again and approve the version before adding.', unauthorized: 'Spotify authorization is no longer valid. The host needs to reconnect Party Spotify.', - host_reconnect: 'The host needs to reconnect Spotify for Party. Library login remains separate.', + host_reconnect: 'The host needs to reconnect Spotify for Party.', reconnect_required: 'Reconnect Party Spotify to restore playback permission.', premium_required: 'Spotify confirmed Premium is required. Check the host subscription before continuing.', insufficient_scope: 'Spotify playback permission is missing. The host needs to reconnect Party Spotify and grant playback access.', + catalog_insufficient_scope: 'Spotify denied song lookup permission. Try a direct Spotify link; reconnecting playback may not fix this.', + catalog_forbidden: 'Spotify denied song lookup. Check the app’s Spotify access, or try a direct Spotify link.', 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 Spotify app access and device restrictions; this does not necessarily mean Premium is missing.', diff --git a/projects/mini-app/tests/party-preview.test.mjs b/projects/mini-app/tests/party-preview.test.mjs index 3a5baf2..5a29c00 100644 --- a/projects/mini-app/tests/party-preview.test.mjs +++ b/projects/mini-app/tests/party-preview.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { partyPreviewPlugins } from '../dev/party-preview.ts'; import { partyFailureMessage } from '../src/features/party/messages.ts'; import { PartyDemoClient } from '../dev/party-demo.ts'; +import { PartyError, errorText } from '../src/features/party/api.ts'; test('visual fixture is absent from production builds and disabled by default', () => { assert.deepEqual(partyPreviewPlugins('build', true), []); @@ -37,6 +38,11 @@ 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/); + for (const code of ['catalog_insufficient_scope', 'catalog_forbidden']) { + const message = errorText(new PartyError('Provider failed', code, 403)); + assert.match(message, /song lookup/); + assert.doesNotMatch(message, /playback permission is missing|grant playback access|Premium/i); + } 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); diff --git a/projects/mini-app/tests/party-start.test.mjs b/projects/mini-app/tests/party-start.test.mjs new file mode 100644 index 0000000..531e615 --- /dev/null +++ b/projects/mini-app/tests/party-start.test.mjs @@ -0,0 +1,137 @@ +import { before, after, afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createServer } from 'vite'; +import react from '@vitejs/plugin-react'; +import { act, createElement as h } from 'react'; +import QRCode from 'qrcode'; + +let vite, dom, createRoot, Party, PartyClient, PartyError, ToastProvider, root, container; +before(async () => { + dom = new JSDOM('', { url: 'http://localhost/app/', pretendToBeVisual: true }); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + ({ createRoot } = await import('react-dom/client')); + vite = await createServer({ + configFile: false, plugins: [react()], server: { middlewareMode: true, hmr: false }, appType: 'custom', + ssr: { external: ['@brewtify/shared'] }, optimizeDeps: { noDiscovery: true }, + cacheDir: 'node_modules/.vite-party-start-tests', + }); + ({ default: Party } = await vite.ssrLoadModule('/src/features/party/Party.tsx')); + ({ PartyClient, PartyError } = await vite.ssrLoadModule('/src/features/party/api.ts')); + ({ ToastProvider } = await vite.ssrLoadModule('/src/hooks/useToast.tsx')); +}); +afterEach(async () => { + if (root) await act(async () => root.unmount()); + root = undefined; + container?.remove(); +}); +after(async () => { + await vite?.close(); + dom?.window.close(); + delete globalThis.window; + delete globalThis.document; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; +}); + +async function mount(t, { connected = false, existing = null, authResult = 'complete', creationFails = false, invitation = null } = {}) { + const calls = [], opened = []; + const hostRoom = { id: 'room', isHost: true, status: 'open', mode: 'auto', blockedReason: null }; + let room = existing, auth = 'idle', creations = 0; + window.Telegram = { WebApp: { initData: 'signed-fixture', openLink: url => opened.push(url) } }; + t.mock.method(QRCode, 'toDataURL', async () => 'data:image/png;base64,test'); + t.mock.method(globalThis, 'fetch', () => assert.fail('No real network in UI tests')); + t.mock.method(PartyClient.prototype, 'restore', async () => ({ hostConnected: connected, room })); + t.mock.method(PartyClient.prototype, 'request', async (path, body) => { + calls.push({ path, body }); + if (path === '/auth/status') return { status: auth, ...(auth === 'failed' ? { error: 'authorization_cancelled' } : {}) }; + if (path === '/auth/start') { + assert.deepEqual(body, {}); + auth = authResult; + connected = auth === 'complete'; + if (room) room = { ...room, blockedReason: null }; + return { authorizationUrl: 'https://example.invalid/oauth' }; + } + if (path === '/session') return { hostConnected: connected, room }; + if (path === '/rooms') { + assert.deepEqual(body, {}); + creations++; + if (creationFails && creations === 1) throw new PartyError('Temporary creation failure', 'party_unavailable', 503); + room = hostRoom; + return { room }; + } + if (path === '/join') { + room = { ...hostRoom, isHost: false }; + return { room }; + } + if (path.endsWith('/invite')) return { inviteUrl: 'https://example.invalid/invite' }; + if (path.includes('/requests')) return { room, requests: [], nextCursor: null }; + assert.fail(`Unexpected path: ${path}`); + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render(h(ToastProvider, null, h(Party, { + config: { enabled: true, autoEnabled: true, telegramUrl: null }, + initialSecret: invitation, onInviteConsumed() {}, + })))); + return { calls, opened }; +} +const button = text => [...container.querySelectorAll('button')].find(element => element.textContent === text); + +test('Start party opens OAuth in one click with no Premium declaration, then opens the room', async t => { + const { calls, opened } = await mount(t); + assert.equal(container.querySelector('input[type=checkbox]'), null); + assert.doesNotMatch(container.textContent, /Premium|Rooms expire|Library login|playback permission/); + assert.equal(button('Start party').disabled, false); + await act(async () => button('Start party').click()); + assert.deepEqual(opened, ['https://example.invalid/oauth']); + assert.equal(calls.filter(call => call.path === '/auth/start').length, 1); + assert.equal(calls.filter(call => call.path === '/rooms').length, 1); + assert.ok(container.querySelector('[aria-label="End party"]')); +}); + +test('connected host creates a room without reauthorizing', async t => { + const { calls, opened } = await mount(t, { connected: true }); + assert.deepEqual(opened, []); + assert.equal(calls.filter(call => call.path === '/rooms').length, 1); + assert.equal(calls.filter(call => call.path === '/auth/start').length, 0); + assert.ok(container.querySelector('[aria-label="End party"]')); +}); + +test('reconnect reuses the existing room without a Premium checkbox', async t => { + const existing = { id: 'room', isHost: true, status: 'open', mode: 'auto', blockedReason: 'insufficient_scope' }; + const { calls, opened } = await mount(t, { connected: true, existing }); + await act(async () => button('Reconnect Spotify').click()); + assert.deepEqual(opened, ['https://example.invalid/oauth']); + assert.equal(calls.filter(call => call.path === '/rooms').length, 0); + assert.equal(container.querySelector('input[type=checkbox]'), null); +}); + +test('cancelled OAuth returns an actionable error and allows a new start', async t => { + const { calls } = await mount(t, { authResult: 'failed' }); + await act(async () => button('Start party').click()); + assert.match(container.querySelector('[role=alert]').textContent, /authorization cancelled/); + assert.equal(button('Start party').disabled, false); + assert.equal(calls.filter(call => call.path === '/rooms').length, 0); +}); + +test('room creation failure is not retried without another user click', async t => { + const { calls } = await mount(t, { connected: true, creationFails: true }); + assert.equal(container.querySelector('[role=alert]').textContent, 'Temporary creation failure'); + assert.equal(calls.filter(call => call.path === '/rooms').length, 1); + await act(async () => button('Start party').click()); + assert.equal(calls.filter(call => call.path === '/rooms').length, 2); + assert.equal(calls.filter(call => call.path === '/auth/start').length, 0); +}); + +test('invited guest joins without Spotify authorization or host consent', async t => { + const { calls, opened } = await mount(t, { invitation: 'test-invite' }); + assert.equal(calls.filter(call => call.path === '/join').length, 1); + assert.equal(calls.filter(call => call.path === '/auth/start').length, 0); + assert.deepEqual(opened, []); + assert.equal(container.querySelector('input[type=checkbox]'), null); + assert.match(container.textContent, /Your songs/); + assert.doesNotMatch(container.textContent, /Premium|Reconnect Spotify/); +}); diff --git a/projects/spotify/src/index.test.ts b/projects/spotify/src/index.test.ts index abc5e84..6520f23 100644 --- a/projects/spotify/src/index.test.ts +++ b/projects/spotify/src/index.test.ts @@ -167,7 +167,8 @@ test('deadline aborts a stalled queue request as unknown without repeating it', })); const pending = client().enqueue('token', ID, 'speaker'); const assertion = assert.rejects(pending, (error: unknown) => - error instanceof SpotifyError && error.code === 'network' && error.unknownDelivery); + error instanceof SpotifyError && error.code === 'network' && error.unknownDelivery + && error.diagnostics.phase === 'request' && error.diagnostics.transportCode === 'deadline_exceeded'); t.mock.timers.tick(PROVIDER_DEADLINE_MS); await assertion; }); @@ -252,7 +253,7 @@ test('minimal profile, devices, bounded search, market/relinking evidence preser t.mock.method(globalThis, 'fetch', async (input: Parameters[0], init?: RequestInit) => { const url = new URL(String(input)); assert.equal(url.searchParams.get('market'), - url.pathname.endsWith('/search') || url.pathname.includes('/tracks/') ? 'from_token' : null); + url.pathname.includes('/tracks/') ? 'from_token' : null); assert.equal((init?.headers as Record).Authorization, 'Bearer token'); if (url.pathname.endsWith('/search')) assert.equal(url.searchParams.get('limit'), '10'); return json(responses.shift()); @@ -266,7 +267,7 @@ test('minimal profile, devices, bounded search, market/relinking evidence preser assert.equal((await client().search('token', 'isrc:USABC1234567')).length, 1); }); -test('search and direct metadata request host-market playability rather than infer it', async t => { +test('search avoids from_token scope rejection while direct metadata retains host-market evidence', async t => { for (const recording of [ { ...track(), name: 'Tron', artists: [{ name: 'Foals' }], album: { name: 'Antidotes' }, duration_ms: 290840, external_ids: { isrc: 'GBVKZ0725315' } }, @@ -277,7 +278,9 @@ test('search and direct metadata request host-market playability rather than inf const mocked = t.mock.method(globalThis, 'fetch', async (input: Parameters[0]) => { const url = new URL(String(input)); calls.push(url); - if (url.pathname.endsWith('/search')) return json({ tracks: { items: [recording] } }); + if (url.pathname.endsWith('/search')) return url.searchParams.has('market') + ? json({ error: { message: 'Insufficient client scope' } }, 403) + : json({ tracks: { items: [recording] } }); // Without market, track metadata omits the positive playability evidence. return json({ ...recording, is_playable: url.searchParams.has('market') ? true : undefined }); }); @@ -290,11 +293,61 @@ test('search and direct metadata request host-market playability rather than inf url: `https://open.spotify.com/track/${found.id}`, evidence: ['playable'], })); assert.equal(calls.length, 2); - for (const url of calls) assert.equal(url.searchParams.get('market'), 'from_token'); + assert.equal(calls[0].searchParams.has('market'), false); + assert.equal(calls[1].searchParams.get('market'), 'from_token'); mocked.mock.restore(); } }); +test('catalog permission errors stay distinct from playback and Premium errors', async t => { + for (const [body, catalogCode, playbackCode] of [ + [{ error: { message: 'Insufficient client scope' } }, 'catalog_insufficient_scope', 'insufficient_scope'], + [{ error: { message: 'App not allowed' } }, 'catalog_forbidden', 'forbidden'], + [{ error: { reason: 'PREMIUM_REQUIRED' } }, 'premium_required', 'premium_required'], + ] as const) { + const mocked = t.mock.method(globalThis, 'fetch', async () => json(body, 403)); + await assert.rejects(client().search('token', 'Tron'), { code: catalogCode, status: 403 }); + await assert.rejects(client().track('token', ID), { code: catalogCode, status: 403 }); + await assert.rejects(client().enqueue('token', ID), { code: playbackCode, status: 403, unknownDelivery: false }); + mocked.mock.restore(); + } +}); + +test('queue diagnostics distinguish phases without exposing response bodies or transport messages', async t => { + const cases = [ + { response: () => new Response('not-json'), phase: 'response_decode', status: 200 }, + { response: () => new Response(null, { status: 202 }), phase: 'response_status', status: 202 }, + { response: () => new Response('large', { headers: { 'Content-Length': String(PROVIDER_BODY_LIMIT + 1) } }), phase: 'response_headers', status: 200 }, + { response: () => new Response(new ReadableStream({ start(c) { c.error(new Error('private response body')); } })), phase: 'response_body', status: 200 }, + { response: () => { throw new TypeError('private token URL', { cause: Object.assign(new Error('private hostname'), { code: 'ECONNRESET' }) }); }, phase: 'request', status: undefined, transportCode: 'ECONNRESET' }, + { response: () => { throw Object.assign(new Error('private message'), { code: 'PRIVATE_SECRET' }); }, phase: 'request', status: undefined }, + ]; + for (const fixture of cases) { + let calls = 0; + const mocked = t.mock.method(globalThis, 'fetch', async () => { calls++; return fixture.response(); }); + await assert.rejects(client().enqueue('token', ID), error => { + assert.ok(error instanceof SpotifyError); + assert.equal(error.unknownDelivery, true); + assert.equal(error.status, fixture.status); + assert.equal(error.diagnostics.phase, fixture.phase); + assert.equal(error.diagnostics.transportCode, fixture.transportCode); + assert.doesNotMatch(JSON.stringify(error), /private|PRIVATE_SECRET/); + return true; + }); + assert.equal(calls, 1); + mocked.mock.restore(); + } +}); + +test('local enqueue validation is definitely unsent, including invalid token headers', async t => { + const fetch = t.mock.method(globalThis, 'fetch', async () => { throw new Error('must not send'); }); + for (const token of ['', 'bad\r\ntoken']) { + await assert.rejects(client().enqueue(token, ID), error => + error instanceof SpotifyError && !error.unknownDelivery && error.diagnostics.phase === 'preflight'); + } + assert.equal(fetch.mock.callCount(), 0); +}); + test('host-market responses still reject unknown, restricted, local and relinked tracks', async t => { for (const variant of [ { is_playable: undefined }, { is_playable: false }, { restrictions: { reason: 'market' } }, diff --git a/projects/spotify/src/index.ts b/projects/spotify/src/index.ts index 4d7c845..d2148b6 100644 --- a/projects/spotify/src/index.ts +++ b/projects/spotify/src/index.ts @@ -65,6 +65,7 @@ export type SpotifyErrorCode = | 'configuration' | 'invalid_input' | 'network' | 'provider_unavailable' | 'rate_limited' | 'unauthorized' | 'invalid_grant' | 'forbidden' | 'premium_required' | 'insufficient_scope' | 'device_unavailable' + | 'catalog_insufficient_scope' | 'catalog_forbidden' | 'not_found' | 'redirect_rejected' | 'invalid_response' | 'provider_rejected' | 'track_unavailable' | 'recording_changed'; @@ -74,12 +75,18 @@ export class SpotifyError extends Error { public readonly status?: number, public readonly retryAfterSeconds?: number, public readonly unknownDelivery = false, + public readonly diagnostics: ProviderFailureDiagnostics = { phase: 'preflight' }, ) { super(`Spotify request failed: ${code}`); this.name = 'SpotifyError'; } } +export interface ProviderFailureDiagnostics { + phase: 'preflight' | 'request' | 'response_headers' | 'response_body' | 'response_decode' | 'response_status'; + transportCode?: string; +} + const BASES = { spotify: 'https://api.spotify.com/v1/', oauth: 'https://accounts.spotify.com/api/', @@ -90,11 +97,25 @@ export class ProviderTransportError extends Error { constructor( public readonly code: 'network' | 'redirect_rejected' | 'invalid_response', public readonly status?: number, + public readonly diagnostics: ProviderFailureDiagnostics = { phase: 'preflight' }, ) { super(`Provider transport failed: ${code}`); } } +function transportCode(error: unknown, aborted: boolean): string | undefined { + if (aborted) return 'deadline_exceeded'; + const allowed = new Set([ + 'EACCES', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', + 'UND_ERR_SOCKET', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + ]); + for (let depth = 0; depth < 3 && error instanceof Error; depth++, error = error.cause) { + if ('code' in error && typeof error.code === 'string' && allowed.has(error.code)) return error.code; + } + return undefined; +} + export interface ProviderResponse { status: number; body: unknown; @@ -129,23 +150,26 @@ export async function providerRequest( const timer = setTimeout(() => controller.abort(), PROVIDER_DEADLINE_MS); let status: number | undefined; let retryAfterSeconds: number | undefined; + let phase: ProviderFailureDiagnostics['phase'] = 'request'; try { const options: RequestInit & { dispatcher: typeof providerDispatcher } = { ...init, dispatcher: providerDispatcher, redirect: 'manual', signal: controller.signal, }; const response = await fetch(url, options); status = response.status; + phase = 'response_headers'; if (status >= 300 && status < 400) { await response.body?.cancel(); - throw new ProviderTransportError('redirect_rejected', status); + throw new ProviderTransportError('redirect_rejected', status, { phase }); } retryAfterSeconds = retryAfter(response.headers.get('retry-after')); if (status === 204) return { status, body: undefined, retryAfterSeconds }; const declaredSize = Number(response.headers.get('content-length')); if (declaredSize > PROVIDER_BODY_LIMIT) { await response.body?.cancel(); - throw new ProviderTransportError('invalid_response', status); + throw new ProviderTransportError('invalid_response', status, { phase }); } + phase = 'response_body'; const reader = response.body?.getReader(); const chunks: Uint8Array[] = []; let size = 0; @@ -157,7 +181,7 @@ export async function providerRequest( size += value.byteLength; if (size > PROVIDER_BODY_LIMIT) { await reader.cancel(); - throw new ProviderTransportError('invalid_response', status); + throw new ProviderTransportError('invalid_response', status, { phase }); } chunks.push(value); } @@ -166,13 +190,14 @@ export async function providerRequest( } } const text = Buffer.concat(chunks).toString('utf8'); + phase = 'response_decode'; let body: unknown; try { body = text ? JSON.parse(text) : undefined; } catch { // A definite rejection (notably 429) remains definite even without JSON. if (status >= 400) return { status, body: undefined, retryAfterSeconds }; - throw new ProviderTransportError('invalid_response', status); + throw new ProviderTransportError('invalid_response', status, { phase }); } return { status, body, retryAfterSeconds }; } catch (error) { @@ -182,7 +207,9 @@ export async function providerRequest( return { status, body: undefined, retryAfterSeconds }; } if (error instanceof ProviderTransportError) throw error; - throw new ProviderTransportError('network', status); + throw new ProviderTransportError('network', status, { + phase, transportCode: transportCode(error, controller.signal.aborted), + }); } finally { clearTimeout(timer); } @@ -350,7 +377,8 @@ export class SpotifyClient { async search(token: string, query: string): Promise { if (!query || query.length > 1000) throw new SpotifyError('invalid_input'); - const params = new URLSearchParams({ q: query, type: 'track', limit: '10', market: HOST_MARKET }); + // Search uses the user token's country; from_token causes scope errors here. + const params = new URLSearchParams({ q: query, type: 'track', limit: '10' }); const items = object(object(await this.read(`search?${params}`, token)).tracks).items; if (!Array.isArray(items) || items.length > 10) throw new SpotifyError('invalid_response'); return items.map(decodeTrack); @@ -383,33 +411,38 @@ export class SpotifyClient { response = await providerRequest(service, path, init); } catch (error) { if (error instanceof ProviderTransportError) { - throw new SpotifyError(error.code, error.status, undefined, write); + throw new SpotifyError(error.code, error.status, undefined, + write && error.diagnostics.phase !== 'preflight', error.diagnostics); } throw error; } const { status, body, retryAfterSeconds } = response; + const failure = (code: SpotifyErrorCode, unknownDelivery = false, retry?: number) => + new SpotifyError(code, status, retry, unknownDelivery, { phase: 'response_status' }); if (write && status === 204) return undefined; if (status >= 200 && status < 300) { - if (write) throw new SpotifyError('invalid_response', status, undefined, true); - if (body === undefined) throw new SpotifyError('invalid_response', status); + if (write) throw failure('invalid_response', true); + if (body === undefined) throw failure('invalid_response'); return body; } - if (status === 429) throw new SpotifyError('rate_limited', status, retryAfterSeconds ?? 60); - if (status >= 500) throw new SpotifyError('provider_unavailable', status, undefined, write); - if (status === 401) throw new SpotifyError('unauthorized', status); + if (status === 429) throw failure('rate_limited', false, retryAfterSeconds ?? 60); + if (status >= 500) throw failure('provider_unavailable', write); + if (status === 401) throw failure('unauthorized'); const error = object(body).error; - if (error === 'invalid_grant') throw new SpotifyError('invalid_grant', status); + if (error === 'invalid_grant') throw failure('invalid_grant'); const detail = object(error); const reason = typeof detail.reason === 'string' ? detail.reason : ''; const message = typeof detail.message === 'string' ? detail.message.toLowerCase() : ''; if (status === 403) { + const catalog = service === 'spotify' && (path.startsWith('search?') || path.startsWith('tracks/')); const code = reason === 'PREMIUM_REQUIRED' || message === 'premium required' ? 'premium_required' : reason === 'INSUFFICIENT_SCOPE' || message === 'insufficient client scope' - ? 'insufficient_scope' : 'forbidden'; - throw new SpotifyError(code, status); + ? catalog ? 'catalog_insufficient_scope' : 'insufficient_scope' + : catalog ? 'catalog_forbidden' : 'forbidden'; + throw failure(code); } - if (status === 404) throw new SpotifyError(write ? 'device_unavailable' : 'not_found', status); - throw new SpotifyError('provider_rejected', status); + if (status === 404) throw failure(write ? 'device_unavailable' : 'not_found'); + throw failure('provider_rejected'); } } diff --git a/tests/party.browser.mjs b/tests/party.browser.mjs index c738bb1..4fc61d7 100644 --- a/tests/party.browser.mjs +++ b/tests/party.browser.mjs @@ -109,7 +109,7 @@ test('interactive demo renders host, guest and direct start with no API traffic' await page.getByRole('button', { name: 'Start screen', exact: true }).click(); assert.equal(await page.getByRole('heading', { name: 'Host setup', exact: true }).count(), 0); assert.equal(await page.getByLabel('Active Spotify device', { exact: true }).count(), 0); - await page.getByRole('checkbox', { name: 'I have Spotify Premium and will host playback.' }).check(); + assert.equal(await page.getByRole('checkbox').count(), 0); await page.getByRole('button', { name: 'Start party', exact: true }).click(); await page.getByRole('button', { name: 'End party', exact: true }).waitFor(); page.once('dialog', dialog => dialog.dismiss()); @@ -408,7 +408,7 @@ test('hosts go from authorization directly to a room, resume safely, and can ret else if (path.endsWith('/session')) data = { csrfToken: 'start-csrf', hostConnected: connected, room }; else if (path.endsWith('/auth/status')) data = { status: auth, ...(auth === 'failed' ? { error: 'authorization_cancelled' } : {}) }; else if (path.endsWith('/auth/start')) { - assert.deepEqual(http.postDataJSON(), { premiumConfirmed: true }); + assert.deepEqual(http.postDataJSON(), {}); authorizations++; auth = scenario === 'authorization-failure' ? 'failed' : 'complete'; connected = auth === 'complete'; @@ -433,10 +433,10 @@ 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(); + await page.getByRole('heading', { name: 'Start a party', exact: true }).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(); + assert.equal(await page.getByRole('button', { name: 'Start party', exact: true }).isDisabled(), false); + assert.equal(await page.getByRole('checkbox').count(), 0); await page.getByRole('button', { name: 'Start party', exact: true }).click(); } else if (scenario === 'reconnect') { await page.getByRole('button', { name: 'Reconnect Spotify', exact: true }).click();