feat(stalker): protocol-correct auth lifecycle (#1354)

This commit is contained in:
4gray authored and GitHub committed 2026-08-03 23:06:47 +02:00
1 parent e197409b10
commit d2a83164ec
56 files changed
+4807 -709

No files matched your search

@@ -101,16 +101,27 @@
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
}}</mat-hint>
</mat-form-field>
<!-- <mat-form-field class="w-full">
<mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
<mat-hint>{{
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field> -->
<input
matInput
type="password"
id="password"
formControlName="password"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
}}</mat-hint>
</mat-form-field>
</form>
@@ -2,13 +2,19 @@ import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
import {
stalkerSessionFingerprint,
StalkerPortalDiscoveryService,
StalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
describe('StalkerPortalImportComponent identity handling', () => {
let component: StalkerPortalImportComponent;
let portalDiscovery: { discover: jest.Mock };
let store: { dispatch: jest.Mock };
let snackBar: { open: jest.Mock };
beforeEach(() => {
portalDiscovery = {
@@ -23,6 +29,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
store = {
dispatch: jest.fn(),
};
snackBar = { open: jest.fn() };
TestBed.configureTestingModule({
providers: [
@@ -33,7 +40,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
{ provide: Store, useValue: store },
{
provide: MatSnackBar,
useValue: { open: jest.fn() },
useValue: snackBar,
},
{
provide: TranslateService,
@@ -72,7 +79,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
}
},
// Credentials ride along for portals that answer get_profile
// with status 2 (login/password required).
{ credentials: { username: '', password: '' } }
);
const playlist = store.dispatch.mock.calls[0][0].playlist;
@@ -115,7 +125,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(portalDiscovery.discover).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/c',
'00:1A:79:AA:BB:CC',
{}
{},
{ credentials: { username: '', password: '' } }
);
const playlist = store.dispatch.mock.calls[0][0].playlist;
@@ -131,6 +142,170 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(playlist.signature2).toBeUndefined();
});
it('passes the entered login and password into discovery', async () => {
component.form.patchValue({
_id: 'playlist-login',
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
username: 'user',
password: 'secret',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
expect(portalDiscovery.discover).toHaveBeenCalledWith(
expect.any(String),
'00:1A:79:00:00:08',
{},
{ credentials: { username: 'user', password: 'secret' } }
);
// Persisted so runtime re-auth can repeat do_auth after the portal
// drops the session.
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.username).toBe('user');
expect(playlist.password).toBe('secret');
});
it('persists the cadence and the identity the token was negotiated for', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
watchdogTimeoutSeconds: 90,
timeslotSeconds: 11,
});
component.form.patchValue({
_id: 'playlist-cadence',
title: 'Cadence Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.stalkerWatchdogTimeout).toBe(90);
expect(playlist.stalkerTimeslot).toBe(11);
// Without this a later start would re-present the token under an
// edited identity.
expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String));
});
it('records credentials in the imported session fingerprint', async () => {
// Otherwise the first runtime ensureToken() computes a fingerprint
// WITH the credentials, mismatches the imported one, and discards the
// session the import just established — silently defeating reuse for
// exactly the login portals this flow exists for.
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
});
component.form.patchValue({
_id: 'playlist-login-fp',
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
username: 'user',
password: 'secret',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.stalkerSessionIdentity).toBe(
stalkerSessionFingerprint({
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:08',
username: 'user',
password: 'secret',
} as Playlist)
);
});
it('records the effective cadence when the portal advertises none', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
});
component.form.patchValue({
_id: 'playlist-default-cadence',
title: 'Quiet Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
// Stored absence has to keep meaning "never profiled", or every
// later start would re-profile such a portal.
expect(playlist.stalkerWatchdogTimeout).toBe(120);
expect(playlist.stalkerTimeslot).toBe(0);
});
it("relays the portal's own refusal instead of a generic error", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('login-required'),
});
component.form.patchValue({
_id: 'playlist-refused',
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
undefined,
expect.any(Object)
);
expect(store.dispatch).not.toHaveBeenCalled();
});
it("appends the portal's explanation to a blocked refusal", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError(
'blocked',
'device conflict - device_id mismatch'
),
});
component.form.patchValue({
_id: 'playlist-blocked',
title: 'Blocked Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
// The translate mock returns keys, so both the kind headline and the
// portal-message wrapper must be present.
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
undefined,
expect.any(Object)
);
});
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
// A query merely MENTIONING /server/load.php must not make a
// panel-style /c URL look canonical and abort the offline import.
@@ -13,11 +13,15 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
asStalkerPortalError,
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
StalkerPortalDiscoveryService,
StalkerPortalIdentity,
normalizeStalkerPortalIdentity,
stalkerSessionFingerprint,
type StalkerPortalErrorKind,
} from '@iptvnator/portal/stalker/data-access';
import {
createRandomId,
@@ -129,18 +133,37 @@ export class StalkerPortalImportComponent {
const discovery = await this.portalDiscovery.discover(
originalUrl,
formValue.macAddress ?? '',
stalkerIdentity
stalkerIdentity,
{
credentials: {
username: formValue.username ?? '',
password: formValue.password ?? '',
},
}
);
let portalUrl: string;
let isFullStalkerPortal: boolean;
let stalkerToken: string | undefined;
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
// The import profile is the only get_profile some portals ever
// see: later starts reuse the token and skip it, so the cadence
// it advertises has to be persisted here or the watchdog would
// stay on the 120 s default forever. Effective values, so stored
// absence keeps meaning "never profiled".
let stalkerWatchdogTimeout: number | undefined;
let stalkerTimeslot: number | undefined;
if (discovery.status === 'resolved') {
portalUrl = discovery.portalUrl;
isFullStalkerPortal = discovery.isFullStalkerPortal;
stalkerToken = discovery.token;
if (stalkerToken) {
stalkerWatchdogTimeout =
discovery.watchdogTimeoutSeconds ??
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
stalkerTimeslot = discovery.timeslotSeconds ?? 0;
}
if (discovery.accountInfo) {
stalkerAccountInfo = {
@@ -167,10 +190,13 @@ export class StalkerPortalImportComponent {
'[StalkerImport] Authentication failed:',
discovery.error
);
// The portal explains its own refusals — a demanded login, a
// rejected one, a device conflict — so relay those words
// instead of the generic "check URL and MAC".
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
this.buildAuthErrorMessage(discovery.error),
undefined,
{ duration: 5000 }
{ duration: 8000 }
);
return;
} else if (
@@ -219,6 +245,25 @@ export class StalkerPortalImportComponent {
portalUrl,
isFullStalkerPortal,
stalkerToken,
// What this token was negotiated for: endpoint, identity AND
// credentials. Reuse is refused when any of them no longer
// matches — and the credentials must be included here, or the
// first runtime `ensureToken()` would compute a fingerprint
// WITH them, mismatch this one, and throw away the session
// the import just established.
...(stalkerToken
? {
stalkerSessionIdentity: stalkerSessionFingerprint({
portalUrl,
macAddress: formValue.macAddress ?? '',
username: formValue.username ?? '',
password: formValue.password ?? '',
...this.toPlaylistIdentityFields(stalkerIdentity),
} as Playlist),
}
: {}),
stalkerWatchdogTimeout,
stalkerTimeslot,
stalkerAccountInfo,
...this.toPlaylistIdentityFields(stalkerIdentity),
} as Playlist;
@@ -230,6 +275,36 @@ export class StalkerPortalImportComponent {
}
}
/**
* Turns an authentication failure into a message the user can act on.
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
* documented plain-text bodies); its own words are appended verbatim.
*/
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
const keyByKind: Record<StalkerPortalErrorKind, string> = {
'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
};
const base = this.translate.instant(
portalError
? keyByKind[portalError.kind]
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
);
if (portalError?.portalText) {
const detail = this.translate.instant(
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
{ message: portalError.portalText }
);
return `${base} ${detail}`;
}
return base;
}
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
stalkerSerialNumber?: string;
stalkerDeviceId1?: string;
@@ -4,7 +4,10 @@ import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import {
StalkerPortalRepairService,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DataService,
@@ -25,6 +28,7 @@ describe('StreamResolverService', () => {
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: {
getCachedToken: jest.Mock;
ensureToken: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let epgBridge: Partial<EpgRuntimeBridgeService>;
@@ -44,8 +48,8 @@ describe('StreamResolverService', () => {
};
stalkerSession = {
getCachedToken: jest.fn(() => null),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
makeAuthenticatedRequest: jest.fn(),
};
epgBridge = {
getChannelPrograms: jest.fn(),
@@ -792,7 +796,10 @@ describe('StreamResolverService', () => {
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
// Playback goes through ensureToken, never the raw cache accessor:
// that one skips the endpoint/identity/credential check, so an
// edited playlist would put the old account's token in the headers.
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN77' });
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' },
});
@@ -808,7 +815,7 @@ describe('StreamResolverService', () => {
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1');
expect(stalkerSession.ensureToken).toHaveBeenCalled();
expect(playback.headers?.['Cookie']).toContain(
'mac=00:11:22:33:44:55'
);
@@ -818,6 +825,202 @@ describe('StreamResolverService', () => {
expect(playback.origin).toBe('https://stalker.example.com');
});
it('binds the playback token to the endpoint the headers claim', async () => {
// A completed repair moved the endpoint. The headers are built from
// that moved endpoint, so the session must be negotiated for it too —
// authenticating against the pre-repair row would send a token minted
// for one host to another (and key the session cache differently from
// every other consumer, forcing a redundant handshake).
const stored = {
_id: 'stalker-1',
portalUrl: 'https://old.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
} satisfies Partial<Playlist>;
const repaired =
'https://new.example.com/stalker_portal/server/load.php';
playlistsService.getPlaylistById.mockReturnValue(of(stored));
jest.spyOn(
TestBed.inject(StalkerPortalRepairService),
'applyOverride'
).mockImplementation(
(playlist: any) => ({ ...playlist, portalUrl: repaired }) as any
);
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN88' });
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
js: { cmd: 'ffmpeg https://new.example.com:8080/live/88.ts' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::88',
name: 'Moved Portal Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '88',
stalkerCmd: 'ffrt3 http://old.example.com/media/88.mpg',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
expect.objectContaining({ portalUrl: repaired })
);
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN88');
expect(playback.origin).toBe('https://new.example.com');
});
it('carries a repaired simple→full mode into the playback headers', async () => {
// A lazy repair rewrites the portal MODE as well as the URL. The
// create_link request runs under the repaired mode and adopts a token,
// so reading the stored row's stale `isFullStalkerPortal: false` when
// resolving that token would drop the Bearer header from the very
// playback the repair was meant to rescue.
const stored = {
_id: 'stalker-1',
portalUrl: 'https://portal.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>;
playlistsService.getPlaylistById.mockReturnValue(of(stored));
jest.spyOn(
TestBed.inject(StalkerPortalRepairService),
'applyOverride'
).mockImplementation(
(playlist: any) =>
({
...playlist,
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
}) as any
);
stalkerSession.ensureToken.mockResolvedValue({ token: 'REPAIRED77' });
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
js: { cmd: 'ffmpeg https://portal.example.com:8080/live/95.ts' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::95',
name: 'Repaired Portal Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '95',
stalkerCmd: 'ffrt3 http://portal.example.com/media/95.mpg',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
expect.objectContaining({ isFullStalkerPortal: true })
);
expect(playback.headers?.['Authorization']).toBe('Bearer REPAIRED77');
});
it('authenticates a cold session for a direct-URL radio favorite', async () => {
// A direct-URL radio favorite skips create_link entirely, so on a
// cold session nothing has authenticated and the in-memory cache is
// empty — the Bearer-gated stream would 403 in the audio player.
// Re-presenting the persisted token costs one idempotent handshake.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
stalkerToken: 'PERSISTED77',
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue(null);
stalkerSession.ensureToken.mockResolvedValue({
token: 'PERSISTED77',
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::99',
name: 'Gated Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '99',
radio: 'true',
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.ensureToken).toHaveBeenCalled();
// The fast path must stay a fast path: no create_link round trip.
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77');
});
it('falls back to create_link when the cold session cannot be established', async () => {
// A portal-owned static stream with no usable session would be served
// knowing it will 401, so playback is not blocked — it takes the
// `create_link` route instead, which mints a URL carrying its own
// token and is the only path that can trigger the lazy portal repair.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue(null);
stalkerSession.ensureToken.mockRejectedValue(
new Error('handshake refused')
);
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
js: { cmd: 'ffmpeg https://stalker.example.com/radio/99-tmp.mp3' },
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::99',
name: 'Gated Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '99',
radio: 'true',
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
} satisfies UnifiedCollectionItem);
expect(playback.streamUrl).toBe(
'https://stalker.example.com/radio/99-tmp.mp3'
);
// No session, so no Bearer — the minted URL carries its own token.
expect(playback.headers?.['Authorization']).toBeUndefined();
});
it('does not authenticate a simple portal for playback headers', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-2',
portalUrl: 'https://simple.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue(null);
await service.resolvePlayback({
uid: 'stalker::stalker-2::99',
name: 'Simple Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-2',
playlistName: 'Stalker',
stalkerId: '99',
radio: 'true',
stalkerCmd: 'https://simple.example.com/radio/99.mp3',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
});
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -1027,10 +1230,12 @@ describe('StreamResolverService', () => {
_id: 'stalker-1',
portalUrl: 'https://new.example.com/portal.php',
macAddress: 'AA:BB:CC:00:00:99',
isFullStalkerPortal: false,
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW');
// A full portal, so the Bearer assertion below is meaningful: only a
// portal with a session has a token to attach at all.
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN-NEW' });
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::93',
@@ -8,6 +8,7 @@ import {
Channel,
EpgItem,
EpgProgram,
isFullStalkerPortalPlaylist,
Playlist,
isStalkerStreamCredentialSafe,
ResolvedPortalPlayback,
@@ -505,7 +506,7 @@ export class StreamResolverService {
* them to the stream origin; foreign hosts get the credential-free
* profile from the shared classifier).
*/
private buildStalkerPlayback(
private async buildStalkerPlayback(
item: UnifiedCollectionItem,
playlist: Playlist | undefined,
resolved: {
@@ -514,24 +515,51 @@ export class StreamResolverService {
streamUrl: string;
isLive?: boolean;
}
): ResolvedPortalPlayback {
): Promise<ResolvedPortalPlayback> {
// The item may carry portal/mac overrides for playlists that no
// longer exist; the builder only reads header-relevant fields.
//
// The override is applied to the ROW, not just folded in as the two
// resolved coordinates: a repair rewrites the portal MODE as well as
// the URL, and a playlist repaired from simple to full would otherwise
// keep its stale `isFullStalkerPortal: false` here. The request that
// just ran adopted a token under the repaired mode, but the mode-aware
// token resolver below would read the stale flag and hand back none —
// emitting a same-host gated stream without the Bearer header on the
// very playback the repair existed to rescue.
const headerPlaylist = {
...(playlist ?? {}),
...(playlist ? this.portalRepair.applyOverride(playlist) : {}),
macAddress: resolved.macAddress,
portalUrl: resolved.portalUrl,
} as Playlist;
const token = this.stalkerSession.getCachedToken(item.playlistId);
const crossOriginStream = isCrossOriginStalkerStream(
headerPlaylist,
resolved.streamUrl
);
// Classified before authenticating, for the same reason the static
// branch classifies first: the header builder gives a foreign host the
// credential-free profile, so a token obtained here would be discarded
// — after stalling playback behind a handshake against a portal that
// may be slow or offline while the CDN is perfectly reachable.
//
// When it IS needed, the token is resolved from `headerPlaylist`
// rather than the row it came from: those are the exact coordinates
// the headers claim, so the bearer token and the MAC cookie cannot end
// up bound to a different endpoint than the one they are sent to. A
// repair override that moved the endpoint is the live case — it
// reaches `resolved.portalUrl` but not the raw row, and every other
// session consumer (`ensureStalkerSession`, `executeStalkerRequest`)
// already authenticates against the override, so this also stops the
// resolver from keying the session cache differently and re-shaking.
const token =
playlist && !crossOriginStream
? await this.resolveStalkerPlaybackToken(headerPlaylist)
: null;
const headers = buildStalkerExternalPlaybackHeaders(
headerPlaylist,
token,
resolved.streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
headerPlaylist,
resolved.streamUrl
);
const portalOrigin = getStalkerPortalOrigin(headerPlaylist);
return {
@@ -550,6 +578,40 @@ export class StreamResolverService {
};
}
/**
* Resolves the Bearer token a full portal's stream needs.
*
* A direct-URL radio favorite skips `create_link` entirely, so on a cold
* session nothing has authenticated yet and the in-memory cache is empty —
* a same-host Bearer-gated stream would then 403 in the built-in audio
* player. `ensureToken()` re-presents the persisted token instead, which
* is a single idempotent handshake rather than a full re-auth. Failures
* stay non-fatal: many portals do not gate the stream itself.
*/
private async resolveStalkerPlaybackToken(
playlist: Playlist | undefined
): Promise<string | null> {
// The shared mode contract, not the raw flag: a legacy row with an
// absent flag but a canonical URL IS a full portal, and reading the
// property directly would skip authentication for it — a restored
// older backup opens a direct-URL radio favorite with no Bearer.
if (!playlist || !isFullStalkerPortalPlaylist(playlist)) {
return null;
}
// Always through ensureToken, never the raw cache accessor: that one
// skips the endpoint/identity/credential check, so after an edit this
// playback path would put the previous account's token into the
// stream headers. ensureToken returns the cached token when it is
// still valid for this playlist, so a warm session costs nothing.
try {
const { token } = await this.stalkerSession.ensureToken(playlist);
return token;
} catch {
return null;
}
}
private buildStalkerRadioChannel(
item: UnifiedCollectionItem,
playback: ResolvedPortalPlayback
@@ -2,6 +2,10 @@ export * from './lib/models';
export * from './lib/stores';
export * from './lib/stalker-account-info.service';
export * from './lib/stalker-content-types';
export * from './lib/stalker-portal-error';
export * from './lib/stalker-response-classification';
export * from './lib/stalker-session-store';
export * from './lib/stalker-watchdog.controller';
export * from './lib/stalker-itv-cache.service';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-portal-discovery.service';
@@ -0,0 +1,423 @@
import type { DataService } from '@iptvnator/services';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerAuthApi } from './stalker-auth.api';
/**
* Client-side mirror of the mock server's `auth-handlers.spec.ts`: the same
* documented flows — status 2 → do_auth → profile retry, blocked profiles,
* idempotent token reuse, not_valid propagation — asserted against the
* requests the client actually sends.
*/
describe('StalkerAuthApi', () => {
const portalUrl =
'https://portal.example.com/stalker_portal/server/load.php';
const macAddress = '00:1A:79:AA:BB:CC';
let sendIpcEvent: jest.Mock;
let api: StalkerAuthApi;
const logger = {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
} as unknown as ReturnType<typeof createLogger>;
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
subtle: {
digest: jest.fn(
async () => new Uint8Array(20).fill(1).buffer
),
},
},
});
sendIpcEvent = jest.fn();
api = new StalkerAuthApi(
{ sendIpcEvent } as unknown as DataService,
logger
);
});
function callsByAction(action: string) {
return sendIpcEvent.mock.calls.filter(
(call) => call[1].params.action === action
);
}
it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1', not_valid: 0 },
})
.mockResolvedValueOnce({
js: { status: 2, template: 'auth', info: 'Login required' },
})
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({
js: { status: 0, watchdog_timeout: 120, timeslot: 15 },
});
const result = await api.authenticate(
portalUrl,
macAddress,
{ deviceId1: 'DEV-1', deviceId2: 'DEV-2' },
{ credentials: { username: 'user', password: 'secret' } }
);
expect(result.token).toBe('TOKEN-1');
expect(result.watchdogTimeoutSeconds).toBe(120);
expect(result.timeslotSeconds).toBe(15);
// First profile request is NOT the second auth step.
const profiles = callsByAction('get_profile');
expect(profiles).toHaveLength(2);
expect(profiles[0][1].params.auth_second_step).toBe('0');
// The retry after do_auth is.
expect(profiles[1][1].params.auth_second_step).toBe('1');
// do_auth carries the credentials and the device identity.
const doAuth = callsByAction('do_auth');
expect(doAuth).toHaveLength(1);
expect(doAuth[0][1].params).toEqual(
expect.objectContaining({
login: 'user',
password: 'secret',
device_id: 'DEV-1',
device_id2: 'DEV-2',
})
);
});
it('throws login-required when the portal wants credentials and none are stored', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 2 } });
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'login-required',
});
// Empty credentials are never sent to the billing script.
expect(callsByAction('do_auth')).toHaveLength(0);
});
it('throws login-rejected when do_auth answers {js:false}', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 2 } })
.mockResolvedValueOnce({ js: false });
await expect(
api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'wrong' },
})
).rejects.toMatchObject({ kind: 'login-rejected' });
});
it('throws login-rejected when the profile still demands a login after do_auth', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 2 } })
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({ js: { status: 2 } });
await expect(
api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'secret' },
})
).rejects.toMatchObject({ kind: 'login-rejected' });
});
it('carries the portal explanation out of every login refusal', async () => {
// The actionable sentence ("wrong password", "subscription expired")
// rides along with the refusal, and the dialog renders it after the
// generic line. Each exit must read the response IN HAND: the retry
// explains its own rejection, and quoting the first profile back
// would describe a request that already succeeded.
const handshake = { js: { token: 'TOKEN-1', random: 'r1' } };
const credentials = { username: 'user', password: 'secret' };
sendIpcEvent
.mockResolvedValueOnce(handshake)
.mockResolvedValueOnce({
js: { status: 2, msg: 'Enter your subscriber login' },
});
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
kind: 'login-required',
portalText: 'Enter your subscriber login',
});
sendIpcEvent
.mockResolvedValueOnce(handshake)
.mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } })
.mockResolvedValueOnce({ js: false });
await expect(
api.authenticate(portalUrl, macAddress, {}, { credentials })
).rejects.toMatchObject({
kind: 'login-rejected',
portalText: 'Login needed',
});
sendIpcEvent
.mockResolvedValueOnce(handshake)
.mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } })
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({
js: { status: 2, block_msg: 'Subscription expired' },
});
await expect(
api.authenticate(portalUrl, macAddress, {}, { credentials })
).rejects.toMatchObject({
kind: 'login-rejected',
// The retry's text, not the first profile's.
portalText: 'Subscription expired',
});
});
it('decodes a blocked profile into the portal explanation', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({
js: {
status: 1,
msg: 'device conflict - device_id mismatch',
block_msg: 'Your STB is damaged.<br/> Call the provider.',
},
});
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
kind: 'blocked',
portalText:
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
});
});
it('treats a bare {status:1} profile as refused', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 1 } });
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({ kind: 'blocked' });
});
it('propagates the handshake not_valid flag into not_valid_token', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'NEW-TOKEN', random: 'r1', not_valid: 1 },
})
.mockResolvedValueOnce({ js: { status: 0 } });
await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STALE-TOKEN',
});
const profile = callsByAction('get_profile')[0][1];
expect(profile.params.not_valid_token).toBe('1');
});
it('re-presents a stored token and skips get_profile when it comes back unchanged', async () => {
sendIpcEvent.mockResolvedValueOnce({
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 0 },
});
const result = await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STORED-TOKEN',
skipProfileWhenReused: true,
});
expect(result).toEqual({
token: 'STORED-TOKEN',
reusedStoredToken: true,
});
expect(callsByAction('handshake')[0][1].params.token).toBe(
'STORED-TOKEN'
);
expect(callsByAction('get_profile')).toHaveLength(0);
});
it('runs the full profile when the portal replaces a stale stored token', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'FRESH-TOKEN', random: 'r1', not_valid: 1 },
})
.mockResolvedValueOnce({ js: { status: 0 } });
const result = await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STALE-TOKEN',
skipProfileWhenReused: true,
});
expect(result.token).toBe('FRESH-TOKEN');
expect(result.reusedStoredToken).toBe(false);
expect(callsByAction('get_profile')).toHaveLength(1);
});
it('does not reuse an echoed token the portal flagged not_valid', async () => {
// `not_valid: 1` is the portal saying the presented token is not a
// live session. Adopting it because the string matched would be
// unrecoverable: nothing writes a replacement back, so every later
// start would re-present the same dead token.
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 1 },
})
.mockResolvedValueOnce({ js: { status: 0 } });
const result = await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STORED-TOKEN',
skipProfileWhenReused: true,
});
expect(result.reusedStoredToken).toBe(false);
expect(callsByAction('get_profile')).toHaveLength(1);
expect(callsByAction('get_profile')[0][1].params.not_valid_token).toBe(
'1'
);
});
it('decodes a stringified status 2 into the login flow', async () => {
// Portals routinely stringify numeric fields — the same reason
// watchdog_timeout/timeslot accept strings. A strict === would read
// "2" as a healthy profile and skip do_auth entirely.
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: '2' } })
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({ js: { status: '0' } });
const result = await api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'secret' },
});
expect(result.token).toBe('TOKEN-1');
expect(callsByAction('do_auth')).toHaveLength(1);
expect(
callsByAction('get_profile')[1][1].params.auth_second_step
).toBe('1');
});
it('decodes a stringified status 1 as a refusal', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: '1' } });
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({ kind: 'blocked' });
});
it('never sends get_profile once the caller abandoned the attempt', async () => {
// The harm this prevents: `get_profile` is what adopts a token for
// the MAC portal-side, so an abandoned discovery attempt reaching it
// after another candidate authenticated would invalidate that
// healthy candidate's token.
const abandon = new AbortController();
sendIpcEvent.mockImplementationOnce(async () => {
abandon.abort();
return { js: { token: 'TOKEN-1', random: 'r1' } };
});
await expect(
api.authenticate(portalUrl, macAddress, {}, {
signal: abandon.signal,
})
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
expect(callsByAction('handshake')).toHaveLength(1);
expect(callsByAction('get_profile')).toHaveLength(0);
});
it('does not even handshake when already abandoned', async () => {
const abandon = new AbortController();
abandon.abort();
await expect(
api.authenticate(portalUrl, macAddress, {}, {
signal: abandon.signal,
})
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
expect(sendIpcEvent).not.toHaveBeenCalled();
});
it('rechecks the abort after the async prehash, not just before the call', async () => {
// getProfile awaits generatePrehash(); an abort landing during that
// await would otherwise still let the adopting request go out.
const abandon = new AbortController();
const digest = globalThis.crypto.subtle.digest as jest.Mock;
digest.mockImplementation(async () => {
abandon.abort();
return new Uint8Array(20).fill(1).buffer;
});
await expect(
api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', {
signal: abandon.signal,
})
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
expect(sendIpcEvent).not.toHaveBeenCalled();
});
it('stops the status-2 login flow when abandoned mid-way', async () => {
const abandon = new AbortController();
sendIpcEvent
.mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } })
.mockImplementationOnce(async () => {
abandon.abort();
return { js: { status: 2 } };
});
await expect(
api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'secret' },
signal: abandon.signal,
})
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
expect(callsByAction('do_auth')).toHaveLength(0);
});
it('classifies a transport auth-failure marker during get_profile', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({
stalkerAuthFailure: 'Unauthorized request.',
});
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
kind: 'auth-failed',
failureBody: 'Unauthorized request.',
});
});
});
@@ -0,0 +1,546 @@
import type { DataService } from '@iptvnator/services';
import {
extractStalkerAuthFailureBody,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import {
normalizeStalkerPortalIdentity,
type StalkerPortalIdentity,
} from './stalker-identity.utils';
import {
combineStalkerPortalMessages,
StalkerPortalError,
} from './stalker-portal-error';
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
/** Watchdog cadence in seconds, echoed by the portal (default 120). */
watchdog_timeout?: number | string;
/** Per-user jitter in seconds so watchdog pings do not align. */
timeslot?: number | string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
interface StalkerAuthConfirmationResponse {
js?: boolean;
}
export interface StalkerPortalCredentials {
username?: string;
password?: string;
}
export interface StalkerAuthenticateOptions {
/**
* Previously persisted session token. The handshake is idempotent: it is
* re-presented, and the portal returns it unchanged while it is still the
* MAC's session token.
*/
storedToken?: string;
/** Login/password for portals that answer `get_profile` with status 2. */
credentials?: StalkerPortalCredentials;
/**
* Skip the `get_profile` round trip when the stored token came back
* unchanged — an unchanged token is already an adopted session.
*/
skipProfileWhenReused?: boolean;
/**
* Abandons the flow between portal calls.
*
* This is what a timed-out discovery attempt needs: `get_profile` is the
* call that adopts a token for the MAC portal-side, so an abandoned
* attempt reaching it AFTER another candidate authenticated would
* invalidate that healthy candidate's token. Checking the signal before
* each call stops the request from being sent, which is the only thing
* that prevents it — a request already on the wire is processed by the
* server whether or not the client tears the socket down.
*/
signal?: AbortSignal;
}
export interface StalkerAuthenticationResult {
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
/**
* Raw envelope so callers can apply their own failure checks — endpoint
* discovery rejects a candidate whose `get_profile` refuses even though
* the handshake succeeded. Absent when the stored token was reused and
* no profile was fetched.
*/
profileResponse?: StalkerProfileResponse;
/** True when the stored token was accepted and `get_profile` was skipped. */
reusedStoredToken?: boolean;
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
interface StalkerHandshakeOutcome {
token: string;
random: string;
/** The portal flagged the presented stored token as no longer valid. */
notValid: boolean;
}
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function toFiniteNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
/** Raised when a caller abandoned the flow between portal calls. */
export class StalkerAuthAbortedError extends Error {
constructor() {
super('Stalker authentication was aborted');
this.name = 'StalkerAuthAbortedError';
}
}
function assertNotAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new StalkerAuthAbortedError();
}
}
/**
* Throws when a response is one of the portal's plain-text auth-failure
* bodies (already classified by the transport, or still raw on legacy paths).
*/
function assertNotAuthFailure(response: unknown): void {
const failureBody = extractStalkerAuthFailureBody(response);
if (failureBody) {
throw new StalkerPortalError('auth-failed', failureBody, failureBody);
}
}
/**
* Raw Stalker authentication requests plus the documented login flow.
*
* Protocol (Stalker 4.9.35): `handshake` issues an idempotent token;
* `get_profile` turns it into a session and decodes as: full profile = OK,
* `status: 1` = blocked (see `msg`/`block_msg`), `status: 2` = login/password
* required → `do_auth`, then `get_profile` again with `auth_second_step=1`.
*/
export class StalkerAuthApi {
constructor(
private readonly dataService: DataService,
private readonly logger: ReturnType<typeof createLogger>
) {}
async performHandshake(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {},
storedToken?: string,
signal?: AbortSignal
): Promise<StalkerHandshakeOutcome> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
// Re-presenting a persisted token is how a real client resumes a
// session: an idempotent portal returns it unchanged.
token: storedToken ?? '',
prehash,
JsHttpRequest: '1-xml',
};
try {
// Re-checked here rather than only at the call site: the prehash
// above is async, so an abort can land while it is being
// computed and the request would still go out.
assertNotAborted(signal);
const response =
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
assertNotAuthFailure(response);
if (response?.js?.token) {
return {
token: response.js.token,
random: response.js.random || generateRandom(),
notValid: Number(response.js.not_valid ?? 0) === 1,
};
}
this.logger.error('No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
this.logger.error('Handshake error:', error);
throw error;
}
}
/**
* Gets account profile information to validate the portal and check
* subscription. `authSecondStep` is set only on the retry after `do_auth`;
* `notValidToken` propagates the handshake's `not_valid` flag.
*/
async getProfile(
portalUrl: string,
macAddress: string,
token: string,
identity: StalkerPortalIdentity,
handshakeRandom: string,
options: {
authSecondStep?: boolean;
notValidToken?: boolean;
signal?: AbortSignal;
} = {}
): Promise<StalkerProfileResponse> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Build metrics JSON matching working app
const metrics: Record<string, string> = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
};
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: options.notValidToken ? '1' : '0',
video_out: 'hdmi',
auth_second_step: options.authSecondStep ? '1' : '0',
num_banks: '2',
metrics: JSON.stringify(metrics),
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
...(normalizedIdentity.signature1
? { signature: normalizedIdentity.signature1 }
: {}),
...(normalizedIdentity.signature2
? { signature2: normalizedIdentity.signature2 }
: {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
try {
// The last possible moment before the call that adopts the MAC's
// token portal-side — the prehash above is async, so the caller's
// pre-check can be stale by now.
assertNotAborted(options.signal);
const response =
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
assertNotAuthFailure(response);
return response;
} catch (error) {
this.logger.error('Get profile error:', error);
throw error;
}
}
/**
* Performs `do_auth` — the login/password step behind `get_profile`
* status 2. Returns the portal's bare boolean verdict.
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string,
credentials: StalkerPortalCredentials,
identity: StalkerPortalIdentity = {},
signal?: AbortSignal
): Promise<boolean> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: credentials.username ?? '',
password: credentials.password ?? '',
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
JsHttpRequest: '1-xml',
};
try {
assertNotAborted(signal);
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
assertNotAuthFailure(response);
// do_auth returns { js: true } on success
return response?.js === true;
} catch (error) {
this.logger.error('do_auth error:', error);
throw error;
}
}
/**
* Full authentication flow: handshake → (optional token reuse) →
* get_profile → (status 2 → do_auth → get_profile with
* auth_second_step=1) → decoded profile.
*/
async authenticate(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {},
options: StalkerAuthenticateOptions = {}
): Promise<StalkerAuthenticationResult> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
assertNotAborted(options.signal);
const handshake = await this.performHandshake(
portalUrl,
macAddress,
normalizedIdentity,
options.storedToken,
options.signal
);
// An unchanged stored token is already an adopted session — the
// profile round trip is what token persistence saves. `not_valid`
// vetoes that shortcut: it is the portal saying the presented token
// is not a live session, and adopting it anyway would be
// unrecoverable — nothing writes a new token back, so every later
// start would re-present the same dead one.
if (
options.skipProfileWhenReused &&
options.storedToken &&
!handshake.notValid &&
handshake.token === options.storedToken
) {
return { token: handshake.token, reusedStoredToken: true };
}
// The abandoned-attempt guard that matters: this is the call that
// adopts the token for the MAC portal-side.
assertNotAborted(options.signal);
const profile = await this.getProfile(
portalUrl,
macAddress,
handshake.token,
normalizedIdentity,
handshake.random,
{
authSecondStep: false,
notValidToken: handshake.notValid,
signal: options.signal,
}
);
// The envelope the login flow actually settled on: after a status-2
// `do_auth` that is the RETRIED profile, not the first one.
const profileResponse = await this.resolveProfile(
profile,
portalUrl,
macAddress,
handshake,
normalizedIdentity,
options
);
const resolved = profileResponse?.js;
return {
token: handshake.token,
accountInfo: resolved?.account_info,
profileResponse,
reusedStoredToken: false,
watchdogTimeoutSeconds: toFiniteNumber(resolved?.watchdog_timeout),
timeslotSeconds: toFiniteNumber(resolved?.timeslot),
};
}
/**
* Decodes `js.status` and drives the status-2 login flow. Throws a
* `StalkerPortalError` carrying the portal's own `msg`/`block_msg` text
* when the portal refused the session.
*/
private async resolveProfile(
profile: StalkerProfileResponse,
portalUrl: string,
macAddress: string,
handshake: StalkerHandshakeOutcome,
identity: StalkerPortalIdentity,
options: StalkerAuthenticateOptions
): Promise<StalkerProfileResponse> {
let settled = profile;
let js = profile?.js;
// Portals routinely stringify numeric fields (the same reason
// `watchdog_timeout`/`timeslot` are typed `number | string`), so a
// strict `=== 2` would read `"2"` as a healthy profile and skip the
// whole login flow.
if (toFiniteNumber(js?.status) === 2) {
// The portal's own sentence travels WITH the refusal — "wrong
// password", "subscription expired", "contact your provider" —
// and reading it is the whole reason these errors carry
// `portalText`. It is read at each exit rather than once up
// front because `js` advances to the retry's response below: the
// second refusal explains itself, and quoting the first one back
// would describe a request that already succeeded.
const loginText = () =>
combineStalkerPortalMessages(js?.msg, js?.block_msg);
const username = options.credentials?.username?.trim() ?? '';
const password = options.credentials?.password ?? '';
if (!username || !password) {
throw new StalkerPortalError('login-required', loginText());
}
assertNotAborted(options.signal);
const accepted = await this.doAuth(
portalUrl,
macAddress,
handshake.token,
{ username, password },
identity,
options.signal
);
if (!accepted) {
// `do_auth` answers a bare `{js: false}`, so the only text
// available here is the profile's — which is the one that
// asked for the login in the first place.
throw new StalkerPortalError('login-rejected', loginText());
}
assertNotAborted(options.signal);
const retried = await this.getProfile(
portalUrl,
macAddress,
handshake.token,
identity,
handshake.random,
{
authSecondStep: true,
notValidToken: handshake.notValid,
signal: options.signal,
}
);
settled = retried;
js = retried?.js;
if (toFiniteNumber(js?.status) === 2) {
throw new StalkerPortalError('login-rejected', loginText());
}
}
const portalText = combineStalkerPortalMessages(
js?.msg,
js?.block_msg
);
if (toFiniteNumber(js?.status) === 1 || portalText) {
this.logger.error('Profile error:', portalText ?? 'status 1');
throw new StalkerPortalError('blocked', portalText);
}
return settled;
}
}
@@ -101,7 +101,12 @@ describe('StalkerPortalDiscoveryService', () => {
expect(authenticate).toHaveBeenCalledWith(
'http://ministra.example/server/load.php',
MAC,
{ serialNumber: 'SN1' }
{ serialNumber: 'SN1' },
// Import credentials ride along for portals that answer
// get_profile with status 2 (login/password required).
// A per-attempt abort signal: a timed-out authentication must
// not send its get_profile behind the next candidate's back.
{ credentials: undefined, signal: expect.any(AbortSignal) }
);
});
@@ -190,7 +195,10 @@ describe('StalkerPortalDiscoveryService', () => {
expect(authenticate).toHaveBeenCalledWith(
'http://gated.example/portal.php',
MAC,
{}
{},
// A per-attempt abort signal: a timed-out authentication must
// not send its get_profile behind the next candidate's back.
{ credentials: undefined, signal: expect.any(AbortSignal) }
);
});
@@ -343,4 +351,153 @@ describe('StalkerPortalDiscoveryService', () => {
isFullStalkerPortal: false,
});
});
it('aborts a timed-out authentication before advancing to the next candidate', async () => {
jest.useFakeTimers();
try {
mockProbes({
'http://slow.example/portal.php': {
resolve: 'Authorization failed.',
},
});
let captured: AbortSignal | undefined;
authenticate.mockImplementation(
(_url, _mac, _identity, options) => {
captured = options?.signal;
// Hangs past the auth budget, like the real symptom.
return new Promise(() => undefined);
}
);
const discovery = service.discover(
'http://slow.example/c',
MAC
);
// Let the probe resolve so the auth attempt actually starts.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
jest.advanceTimersByTime(65_000);
await Promise.resolve();
// The abandoned attempt is cancelled, so its get_profile never
// goes out to adopt the MAC's token behind a later candidate.
expect(captured?.aborted).toBe(true);
// …and discovery does not RACE it: a request already dispatched
// cannot be un-sent, so the run drains it (bounded) instead of
// probing the next candidate while it may still land.
jest.advanceTimersByTime(15_000);
// Outcome shape is unchanged: a timed-out confirmation is still
// reported as a refusal of that endpoint.
await expect(discovery).resolves.toMatchObject({
status: 'auth-rejected',
portalUrl: 'http://slow.example/portal.php',
});
} finally {
jest.useRealTimers();
}
});
it('drains an abandoned attempt instead of probing the next candidate', async () => {
jest.useFakeTimers();
try {
// Two candidates both answer "auth required", so a resolved first
// attempt would stop the run — only a timed-out one advances.
mockProbes({
'http://slow.example/portal.php': {
resolve: 'Authorization failed.',
},
'http://slow.example/server/load.php': {
resolve: 'Authorization failed.',
},
});
let settleFirst: (() => void) | undefined;
authenticate
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
settleFirst = () => reject(new Error('late'));
})
)
.mockResolvedValue({ token: 'SECOND' });
const discovery = service.discover('http://slow.example/c', MAC);
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
jest.advanceTimersByTime(65_000);
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
// The first attempt is still on the wire: the second candidate
// must NOT have been authenticated yet, or its token could be
// invalidated by the first one's late get_profile.
expect(authenticate).toHaveBeenCalledTimes(1);
// Once it settles, the run continues immediately.
settleFirst?.();
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
expect(authenticate).toHaveBeenCalledTimes(2);
await expect(discovery).resolves.toMatchObject({
status: 'resolved',
});
} finally {
jest.useRealTimers();
}
});
it('stops discovery when the drain deadline expires with the attempt still live', async () => {
jest.useFakeTimers();
try {
// Cancellation is cooperative: neither transport can pull a
// request off the wire. An attempt still unsettled 80 s in may
// yet land its `get_profile`, which adopts the MAC's token
// portal-side — so probing on would stake the next candidate's
// freshly issued session on a request nobody can recall.
mockProbes({
'http://hung.example/portal.php': {
resolve: 'Authorization failed.',
},
'http://hung.example/server/load.php': {
resolve: 'Authorization failed.',
},
});
// Never settles — not even after the drain.
authenticate
.mockImplementationOnce(() => new Promise(() => undefined))
.mockResolvedValue({ token: 'SECOND' });
const discovery = service.discover('http://hung.example/c', MAC);
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
jest.advanceTimersByTime(65_000);
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
jest.advanceTimersByTime(15_000);
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
await expect(discovery).resolves.toMatchObject({
status: 'auth-rejected',
abandonedInFlight: true,
});
// The second candidate was never authenticated.
expect(authenticate).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});
@@ -3,6 +3,7 @@ import { DataService } from '@iptvnator/services';
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerPortalCredentials,
StalkerProfileResponse,
StalkerSessionService,
} from './stalker-session.service';
@@ -26,6 +27,13 @@ export interface StalkerPortalEndpointResolution {
token?: string;
/** Account block from the classification `get_profile` (full portals only). */
accountInfo?: StalkerProfileResponse['js']['account_info'];
/**
* Watchdog cadence the confirming `get_profile` advertised. Persisted at
* import because a later start reuses the token and skips the only
* response that carries it.
*/
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
/**
@@ -37,6 +45,16 @@ export interface StalkerPortalDiscoveryRejection {
status: 'auth-rejected';
portalUrl: string;
error?: unknown;
/**
* The abandoned attempt was STILL in flight when the drain deadline
* expired, so discovery must not advance. Cancellation is cooperative —
* neither transport can pull a request off the wire (the PWA `fetch()`
* takes no signal, and the Electron main process runs its HTTP request to
* completion) — so an attempt this far past its deadline may still land a
* `get_profile`, which adopts the MAC's token portal-side and would
* invalidate the session a later candidate had just established.
*/
abandonedInFlight?: boolean;
}
/** No candidate answered like a Stalker portal (host down or not a portal). */
@@ -51,15 +69,42 @@ export type StalkerPortalDiscoveryOutcome =
/** Per-request guard so a hanging host cannot stall discovery forever. */
const PROBE_TIMEOUT_MS = 20_000;
/** authenticate() is two sequential requests; give it a matching budget. */
const AUTH_TIMEOUT_MS = 45_000;
/**
* `authenticate()` is up to FOUR sequential requests once a portal answers
* `get_profile` with status 2 — handshake, profile, `do_auth`, profile retry —
* and the Electron transport allows each non-`create_link` call 15 s. A budget
* that only covered two would abort a valid but slow login-required portal
* before its final profile and report it as `auth-rejected`.
*/
const AUTH_TIMEOUT_MS = 4 * 15_000 + 5_000;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
/**
* How long to wait for an abandoned attempt to settle before probing the next
* candidate.
*
* Aborting cannot un-send a request: if its `get_profile` was already
* dispatched, the portal adopts that token regardless of what the client does
* to its socket. What we CAN do is refuse to race it — advancing while it is
* still in flight is what lets it invalidate the token the next candidate
* negotiates. Bounded by one request budget so a genuinely hung host cannot
* stall discovery forever; past that the risk is accepted rather than hanging.
*/
const ABANDONED_DRAIN_MS = 15_000;
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
onTimeout?: () => void
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('Stalker portal probe timed out')),
timeoutMs
);
const timer = setTimeout(() => {
// Abandon the underlying operation BEFORE advancing: the timer
// only rejects this wrapper, and a late `get_profile` would adopt
// the MAC's token portal-side, invalidating whatever the next
// candidate just negotiated.
onTimeout?.();
reject(new Error('Stalker portal probe timed out'));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
@@ -95,7 +140,8 @@ export class StalkerPortalDiscoveryService {
async discover(
rawUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
identity: StalkerPortalIdentity = {},
options: { credentials?: StalkerPortalCredentials } = {}
): Promise<StalkerPortalDiscoveryOutcome> {
const candidates = buildStalkerEndpointCandidates(rawUrl);
let authRejection: StalkerPortalDiscoveryRejection | null = null;
@@ -114,11 +160,15 @@ export class StalkerPortalDiscoveryService {
const outcome = await this.confirmFullPortal(
candidate,
macAddress,
identity
identity,
options.credentials
);
if (outcome.status === 'resolved') {
return outcome;
}
if (outcome.abandonedInFlight) {
return authRejection ?? outcome;
}
authRejection = authRejection ?? outcome;
continue;
}
@@ -158,11 +208,17 @@ export class StalkerPortalDiscoveryService {
const outcome = await this.confirmFullPortal(
candidate,
macAddress,
identity
identity,
options.credentials
);
if (outcome.status === 'resolved') {
return outcome;
}
// An attempt still on the wire outranks further probing:
// see `abandonedInFlight`.
if (outcome.abandonedInFlight) {
return authRejection ?? outcome;
}
// The endpoint is real but refused our credentials;
// remember the first such endpoint in case no later
// candidate resolves.
@@ -184,18 +240,25 @@ export class StalkerPortalDiscoveryService {
private async confirmFullPortal(
candidate: string,
macAddress: string,
identity: StalkerPortalIdentity
identity: StalkerPortalIdentity,
credentials?: StalkerPortalCredentials
): Promise<
StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection
> {
// Cooperative cancellation: `authenticate()` checks this before each
// portal call, so a timed-out attempt never sends the `get_profile`
// that would adopt the MAC's token behind the next candidate's back.
const abandon = new AbortController();
// Kept so a timed-out attempt can be drained rather than raced.
const pending = this.stalkerSession.authenticate(
candidate,
macAddress,
identity,
{ credentials, signal: abandon.signal }
);
try {
const auth = await withTimeout(
this.stalkerSession.authenticate(
candidate,
macAddress,
identity
),
AUTH_TIMEOUT_MS
const auth = await withTimeout(pending, AUTH_TIMEOUT_MS, () =>
abandon.abort()
);
// A handshake can hand out a token whose `get_profile` still
// answers a structured denial (`{js:{error:'Invalid token'}}`);
@@ -215,12 +278,41 @@ export class StalkerPortalDiscoveryService {
isFullStalkerPortal: true,
token: auth.token,
accountInfo: auth.accountInfo,
watchdogTimeoutSeconds: auth.watchdogTimeoutSeconds,
timeslotSeconds: auth.timeslotSeconds,
};
} catch (error) {
// Do not advance while the abandoned attempt may still be on the
// wire: its `get_profile` adopts the MAC's token portal-side, so
// racing it is exactly what invalidates the next candidate's
// freshly issued session.
//
// Draining is the normal case and usually returns at once — an
// aborted attempt settles as soon as its in-flight request errors
// out. The deadline exists for the attempt that does not settle,
// and reaching it is reported rather than swallowed: continuing
// would stake a working candidate's session on a request nobody
// can recall.
const DRAINED = Symbol('drained');
const outcome = await Promise.race([
pending.then(
() => DRAINED,
() => DRAINED
),
new Promise<undefined>((resolve) =>
setTimeout(resolve, ABANDONED_DRAIN_MS)
),
]);
if (outcome !== DRAINED) {
this.logger.warn(
'Abandoned Stalker authentication is still in flight after the drain deadline; stopping discovery rather than racing it'
);
}
return {
status: 'auth-rejected',
portalUrl: candidate,
error,
...(outcome === DRAINED ? {} : { abandonedInFlight: true }),
};
}
}
@@ -0,0 +1,55 @@
import {
asStalkerPortalError,
combineStalkerPortalMessages,
StalkerPortalError,
stripStalkerPortalMarkup,
} from './stalker-portal-error';
describe('stripStalkerPortalMarkup', () => {
it('strips the markup a real block_msg carries', () => {
expect(
stripStalkerPortalMarkup(
'Your STB is damaged.<br/> Call the provider.'
)
).toBe('Your STB is damaged. Call the provider.');
});
it('collapses whitespace and trims', () => {
expect(stripStalkerPortalMarkup(' a \n b ')).toBe('a b');
});
});
describe('combineStalkerPortalMessages', () => {
it('joins msg and block_msg', () => {
expect(
combineStalkerPortalMessages('device conflict', 'STB damaged')
).toBe('device conflict — STB damaged');
});
it('drops empty and duplicated parts', () => {
expect(combineStalkerPortalMessages('', undefined)).toBeUndefined();
expect(combineStalkerPortalMessages('same', 'same')).toBe('same');
});
});
describe('asStalkerPortalError', () => {
it('recognizes real instances', () => {
const error = new StalkerPortalError('blocked', 'text');
expect(asStalkerPortalError(error)).toBe(error);
});
it('recognizes a structurally equivalent object across chunk boundaries', () => {
const shaped = {
name: 'StalkerPortalError',
kind: 'login-required',
message: 'refused',
};
expect(asStalkerPortalError(shaped)?.kind).toBe('login-required');
});
it('rejects ordinary errors and non-errors', () => {
expect(asStalkerPortalError(new Error('nope'))).toBeNull();
expect(asStalkerPortalError('Access denied.')).toBeNull();
expect(asStalkerPortalError(null)).toBeNull();
});
});
@@ -0,0 +1,93 @@
import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
/**
* Why a Stalker portal refused the session.
*
* - `login-required` — `get_profile` answered `status: 2`: the portal wants
* `do_auth` with a login/password, and none are stored for the playlist.
* - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing
* script refused the credentials), or the profile still demanded a login
* after a successful `do_auth`.
* - `blocked` — `get_profile` answered `status: 1`: the account is blocked or
* the device identity conflicts. `msg`/`block_msg` explain why.
* - `auth-failed` — a request came back as one of the plain-text bodies
* (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and
* re-authentication did not recover it.
*/
export type StalkerPortalErrorKind =
| 'login-required'
| 'login-rejected'
| 'blocked'
| 'auth-failed';
/**
* `block_msg` routinely carries markup ("Your STB is damaged.<br/> Call the
* provider."); strip it before the text reaches a snackbar or error view.
*/
export function stripStalkerPortalMarkup(text: string): string {
return text
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Combines the portal's own `msg`/`block_msg` explanation into one plain-text
* line, or returns undefined when the portal sent none.
*/
export function combineStalkerPortalMessages(
msg: string | undefined,
blockMsg: string | undefined
): string | undefined {
const parts = [msg, blockMsg]
.map((part) => stripStalkerPortalMarkup(part ?? ''))
.filter((part) => part.length > 0);
// A duplicated msg/block_msg pair should not be echoed twice.
const unique = parts.filter(
(part, index) => parts.indexOf(part) === index
);
return unique.length > 0 ? unique.join(' — ') : undefined;
}
/**
* A portal-explained authentication failure. `portalText` carries the server's
* own words (already markup-stripped) when it sent any — the UI shows them
* verbatim instead of a generic "unable to load" message.
*/
export class StalkerPortalError extends Error {
constructor(
readonly kind: StalkerPortalErrorKind,
readonly portalText?: string,
readonly failureBody?: StalkerAuthFailureBody
) {
super(
`Stalker portal refused the session (${kind})` +
(portalText ? `: ${portalText}` : '')
);
this.name = 'StalkerPortalError';
}
}
/**
* Recognizes a StalkerPortalError across chunk boundaries: `instanceof` is
* checked first, but a structurally equivalent object (same name + kind) is
* accepted too so lazy-loaded consumers never mis-classify one.
*/
export function asStalkerPortalError(
error: unknown
): StalkerPortalError | null {
if (error instanceof StalkerPortalError) {
return error;
}
if (
typeof error === 'object' &&
error !== null &&
(error as { name?: unknown }).name === 'StalkerPortalError' &&
typeof (error as { kind?: unknown }).kind === 'string'
) {
return error as StalkerPortalError;
}
return null;
}
@@ -35,6 +35,7 @@ describe('StalkerPortalRepairService', () => {
/** When set, the atomic write fails AFTER the transform verified. */
let persistError: Error | null;
let setCachedToken: jest.Mock;
let adoptDiscoveredSession: jest.Mock;
let clearCachedToken: jest.Mock;
let refreshActiveWatchdogPlaylist: jest.Mock;
@@ -61,6 +62,7 @@ describe('StalkerPortalRepairService', () => {
return of(next);
});
setCachedToken = jest.fn();
adoptDiscoveredSession = jest.fn();
clearCachedToken = jest.fn();
refreshActiveWatchdogPlaylist = jest.fn();
@@ -81,6 +83,7 @@ describe('StalkerPortalRepairService', () => {
provide: StalkerSessionService,
useValue: {
setCachedToken,
adoptDiscoveredSession,
clearCachedToken,
refreshActiveWatchdogPlaylist,
},
@@ -210,12 +213,13 @@ describe('StalkerPortalRepairService', () => {
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
});
// The classification handshake already produced a token,
// tagged with the playlist as its identity source.
expect(setCachedToken).toHaveBeenCalledWith(
// The classification handshake already produced a session; it is
// adopted whole (token + cadence), tagged with the REPAIRED
// configuration as its identity source.
expect(adoptDiscoveredSession).toHaveBeenCalledWith(
'portal-1',
'TOKEN1',
expect.objectContaining({ _id: 'portal-1' })
expect.objectContaining({ _id: 'portal-1' }),
expect.objectContaining({ token: 'TOKEN1' })
);
// A repaired ACTIVE playlist must re-sync the watchdog now: a
// simple→full flip has to start the keepalive mid-session.
@@ -282,6 +286,82 @@ describe('StalkerPortalRepairService', () => {
expect(writtenRow).toBeNull();
});
it('discards a repair whose credentials changed while probing', async () => {
// Discovery can run for tens of seconds; a login saved meanwhile
// means the outcome was negotiated for an account the row no
// longer belongs to, so committing it would adopt the wrong
// session.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://panel.example/server/load.php',
isFullStalkerPortal: true,
token: 'WRONG-ACCOUNT',
});
// The row the transform sees carries the NEW login.
persistedRow = {
...MISCLASSIFIED,
username: 'edited-mid-probe',
} as Playlist;
expect(
await service.repairPortal({
...MISCLASSIFIED,
username: 'original',
})
).toBeNull();
expect(writtenRow).toBeNull();
expect(adoptDiscoveredSession).not.toHaveBeenCalled();
});
it('adopts the cadence the repair confirmation discovered', async () => {
// Caching the token alone satisfies the retry, so NO
// authentication path would ever apply the profile outcome — the
// repaired playlist would keep the default cadence until the
// token failed or the app restarted.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://panel.example/server/load.php',
isFullStalkerPortal: true,
token: 'REPAIRED',
watchdogTimeoutSeconds: 60,
timeslotSeconds: 9,
});
await service.repairPortal(MISCLASSIFIED);
expect(adoptDiscoveredSession).toHaveBeenCalledWith(
MISCLASSIFIED._id,
expect.objectContaining({
portalUrl: 'http://panel.example/server/load.php',
}),
{
token: 'REPAIRED',
watchdogTimeoutSeconds: 60,
timeslotSeconds: 9,
}
);
});
it("forwards the playlist's stored credentials to discovery", async () => {
// A login/password portal answers status 2 during confirmation;
// without the credentials the probe reports `login-required` and
// the source could never be repaired.
discover.mockResolvedValue({ status: 'unreachable' });
await service.repairPortal({
...MISCLASSIFIED,
username: 'user',
password: 'secret',
});
expect(discover).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(Object),
{ credentials: { username: 'user', password: 'secret' } }
);
});
it('probes at most once per playlist per session', async () => {
discover.mockResolvedValue({ status: 'unreachable' });
@@ -438,7 +518,7 @@ describe('StalkerPortalRepairService', () => {
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(writtenRow).toBeNull();
expect(setCachedToken).not.toHaveBeenCalled();
expect(adoptDiscoveredSession).not.toHaveBeenCalled();
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
});
@@ -322,6 +322,11 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
playlist.portalUrl ?? '',
isFullStalkerPortalPlaylist(playlist),
stalkerIdentityFingerprint(playlist),
// Credentials are part of the discovery outcome now: a probe that
// failed on a wrong login must be retried once the login is
// corrected, instead of staying declined until the app restarts.
playlist.username ?? '',
playlist.password ?? '',
]);
}
@@ -336,7 +341,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
const outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
getStalkerPortalIdentityFromPlaylist(playlist)
getStalkerPortalIdentityFromPlaylist(playlist),
{
// A login/password portal answers `get_profile` with status 2
// during confirmation. Without the stored credentials the
// probe reports `login-required` and such a source could
// never be repaired, even though the playlist holds a
// working login.
credentials: {
username: playlist.username,
password: playlist.password,
},
}
);
if (outcome.status !== 'resolved') {
@@ -416,14 +432,22 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
?.set(this.repairSourceFingerprint(playlist), override);
if (outcome.isFullStalkerPortal && outcome.token) {
// The classification handshake already authenticated; reuse its
// token so the retry does not immediately handshake again. The
// playlist itself is the identity source — a repair never
// changes WHO the session belongs to, only WHERE it talks.
this.stalkerSession.setCachedToken(
// The classification handshake already authenticated; adopt the
// whole session so the retry does not handshake again AND the
// cadence that profile advertised is applied — caching the token
// alone would satisfy the retry and leave the repaired playlist
// pinging on the default until restart. The identity source is
// the REPAIRED configuration: a repair never changes WHO the
// session belongs to, only WHERE it talks, and the session is
// bound to that endpoint.
this.stalkerSession.adoptDiscoveredSession(
playlist._id,
outcome.token,
playlist
toStalkerSessionPlaylist(this.applyOverride(playlist)),
{
token: outcome.token,
watchdogTimeoutSeconds: outcome.watchdogTimeoutSeconds,
timeslotSeconds: outcome.timeslotSeconds,
}
);
} else if (!outcome.isFullStalkerPortal) {
this.stalkerSession.clearCachedToken(playlist._id);
@@ -483,7 +507,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
row.portalUrl === playlist.portalUrl &&
isFullStalkerPortalPlaylist(row) === sourceMode &&
stalkerIdentityFingerprint(row) ===
stalkerIdentityFingerprint(playlist)
stalkerIdentityFingerprint(playlist) &&
// Credentials too, matching repairSourceFingerprint(): discovery
// can run for tens of seconds, and a login saved meanwhile means
// the outcome was negotiated for an account the row no longer
// belongs to — committing it would adopt the wrong session.
(row.username ?? '') === (playlist.username ?? '') &&
(row.password ?? '') === (playlist.password ?? '')
);
}
@@ -0,0 +1,48 @@
import {
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
} from '@iptvnator/shared/interfaces';
/**
* Checks whether a response or thrown error indicates a Stalker authorization
* failure.
*
* Every signal here is structural. `isStalkerAuthFailureResponse` covers the
* complete set of portal auth failures — the plain-text bodies, the transport
* marker Electron mints from them, and the JSON-envelope forms — and is
* shared with endpoint discovery and the lazy repair, so a phrase one layer
* classifies as an auth failure cannot be ignored by another: the session
* would otherwise keep an expired token and every later request fails.
*
* What is deliberately gone: the old `JSON.stringify(response)` regex sweep.
* It matched the phrase anywhere in an arbitrary payload — including inside
* legitimate catalog data — and existed only because the raw body reached the
* renderer unclassified. The transports classify now, so the shapes are known.
*/
export function isStalkerAuthorizationFailure(
responseOrError: unknown
): boolean {
if (!responseOrError) {
return false;
}
const response = responseOrError as Record<string, unknown>;
const message = response?.['message'];
if (
isStalkerAuthFailureResponse(responseOrError) ||
isStalkerAuthFailureMessage(message)
) {
return true;
}
// HTTP auth codes survive the IPC boundary only as message text
// (`HTTP Error 401: …`): the custom `status` property is stripped by
// ipcRenderer, so the numeric code must be read from the message.
if (typeof message === 'string' && /HTTP Error 40[13]\b/.test(message)) {
return true;
}
// Same code on a response object that never crossed IPC.
return response?.['status'] === 401 || response?.['status'] === 403;
}
@@ -0,0 +1,266 @@
import { firstValueFrom } from 'rxjs';
import type { Playlist } from '@iptvnator/shared/interfaces';
import type { PlaylistsService } from '@iptvnator/services';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
import { stalkerIdentityFingerprint } from './stalker-identity.utils';
import type { StalkerAuthenticationResult } from './stalker-auth.api';
/**
* Everything a Stalker session is bound to: the endpoint it was negotiated
* against, the device identity, and the account credentials.
*
* All three halves matter, and each was a real defect when missing:
*
* - **Endpoint** — `ensureToken()` re-presents tokens in a handshake, so a
* playlist repointed at another portal would disclose the previous one's
* bearer token to it. Origin alone is not enough: discovery deliberately
* preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are
* different portals on one host.
* - **Identity** — an edited MAC/serial must not inherit the old session.
* - **Credentials** — for a status-2 portal the login decides which account
* the token represents, so changing it must not keep serving the previous
* account's session.
*
* Used for BOTH the in-run cache and the persisted session: an edit applies
* without waiting for a restart.
*/
export function stalkerSessionFingerprint(
playlist: Pick<
Playlist,
| 'portalUrl'
| 'macAddress'
| 'username'
| 'password'
| 'stalkerSerialNumber'
| 'stalkerDeviceId1'
| 'stalkerDeviceId2'
| 'stalkerSignature1'
| 'stalkerSignature2'
>
): string {
return JSON.stringify([
portalEndpoint(playlist.portalUrl),
stalkerIdentityFingerprint(playlist as Playlist),
playlist.username ?? '',
playlist.password ?? '',
]);
}
/**
* Origin AND path, minus query/fragment — the endpoint a session belongs to.
*
* No attempt is made to treat different handler paths for the same portal
* (`…/portal.php` vs `…/server/load.php`) as equivalent. Both sides of the
* comparison read the SAME persisted `portalUrl`, so the same portal always
* yields the same key; the value changes only on a real edit or repair, and
* both of those re-authenticate anyway. An equivalence rule would add
* machinery whose only failure mode is aliasing two genuinely different
* endpoints — the exact thing this key exists to prevent.
*/
function portalEndpoint(portalUrl: string | undefined): string {
if (!portalUrl) {
return '';
}
try {
const parsed = new URL(portalUrl);
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
} catch {
// Unparseable: fall back to the raw string so a change is still a
// change — never to a constant, which would alias every endpoint.
return portalUrl;
}
}
/**
* Session facts persisted with the playlist between app starts.
*
* `identityFingerprint` is what makes the token safe to re-present: it records
* the identity the session was negotiated for, so an edited MAC or serial
* cannot inherit the previous session.
*/
export interface PersistedStalkerSession {
token?: string;
identityFingerprint?: string;
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
/**
* Reads and writes the Stalker session persisted on the playlist row.
*
* Kept out of the session service because it is the only part that needs the
* persistence stack, which is resolved lazily: the service is constructed
* during bootstrap, and pulling `PlaylistsService` in eagerly would both
* widen its dependency graph and risk a DI cycle.
*/
export class StalkerSessionStore {
constructor(
private readonly resolvePlaylistsService: () => PlaylistsService,
private readonly logger: ReturnType<typeof createLogger>
) {}
/** Reads a playlist row through the lazily-resolved persistence stack. */
async readRow(playlistId: string): Promise<Playlist | undefined> {
const row = await firstValueFrom(
this.resolvePlaylistsService().getPlaylistById(playlistId)
);
return (row as Playlist) ?? undefined;
}
/**
* Reads the session persisted with the playlist: the token, the identity
* it was negotiated for, and the watchdog cadence the portal advertised.
*
* The token is only offered for reuse when its identity still matches —
* re-presenting one minted for an edited MAC would pair a new identity
* with an old session, exactly what the in-memory cache guards against.
*/
async read(
playlist: Playlist,
fingerprint: string
): Promise<PersistedStalkerSession> {
const fromPlaylist: PersistedStalkerSession = {
token: playlist.stalkerToken || undefined,
identityFingerprint: playlist.stalkerSessionIdentity,
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
timeslotSeconds: playlist.stalkerTimeslot,
};
// Shortcut only when the object carries the cadence too. Taking it on
// the token alone would silently drop a persisted cadence for any
// playlist shape that copies the token without the timing fields.
const stored =
fromPlaylist.token &&
fromPlaylist.watchdogTimeoutSeconds !== undefined
? fromPlaylist
: await this.readFromRow(playlist, fromPlaylist);
// A token with NO recorded fingerprint is unverified, not trusted:
// playlists written before the fingerprint existed carry one, and
// re-presenting it after an endpoint or identity edit is exactly the
// disclosure the fingerprint prevents. Such a row has no cadence
// either, so it already owes a full profile — refusing the token
// costs it nothing, and the write-back then records the fingerprint.
if (stored.token && stored.identityFingerprint !== fingerprint) {
return { ...stored, token: undefined };
}
return stored;
}
private async readFromRow(
playlist: Playlist,
fallback: PersistedStalkerSession
): Promise<PersistedStalkerSession> {
try {
const stored = await this.readRow(playlist._id);
return {
// The row is authoritative, but a meta that carried a token
// the row has not seen yet must not lose it.
token: stored?.stalkerToken || fallback.token,
identityFingerprint:
stored?.stalkerSessionIdentity ??
fallback.identityFingerprint,
watchdogTimeoutSeconds: stored?.stalkerWatchdogTimeout,
timeslotSeconds: stored?.stalkerTimeslot,
};
} catch (error) {
this.logger.debug('Persisted session lookup failed:', error);
return fallback;
}
}
/**
* Propagates a successful authentication into session-side state: the
* watchdog cadence, and the write-back.
*
* Reusing a stored token skips the `get_profile` that carries the
* cadence, so the persisted cadence is applied instead — otherwise a
* portal advertising a non-default `watchdog_timeout` would sit on the
* 120 s fallback for the whole session.
*/
applyAuthenticationOutcome(
playlistId: string,
result: StalkerAuthenticationResult,
stored: PersistedStalkerSession,
fingerprint: string,
watchdog: {
applyProfileTiming: (
playlistId: string,
timing: {
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
) => void;
}
): void {
if (result.reusedStoredToken) {
watchdog.applyProfileTiming(playlistId, {
watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds,
timeslotSeconds: stored.timeslotSeconds,
});
return;
}
watchdog.applyProfileTiming(playlistId, {
watchdogTimeoutSeconds: result.watchdogTimeoutSeconds,
timeslotSeconds: result.timeslotSeconds,
});
this.write(playlistId, result, stored, fingerprint);
}
/**
* Writes a renegotiated session back, best effort.
*
* The EFFECTIVE cadence is stored, not the raw one: a portal that
* advertises nothing must still leave a value behind, or "no cadence
* stored" would keep meaning "never profiled" and every start would
* re-profile it. Stored absence therefore means exactly one thing — this
* playlist has never completed a profile.
*
* Returns the cadence that was applied, so the caller can drive the
* watchdog with the same numbers.
*/
write(
playlistId: string,
result: StalkerAuthenticationResult,
stored: PersistedStalkerSession,
fingerprint: string
): void {
const watchdogTimeout =
result.watchdogTimeoutSeconds ??
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
const timeslot = result.timeslotSeconds ?? 0;
const changed =
result.token !== stored.token ||
fingerprint !== stored.identityFingerprint ||
watchdogTimeout !== stored.watchdogTimeoutSeconds ||
timeslot !== stored.timeslotSeconds;
if (!changed) {
return;
}
try {
void firstValueFrom(
this.resolvePlaylistsService().updateStalkerSession(
playlistId,
{
stalkerToken: result.token,
stalkerSessionIdentity: fingerprint,
stalkerWatchdogTimeout: watchdogTimeout,
stalkerTimeslot: timeslot,
}
)
).catch((error) => {
this.logger.debug('Session write-back failed:', error);
});
} catch (error) {
// Persistence stack unavailable (e.g. isolated tests, or a DI
// cycle at this point in bootstrap). The session still works for
// this run; only the cross-restart reuse is lost.
this.logger.debug('Session write-back unavailable:', error);
}
}
}
@@ -2,6 +2,9 @@ import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { StalkerPortalError } from './stalker-portal-error';
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
import { stalkerSessionFingerprint } from './stalker-session-store';
import {
STALKER_SERIAL_NUMBER,
StalkerProfileResponse,
@@ -186,8 +189,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
);
const oldAuth = service.ensureToken(playlistA);
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
// ensureToken reads the persisted session before the handshake, so
// wait for the transport call rather than a fixed microtask count.
while (pendingResolvers.length === 0) {
await new Promise((resolve) => setTimeout(resolve));
}
const editedIdentity = {
@@ -198,10 +203,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
// Settle the OLD identity's handshake + profile.
pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } });
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
while (pendingResolvers.length < 2) {
await new Promise((resolve) => setTimeout(resolve));
}
pendingResolvers[1]?.({ js: {} });
pendingResolvers[1]({ js: {} });
await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' });
// The edited identity re-enters and negotiates its OWN session.
@@ -285,13 +290,19 @@ describe('StalkerSessionService identity-tagged token cache', () => {
service.setCachedToken('portal-1', 'DEAD', playlistA);
sendIpcEvent.mockResolvedValue('Authorization failed.');
// The typed refusal replaced the generic "Authorization failed after
// retry": callers need the portal's own reason to show the user.
await expect(
service.makeAuthenticatedRequest(
playlistA,
{ action: 'get_events' },
false
)
).rejects.toThrow('Authorization failed after retry');
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'auth-failed',
failureBody: 'Authorization failed.',
});
// Leaving the dead token cached would hand it to the next caller.
expect(service.getCachedToken('portal-1')).toBeNull();
@@ -374,6 +385,10 @@ describe('StalkerSessionService identity payloads', () => {
let service: StalkerSessionService;
let dataService: { sendIpcEvent: jest.Mock };
let playlistsService: {
getPlaylistById: jest.Mock;
updateStalkerSession: jest.Mock;
};
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
@@ -390,11 +405,16 @@ describe('StalkerSessionService identity payloads', () => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
};
playlistsService = {
getPlaylistById: jest.fn().mockReturnValue(of(undefined)),
updateStalkerSession: jest.fn().mockReturnValue(of(null)),
};
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{ provide: DataService, useValue: dataService },
{ provide: PlaylistsService, useValue: playlistsService },
],
});
@@ -484,13 +504,19 @@ describe('StalkerSessionService identity payloads', () => {
stalkerSignature2: 'SIGNATURE-2',
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
});
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
},
// No cadence stored for this playlist, so the profile must run.
expect.objectContaining({ skipProfileWhenReused: false })
);
});
it('treats the legacy default serial number as absent during ensureToken', async () => {
@@ -507,7 +533,12 @@ describe('StalkerSessionService identity payloads', () => {
} as Playlist);
expect(result.serialNumber).toBeUndefined();
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ skipProfileWhenReused: false })
);
});
it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => {
@@ -536,8 +567,9 @@ describe('StalkerSessionService identity payloads', () => {
const refresh = service.refreshAccountProfile(playlist);
// Two handshakes must never overlap: on strict portals the second
// would invalidate the first one's token.
await Promise.resolve();
// would invalidate the first one's token. (Macrotask flush: the
// persisted-token lookup adds awaits before authenticate fires.)
await new Promise((resolve) => setTimeout(resolve));
expect(authenticate).toHaveBeenCalledTimes(1);
releaseFirst({ token: 'session-token' });
@@ -574,7 +606,7 @@ describe('StalkerSessionService identity payloads', () => {
const first = service.refreshAccountProfile(playlist);
const second = service.refreshAccountProfile(playlist);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve));
expect(authenticate).toHaveBeenCalledTimes(1);
releases[0]({ token: 'session-token' });
@@ -708,6 +740,581 @@ describe('StalkerSessionService identity payloads', () => {
expect(accountInfo).toEqual({ login: 'user-2' });
});
it('re-presents the token persisted on the playlist during ensureToken', async () => {
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'STORED-TOKEN' });
await service.ensureToken({
_id: 'playlist-7',
portalUrl,
macAddress,
isFullStalkerPortal: true,
stalkerToken: 'STORED-TOKEN',
stalkerSessionIdentity: stalkerSessionFingerprint({
portalUrl,
macAddress,
} as Playlist),
// With the cadence present the playlist is self-sufficient, so no
// row read is needed.
stalkerWatchdogTimeout: 120,
} as Playlist);
expect(playlistsService.getPlaylistById).not.toHaveBeenCalled();
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({
storedToken: 'STORED-TOKEN',
skipProfileWhenReused: true,
})
);
});
it('falls back to the stored playlist row for the persisted token', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-8',
stalkerToken: 'ROW-TOKEN',
stalkerSessionIdentity: stalkerSessionFingerprint({
portalUrl,
macAddress,
} as Playlist),
stalkerWatchdogTimeout: 120,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'ROW-TOKEN', reusedStoredToken: true });
await service.ensureToken({
_id: 'playlist-8',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
'playlist-8'
);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ storedToken: 'ROW-TOKEN' })
);
// A reused token was not renegotiated — nothing to write back.
expect(playlistsService.updateStalkerSession).not.toHaveBeenCalled();
});
it('writes a newly negotiated token back to the playlist', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-9', stalkerToken: 'OLD-TOKEN' } as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'NEW-TOKEN',
reusedStoredToken: false,
});
await service.ensureToken({
_id: 'playlist-9',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-9',
expect.objectContaining({ stalkerToken: 'NEW-TOKEN' })
);
});
it('applies the persisted watchdog cadence when the token is reused', async () => {
// Reuse skips the get_profile that carries the cadence, so without
// the persisted values the watchdog would sit on its 120 s default
// for the whole session — for a portal that advertised 60 s that is
// slower than before this feature existed.
const watchdog = (
service as unknown as {
watchdog: { applyProfileTiming: jest.Mock };
}
).watchdog;
jest.spyOn(watchdog, 'applyProfileTiming');
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-12',
stalkerToken: 'REUSED',
stalkerWatchdogTimeout: 60,
stalkerTimeslot: 7,
} as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'REUSED',
reusedStoredToken: true,
});
await service.ensureToken({
_id: 'playlist-12',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
'playlist-12',
{ watchdogTimeoutSeconds: 60, timeslotSeconds: 7 }
);
});
it('profiles a legacy token-only playlist and refuses its unverified token', async () => {
// A playlist written before this change has a token but no recorded
// fingerprint, so nothing proves which endpoint/identity it belongs
// to — re-presenting it after an edit is the disclosure the
// fingerprint exists to prevent. It owes a full profile anyway (no
// cadence), so refusing the token costs nothing and the write-back
// then records the fingerprint.
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({
token: 'LEGACY',
reusedStoredToken: false,
watchdogTimeoutSeconds: 60,
timeslotSeconds: 5,
});
await service.ensureToken({
_id: 'playlist-16',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({
storedToken: undefined,
skipProfileWhenReused: false,
})
);
// ...and the learned cadence is persisted, so the next start skips.
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-16',
expect.objectContaining({
stalkerWatchdogTimeout: 60,
stalkerTimeslot: 5,
})
);
});
it('records the effective cadence when the portal advertises none', async () => {
// Otherwise "no cadence stored" would keep meaning "never profiled"
// and such a portal would be re-profiled on every single start.
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-17', stalkerToken: 'LEGACY' } as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'LEGACY',
reusedStoredToken: false,
});
await service.ensureToken({
_id: 'playlist-17',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-17',
expect.objectContaining({
stalkerWatchdogTimeout:
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
stalkerTimeslot: 0,
})
);
});
it('refuses a cached and persisted session after the login changed', async () => {
// For a status-2 portal the login decides WHICH account the token
// represents, so serving the old session would keep the user on the
// previous account indefinitely.
const before = {
_id: 'playlist-login-change',
portalUrl,
macAddress,
isFullStalkerPortal: true,
username: 'old-user',
password: 'old-pass',
} as Playlist;
service.setCachedToken(before._id, 'OLD-ACCOUNT-TOKEN', before);
playlistsService.getPlaylistById.mockReturnValue(
of({
...before,
stalkerToken: 'OLD-ACCOUNT-TOKEN',
stalkerSessionIdentity: stalkerSessionFingerprint(before),
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'NEW', reusedStoredToken: false });
const result = await service.ensureToken({
...before,
username: 'new-user',
password: 'new-pass',
} as Playlist);
// Neither the in-run cache nor the persisted token is reused.
expect(result.token).toBe('NEW');
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({
storedToken: undefined,
credentials: { username: 'new-user', password: 'new-pass' },
})
);
});
it('refuses a persisted token across tenants on the SAME host', async () => {
// Discovery deliberately preserves tenant base paths, so two portals
// can share an origin. An origin-only key would send tenant A's
// bearer to tenant B.
const tenantA = {
_id: 'playlist-tenant',
portalUrl: 'https://panel.example.com/tenant-a/server/load.php',
macAddress,
isFullStalkerPortal: true,
} as Playlist;
playlistsService.getPlaylistById.mockReturnValue(
of({
...tenantA,
stalkerToken: 'TENANT-A-TOKEN',
stalkerSessionIdentity: stalkerSessionFingerprint(tenantA),
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
await service.ensureToken({
...tenantA,
portalUrl: 'https://panel.example.com/tenant-b/server/load.php',
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(
'https://panel.example.com/tenant-b/server/load.php',
macAddress,
{},
expect.objectContaining({ storedToken: undefined })
);
});
it('keeps the session for the same endpoint spelled with a trailing slash', async () => {
// Normalisation must not turn a cosmetic difference into a forced
// re-authentication.
const portal = {
_id: 'playlist-slash',
portalUrl: 'https://panel.example.com/tenant-a/server/load.php',
macAddress,
isFullStalkerPortal: true,
} as Playlist;
expect(
stalkerSessionFingerprint({
...portal,
portalUrl: 'https://panel.example.com/tenant-a/server/load.php/',
} as Playlist)
).toBe(stalkerSessionFingerprint(portal));
});
it('refuses a persisted token when the playlist was repointed at another host', async () => {
// ensureToken re-presents persisted tokens in a handshake, so an
// identity-only check would disclose the previous portal's bearer
// token to an unrelated server.
const original = {
_id: 'playlist-moved',
portalUrl: 'https://old.example.com/stalker_portal/server/load.php',
macAddress,
isFullStalkerPortal: true,
} as Playlist;
playlistsService.getPlaylistById.mockReturnValue(
of({
...original,
stalkerToken: 'OLD-HOST-TOKEN',
stalkerSessionIdentity: stalkerSessionFingerprint(original),
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
await service.ensureToken({
...original,
portalUrl: 'https://new.example.com/stalker_portal/server/load.php',
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(
'https://new.example.com/stalker_portal/server/load.php',
macAddress,
{},
expect.objectContaining({ storedToken: undefined })
);
});
it('refuses a persisted token minted for a different identity', async () => {
// The playlist was edited after the token was issued. Re-presenting
// it would pair the new identity with the old session — the same bug
// class the in-memory cache guards against, just across a restart.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-identity',
stalkerToken: 'OLD-IDENTITY-TOKEN',
stalkerSessionIdentity: 'not-the-current-fingerprint',
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
await service.ensureToken({
_id: 'playlist-identity',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ storedToken: undefined })
);
});
it('reuses a persisted token whose identity still matches', async () => {
const playlist = {
_id: 'playlist-identity-ok',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
playlistsService.getPlaylistById.mockReturnValue(
of({
...playlist,
stalkerToken: 'SAME-IDENTITY-TOKEN',
stalkerSessionIdentity: stalkerSessionFingerprint(playlist),
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({
token: 'SAME-IDENTITY-TOKEN',
reusedStoredToken: true,
});
await service.ensureToken(playlist);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ storedToken: 'SAME-IDENTITY-TOKEN' })
);
});
it('reads the stored row when a token arrives without its cadence', async () => {
// A playlist shape that copies the token but not the timing fields
// must not silently downgrade the portal to the 120 s default.
const watchdog = (
service as unknown as {
watchdog: { applyProfileTiming: jest.Mock };
}
).watchdog;
jest.spyOn(watchdog, 'applyProfileTiming');
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-15',
stalkerToken: 'REUSED',
stalkerWatchdogTimeout: 45,
stalkerTimeslot: 3,
} as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'REUSED',
reusedStoredToken: true,
});
await service.ensureToken({
_id: 'playlist-15',
portalUrl,
macAddress,
isFullStalkerPortal: true,
// Token present, cadence absent — the shortcut must not fire.
stalkerToken: 'REUSED',
} as Playlist);
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
'playlist-15'
);
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
'playlist-15',
{ watchdogTimeoutSeconds: 45, timeslotSeconds: 3 }
);
});
it('persists a freshly decoded watchdog cadence with the token', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-13' } as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'NEW-TOKEN',
reusedStoredToken: false,
watchdogTimeoutSeconds: 90,
timeslotSeconds: 12,
});
await service.ensureToken({
_id: 'playlist-13',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-13',
expect.objectContaining({
stalkerToken: 'NEW-TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
// The identity the session was negotiated for, so a later
// start can refuse to reuse it after an identity edit.
stalkerSessionIdentity: expect.any(String),
})
);
});
it('rewrites the session when only the cadence changed', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-14',
stalkerToken: 'SAME',
stalkerWatchdogTimeout: 120,
} as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'SAME',
reusedStoredToken: false,
watchdogTimeoutSeconds: 45,
});
await service.ensureToken({
_id: 'playlist-14',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-14',
expect.objectContaining({ stalkerWatchdogTimeout: 45 })
);
});
it('surfaces the portal failure body when the retry also fails', async () => {
const playlist = {
_id: 'playlist-10',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
jest.spyOn(service, 'ensureToken').mockResolvedValue({
token: 'token-1',
});
// The transport marker for a blocked account, on both attempts.
dataService.sendIpcEvent.mockResolvedValue({
stalkerAuthFailure: 'Access denied.',
});
await expect(
service.makeAuthenticatedRequest(playlist, {
type: 'vod',
action: 'get_categories',
})
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'auth-failed',
failureBody: 'Access denied.',
});
// One retry happened before giving up.
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
});
it('recognizes the raw PWA plain-text failure body and retries', async () => {
const playlist = {
_id: 'playlist-11',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
jest.spyOn(service, 'ensureToken')
.mockResolvedValueOnce({ token: 'stale' })
.mockResolvedValueOnce({ token: 'fresh' });
dataService.sendIpcEvent
.mockResolvedValueOnce('Unauthorized request.')
.mockResolvedValueOnce({ js: { data: [] } });
await expect(
service.makeAuthenticatedRequest(playlist, {
type: 'itv',
action: 'get_ordered_list',
})
).resolves.toEqual({ js: { data: [] } });
});
it('throws StalkerPortalError with the portal text when auth is refused', async () => {
// Blocked account: get_profile answers status 1 with msg/block_msg.
dataService.sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'token-1', random: 'random-1' },
})
.mockResolvedValueOnce({
js: {
status: 1,
msg: 'device conflict - device_id mismatch',
block_msg: 'Your STB is damaged.<br/> Call the provider.',
},
});
await expect(
service.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'blocked',
portalText:
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
});
});
it('keeps StalkerPortalError recognizable via instanceof', () => {
expect(new StalkerPortalError('blocked')).toBeInstanceOf(Error);
});
it('passes an explicit serial into the initial handshake request', async () => {
dataService.sendIpcEvent
.mockResolvedValueOnce({
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
export interface StalkerPendingAuth {
promise: Promise<{ token: string; serialNumber?: string }>;
identityFingerprint: string;
}
/**
* In-memory session state for the current app run, keyed by playlist ID and
* tagged with the identity fingerprint the session was negotiated for.
*
* The tagging is the point: a playlist whose endpoint, identity or login was
* edited must never inherit the previous session — neither the cached token
* nor an authentication still in flight for the old one. The key comes from
* `stalkerSessionFingerprint`, so an edit applies without a restart.
*/
export class StalkerTokenCache {
private readonly tokens = new Map<
string,
{ token: string; identityFingerprint: string }
>();
private readonly pending = new Map<string, StalkerPendingAuth>();
/**
* The cached token regardless of identity. Playback fast paths that
* cannot supply an identity use this; `takeFor` is the checked accessor.
*/
get(playlistId: string): string | null {
return this.tokens.get(playlistId)?.token || null;
}
set(playlistId: string, token: string, sessionFingerprint: string): void {
this.tokens.set(playlistId, {
token,
identityFingerprint: sessionFingerprint,
});
}
clear(playlistId: string): void {
this.tokens.delete(playlistId);
}
/**
* The cached token when it was negotiated for this exact identity. A
* mismatch retires the entry and returns null, so the caller
* authenticates as the current identity instead of pairing a new one
* with an old session.
*/
takeFor(playlistId: string, fingerprint: string): string | null {
const cached = this.tokens.get(playlistId);
if (!cached) {
return null;
}
if (cached.identityFingerprint === fingerprint) {
return cached.token;
}
this.clear(playlistId);
return null;
}
/**
* Clears the cached token only while it is still the one that just
* failed. A request dispatched with the previous token can see its
* authorization failure arrive after a profile refresh has already
* cached a fresh token — deleting blindly would kill that fresh token
* and trigger a cascade of competing handshakes on strict portals.
*/
retireFailed(playlistId: string, failedToken: string | null): void {
if (failedToken && this.get(playlistId) === failedToken) {
this.clear(playlistId);
}
}
getPending(playlistId: string): StalkerPendingAuth | undefined {
return this.pending.get(playlistId);
}
setPending(playlistId: string, entry: StalkerPendingAuth): void {
this.pending.set(playlistId, entry);
}
clearPending(playlistId: string): void {
this.pending.delete(playlistId);
}
/** Retires the pending slot only when it is still the caller's own. */
clearPendingIf(playlistId: string, entry: StalkerPendingAuth): void {
if (this.pending.get(playlistId) === entry) {
this.pending.delete(playlistId);
}
}
}
@@ -0,0 +1,168 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerWatchdogController } from './stalker-watchdog.controller';
describe('StalkerWatchdogController', () => {
const playlist = {
_id: 'playlist-1',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: true,
} as Playlist;
let sendRequest: jest.Mock;
let readPersistedPlaylist: jest.Mock;
let controller: StalkerWatchdogController;
const logger = {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
} as unknown as ReturnType<typeof createLogger>;
beforeEach(() => {
jest.useFakeTimers();
sendRequest = jest.fn().mockResolvedValue({ js: {} });
// The row is the source of truth for each ping; specs that care
// about mid-session edits override this per test.
readPersistedPlaylist = jest.fn().mockResolvedValue(playlist);
controller = new StalkerWatchdogController({
sendRequest,
readPersistedPlaylist,
logger,
});
});
afterEach(() => {
controller.setActivePlaylist(null);
jest.useRealTimers();
});
/** Lets the async sendPing settle between timer advances. */
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
it('pings immediately with init=1, then every 120 s by default', async () => {
controller.setActivePlaylist(playlist);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest).toHaveBeenCalledWith(
playlist,
expect.objectContaining({
action: 'get_events',
type: 'watchdog',
init: '1',
})
);
// The legacy cadence was 25 s — nothing may fire that early.
jest.advanceTimersByTime(25_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(95_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(2);
expect(sendRequest.mock.calls[1][1]).toEqual(
expect.objectContaining({ init: '0' })
);
});
it('reschedules to the profile cadence with the timeslot offset', async () => {
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
controller.applyProfileTiming(playlist._id, {
watchdogTimeoutSeconds: 90,
timeslotSeconds: 10,
});
// Timeslot offset: nothing during the first 10 s...
jest.advanceTimersByTime(9_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
// ...then the 90 s cadence starts.
jest.advanceTimersByTime(91_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(90_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(2);
});
it('keeps the stored cadence when the watchdog restarts', async () => {
controller.applyProfileTiming(playlist._id, {
watchdogTimeoutSeconds: 60,
timeslotSeconds: 0,
});
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
jest.advanceTimersByTime(60_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
});
it('clamps a garbage watchdog_timeout instead of adopting it', async () => {
controller.applyProfileTiming(playlist._id, {
watchdogTimeoutSeconds: 1,
timeslotSeconds: 0,
});
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
// Clamped to the 30 s floor — a 1 s cadence would hammer the portal.
jest.advanceTimersByTime(29_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
jest.advanceTimersByTime(1_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
});
it('logs and continues when a ping fails — never retries or escalates', async () => {
sendRequest.mockRejectedValue(new Error('portal down'));
controller.setActivePlaylist(playlist);
await flush();
expect(logger.warn).toHaveBeenCalled();
expect(sendRequest).toHaveBeenCalledTimes(1);
// No immediate retry: the next attempt is the next scheduled tick.
jest.advanceTimersByTime(119_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(2);
});
it('stops pinging when the active playlist is cleared', async () => {
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
controller.setActivePlaylist(null);
jest.advanceTimersByTime(600_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
});
it('never pings for a simple (non-full) portal', async () => {
controller.setActivePlaylist({
...playlist,
isFullStalkerPortal: false,
} as Playlist);
jest.advanceTimersByTime(600_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,294 @@
import {
isFullStalkerPortalPlaylist,
type Playlist,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
/**
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
* default — not every 25 s as the client historically pinged.
*/
export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120;
/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */
const MIN_PERIOD_SECONDS = 30;
const MAX_PERIOD_SECONDS = 3600;
export interface StalkerWatchdogTiming {
periodSeconds: number;
/** Per-user jitter: delays the first periodic ping so clients spread out. */
timeslotSeconds: number;
}
export interface StalkerWatchdogDeps {
/** Sends one authenticated `get_events` ping. */
sendRequest: (
playlist: Playlist,
params: Record<string, string | number>
) => Promise<unknown>;
/**
* Reads the persisted row for a playlist. The row is the source of truth
* for the configuration a ping authenticates as; see `resolvePlaylist`.
*/
readPersistedPlaylist: (playlistId: string) => Promise<Playlist | undefined>;
logger: ReturnType<typeof createLogger>;
}
interface WatchdogTimers {
timeslotTimeout?: ReturnType<typeof setTimeout>;
interval?: ReturnType<typeof setInterval>;
}
/**
* Keeps the active full portal's session "online" by pinging `get_events`.
*
* The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until
* a profile has been decoded the documented default of 120 s applies. A missed
* ping never invalidates authentication — it only affects the portal's
* "online" reporting — so failures are logged and never retried or escalated.
*/
export class StalkerWatchdogController {
private readonly timers = new Map<string, WatchdogTimers>();
/** Activation-time snapshot; only a fallback when the row cannot be read. */
private readonly playlists = new Map<string, Playlist>();
private readonly inFlight = new Set<string>();
/** Survives stop/start: the cadence is a per-portal fact, not session state. */
private readonly timings = new Map<string, StalkerWatchdogTiming>();
private activePlaylistId: string | null = null;
private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null;
constructor(private readonly deps: StalkerWatchdogDeps) {}
/**
* Sets which playlist should receive periodic watchdog pings.
* This keeps some Ministra/Stalker sessions alive for live playback.
*/
setActivePlaylist(playlist?: Playlist | null): void {
const nextPlaylistId = playlist?._id ?? null;
if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) {
this.stop(this.activePlaylistId);
}
this.activePlaylistId = nextPlaylistId;
if (
!playlist ||
!isFullStalkerPortalPlaylist(playlist) ||
!playlist.portalUrl ||
!playlist.macAddress
) {
if (nextPlaylistId) {
this.stop(nextPlaylistId);
}
return;
}
this.start(playlist);
}
/** Whether this playlist currently owns the watchdog. */
isActivePlaylist(playlistId: string): boolean {
return this.activePlaylistId === playlistId;
}
/**
* Lets the repair layer overlay its in-session override on the row a ping
* resolves: the persisted row can still carry a pre-repair configuration
* while persistence is pending (or failed), and pinging that would stop
* or misdirect the keepalive.
*/
registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void {
this.playlistDecorator = decorator;
}
/**
* Applies the cadence decoded from a `get_profile` response. When the
* playlist's watchdog is already running on a different cadence, its
* periodic schedule restarts (without re-sending the init ping).
*/
applyProfileTiming(
playlistId: string,
timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number }
): void {
const next = normalizeTiming(timing);
const current = this.timings.get(playlistId);
if (
current &&
current.periodSeconds === next.periodSeconds &&
current.timeslotSeconds === next.timeslotSeconds
) {
return;
}
this.timings.set(playlistId, next);
if (this.timers.has(playlistId)) {
this.clearTimers(playlistId);
this.timers.set(playlistId, {});
this.schedule(playlistId);
}
}
private start(playlist: Playlist): void {
const playlistId = playlist._id;
this.playlists.set(playlistId, playlist);
if (this.timers.has(playlistId)) {
return;
}
this.timers.set(playlistId, {});
void this.sendPing(playlistId, '1');
this.schedule(playlistId);
}
private stop(playlistId: string): void {
this.clearTimers(playlistId);
this.timers.delete(playlistId);
this.playlists.delete(playlistId);
this.inFlight.delete(playlistId);
}
private clearTimers(playlistId: string): void {
const timers = this.timers.get(playlistId);
if (!timers) {
return;
}
if (timers.timeslotTimeout) {
clearTimeout(timers.timeslotTimeout);
}
if (timers.interval) {
clearInterval(timers.interval);
}
}
private timingFor(playlistId: string): StalkerWatchdogTiming {
return (
this.timings.get(playlistId) ?? {
periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
timeslotSeconds: 0,
}
);
}
private schedule(playlistId: string): void {
const timers = this.timers.get(playlistId);
if (!timers) {
return;
}
const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId);
const startInterval = () => {
const current = this.timers.get(playlistId);
if (!current) {
return;
}
current.timeslotTimeout = undefined;
current.interval = setInterval(() => {
void this.sendPing(playlistId, '0');
}, periodSeconds * 1000);
};
if (timeslotSeconds > 0) {
timers.timeslotTimeout = setTimeout(
startInterval,
timeslotSeconds * 1000
);
} else {
startInterval();
}
}
/**
* The playlist a ping authenticates as. The persisted row is the source of
* truth: portal metadata (endpoint, mode, MAC, identity) edited or
* repaired mid-session must reach the keepalive within one cycle — the
* activation-time snapshot is only the fallback when the row cannot be
* read.
*/
private async resolvePlaylist(
playlistId: string
): Promise<Playlist | undefined> {
try {
const row = await this.deps.readPersistedPlaylist(playlistId);
if (row) {
// Keep the fallback snapshot fresh for the next cycle.
this.playlists.set(playlistId, row);
return this.decorate(row);
}
} catch {
// Store unavailable (e.g. isolated tests): keep the snapshot.
}
const snapshot = this.playlists.get(playlistId);
return snapshot ? this.decorate(snapshot) : snapshot;
}
private decorate(playlist: Playlist): Playlist {
return this.playlistDecorator
? this.playlistDecorator(playlist)
: playlist;
}
private async sendPing(playlistId: string, init: '0' | '1'): Promise<void> {
if (this.inFlight.has(playlistId)) {
return;
}
// Claimed BEFORE the row read: it awaits, and two overlapping pings
// passing the check together would double-fire the keepalive.
this.inFlight.add(playlistId);
try {
const playlist = await this.resolvePlaylist(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!isFullStalkerPortalPlaylist(playlist)
) {
this.stop(playlistId);
return;
}
await this.deps.sendRequest(playlist, {
type: 'watchdog',
action: 'get_events',
event_active_id: '0',
cur_play_type: '0',
init,
JsHttpRequest: '1-xml',
});
} catch (error) {
// Keep failures non-fatal; the next tick can recover after a
// token refresh. Never retry or escalate: a missed ping does not
// invalidate authentication.
this.deps.logger.warn('Watchdog ping failed:', error);
} finally {
this.inFlight.delete(playlistId);
}
}
}
function normalizeTiming(timing: {
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}): StalkerWatchdogTiming {
const period = clamp(
Math.floor(
timing.watchdogTimeoutSeconds ??
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS
),
MIN_PERIOD_SECONDS,
MAX_PERIOD_SECONDS
);
const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1);
return { periodSeconds: period, timeslotSeconds: timeslot };
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
@@ -441,6 +441,115 @@ describe('PlaylistsService', () => {
).toBe('1');
});
it('carries the Stalker session fields through the SQLite fallback read', async () => {
// Without these, every cold Electron session loses the persisted
// cadence AND the identity the token was negotiated for — so the
// mismatch check cannot run and the watchdog falls back to default.
const electron = {
dbGetAppPlaylist: jest.fn(async () => ({
id: 'stalker-1',
stalkerToken: 'TOKEN',
stalkerSessionIdentity: 'fingerprint-1',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 7,
})),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async () => '1'),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
const playlist = await firstValueFrom(
service.getPlaylistById('stalker-1')
);
expect(playlist).toEqual(
expect.objectContaining({
stalkerToken: 'TOKEN',
stalkerSessionIdentity: 'fingerprint-1',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 7,
})
);
});
it('clears a stale watchdog cadence when the portal stops advertising one', async () => {
// Reusing the token skips the profile that would correct the cadence,
// so leaving the old value on the row means the next restart applies
// a cadence the portal no longer asks for.
const existingPlaylist: Playlist = {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
autoRefresh: false,
stalkerToken: 'OLD',
stalkerWatchdogTimeout: 45,
stalkerTimeslot: 7,
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' })
);
const written = dbService.update.mock.calls[0][1] as Playlist;
expect(written.stalkerToken).toBe('NEW');
expect(written.stalkerWatchdogTimeout).toBeUndefined();
expect(written.stalkerTimeslot).toBeUndefined();
});
it('persists the watchdog cadence alongside the stalker token', async () => {
const existingPlaylist: Playlist = {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
autoRefresh: false,
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updateStalkerSession('stalker-1', {
stalkerToken: 'TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
})
);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect.objectContaining({
stalkerToken: 'TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
})
);
});
it('persists hiddenGroupTitles in playlist meta updates', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-1',
@@ -242,6 +242,13 @@ export class PlaylistsService {
stalkerSignature2: playlist.stalkerSignature2,
isFullStalkerPortal: playlist.isFullStalkerPortal,
stalkerToken: playlist.stalkerToken,
// Without these the Electron cold read loses the persisted
// cadence AND the identity the token was negotiated for, so the
// mismatch check cannot run and the watchdog falls back to the
// default.
stalkerSessionIdentity: playlist.stalkerSessionIdentity,
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
stalkerTimeslot: playlist.stalkerTimeslot,
stalkerAccountInfo: playlist.stalkerAccountInfo,
} as Playlist;
}
@@ -729,6 +736,45 @@ export class PlaylistsService {
});
}
/**
* Persists a freshly negotiated Stalker session on the playlist so the
* next app start can re-present the token (the portal handshake is
* idempotent) and keep the portal's own watchdog cadence — reusing a
* token skips the `get_profile` that carries the cadence, so it has to
* survive with the token. No-ops when the playlist row does not exist
* yet: the import flow saves both with the playlist itself.
*/
updateStalkerSession(
playlistId: string,
session: {
stalkerToken: string;
stalkerSessionIdentity?: string;
stalkerWatchdogTimeout?: number;
stalkerTimeslot?: number;
}
) {
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
// The cadence is written unconditionally, including as
// `undefined`: a portal that stops advertising one must not leave
// a stale value behind for the next restart to re-apply, since
// reusing the token skips the profile that would correct it.
return this.persistPlaylistMutation({
...playlist,
stalkerToken: session.stalkerToken,
stalkerSessionIdentity: session.stalkerSessionIdentity,
stalkerWatchdogTimeout: session.stalkerWatchdogTimeout,
stalkerTimeslot: session.stalkerTimeslot,
});
});
}
updateFavorites(id: string, favorites: string[]) {
return this.serializePlaylistWrite(id, async () => {
const playlist = await firstValueFrom(this.getPlaylistById(id));
@@ -58,6 +58,23 @@ export interface Playlist {
serverTimezone?: string;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
/**
* Identity fingerprint the persisted `stalkerToken` was negotiated for.
* Re-presenting a token minted for a DIFFERENT identity (the user edited
* the MAC, serial or device ids) would pair a new identity with an old
* session — the same class of bug the in-memory cache guards against, so
* reuse is refused unless this still matches the playlist.
*/
stalkerSessionIdentity?: string;
/**
* Watchdog cadence the portal advertised in `get_profile`, persisted
* alongside the token: reusing a stored token skips the profile request
* that carries these, so without persistence the keep-alive would fall
* back to the 120 s default forever.
*/
stalkerWatchdogTimeout?: number;
/** Per-user watchdog jitter (seconds) from `get_profile`. */
stalkerTimeslot?: number;
/** Serial number for stalker portal - generated once and stored for consistency */
stalkerSerialNumber?: string;
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
@@ -1,5 +1,7 @@
import {
classifyStalkerAuthFailureBody,
createStalkerAuthFailureMarker,
extractStalkerAuthFailureBody,
isStalkerAuthFailureBody,
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
@@ -98,6 +100,58 @@ describe('isStalkerAuthFailureResponse', () => {
});
});
describe('transport marker', () => {
it('round-trips through create / detect / extract', () => {
const marker = createStalkerAuthFailureMarker('Access denied.');
expect(isStalkerAuthFailureResponse(marker)).toBe(true);
expect(extractStalkerAuthFailureBody(marker)).toBe('Access denied.');
});
it('is what keeps the repair trigger working once Electron classifies', () => {
// The main process replaces the plain-text body with this marker, so
// a detector that only knew the raw string would silently stop
// triggering portal repair on the desktop app.
expect(
isStalkerAuthFailureResponse({
stalkerAuthFailure: 'Authorization failed.',
})
).toBe(true);
});
it('does not accept arbitrary objects as markers', () => {
expect(isStalkerAuthFailureResponse({ stalkerAuthFailure: 'nope' })).toBe(
false
);
expect(extractStalkerAuthFailureBody({})).toBeNull();
});
});
describe('extractStalkerAuthFailureBody', () => {
it('extracts from the raw PWA payload string', () => {
expect(extractStalkerAuthFailureBody('Authorization failed. 12')).toBe(
'Authorization failed.'
);
});
it('extracts from a {js: body} envelope', () => {
expect(
extractStalkerAuthFailureBody({ js: 'Authorization failed. 75' })
).toBe('Authorization failed.');
});
it('reports no canonical body for the structured panel wording', () => {
// Still an auth failure — just not one of the middleware's bodies.
const response = { js: { error: 'Invalid token' } };
expect(extractStalkerAuthFailureBody(response)).toBeNull();
expect(isStalkerAuthFailureResponse(response)).toBe(true);
});
it('returns null for ordinary responses', () => {
expect(extractStalkerAuthFailureBody({ js: { data: [] } })).toBeNull();
expect(extractStalkerAuthFailureBody(undefined)).toBeNull();
});
});
describe('isStalkerAuthFailureMessage', () => {
it('accepts messages our own auth layer produces', () => {
expect(
@@ -128,17 +128,77 @@ export function isStalkerAuthFailureMessage(message: unknown): boolean {
}
/**
* Whether a portal response is an authorization failure in EITHER wire
* shape: the middleware's plain-text body, or the JSON envelope some panels
* answer instead. Classification and the lazy-repair trigger must use this,
* not the string-only primitive: a JSON-failing panel would otherwise be
* persisted as token-free and never repaired.
* Structured shape a transport returns in place of the raw plain-text body.
*
* Returned, never thrown: `ipcRenderer.invoke` strips custom properties from
* rejected values, so a classified body would not survive the trip to the
* renderer as an error. The Electron main process mints this because it is
* where the body arrives; the PWA proxy still delivers the raw string, and
* every consumer goes through the predicates below rather than caring which.
*/
export interface StalkerAuthFailureMarker {
stalkerAuthFailure: StalkerAuthFailureBody;
}
export function createStalkerAuthFailureMarker(
body: StalkerAuthFailureBody
): StalkerAuthFailureMarker {
return { stalkerAuthFailure: body };
}
function readAuthFailureMarker(
value: unknown
): StalkerAuthFailureBody | null {
if (typeof value !== 'object' || value === null) {
return null;
}
return classifyStalkerAuthFailureBody(
(value as StalkerAuthFailureMarker).stalkerAuthFailure
);
}
/**
* Extracts the canonical failure body from any shape an auth failure reaches
* a consumer in: the transport marker (Electron), the raw plain-text payload
* (PWA proxy), or a `{js: '<body>'}` envelope. Returns null for everything
* else, including the structured `{js: {error|msg}}` form — that one is a
* panel's own wording, not one of the middleware's three bodies, so it has
* no canonical body to report. Use `isStalkerAuthFailureResponse` when the
* question is merely "did authorization fail?".
*/
export function extractStalkerAuthFailureBody(
value: unknown
): StalkerAuthFailureBody | null {
return (
readAuthFailureMarker(value) ??
classifyStalkerAuthFailureBody(value) ??
classifyStalkerAuthFailureBody(
typeof value === 'object' && value !== null && 'js' in value
? (value as { js?: unknown }).js
: undefined
)
);
}
/**
* Whether a portal response is an authorization failure in ANY wire shape:
* the middleware's plain-text body, the transport marker minted from it, or
* the JSON envelope some panels answer instead. Classification and the
* lazy-repair trigger must use this, not the string-only primitive: a
* JSON-failing panel would otherwise be persisted as token-free and never
* repaired, and an Electron-classified body would stop triggering repair at
* all.
*/
export function isStalkerAuthFailureResponse(response: unknown): boolean {
if (isStalkerAuthFailureBody(response)) {
return true;
}
if (readAuthFailureMarker(response) !== null) {
return true;
}
if (
response === null ||
typeof response !== 'object' ||
@@ -157,9 +157,7 @@
} @else if (isStalkerCategoryFailed()) {
<app-workspace-context-error-view
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
[description]="
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
"
[description]="stalkerCategoryErrorDescription()"
[showActionButtons]="false"
/>
} @else {
@@ -17,7 +17,10 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
StalkerStore,
asStalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import {
PortalCategorySortMode,
persistPortalCategorySortMode,
@@ -152,6 +155,28 @@ export class WorkspaceContextPanelComponent {
this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed =
this.stalkerStore.isCategoryResourceFailed;
/**
* When category loading failed because the portal refused the session,
* the portal's own explanation (msg/block_msg or the documented
* plain-text failure body) replaces the generic hint; a login-required
* refusal gets its own actionable text.
*/
readonly stalkerCategoryErrorDescription = computed(() => {
const portalError = asStalkerPortalError(
this.isStalkerCategoryFailed()
);
if (portalError?.portalText) {
return portalError.portalText;
}
if (portalError?.kind === 'login-required') {
return this.translate.instant(
'PORTALS.ERROR_VIEW.STALKER_LOGIN_REQUIRED'
);
}
return this.translate.instant(
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT'
);
});
// Category count badges are only available for Stalker Live TV, where the
// full channel list is cached — VOD/series/radio still page lazily, so
// their per-category totals are unknown.