From 0f603e1a18dee3c54808344da9bd48e8f62791a4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 3 Sep 2026 23:58:01 +0000 Subject: [PATCH 1/2] Each side's own SiriusXM feed, found for them For the leagues SiriusXM carries by team -- NFL, NBA, MLB, NHL, WNBA, the college conferences, MLS -- a fixture's page and a team's page now draw an "On SiriusXM" section that looks up each side's own broadcast as soon as the page is up, rather than behind a button. The team name is parsed (packages/radio/src/teams.js), SiriusXM is searched for it on the reader's session, and only channels that actually name the team are kept: the full name, a distinctive nickname, or the school for a college. Lookups are cached across readers for ten minutes; one search per side per fixture, not per view. Nothing changes for leagues without team feeds, and nothing at all on genrewatch, where the rail stays off. SiriusXM failures are now logged with what SXM said, so a failed sign-in can be read from the service logs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ac4GWmd6Nfd8JReX9u7kH1 --- README.md | 9 ++ apps/web/public/app.js | 37 +++++--- apps/web/src/app.js | 101 +++++++++++++++------ apps/web/src/views/pages.jsx | 22 +++-- apps/web/src/views/radio.jsx | 60 ++++++++---- packages/db/src/queries.js | 11 +++ packages/radio/src/index.js | 8 ++ packages/radio/src/teams.js | 171 +++++++++++++++++++++++++++++++++++ test/radio-config.test.js | 10 ++ test/radio-pending.test.js | 2 +- test/radio-protocol.test.js | 32 +++++-- test/radio-routes.test.js | 33 ++++++- test/radio-schema.test.js | 15 +-- test/radio-teams.test.js | 81 +++++++++++++++++ test/radio-views.test.js | 46 ++++++++-- 15 files changed, 536 insertions(+), 102 deletions(-) create mode 100644 packages/radio/src/teams.js create mode 100644 test/radio-teams.test.js diff --git a/README.md b/README.md index 04e5e78..63bb191 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,15 @@ as that reader; the browser never sees a SiriusXM address or a bearer. The playe is `@profullstack/player` with its audio bar, bundled to `vendor-player.js` and fetched on the first press of Play. +For the leagues SiriusXM carries by team (NFL, NBA, MLB, NHL, WNBA, the college +football and basketball conferences, MLS), a fixture's page and a team's page +draw an **On SiriusXM** section that looks up each side's own feed as soon as +the page is up: the team name is parsed (`packages/radio/src/teams.js`), +SiriusXM is searched for it on the reader's session, and only channels that +actually name the team are kept. Lookups are cached across readers for ten +minutes, so a busy fixture costs one search per side, not one per view. Team +feeds appear close to kickoff and vanish after; the section says which. + Knobs, all read at request time: - `SIRIUSXM` — `0` turns the rail off. Defaults to on for the tipoffwatch brand diff --git a/apps/web/public/app.js b/apps/web/public/app.js index c3a171c..959fec1 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -1689,17 +1689,18 @@ function initRadioSection(section) { wire(section); /* - * The event page asks only when told to. Searching SiriusXM is an upstream - * call on the reader's own session, and most visits to a fixture do not want - * radio for it. The answer arrives as HTML rendered by the same component as - * the lineup page, so there is one row template and it is on the server. + * A fixture's or a team's own feeds, asked for as soon as the section is up. + * + * The server drew the section without asking SiriusXM, so the page arrived + * as fast as it always has; the lookup runs on the reader's own session + * here, and the rows come back as HTML from the same template as the lineup + * page. A failure is said in words with a way to try again, because "no + * feed" and "the lookup broke" must never look alike. */ - const find = section.querySelector('button[data-radio-find-button]'); const results = section.querySelector('[data-radio-results]'); - if (find && results && section.dataset.radioFind) { - find.addEventListener('click', async () => { - find.disabled = true; - find.textContent = 'Looking…'; + if (results && section.dataset.radioFind) { + const look = async () => { + results.innerHTML = '

Looking on SiriusXM…

