diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts index e23459990..5d941aef3 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts @@ -115,6 +115,7 @@ class FakeAuth implements StalkerSessionAuthLike { readonly profileOutcomes: StalkerAuthenticatedRequestOutcome[] = []; profileChecks = 0; readonly requests: Readonly>[] = []; + readonly startedCredentials: Array = []; readonly submittedCredentials: StalkerAuthCredentials[] = []; startCalls = 0; @@ -151,8 +152,11 @@ class FakeAuth implements StalkerSessionAuthLike { ); } - async start(): Promise { + async start( + credentials?: StalkerAuthCredentials + ): Promise { this.startCalls += 1; + this.startedCredentials.push(credentials); return ( this.startOutcomes.shift() ?? { kind: 'failure', @@ -323,6 +327,59 @@ function deferred() { } describe('StalkerSessionManager', () => { + it('does not replay a credential candidate after an origin challenge intervenes', async () => { + const sourceAuth = new FakeAuth('mac-only', false, [ + { attemptNumber: 1, kind: 'credentials-required' }, + { + finalOrigin: 'https://approved.test', + kind: 'origin-approval-required', + sourceOrigin: 'https://portal.test', + targetUrl: 'https://approved.test/c/', + }, + ]); + const targetAuth = new FakeAuth(); + const { manager } = harness({ + auths: [sourceAuth, targetAuth], + resolverOutcomes: [ + full('https://portal.test/server/load.php'), + { + ...full('https://approved.test/server/load.php'), + landingUrl: 'https://approved.test/c/', + }, + ], + }); + + const credentialChallenge = await manager.open(1, { + descriptor: descriptor('credential-redirect', 'provisional'), + }); + if (credentialChallenge.kind !== 'credentials-required') { + throw new Error('expected-credentials-challenge'); + } + + const originChallenge = await manager.continue(1, { + challengeRef: credentialChallenge.challengeRef, + response: { + kind: 'credentials', + password: 'candidate-password', + username: 'candidate-user', + }, + }); + if (originChallenge.kind !== 'origin-approval-required') { + throw new Error('expected-origin-challenge'); + } + + const ready = await manager.continue(1, { + challengeRef: originChallenge.challengeRef, + response: { + approved: true, + kind: 'origin-approval', + }, + }); + + expectFullReady(ready); + expect(targetAuth.startedCredentials).toEqual([undefined]); + }); + it('restarts approved cross-origin discovery at the target without changing the persisted source', async () => { const originalSource = 'https://portal.test/customer/c/'; const approvedLanding = diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts index de288c528..7c3bd9408 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts @@ -208,6 +208,7 @@ type AuthenticationProgress = | Extract; interface AttemptRecord { + acceptedCredentials?: StalkerAuthCredentials; approvedOrigins: Set; auth?: StalkerSessionAuthLike; challengeRef?: string; @@ -1105,7 +1106,8 @@ export class StalkerSessionManager { } if (progress.kind === 'full-ready') { attempt.auth = progress.auth; - attempt.credentialCandidate = progress.credentials; + attempt.acceptedCredentials = progress.credentials; + attempt.credentialCandidate = undefined; attempt.ready = this.#createFullAttemptReady(attempt, progress); attempt.state = 'ready'; this.#refreshAttemptExpiry(attempt); @@ -1150,6 +1152,8 @@ export class StalkerSessionManager { ); } if (progress.kind === 'stateless-mac') { + attempt.acceptedCredentials = undefined; + attempt.credentialCandidate = undefined; attempt.ready = { endpoint: progress.endpoint, kind: progress.kind, @@ -1172,6 +1176,8 @@ export class StalkerSessionManager { return outcome; } if (progress.kind === 'origin-approval-required') { + attempt.acceptedCredentials = undefined; + attempt.credentialCandidate = undefined; return this.#issueChallenge( attempt, { @@ -1188,9 +1194,8 @@ export class StalkerSessionManager { attempt.lastEndpoint = progress.endpoint; attempt.lastLandingUrl = progress.landingUrl; attempt.mutationEpoch = progress.epoch; - if (progress.outcome.savedCredentialsRejected) { - attempt.credentialCandidate = undefined; - } + attempt.acceptedCredentials = undefined; + attempt.credentialCandidate = undefined; return this.#issueChallenge( attempt, { @@ -1258,9 +1263,9 @@ export class StalkerSessionManager { ? {} : { accountSummary: progress.accountSummary }), auth: progress.auth, - ...(progress.credentials === undefined + ...(attempt.acceptedCredentials === undefined ? {} - : { credentials: progress.credentials }), + : { credentials: attempt.acceptedCredentials }), endpoint: progress.endpoint, epoch: progress.epoch, identityRevision: progress.identityRevision, @@ -1323,6 +1328,8 @@ export class StalkerSessionManager { 'stalker-session-promotion-revalidation-failed' ); } + attempt.acceptedCredentials = progress.credentials; + attempt.credentialCandidate = undefined; ready = this.#createFullAttemptReady( attempt, progress, @@ -1522,6 +1529,7 @@ export class StalkerSessionManager { ); this.#attempts.delete(attempt.ref); this.#rememberAttempt(attempt, action); + attempt.acceptedCredentials = undefined; attempt.auth = undefined; attempt.credentialCandidate = undefined; attempt.ready = undefined; @@ -1552,6 +1560,7 @@ export class StalkerSessionManager { if (attempt.ready?.kind === 'full-session') { this.#playbackContexts?.invalidateLease(attempt.ready.leaseRef); } + attempt.acceptedCredentials = undefined; attempt.auth = undefined; attempt.credentialCandidate = undefined; attempt.ready = undefined; 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 8d9737509..dcaf6ff59 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 @@ -62,6 +62,12 @@ function deferred(): { return { promise, resolve }; } +async function flushPromises(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +} + describe('StalkerPortalImportComponent', () => { let component: StalkerPortalImportComponent; let session: { @@ -69,6 +75,7 @@ describe('StalkerPortalImportComponent', () => { continue: jest.Mock; commit: jest.Mock; discard: jest.Mock; + close: jest.Mock; }; let playlists: { persistStalkerConnection: jest.Mock; @@ -85,6 +92,11 @@ describe('StalkerPortalImportComponent', () => { kind: 'success', requestId: 'request-discard-1', }), + close: jest.fn().mockResolvedValue({ + action: 'close', + kind: 'success', + requestId: 'request-close-1', + }), }; playlists = { persistStalkerConnection: jest.fn((playlist: Playlist) => @@ -281,6 +293,89 @@ describe('StalkerPortalImportComponent', () => { expect(component.connectionStage()).toBe('connected'); }); + it.each([ + ['status-0 full session', READY_OUTCOME], + [ + 'stateless portal', + { + attemptRef: 'attempt-import-stateless', + connectionMode: 'provisional', + endpoint: 'https://portal.example/portal.php', + kind: 'ready', + landingUrl: 'https://portal.example/c/', + persistenceDraft: { + isFullStalkerPortal: false, + portalUrl: 'https://portal.example/portal.php', + stalkerLandingUrl: 'https://portal.example/c/', + stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z', + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'stateless-mac', + stalkerSourceUrl: 'https://portal.example/c/', + }, + provisionalReason: 'import', + recipe: 'stateless-mac', + requestId: 'request-ready-stateless', + } satisfies StalkerSessionConnectionOutcome, + ], + ])( + 'does not persist prefilled credential candidates for a ready %s', + async (_label, outcome) => { + component.form.patchValue({ + password: 'candidate-password', + username: 'candidate-user', + }); + session.open.mockResolvedValueOnce(outcome); + + await component.addPlaylist(); + + const persisted = playlists.persistStalkerConnection.mock + .calls[0][0] as Playlist; + expect(persisted.username).toBeUndefined(); + expect(persisted.password).toBeUndefined(); + } + ); + + it('does not accept credentials when their continuation resolves as stateless', async () => { + session.open.mockResolvedValueOnce({ + attemptNumber: 1, + attemptRef: 'attempt-credentials-stateless', + challengeRef: 'challenge-credentials-stateless', + kind: 'credentials-required', + requestId: 'request-credentials-stateless', + }); + session.continue.mockResolvedValueOnce({ + attemptRef: 'attempt-credentials-stateless', + connectionMode: 'provisional', + endpoint: 'https://portal.example/portal.php', + kind: 'ready', + landingUrl: 'https://portal.example/c/', + persistenceDraft: { + isFullStalkerPortal: false, + portalUrl: 'https://portal.example/portal.php', + stalkerLandingUrl: 'https://portal.example/c/', + stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z', + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'stateless-mac', + stalkerSourceUrl: 'https://portal.example/c/', + }, + provisionalReason: 'import', + recipe: 'stateless-mac', + requestId: 'request-ready-stateless', + }); + + await component.addPlaylist(); + component.form.patchValue({ + password: 'candidate-password', + username: 'candidate-user', + }); + await component.submitCredentials(); + + const persisted = playlists.persistStalkerConnection.mock + .calls[0][0] as Playlist; + expect(persisted.username).toBeUndefined(); + expect(persisted.password).toBeUndefined(); + }); + it('retains form values after rejected credentials without overwriting storage', async () => { session.open.mockResolvedValueOnce({ attemptNumber: 1, @@ -485,6 +580,24 @@ describe('StalkerPortalImportComponent', () => { expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); }); + it('closes a committed full-session lease when the form is cleared during commit', async () => { + const commit = deferred(); + session.commit.mockReturnValueOnce(commit.promise); + + const add = component.addPlaylist(); + await flushPromises(); + expect(session.commit).toHaveBeenCalledWith('attempt-import-1'); + + component.clearForm(); + await Promise.resolve(); + commit.resolve(CONTROL_SUCCESS); + await add; + + expect(session.close).toHaveBeenCalledWith('lease-import-1'); + expect(component.connectionStage()).toBe('idle'); + expect(component.addClicked.emit).not.toHaveBeenCalled(); + }); + function setValidForm(): void { component.form.patchValue({ _id: 'playlist-1', 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 25ecf1875..4a713b588 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 @@ -104,6 +104,7 @@ export class StalkerPortalImportComponent implements OnDestroy { private pendingAttemptRef: StalkerSessionAttemptRef | null = null; private pendingChallengeRef: StalkerSessionChallengeRef | null = null; private pendingReady: PendingReady | null = null; + private acceptedCredentials: StalkerAcceptedCredentials | undefined; private credentialCandidate: StalkerAcceptedCredentials | undefined; private automaticCredentialsTried = false; private committed = false; @@ -233,6 +234,7 @@ export class StalkerPortalImportComponent implements OnDestroy { const runId = this.runId; this.credentialCandidate = credentials; + this.acceptedCredentials = undefined; this.automaticCredentialsTried = true; this.pendingChallengeRef = null; this.connectionStage.set('validating-credentials'); @@ -245,6 +247,11 @@ export class StalkerPortalImportComponent implements OnDestroy { }, }); if (runId === this.runId) { + this.acceptedCredentials = + isStalkerReadyOutcome(outcome) && + outcome.recipe === 'full-session' + ? credentials + : undefined; await this.handleOutcome(outcome, runId); } else { await this.discardStaleOutcome(outcome); @@ -269,7 +276,7 @@ export class StalkerPortalImportComponent implements OnDestroy { const playlist = applyStalkerReadyOutcome( provisional, outcome, - this.credentialCandidate + this.acceptedCredentials ); const pending = { outcome, playlist }; this.pendingAttemptRef = outcome.attemptRef ?? null; @@ -279,6 +286,8 @@ export class StalkerPortalImportComponent implements OnDestroy { } if (outcome.kind === 'origin-approval-required') { + this.acceptedCredentials = undefined; + this.credentialCandidate = undefined; this.pendingAttemptRef = outcome.attemptRef; this.pendingChallengeRef = outcome.challengeRef; this.pendingOriginApproval.set({ @@ -290,18 +299,18 @@ export class StalkerPortalImportComponent implements OnDestroy { } if (outcome.kind === 'credentials-required') { + this.acceptedCredentials = undefined; this.pendingAttemptRef = outcome.attemptRef; this.pendingChallengeRef = outcome.challengeRef; this.credentialsRequested.set(true); this.savedCredentialsRejected.set( outcome.savedCredentialsRejected === true ); - if ( - this.credentialCandidate !== undefined && - !this.automaticCredentialsTried - ) { + const candidate = this.credentialCandidate; + this.credentialCandidate = undefined; + if (candidate !== undefined && !this.automaticCredentialsTried) { this.automaticCredentialsTried = true; - await this.continueWithCredentials(this.credentialCandidate); + await this.continueWithCredentials(candidate); return; } this.connectionStage.set('credentials-required'); @@ -347,7 +356,7 @@ export class StalkerPortalImportComponent implements OnDestroy { } try { const promotion = await this.session.commit(attemptRef); - if (runId !== this.runId || promotion.kind !== 'success') { + if (promotion.kind !== 'success') { await this.failPromotion(runId); return; } @@ -355,6 +364,10 @@ export class StalkerPortalImportComponent implements OnDestroy { await this.failPromotion(runId); return; } + if (runId !== this.runId) { + await this.closeCommittedLease(pending.outcome); + return; + } this.committed = true; this.pendingAttemptRef = null; @@ -370,6 +383,14 @@ export class StalkerPortalImportComponent implements OnDestroy { } } + private async closeCommittedLease( + outcome: StalkerImportReadyOutcome + ): Promise { + if (outcome.recipe === 'full-session') { + await this.session.close(outcome.leaseRef).catch(() => undefined); + } + } + private async discardPendingAttempt(): Promise { const attemptRef = this.pendingReady?.outcome.attemptRef ?? @@ -393,6 +414,8 @@ export class StalkerPortalImportComponent implements OnDestroy { } private fail(reason: StalkerSessionFailureReason): void { + this.acceptedCredentials = undefined; + this.credentialCandidate = undefined; this.failureReason.set(reason); this.connectionStage.set('failure'); this.notifyFailure(reason); @@ -414,6 +437,7 @@ export class StalkerPortalImportComponent implements OnDestroy { this.pendingAttemptRef = null; this.pendingChallengeRef = null; this.pendingReady = null; + this.acceptedCredentials = undefined; this.credentialCandidate = undefined; this.automaticCredentialsTried = false; this.committed = false; diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts index 9e83ddb06..397b02e7a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.spec.ts @@ -118,6 +118,11 @@ function createSessionMock() { let recoveryHandler: StalkerSessionRecoveryHandler | undefined; return { activate: jest.fn(), + close: jest.fn().mockResolvedValue({ + action: 'close', + kind: 'success', + requestId: 'request-close-route-1', + }), commit: jest.fn().mockResolvedValue(CONTROL_SUCCESS), continue: jest.fn(), discard: jest.fn().mockResolvedValue({ @@ -243,10 +248,12 @@ describe('StalkerConnectionFlowService', () => { expect(connected).toEqual(persistedDraft); }); - it('keeps an explicitly simple record and PWA behavior unchanged', async () => { + it('keeps an explicitly stateless record and PWA behavior unchanged', async () => { const simple = { ...LEGACY_PLAYLIST, isFullStalkerPortal: false, + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'stateless-mac' as const, }; await expect(service.ensureConnected(simple)).resolves.toBe(simple); @@ -258,6 +265,73 @@ describe('StalkerConnectionFlowService', () => { expect(session.open).not.toHaveBeenCalled(); }); + it('lets a full-session recipe override a stale false legacy boolean', async () => { + session.open.mockResolvedValueOnce(READY); + const playlist = { + ...LEGACY_PLAYLIST, + isFullStalkerPortal: false, + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'full-session' as const, + }; + + await expect(service.ensureConnected(playlist)).resolves.toMatchObject({ + stalkerRequestRecipe: 'full-session', + }); + + expect(session.open).toHaveBeenCalledWith(playlist, {}); + }); + + it('provisionally reclassifies a full-session recipe from a stale classifier', async () => { + session.open.mockResolvedValueOnce(READY); + const playlist = { + ...LEGACY_PLAYLIST, + isFullStalkerPortal: true, + stalkerRecipeClassifierVersion: 0, + stalkerRequestRecipe: 'full-session' as const, + }; + + await service.ensureConnected(playlist); + + expect(session.open).toHaveBeenCalledWith(playlist, { + connectionMode: 'provisional', + provisionalReason: 'migration', + }); + }); + + it('provisionally reclassifies a stateless recipe from a stale classifier', async () => { + session.open.mockResolvedValueOnce(READY); + const playlist = { + ...LEGACY_PLAYLIST, + isFullStalkerPortal: false, + stalkerRecipeClassifierVersion: 0, + stalkerRequestRecipe: 'stateless-mac' as const, + }; + + await service.ensureConnected(playlist); + + expect(session.open).toHaveBeenCalledWith(playlist, { + connectionMode: 'provisional', + provisionalReason: 'migration', + }); + }); + + it('lazily classifies a recipe-less record regardless of its legacy boolean', async () => { + session.open.mockResolvedValueOnce(READY); + const playlist = { + ...LEGACY_PLAYLIST, + isFullStalkerPortal: false, + }; + + await expect(service.ensureConnected(playlist)).resolves.toMatchObject({ + stalkerRequestRecipe: 'full-session', + }); + + expect(session.open).toHaveBeenCalledWith(playlist, { + connectionMode: 'provisional', + provisionalReason: 'migration', + }); + }); + it('shows exact origin and credential challenges and persists only accepted credentials', async () => { const origin: StalkerSessionConnectionOutcome = { attemptRef: 'attempt-route-1', @@ -341,6 +415,92 @@ describe('StalkerConnectionFlowService', () => { }); }); + it('does not persist credentials when an origin challenge intervenes before ready', async () => { + const credentials: StalkerSessionConnectionOutcome = { + attemptNumber: 1, + attemptRef: 'attempt-credential-origin', + challengeRef: 'challenge-credential-origin', + kind: 'credentials-required', + requestId: 'request-credential-origin', + }; + const origin: StalkerSessionConnectionOutcome = { + attemptRef: 'attempt-credential-origin', + challengeRef: 'challenge-origin-after-credentials', + finalOrigin: 'https://redirected.example', + kind: 'origin-approval-required', + requestId: 'request-origin-after-credentials', + sourceOrigin: 'https://source.example', + }; + session.open.mockResolvedValueOnce(credentials); + session.continue + .mockResolvedValueOnce(origin) + .mockResolvedValueOnce(READY); + queuedDialogResults.push( + { + password: 'candidate-password', + username: 'candidate-user', + }, + true + ); + + const connected = await service.ensureConnected(LEGACY_PLAYLIST); + + expect(connected?.username).toBeUndefined(); + expect(connected?.password).toBeUndefined(); + const persisted = playlists.persistStalkerConnection.mock + .calls[0][0] as Playlist; + expect(persisted.username).toBeUndefined(); + expect(persisted.password).toBeUndefined(); + }); + + it('invalidates saved credentials when open redirects before the target becomes ready', async () => { + const origin: StalkerSessionConnectionOutcome = { + attemptRef: 'attempt-saved-credential-origin', + challengeRef: 'challenge-saved-credential-origin', + finalOrigin: 'https://redirected.example', + kind: 'origin-approval-required', + requestId: 'request-saved-credential-origin', + sourceOrigin: 'https://source.example', + }; + session.open.mockResolvedValueOnce(origin); + session.continue.mockResolvedValueOnce(READY); + queuedDialogResults.push(true); + + const connected = await service.ensureConnected({ + ...LEGACY_PLAYLIST, + password: 'saved-password', + username: 'saved-user', + }); + + expect(connected?.username).toBeUndefined(); + expect(connected?.password).toBeUndefined(); + const persisted = playlists.persistStalkerConnection.mock + .calls[0][0] as Playlist; + expect(persisted.username).toBeUndefined(); + expect(persisted.password).toBeUndefined(); + }); + + it('discards the active credential challenge when the dialog is explicitly cancelled', async () => { + const credentials: StalkerSessionConnectionOutcome = { + attemptNumber: 1, + attemptRef: 'attempt-credentials-cancelled', + challengeRef: 'challenge-credentials-cancelled', + kind: 'credentials-required', + requestId: 'request-credentials-cancelled', + }; + session.open.mockResolvedValueOnce(credentials); + queuedDialogResults.push(null); + + await expect( + service.ensureConnected(LEGACY_PLAYLIST) + ).resolves.toBeUndefined(); + + expect(session.discard).toHaveBeenCalledWith( + 'attempt-credentials-cancelled' + ); + expect(playlists.persistStalkerConnection).not.toHaveBeenCalled(); + }); + it('consumes a rejected origin challenge without persisting anything', async () => { const origin: StalkerSessionConnectionOutcome = { attemptRef: 'attempt-origin-rejected', @@ -409,6 +569,25 @@ describe('StalkerConnectionFlowService', () => { expect(session.discard).toHaveBeenCalledWith('attempt-route-1'); }); + it('closes a committed lease when cancellation wins during commit', async () => { + const commit = createDeferred(); + session.forceRedetect.mockResolvedValueOnce(READY); + session.commit.mockReturnValueOnce(commit.promise); + const emitted = jest.fn(); + service.connectionReady$.subscribe(emitted); + + const connecting = service.forceRedetect(LEGACY_PLAYLIST); + await flushPromises(); + expect(session.commit).toHaveBeenCalledWith('attempt-route-1'); + + await service.cancel(); + commit.resolve(CONTROL_SUCCESS); + + await expect(connecting).resolves.toBeUndefined(); + expect(session.close).toHaveBeenCalledWith('lease-route-1'); + expect(emitted).not.toHaveBeenCalled(); + }); + it('reopens the persisted connection after discarding a rejected promotion', async () => { session.open .mockResolvedValueOnce(READY) diff --git a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts index 4dca685f2..6f7f41127 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-connection-flow/stalker-connection-flow.service.ts @@ -9,6 +9,7 @@ import { StalkerSessionRecoveryRequest, StalkerSessionService, } from '@iptvnator/portal/stalker/data-access'; +import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol'; import { PlaylistsService } from '@iptvnator/services'; import { Playlist, @@ -164,7 +165,11 @@ export class StalkerConnectionFlowService { announceReady: boolean ): Promise { let outcome = initialOutcome; - let credentials: StalkerCredentialsDialogResult | undefined; + let acceptedCredentials: + | StalkerCredentialsDialogResult + | undefined; + let credentialsInvalidated = false; + let credentialsSubmitted = false; while (true) { if (await this.discardOutcomeWhenStale(outcome, runId)) { return undefined; @@ -175,7 +180,8 @@ export class StalkerConnectionFlowService { const draft = this.applyReadyOutcome( playlist, outcome, - credentials + acceptedCredentials, + credentialsInvalidated || credentialsSubmitted ); return this.persistAndCommit({ announceReady, @@ -185,6 +191,8 @@ export class StalkerConnectionFlowService { }); } if (outcome.kind === 'origin-approval-required') { + acceptedCredentials = undefined; + credentialsInvalidated = true; const approved = await this.requestOriginApproval(outcome); if (runId !== this.runId) { return undefined; @@ -205,13 +213,19 @@ export class StalkerConnectionFlowService { continue; } if (outcome.kind === 'credentials-required') { - credentials = await this.requestCredentials( + acceptedCredentials = undefined; + const credentials = await this.requestCredentials( playlist, outcome.savedCredentialsRejected === true ); - if (runId !== this.runId || credentials === undefined) { + if (runId !== this.runId) { return undefined; } + if (credentials === undefined) { + await this.discardPending(); + return undefined; + } + credentialsSubmitted = true; outcome = await this.session.continue(playlist._id, { challengeRef: outcome.challengeRef, response: { @@ -219,6 +233,8 @@ export class StalkerConnectionFlowService { ...credentials, }, }); + acceptedCredentials = + outcome.kind === 'ready' ? credentials : undefined; continue; } return undefined; @@ -314,6 +330,10 @@ export class StalkerConnectionFlowService { ); } } + if (pending.runId !== this.runId) { + await this.closeCommittedLease(pending.outcome); + return undefined; + } this.activeAttemptRef = null; this.pendingPersistence = null; if (pending.announceReady) { @@ -382,18 +402,34 @@ export class StalkerConnectionFlowService { private applyReadyOutcome( playlist: Playlist, outcome: ReadyOutcome, - credentials: StalkerCredentialsDialogResult | undefined + acceptedCredentials: StalkerCredentialsDialogResult | undefined, + clearUnacceptedCredentials: boolean ): Playlist { const draft: Playlist = { ...playlist, ...outcome.persistenceDraft, portalUrl: outcome.persistenceDraft.portalUrl, - ...(credentials === undefined ? {} : credentials), + ...(acceptedCredentials === undefined + ? {} + : acceptedCredentials), }; + if ( + outcome.recipe === 'stateless-mac' || + (clearUnacceptedCredentials && acceptedCredentials === undefined) + ) { + delete draft.username; + delete draft.password; + } delete draft.stalkerToken; return draft; } + private async closeCommittedLease(outcome: ReadyOutcome): Promise { + if (outcome.recipe === 'full-session') { + await this.session.close(outcome.leaseRef).catch(() => undefined); + } + } + private offerPersistenceRetry(): void { const ref = this.snackBar.open( this.translate.instant( @@ -440,15 +476,19 @@ export class StalkerConnectionFlowService { private shouldUseTypedSession(playlist: Playlist): boolean { return ( this.session.supportsTypedSessions() && - playlist.isFullStalkerPortal !== false && - playlist.stalkerRequestRecipe !== 'stateless-mac' + !( + playlist.stalkerRequestRecipe === 'stateless-mac' && + playlist.stalkerRecipeClassifierVersion === + STALKER_RECIPE_CLASSIFIER_VERSION + ) ); } private hasVerifiedRecipe(playlist: Playlist): boolean { return ( playlist.stalkerRequestRecipe === 'full-session' && - typeof playlist.stalkerRecipeClassifierVersion === 'number' + playlist.stalkerRecipeClassifierVersion === + STALKER_RECIPE_CLASSIFIER_VERSION ); } }