fix(stalker): harden provisional connection lifecycle

This commit is contained in:
4gray committed 2026-07-27 12:29:46 +02:00
1 parent 79dcbba241
commit 8569612dbd
6 files changed
+446 -24

No files matched your search

@@ -115,6 +115,7 @@ class FakeAuth implements StalkerSessionAuthLike {
readonly profileOutcomes: StalkerAuthenticatedRequestOutcome[] = [];
profileChecks = 0;
readonly requests: Readonly<Record<string, string | number>>[] = [];
readonly startedCredentials: Array<StalkerAuthCredentials | undefined> = [];
readonly submittedCredentials: StalkerAuthCredentials[] = [];
startCalls = 0;
@@ -151,8 +152,11 @@ class FakeAuth implements StalkerSessionAuthLike {
);
}
async start(): Promise<StalkerAuthOutcome> {
async start(
credentials?: StalkerAuthCredentials
): Promise<StalkerAuthOutcome> {
this.startCalls += 1;
this.startedCredentials.push(credentials);
return (
this.startOutcomes.shift() ?? {
kind: 'failure',
@@ -323,6 +327,59 @@ function deferred<T>() {
}
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 =
@@ -208,6 +208,7 @@ type AuthenticationProgress =
| Extract<StalkerSessionConnectionOutcome, { kind: 'failure' }>;
interface AttemptRecord {
acceptedCredentials?: StalkerAuthCredentials;
approvedOrigins: Set<string>;
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;
@@ -62,6 +62,12 @@ function deferred<T>(): {
return { promise, resolve };
}
async function flushPromises(): Promise<void> {
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<typeof CONTROL_SUCCESS>();
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',
@@ -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<void> {
if (outcome.recipe === 'full-session') {
await this.session.close(outcome.leaseRef).catch(() => undefined);
}
}
private async discardPendingAttempt(): Promise<void> {
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;
@@ -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<typeof CONTROL_SUCCESS>();
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)
@@ -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<Playlist | undefined> {
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<void> {
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
);
}
}