fix(stalker): authenticate before serving a static collection stream

Second Codex P1 on #1364, and a regression this PR introduced. `create_link`
was also the request that warmed the portal session. Tokens live in memory
only (`StalkerSessionService.tokenCache` is a plain Map), and the collection
header builder reads the raw `getCachedToken()`. So a cold start from global
Favorites or Recently Viewed — the portal never opened this session — took the
static path, found no token, and handed a same-host gated stream headers with
no `Authorization`: a 403 on exactly the streams the header contract exists
for. The same raw accessor cannot tell a token negotiated for a pre-edit
identity from a current one.

`StreamResolverService` now calls `ensureToken()` before building a static
playback. It is the right primitive: handshake + `get_profile` with no link
minted, identity fingerprint validated, concurrent callers deduped, and an
immediate null for simple portals — and calling it keeps this change out of
`stalker-session.service.ts`, which PR 6 (#1354) is splitting.

Best-effort by design: a static URL may point at a CDN that needs no
credentials, so a failed handshake degrades to the token-less header set
instead of costing the user their playback. Both halves are pinned by tests,
and removing the call makes the cold-start test fail.

The portal routes need no equivalent and do not get one: an item cannot be
selected before its catalog has loaded, and every catalog load authenticates.
That reasoning is now written down rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 09:32:25 +02:00
1 parent 0278205984
commit 409d8235a2
3 files changed
+144 -2

No files matched your search

+22 -1
View File
@@ -364,6 +364,26 @@ Callers pass the row they resolved the `cmd` from:
An item with no row snapshot gets no verdict, except radio, which keeps its
long-standing "directly usable command plays as-is" behaviour.
### The static path still needs the session
`create_link` was also the request that warmed the portal session, and tokens
live in memory only (`StalkerSessionService.tokenCache`). Skipping it therefore
has to account for streams that are still gated on the Bearer token:
- **Portal routes** (ITV, VOD, series, radio) are structurally warm. An item
cannot be selected before its catalog has loaded, and every catalog load goes
through `executeStalkerRequest`, which authenticates. No extra work needed.
- **Collection routes** (global Favorites / Recently Viewed) are not. They read
the persisted row and can play on a cold start, without the portal ever
having been opened this session. `StreamResolverService` therefore calls
`StalkerSessionService.ensureToken()` before building a static playback —
handshake + `get_profile`, no link minted, and it validates the identity the
cached token was negotiated for, which the raw `getCachedToken()` used by the
header builder cannot. A simple portal returns `null` immediately.
The call is best-effort: a static URL may point at a CDN needing no
credentials at all, so a failed handshake degrades to the token-less header
set rather than costing the user their playback.
### Resolved links are never stored
A temporary link lives about 5 seconds (`tv_tmp_link_ttl` /
@@ -420,7 +440,8 @@ Revisit both only with a portal that demonstrably fails without them.
the `series` exception, relative VOD commands.
- `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that
Recently Viewed stores the `cmd`, never the stream URL.
- `stream-resolver.service.spec.ts` — the collection route.
- `stream-resolver.service.spec.ts` — the collection route, plus the cold
full-portal session warm-up and its best-effort degradation.
- `stalker-playback-context.service.spec.ts` — headers only, query-insensitive
key.
- `apps/web-e2e/src/stalker.e2e.ts` — mock scenario `00:1A:79:00:00:0A` serves
@@ -45,6 +45,7 @@ describe('StreamResolverService', () => {
stalkerSession = {
getCachedToken: jest.fn(() => null),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
};
epgBridge = {
getChannelPrograms: jest.fn(),
@@ -853,6 +854,86 @@ describe('StreamResolverService', () => {
expect(playback.isLive).toBe(true);
});
it('authenticates a cold full-portal session before a static stream', async () => {
// Skipping create_link also skips the request that used to warm the
// session. Tokens are in-memory only, so a cold start from global
// Favorites would otherwise hand a same-host gated stream headers
// with no Authorization and take a 403.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
// Cold: no token until ensureToken has run.
stalkerSession.getCachedToken.mockReturnValue(null);
stalkerSession.ensureToken.mockImplementation(async () => {
stalkerSession.getCachedToken.mockReturnValue('TOKEN-COLD');
return { token: 'TOKEN-COLD' };
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::91',
name: 'Cold Static Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '91',
stalkerCmd: 'ffrt3 https://stalker.example.com/live/91.m3u8',
stalkerItem: {
id: '91',
cmd: 'ffrt3 https://stalker.example.com/live/91.m3u8',
use_http_tmp_link: '0',
},
} as UnifiedCollectionItem);
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
expect.objectContaining({ _id: 'stalker-1' })
);
expect(playback.streamUrl).toBe(
'https://stalker.example.com/live/91.m3u8'
);
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-COLD');
});
it('still plays a static stream when the session cannot be established', async () => {
// The static URL may point at a CDN that needs no credentials, so a
// failed handshake must not cost the user their playback.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
stalkerSession.ensureToken.mockRejectedValue(new Error('portal down'));
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::92',
name: 'Cdn Static Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '92',
stalkerCmd: 'ffrt3 https://cdn.example.com/live/92.m3u8',
stalkerItem: {
id: '92',
cmd: 'ffrt3 https://cdn.example.com/live/92.m3u8',
use_http_tmp_link: '0',
},
} as UnifiedCollectionItem);
expect(playback.streamUrl).toBe('https://cdn.example.com/live/92.m3u8');
expect(playback.headers?.['Authorization']).toBeUndefined();
});
it('mints a link for a flagged Stalker favorite', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -28,7 +28,10 @@ import {
StalkerSessionService,
type StalkerLinkFlagSource,
} from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
UnifiedCollectionItem,
createLogger,
} from '@iptvnator/portal/shared/util';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
@@ -75,6 +78,7 @@ export class StreamResolverService {
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly portalRepair = inject(StalkerPortalRepairService);
private readonly logger = createLogger('StreamResolver');
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 10000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
@@ -349,6 +353,17 @@ export class StreamResolverService {
item.stalkerCmd ?? ''
);
if (staticUrl) {
// Skipping `create_link` also skips the only authenticated
// request this route used to make, and it was what warmed the
// session. Tokens live in memory only, so on a cold start from
// global Favorites/Recently Viewed a same-host stream gated on
// the portal Bearer token would get headers without one and 403.
// `ensureToken` performs the handshake + get_profile — and
// validates the identity the cached token was negotiated for,
// which the raw `getCachedToken` below cannot — without minting a
// link; a simple portal returns null immediately.
await this.warmStalkerSession(playlist);
return this.buildStalkerPlayback(item, playlist, {
macAddress,
portalUrl,
@@ -417,6 +432,31 @@ export class StreamResolverService {
});
}
/**
* Establish the portal session a static stream may still be gated on.
*
* Best-effort on purpose: the static URL can just as well point at a
* foreign CDN that needs no credentials, so a failed handshake must not
* cost the user their playback. It degrades to the token-less header set,
* which is exactly what this path produced before.
*/
private async warmStalkerSession(
playlist: Playlist | undefined
): Promise<void> {
if (!playlist) {
return;
}
try {
await this.stalkerSession.ensureToken(playlist);
} catch (error) {
this.logger.warn(
'Could not establish the Stalker session for a static stream',
error
);
}
}
/**
* The collection routes must hand players the SAME portal header set the
* Stalker live layout builds — an auth-gated stream opened from Favorites