From 5e4f2ca3dd8b6a52afbf2ddb8e0c0daf51ec3db1 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:49 +0200 Subject: [PATCH] docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .codex/skills/stalker-portal/SKILL.md | 24 ++- CLAUDE.md | 13 +- apps/stalker-mock-server/README.md | 49 ++++-- apps/stalker-mock-server/src/main.ts | 15 +- apps/web-e2e/playwright.config.ts | 7 +- docs/architecture/playlist-backup-restore.md | 4 +- docs/architecture/stalker-mock-server.md | 67 ++++++-- docs/architecture/stalker-portal.md | 159 +++++++++++++++--- .../src/lib/stalker-session.service.ts | 12 +- .../lib/stores/utils/stalker-request.utils.ts | 37 +++- .../electron-stream-headers.service.ts | 7 +- 11 files changed, 319 insertions(+), 75 deletions(-) diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md index 832b833b7..0ebfffadb 100644 --- a/.codex/skills/stalker-portal/SKILL.md +++ b/.codex/skills/stalker-portal/SKILL.md @@ -14,14 +14,30 @@ description: Use when changing Stalker or Ministra routes, stores, catalog or se ## Ownership - Routed UI: `libs/portal/stalker/feature/src/lib/` -- API, session, store, and normalization: +- Session, store, and normalization: `libs/portal/stalker/data-access/src/lib/` +- Wire-format and identity contracts: `libs/shared/interfaces/src/lib/` — + portal-mode predicate, MAC/device-ID utils, auth-failure classifier, `cmd` + encoder, URL/identity builders. There because Electron main cannot import + renderer libs; never fork them. - Electron transport: `apps/electron-backend/src/app/events/stalker.events.ts` - Provider-neutral collections: `libs/portal/shared/data-access/src/lib/` -Keep Stalker request and shape rules in Stalker data access. Shared portal UI +Keep Stalker shape and store rules in Stalker data access. Shared portal UI must remain provider-neutral. +## Portal Mode And Session + +Full vs. simple mode is decided by observed behavior, not URL shape, and read +only through `isFullStalkerPortalPlaylist()`. Route playlist-backed catalog, +content and playback calls through `executeStalkerRequest()`. Auth, discovery, +account-profile refresh and row-less collection resolution go direct; read +`stalker-request.utils.ts` before adding a fifth. Repair is lazy per session, +never eager. +Full portals reuse the persisted idempotent handshake token while its session +fingerprint matches, and ping `get_events` at the profile cadence (default +120 s). Auth failures are HTTP 200 plus plain text. + ## Series Contract Inside Stalker portal code, `isStalkerSeriesFlag()` is the canonical predicate @@ -69,5 +85,5 @@ Run: `workspace-dashboard-feature` test targets For the user workflow, run -`pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts` or document the missing -fixture and strongest focused coverage. +`pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts` or document the strongest +focused coverage available. diff --git a/CLAUDE.md b/CLAUDE.md index d7e0dfacb..fd09cb7d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1111,10 +1111,19 @@ engine` (restart required) or - Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). - Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read. +**Stalker Portal Mode and Endpoint Discovery**: + +- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one. +- Import probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists the proven endpoint and mode. +- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair *drives*, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. +- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them. +- Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream. +- Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding"). + **Stalker Session Authentication**: -- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`. -- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. +- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a thin facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-token-cache.ts` (in-run token + pending-auth state, tagged with the identity fingerprint), `stalker-session-store.ts` (the session persisted on the playlist row), `stalker-portal-error.ts` and `stalker-response-classification.ts`. +- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. - Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text. - Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. - The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index ed222f3ce..96edfaf5d 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -59,7 +59,10 @@ actually get wrong: accepted, so a client with a broken token pipeline fails loudly. - Auth failures come back as **HTTP 200 with a plain-text body** (`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients - that only check status codes will silently render nothing. + that only check status codes will silently render nothing. The stock server + has a third body, `Access denied.` (blocked account), which the mock does not + produce — the app classifies all three + (`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`). - The handshake is **idempotent**: presenting the MAC's current token returns that same token instead of rotating it. - `device_id`/`device_id2` are pinned to the MAC on first non-empty value; any @@ -98,19 +101,27 @@ flags a real portal sends. Outside the `static-channel-cmd` scenario they are | Environment Variable | Default | Description | |---|---|---| | `PORT` | `3210` | HTTP port the server listens on | +| `HOST` | `127.0.0.1` | Bind address. Loopback by default — the fixture serves fabricated, unauthenticated content, so set `HOST=0.0.0.0` only to deliberately point a phone or STB at it | | `NODE_ENV` | `development` | Node environment | -## Utility Endpoints +## Non-Portal Endpoints + +Besides the portal paths above, `src/main.ts` mounts: | Endpoint | Method | Description | |---|---|---| +| `/stalker?url=&macAddress=&action=&…` | `GET` | CORS-proxy mirror of the IPTVnator web-backend `/stalker` endpoint, used by PWA/Playwright runs. `macAddress`, `token` and `serialNumber` are control params turned into headers and stripped from the portal query (except `handshake`'s candidate token); the response is wrapped as `{ payload }`. Strictness follows the proxied `url`'s shape | +| `/stream/gated/:file` | `GET` | `video.mp4` / `audio.mp4` fixtures for the `gated-stream` scenario — 403 without the mac cookie **and** the MAC's current Bearer token | +| `/assets/marketing/poster/.png` | `GET` | The committed screenshot-safe poster catalog shared with the Xtream mock, served from this process so `marketing-demo` needs no second server | | `/health` | `GET` | Health check — returns `{ status: "ok" }` | -| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) | +| `/reset[?macAddress=&macAddress=…]` | `POST` | Clear generated data, favorites, session/auth state (including pinned device IDs) and watchdog counters. **Pass the MACs you own**: state is per-MAC and parallel spec files share this process, so a bare `/reset` wipes their state mid-test. Repeated params clear several MACs in one request | | `/invalidate-session?macAddress=` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal | ## API Coverage -All endpoints are served at `GET /portal.php?action=&...` matching the real Stalker protocol: +Every action is served by the same dispatcher (`src/app/routes/dispatch.ts`) at +every portal path — `GET /portal.php?action=&...` and the strict +`server/load.php` shapes alike — matching the real Stalker protocol: | Action | Description | |---|---| @@ -118,11 +129,12 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format | | `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) | | `do_auth` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise | -| `get_categories` | Category list filtered by `type` (itv/vod/series) | +| `get_main_info` | `account_info/get_main_info` — simple-mode subscription facts for the Stalker account-info dialog, in the nested `js.account_info` envelope | +| `get_categories` | Category list filtered by `type` (itv/vod/series); `get_genres_itv` / `get_genres_vod` are handled by the same handler | | `get_genres` | Genre list (mirrors categories) | | `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | | `get_all_channels` | Complete ITV channel list in one response (`type=itv` only); excludes censored (adult) genres; disabled in the `legacy-pagination` scenario | -| `create_link` | Returns a real public HLS stream URL for playback | +| `create_link` | Returns a playable stream URL — a real public HLS stream, or the credential-gated local URL in the `gated-stream` scenario. Also echoes the mock-only `cmd_received` / `query_keys_received` diagnostics that pin the client's `cmd` wire format | | `favorites` | Add / remove / get favorites (in-memory, resets on restart) | | `get_short_epg` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) | | `get_epg_info` | Bulk EPG keyed by channel id for a requested `period` window | @@ -159,7 +171,15 @@ nx e2e web-e2e nx e2e web-e2e --grep "@stalker" ``` -The test suite uses `00:1A:79:00:00:01` (default scenario) for most tests, and calls `POST /reset` in `beforeEach` to ensure a clean state between tests. +The test suite uses `00:1A:79:00:00:01` (default scenario) for most tests. State +is per-MAC and several spec files share this one server process, so `beforeEach` +resets **only the MACs the file owns** (`OWNED_MACS` in `stalker.e2e.ts`, sent as +repeated `macAddress` params in a single request) rather than clearing +everything. The sibling specs that talk to this server (`self-hosted.e2e.ts`, +the `sources-pwa` helpers) own a disjoint `00:1A:79:5F:*` range for the same +reason, and `stalker.e2e.ts` runs `mode: 'serial'` because its tests +deliberately share scenario MACs. Full contract: +[`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker-mock-server.md#test-isolation). ## EPG Behavior @@ -182,25 +202,32 @@ See [`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker ``` apps/stalker-mock-server/ ├── src/ -│ ├── main.ts # Express bootstrap +│ ├── main.ts # Express bootstrap + non-portal routes │ └── app/ │ ├── scenarios.ts # MAC → scenario config mapping │ ├── data-generator.ts # Seeded faker data generation │ ├── data-store.ts # Lazy per-MAC in-memory cache +│ ├── auth-store.ts # Tokens, device pinning, do_auth state +│ ├── request-mac.ts # MAC read from the `mac=` cookie +│ ├── marketing-poster-url.ts # Origin-resolved poster paths │ ├── routes/ -│ │ ├── portal.route.ts # /portal.php route +│ │ ├── portal.route.ts # Portal router (tolerant + strict) │ │ └── dispatch.ts # Shared Stalker action dispatcher │ └── handlers/ │ ├── handshake.handler.ts +│ ├── get-profile.handler.ts +│ ├── get-events.handler.ts │ ├── do-auth.handler.ts +│ ├── get-main-info.handler.ts │ ├── get-categories.handler.ts +│ ├── get-genres.handler.ts │ ├── get-ordered-list.handler.ts +│ ├── get-all-channels.handler.ts │ ├── get-seasons.handler.ts │ ├── create-link.handler.ts │ ├── favorites.handler.ts │ ├── get-epg-info.handler.ts -│ ├── get-short-epg.handler.ts -│ └── get-genres.handler.ts +│ └── get-short-epg.handler.ts ├── project.json ├── tsconfig.json └── README.md diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index f6798b37a..738d94039 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -105,11 +105,16 @@ app.use('/server/load.php', createPortalRouter(true)); app.use('/ministra/server/load.php', createPortalRouter(true)); /** - * Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks - * `/stalker_portal/` or `/server/load.php`; import-time normalization checks - * `/stalker_portal`). Any URL shape the client would authenticate against must - * be enforced by the proxy too, or tests would silently exercise the tolerant - * branch. + * Which proxied portal URLs this mock enforces the token on. It mirrors + * `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` — the union of + * the three predicates that used to diverge in the app before endpoint + * discovery unified them. + * + * The app itself no longer classifies by URL shape (mode is an observed, + * persisted fact), but a fixture has to decide strictness from the path + * alone: it IS the behavior being observed. Every URL shape the client would + * authenticate against must be enforced here, or tests silently exercise the + * tolerant branch. */ function isFullPortalUrlShape(url: string): boolean { return url.includes('/stalker_portal') || url.includes('/server/load.php'); diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index adee8a21c..07459da9e 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -51,7 +51,12 @@ export default defineConfig({ }, /* Run local dev servers before starting the tests. * Both the Angular app and the Stalker mock server start in parallel. - * Set MOCK_PORT to override the default mock server port (3210). + * + * MOCK_PORT only moves where the CLIENT looks — this health check and the + * specs' MOCK_SERVER constants. The mock reads PORT, which + * stalker-mock-server's serve target pins to 3210, and nothing maps one to + * the other, so MOCK_PORT alone makes the wait below time out. Use it to + * point at a mock you started yourself on that port. */ webServer: [ { diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index b45eedae0..71d955448 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -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 diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 2c193f90d..2d2909452 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -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>` 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=` 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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 309d00ce1..45d824421 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index f31a6c613..2af5538eb 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -52,8 +52,16 @@ export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL; /** * Service to manage Stalker portal session tokens. - * Handles handshake authentication for full stalker portals (/stalker_portal/c URLs). - * Persists tokens during session and handles re-authentication on auth failures. + * + * Handles handshake authentication for playlists in FULL portal mode. Mode is + * a persisted, behavior-observed fact read through + * `isFullStalkerPortalPlaylist()` — never a URL substring: since endpoint + * discovery, a token-enforcing `portal.php` panel is a full portal and a + * `server/load.php` endpoint that answers without a token is a simple one. + * + * Tokens are cached in-run and written back to the playlist row, so a session + * survives a restart; both are tagged with the identity fingerprint they were + * negotiated for. Re-authenticates on auth failures. */ @Injectable({ providedIn: 'root', diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts index 7659936f6..58d0dc6bf 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -123,13 +123,36 @@ async function dispatchStalkerRequest( } /** - * Single choke point for Stalker API calls. On top of the mode routing it - * hooks the lazy portal repair: when a request fails in a way only a wrong - * persisted endpoint/mode produces (plain-text `Authorization failed.` - * bodies on token-less requests, HTTP 404 on a vanished endpoint, terminal - * handshake failures), the repair service re-probes the portal once per - * session and — only when a different configuration is PROVEN to work — - * the request is retried against it. Healthy portals never probe. + * Choke point for Stalker catalog, content and playback calls. On top of the + * mode routing it hooks the lazy portal repair: when a request fails in a way + * only a wrong persisted endpoint/mode produces (plain-text + * `Authorization failed.` bodies on token-less requests, HTTP 404 on a + * vanished endpoint, terminal handshake failures), the repair service + * re-probes the portal once per session and — only when a different + * configuration is PROVEN to work — the request is retried against it. + * Healthy portals never probe. + * + * Four callers deliberately issue `STALKER_REQUEST` themselves, because each + * runs BELOW or BEFORE what this routes on: + * + * - `StalkerAuthApi` — `handshake`/`get_profile`/`do_auth` are what the + * full-portal branch here is implemented in terms of, so routing them back + * through it would recurse. + * - `StalkerPortalDiscoveryService` — probes precede the mode they determine. + * - `StalkerAccountInfoService.fetchViaProfile()` — 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. Its row-backed branch does come through here. + * + * The exemption is from the ROUTING, not from the repair hooked above — but + * only `fetchViaProfile()` wires `StalkerPortalRepairService` itself. + * Discovery is what repair drives, the row-less branch has no playlist to + * repair, and the auth layer needs nothing: a terminal handshake failure + * propagates out of the full-portal branch below and is caught here, which is + * why it is one of the triggers listed above. + * + * Anything new that is not auth or discovery belongs here. */ export async function executeStalkerRequest( deps: StalkerRequestDeps, diff --git a/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts b/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts index 5c2c34a7b..b60d56970 100644 --- a/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts +++ b/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts @@ -19,9 +19,10 @@ function getHeaderValue( * Single owner of the scoped Electron request-header override for built-in * playback. There is exactly one scoped override slot in the main process, so * every surface that plays a stream inline goes through this service: - * `WebPlayerViewComponent` for the web video players, and the Stalker live - * layout for the dedicated radio audio player, which never mounts a - * `WebPlayerViewComponent` at all. + * `WebPlayerViewComponent` for the web video players, plus the two surfaces + * that render `AudioPlayerComponent` for radio and therefore never mount a + * `WebPlayerViewComponent` at all — the Stalker live layout, and the unified + * live tab behind the Favorites / Recently Viewed collection routes. * * `apply()` extracts the full header set from the resolved playback — * including the portal Cookie/Authorization that auth-gated streams require —