fix(stalker): surface watchdog session failures

This commit is contained in:
4gray committed 2026-07-27 12:15:35 +02:00
1 parent 4317380137
commit 317f4ffdad
5 files changed
+375 -25

No files matched your search

@@ -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(
@@ -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<StalkerAuthenticatedRequestOutcome> {
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<StalkerPreparedPlayback> {
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<Record<string, string | number>> {
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<Record<string, unknown>>
): 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',
@@ -48,6 +48,10 @@ class BoundaryAuth implements StalkerSessionAuthLike {
return true;
}
async checkProfile(): Promise<StalkerAuthenticatedRequestOutcome> {
return { kind: 'success', value: { js: { status: 0 } } };
}
async preparePlayback(): Promise<{
headers: Readonly<Record<string, string>>;
streamUrl: string;
@@ -112,6 +112,8 @@ function stateless(): StalkerEndpointResolverOutcome {
}
class FakeAuth implements StalkerSessionAuthLike {
readonly profileOutcomes: StalkerAuthenticatedRequestOutcome[] = [];
profileChecks = 0;
readonly requests: Readonly<Record<string, string | number>>[] = [];
readonly submittedCredentials: StalkerAuthCredentials[] = [];
startCalls = 0;
@@ -139,6 +141,16 @@ class FakeAuth implements StalkerSessionAuthLike {
return this.acceptedCredentials;
}
async checkProfile(): Promise<StalkerAuthenticatedRequestOutcome> {
this.profileChecks += 1;
return (
this.profileOutcomes.shift() ?? {
kind: 'success',
value: { js: { status: 0 } },
}
);
}
async start(): Promise<StalkerAuthOutcome> {
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<StalkerEndpointResolverOutcome>();
const manager = new StalkerSessionManager({
@@ -75,6 +75,7 @@ export interface StalkerPreparedPlayback {
}
export interface StalkerSessionAuthLike {
checkProfile(): Promise<StalkerAuthenticatedRequestOutcome>;
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<string>;
@@ -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<Operation>;
}
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<Operation>;
}
return projectAuthFailure(
requestId,
watchdogRecovery
) as StalkerSessionRequestOutcome<Operation>;
}
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
);