'; try { const res = await fetch(section.dataset.radioFind, { headers: { accept: 'text/html', 'x-requested-with': 'fetch' }, @@ -1708,12 +1709,20 @@ function initRadioSection(section) { if (!res.ok) throw new Error(html.slice(0, 200) || String(res.status)); results.innerHTML = html; wire(results); - find.closest('p')?.remove(); } catch (err) { - find.disabled = false; - find.textContent = 'Find this game on SiriusXM'; - message(err?.message || 'SiriusXM did not answer. Try again in a moment.', true); + results.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'feedback error'; + p.textContent = `${err?.message || 'SiriusXM did not answer.'} `; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'ghost small-btn'; + retry.textContent = 'Try again'; + retry.addEventListener('click', look); + p.append(retry); + results.append(p); } - }); + }; + look(); } } diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 5358177..dbc5a9d 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -55,7 +55,7 @@ import { } from './views/pages.jsx'; import { Inbox, PeopleListPage, ProfilePage, Thread } from './views/people.jsx'; import { InvitePage, PremiumPage } from './views/premium.jsx'; -import { RadioPage, RadioRows } from './views/radio.jsx'; +import { RadioPage, RadioSidesFragment } from './views/radio.jsx'; export const app = new Hono(); @@ -467,7 +467,13 @@ app.get(`/${brand.paths.participant}/:slug`, async (c) => { * * Per-viewer, and safe only because this page is not one of the cached() ones. */ - const ownChannels = await ownChannelsForTeam({ userId: user?.id, team }); + const [ownChannels, radioSession] = await Promise.all([ + ownChannelsForTeam({ userId: user?.id, team }), + // A row read, not a lookup: the team's feed is asked for by app.js. + user && config.radio.enabled && radio.hasTeamRadio(team.league_slug) + ? radio.storedSession(user.id) + : null, + ]); return c.html( await render( @@ -477,6 +483,11 @@ app.get(`/${brand.paths.participant}/:slug`, async (c) => { events={events} following={following} ownChannels={ownChannels} + radio={ + radioSession && !radioSession.unreadable + ? { find: `/radio/find?team=${team.id}`, sides: [team.display_name] } + : null + } live={live} liveTotal={liveTotal} stalled={stalled} @@ -549,7 +560,9 @@ app.get('/events/:id', async (c) => { marketChannelsForEvent({ userId: user?.id, markets: marketsOf(event) }), // Whether the "On SiriusXM" section is drawn at all. A row read, no upstream // call: the lookup itself waits for the button. - user && config.radio.enabled ? radio.storedSession(user.id) : null, + user && config.radio.enabled && radio.hasTeamRadio(event.league_slug) + ? radio.storedSession(user.id) + : null, // Other people's open lists. Signed-in only, and it returns no URLs at all -- // a shared channel is playable through the proxy and nowhere else, because // every other route hands over the address and the address is the owner's @@ -572,7 +585,14 @@ app.get('/events/:id', async (c) => { marketChannels={marketChannels} sharedChannels={sharedChannels} streamDead={c.req.query('stream_dead') ?? null} - radioConnected={Boolean(radioSession && !radioSession.unreadable)} + radio={ + radioSession && !radioSession.unreadable + ? { + find: `/radio/find?event=${event.id}`, + sides: [event.home_name, event.away_name].filter(Boolean), + } + : null + } />, ), ); @@ -2240,7 +2260,15 @@ const radioBack = (query) => `/settings?${query}#siriusxm`; * anything else is ours and must not read as "wrong code". */ function radioFailure(err) { - if (err instanceof radio.SiriusXmError) return { message: err.message, status: err.status }; + if (err instanceof radio.SiriusXmError) { + // Logged as well as shown: the page gets the sentence, the log gets what + // SXM actually said, which is the only way a failed sign-in is diagnosable + // from here without the reader's screen. + console.warn( + `[radio] ${err.status} ${err.message}${err.data ? ` -- ${JSON.stringify(err.data).slice(0, 300)}` : ''}`, + ); + return { message: err.message, status: err.status }; + } console.error('[radio]', err); return { message: 'SiriusXM did not answer. Try again in a moment.', status: 502 }; } @@ -2365,37 +2393,50 @@ app.get('/radio', async (c) => { }); /** - * The channels naming either side of a fixture, as rows. + * The team feeds for a fixture or a team, as rows. * - * Both names are searched and the union is kept in lineup order, so a game on - * a team channel and one on a league channel both surface. Answered as HTML - * because the row already has one template, on the server. + * `?event=` looks up both sides; `?team=` one team. Only for a league SiriusXM + * carries by team -- anything else is 404, and the page never drew the section + * to ask. Each side is searched on the reader's own session, cached across + * readers, and settled separately so one side failing does not empty the + * other. Answered as HTML because the row has one template, on the server. */ app.get('/radio/find', async (c) => { const user = requireUser(c); if (!config.radio.enabled) return c.text('radio is off', 404); - const event = await q.getEvent(Number(c.req.query('event'))); - if (!event) return c.text('no such fixture', 404); - const names = [event.home_team_name, event.away_team_name, event.league_name] - .map((n) => String(n ?? '').trim()) - .filter((n) => n.length >= 3); - if (names.length === 0) return c.text('This fixture has no names to look up.', 404); - try { - const found = await Promise.all(names.map((n) => radio.search(user.id, n))); - const seen = new Set(); - const channels = found.flat().filter((ch) => { - if (seen.has(ch.stationId)) return false; - seen.add(ch.stationId); - return true; + const fragment = (node) => + c.body(node, 200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'private, no-store', }); - c.header('cache-control', 'private, no-store'); - // A fragment, not a page: no doctype, no Layout, so not render(). It is - // dropped into a section app.js already has on the page. - const fragment = - channels.length === 0 - ? '

SiriusXM has no channel naming this fixture right now. Game channels usually appear close to kickoff.

' - : await ().toString(); - return c.body(fragment, 200, { 'content-type': 'text/html; charset=utf-8' }); + + let leagueSlug = null; + let sides = []; + if (c.req.query('event')) { + const event = await q.getEvent(Number(c.req.query('event'))); + if (!event) return c.text('no such fixture', 404); + leagueSlug = event.league_slug; + const teams = await q.teamNamesByIds([event.home_team_id, event.away_team_id]); + // In fixture order, home first, with the provider's own nickname when the + // row is there and the display name alone when it is not. + sides = [event.home_team_id, event.away_team_id] + .map( + (id, i) => teams.find((t) => t.id === id) ?? (i === 0 ? event.home_name : event.away_name), + ) + .filter(Boolean); + } else if (c.req.query('team')) { + const [team] = await q.teamNamesByIds([c.req.query('team')]); + if (!team) return c.text('no such team', 404); + leagueSlug = team.league_slug; + sides = [team]; + } + if (!radio.hasTeamRadio(leagueSlug)) + return c.text('SiriusXM has no team feeds for this league.', 404); + if (sides.length === 0) return c.text('This fixture has no sides to look up.', 404); + + try { + const result = await radio.sidesStations(user.id, leagueSlug, sides); + return fragment(await ().toString()); } catch (err) { const { message, status } = radioFailure(err); return c.text(message, status); diff --git a/apps/web/src/views/pages.jsx b/apps/web/src/views/pages.jsx index 4c9b7f6..910fabd 100644 --- a/apps/web/src/views/pages.jsx +++ b/apps/web/src/views/pages.jsx @@ -10,7 +10,7 @@ import { TeamRow, } from './components.jsx'; import { Layout } from './Layout.jsx'; -import { RadioEventSection, RadioSettings } from './radio.jsx'; +import { RadioSettings, RadioTeamSection } from './radio.jsx'; /* * A sentence describing THIS page, for the meta description and the share cards. @@ -955,6 +955,9 @@ export const TeamPage = ({ soonTotal = 0, soonHours = 4, stalled = 0, + // The team's own SiriusXM feed, for a connected reader on a league that has + // them. See views/radio.jsx. + radio = null, }) => { const liveEmpty = `${team.display_name} are not playing right now.`; const soonEmpty = `${team.display_name} are not on in the next ${soonHours} hours.`; @@ -1040,6 +1043,8 @@ export const TeamPage = ({ Below the fixture list and the reader's own line, because both of those are why somebody opened a team page. This answers the narrower question they may not have thought to ask: is this lot playing at this moment. */} + {radio ? : null} + { const live = event.state === 'in'; const done = event.state === 'post'; @@ -1880,10 +1885,11 @@ export const EventPage = ({ ) : null} - {/* The reader's own SiriusXM, when connected. Radio rather than television, - so it sits after both TV rails; the same one-stream-at-a-time rule applies - across all three and app.js enforces it. */} - {radioConnected ? : null} + {/* The reader's own SiriusXM, when connected and the league has team feeds. + Radio rather than television, so it sits after both TV rails; the same + one-stream-at-a-time rule applies across all three and app.js enforces + it. */} + {radio ? : null}

Watch

diff --git a/apps/web/src/views/radio.jsx b/apps/web/src/views/radio.jsx index b4d8c51..48c19f5 100644 --- a/apps/web/src/views/radio.jsx +++ b/apps/web/src/views/radio.jsx @@ -172,34 +172,54 @@ export const RadioPage = ({ ); /** - * The event page's slice of this: the channels that name either side. + * A fixture's or a team's own broadcasts, on SiriusXM. * - * Nothing is fetched at render. Searching SiriusXM is an upstream call on the - * reader's own session, and an event page is opened far more often than a - * reader wants radio for it -- so the section offers a button, and app.js asks - * `/radio/find` only when it is pressed. Rendered only for a connected reader, - * like the playlist rail above it. + * Drawn for a connected reader on a league SiriusXM carries by team, and + * filled in by app.js the moment the page is up: `data-radio-find` is the + * fragment route, and nothing upstream is asked at render, so the fixture page + * stays as fast as it was. The rows arrive as HTML from the same component the + * lineup page uses, so there is one row template and it is on the server. + * + * @param {{find: string, sides: string[]}} props what to look up, and who for */ -export const RadioEventSection = ({ event }) => ( -
+export const RadioTeamSection = ({ find, sides }) => ( +

On SiriusXM

- SiriusXM carries most major-league games on a channel of their own. Look one up for this - fixture and it plays here, on your subscription. -

-

- + {sides.length === 1 + ? `${sides[0]}'s own broadcast, when SiriusXM is carrying it. ` + : `Each side's own broadcast, when SiriusXM is carrying it. `} + Team feeds appear close to kickoff and go away after the final whistle. These play on your + subscription, here and nowhere else.

-
+
+

Looking on SiriusXM…

+
); +/** + * The answer, per side. A side with nothing is said so, in words, because + * "no feed yet" and "the lookup broke" must never look the same; a side whose + * lookup failed says what SiriusXM said. + */ +export const RadioSidesFragment = ({ sides }) => ( + <> + {sides.map((side) => ( +
+

{side.team}

+ {side.error ? ( + + ) : side.stations.length === 0 ? ( +

No {side.team} feed on SiriusXM right now.

+ ) : ( + + )} +
+ ))} + +); + /** * The settings section. Three states, one form each: not connected (ask for * the email), waiting for the code (ask for the code), connected (offer to diff --git a/packages/db/src/queries.js b/packages/db/src/queries.js index 48f9a5e..6958a24 100644 --- a/packages/db/src/queries.js +++ b/packages/db/src/queries.js @@ -782,6 +782,17 @@ export async function deleteSiriusXm(userId) { await sql`delete from siriusxm_sessions where user_id = ${userId}`; } +/** The two halves of a team's name, and its league, for matching a station to it. */ +export async function teamNamesByIds(ids) { + const wanted = (ids ?? []).map(Number).filter(Number.isFinite); + if (wanted.length === 0) return []; + return sql` + select t.id, t.name, t.display_name, l.slug as league_slug + from teams t left join leagues l on l.id = t.league_id + where t.id = any(${pgArray(wanted)}::bigint[]) + `; +} + /* ----------------------------------------------------- sharing a playlist -- */ /** * Record a probe verdict on a SHARED entry. diff --git a/packages/radio/src/index.js b/packages/radio/src/index.js index d97e6bd..a9e4350 100644 --- a/packages/radio/src/index.js +++ b/packages/radio/src/index.js @@ -38,4 +38,12 @@ export { stationId, sxmFetch, } from './siriusxm.js'; +export { + hasTeamRadio, + matchesTeam, + sidesStations, + TEAM_RADIO_LEAGUES, + teamStations, + teamTerms, +} from './teams.js'; export { sharedFetch } from './upstream-cache.js'; diff --git a/packages/radio/src/teams.js b/packages/radio/src/teams.js new file mode 100644 index 0000000..4e99f0b --- /dev/null +++ b/packages/radio/src/teams.js @@ -0,0 +1,171 @@ +/** + * A team's own broadcast on SiriusXM. + * + * For the leagues SiriusXM carries by team -- the American national ones -- + * every game has a home feed and an away feed on channels of their own, named + * for the team, and they are what a fan wants over the national call. They + * come and go with the schedule, so they cannot be a list: they are found by + * searching SiriusXM for the team's name and keeping what actually names it. + * + * The search is upstream, on the reader's own session, so it is bounded here: + * one lookup per team name every few minutes, shared by every reader on the + * site, because a Broncos feed is the same Broncos feed whoever asks. + */ + +import { search } from './session.js'; + +/** + * Leagues where a team has a station of its own, by our league slug. + * + * ESPN slugs. College is in because SiriusXM carries the big conferences by + * school; MLS because it carries a game-of-the-week by club. Nothing outside + * the United States, because that is where the team feeds are -- a Premier + * League club has no SiriusXM channel to find, and searching for one is a + * wasted call on a reader's session. + */ +export const TEAM_RADIO_LEAGUES = new Set([ + 'nfl', + 'nba', + 'mlb', + 'nhl', + 'wnba', + 'college-football', + 'mens-college-basketball', + 'womens-college-basketball', + 'usa.1', +]); + +export const hasTeamRadio = (leagueSlug) => TEAM_RADIO_LEAGUES.has(String(leagueSlug ?? '')); + +/** Suffixes that are not a nickname. "Inter Miami CF" is not nicknamed "CF". */ +const NOT_A_NICKNAME = new Set(['fc', 'sc', 'cf', 'afc', 'united']); + +const fold = (s) => + String(s ?? '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +/** + * The names a team goes by, from what the provider gives us. + * + * `nickname` is the provider's own short name when we have it ("Broncos", + * "Crimson Tide") and the last word of the display name otherwise; `place` is + * what is left ("Denver", "Alabama"). A college feed is usually named for the + * school rather than the mascot, so both halves matter. + * + * @param {{display_name?: string, name?: string}|string} team + */ +export function teamTerms(team) { + const display = typeof team === 'string' ? team : (team?.display_name ?? team?.name ?? ''); + const provided = typeof team === 'string' ? '' : (team?.name ?? ''); + const full = fold(display); + const words = full.split(' ').filter(Boolean); + + let nickname = fold(provided); + if (!nickname || nickname === full) { + const tail = [...words]; + while (tail.length > 1 && NOT_A_NICKNAME.has(tail[tail.length - 1])) tail.pop(); + nickname = tail.length > 1 ? tail[tail.length - 1] : ''; + } + const place = nickname && full.endsWith(nickname) ? full.slice(0, -nickname.length).trim() : ''; + return { full, nickname, place, display }; +} + +const hasWord = (haystack, needle) => + Boolean(needle) && + new RegExp(`(^|\\s)${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`).test(haystack); + +/** + * How well a channel names a team. 0 is not at all. + * + * The full name is the strong signal. A nickname alone is accepted only when + * it is distinctive enough to be one -- four letters and up, so "Jets" and + * "Kings" count but a stray "Sox" does not -- and the place alone only for a + * college, where "Alabama" IS the team on the air. + */ +export function matchesTeam(channel, terms, { college = false } = {}) { + const text = fold(`${channel?.title ?? ''} ${channel?.description ?? ''}`); + if (!text) return 0; + if (terms.full && text.includes(terms.full)) return 3; + if (terms.nickname.length >= 4 && hasWord(text, terms.nickname)) return 2; + if (college && terms.place.length >= 4 && hasWord(text, terms.place)) return 1; + return 0; +} + +/* + * Found stations, by folded team name, for a few minutes. Negative answers are + * kept too, more briefly: a team with no game today has no feed, and asking + * SiriusXM again on every page view would be the cost this cache exists to + * remove. + */ +const HIT_TTL_MS = 10 * 60 * 1000; +const MISS_TTL_MS = 3 * 60 * 1000; +const found = new Map(); + +/** + * The stations naming one team, best match first. + * + * @param {string} userId whose session performs the search + * @param {{display_name?: string, name?: string}|string} team + * @param {{college?: boolean}} [opts] + */ +export async function teamStations(userId, team, { college = false } = {}) { + const terms = teamTerms(team); + if (!terms.full) return []; + const key = `${terms.full}|${college ? 'c' : ''}`; + const hit = found.get(key); + if (hit && hit.expiresAt > Date.now()) return hit.stations; + if (hit?.pending) return hit.pending; + + const pending = search(userId, terms.display) + .then((channels) => { + const stations = channels + .map((ch) => ({ ch, score: matchesTeam(ch, terms, { college }) })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score || (a.ch.number ?? 1e9) - (b.ch.number ?? 1e9)) + .map((x) => x.ch); + found.set(key, { + stations, + expiresAt: Date.now() + (stations.length ? HIT_TTL_MS : MISS_TTL_MS), + }); + return stations; + }) + .catch((err) => { + found.delete(key); + throw err; + }); + found.set(key, { pending, expiresAt: 0 }); + return pending; +} + +const isCollege = (leagueSlug) => /college/.test(String(leagueSlug ?? '')); + +/** + * Both sides of a fixture, each with what SiriusXM has for them. + * + * Searched in parallel and settled separately: one side failing must not + * take the other side's feed off the page. A failure is carried as `error` + * so the section can say which side it could not ask about. + * + * @returns {Promise>} + */ +export async function sidesStations(userId, leagueSlug, sides) { + const college = isCollege(leagueSlug); + const results = await Promise.allSettled( + sides.map((team) => teamStations(userId, team, { college })), + ); + return sides.map((team, i) => { + const r = results[i]; + const display = teamTerms(team).display; + return r.status === 'fulfilled' + ? { team: display, stations: r.value } + : { team: display, stations: [], error: r.reason?.message ?? 'SiriusXM did not answer.' }; + }); +} + +export function _resetTeamCache() { + found.clear(); +} diff --git a/test/radio-config.test.js b/test/radio-config.test.js index b6ce5ab..b96d8a1 100644 --- a/test/radio-config.test.js +++ b/test/radio-config.test.js @@ -25,4 +25,14 @@ describe('config.radio', () => { expect(config.radio.enabled).toBe(false); delete process.env.SIRIUSXM; }); + + test('off for genrewatch unless asked for: that site is VOD only', () => { + process.env.BRAND = 'genrewatch'; + expect(config.radio.enabled).toBe(false); + process.env.SIRIUSXM = '1'; + expect(config.radio.enabled).toBe(true); + delete process.env.SIRIUSXM; + delete process.env.BRAND; + expect(config.radio.enabled).toBe(true); + }); }); diff --git a/test/radio-pending.test.js b/test/radio-pending.test.js index d5ab35c..e733d49 100644 --- a/test/radio-pending.test.js +++ b/test/radio-pending.test.js @@ -20,7 +20,7 @@ describe('pending sign-ins', () => { expect(peekPending('u1')).toBeNull(); }); - test('one reader cannot see another\'s', () => { + test("one reader cannot see another's", () => { putPending('u1', { email: 'a@b.c' }); expect(peekPending('u2')).toBeNull(); dropPending('u1'); diff --git a/test/radio-protocol.test.js b/test/radio-protocol.test.js index 9a20a97..4f36b6a 100644 --- a/test/radio-protocol.test.js +++ b/test/radio-protocol.test.js @@ -26,8 +26,7 @@ describe('cookie jar', () => { }); describe('jwt expiry', () => { - const token = (exp) => - `h.${Buffer.from(JSON.stringify({ exp })).toString('base64url')}.s`; + const token = (exp) => `h.${Buffer.from(JSON.stringify({ exp })).toString('base64url')}.s`; test('reads exp in seconds and answers in ms', () => { expect(sxm.jwtExpiryMs(token(1_700_000_000))).toBe(1_700_000_000_000); }); @@ -139,7 +138,12 @@ describe('hls rewriting', () => { 'audio_128k_v3.m3u8', '', ].join('\n'); - const out = sxm.rewritePlaylist(master, 'https://cdn.siriusxm.com/ch/master.m3u8', '128', proxify); + const out = sxm.rewritePlaylist( + master, + 'https://cdn.siriusxm.com/ch/master.m3u8', + '128', + proxify, + ); expect(out).toContain('BANDWIDTH=128000'); expect(out).not.toContain('256000'); expect(out).toContain(proxify('https://cdn.siriusxm.com/ch/audio_128k_v3.m3u8')); @@ -155,7 +159,12 @@ describe('hls rewriting', () => { '/abs/seg002.aac', '', ].join('\n'); - const out = sxm.rewritePlaylist(media, 'https://cdn.siriusxm.com/ch/a/media.m3u8', '256', proxify); + const out = sxm.rewritePlaylist( + media, + 'https://cdn.siriusxm.com/ch/a/media.m3u8', + '256', + proxify, + ); const lines = out.split('\n'); expect(lines[1]).toBe('#EXT-X-TARGETDURATION:10'); expect(lines[2]).toContain( @@ -186,9 +195,9 @@ describe('key decoding', () => { const bytes = Buffer.from('0123456789abcdef'); test('base64, base64url, hex and literal', () => { expect(sxm.decodeKeyJson({ key: bytes.toString('base64') }).equals(bytes)).toBe(true); - expect(sxm.decodeKeyJson({ result: { value: bytes.toString('base64url') } }).equals(bytes)).toBe( - true, - ); + expect( + sxm.decodeKeyJson({ result: { value: bytes.toString('base64url') } }).equals(bytes), + ).toBe(true); // A literal that is neither alphabet: taken as the bytes it is. expect(sxm.decodeKeyJson({ data: 'raw key sixteen!' }).length).toBe(16); }); @@ -224,7 +233,12 @@ describe('otp login', () => { fetch: async (req) => { const url = new URL(req.url); const auth = req.headers.get('authorization'); - calls.push({ method: req.method, path: url.pathname, auth, cookie: req.headers.get('cookie') }); + calls.push({ + method: req.method, + path: url.pathname, + auth, + cookie: req.headers.get('cookie'), + }); const json = (data, status = 200, headers = {}) => new Response(JSON.stringify(data), { status, @@ -304,7 +318,7 @@ describe('otp login', () => { expect(calls[4].cookie).toContain('sxm=jar1'); }); - test('an unknown email is the reader\'s problem, not ours', async () => { + test("an unknown email is the reader's problem, not ours", async () => { await expect( sxm.startOtpLogin('nobody@example.com', { deviceGrant: JSON.stringify({ grant: 'device-grant' }), diff --git a/test/radio-routes.test.js b/test/radio-routes.test.js index 4ff8722..5632f7a 100644 --- a/test/radio-routes.test.js +++ b/test/radio-routes.test.js @@ -1,5 +1,5 @@ -import { readFile } from 'node:fs/promises'; import { describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; /* * Shape assertions on the routes, for the properties that a session-less test @@ -32,7 +32,7 @@ describe('radio routes', () => { test('a manifest leaves with root-relative proxy addresses and no-store', async () => { const src = await read('../apps/web/src/app.js'); - expect(src).toContain('`/radio/proxy?u=${encodeURIComponent(target)}'); + expect(src).toMatch(/`\/radio\/proxy\?u=\$\{encodeURIComponent\(target\)\}/); const resource = src.slice(src.indexOf('function radioResource')); expect(resource.slice(0, 2000)).toContain("'cache-control': 'no-store, private'"); }); @@ -40,7 +40,9 @@ describe('radio routes', () => { test('a wrong code keeps the sign-in; an expired one says so', async () => { const src = await read('../apps/web/src/app.js'); const verify = src.slice(src.indexOf("app.post('/api/radio/connect/verify'")); - expect(verify.slice(0, 2500)).toContain('if (status === 400) radio.putPending(user.id, pending)'); + expect(verify.slice(0, 2500)).toContain( + 'if (status === 400) radio.putPending(user.id, pending)', + ); expect(verify.slice(0, 2500)).toContain('That code has expired'); }); @@ -55,11 +57,34 @@ describe('radio routes', () => { expect(build).toContain("external: ['mpegts.js']"); }); + test('the team lookup is gated on a league with team feeds, for a fixture and for a team', async () => { + const src = await read('../apps/web/src/app.js'); + const find = src.slice(src.indexOf("app.get('/radio/find'")); + expect(find.slice(0, 2500)).toContain('radio.hasTeamRadio(leagueSlug)'); + expect(find.slice(0, 2500)).toContain("c.req.query('event')"); + expect(find.slice(0, 2500)).toContain("c.req.query('team')"); + // The pages draw the section only for those leagues, and never look up at render. + expect(src).toContain('radio.hasTeamRadio(event.league_slug)'); + expect(src).toContain('radio.hasTeamRadio(team.league_slug)'); + expect(src.match(/radio\.sidesStations\(/g)).toHaveLength(1); + }); + + test('app.js looks the feeds up as soon as the section is on the page', async () => { + const client = await read('../apps/web/public/app.js'); + const radio = client.slice(client.indexOf('function initRadioSection')); + expect(radio).toContain('section.dataset.radioFind'); + expect(radio).toContain('look();'); + expect(radio).toContain("retry.textContent = 'Try again'"); + }); + test('app.js wires the radio sections at boot and after a client-side navigation', async () => { const client = await read('../apps/web/public/app.js'); expect(client).toContain('function initRadio('); expect(client.match(/^initRadio\(\);/m)).not.toBeNull(); - const nav = client.slice(client.indexOf('function initNavigation'), client.indexOf('function initNavigation') + 2500); + const nav = client.slice( + client.indexOf('function initNavigation'), + client.indexOf('function initNavigation') + 2500, + ); expect(nav).toContain('initRadio();'); // One stream per reader, across TV and radio alike. const radio = client.slice(client.indexOf('function initRadioSection')); diff --git a/test/radio-schema.test.js b/test/radio-schema.test.js index 6bf8733..9f8b9cc 100644 --- a/test/radio-schema.test.js +++ b/test/radio-schema.test.js @@ -1,8 +1,8 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { readdir, readFile } from 'node:fs/promises'; import { PGlite } from '@electric-sql/pglite'; import { citext } from '@electric-sql/pglite/contrib/citext'; import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused'; process.env.PLAYLIST_SECRET ??= 'test-secret-for-sealing-values'; @@ -27,7 +27,10 @@ describe('siriusxm_sessions', () => { [u.id, 'r@sxm', 'v1.sealed', 'v1.sealed'], ); await expect( - db.query('insert into siriusxm_sessions (user_id, access_token) values ($1, $2)', [u.id, 'x']), + db.query('insert into siriusxm_sessions (user_id, access_token) values ($1, $2)', [ + u.id, + 'x', + ]), ).rejects.toThrow(); await db.query('delete from users where id = $1', [u.id]); const { rows } = await db.query('select count(*)::int as n from siriusxm_sessions'); @@ -53,13 +56,13 @@ describe('siriusxm_sessions', () => { describe('proxy pinning', () => { test('a reader always exits the same entry, and readers spread across the pool', async () => { - process.env.SIRIUSXM_PROXIES = Array.from({ length: 8 }, (_, i) => `http://u:p@h${i}:1`).join(','); + process.env.SIRIUSXM_PROXIES = Array.from({ length: 8 }, (_, i) => `http://u:p@h${i}:1`).join( + ',', + ); const { proxyFor } = await import('../packages/radio/src/session.js'); const a = proxyFor('11111111-1111-1111-1111-111111111111'); expect(proxyFor('11111111-1111-1111-1111-111111111111')).toBe(a); - const seen = new Set( - Array.from({ length: 40 }, (_, i) => proxyFor(`user-${i}`)), - ); + const seen = new Set(Array.from({ length: 40 }, (_, i) => proxyFor(`user-${i}`))); expect(seen.size).toBeGreaterThan(3); delete process.env.SIRIUSXM_PROXIES; process.env.SIRIUSXM_PROXY_URL = 'http://rotate:1'; diff --git a/test/radio-teams.test.js b/test/radio-teams.test.js new file mode 100644 index 0000000..dc52a13 --- /dev/null +++ b/test/radio-teams.test.js @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; + +process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused'; +process.env.PLAYLIST_SECRET ??= 'test-secret-for-sealing-values'; + +const { hasTeamRadio, matchesTeam, TEAM_RADIO_LEAGUES, teamTerms } = await import( + '../packages/radio/src/teams.js' +); + +/* + * Finding a team's own feed: which leagues have one, how a name is split, and + * what counts as a channel naming the team. The search itself is SiriusXM's; + * these are the parts that decide what is asked and what is kept. + */ +describe('leagues with team feeds', () => { + test('the American national leagues, by our slug, and nothing abroad', () => { + for (const slug of ['nfl', 'nba', 'mlb', 'nhl', 'college-football', 'usa.1']) { + expect(hasTeamRadio(slug)).toBe(true); + } + for (const slug of ['eng.1', 'uefa.champions', 'f1', 'atp', null, undefined, '']) { + expect(hasTeamRadio(slug)).toBe(false); + } + expect(TEAM_RADIO_LEAGUES.size).toBeGreaterThan(5); + }); +}); + +describe('team names', () => { + test('uses the provider nickname when there is one', () => { + const t = teamTerms({ display_name: 'Denver Broncos', name: 'Broncos' }); + expect(t).toMatchObject({ full: 'denver broncos', nickname: 'broncos', place: 'denver' }); + }); + test('falls back to the last word, skipping club suffixes', () => { + expect(teamTerms('Tampa Bay Buccaneers').nickname).toBe('buccaneers'); + expect(teamTerms('Inter Miami CF').nickname).toBe('miami'); + // "United" is half the clubs in the world; with no nickname left, only the + // full name can match, which is the right amount of caution. + expect(teamTerms('Atlanta United FC').nickname).toBe(''); + expect(teamTerms('LA Galaxy').place).toBe('la'); + }); + test('a multi-word nickname from the provider keeps the school as the place', () => { + const t = teamTerms({ display_name: 'Alabama Crimson Tide', name: 'Crimson Tide' }); + expect(t.nickname).toBe('crimson tide'); + expect(t.place).toBe('alabama'); + }); + test('one word is a name with no nickname', () => { + expect(teamTerms('Arsenal')).toMatchObject({ full: 'arsenal', nickname: '', place: '' }); + }); +}); + +describe('matching a channel to a team', () => { + const broncos = teamTerms({ display_name: 'Denver Broncos', name: 'Broncos' }); + const jets = teamTerms({ display_name: 'New York Jets', name: 'Jets' }); + const sox = teamTerms({ display_name: 'Boston Red Sox', name: 'Red Sox' }); + const bama = teamTerms({ display_name: 'Alabama Crimson Tide', name: 'Crimson Tide' }); + + test('the full name is the strong match, in the title or the description', () => { + expect(matchesTeam({ title: 'Denver Broncos', description: '' }, broncos)).toBe(3); + expect( + matchesTeam({ title: 'NFL Home Feed', description: 'Denver Broncos vs Chiefs' }, broncos), + ).toBe(3); + }); + test('a distinctive nickname alone is enough; a short one is not', () => { + expect(matchesTeam({ title: 'Broncos Radio' }, broncos)).toBe(2); + expect(matchesTeam({ title: 'Jets Radio Network' }, jets)).toBe(2); + // "Sox" would also be the White Sox; the two-word nickname is what is looked for. + expect(matchesTeam({ title: 'Sox Talk' }, sox)).toBe(0); + expect(matchesTeam({ title: 'Red Sox Radio' }, sox)).toBe(2); + }); + test('word boundaries: Jets is not Jetsons', () => { + expect(matchesTeam({ title: 'The Jetsons Hour' }, jets)).toBe(0); + }); + test('a college is found by its school, and a pro team is not found by its city', () => { + expect(matchesTeam({ title: 'Alabama Football' }, bama, { college: true })).toBe(1); + expect(matchesTeam({ title: 'Alabama Football' }, bama)).toBe(0); + expect(matchesTeam({ title: 'Denver Sports Talk' }, broncos)).toBe(0); + }); + test('nothing matches nothing', () => { + expect(matchesTeam({ title: '' }, broncos)).toBe(0); + expect(matchesTeam(null, broncos)).toBe(0); + }); +}); diff --git a/test/radio-views.test.js b/test/radio-views.test.js index ecea57c..d7cd135 100644 --- a/test/radio-views.test.js +++ b/test/radio-views.test.js @@ -4,9 +4,8 @@ process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused'; process.env.PLAYLIST_SECRET ??= 'test-secret-for-sealing-values'; process.env.SITE_URL ??= 'https://tipoffwatch.com'; -const { RadioChannelRow, RadioEventSection, RadioPage, RadioSettings } = await import( - '../apps/web/src/views/radio.jsx' -); +const { RadioChannelRow, RadioPage, RadioSettings, RadioSidesFragment, RadioTeamSection } = + await import('../apps/web/src/views/radio.jsx'); const { Layout } = await import('../apps/web/src/views/Layout.jsx'); const html = (node) => node.toString(); @@ -64,7 +63,9 @@ describe('RadioSettings', () => { expect(broken).toContain('action="/api/radio/connect"'); }); test('shows what happened', () => { - const out = html(RadioSettings({ session: null, pending: null, notice: 'Done.', error: 'Nope.' })); + const out = html( + RadioSettings({ session: null, pending: null, notice: 'Done.', error: 'Nope.' }), + ); expect(out).toContain('feedback ok'); expect(out).toContain('feedback error'); }); @@ -82,7 +83,9 @@ describe('RadioPage', () => { expect(out).not.toContain('data-radio-src'); }); test('connected: tabs, search, quality and the rows, with the bundle attributes', () => { - const out = html(RadioPage({ user: { id: 'u' }, session: { email: 'a@b.c' }, cat: 'news', channels: [ch] })); + const out = html( + RadioPage({ user: { id: 'u' }, session: { email: 'a@b.c' }, cat: 'news', channels: [ch] }), + ); expect(out).toMatch(/data-radio-src="\/vendor-player\.js(\?v=[^"]+)?"/); expect(out).toMatch(/data-radio-css="\/vendor-player\.css(\?v=[^"]+)?"/); expect(out).toContain('href="/radio?cat=news" class="active"'); @@ -91,17 +94,40 @@ describe('RadioPage', () => { expect(out).toContain('ESPN Radio'); }); test('an error from SiriusXM is shown, not swallowed', () => { - const out = html(RadioPage({ user: { id: 'u' }, session: { email: 'a' }, channels: [], error: 'SXM said no' })); + const out = html( + RadioPage({ user: { id: 'u' }, session: { email: 'a' }, channels: [], error: 'SXM said no' }), + ); expect(out).toContain('SXM said no'); }); }); -describe('RadioEventSection', () => { - test('offers a lookup and fetches nothing at render', () => { - const out = html(RadioEventSection({ event: { id: 42 } })); +describe('RadioTeamSection', () => { + test('names the lookup and fetches nothing at render', () => { + const out = html(RadioTeamSection({ find: '/radio/find?event=42', sides: ['A', 'B'] })); expect(out).toContain('data-radio-find="/radio/find?event=42"'); - expect(out).toContain('data-radio-find-button'); expect(out).toContain('data-radio-results'); + expect(out).toContain('Looking on SiriusXM'); + expect(out).toContain('Each side's own broadcast'); + const one = html(RadioTeamSection({ find: '/radio/find?team=7', sides: ['Denver Broncos'] })); + expect(one).toContain('Denver Broncos's own broadcast'); + }); +}); + +describe('RadioSidesFragment', () => { + test('a side with a feed gets rows; without one, words; a failure, its reason', () => { + const out = html( + RadioSidesFragment({ + sides: [ + { team: 'Denver Broncos', stations: [ch] }, + { team: 'Kansas City Chiefs', stations: [] }, + { team: 'Nobody', stations: [], error: 'SXM said no' }, + ], + }), + ); + expect(out).toContain('ESPN Radio'); + expect(out).toContain('No Kansas City Chiefs feed on SiriusXM right now.'); + expect(out).toContain('SXM said no'); + expect(out).not.toContain(' Date: Fri, 4 Sep 2026 00:01:20 +0000 Subject: [PATCH 2/2] The password door: sign in to SiriusXM the way its own app does The settings form asked for an email and had SiriusXM send a code, which is one of the two doors the SiriusXM player offers and not the one people reach for. Read from the player's bundle: authenticatePassword posts {handle, password} with the anonymous session's token and answers with an identity grant, which sessions/authenticated turns into a session exactly as the OTP grant is. So the form now takes email and password, with 'Email me a code instead' beside it. The password is used for that one call and is never stored, logged or echoed; what is kept is the session, sealed as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ac4GWmd6Nfd8JReX9u7kH1 --- README.md | 6 ++-- apps/web/src/app.js | 40 ++++++++++++++++++++++ apps/web/src/views/radio.jsx | 29 +++++++++++++--- packages/radio/src/index.js | 1 + packages/radio/src/siriusxm.js | 61 ++++++++++++++++++++++++++++++++++ test/radio-protocol.test.js | 39 ++++++++++++++++++++++ test/radio-routes.test.js | 10 ++++++ test/radio-views.test.js | 9 +++-- 8 files changed, 184 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 63bb191..10b5b9c 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ Secrets belong on the service and in the logicsrc vault, not in a committed ## Radio (SiriusXM) -A reader connects their own SiriusXM subscription in settings — the email on the -account and the code SiriusXM sends to it, the way the SiriusXM app signs in — -and the sports and news lineups play on `/radio` and on a fixture's page. The +A reader connects their own SiriusXM subscription in settings — email and +password, or the code SiriusXM emails them, the two doors the SiriusXM app +itself offers; only the resulting session is kept — and the sports and news lineups play on `/radio` and on a fixture's page. The session is sealed with the playlist key and every byte is fetched by the server as that reader; the browser never sees a SiriusXM address or a bearer. The player is `@profullstack/player` with its audio bar, bundled to `vendor-player.js` and diff --git a/apps/web/src/app.js b/apps/web/src/app.js index dbc5a9d..65b4228 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -2302,6 +2302,46 @@ app.post('/api/radio/connect', async (c) => { } }); +/** + * The password door. Nothing is stored but the session it produces -- the + * same row the code path writes -- and the password itself is in this handler + * and nowhere else, not even the log. + */ +app.post('/api/radio/connect/password', async (c) => { + const user = requireUser(c); + if (!config.radio.enabled) return c.json({ error: 'radio is off' }, 404); + const body = await c.req.parseBody(); + const email = String(body.email ?? '').trim(); + const password = String(body.password ?? ''); + if (!email.includes('@') || !password) { + const message = 'Enter the email and password on your SiriusXM account.'; + return respond(c, { + json: { error: message }, + status: 400, + redirectTo: radioBack(`siriusxm_error=${encodeURIComponent(message)}`), + }); + } + try { + const session = await radio.passwordLogin(email, password, { + proxy: radio.proxyFor(user.id), + deviceGrant: config.radio.deviceGrant || null, + }); + radio.dropPending(user.id); + await radio.saveSession(user.id, { email, ...session }); + return respond(c, { + json: { ok: true, connected: true }, + redirectTo: radioBack('siriusxm=connected'), + }); + } catch (err) { + const { message, status } = radioFailure(err); + return respond(c, { + json: { error: message }, + status, + redirectTo: radioBack(`siriusxm_error=${encodeURIComponent(message)}`), + }); + } +}); + app.post('/api/radio/connect/verify', async (c) => { const user = requireUser(c); if (!config.radio.enabled) return c.json({ error: 'radio is off' }, 404); diff --git a/apps/web/src/views/radio.jsx b/apps/web/src/views/radio.jsx index 48c19f5..4b4b9c6 100644 --- a/apps/web/src/views/radio.jsx +++ b/apps/web/src/views/radio.jsx @@ -290,7 +290,7 @@ export const RadioSettings = ({ session, pending, notice, error }) => ( ) : ( -
+

Connect your SiriusXM account

{session?.unreadable ? (

+ The password is used once, to sign in as the SiriusXM app would, and is not stored. What + is kept is the session it produces, encrypted. No password, or would rather not? Leave it + blank and have SiriusXM email you a code instead. +

+
+ + +
)}
diff --git a/packages/radio/src/index.js b/packages/radio/src/index.js index a9e4350..20c769e 100644 --- a/packages/radio/src/index.js +++ b/packages/radio/src/index.js @@ -31,6 +31,7 @@ export { isSiriusXmUrl, looksLikePlaylist, parseStationId, + passwordLogin, QUALITIES, rewritePlaylist, SiriusXmError, diff --git a/packages/radio/src/siriusxm.js b/packages/radio/src/siriusxm.js index 3c55ba1..d459dbb 100644 --- a/packages/radio/src/siriusxm.js +++ b/packages/radio/src/siriusxm.js @@ -428,6 +428,67 @@ export async function completeOtpLogin(state, otp, { proxy = null } = {}) { return extractSession(authed, jar); } +/** + * The other door: the account password. + * + * What the SiriusXM web player does when a reader types a password rather than + * asking for a code (read from its bundle, 2026-09-03: endpoint + * `authenticatePassword`, body `{handle, password}`, bearer the anonymous + * session's access token; the reply is an identity grant, and + * `sessions/authenticated` turns it into a session the same way the OTP path + * does). Tried first with no bearer, as the OTP start is; only if the gateway + * insists is the anonymous session minted underneath it. + * + * The password is used for this one call and never stored -- what is kept is + * the session it produced, which is all the OTP path keeps too. + */ +export async function passwordLogin(handle, password, { proxy = null, deviceGrant = null } = {}) { + let jar = ''; + const body = { handle, password }; + const attempt = (bearer) => + sxmCall('identity/v1/identities/authenticate/password', { + bearer: bearer || undefined, + cookies: jar, + body, + proxy, + }); + + let reply = await attempt(''); + if (reply.status === 401 || reply.status === 403) { + const grant = await bootstrapDeviceGrant({ proxy, pasted: deviceGrant }); + const anon = await sxmCall('session/v1/sessions/anonymous', { bearer: grant.grant, proxy }); + if (anon.status >= 400) { + throw new SiriusXmError(`anonymous session failed: ${anon.status}`, 502, anon.data); + } + jar = mergeCookies(jar, anon.setCookie); + reply = await attempt(extractSession(anon, jar).accessToken); + } + if (reply.status >= 400) { + throw new SiriusXmError( + [400, 401, 403, 404].includes(reply.status) + ? 'SiriusXM did not accept that email and password.' + : `SiriusXM would not sign in (${reply.status}).`, + reply.status >= 500 ? 502 : 400, + reply.data, + ); + } + jar = mergeCookies(jar, reply.setCookie); + const identityGrant = reply.data?.grant ?? reply.data?.identityGrant?.grant; + if (!identityGrant) { + throw new SiriusXmError(`no grant in password response: ${reply.raw.slice(0, 300)}`, 502); + } + + const authed = await sxmCall('session/v1/sessions/authenticated', { + bearer: identityGrant, + cookies: jar, + proxy, + }); + if (authed.status >= 400) { + throw new SiriusXmError(`sessions/authenticated failed: ${authed.status}`, 502, authed.data); + } + return extractSession(authed, jar); +} + /** A new access token from the jar alone. No bearer: the cookies are the refresh. */ export async function refreshSession(cookies, { proxy = null } = {}) { const reply = await sxmCall('session/v1/sessions/refresh', { cookies, body: {}, proxy }); diff --git a/test/radio-protocol.test.js b/test/radio-protocol.test.js index 4f36b6a..f4650bf 100644 --- a/test/radio-protocol.test.js +++ b/test/radio-protocol.test.js @@ -264,6 +264,14 @@ describe('otp login', () => { case 'POST /identity/v1/identities/authenticate/otp': if (auth !== 'Bearer otp-grant') return json({}, 403); return json({ grant: 'identity-grant' }); + case 'POST /identity/v1/identities/authenticate/password': { + if (!auth) return json({ error: 'auth' }, 401); + const body = await req.json(); + if (body.handle !== 'me@example.com' || body.password !== 'hunter2') { + return json({ error: 'bad credentials' }, 401); + } + return json({ grant: 'identity-grant' }, 200, { 'set-cookie': 'pw=1' }); + } case 'POST /session/v1/sessions/authenticated': if (auth !== 'Bearer identity-grant') return json({}, 403); return json(session('access-1'), 200, { 'set-cookie': 'refresh=r1; HttpOnly' }); @@ -336,6 +344,37 @@ describe('otp login', () => { }); }); + test('the password door: anonymous session, then the grant, then the session', async () => { + calls.length = 0; + const result = await sxm.passwordLogin('me@example.com', 'hunter2', { + deviceGrant: JSON.stringify({ grant: 'device-grant' }), + }); + expect(result.accessToken).toBe('access-1'); + expect(result.cookies).toContain('refresh=r1'); + const paths = calls.map((c) => `${c.method} ${c.path}`); + expect(paths).toEqual([ + 'POST /identity/v1/identities/authenticate/password', + 'POST /session/v1/sessions/anonymous', + 'POST /identity/v1/identities/authenticate/password', + 'POST /session/v1/sessions/authenticated', + ]); + expect(calls[2].auth).toBe('Bearer anon-token'); + expect(calls[3].auth).toBe('Bearer identity-grant'); + // The jar from the anonymous session rides along to the password call. + expect(calls[2].cookie).toContain('anon=1'); + }); + + test('a wrong password is a 400 in words, never a 502', async () => { + await expect( + sxm.passwordLogin('me@example.com', 'nope', { + deviceGrant: JSON.stringify({ grant: 'device-grant' }), + }), + ).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining('email and password'), + }); + }); + test('refresh replays the jar with no bearer', async () => { calls.length = 0; const r = await sxm.refreshSession('refresh=r1; other=2'); diff --git a/test/radio-routes.test.js b/test/radio-routes.test.js index 5632f7a..39d12d4 100644 --- a/test/radio-routes.test.js +++ b/test/radio-routes.test.js @@ -19,6 +19,7 @@ describe('radio routes', () => { expect(guard).toBeLessThan(fetchAt); for (const route of [ "app.post('/api/radio/connect'", + "app.post('/api/radio/connect/password'", "app.post('/api/radio/connect/verify'", "app.post('/api/radio/disconnect'", "app.get('/radio/find'", @@ -37,6 +38,15 @@ describe('radio routes', () => { expect(resource.slice(0, 2000)).toContain("'cache-control': 'no-store, private'"); }); + test('the password is used once and never reaches a log or the database', async () => { + const src = await read('../apps/web/src/app.js'); + const route = src.slice(src.indexOf("app.post('/api/radio/connect/password'")); + const body = route.slice(0, route.indexOf("app.post('/api/radio/connect/verify'")); + expect(body).toContain('radio.passwordLogin(email, password'); + expect(body).not.toMatch(/console\.\w+\([^)]*password/); + expect(body).not.toMatch(/saveSession\([^)]*password/); + }); + test('a wrong code keeps the sign-in; an expired one says so', async () => { const src = await read('../apps/web/src/app.js'); const verify = src.slice(src.indexOf("app.post('/api/radio/connect/verify'")); diff --git a/test/radio-views.test.js b/test/radio-views.test.js index d7cd135..df5bd61 100644 --- a/test/radio-views.test.js +++ b/test/radio-views.test.js @@ -39,10 +39,13 @@ describe('RadioChannelRow', () => { }); describe('RadioSettings', () => { - test('asks for the email when nothing is connected', () => { + test('asks for email and password when nothing is connected, with the code as the other door', () => { const out = html(RadioSettings({ session: null, pending: null })); - expect(out).toContain('action="/api/radio/connect"'); + expect(out).toContain('action="/api/radio/connect/password"'); expect(out).toContain('name="email"'); + expect(out).toContain('type="password"'); + expect(out).toContain('autocomplete="current-password"'); + expect(out).toContain('formaction="/api/radio/connect"'); expect(out).not.toContain('name="otp"'); }); test('asks for the code while one is pending', () => { @@ -60,7 +63,7 @@ describe('RadioSettings', () => { expect(on).not.toContain('name="email"'); const broken = html(RadioSettings({ session: { unreadable: true }, pending: null })); expect(broken).toContain('no longer be decrypted'); - expect(broken).toContain('action="/api/radio/connect"'); + expect(broken).toContain('action="/api/radio/connect/password"'); }); test('shows what happened', () => { const out = html(