fix(stalker): bind the persisted session to its endpoint and carry it on Electron

Three review findings, all on the persisted-session work:

- `createSqliteFallbackPlaylist()` rebuilds the playlist field by field and did
  not copy `stalkerSessionIdentity`, `stalkerWatchdogTimeout` or
  `stalkerTimeslot`. Every cold Electron session therefore lost the cadence and
  could not run the identity-mismatch check at all — defeating the guard on the
  desktop app specifically.
- A persisted session was bound to the device identity only, so a playlist
  repointed at a different host kept its token, and `ensureToken()`
  re-presented it in a handshake — disclosing the previous portal's bearer
  token to an unrelated server. Sessions are now keyed by portal origin AND
  identity (`stalkerSessionFingerprint`); the in-run token cache keeps its
  identity-only key.
- The repair probe fingerprint ignored credentials, so a probe that failed on a
  wrong login stayed declined for the session even after the login was
  corrected — now that credentials are part of the discovery outcome, they
  belong in the fingerprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-03 18:41:55 +02:00
1 parent bdb4e555d6
commit d2f28334de
8 files changed
+127 -9

No files matched your search

@@ -20,14 +20,13 @@ import {
StalkerPortalDiscoveryService,
StalkerPortalIdentity,
normalizeStalkerPortalIdentity,
stalkerIdentityFingerprint,
stalkerSessionFingerprint,
type StalkerPortalErrorKind,
} from '@iptvnator/portal/stalker/data-access';
import {
createRandomId,
isFullStalkerPortalUrl,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
@Component({
@@ -251,10 +250,11 @@ export class StalkerPortalImportComponent {
// inherit the previous session.
...(stalkerToken
? {
stalkerSessionIdentity: stalkerIdentityFingerprint({
stalkerSessionIdentity: stalkerSessionFingerprint({
portalUrl,
macAddress: formValue.macAddress ?? '',
...this.toPlaylistIdentityFields(stalkerIdentity),
} as PlaylistMeta),
} as Playlist),
}
: {}),
stalkerWatchdogTimeout,
@@ -4,6 +4,7 @@ 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';
@@ -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 ?? '',
]);
}
@@ -3,8 +3,38 @@ 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';
/**
* What a persisted session is bound to: the device identity AND the endpoint
* it was negotiated against.
*
* The endpoint half is not optional. Identity alone would let a playlist
* repointed at a different host keep the old token — and `ensureToken()`
* re-presents persisted tokens in a handshake, so the previous portal's
* bearer token would be disclosed to an unrelated server.
*/
export function stalkerSessionFingerprint(playlist: Playlist): string {
return JSON.stringify([
portalOrigin(playlist.portalUrl),
stalkerIdentityFingerprint(playlist),
]);
}
function portalOrigin(portalUrl: string | undefined): string {
if (!portalUrl) {
return '';
}
try {
return new URL(portalUrl).origin;
} 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.
*
@@ -4,8 +4,8 @@ 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 {
stalkerIdentityFingerprint,
STALKER_SERIAL_NUMBER,
StalkerProfileResponse,
StalkerSessionService,
@@ -927,6 +927,41 @@ describe('StalkerSessionService identity payloads', () => {
);
});
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
@@ -969,7 +1004,7 @@ describe('StalkerSessionService identity payloads', () => {
of({
...playlist,
stalkerToken: 'SAME-IDENTITY-TOKEN',
stalkerSessionIdentity: stalkerIdentityFingerprint(playlist),
stalkerSessionIdentity: stalkerSessionFingerprint(playlist),
stalkerWatchdogTimeout: 60,
} as Playlist)
);
@@ -27,6 +27,7 @@ import {
import { isStalkerAuthorizationFailure } from './stalker-response-classification';
import { StalkerPortalError } from './stalker-portal-error';
import {
stalkerSessionFingerprint,
StalkerSessionStore,
type PersistedStalkerSession,
} from './stalker-session-store';
@@ -295,9 +296,13 @@ export class StalkerSessionService {
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
// The PERSISTED session is bound to the endpoint too: a
// playlist repointed at another host must not re-present the
// previous portal's token to it.
const sessionKey = stalkerSessionFingerprint(playlist);
const stored = await this.sessionStore.read(
playlist,
fingerprint
sessionKey
);
const result = await this.authenticate(
portalUrl,
@@ -324,7 +329,7 @@ export class StalkerSessionService {
playlist._id,
result,
stored,
fingerprint
sessionKey
);
return {
token: result.token,
@@ -434,7 +439,7 @@ export class StalkerSessionService {
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
timeslotSeconds: playlist.stalkerTimeslot,
},
fingerprint
stalkerSessionFingerprint(playlist)
);
settleSlot({
token: result.token,
@@ -441,6 +441,41 @@ 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
@@ -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;
}