docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375)

Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.

Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).

CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".

Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.

The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.

Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.

Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-08-06 17:49:49 +02:00
1 parent 53c318bac7
commit 5e4f2ca3dd
11 files changed
+319 -75

No files matched your search

+3 -1
View File
@@ -89,9 +89,11 @@ state.
- favorites snapshots
- recently viewed snapshots
Explicitly excluded:
Explicitly excluded — session state, as opposed to the connection definition:
- `stalkerToken`
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the profile-advertised cadence)
- `stalkerAccountInfo`
- playback positions in v1
+55 -12
View File
@@ -102,6 +102,13 @@ tests can assert the client re-handshakes and retries instead of surfacing an
error; pinned device identity survives invalidation, as it does on a real
portal.
Beside the portal endpoints, `main.ts` mounts a handful of non-portal routes:
`GET /stalker` (the PWA CORS-proxy mirror), `GET /stream/gated/:file` (the
credential-gated media fixtures for the `gated-stream` scenario),
`GET /assets/marketing/poster/*` (the committed screenshot-safe posters) and
the `/health` + `/reset` + `/invalidate-session` utilities. The full list with
request shapes is in `apps/stalker-mock-server/README.md`.
## Data Generation Pipeline
```
@@ -340,7 +347,9 @@ that no `create_link` request reaches the portal. See
Favorites are stored in a `Map<mac, Set<itemId>>` in `src/app/data-store.ts`. They persist for the lifetime of the server process and are shared across all requests for the same MAC.
Call `POST /reset` to clear all favorites (and regenerated data) between test runs.
Call `POST /reset?macAddress=<mac>` to clear a MAC's favorites (and its cached
generated data) between test runs — see "Test Isolation" for why the scoped
form is the one specs should use.
## Playwright Integration
@@ -348,26 +357,53 @@ Call `POST /reset` to clear all favorites (and regenerated data) between test ru
```typescript
webServer: [
{
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
},
{ command: webServerCommand /* web:serve */, url: baseURL },
{
command: 'pnpm nx run stalker-mock-server:serve',
url: 'http://localhost:3210/health',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
},
// plus the xtream mock (3211) and web-backend (3333) entries
]
```
Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance.
Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance.
**`MOCK_PORT` moves the CLIENT side only** — Playwright's health-check URL and
the `MOCK_SERVER` constants in the specs. The server's own port comes from
`PORT` (`main.ts`), which the `serve` and `serve-with-watch` targets pin to
`3210` in `project.json`, and nothing maps one variable to the other. Setting
`MOCK_PORT` alone therefore points Playwright at a port nothing is listening
on and the run times out waiting for `/health`. It is only useful against a
mock you started yourself on that port (`reuseExistingServer` is on outside
CI); relocating the Nx-managed one would need `MOCK_PORT` passed through as
`PORT`.
### Test Isolation
Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.
Mock state is keyed by MAC and one mock-server process is shared by every spec
file running in parallel workers, so isolation is per-MAC rather than global:
`resetAll()` clears both the generated-content cache and in-memory favorites (`data-store.ts`). Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets.
- `POST /reset` accepts one or more `?macAddress=` params and clears only those
MACs. A bare `POST /reset` clears everything and is only safe when nothing
else is talking to the server — a spec that used it would wipe a sibling
spec's session mid-test.
- `apps/web-e2e/src/stalker.e2e.ts` declares the MACs it owns in `OWNED_MACS`
and clears exactly those in one batched request. The sibling specs that reach
this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a disjoint
`00:1A:79:5F:*` range, so neither file can clear the other's state.
- Within the file, tests deliberately share scenario MACs (their fixture shapes
are what the assertions are written against), so it pins itself to one worker
with `test.describe.configure({ mode: 'serial' })`.
- A few MACs are deliberately kept OUT of `OWNED_MACS`: the token-reuse test
asserts that a session SURVIVES, so nothing may reset it, and it uses one MAC
per browser project.
A reset drops the generated content cache, favorites (`data-store.ts`), the
auth/session record (`auth-store.ts`, including any pinned device identity) and
the watchdog ping counters. Because generation is seed-deterministic, the next
request regenerates identical content, so the observable data does not change
across resets.
### Recommended Test Structure
@@ -376,9 +412,16 @@ import { test, expect } from '@playwright/test';
const MOCK_URL = 'http://localhost:3210/portal.php';
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario
const OWNED_MACS = [MOCK_MAC];
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ request }) => {
await request.post('http://localhost:3210/reset');
// Scoped reset: only the MACs this file owns.
const query = OWNED_MACS.map(
(mac) => `macAddress=${encodeURIComponent(mac)}`
).join('&');
await request.post(`http://localhost:3210/reset?${query}`);
});
test('browse VOD categories', async ({ page }) => {
@@ -393,5 +436,5 @@ test('browse VOD categories', async ({ page }) => {
- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`.
- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`.
- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers.
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness).
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Endpoints With Different Strictness](#endpoints-with-different-strictness).
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.
+132 -27
View File
@@ -30,31 +30,71 @@ Stalker support covers:
## Routing Structure
Primary route tree lives in
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`
(`createStalkerRoutes()`), mounted under the workspace shell, so every path
below is reached as `/workspace/stalker/:id/…`.
- `/stalker/:id/vod` (plus `vod/:categoryId` child)
- `/stalker/:id/series` (plus `series/:categoryId` child)
- `/stalker/:id/itv`
- `/stalker/:id/radio`
- `/stalker/:id/favorites`
- `/stalker/:id/recent`
- `/stalker/:id/search`
- `/stalker/:id/actor/:personId`
- `/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
- `/stalker/:id/downloads/:downloadId` (focused local movie/series detail with
no category context panel)
- `/workspace/stalker/:id/vod` (plus `vod/:categoryId` child)
- `/workspace/stalker/:id/series` (plus `series/:categoryId` child)
- `/workspace/stalker/:id/itv`
- `/workspace/stalker/:id/radio`
- `/workspace/stalker/:id/favorites`
- `/workspace/stalker/:id/recent`
- `/workspace/stalker/:id/search`
- `/workspace/stalker/:id/actor/:personId`
- `/workspace/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
- `/workspace/stalker/:id/downloads/:downloadId` (focused local movie/series
detail with no category context panel)
`/workspace/stalker/:id` itself redirects to `vod`.
## Runtime Architecture
1. Angular Stalker screens call methods/resources in `StalkerStore`.
2. `StalkerStore` builds request params based on selected content type and current view state.
3. Every portal API call funnels through `executeStalkerRequest()`
3. Catalog, content and playback calls funnel through `executeStalkerRequest()`
(`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts`),
the single choke point that decides the transport per portal mode:
the choke point that decides the transport per portal mode:
full portals go through `StalkerSessionService` (handshake + Bearer token +
retry), token-free panels call
`DataService.sendIpcEvent(STALKER_REQUEST, ...)` directly. It also hooks
the lazy portal repair (see "Portal Mode and Endpoint Discovery").
Four callers deliberately sit outside it and issue `STALKER_REQUEST`
themselves, because each one runs *below* or *before* what it routes on:
- `StalkerAuthApi` — `handshake` / `get_profile` / `do_auth` are what the
full-portal branch is implemented in terms of, so routing them back
through it would recurse.
- `StalkerPortalDiscoveryService` — probes run before a mode exists; the
mode is what they are determining.
- `StalkerAccountInfoService.fetchViaProfile()` — the full-mode refresh is
a profile request, so it takes the same exemption as the auth layer.
- `StreamResolverService`, for a collection item carrying its own portal
coordinates with **no playlist row** — there is no meta to route or
repair with. The playlist-backed branch beside it does use
`executeStalkerRequest()`, and wins when a row exists, so a repaired
endpoint beats a stale favorite's snapshot.
The exemption is from the routing, not from the repair it hooks — but only
**one** of the four wires `StalkerPortalRepairService` itself, and the
asymmetry is worth knowing before adding a fifth:
- `StalkerAccountInfoService.fetchViaProfile()` wires it explicitly,
because opening the account dialog on a playlist with a stale endpoint
must be able to fix it instead of waiting for an unrelated catalog
request.
- `StalkerPortalDiscoveryService` is what repair *drives*, so it cannot
repair itself.
- The auth layer wires nothing. It does not need to: a terminal handshake
failure propagates out of the full-portal branch and is caught by
whichever `executeStalkerRequest()` call triggered the authentication,
which is exactly why "terminal handshake failures" is one of the repair
triggers listed above.
- `StreamResolverService`'s row-less branch has no playlist to repair.
Anything new that is not auth or discovery belongs on
`executeStalkerRequest()`.
4. Electron main process handles `STALKER_REQUEST` in
`apps/electron-backend/src/app/events/stalker.events.ts`.
5. Axios calls the portal's persisted API endpoint (`portal.php` on
@@ -86,8 +126,34 @@ Two portal modes exist, persisted per playlist as
periodic authenticated `watchdog/get_events` pings at the cadence the
portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog"
below) whose failures are non-fatal.
- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle
at all — requests carry only the `mac=` cookie.
- **Simple portal** (typically a reseller-style `portal.php` panel): no auth
lifecycle at all — no handshake, no Bearer token, no watchdog. The requests
are not stripped down to a bare cookie either: every Stalker request goes
through the shared identity builder, so a simple portal receives everything
that builder can derive from a MAC alone — the `mac`/`stb_lang`/`timezone`
cookie, the MAG `User-Agent` / `X-User-Agent` pair and the
`Accept`/`Accept-Language`/`Connection` set (see "Request Transport and
`cmd` Encoding").
What it does **not** get is anything carried by the session. The direct
branch of `dispatchStalkerRequest()` forwards only `url`, `macAddress` and
`params`, so the builder never sees a token *or* a serial: no
`Authorization: Bearer` (there is none to send), and no `SN` header or
serial-derived `__cfduid` cookie **even when the playlist stores a serial**.
The full-portal branch forwards both, because `makeAuthenticatedRequest()`
passes `token` and `serialNumber`. This covers portal API requests only —
playback headers are built from the playlist row by a different helper and
are NOT mode-gated, so the same simple-mode playlist does send its serial
with a portal-owned stream (see "Stalker Identity Policy").
Whether a simple panel ought to receive
the serial is unproven: no reference portal is known to require it, and the
`sn` *parameter* only ever travels on `get_profile`, which a simple portal
never calls. Treat it as an open question rather than a bug to fix blind.
Neither label is tied to a URL shape: mode follows OBSERVED behavior, so a
`portal.php` panel that enforces the token is classified — and treated
everywhere — as a full portal, and a canonical `server/load.php` endpoint that
answers without one is a simple portal.
The single predicate lives in `@iptvnator/shared/interfaces`
(`stalker-portal-mode.util.ts`): `isFullStalkerPortalPlaylist()` treats the
@@ -209,13 +275,33 @@ Failure-handling rule:
## Stalker Identity Policy
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
import UI can capture optional serial number, device IDs, and signatures, but
blank fields are not generated or forwarded to `get_profile`.
import UI can capture optional serial number, device IDs, and signatures, but a
field the user leaves blank stays blank — nothing is invented for it, and
nothing empty is forwarded to `get_profile`. The one way a value appears
without being typed is the explicit import-time opt-in described under
"Deriving device IDs from the MAC" below, which writes into the visible fields
first. (The fixed MAG250 description `get_profile` reports is separate: it
describes the emulated box, not the account — see "Reported device profile".)
- User-provided `sn`, `device_id`, `device_id2`, `signature`, and `signature2`
values are trimmed, persisted under the canonical `stalker*` playlist fields,
and reused for initial auth, token refresh, retry auth, normal API requests,
and same-origin playback headers.
Those last two reach the wire by different routes, and **only one is
mode-gated** — the asymmetry is easy to get backwards:
- **Portal API requests** receive the serial through
`makeAuthenticatedRequest()`, which only the full-portal branch of
`dispatchStalkerRequest()` calls. A simple-mode playlist therefore sends
no serial on any API call, whatever it has stored (see "Portal Mode and
Endpoint Discovery").
- **Same-origin playback headers** are built by
`buildStalkerExternalPlaybackHeaders()`, which reads
`playlist.stalkerSerialNumber` straight off the row with no mode check
at all. So a simple-mode playlist holding a real serial *does* send `SN`
and the serial-derived `__cfduid` on portal-owned streams — while its
API calls do not.
- Empty optional identity fields remain absent. IPTVnator must never generate a
device ID behind the user's back, and must never duplicate `device_id2` from
`device_id1` on its own.
@@ -296,7 +382,10 @@ dialog. Newly typed values are still held to the format.
### Deriving device IDs from the MAC
`deriveStalkerDeviceIdsFromMac` (`stalker-identity.utils.ts`) returns the pair
`deriveStalkerDeviceIdsFromMac`
(`libs/shared/interfaces/src/lib/stalker-identity.utils.ts` — note the
same-named file in `libs/portal/stalker/data-access` is a different module)
returns the pair
StbEmu and `stalker-to-m3u` generate: uppercase hex `SHA256` of the canonical
MAC for `device_id`, and of that MAC plus a `stalker` salt for `device_id2`.
The import dialog offers it behind an opt-in checkbox that fills both fields.
@@ -407,7 +496,8 @@ nothing to them.
Full portals authenticate through `StalkerSessionService`
(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which
is a facade over three focused modules:
is a thin facade over focused modules (it was split when the single file
outgrew the `max-lines` budget; never re-add it to the baseline):
- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth`
requests and the `authenticate()` orchestration.
@@ -478,7 +568,11 @@ every start.
`msg`/`block_msg` carry the portal's own explanation; they are
markup-stripped, combined, and thrown as `StalkerPortalError`. The kind is
`device-conflict` when `isStalkerDeviceConflictMessage` matches the combined
text, otherwise `blocked` — see "Device conflicts" below.
text, otherwise `blocked` — see "Device conflicts" below. A **bare**
`{status: 1}` with no message is a refusal too: it used to be read as success
whenever the portal sent no `msg`, which imported dead sources (the stock
MAC-format rejection is exactly that shape). A profile that carries refusal
text without setting the status is likewise refused.
- `status: 2` — login/password required. The client runs `do_auth`
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
@@ -908,11 +1002,18 @@ streams it resolves, so a channel opened from a collection carries the same
credentials as one opened from the portal.
The resolved `ResolvedPortalPlayback.headers` feed both the external players
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
scoped Electron request-header override (`ElectronStreamHeadersService`,
applied by `WebPlayerViewComponent` for the video players and by the Stalker
live layout for the radio audio player, which renders outside
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
"Scoped Request Header Overrides").
scoped Electron request-header override (`ElectronStreamHeadersService` — see
`docs/architecture/electron-security.md`, "Scoped Request Header Overrides").
Three surfaces apply that override, because `WebPlayerViewComponent` owns it
only for the video players and radio renders `AudioPlayerComponent` outside it:
- `WebPlayerViewComponent` — every built-in video player, on every route.
- `StalkerLiveStreamLayoutComponent` — the Stalker radio route's audio player.
- `UnifiedLiveTabComponent` (`libs/portal/shared/ui`) — the radio audio player
of the Favorites / Recently Viewed collection routes.
Each owns a single scope slot and clears it only while it still owns it, so a
handover between them cannot drop the other's credentials.
Two stream profiles exist, selected by one shared predicate:
@@ -1303,9 +1404,13 @@ Exported fields:
- full-portal serial/device/signature fields when present
- favorites and recently viewed collections
Excluded fields:
Excluded fields — everything that describes a negotiated session rather than
the connection:
- `stalkerToken`
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the cadence the profile
advertised)
- `stalkerAccountInfo`
- playback positions in backup v1