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
12 changes: 12 additions & 0 deletions docs/party-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions projects/api/tests/party.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' });
});
24 changes: 13 additions & 11 deletions projects/mini-app/src/features/party/Room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,19 @@ export function Room({ client, initialRoom, onRoomChange, onReconnect }: {
</section>}
{error && <div className="party-error" role="alert">{error}</div>}
{room.isHost && !inactive && (
<section className="party-card party-stack">
<h2>Invite friends</h2>
{qr && <img className="party-qr" src={qr} alt="QR code opening this party invitation in Telegram" />}
{inviteUrl ? (
<>
<label className="sr-only" htmlFor="party-invite-copy">Telegram invitation</label>
<input id="party-invite-copy" value={inviteUrl} readOnly onFocus={(event) => event.target.select()} />
<button className="party-secondary" onClick={() => void copyInvite()}>{copied ? 'Copied!' : 'Copy invite'}</button>
</>
) : <p>Invitation unavailable. Reopen the room to retry.</p>}
</section>
<details className="party-card">
<summary>Invite friends</summary>
<div className="party-stack">
{qr && <img className="party-qr" src={qr} alt="QR code opening this party invitation in Telegram" />}
{inviteUrl ? (
<>
<label className="sr-only" htmlFor="party-invite-copy">Telegram invitation</label>
<input id="party-invite-copy" value={inviteUrl} readOnly onFocus={(event) => event.target.select()} />
<button className="party-secondary" onClick={() => void copyInvite()}>{copied ? 'Copied!' : 'Copy invite'}</button>
</>
) : <p>Invitation unavailable. Reopen the room to retry.</p>}
</div>
</details>
)}
{!inactive && (
<SongSearch key={`${room.id}:${room.status}`} client={client} path={path} enabled={room.status === 'open'} requests={requests} />
Expand Down
93 changes: 93 additions & 0 deletions projects/mini-app/tests/room.test.mjs
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><body></body></html>', { 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/);
});
11 changes: 10 additions & 1 deletion projects/spotify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion projects/spotify/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,8 @@ test('minimal profile, devices, bounded search, market/relinking evidence preser
];
t.mock.method(globalThis, 'fetch', async (input: Parameters<typeof fetch>[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<string, string>).Authorization, 'Bearer token');
if (url.pathname.endsWith('/search')) assert.equal(url.searchParams.get('limit'), '10');
return json(responses.shift());
Expand All @@ -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<typeof fetch>[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<typeof fetch>[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<unknown>][] = [
[{}, instance => instance.profile('token')],
Expand Down
Loading
Loading