diff --git a/CLAUDE.md b/CLAUDE.md
index 046b116ff..802daba39 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1262,9 +1262,9 @@ engine` (restart required) or
**Stalker Portal Mode and Endpoint Discovery**:
- 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.
+- 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. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
+- `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. 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.
- 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.
diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md
index d4092b71b..96ec802ac 100644
--- a/docs/architecture/stalker-portal.md
+++ b/docs/architecture/stalker-portal.md
@@ -261,7 +261,12 @@ may probe when it fails, while every already-probed one stays latched for
the session. Before an unrecorded probe makes any portal request, it verifies
that the persisted row still carries the failing source; a request that failed
late after Edit committed is declined before discovery can authenticate against
-the old portal and invalidate the edited session. Repair persists only a
+the old portal and invalidate the edited session. A repair installs a
+per-playlist authentication fence before probing, drains authentication that
+already owns the runtime token slot, and makes request routing wait before it
+chooses the effective connection. The fence normally ends with the repair; an
+abandoned authentication keeps both it and `pendingRepairs` alive until the
+transport settles. Repair persists only a
configuration discovery has proven to answer, and only when it differs from the
failing one. A repaired configuration is applied immediately via an
in-session override inside `executeStalkerRequest()` (stale store snapshots
@@ -762,8 +767,10 @@ session that looks established and dies later. It costs nothing in the normal
case: an aborted attempt settles as soon as its in-flight request errors out,
so the drain returns at once and the loop continues. Edit may return that
bounded error to the dialog, but its session and repair fences remain installed
-until the settlement promise resolves; catalog, watchdog, repair and retry
-authentication therefore cannot race the abandoned `get_profile`.
+until the settlement promise resolves. Import reports the refusal immediately
+but keeps Add and the form disabled for the same lifetime. Lazy repair retains
+its pending/runtime-authentication fences. Catalog, watchdog, repair, Add and
+retry authentication therefore cannot race the abandoned `get_profile`.
The budget itself covers the longest real flow: a status-2 portal costs four
sequential requests (handshake, profile, `do_auth`, profile retry) and the
diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
index 9c9e0ef9b..fd3844e1a 100644
--- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
+++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
@@ -313,6 +313,47 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(store.dispatch).not.toHaveBeenCalled();
});
+ it('keeps Add disabled until an abandoned authentication settles', async () => {
+ let settleAuthentication: () => void = () => undefined;
+ const abandonedAuthenticationSettled = new Promise((resolve) => {
+ settleAuthentication = resolve;
+ });
+ portalDiscovery.discover.mockResolvedValue({
+ status: 'auth-rejected',
+ portalUrl:
+ 'https://portal.example.com/stalker_portal/server/load.php',
+ abandonedInFlight: true,
+ abandonedAuthenticationSettled,
+ });
+ component.form.patchValue({
+ _id: 'playlist-abandoned',
+ title: 'Slow Portal',
+ macAddress: '00:1A:79:00:00:08',
+ portalUrl: 'https://portal.example.com/stalker_portal/c',
+ importDate: '2026-05-15T00:00:00.000Z',
+ });
+
+ const importing = component.addPlaylist();
+ while (portalDiscovery.discover.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+ for (let i = 0; i < 5; i += 1) {
+ await Promise.resolve();
+ }
+
+ expect(component.isLoading()).toBe(true);
+ expect(component.form.controls.portalUrl.disabled).toBe(true);
+ await component.addPlaylist();
+ expect(portalDiscovery.discover).toHaveBeenCalledTimes(1);
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ settleAuthentication();
+ await importing;
+
+ expect(component.isLoading()).toBe(false);
+ expect(component.form.controls.portalUrl.enabled).toBe(true);
+ });
+
it("appends the portal's explanation to a blocked refusal", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts
index 566983054..bcfb41477 100644
--- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts
+++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts
@@ -433,6 +433,14 @@ export class StalkerPortalImportComponent {
undefined,
{ duration: 8000 }
);
+ if (discovery.abandonedInFlight) {
+ // The bounded error may arrive while get_profile is still
+ // on the wire. Keep Add and every identity field locked
+ // until it leaves the transport, or an immediate retry
+ // could establish a token that this late attempt revokes.
+ await (discovery.abandonedAuthenticationSettled ??
+ new Promise(() => undefined));
+ }
return;
} else if (
isFullStalkerPortalUrl(
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts
index 32da1f5ed..1b0d9d94e 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts
@@ -18,6 +18,7 @@ describe('StalkerAccountInfoService', () => {
ensureToken: jest.Mock;
};
let portalRepair: {
+ waitForPendingRepair: jest.Mock;
applyOverride: jest.Mock;
shouldAttemptRepair: jest.Mock;
repairPortal: jest.Mock;
@@ -48,6 +49,7 @@ describe('StalkerAccountInfoService', () => {
ensureToken: jest.fn().mockResolvedValue({ token: null }),
};
portalRepair = {
+ waitForPendingRepair: jest.fn().mockResolvedValue(undefined),
applyOverride: jest.fn((playlist) => playlist),
shouldAttemptRepair: jest.fn().mockReturnValue(false),
repairPortal: jest.fn().mockResolvedValue(null),
@@ -123,9 +125,7 @@ describe('StalkerAccountInfoService', () => {
expect(portalRepair.repairPortal).toHaveBeenCalledWith(
fullPortalPlaylist
);
- expect(
- stalkerSession.refreshAccountProfile
- ).toHaveBeenLastCalledWith(
+ expect(stalkerSession.refreshAccountProfile).toHaveBeenLastCalledWith(
expect.objectContaining({ portalUrl: repaired.portalUrl })
);
expect(snapshot).toMatchObject({ login: 'user-1' });
@@ -276,9 +276,9 @@ describe('StalkerAccountInfoService', () => {
const boom = new Error('timeout of 15000ms exceeded');
stalkerSession.refreshAccountProfile.mockRejectedValue(boom);
- await expect(
- service.fetchAccountInfo(fullPortalPlaylist)
- ).rejects.toBe(boom);
+ await expect(service.fetchAccountInfo(fullPortalPlaylist)).rejects.toBe(
+ boom
+ );
expect(portalRepair.repairPortal).not.toHaveBeenCalled();
});
@@ -408,9 +408,9 @@ describe('StalkerAccountInfoService', () => {
it('propagates portal errors so the dialog can fall back to cached data', async () => {
dataService.sendIpcEvent.mockRejectedValue(new Error('offline'));
- await expect(
- service.fetchAccountInfo(portalPlaylist)
- ).rejects.toThrow('offline');
+ await expect(service.fetchAccountInfo(portalPlaylist)).rejects.toThrow(
+ 'offline'
+ );
});
});
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts
index 215cd8171..fa32b8f82 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts
@@ -76,6 +76,7 @@ export class StalkerAccountInfoService {
return null;
}
+ await this.portalRepair.waitForPendingRepair(playlist._id);
const effectivePlaylist = this.portalRepair.applyOverride(playlist);
if (isFullStalkerPortalPlaylist(effectivePlaylist)) {
return this.fetchViaProfile(effectivePlaylist);
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 b094e16ce..27c7b38cd 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
@@ -131,6 +131,49 @@ describe('Stalker edited-session coordination', () => {
service.cancelEditDiscovery(fence);
});
+ it('blocks runtime authentication while portal repair discovery owns the playlist', async () => {
+ const repairFence = service.beginPortalRepairDiscovery(oldPlaylist._id);
+ await repairFence.drained;
+
+ const authentication = service.ensureToken(oldPlaylist);
+ for (let i = 0; i < 5; i += 1) {
+ await Promise.resolve();
+ }
+ expect(authenticate).not.toHaveBeenCalled();
+
+ service.completePortalRepairDiscovery(repairFence);
+ while (authenticate.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+ resolveOldAuthentication({ token: 'REPAIRED_TOKEN' });
+
+ await expect(authentication).resolves.toMatchObject({
+ token: 'REPAIRED_TOKEN',
+ });
+ });
+
+ it('drains authentication that started before portal repair discovery', async () => {
+ const authentication = service.ensureToken(oldPlaylist);
+ while (authenticate.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+
+ const repairFence = service.beginPortalRepairDiscovery(oldPlaylist._id);
+ let drained = false;
+ void repairFence.drained.then(() => {
+ drained = true;
+ });
+ await Promise.resolve();
+ expect(drained).toBe(false);
+
+ resolveOldAuthentication({ token: 'PRE_REPAIR_TOKEN' });
+ await expect(authentication).rejects.toThrow(/stale/i);
+ await repairFence.drained;
+ expect(drained).toBe(true);
+
+ service.completePortalRepairDiscovery(repairFence);
+ });
+
it('rejects an overlapping edit without retiring the first owner', async () => {
const firstFence = await service.beginEditDiscovery(oldPlaylist);
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts
new file mode 100644
index 000000000..7721a82b8
--- /dev/null
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair-discovery-coordinator.ts
@@ -0,0 +1,79 @@
+import type { StalkerTokenCache } from './stalker-token-cache';
+
+/** Opaque ownership proof for one lazy-repair authentication fence. */
+export interface StalkerPortalRepairDiscoveryFence {
+ readonly playlistId: string;
+ readonly owner: symbol;
+ /** Existing runtime authentication that repair must drain first. */
+ readonly drained: Promise;
+}
+
+interface PendingPortalRepairDiscovery {
+ readonly owner: symbol;
+ readonly settled: Promise;
+ readonly release: () => void;
+}
+
+/** Serializes runtime authentication against lazy endpoint repair. */
+export class StalkerPortalRepairDiscoveryCoordinator {
+ private readonly pending = new Map();
+
+ constructor(private readonly tokens: StalkerTokenCache) {}
+
+ begin(playlistId: string): StalkerPortalRepairDiscoveryFence {
+ if (this.pending.has(playlistId)) {
+ throw new Error('Stalker portal repair already in progress');
+ }
+
+ const owner = Symbol('stalker-portal-repair');
+ let release: () => void = () => undefined;
+ const settled = new Promise((resolve) => {
+ release = resolve;
+ });
+ this.pending.set(playlistId, { owner, settled, release });
+ const pendingAuthentication = this.tokens.getPending(playlistId);
+ const drained = (
+ pendingAuthentication?.promise.catch(() => undefined) ??
+ Promise.resolve()
+ ).then(() => {
+ if (this.pending.get(playlistId)?.owner !== owner) {
+ throw new Error('Stale Stalker portal repair fence');
+ }
+ });
+
+ return { playlistId, owner, drained };
+ }
+
+ complete(fence: StalkerPortalRepairDiscoveryFence): void {
+ const pending = this.pending.get(fence.playlistId);
+ if (pending?.owner !== fence.owner) {
+ return;
+ }
+ this.pending.delete(fence.playlistId);
+ pending.release();
+ }
+
+ /** Returns synchronously when no repair owns the playlist. */
+ waitIfPending(playlistId: string): Promise | null {
+ if (!this.pending.has(playlistId)) {
+ return null;
+ }
+ return this.waitUntilAvailable(playlistId);
+ }
+
+ assertAvailable(playlistId: string): void {
+ if (this.pending.has(playlistId)) {
+ throw new Error('Stale Stalker playlist configuration');
+ }
+ }
+
+ private async waitUntilAvailable(playlistId: string): Promise {
+ for (;;) {
+ const pending = this.pending.get(playlistId);
+ if (!pending) {
+ return;
+ }
+ await pending.settled;
+ }
+ }
+}
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 1d8d50af5..450d50454 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
@@ -39,6 +39,8 @@ describe('StalkerPortalRepairService', () => {
let adoptDiscoveredSimplePortal: jest.Mock;
let clearCachedToken: jest.Mock;
let refreshActiveWatchdogPlaylist: jest.Mock;
+ let beginPortalRepairDiscovery: jest.Mock;
+ let completePortalRepairDiscovery: jest.Mock;
beforeEach(() => {
discover = jest.fn();
@@ -67,6 +69,12 @@ describe('StalkerPortalRepairService', () => {
adoptDiscoveredSimplePortal = jest.fn();
clearCachedToken = jest.fn();
refreshActiveWatchdogPlaylist = jest.fn();
+ beginPortalRepairDiscovery = jest.fn((playlistId: string) => ({
+ playlistId,
+ owner: Symbol('portal-repair'),
+ drained: Promise.resolve(),
+ }));
+ completePortalRepairDiscovery = jest.fn();
TestBed.configureTestingModule({
providers: [
@@ -89,6 +97,8 @@ describe('StalkerPortalRepairService', () => {
adoptDiscoveredSimplePortal,
clearCachedToken,
refreshActiveWatchdogPlaylist,
+ beginPortalRepairDiscovery,
+ completePortalRepairDiscovery,
},
},
],
@@ -292,6 +302,49 @@ describe('StalkerPortalRepairService', () => {
expect(writtenRow).toBeNull();
});
+ it('keeps repair authentication fenced until an abandoned attempt settles', async () => {
+ let settleAuthentication: () => void = () => undefined;
+ const abandonedAuthenticationSettled = new Promise(
+ (resolve) => {
+ settleAuthentication = resolve;
+ }
+ );
+ discover.mockResolvedValue({
+ status: 'auth-rejected',
+ portalUrl: MISCLASSIFIED.portalUrl,
+ abandonedInFlight: true,
+ abandonedAuthenticationSettled,
+ });
+
+ const repair = service.repairPortal(MISCLASSIFIED);
+ while (discover.mock.calls.length === 0) {
+ await Promise.resolve();
+ }
+ let repairSettled = false;
+ void repair.then(() => {
+ repairSettled = true;
+ });
+ for (let i = 0; i < 5; i += 1) {
+ await Promise.resolve();
+ }
+
+ expect(repairSettled).toBe(false);
+ expect(beginPortalRepairDiscovery).toHaveBeenCalledWith(
+ MISCLASSIFIED._id
+ );
+ expect(completePortalRepairDiscovery).not.toHaveBeenCalled();
+
+ const waitingForRepair = service.waitForPendingRepair(
+ MISCLASSIFIED._id
+ );
+ settleAuthentication();
+ await expect(repair).resolves.toBeNull();
+ await expect(waitingForRepair).resolves.toBeUndefined();
+ expect(completePortalRepairDiscovery).toHaveBeenCalledWith(
+ expect.objectContaining({ playlistId: MISCLASSIFIED._id })
+ );
+ });
+
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
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 24a8b3dfd..930971a97 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
@@ -16,7 +16,10 @@ import {
getStalkerPortalIdentityFromPlaylist,
stalkerIdentityFingerprint,
} from './stalker-identity.utils';
-import { StalkerSessionService } from './stalker-session.service';
+import {
+ StalkerSessionService,
+ type StalkerPortalRepairDiscoveryFence,
+} from './stalker-session.service';
import {
type StalkerPortalRepairApi,
toStalkerSessionPlaylist,
@@ -346,18 +349,27 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
// produced override or the 'discarded' marker.
history.set(fingerprint, 'no-change');
this.probeHistory.set(playlistId, history);
- const run = this.runRepair(
- playlist,
- this.editGenerations.get(playlistId) ?? 0
+ const authenticationFence: StalkerPortalRepairDiscoveryFence =
+ this.stalkerSession.beginPortalRepairDiscovery(playlistId);
+ const run = authenticationFence.drained.then(() =>
+ this.runRepair(playlist, this.editGenerations.get(playlistId) ?? 0)
);
this.pendingRepairs.set(playlistId, run);
try {
return await run;
} finally {
this.pendingRepairs.delete(playlistId);
+ this.stalkerSession.completePortalRepairDiscovery(
+ authenticationFence
+ );
}
}
+ /** Lets request routing choose its effective row after repair settles. */
+ async waitForPendingRepair(playlistId: string): Promise {
+ 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`
@@ -420,6 +432,17 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
this.logger.info(
`Portal probe found no working configuration (${outcome.status}); leaving playlist untouched`
);
+ if (
+ outcome.status === 'auth-rejected' &&
+ outcome.abandonedInFlight
+ ) {
+ // Returning the repair would release its session fence. The
+ // transport can outlive discovery's bounded error, so hold
+ // every runtime authenticator until the old get_profile has
+ // actually settled. Missing lifetime evidence fails closed.
+ await (outcome.abandonedAuthenticationSettled ??
+ new Promise(() => undefined));
+ }
return null;
}
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 7d313989b..3846625ec 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
@@ -25,6 +25,10 @@ import {
import { StalkerAuthenticatedRequestClient } from './stalker-authenticated-request-client';
import { StalkerEditedSessionCoordinator } from './stalker-edited-session-coordinator';
import type { StalkerEditFence } from './stalker-edited-session-coordinator';
+import {
+ StalkerPortalRepairDiscoveryCoordinator,
+ type StalkerPortalRepairDiscoveryFence,
+} from './stalker-portal-repair-discovery-coordinator';
import {
stalkerSessionFingerprint,
StalkerSessionStore,
@@ -40,6 +44,7 @@ export {
};
export type { StalkerPortalIdentity };
export type { StalkerEditFence };
+export type { StalkerPortalRepairDiscoveryFence };
export type {
StalkerAuthenticateOptions,
StalkerAuthenticationResult,
@@ -76,6 +81,12 @@ export class StalkerSessionService {
this.dataService,
this.logger
);
+ readonly performHandshake = this.authApi.performHandshake.bind(
+ this.authApi
+ );
+ readonly getProfile = this.authApi.getProfile.bind(this.authApi);
+ readonly doAuth = this.authApi.doAuth.bind(this.authApi);
+ readonly authenticate = this.authApi.authenticate.bind(this.authApi);
private readonly sessionStore = new StalkerSessionStore(
() => this.injector.get(PlaylistsService),
this.logger
@@ -96,12 +107,16 @@ export class StalkerSessionService {
this.watchdog,
() => this.injector.get(PlaylistsService)
);
+ private readonly portalRepairDiscoveries =
+ new StalkerPortalRepairDiscoveryCoordinator(this.tokens);
private readonly requestClient = new StalkerAuthenticatedRequestClient(
this.dataService,
this.tokens,
(playlist) => this.ensureToken(playlist),
- (playlist, sessionFingerprint) =>
- this.editedSessions.assertCurrent(playlist, sessionFingerprint)
+ (playlist, sessionFingerprint) => {
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
+ this.editedSessions.assertCurrent(playlist, sessionFingerprint);
+ }
);
/**
@@ -242,6 +257,24 @@ export class StalkerSessionService {
return this.editedSessions.beginEdit(playlist);
}
+ /**
+ * Blocks new runtime authentication while lazy repair probes this
+ * playlist, and snapshots authentication that was already in flight so
+ * repair can drain it before issuing its own profile request.
+ */
+ beginPortalRepairDiscovery(
+ playlistId: string
+ ): StalkerPortalRepairDiscoveryFence {
+ return this.portalRepairDiscoveries.begin(playlistId);
+ }
+
+ /** Releases the exact lazy-repair authentication owner. */
+ completePortalRepairDiscovery(
+ fence: StalkerPortalRepairDiscoveryFence
+ ): void {
+ this.portalRepairDiscoveries.complete(fence);
+ }
+
/** Releases a discovery reservation whose result will not be saved. */
cancelEditDiscovery(fence: StalkerEditFence): void {
this.editedSessions.cancelEdit(fence);
@@ -258,86 +291,6 @@ export class StalkerSessionService {
return this.editedSessions.replace(playlist, fence);
}
- /**
- * Performs handshake to get a session token for a full stalker portal.
- * An optional persisted token is re-presented: the handshake is
- * idempotent, so a still-valid token comes back unchanged.
- */
- performHandshake(
- portalUrl: string,
- macAddress: string,
- identity: StalkerPortalIdentity = {},
- storedToken?: string
- ): Promise<{ token: string; random: string; notValid: boolean }> {
- return this.authApi.performHandshake(
- portalUrl,
- macAddress,
- identity,
- storedToken
- );
- }
-
- /**
- * Gets account profile information to validate the portal and check
- * subscription.
- */
- getProfile(
- portalUrl: string,
- macAddress: string,
- token: string,
- identity: StalkerPortalIdentity,
- handshakeRandom: string,
- options: { authSecondStep?: boolean; notValidToken?: boolean } = {}
- ): Promise {
- return this.authApi.getProfile(
- portalUrl,
- macAddress,
- token,
- identity,
- handshakeRandom,
- options
- );
- }
-
- /**
- * Performs do_auth — the login/password step behind get_profile status 2.
- */
- doAuth(
- portalUrl: string,
- macAddress: string,
- token: string,
- credentials: StalkerPortalCredentials,
- identity: StalkerPortalIdentity = {}
- ): Promise {
- return this.authApi.doAuth(
- portalUrl,
- macAddress,
- token,
- credentials,
- identity
- );
- }
-
- /**
- * Performs the full authentication flow: handshake → get_profile, driving
- * the status-2 `do_auth` login flow when the portal demands credentials.
- * Throws `StalkerPortalError` with the portal's own `msg`/`block_msg`
- * text when the portal refuses the session.
- */
- authenticate(
- portalUrl: string,
- macAddress: string,
- identity: StalkerPortalIdentity = {},
- options: StalkerAuthenticateOptions = {}
- ): Promise {
- return this.authApi.authenticate(
- portalUrl,
- macAddress,
- identity,
- options
- );
- }
-
/**
* Ensures a valid token exists for a playlist, performing auth if needed.
* A previously persisted token is re-presented in the handshake first —
@@ -348,7 +301,14 @@ export class StalkerSessionService {
async ensureToken(
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
+ const pendingRepair = this.portalRepairDiscoveries.waitIfPending(
+ playlist._id
+ );
+ if (pendingRepair) {
+ await pendingRepair;
+ }
const fingerprint = await this.editedSessions.guard(playlist);
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
// If not a full stalker portal, no token needed
if (!isFullStalkerPortalPlaylist(playlist)) {
@@ -409,6 +369,10 @@ export class StalkerSessionService {
playlist,
fingerprint
);
+ // Repair may have claimed the playlist while the persisted
+ // session read yielded. Its fence will drain this published
+ // slot; abort before putting another get_profile on the wire.
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
const result = await this.authenticate(
portalUrl,
macAddress,
@@ -429,6 +393,7 @@ export class StalkerSessionService {
stored.watchdogTimeoutSeconds !== undefined,
}
);
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
this.editedSessions.assertCurrent(playlist, fingerprint);
this.setCachedToken(playlist._id, result.token, playlist);
this.applySessionOutcome(
@@ -474,6 +439,12 @@ export class StalkerSessionService {
throw new Error('Portal URL and MAC address are required');
}
+ const pendingRepair = this.portalRepairDiscoveries.waitIfPending(
+ playlist._id
+ );
+ if (pendingRepair) {
+ await pendingRepair;
+ }
const portalUrl = playlist.portalUrl;
const macAddress = playlist.macAddress;
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
@@ -481,6 +452,7 @@ export class StalkerSessionService {
// identity-only in-run key would hand the cached bearer token to a
// freshly edited endpoint before the persisted check ever ran.
const fingerprint = await this.editedSessions.guard(playlist);
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
// Claim the per-playlist slot. Re-check after every await: one
// settled promise releases every waiter at once, so a single
@@ -495,6 +467,7 @@ export class StalkerSessionService {
// performs its own handshake either way.
await inFlight.promise.catch(() => undefined);
}
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
this.editedSessions.assertCurrent(playlist, fingerprint);
// Publish the slot before the first await so no other waiter can
@@ -536,6 +509,7 @@ export class StalkerSessionService {
},
}
);
+ this.portalRepairDiscoveries.assertAvailable(playlist._id);
this.editedSessions.assertCurrent(playlist, fingerprint);
this.setCachedToken(playlist._id, result.token, playlist);
// This path always ran a real get_profile, so the decoded cadence
diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts
index 19275d128..9fca2fb36 100644
--- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts
@@ -173,6 +173,7 @@ describe('executeStalkerRequest lazy portal repair', () => {
overrides: Partial = {}
): StalkerPortalRepairApi {
return {
+ waitForPendingRepair: jest.fn().mockResolvedValue(undefined),
applyOverride: jest.fn((playlist) => playlist),
shouldAttemptRepair: jest.fn().mockReturnValue(false),
repairPortal: jest.fn().mockResolvedValue(null),
@@ -180,6 +181,42 @@ describe('executeStalkerRequest lazy portal repair', () => {
} as StalkerPortalRepairApi;
}
+ it('waits for a pending repair before choosing the effective connection', async () => {
+ const deps = createDeps();
+ let releaseRepair: () => void = () => undefined;
+ const repairSettled = new Promise((resolve) => {
+ releaseRepair = resolve;
+ });
+ const repaired = {
+ ...PLAYLIST,
+ isFullStalkerPortal: true,
+ } as PlaylistMeta;
+ const repair = createRepair({
+ waitForPendingRepair: jest.fn(() => repairSettled),
+ applyOverride: jest.fn().mockReturnValue(repaired),
+ });
+ deps.portalRepair = repair;
+
+ const request = executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS);
+ await Promise.resolve();
+
+ expect(repair.applyOverride).not.toHaveBeenCalled();
+ expect(
+ deps.stalkerSession.makeAuthenticatedRequest
+ ).not.toHaveBeenCalled();
+
+ releaseRepair();
+ await request;
+
+ expect(repair.applyOverride).toHaveBeenCalledWith(PLAYLIST);
+ expect(
+ deps.stalkerSession.makeAuthenticatedRequest
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({ isFullStalkerPortal: true }),
+ CATEGORY_PARAMS
+ );
+ });
+
it('retries once against the repaired configuration after an auth-failure body', async () => {
const deps = createDeps();
deps.dataService.sendIpcEvent = jest
@@ -258,9 +295,7 @@ describe('executeStalkerRequest lazy portal repair', () => {
await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS);
- expect(
- deps.stalkerSession.makeAuthenticatedRequest
- ).toHaveBeenCalled();
+ expect(deps.stalkerSession.makeAuthenticatedRequest).toHaveBeenCalled();
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
});
diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts
index 01ce41949..f3ed08ded 100644
--- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts
+++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts
@@ -13,6 +13,8 @@ import { StalkerSessionService } from '../../stalker-session.service';
* at the lib root and depend on these utils without a cycle.
*/
export interface StalkerPortalRepairApi {
+ /** Waits until repair can no longer change the effective connection. */
+ waitForPendingRepair?(playlistId: string): Promise;
/** Applies a completed repair; returns the SAME reference when no-op. */
applyOverride(playlist: T): T;
/** Whether this failure shape justifies probing the portal at all. */
@@ -70,6 +72,8 @@ export async function ensureStalkerSession(
return false;
}
+ await deps.portalRepair?.waitForPendingRepair?.(playlist._id);
+
// The repair override is applied here for the same reason
// `executeStalkerRequest` applies it on its first line: a completed repair
// may have moved the endpoint or the mode while the caller still holds the
@@ -86,9 +90,7 @@ export async function ensureStalkerSession(
// `ensureToken` is also the post-Edit configuration guard. For a
// simple portal it returns immediately with no token and no network
// request, but still rejects a snapshot whose observed mode is stale.
- return isFullStalkerPortalPlaylist(effective)
- ? Boolean(token)
- : true;
+ return isFullStalkerPortalPlaylist(effective) ? Boolean(token) : true;
} catch (error) {
logger?.warn('Could not establish the Stalker session', error);
return false;
@@ -169,6 +171,7 @@ export async function executeStalkerRequest(
playlist: PlaylistMeta,
params: Record
): Promise {
+ await deps.portalRepair?.waitForPendingRepair?.(playlist._id);
const effective = deps.portalRepair
? deps.portalRepair.applyOverride(playlist)
: playlist;