diff --git a/CLAUDE.md b/CLAUDE.md index 7b281d399..32709a212 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1265,7 +1265,7 @@ engine` (restart required) or - 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. It preserves an unchanged connection byte-for-byte and skips discovery. 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`. If navigation or another owner closes/destroys the dialog while discovery is in flight, the UI discards a later successful result through `discardResolvedConnection()` and releases both reservations without persisting it. 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. - `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair calls discovery, it verifies that the persisted row still owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or backup restore with the same playlist ID but different connection metadata retires the override and token only after the persisted row confirms ownership and only if no explicit Edit took ownership during that read, so a delayed stale request cannot remove valid runtime state or a token negotiated by the overlapping Edit. Each repair installs a session-level authentication fence synchronously, drains the existing token slot before probing, and keeps request routing ahead of effective-connection selection until repair finishes; an abandoned transport keeps both the repair and session fences until it actually settles. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. -- Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit. +- Explicit Edit advances the repair generation before installing its resolved session. Lazy repair captures that generation before any probe-history row read and rechecks it with the active Edit fence before reserving discovery. A repair that started earlier is therefore discarded even if it was restoring a `discarded` history record or had already verified its row, so it cannot probe alongside Edit or restore an older endpoint, mode or token afterwards. - Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them. - Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream. - Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding"). diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index a3fbe9ec5..6e66a0434 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -282,8 +282,11 @@ queue, so a user edit that is queued but not yet committed wins over the repair instead of being overwritten; the transform patches the freshly read row (`portalUrl` + `isFullStalkerPortal` only, so user state can never be clobbered) and returns null to abort. Explicit Edit also advances an in-run -generation before replacing the session, so a repair that already verified -its row but finishes later cannot install its older override or token. +generation before replacing the session. Repair captures it before any +history-path row read and rechecks it together with the active Edit fence +before reserving discovery, so restoring a discarded configuration cannot +start a probe alongside Edit; a repair that already verified its row but +finishes later likewise cannot install its older override or token. Deletion runs through the same queue, so a repair can never resurrect a playlist deleted mid-probe. Portals that work are never probed, let alone rewritten. E2E coverage: diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts new file mode 100644 index 000000000..7c3980793 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-state.ts @@ -0,0 +1,32 @@ +import { + isFullStalkerPortalPlaylist, + type PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { stalkerIdentityFingerprint } from './stalker-identity.utils'; + +/** What a probe of one source configuration concluded this session. */ +export type StalkerProbeRecord = + StalkerPortalModeOverride | 'no-change' | 'discarded'; + +export interface StalkerPortalModeOverride { + sourcePortalUrl?: string; + sourceIsFullStalkerPortal: boolean; + identityFingerprint: string; + credentialsFingerprint: string; + portalUrl: string; + isFullStalkerPortal: boolean; +} + +export function stalkerCredentialsFingerprint(playlist: PlaylistMeta): string { + return JSON.stringify([playlist.username ?? '', playlist.password ?? '']); +} + +export function stalkerRepairSourceFingerprint(playlist: PlaylistMeta): string { + return JSON.stringify([ + playlist.portalUrl ?? '', + isFullStalkerPortalPlaylist(playlist), + stalkerIdentityFingerprint(playlist), + playlist.username ?? '', + playlist.password ?? '', + ]); +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index 04e0f38bf..7723eadeb 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -938,6 +938,47 @@ describe('StalkerPortalRepairService', () => { expect(repaired).toMatchObject({ isFullStalkerPortal: true }); }); + it('does not start discovery when Edit takes ownership during a DISCARDED history read', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/portal.php', + } as Playlist; + await service.repairPortal(MISCLASSIFIED); + expect(discover).not.toHaveBeenCalled(); + + persistedRow = MISCLASSIFIED as Playlist; + const historyRead = new Subject(); + getPlaylistById.mockClear(); + getPlaylistById.mockReturnValueOnce(historyRead); + beginPortalRepairDiscovery.mockClear(); + completePortalRepairDiscovery.mockClear(); + + const repair = service.repairPortal(MISCLASSIFIED); + expect(getPlaylistById).toHaveBeenCalledTimes(1); + // No pending repair exists yet on this history path, so Edit's + // drain resolves immediately while the row read is still live. + await service.fenceForPlaylistEdit(MISCLASSIFIED._id); + historyRead.next(MISCLASSIFIED as Playlist); + historyRead.complete(); + + await expect(repair).resolves.toBeNull(); + expect(beginPortalRepairDiscovery).not.toHaveBeenCalled(); + expect(discover).not.toHaveBeenCalled(); + + // A failed/cancelled Edit releases the fence without consuming + // the discarded history record; the restored source can retry. + service.releasePlaylistEdit(MISCLASSIFIED._id); + await expect( + service.repairPortal(MISCLASSIFIED) + ).resolves.toMatchObject({ isFullStalkerPortal: true }); + expect(discover).toHaveBeenCalledTimes(1); + }); + it('re-arms the EDITED configuration after a mid-probe edit discarded a repair', async () => { const edited = { ...MISCLASSIFIED, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index 433f44989..eeafb8c12 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -17,39 +17,17 @@ import { stalkerIdentityFingerprint, } from './stalker-identity.utils'; import { - StalkerSessionService, - type StalkerPortalRepairDiscoveryFence, -} from './stalker-session.service'; + stalkerCredentialsFingerprint, + stalkerRepairSourceFingerprint, + type StalkerPortalModeOverride, + type StalkerProbeRecord, +} from './stalker-portal-repair-state'; +import { StalkerSessionService } from './stalker-session.service'; import { type StalkerPortalRepairApi, toStalkerSessionPlaylist, } from './stores/utils/stalker-request.utils'; -/** - * What a probe of one source configuration concluded this session: - * an override to (re)install, 'no-change' (probed; the stored configuration - * is what probing proves, or nothing answered), or 'discarded' (the row - * moved on mid-probe, so the outcome never applied to any persisted state). - */ -type StalkerProbeRecord = StalkerPortalModeOverride | 'no-change' | 'discarded'; - -interface StalkerPortalModeOverride { - /** The failing configuration this repair replaced. */ - sourcePortalUrl?: string; - sourceIsFullStalkerPortal: boolean; - /** MAC + Stalker identity the repair probe authenticated as. */ - identityFingerprint: string; - /** Login/password the repair outcome was negotiated with. */ - credentialsFingerprint: string; - /** The proven-working configuration. */ - portalUrl: string; - isFullStalkerPortal: boolean; -} - -function stalkerCredentialsFingerprint(playlist: PlaylistMeta): string { - return JSON.stringify([playlist.username ?? '', playlist.password ?? '']); -} - /** * Lazy repair for playlists whose persisted portal endpoint or mode is * wrong. The flag used to be a URL-shape guess frozen at import, so a @@ -317,6 +295,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { if ((this.editFenceCounts.get(playlistId) ?? 0) > 0) { return null; } + const editGeneration = this.editGenerations.get(playlistId) ?? 0; const pending = this.pendingRepairs.get(playlistId); if (pending) { @@ -328,7 +307,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { return this.repairPortal(playlist); } - const fingerprint = this.repairSourceFingerprint(playlist); + const fingerprint = stalkerRepairSourceFingerprint(playlist); const history = this.probeHistory.get(playlistId) ?? new Map(); const record = history.get(fingerprint) as StalkerProbeRecord | undefined; @@ -338,45 +317,54 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { // RESTORED to it, the outcome was never recorded — probe again. // A stale snapshot (row still elsewhere) stays declined, gated // by one cheap row read instead of a discovery run. - if (!(await this.rowCurrentlyMatches(playlist))) { + const rowMatches = await this.rowCurrentlyMatches(playlist); + if (this.editBlocksRepair(playlistId, editGeneration)) { + return null; + } + if (!rowMatches) { return this.reapplyIfChanged(playlist); } history.delete(fingerprint); } else if (record !== undefined) { - if ( - record !== 'no-change' && - !this.overrides.has(playlistId) && + if (record !== 'no-change' && !this.overrides.has(playlistId)) { // Reinstall ONLY when the persisted row actually carries // this configuration again. A stale request for A while the // row now holds an unrelated C must not resurrect A's // override — that would retry against B and repoint the // watchdog away from C. - (await this.rowCurrentlyMatches(playlist)) - ) { - // The user restored a configuration whose override was - // dropped by an intermediate edit: reinstall the remembered - // outcome — probing again is unnecessary, and doing nothing - // would leave the restored configuration broken until - // restart. - this.overrides.set(playlistId, record); - // Same synchronization as a fresh repair: if the - // intermediate configuration stopped the active watchdog, - // the restored full-portal session needs its keepalive back. - this.stalkerSession.refreshActiveWatchdogPlaylist( - toStalkerSessionPlaylist(this.applyOverride(playlist)) - ); + const rowMatches = await this.rowCurrentlyMatches(playlist); + if (this.editBlocksRepair(playlistId, editGeneration)) { + return null; + } + if (rowMatches) { + // The user restored a configuration whose override was + // dropped by an intermediate edit: reinstall the + // remembered outcome — probing again is unnecessary, and + // doing nothing would leave it broken until restart. + this.overrides.set(playlistId, record); + // Same synchronization as a fresh repair: if the + // intermediate configuration stopped the active watchdog, + // the restored full-portal session needs keepalive back. + this.stalkerSession.refreshActiveWatchdogPlaylist( + toStalkerSessionPlaylist(this.applyOverride(playlist)) + ); + } } return this.reapplyIfChanged(playlist); } + if (this.editBlocksRepair(playlistId, editGeneration)) { + return null; + } + // Reserved BEFORE the probe; the run overwrites it with the // produced override or the 'discarded' marker. history.set(fingerprint, 'no-change'); this.probeHistory.set(playlistId, history); - const authenticationFence: StalkerPortalRepairDiscoveryFence = + const authenticationFence = this.stalkerSession.beginPortalRepairDiscovery(playlistId); const run = authenticationFence.drained.then(() => - this.runRepair(playlist, this.editGenerations.get(playlistId) ?? 0) + this.runRepair(playlist, editGeneration) ); this.pendingRepairs.set(playlistId, run); try { @@ -394,26 +382,6 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { await this.pendingRepairs.get(playlistId)?.catch(() => null); } - /** - * Everything a probe's outcome depends on: endpoint, mode, MAC and the - * full Stalker identity — the same field set `rowStillMatchesSource` - * verifies before committing. - */ - private repairSourceFingerprint(playlist: PlaylistMeta): string { - // JSON-encoded for the same reason as the identity fingerprint: - // unrestricted values must not alias across field boundaries. - return JSON.stringify([ - 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 ?? '', - ]); - } - private reapplyIfChanged(playlist: PlaylistMeta): PlaylistMeta | null { const applied = this.applyOverride(playlist); return applied === playlist ? null : applied; @@ -539,7 +507,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { this.overrides.set(playlist._id, override); this.probeHistory .get(playlist._id) - ?.set(this.repairSourceFingerprint(playlist), override); + ?.set(stalkerRepairSourceFingerprint(playlist), override); if (outcome.isFullStalkerPortal && outcome.token) { // The classification handshake already authenticated; adopt the @@ -587,6 +555,16 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { return (this.editGenerations.get(playlistId) ?? 0) !== expected; } + private editBlocksRepair( + playlistId: string, + expectedGeneration: number + ): boolean { + return ( + this.editFenceCounts.has(playlistId) || + this.editGenerationChanged(playlistId, expectedGeneration) + ); + } + private discardSupersededRepair( playlist: PlaylistMeta ): PlaylistMeta | null { @@ -596,7 +574,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { // A later failure may probe again if this source is restored. this.probeHistory .get(playlist._id) - ?.set(this.repairSourceFingerprint(playlist), 'discarded'); + ?.set(stalkerRepairSourceFingerprint(playlist), 'discarded'); return null; } @@ -617,8 +595,8 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { ); return ( !!row && - this.repairSourceFingerprint(row) === - this.repairSourceFingerprint(playlist) + stalkerRepairSourceFingerprint(row) === + stalkerRepairSourceFingerprint(playlist) ); } catch { return false;