diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts index 527f91c49..799e94191 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts @@ -294,6 +294,55 @@ describe('StalkerEndpointResolver', () => { } ); + it('skips a learned endpoint whose valid API JSON has the wrong content type', async () => { + const handshakeUrls: string[] = []; + const harness = createHarness((request) => { + const action = request.params?.['action']; + if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) { + return success({}, { finalUrl: request.url }); + } + if (action === 'handshake') { + handshakeUrls.push(request.url); + } + if (request.url.includes('/learned/')) { + return rawSuccess( + '{"js":{"token":"stale-learned-token"}}', + { + contentType: 'text/html', + finalUrl: request.url, + } + ); + } + if (action === 'handshake') { + return success( + { js: { token: 'rediscovered-token' } }, + { finalUrl: request.url } + ); + } + return success( + { js: { status: 0 } }, + { finalUrl: request.url } + ); + }); + + const outcome = await harness.resolver.resolve({ + descriptor: descriptor({ + learnedEndpointHint: + 'https://portal.test/learned/server/load.php', + }), + transport, + }); + + expect(outcome).toMatchObject({ + endpoint: 'https://portal.test/server/load.php', + kind: 'full-session', + }); + expect(handshakeUrls).toEqual([ + 'https://portal.test/learned/server/load.php', + 'https://portal.test/server/load.php', + ]); + }); + it('rejects a stateless catalog near miss without advancing candidates', async () => { const harness = createHarness((request) => { const action = request.params?.['action']; diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts index 065d12086..8d1b91fb1 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts @@ -180,14 +180,21 @@ export class StalkerEndpointResolver { } const identity = buildIdentity(input.descriptor, landingUrl); + const learnedEndpointHint = + input.descriptor.learnedEndpointHint === undefined + ? undefined + : normalizeStalkerSourceUrl( + input.descriptor.learnedEndpointHint + ); const candidates = buildCandidates( landingUrl, - input.descriptor.learnedEndpointHint, + learnedEndpointHint, approvedOrigins ); let statelessFallback: string | undefined; for (const endpoint of candidates) { + const isLearnedEndpoint = endpoint === learnedEndpointHint; const candidateJar = await anonymousJar.cloneWithManagedCookies( identity.managedCookies ); @@ -200,6 +207,7 @@ export class StalkerEndpointResolver { candidateSession, endpoint, identity, + isLearnedEndpoint, landingUrl, transport: input.transport, }); @@ -215,7 +223,8 @@ export class StalkerEndpointResolver { const simple = await this.probeStateless( candidateSession, endpoint, - identity + identity, + isLearnedEndpoint ); if ( simple.kind === 'failure' || @@ -243,6 +252,7 @@ export class StalkerEndpointResolver { candidateSession: StalkerHttpSessionLike; endpoint: string; identity: StalkerIdentityProfile; + isLearnedEndpoint: boolean; landingUrl: string; transport: StalkerTransportConfig; }): Promise { @@ -256,7 +266,10 @@ export class StalkerEndpointResolver { }, url: input.endpoint, }); - const normalizedHandshake = normalizeProtocolOutcome(handshake); + const normalizedHandshake = normalizeProtocolOutcome( + handshake, + input.isLearnedEndpoint + ); if (normalizedHandshake.kind !== 'parsed') { return normalizedHandshake; } @@ -291,7 +304,10 @@ export class StalkerEndpointResolver { }, url: input.endpoint, }); - const normalizedProfile = normalizeProtocolOutcome(profile); + const normalizedProfile = normalizeProtocolOutcome( + profile, + input.isLearnedEndpoint + ); if (normalizedProfile.kind !== 'parsed') { return normalizedProfile; } @@ -319,7 +335,8 @@ export class StalkerEndpointResolver { private async probeStateless( session: StalkerHttpSessionLike, endpoint: string, - identity: StalkerIdentityProfile + identity: StalkerIdentityProfile, + isLearnedEndpoint: boolean ): Promise< | StalkerEndpointStatelessOutcome | StalkerEndpointOriginApprovalOutcome @@ -336,7 +353,10 @@ export class StalkerEndpointResolver { }, url: endpoint, }); - const normalized = normalizeProtocolOutcome(outcome); + const normalized = normalizeProtocolOutcome( + outcome, + isLearnedEndpoint + ); if (normalized.kind !== 'parsed') { return normalized.kind === 'unsupported' ? { kind: 'miss' } @@ -427,7 +447,8 @@ function normalizeApprovedOrigins( } function normalizeProtocolOutcome( - outcome: StalkerHttpRequestOutcome + outcome: StalkerHttpRequestOutcome, + allowLearnedEndpointMediaTypeMiss = false ): | { kind: 'parsed'; value: unknown } | CandidateProbeUnsupported @@ -470,6 +491,15 @@ function normalizeProtocolOutcome( return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false); } if (parsed.kind === 'unparsed') { + if ( + allowLearnedEndpointMediaTypeMiss && + isApiEnvelopeRejectedOnlyByMediaType( + outcome.result, + parsed.rawBody + ) + ) { + return { kind: 'miss' }; + } if (isBenignHtmlEndpointMiss(outcome.result, parsed.rawBody)) { return { kind: 'miss' }; } @@ -544,6 +574,22 @@ function isBenignHtmlEndpointMiss( ); } +function isApiEnvelopeRejectedOnlyByMediaType( + result: StalkerTransportResult, + rawBody: string | undefined +): boolean { + if (rawBody === undefined) { + return false; + } + return ( + parseStalkerResponseEnvelope({ + body: rawBody, + contentType: undefined, + maxBodyBytes: result.body.byteLength, + }).kind === 'parsed' + ); +} + function boundedNonEmptyString(value: unknown): string | undefined { if ( typeof value !== 'string' ||