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

}
+ {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']);