From fabffcc9028964ba67e885c064a6eae00a81d7bc Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 9 Aug 2026 18:23:34 +0200 Subject: [PATCH] fix(stalker): reject replaced late edit targets --- CLAUDE.md | 1 + ...playlist-connection-editor.service.spec.ts | 17 +++++++++- ...lker-playlist-connection-editor.service.ts | 14 +++++--- docs/architecture/stalker-portal.md | 5 ++- .../playlist-info.component.spec.ts | 4 +++ .../playlist-info/playlist-info.component.ts | 3 +- ...talker-playlist-connection-editor.token.ts | 3 +- ...stalker-edited-session-coordinator.spec.ts | 34 ++++++++++++++++++- .../lib/stalker-edited-session-coordinator.ts | 31 +++++++++++++---- .../src/lib/stalker-session.service.ts | 7 ++-- 10 files changed, 102 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c023a0536..f0221481e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1261,6 +1261,7 @@ engine` (restart required) or **Stalker Portal Mode and Endpoint Discovery**: +- A late post-navigation Edit is a compare-and-merge: its queued transform must still see the source connection authority captured when Edit began. Delete/restore or replacement under the same playlist ID aborts the late merge, while concurrent title/EPG metadata remains mergeable. - Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one. - Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `/portal.php`, while canonical-shaped unreachable addresses still abort. If bounded discovery returns while abandoned authentication remains on the wire, the refusal is shown immediately but Add and every form field stay disabled until its settlement promise resolves. - The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays byte-identical even if the dialog hydrated before a concurrent discovery committed; it skips discovery. A persisted `portalUrl` keeps the row on the Stalker save path even if legacy Xtream fields remain. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled. That late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only connection/session fields into the current row, so newer title/EPG/metadata edits win; its returned row feeds the state-only update together with discovery's transient session patch, so NgRx replaces or clears its session fields while success UI is suppressed. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape. diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts index 47f01976a..6ff536f71 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts @@ -87,13 +87,28 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { }); it('resolves a simple portal and clears the previous full session', async () => { + const sourcePlaylist = { + ...draft, + portalUrl: 'https://old.example.com/server/load.php', + }; discovery.discover.mockResolvedValue({ status: 'resolved', portalUrl: 'https://portal.example.com/portal.php', isFullStalkerPortal: false, }); - const result = await service.resolveConnection(draft); + const result = await service.resolveConnection(draft, sourcePlaylist); + + expect(stalkerSession.beginEditDiscovery).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: draft.portalUrl, + stalkerSerialNumber: 'SERIAL', + }), + expect.objectContaining({ + portalUrl: sourcePlaylist.portalUrl, + stalkerSerialNumber: sourcePlaylist.stalkerSerialNumber, + }) + ); expect(discovery.discover).toHaveBeenCalledWith( draft.portalUrl, diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts index b8e5b0dd4..34374856d 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts @@ -88,7 +88,8 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis } async resolveConnection( - playlist: PlaylistMeta + playlist: PlaylistMeta, + sourcePlaylist: PlaylistMeta = playlist ): Promise { const identity = normalizeStalkerPortalIdentity({ serialNumber: playlist.stalkerSerialNumber, @@ -106,7 +107,8 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis stalkerSignature2: identity.signature2 ?? '', }; const fence = await this.beginEditFence( - this.toRuntimePlaylist(normalizedPlaylist) + this.toRuntimePlaylist(normalizedPlaylist), + this.toRuntimePlaylist(sourcePlaylist) ); let outcome: Awaited< ReturnType @@ -207,7 +209,8 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis } private async beginEditFence( - playlist: Playlist + playlist: Playlist, + sourcePlaylist: Playlist = playlist ): Promise { // Both fences are installed synchronously before either drain is // awaited. No new authentication or lazy repair can start while the @@ -217,7 +220,10 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis ); let fence: StalkerEditFence | undefined; try { - fence = await this.stalkerSession.beginEditDiscovery(playlist); + fence = await this.stalkerSession.beginEditDiscovery( + playlist, + sourcePlaylist + ); await repairDrain; this.editFences.set(playlist._id, fence); return fence; diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index de6c03b86..03beb2604 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -214,7 +214,10 @@ late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only the resolved connection/session fields into the current row, so a newer title, EPG, or other metadata edit wins. The returned merged row feeds the state-only update, while dialog close and success UI are suppressed after -destruction. A row identified by its persisted `portalUrl` stays on the Stalker +destruction. The transform also requires the current row to retain the source +connection authority captured when Edit began; delete/restore or replacement +under the same ID therefore aborts instead of receiving a late portal/session +merge. A row identified by its persisted `portalUrl` stays on the Stalker save path even if legacy Xtream fields remain, so an unrelated Xtream write cannot strand the Edit reservation. Before discovery starts, Edit reserves the playlist ID; an diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts index 4f5466035..917768e00 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -669,6 +669,10 @@ describe('PlaylistInfoComponent', () => { stalkerDeviceId2: 'STORED-DEVICE-2', stalkerSignature1: 'STORED-SIGNATURE-1', stalkerSignature2: 'STORED-SIGNATURE-2', + }), + expect.objectContaining({ + portalUrl: 'https://portal.example.com/c', + stalkerSerialNumber: 'STORED-SERIAL', }) ); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 52c3e3214..49d1b7a00 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -413,7 +413,8 @@ export class PlaylistInfoComponent { ) { const result = await this.stalkerConnectionEditor.resolveConnection( - normalizedPlaylist + normalizedPlaylist, + this.playlist ); if ( result.status !== diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts index 95c9be7d9..1bf26782c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts @@ -41,7 +41,8 @@ export interface StalkerResolvedConnectionApplyOptions { export interface StalkerPlaylistConnectionEditor { resolveConnection( - playlist: PlaylistMeta + playlist: PlaylistMeta, + sourcePlaylist?: PlaylistMeta ): Promise; /** Atomically persists a resolved edit, then synchronizes in-run state. */ applyResolvedConnection( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts index 3dfb0cfd5..bd25888d0 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.spec.ts @@ -341,7 +341,10 @@ describe('Stalker edited-session coordination', () => { stalkerWatchdogTimeout: 90, stalkerTimeslot: 5, } as Playlist; - const fence = await service.beginEditDiscovery(editedPlaylist); + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); const persisted = await service.replaceSessionAfterEdit( editedPlaylist, @@ -367,6 +370,35 @@ describe('Stalker edited-session coordination', () => { ); }); + it('rejects a late merge after another connection replaces the playlist ID', async () => { + const replacementPlaylist = { + ...oldPlaylist, + portalUrl: 'https://restored.example.com/portal.php', + macAddress: '00:1A:79:11:22:33', + } as Playlist; + transformPlaylistMeta.mockImplementationOnce((_id, transform) => + of(transform(replacementPlaylist)) + ); + const editedPlaylist = { + ...oldPlaylist, + portalUrl: 'https://new.example.com/server/load.php', + stalkerToken: 'NEW_TOKEN', + } as Playlist; + const fence = await service.beginEditDiscovery( + editedPlaylist, + oldPlaylist + ); + + await expect( + service.replaceSessionAfterEdit(editedPlaylist, fence, { + preserveCurrentMetadata: true, + }) + ).rejects.toThrow(/could not be persisted/i); + + expect(service.getCachedToken(oldPlaylist._id)).toBeNull(); + expect(updatePlaylistMeta).not.toHaveBeenCalled(); + }); + it('does not adopt a resolved full session when its atomic write fails', async () => { service.adoptDiscoveredSimplePortal(oldPlaylist); updatePlaylistMeta.mockReturnValueOnce( diff --git a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts index a2ba6e917..4287cdf33 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-edited-session-coordinator.ts @@ -18,6 +18,7 @@ export interface StalkerEditFence { interface PendingEdit { readonly owner: symbol; readonly configurationFingerprint: string; + readonly sourceConfigurationFingerprint: string; } /** Serializes authoritative Edit results against pre-edit authentication. */ @@ -87,8 +88,14 @@ export class StalkerEditedSessionCoordinator { } } - async beginEdit(playlist: Playlist): Promise { + async beginEdit( + playlist: Playlist, + sourcePlaylist: Playlist = playlist + ): Promise { const playlistId = playlist._id; + if (sourcePlaylist._id !== playlistId) { + throw new Error('Stale Stalker playlist configuration'); + } if (this.pendingEdits.has(playlistId)) { throw new Error('Stalker playlist edit already in progress'); } @@ -98,6 +105,8 @@ export class StalkerEditedSessionCoordinator { this.pendingEdits.set(playlistId, { owner: fence.owner, configurationFingerprint, + sourceConfigurationFingerprint: + stalkerConfigurationFingerprint(sourcePlaylist), }); try { @@ -132,6 +141,7 @@ export class StalkerEditedSessionCoordinator { sessionFingerprint ); const owner = fence?.owner ?? Symbol('stalker-edit'); + const pending = this.pendingEdits.get(playlistId); if ( fence && (fence.playlistId !== playlistId || @@ -141,11 +151,15 @@ export class StalkerEditedSessionCoordinator { new Error('Stale Stalker playlist configuration') ); } + const sourceConfigurationFingerprint = + pending?.sourceConfigurationFingerprint ?? + stalkerConfigurationFingerprint(playlist); // Keep the same owner while discovery replaces its input-shaped // fingerprint with the resolved endpoint/mode fingerprint. this.pendingEdits.set(playlistId, { owner, configurationFingerprint, + sourceConfigurationFingerprint, }); const previous = this.replacements.get(playlistId) ?? Promise.resolve(); const replacement = previous @@ -156,6 +170,7 @@ export class StalkerEditedSessionCoordinator { sessionFingerprint, configurationFingerprint, owner, + sourceConfigurationFingerprint, options ) ); @@ -178,6 +193,7 @@ export class StalkerEditedSessionCoordinator { sessionFingerprint: string, configurationFingerprint: string, owner: symbol, + sourceConfigurationFingerprint: string, options: { preserveCurrentMetadata?: boolean } ): Promise { const playlistId = playlist._id; @@ -215,11 +231,14 @@ export class StalkerEditedSessionCoordinator { const persistedPlaylist = await firstValueFrom( options.preserveCurrentMetadata ? playlists.transformPlaylistMeta(playlistId, (current) => - mergeResolvedStalkerConnection( - current, - playlist, - sessionPatch - ) + stalkerConfigurationFingerprint(current) === + sourceConfigurationFingerprint + ? mergeResolvedStalkerConnection( + current, + playlist, + sessionPatch + ) + : null ) : playlists.updatePlaylistMeta({ ...playlist, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 000e1ed5f..0d0d72f50 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -253,8 +253,11 @@ export class StalkerSessionService { * before discovery. The opaque fence keeps new authentication out until * the result is either cancelled or atomically persisted. */ - beginEditDiscovery(playlist: Playlist): Promise { - return this.editedSessions.beginEdit(playlist); + beginEditDiscovery( + playlist: Playlist, + sourcePlaylist: Playlist = playlist + ): Promise { + return this.editedSessions.beginEdit(playlist, sourcePlaylist); } /**