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>
18 KiB
Stalker Mock Server Architecture
This document describes the design decisions, data flow, and extension points of the stalker-mock-server development tool.
Related Docs
Purpose
The mock server enables:
- Local development without access to a real Stalker portal
- Playwright E2E testing with predictable, deterministic data
- Scenario-based testing via predefined MAC addresses that map to specific data shapes
- Screenshot-safe marketing capture with committed fictional posters shared with the Xtream mock
Key Design Decisions
Seeded Determinism (Not Per-Request Random)
Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead:
- Data is generated once per MAC address on first request, then cached in memory.
@faker-js/fakeris seeded with the scenario'sseedvalue before generation: predefined scenario MACs use fixed seeds fromscenarios.ts; unknown MACs derive the seed from the MAC viamacToSeed().- Same MAC → identical data on every server restart.
- Restart the server to reshuffle all data.
MAC Address as Identity
Stalker portals use MAC address as the primary credential. The mock server follows the same model:
- Each unique MAC gets its own isolated dataset.
- Predefined MACs map to specific
ScenarioConfigshapes (seesrc/app/scenarios.ts). - Unknown MACs use the sum of their byte values as a seed, producing unique but deterministic data.
In-Memory Only
No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
Endpoints With Different Strictness
The app classifies a portal by observed behavior, not by URL shape: endpoint
discovery (see docs/architecture/stalker-portal.md, "Portal Mode and
Endpoint Discovery") probes candidates at import and on lazy repair, treating
a token-less content request that returns data as a token-free panel and the
middleware's plain-text auth failure as a token-enforcing full portal. The
mock serves the same action set at several paths so every classification
branch is exercisable:
| Path | Router | Behaviour |
|---|---|---|
/portal.php |
createPortalRouter(false) |
Tolerant: ignores the token and the MAC format, like most reseller panels |
/stalker_portal/server/load.php |
createPortalRouter(true) |
Strict: enforces both, like the real middleware |
/server/load.php |
createPortalRouter(true) |
Strict: the bare canonical Ministra shape, enforced identically |
/ministra/server/load.php |
createPortalRouter(true) |
Strict; the /ministra/* prefix has no portal.php (404s like genuine Ministra), so /ministra/c proves the probe's 404 fallthrough |
The /stalker proxy route applies the same rule through
isFullPortalUrlShape() — every URL the client would authenticate against is
enforced, so tests cannot silently fall into the tolerant branch.
Keeping the tolerant path is what lets the pre-existing e2e suite (which imports
portal.php) stay meaningful — it covers the simple-portal branch — while the
strict path finally covers the authenticated branch that had no coverage at all.
The strict behaviours mirror the plaintext Stalker 4.9.35 middleware
(server/lib/stb.class.php), the last openly readable ancestor of the encoded
5.x core:
-
Plain-text auth failures.
Authorization failed./Unauthorized request.are returned with HTTP 200 and atext/htmlbody, because the real serverexits before the JSON envelope is built. A client checking only status codes sees "success" and renders nothing. The/stalkerproxy route still wraps the body in the{ payload }envelope, matching whatapps/web-backenddoes. -
A handshake is not a session. The token only authorizes requests once
get_profilehas adopted it for that MAC. Adoption is deliberately stricter than the stock server: 4.9.35 issues handshake tokens statelessly and pins whatever Bearerget_profilepresents, so a forged token would become a session on a real portal — the mock only adopts tokens it actually issued, so a client with a broken token pipeline fails loudly in tests. -
Idempotent handshake. Presenting the MAC's current token returns that same token, which is what allows real clients to persist tokens across restarts.
-
Device-id pinning.
device_id/device_id2are stored on first non-empty value; any later change — including reverting to empty — is a permanentdevice conflictcarrying the "Your STB is damaged." block message. This is the only identity check the stock server actually enforces. -
signature,metrics,prehashare ignored, exactly as upstream ignores them; they exist for portals with a customaccess_filter.php. -
MAC format validation. Non-Infomir MACs (
00:1A:79:XX:XX:XX) get a bare{ status: 1 }fromget_profile. -
do_authis a boolean login step. Non-empty credentials answer{js:true}and are recorded; thelogin-requiredscenario'sget_profilekeeps answeringstatus: 2until that record exists, because the app sendsauth_second_step=1on its very first profile request and a parameter check alone would be trivially bypassed.
Session state lives in src/app/auth-store.ts and is cleared by /reset.
POST /invalidate-session?macAddress=<mac> drops a single MAC's tokens so
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
faker.seed(config.seed) // scenario seed; unknown MACs: macToSeed(mac)
│
├── generateCategories('itv', N) → itvCategories[]
│ └── generateChannels() → channels Map<categoryId, channel[]>
│ └── generateEpg() → epg Map<channelId, program[]>
│
├── generateCategories('radio', N) → radioCategories[]
│ └── generateRadioStations() → radio Map<categoryId, station[]>
│
├── generateCategories('vod', N) → vodCategories[]
│ └── generateVodItems() → vod Map<categoryId, item[]>
│ ├── normal VOD items
│ ├── is_series=1 items (fraction, Ministra flow)
│ └── embedded series[] items (fraction)
│
├── marketingFixture? → shared curated VOD catalog
│ ├── @iptvnator/shared/marketing-fixtures
│ ├── vod Map<categoryId, item[]>
│ └── vodOrder[] preserves screenshot catalog order
│
└── generateCategories('series', N) → seriesCategories[]
└── generateSeriesItems() → series Map<categoryId, item[]>
└── generateSeasons() → seasons Map<seriesItemId, season[]>
Response Shapes
All responses follow the Stalker portal.php envelope:
{ "js": <action-specific payload> }
get_categories
{
"js": [
{ "id": "2001", "title": "Action", "alias": "action" },
...
]
}
get_ordered_list (content)
{
"js": {
"data": [
{
"id": "20001",
"name": "...",
"cmd": "ffrt4://vod/20001/index.m3u8",
"screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200",
"cover": "https://picsum.photos/seed/vod-cover-20001/300/450",
"description": "...",
"actors": "...",
"director": "...",
"year": "2019",
"rating_imdb": "7.3",
"category_id": "2001",
"is_series": 0,
"has_files": 1
}
],
"total_items": 40,
"max_page_items": 14,
"cur_page": 1,
"total_pages": 3
}
}
For type=radio, items use the same paginated response envelope as live TV
channels, but each item is generated as a radio station with a radio: true
marker and an ffrt4://radio/... command.
get_ordered_list (seasons — when movie_id is present)
{
"js": [
{
"id": "30001-s1",
"name": "Season 1",
"cmd": "ffrt4://series/30001/season/1",
"series": ["1", "2", "3", ...],
"screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
"director": "...",
"actors": "...",
"year": "2021",
"rating_imdb": "8.1"
}
]
}
create_link
{
"js": {
"cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
"streamer_id": "1",
"load": "",
"error": "",
"cmd_received": "ffrt4://ch/live/1001/index.m3u8",
"query_keys_received": ["JsHttpRequest", "action", "cmd", "type"]
}
}
The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the cmd field's character sum, so the same item always returns the same stream.
cmd_received and query_keys_received are mock-only diagnostics (a real
portal does not send them): they echo the request's cmd after Express' single
query decode — the same view a PHP portal gets from $_GET — plus the sorted
set of query keys. E2E uses them to pin the client's cmd wire contract: no
double-encoding, and no query-parameter injection through cmd.
get_short_epg
{
"js": {
"data": [
{
"id": "1",
"name": "Channel Name: Program Title",
"start": "2026-02-21T10:00:00.000Z",
"stop": "2026-02-21T12:00:00.000Z",
"start_timestamp": 1740128400,
"stop_timestamp": 1740135600,
"descr": "...",
"category": "News"
}
]
}
}
get_short_epg returns the current program and upcoming items from the
generated schedule, limited by the requested size.
get_epg_info
{
"js": {
"data": {
"10000": [
{
"id": "1",
"name": "Channel Name: Program Title",
"start": "2026-02-21T10:00:00.000Z",
"stop": "2026-02-21T12:00:00.000Z",
"start_timestamp": 1740128400,
"stop_timestamp": 1740135600,
"descr": "...",
"category": "News"
}
]
}
}
}
get_epg_info returns bulk EPG keyed by channel id and filters the generated
7-day schedule from the current UTC day start through now + period.
EPG programs are generated as 2-hour slots across 7 days for each channel, starting at the current UTC day boundary.
Scenarios
Scenarios are defined in src/app/scenarios.ts. Each scenario is a ScenarioConfig:
interface ScenarioConfig {
name: string;
description: string;
seed: number;
categoryCount: { itv: number; radio: number; vod: number; series: number };
itemsPerCategory: number;
seasonsPerSeries: number;
episodesPerSeason: number;
isSeriesFraction: number; // 0–1: fraction of VOD with is_series=1
embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[]
supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
// without the ITV get_all_channels action
marketingFixture?: true; // replace generated VOD with shared posters
requiresLogin?: true; // get_profile answers status 2 until do_auth
gatedStream?: true; // create_link returns a credential-gated URL
staticChannelCmd?: true; // ITV rows need no temporary link
}
The legacy-pagination scenario (00:1A:79:00:00:06) sets
supportsGetAllChannels: false: get_all_channels then answers with an error
payload so clients fall back to the paginated get_ordered_list crawl. For
supporting scenarios, get_all_channels (get-all-channels.handler.ts,
type=itv only) returns the complete ITV channel list in one
{ js: { data, total_items } } response, excluding channels from censored
(adult) genres.
The marketing-demo scenario (00:1A:79:00:00:07) replaces faker-generated
VOD with the 35-movie provider-neutral showcase catalog from
@iptvnator/shared/marketing-fixtures. Its newest 20 movies are returned first
for the wildcard VOD listing. Fixtures keep deployment-neutral poster paths,
then the JSON middleware resolves them against the request origin (including
forwarded host/protocol) as
<portal-origin>/assets/marketing/poster/<slug>.png. main.ts serves the
committed PNG directory directly, so the Xtream server does not need to run.
Every generated ITV channel and radio station carries use_http_tmp_link and
use_load_balancing, the flags a real portal uses to tell a client whether the
row needs create_link. They are '1'/'0' for the default generators —
honest, because those rows carry ffrt4://… pseudo-URLs. The
static-channel-cmd scenario (00:1A:79:00:00:0A) sets staticChannelCmd,
which gives ITV rows both flags at '0' and a real
ffrt3 https://…m3u8 command, so apps/web-e2e/src/stalker.e2e.ts can assert
that no create_link request reaches the portal. See
docs/architecture/stalker-portal.md, "Playback Link Resolution".
Adding a New Scenario
- Add an entry to the
SCENARIOSmap insrc/app/scenarios.ts. - Use any unique MAC address as the key (lowercase, colon-separated).
- Document it in
README.mdand this file.
Favorites
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?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
apps/web-e2e/playwright.config.ts registers the mock server as a second webServer entry:
webServer: [
{ command: webServerCommand /* web:serve */, url: baseURL },
{
command: 'pnpm nx run stalker-mock-server:serve',
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 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
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:
POST /resetaccepts one or more?macAddress=params and clears only those MACs. A barePOST /resetclears 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.tsdeclares the MACs it owns inOWNED_MACSand clears exactly those in one batched request. The sibling specs that reach this server (self-hosted.e2e.ts, thesources-pwahelpers) own a disjoint00: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
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 }) => {
// 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 }) => {
// Add portal via UI or programmatically via IndexedDB
// Navigate to portal
// Assert category list matches expected count (8 for default scenario)
});
Extension Points
- New content types: Add a new generator function in
data-generator.tsand a new handler inhandlers/. - New scenarios: Add to
SCENARIOSinscenarios.ts. - Session behaviour:
auth-store.tsowns 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 Endpoints With Different Strictness.
- Slow responses: Add a
MOCK_DELAY_MSenv var and apply it in middleware for testing loading states.