diff --git a/docs/party-queue.md b/docs/party-queue.md index 120f6f8..6909853 100644 --- a/docs/party-queue.md +++ b/docs/party-queue.md @@ -239,6 +239,18 @@ all existing version/playability checks and returns `{ candidates, expiresAt }`, with a `selectionToken` on each candidate. It does not store search drafts or 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. +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. + +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. + Each domain-separated HMAC-signed capability binds the session, room, canonical source, exact candidate metadata, common search nonce and expiry (at most five minutes, bounded by session/room expiry). Tokens contain no host credentials and diff --git a/projects/api/tests/party.integration.test.mjs b/projects/api/tests/party.integration.test.mjs index 29b6c12..289dbde 100644 --- a/projects/api/tests/party.integration.test.mjs +++ b/projects/api/tests/party.integration.test.mjs @@ -12,6 +12,8 @@ import EmbeddedPostgres from 'embedded-postgres'; const require = createRequire(import.meta.url); const express = require('express'); const { SpotifyClient, SpotifyError } = require('@brewtify/spotify'); +const spotifyTrack = SpotifyClient.prototype.track; +const spotifyEnqueue = SpotifyClient.prototype.enqueue; const origin = 'https://party.test'; Object.assign(process.env, { PARTY_ENABLED: 'true', @@ -1234,3 +1236,134 @@ test('read-only room search and explicit submitter selection preserve authorizat assert.equal((await call(`${path}/search`, member, { url })).data.error.code, 'room_expired'); assert.equal((await call(`${path}/selections`, member, selection)).data.error.code, 'room_expired'); }); + +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); + const { room: active } = await makeRoom(host); + const route = `/rooms/${active.id}`; + const originalFetch = globalThis.fetch; + t.mock.method(SpotifyClient.prototype, 'track', spotifyTrack); + t.mock.method(SpotifyClient.prototype, 'enqueue', spotifyEnqueue); + let deliveredTrack = track; + let sends = 0; + const metadataMarkets = []; + t.mock.method(globalThis, 'fetch', async (input, options) => { + const url = new URL(String(input)); + if (url.origin === base) return originalFetch(input, options); + if (url.origin === 'https://itunes.apple.com') { + return Response.json({ + resultCount: 1, + results: [{ wrapperType: 'track', kind: 'song', trackId: 123456789, trackName: track.name, + artistName: 'Artist', collectionName: 'Album', trackTimeMillis: track.duration_ms, + trackExplicitness: 'notExplicit' }], + }); + } + assert.equal(url.origin, 'https://api.spotify.com'); + if (url.pathname === '/v1/search') { + metadataMarkets.push(url.searchParams.get('market')); + return Response.json({ tracks: { items: [track] } }); + } + if (url.pathname === `/v1/tracks/${trackId}`) { + metadataMarkets.push(url.searchParams.get('market')); + return Response.json({ + ...deliveredTrack, + is_playable: url.searchParams.has('market') ? deliveredTrack.is_playable : undefined, + }); + } + assert.equal(url.pathname, '/v1/me/player/queue'); + assert.equal(options.method, 'POST'); + assert.equal(url.searchParams.get('uri'), `spotify:track:${trackId}`); + assert.equal(url.searchParams.has('device_id'), false); + sends++; + return new Response(null, { status: 204 }); + }); + for (const [variant, failureCode] of [ + [{}, null], + [{ is_playable: undefined }, 'track_unavailable'], + [{ is_playable: false }, 'track_unavailable'], + [{ restrictions: { reason: 'market' } }, 'track_unavailable'], + [{ id: 'ZZZZZZZZZZZZZZZZZZZZZZ', linked_from: { id: trackId } }, 'track_unavailable'], + [{ name: 'Test Song - Live' }, 'recording_changed'], + ]) { + deliveredTrack = { ...track, ...variant }; + const found = await call(`${route}/search`, host, { + url: 'https://music.apple.com/il/album/album/123456788?i=123456789', + }); + assert.equal(found.response.status, 200, JSON.stringify(found.data)); + assert.equal(found.data.candidates.length, 1); + const selected = await call(`${route}/selections`, host, { + selectionToken: found.data.candidates[0].selectionToken, + }); + assert.equal(selected.response.status, 202, JSON.stringify(selected.data)); + await runPending(selected.data.id, 'deliver'); + await runPending(selected.data.id, 'deliver'); + const [receipt] = await store.rows('SELECT status,failure_code FROM party_requests WHERE id=$1', [selected.data.id]); + assert.equal(receipt.status, failureCode === 'recording_changed' ? 'failed' : failureCode ? 'unavailable' : 'added'); + assert.equal(receipt.failure_code, failureCode); + const attempts = await store.rows('SELECT outcome FROM party_delivery_attempts WHERE request_id=$1', [selected.data.id]); + 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.ok(metadataMarkets.every(market => market === 'from_token')); + await call(`${route}/action`, host, { action: 'close' }); +}); + +test('direct Spotify links including localized share URLs search, select and deliver with host-market metadata', async t => { + await store.rows('DELETE FROM party_throttles'); + await authorize(host); + const { room: active } = await makeRoom(host); + const route = `/rooms/${active.id}`; + const originalFetch = globalThis.fetch; + t.mock.method(SpotifyClient.prototype, 'track', spotifyTrack); + t.mock.method(SpotifyClient.prototype, 'enqueue', spotifyEnqueue); + let reads = 0; + let sends = 0; + 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', 'direct Spotify links need no other catalog'); + if (url.pathname === `/v1/tracks/${trackId}`) { + reads++; + assert.equal(url.searchParams.get('market'), 'from_token'); + return Response.json(track); + } + assert.equal(url.pathname, '/v1/me/player/queue'); + assert.equal(options.method, 'POST'); + assert.equal(url.searchParams.get('uri'), `spotify:track:${trackId}`); + assert.equal(url.searchParams.has('device_id'), false); + sends++; + return new Response(null, { status: 204 }); + }); + const canonical = `https://open.spotify.com/track/${trackId}`; + for (const [index, url] of [ + canonical, + `${canonical}?si=share&context=spotify%3Aalbum%3Atest`, + `https://open.spotify.com/intl-de/track/${trackId}?si=share`, + `https://open.spotify.com/intl-pt-BR/track/${trackId}/?si=share`, + ].entries()) { + const found = await call(`${route}/search`, host, { url }); + assert.equal(found.response.status, 200, JSON.stringify(found.data)); + assert.equal(found.data.candidates.length, 1); + const candidate = found.data.candidates[0]; + assert.equal(candidate.id, trackId); + assert.equal(candidate.url, canonical); + assert.ok(candidate.evidence.includes('direct_spotify_id')); + assert.equal(sends, index, 'finding a direct link never enqueues it'); + const selected = await call(`${route}/selections`, host, { selectionToken: candidate.selectionToken }); + assert.equal(selected.response.status, 202, JSON.stringify(selected.data)); + assert.equal((await call(`${route}/selections`, host, { selectionToken: candidate.selectionToken })).data.id, selected.data.id); + const [saved] = await store.rows('SELECT source_url,status FROM party_requests WHERE id=$1', [selected.data.id]); + assert.equal(saved.source_url, canonical); + assert.equal(saved.status, 'approved'); + await runPending(selected.data.id, 'deliver'); + await runPending(selected.data.id, 'deliver'); + assert.equal(sends, index + 1); + assert.equal(reads, (index + 1) * 2, 'search and pre-send both use direct track metadata'); + const [receipt] = await store.rows('SELECT status,failure_code FROM party_requests WHERE id=$1', [selected.data.id]); + assert.deepEqual(receipt, { status: 'added', failure_code: null }); + assert.deepEqual(await store.rows('SELECT outcome FROM party_delivery_attempts WHERE request_id=$1', [selected.data.id]), [{ outcome: 'accepted' }]); + } + await call(`${route}/action`, host, { action: 'close' }); +}); diff --git a/projects/mini-app/src/features/party/Room.tsx b/projects/mini-app/src/features/party/Room.tsx index 0c7b265..b0b2dce 100644 --- a/projects/mini-app/src/features/party/Room.tsx +++ b/projects/mini-app/src/features/party/Room.tsx @@ -132,17 +132,19 @@ export function Room({ client, initialRoom, onRoomChange, onReconnect }: { } {error &&
{error}
} {room.isHost && !inactive && ( -
-

Invite friends

- {qr && QR code opening this party invitation in Telegram} - {inviteUrl ? ( - <> - - event.target.select()} /> - - - ) :

Invitation unavailable. Reopen the room to retry.

} -
+
+ Invite friends +
+ {qr && QR code opening this party invitation in Telegram} + {inviteUrl ? ( + <> + + event.target.select()} /> + + + ) :

Invitation unavailable. Reopen the room to retry.

} +
+
)} {!inactive && ( diff --git a/projects/mini-app/tests/room.test.mjs b/projects/mini-app/tests/room.test.mjs new file mode 100644 index 0000000..8fc3190 --- /dev/null +++ b/projects/mini-app/tests/room.test.mjs @@ -0,0 +1,93 @@ +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, Room, 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-room-tests', + }); + ({ Room } = await vite.ssrLoadModule('/src/features/party/Room.tsx')); + ({ ToastProvider } = await vite.ssrLoadModule('/src/hooks/useToast.tsx')); +}); +afterEach(async () => { + if (root) await act(async () => root.unmount()); + container?.remove(); + root = undefined; +}); +after(async () => { + await vite?.close(); + dom?.window.close(); + delete globalThis.window; + delete globalThis.document; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; +}); + +const invitation = 'https://example.invalid/party-test-invitation'; +async function mount(t, isHost, status = 'open') { + t.mock.method(QRCode, 'toDataURL', async () => 'data:image/png;base64,test'); + const calls = []; + const room = { id: 'test', isHost, status, mode: 'auto', blockedReason: null, expiresAt: new Date(Date.now() + 60000).toISOString() }; + const client = { request: async path => { + calls.push(path); + if (path === '/rooms/test/invite') return { inviteUrl: invitation }; + assert.equal(path, '/rooms/test/requests'); + return { room, requests: [], nextCursor: null }; + } }; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render(h(ToastProvider, null, h(Room, { + client, initialRoom: room, onRoomChange() {}, onReconnect() {}, + })))); + return calls; +} + +test('host invite starts collapsed and toggles without losing QR, invitation or copy functionality', async t => { + const copied = []; + t.mock.getter(globalThis, 'navigator', () => ({ clipboard: { writeText: async value => { copied.push(value); } } })); + const calls = await mount(t, true); + const details = container.querySelector('details'); + const summary = details.querySelector('summary'); + assert.equal(details.open, false); + assert.equal(summary.textContent, 'Invite friends'); + await act(async () => summary.click()); + assert.equal(details.open, true); + assert.match(details.querySelector('img').alt, /QR code.*Telegram/); + assert.equal(details.querySelector('input').value, invitation); + await act(async () => details.querySelector('button').click()); + assert.deepEqual(copied, [invitation]); + assert.equal(details.querySelector('button').textContent, 'Copied!'); + await act(async () => summary.click()); + assert.equal(details.open, false); + await act(async () => summary.click()); + assert.equal(details.open, true); + assert.equal(details.querySelector('input').value, invitation); + assert.equal(calls.filter(path => path.endsWith('/invite')).length, 1); +}); + +test('guests never render or request host invitations', async t => { + const calls = await mount(t, false); + assert.equal(container.querySelector('details'), null); + assert.equal(container.querySelector('#party-invite-copy'), null); + assert.doesNotMatch(container.textContent, /Invite friends|Copy invite/); + assert.ok(calls.every(path => !path.endsWith('/invite'))); +}); + +test('closed parties do not expose invitations even to hosts', async t => { + const calls = await mount(t, true, 'closed'); + assert.equal(container.querySelector('details'), null); + assert.ok(calls.every(path => !path.endsWith('/invite'))); + assert.match(container.textContent, /Party ended/); +}); diff --git a/projects/spotify/README.md b/projects/spotify/README.md index f634540..2cef20f 100644 --- a/projects/spotify/README.md +++ b/projects/spotify/README.md @@ -25,7 +25,16 @@ any write. Unlike cross-catalog matching, this check has no duration tolerance. All requests use fixed official HTTPS origins, reject redirects, have an 8-second deadline (including body consumption), and a 1 MiB response limit. Search requests use at most 10 results without imposing an Apple storefront -as the Spotify market. The shared server transport also serves free iTunes Store +as the Spotify market. Both search and direct track reads explicitly request +`market=from_token`, Spotify's documented host-account market, without a country +lookup or additional OAuth scopes. Without an explicit market, direct metadata +can omit `is_playable` even for a track offered by search. See Spotify's +[track relinking contract](https://developer.spotify.com/documentation/web-api/concepts/track-relinking). +Unknown/false playability, restrictions, local tracks, relinking, and changed IDs +still fail closed; a market parameter is not itself proof of playability. +This changes only metadata reads: completed unavailable requests are not +automatically retried. After deployment, a guest must search and select again. +The shared server transport also serves free iTunes Store lookup (`itunes`, `https://itunes.apple.com/`); it never fetches submitted links. The shared transport uses an Undici dispatcher with a socket-connect DNS lookup. It resolves only the three allowlisted provider hosts, validates the complete diff --git a/projects/spotify/src/index.test.ts b/projects/spotify/src/index.test.ts index 731d783..abc5e84 100644 --- a/projects/spotify/src/index.test.ts +++ b/projects/spotify/src/index.test.ts @@ -251,7 +251,8 @@ 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.has('market'), false); + assert.equal(url.searchParams.get('market'), + url.pathname.endsWith('/search') || 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()); @@ -265,6 +266,50 @@ 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 => { + for (const recording of [ + { ...track(), name: 'Tron', artists: [{ name: 'Foals' }], album: { name: 'Antidotes' }, + duration_ms: 290840, external_ids: { isrc: 'GBVKZ0725315' } }, + { ...track(), name: 'Freaking Out the Neighborhood', artists: [{ name: 'Mac DeMarco' }], + album: { name: '2' }, duration_ms: 173888, external_ids: { isrc: 'QMMZN1200048' } }, + ]) { + const calls: URL[] = []; + 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] } }); + // Without market, track metadata omits the positive playability evidence. + return json({ ...recording, is_playable: url.searchParams.has('market') ? true : undefined }); + }); + const [found] = await client().search('token', `isrc:${recording.external_ids.isrc}`); + assert.equal(trackEligibility(found).eligible, true); + const fetched = await client().track('token', found.id); + assert.doesNotThrow(() => assertSelectedRecording(fetched, { + id: found.id, title: found.name, artist: found.artists[0].name, album: found.album.name, + durationMs: found.duration_ms!, explicit: found.explicit!, isrc: found.external_ids?.isrc, + 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'); + mocked.mock.restore(); + } +}); + +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' } }, + { is_local: true }, { linked_from: { id: OTHER } }, { id: OTHER, linked_from: { id: ID } }, + ]) { + const mocked = t.mock.method(globalThis, 'fetch', async (input: Parameters[0]) => { + assert.equal(new URL(String(input)).searchParams.get('market'), 'from_token'); + return json({ ...track(), ...variant }); + }); + const result = await client().track('token', ID); + assert.throws(() => assertTrackEligible(result, ID), { code: 'track_unavailable' }); + mocked.mock.restore(); + } +}); + test('malformed provider data is not converted to empty catalog results', async t => { const cases: [unknown, (instance: SpotifyClient) => Promise][] = [ [{}, instance => instance.profile('token')], diff --git a/projects/spotify/src/index.ts b/projects/spotify/src/index.ts index cc43013..4d7c845 100644 --- a/projects/spotify/src/index.ts +++ b/projects/spotify/src/index.ts @@ -7,6 +7,8 @@ export const SPOTIFY_TRACK_ID = /^[A-Za-z0-9]{22}$/; const ISRC = /^[A-Z]{2}[A-Z0-9]{3}\d{7}$/i; export const PROVIDER_DEADLINE_MS = 8_000; export const PROVIDER_BODY_LIMIT = 1_048_576; +// Explicit market requests include playability/relinking evidence for the host. +const HOST_MARKET = 'from_token'; const providerDispatcher = new Agent({ connect: { lookup: createProviderLookup(), timeout: PROVIDER_DEADLINE_MS }, connections: 10, @@ -342,12 +344,13 @@ export class SpotifyClient { async track(token: string, id: string): Promise { if (!SPOTIFY_TRACK_ID.test(id)) throw new SpotifyError('invalid_input'); - return decodeTrack(await this.read(`tracks/${id}`, token)); + const params = new URLSearchParams({ market: HOST_MARKET }); + return decodeTrack(await this.read(`tracks/${id}?${params}`, token)); } 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' }); + const params = new URLSearchParams({ q: query, type: 'track', limit: '10', market: HOST_MARKET }); 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); diff --git a/tests/party.browser.mjs b/tests/party.browser.mjs index bb8f00b..c738bb1 100644 --- a/tests/party.browser.mjs +++ b/tests/party.browser.mjs @@ -60,6 +60,20 @@ test('interactive demo renders host, guest and direct start with no API traffic' assert.equal(await page.getByRole('button', { name: /Approve|Reject|Lock requests|Disconnect Party Spotify|Confirm playback device/ }).count(), 0); assert.equal(await page.getByLabel('Moderation mode', { exact: true }).count(), 0); assert.equal(await page.getByLabel('Display name', { exact: true }).count(), 0); + const invite = page.locator('details').filter({ has: page.locator('summary', { hasText: 'Invite friends' }) }); + const inviteToggle = invite.locator('summary'); + assert.equal(await invite.evaluate(element => element.open), false); + assert.equal(await page.getByLabel('Telegram invitation', { exact: true }).isVisible(), false); + await inviteToggle.focus(); + await inviteToggle.press('Enter'); + assert.equal(await invite.evaluate(element => element.open), true); + await page.getByAltText('QR code opening this party invitation in Telegram').waitFor(); + assert.equal(await page.getByLabel('Telegram invitation', { exact: true }).isVisible(), true); + assert.equal(await page.getByRole('button', { name: 'Copy invite', exact: true }).isVisible(), true); + await inviteToggle.press('Space'); + assert.equal(await invite.evaluate(element => element.open), false); + await inviteToggle.click(); + assert.equal(await invite.evaluate(element => element.open), true); await page.getByRole('button', { name: 'Guest view', exact: true }).click(); await page.getByRole('heading', { name: 'Your songs', exact: true }).waitFor(); assert.equal(await page.getByRole('article').count(), 1); @@ -67,6 +81,8 @@ test('interactive demo renders host, guest and direct start with no API traffic' assert.equal(await page.getByRole('heading', { name: 'Party', exact: true }).count(), 0); assert.equal(await page.getByRole('heading', { name: 'You’re invited', exact: true }).count(), 0); assert.equal(await page.getByRole('button', { name: 'End party', exact: true }).count(), 0); + assert.equal(await page.locator('summary', { hasText: 'Invite friends' }).count(), 0); + assert.equal(await page.getByLabel('Telegram invitation', { exact: true }).count(), 0); assert.equal(await page.locator('form').evaluate(element => element.previousElementSibling === null), true); const sunrise = page.getByRole('article'); assert.deepEqual((await sunrise.innerText()).split('\n').filter(Boolean), ['Sunrise Again', 'Sunday Club · Slow Mornings']);