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