diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts index dd867b57a..228825037 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts @@ -117,6 +117,28 @@ describe('StalkerAuthSession', () => { expect(calls).toHaveLength(0); }); + it('prefers profile timeslot over watchdog_timeout for the watchdog interval', async () => { + const { auth } = harness( + { + kind: 'ready', + profile: { + status: 0, + timeslot: '17.5', + watchdog_timeout: '45', + }, + status: 0, + }, + () => { + throw new Error('No request expected'); + } + ); + + await expect(auth.start()).resolves.toMatchObject({ + kind: 'ready', + watchdogIntervalSeconds: 17.5, + }); + }); + it('runs do_auth only after status 2, then sends the second profile with step 1', async () => { const exactUsername = ' confirmed-user '; const { auth, calls } = harness( @@ -216,6 +238,114 @@ describe('StalkerAuthSession', () => { }); }); + it('reuses the coherent authenticated profile payload for watchdog checks', async () => { + const { auth, calls } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + (request) => { + if (request.params?.['action'] === 'do_auth') { + return success({ js: true }); + } + return success({ + js: { + login: 'configured-user', + status: 0, + }, + }); + }, + { + apiSignature: 'configured-api-signature', + deviceId1: 'configured-device-one', + deviceId2: 'configured-device-two', + firmwareVersion: 'configured-firmware', + hardwareVersion: 'configured-hardware', + macAddress: '00:1A:79:AA:BB:CC', + prehash: 'configured-prehash', + serialNumber: 'configured-serial', + signature1: 'configured-signature', + signature2: 'configured-signature-two', + } + ); + + await auth.start(); + await auth.submitCredentials({ + password: 'configured-password', + username: 'configured-user', + }); + await expect(auth.checkProfile()).resolves.toMatchObject({ + kind: 'success', + }); + + expect(calls).toHaveLength(3); + expect(calls[2].params).toEqual(calls[1].params); + expect(calls[2].params).toMatchObject({ + action: 'get_profile', + api_signature: 'configured-api-signature', + auth_second_step: 1, + device_id: 'configured-device-one', + device_id2: 'configured-device-two', + hw_version: 'configured-hardware', + not_valid_token: 0, + prehash: 'configured-prehash', + signature: 'configured-signature', + signature2: 'configured-signature-two', + sn: 'configured-serial', + type: 'stb', + ver: 'configured-firmware', + }); + expect(JSON.parse(String(calls[2].params?.['metrics']))).toMatchObject({ + mac: '00:1A:79:AA:BB:CC', + sn: 'configured-serial', + uid: 'configured-device-two', + }); + }); + + it('keeps a MAC-only watchdog profile on the first authentication step', async () => { + const { auth, calls } = harness( + { + kind: 'ready', + profile: { status: 0 }, + status: 0, + }, + () => success({ js: { status: 0 } }) + ); + await auth.start(); + + await expect(auth.checkProfile()).resolves.toMatchObject({ + kind: 'success', + }); + + expect(calls[0].params).toMatchObject({ + action: 'get_profile', + auth_second_step: 0, + not_valid_token: 0, + type: 'stb', + }); + }); + + it('classifies a blocked watchdog profile without changing identity', async () => { + const { auth } = harness( + { + kind: 'ready', + profile: { login: 'stable-user', status: 0 }, + status: 0, + }, + () => success({ js: { status: 1 } }) + ); + await auth.start(); + + await expect(auth.checkProfile()).resolves.toEqual({ + kind: 'failure', + reason: 'account-or-device-blocked', + retryable: false, + stage: 'ready', + }); + expect(auth.getPrincipalKey()).toBe('stable-user'); + }); + it('marks rejected saved credentials and accepts a fresh bounded submission', async () => { let doAuthCount = 0; const { auth } = harness( diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts index 4225319ca..404039e4e 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts @@ -232,15 +232,7 @@ export class StalkerAuthSession { const secondProfile = await this.#http.request({ headers: this.authenticatedHeaders(), mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, - params: { - ...this.#identity.profileParameters, - action: 'get_profile', - auth_second_step: 1, - JsHttpRequest: '1-xml', - metrics: JSON.stringify(this.#identity.metrics), - not_valid_token: 0, - type: 'stb', - }, + params: profileRequestParameters(this.#identity, true), url: this.#endpoint, }); const normalizedSecondProfile = normalizeResponse( @@ -288,6 +280,50 @@ export class StalkerAuthSession { return this.#acceptedCredentials !== undefined; } + async checkProfile(): Promise { + if (this.#state !== 'ready') { + return authFailure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + 'ready', + false + ); + } + const outcome = await this.#http.request({ + headers: this.authenticatedHeaders(), + mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, + params: profileRequestParameters( + this.#identity, + this.#acceptedCredentials !== undefined + ), + url: this.#endpoint, + }); + const normalized = normalizeResponse(outcome, 'ready', true); + if (normalized.kind !== 'success') { + return normalized; + } + + const profile = classifyStalkerProfile(normalized.value); + if (profile.kind === 'ready') { + this.#profile = profile.profile; + return normalized; + } + if (profile.kind === 'blocked') { + return authFailure( + STALKER_FAILURE_REASONS.AccountOrDeviceBlocked, + 'ready', + false + ); + } + if (profile.kind === 'credentials-required') { + return authFailure( + STALKER_FAILURE_REASONS.PrincipalTransitionRequired, + 'ready', + false + ); + } + return authFailure(profile.reason, 'ready', false); + } + async preparePlayback(streamUrl: string): Promise { if (this.#state !== 'ready') { throw new Error('stalker-playback-not-ready'); @@ -540,6 +576,21 @@ function doAuthIdentityParameters( return parameters; } +function profileRequestParameters( + identity: StalkerIdentityProfile, + authSecondStep: boolean +): Readonly> { + return { + ...identity.profileParameters, + action: 'get_profile', + auth_second_step: authSecondStep ? 1 : 0, + JsHttpRequest: '1-xml', + metrics: JSON.stringify(identity.metrics), + not_valid_token: 0, + type: 'stb', + }; +} + function buildReadyOutcome( profile: Readonly> ): StalkerAuthReadyOutcome { @@ -562,9 +613,9 @@ function buildReadyOutcome( readNonEmptyString(accountInfo?.['tariff_plan_name']) ?? readNonEmptyString(profile['tariff_plan_name']), }); - const watchdogIntervalSeconds = readPositiveFiniteNumber( - profile['watchdog_timeout'] - ); + const watchdogIntervalSeconds = + readPositiveFiniteNumber(profile['timeslot']) ?? + readPositiveFiniteNumber(profile['watchdog_timeout']); return { ...(accountSummary === undefined ? {} : { accountSummary }), kind: 'ready', diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts index 0b14c2286..b519baf50 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts @@ -48,6 +48,10 @@ class BoundaryAuth implements StalkerSessionAuthLike { return true; } + async checkProfile(): Promise { + return { kind: 'success', value: { js: { status: 0 } } }; + } + async preparePlayback(): Promise<{ headers: Readonly>; streamUrl: string; 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 aea3b02f3..e23459990 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 @@ -112,6 +112,8 @@ function stateless(): StalkerEndpointResolverOutcome { } class FakeAuth implements StalkerSessionAuthLike { + readonly profileOutcomes: StalkerAuthenticatedRequestOutcome[] = []; + profileChecks = 0; readonly requests: Readonly>[] = []; readonly submittedCredentials: StalkerAuthCredentials[] = []; startCalls = 0; @@ -139,6 +141,16 @@ class FakeAuth implements StalkerSessionAuthLike { return this.acceptedCredentials; } + async checkProfile(): Promise { + this.profileChecks += 1; + return ( + this.profileOutcomes.shift() ?? { + kind: 'success', + value: { js: { status: 0 } }, + } + ); + } + async start(): Promise { this.startCalls += 1; return ( @@ -1262,6 +1274,100 @@ describe('StalkerSessionManager', () => { expect(playbackContexts.clear).toHaveBeenCalledTimes(1); }); + it('surfaces classified watchdog failures until a later profile check recovers', async () => { + jest.useFakeTimers(); + const random = jest.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const auth = new FakeAuth(); + auth.profileOutcomes.push( + { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.AccountAccessDenied, + retryable: false, + stage: 'ready', + }, + { kind: 'success', value: { js: { status: 0 } } } + ); + const { manager, resolve } = harness({ + auths: [auth], + resolverOutcomes: [full()], + }); + const ready = await manager.open(1, { + descriptor: descriptor('watchdog-recovery'), + }); + expectFullReady(ready); + await manager.control(1, { + action: 'activate', + leaseRef: ready.leaseRef, + }); + + await jest.advanceTimersByTimeAsync(25_000); + + expect(auth.profileChecks).toBe(1); + await expect( + manager.request(1, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.AccountAccessDenied, + stage: 'ready', + }); + expect(auth.requests).toHaveLength(0); + expect(resolve).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(25_000); + + await expect( + manager.request(1, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ kind: 'success' }); + expect(auth.profileChecks).toBe(2); + expect(auth.requests).toHaveLength(1); + expect(resolve).toHaveBeenCalledTimes(1); + + await manager.destroyAll(); + } finally { + random.mockRestore(); + jest.useRealTimers(); + } + }); + + it('routes watchdog token rejection through one manager-owned refresh', async () => { + jest.useFakeTimers(); + const random = jest.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const expired = new FakeAuth(); + expired.profileOutcomes.push({ kind: 'token-rejected' }); + const refreshed = new FakeAuth(); + const { manager, resolve } = harness({ + auths: [expired, refreshed], + resolverOutcomes: [full(), full()], + }); + const ready = await manager.open(1, { + descriptor: descriptor('watchdog-refresh'), + }); + expectFullReady(ready); + await manager.control(1, { + action: 'activate', + leaseRef: ready.leaseRef, + }); + + await jest.advanceTimersByTimeAsync(25_000); + + expect(expired.profileChecks).toBe(1); + expect(resolve).toHaveBeenCalledTimes(2); + expect(refreshed.startCalls).toBe(1); + await expect( + manager.request(1, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ kind: 'success' }); + expect(expired.requests).toHaveLength(0); + expect(refreshed.requests).toHaveLength(1); + + await manager.destroyAll(); + } finally { + random.mockRestore(); + jest.useRealTimers(); + } + }); + it('cannot resurrect an attempt after its renderer is cleaned up mid-authentication', async () => { const resolution = deferred(); const manager = new StalkerSessionManager({ 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 9f231690d..de288c528 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 @@ -75,6 +75,7 @@ export interface StalkerPreparedPlayback { } export interface StalkerSessionAuthLike { + checkProfile(): Promise; getEndpoint(): string; getPrincipalKey(): string; hasAcceptedCredentials(): boolean; @@ -265,6 +266,11 @@ interface LeaseRecord { session: SessionRecord; } +type WatchdogRecoveryOutcome = Exclude< + StalkerAuthenticatedRequestOutcome, + { kind: 'success' } | { kind: 'token-rejected' } +>; + interface SessionRecord { accountSummary?: StalkerSessionAccountSummary; readonly activeLeases: Set; @@ -284,6 +290,7 @@ interface SessionRecord { resolutionSourceUrl?: string; readonly coordinator: StalkerBaseIdentityCoordinator; transport: StalkerTransportConfig; + watchdogRecovery?: WatchdogRecoveryOutcome; watchdogIntervalSeconds?: number; } @@ -401,22 +408,43 @@ export class StalkerSessionManager { if (!session || session.principal !== target.principal) { return; } + const binding = this.#captureBinding(session); const read = await session.coordinator.runRead( - session.principal, - session.epoch, - () => - session.auth.request({ - action: 'get_profile', - auth_second_step: 0, - JsHttpRequest: '1-xml', - type: 'stb', - }) + binding.principal, + binding.epoch, + () => binding.auth.checkProfile() ); if ( - read.kind === 'completed' && - read.value.kind === 'token-rejected' + read.kind !== 'completed' || + !this.#isCurrentSessionBinding(binding) ) { - await this.#refreshSession(session); + return; + } + if (read.value.kind === 'success') { + session.watchdogRecovery = undefined; + return; + } + if (read.value.kind !== 'token-rejected') { + session.watchdogRecovery = read.value; + return; + } + + const refreshed = await this.#refreshSession(session); + if (refreshed.kind === 'ready') { + refreshed.session.watchdogRecovery = undefined; + } else if (this.#isLiveSession(session)) { + session.watchdogRecovery = + refreshed.kind === 'failure' + ? refreshed.outcome + : { + finalOrigin: + refreshed.progress.finalOrigin, + kind: 'origin-approval-required', + sourceOrigin: + refreshed.progress.sourceOrigin, + targetUrl: + refreshed.progress.landingUrl, + }; } }, }); @@ -626,6 +654,23 @@ export class StalkerSessionManager { } as StalkerSessionRequestOutcome; } + const watchdogRecovery = lease.session.watchdogRecovery; + if (watchdogRecovery !== undefined) { + if (watchdogRecovery.kind === 'origin-approval-required') { + lease.session.watchdogRecovery = undefined; + return this.#issueRecoveryOriginChallenge( + senderId, + lease.session, + watchdogRecovery, + requestId + ) as StalkerSessionRequestOutcome; + } + return projectAuthFailure( + requestId, + watchdogRecovery + ) as StalkerSessionRequestOutcome; + } + let execution = await this.#runSessionRequest( lease.session, mapped.parameters @@ -1782,6 +1827,7 @@ export class StalkerSessionManager { progress.credentials ); session.credentials = progress.credentials; + session.watchdogRecovery = undefined; session.watchdogIntervalSeconds = progress.watchdogIntervalSeconds; this.#sessions.set(nextKey, session); this.#reactivateTransferredLeases(session); @@ -2038,9 +2084,22 @@ export class StalkerSessionManager { lease: LeaseRecord, binding: SessionGenerationBinding ): boolean { - const snapshot = binding.session.coordinator.snapshot; return ( this.#isCurrentGenerationBinding(lease, binding) && + this.#isCurrentSessionBinding(binding) + ); + } + + #isCurrentSessionBinding(binding: SessionGenerationBinding): boolean { + const session = binding.session; + const snapshot = session.coordinator.snapshot; + return ( + this.#sessions.get(binding.key) === session && + session.auth === binding.auth && + session.epoch === binding.epoch && + session.generation === binding.generation && + session.key === binding.key && + session.principal === binding.principal && snapshot.epoch === binding.epoch && snapshot.activePrincipal === binding.principal );