From d3cc18dc720c49956e9a37652dc70a670e053c41 Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Mon, 3 Aug 2026 08:03:08 +0200
Subject: [PATCH] fix(stalker): anchor auth-failure body detection and share it
across transports (#1358)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(stalker): anchor auth-failure body detection and share it across transports
The middleware's auth failures are bare plain-text bodies, but they were
matched by substring under a 200-character cap. A short page from something
in FRONT of the portal — a proxy or WAF answering
`
Access denied` (38 characters) — therefore read as
the portal refusing authorization, which drives probe classification and the
lazy repair trigger: a portal that never answered at all could be re-probed
and reclassified. The body match is now anchored to the whole reply, with the
stock server's optional trailing counter still accepted. The structured
`js.error`/`js.msg` fields keep the wider phrase set, since a panel fills
those in deliberately.
The detection also moves to `@iptvnator/shared/interfaces`. It had to live
somewhere both transports can reach: the Electron main process is where these
bodies actually arrive and cannot import a renderer library, which is the
same reason the identity and URL builders were centralised there.
`stalker-portal-discovery.utils.ts` re-exports it, so no call site changes.
Co-Authored-By: Claude Fable 5
* fix(stalker): drop the unanchored auth-failure sweep in the session service
Anchoring the body detector closed one door and left another open. The session
service still stringified the whole response and matched an UNANCHORED
`authorization failed`, so a short page from something in FRONT of the portal
retired the token, retried, and threw `Authorization failed after retry` —
whose own message then matched the repair trigger's wide phrase set and
re-probed a portal that had refused nothing.
The shared detector already covers every real shape, including the
`js.error`/`js.msg` envelopes the sweep was also catching, so removing it
costs no coverage. Regression goes through `makeAuthenticatedRequest` rather
than the primitive, since that is where the chain actually ran.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
---
.changes/stalker-auth-failure-detection.md | 8 +
docs/architecture/stalker-portal.md | 12 +-
.../src/lib/stalker-portal-discovery.utils.ts | 112 ++----------
.../src/lib/stalker-session.service.spec.ts | 22 +++
.../src/lib/stalker-session.service.ts | 35 ++--
libs/shared/interfaces/src/index.ts | 1 +
.../src/lib/stalker-auth-failure.util.spec.ts | 117 ++++++++++++
.../src/lib/stalker-auth-failure.util.ts | 166 ++++++++++++++++++
8 files changed, 347 insertions(+), 126 deletions(-)
create mode 100644 .changes/stalker-auth-failure-detection.md
create mode 100644 libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts
create mode 100644 libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts
diff --git a/.changes/stalker-auth-failure-detection.md b/.changes/stalker-auth-failure-detection.md
new file mode 100644
index 000000000..84097a3d7
--- /dev/null
+++ b/.changes/stalker-auth-failure-detection.md
@@ -0,0 +1,8 @@
+---
+type: fix
+area: stalker
+---
+
+A Stalker portal is no longer re-probed and reclassified because something
+in front of it — a proxy or firewall, not the portal — answered with a short
+"Access denied" page. Only the portal's own authorization replies count now.
diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md
index 0ddde12fc..4b5b9d0a9 100644
--- a/docs/architecture/stalker-portal.md
+++ b/docs/architecture/stalker-portal.md
@@ -71,7 +71,17 @@ Two portal modes exist, persisted per playlist as
except `handshake`, `get_profile`, `get_localization`, and `do_auth`
requires `Authorization: Bearer `; auth failures are HTTP 200 with a
plain-text body (`Authorization failed.`, `Access denied.`,
- `Unauthorized request.`), never a 401/403. While a full portal is the
+ `Unauthorized request.`), never a 401/403. Detection of those bodies — and
+ of the JSON envelope (`{js: {error|msg}}`) some panels answer instead —
+ lives in `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`, so
+ the Electron main process (where the bodies actually arrive) and the
+ renderer classify identically; `stalker-portal-discovery.utils.ts`
+ re-exports it. The body match is anchored to the whole reply: these are
+ bare phrases, and a substring rule also accepted a short proxy or WAF page
+ containing one, which then triggered portal reclassification against a
+ portal that never answered. The structured `js.error`/`js.msg` fields keep
+ the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`),
+ since a panel fills those in deliberately. While a full portal is the
active playlist, `StalkerSessionService` keeps a **watchdog** running —
periodic authenticated `watchdog/get_events` pings (currently every 25 s;
the protocol default expects 120 s, tracked for a later PR) whose failures
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts
index 40a5ff0f0..c7797dd4f 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts
@@ -9,6 +9,8 @@
* it responds, instead of guessing from the URL shape (#850, #686, #755).
*/
+import { isStalkerAuthFailureResponse } from '@iptvnator/shared/interfaces';
+
/**
* Candidate API endpoints for a pasted portal URL, in probe order.
*
@@ -99,108 +101,16 @@ export function buildStalkerEndpointCandidates(rawUrl: string): string[] {
}
/**
- * The stock Stalker middleware answers auth failures with HTTP 200 and a
- * bare plain-text body — never a 401/403. These are the three exact strings
- * it emits (sometimes with a trailing numeric counter).
+ * Auth-failure detection lives in `@iptvnator/shared/interfaces` so the
+ * Electron main process — where these bodies actually arrive — and the
+ * renderer classify identically. Re-exported here because discovery,
+ * repair and the session service already import it from this module.
*/
-const STALKER_AUTH_FAILURE_PATTERNS = [
- /authorization\s+failed/i,
- /access\s+denied/i,
- /unauthorized\s+request/i,
-];
-
-/**
- * Whether a portal response body is one of the middleware's plain-text auth
- * failures. The length cap keeps an arbitrary HTML error page that merely
- * mentions "access denied" from being mistaken for the middleware's bare
- * phrase.
- */
-export function isStalkerAuthFailureBody(response: unknown): boolean {
- if (typeof response !== 'string') {
- return false;
- }
-
- const body = response.trim();
- if (body.length === 0 || body.length > 200) {
- return false;
- }
-
- return STALKER_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(body));
-}
-
-/**
- * Whether a portal response is an authorization failure in EITHER wire
- * shape: the middleware's plain-text body, or the JSON envelope some panels
- * answer instead (`{ js: { error: "Authorization failed" } }` /
- * `{ js: { msg: … } }` — the same forms
- * `StalkerSessionService.isAuthorizationError()` recognizes). Classification
- * and the lazy-repair trigger must use this, not the string-only primitive:
- * a JSON-failing panel would otherwise be persisted as token-free and never
- * repaired.
- */
-export function isStalkerAuthFailureResponse(response: unknown): boolean {
- if (isStalkerAuthFailureBody(response)) {
- return true;
- }
-
- if (
- response === null ||
- typeof response !== 'object' ||
- !('js' in (response as Record))
- ) {
- return false;
- }
-
- const js = (response as { js?: unknown }).js;
- if (js === null || typeof js !== 'object') {
- return false;
- }
-
- const { error, msg } = js as { error?: unknown; msg?: unknown };
- return [error, msg].some(
- (value) =>
- typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value)
- );
-}
-
-/**
- * Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg`
- * fields. Deliberately wider than the plain-text body patterns (which stay
- * narrow to avoid matching arbitrary HTML pages): these are the same forms
- * `StalkerSessionService.isAuthorizationError()` recognizes — panels answer
- * "Invalid token", "Auth failed" or bare "unauthorized" here.
- */
-const STALKER_JSON_AUTH_FAILURE_PATTERNS = [
- ...STALKER_AUTH_FAILURE_PATTERNS,
- /auth\s+failed/i,
- /invalid\s+token/i,
- /\bunauthorized\b/i,
- /authorization/i,
-];
-
-/**
- * Whether an ERROR MESSAGE reports an authorization failure. Uses the wide
- * phrase set (including `Invalid token` / `Auth failed`) because the input
- * is a controlled string produced by our own auth layer — e.g.
- * `Error('Profile error: Invalid token')` — not an arbitrary portal body,
- * where the same breadth would false-positive on HTML pages.
- */
-export function isStalkerAuthFailureMessage(message: unknown): boolean {
- return (
- typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message)
- );
-}
-
-function isStalkerJsonAuthFailurePhrase(value: string): boolean {
- const phrase = value.trim();
- if (phrase.length === 0 || phrase.length > 200) {
- return false;
- }
-
- return STALKER_JSON_AUTH_FAILURE_PATTERNS.some((pattern) =>
- pattern.test(phrase)
- );
-}
+export {
+ isStalkerAuthFailureBody,
+ isStalkerAuthFailureMessage,
+ isStalkerAuthFailureResponse,
+} from '@iptvnator/shared/interfaces';
export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal';
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts
index ee2e776b7..bb4540b2a 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts
@@ -259,6 +259,28 @@ describe('StalkerSessionService identity-tagged token cache', () => {
}
);
+ it('does not treat a proxy page mentioning the phrase as an auth failure', async () => {
+ // End to end through makeAuthenticatedRequest, not just the
+ // primitive: this is the path that used to stringify the whole
+ // response and match an unanchored `authorization failed`. A short
+ // page from something in FRONT of the portal would retire the token,
+ // retry, and throw a message that itself matches the repair trigger —
+ // re-probing a portal that never refused anything.
+ service.setCachedToken('portal-1', 'LIVE', playlistA);
+ const body = 'The request Authorization failed. Try again later.';
+ sendIpcEvent.mockResolvedValue(body);
+
+ await expect(
+ service.makeAuthenticatedRequest(playlistA, {
+ action: 'get_genres',
+ })
+ ).resolves.toBe(body);
+
+ // No retry, no retirement, nothing for the repair layer to act on.
+ expect(sendIpcEvent).toHaveBeenCalledTimes(1);
+ expect(service.getCachedToken('portal-1')).toBe('LIVE');
+ });
+
it('retires a failed token even on the no-retry path (watchdog pings)', async () => {
service.setCachedToken('portal-1', 'DEAD', playlistA);
sendIpcEvent.mockResolvedValue('Authorization failed.');
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
index 910036dec..746e95a6c 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
@@ -801,30 +801,17 @@ export class StalkerSessionService {
return true;
}
- // Convert response to string for pattern matching
- const responseStr = JSON.stringify(responseOrError).toLowerCase();
-
- // Check for "Authorization failed. XX" pattern (like "Authorization failed. 75")
- if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) {
- return true;
- }
-
- // Check for common auth failure indicators
- const jsData = response?.['js'] as Record;
- const errorMessage =
- (response?.['message'] as string)?.toLowerCase?.() ||
- jsData?.['error']?.toString?.().toLowerCase?.() ||
- jsData?.['msg']?.toString?.().toLowerCase?.() ||
- '';
-
- return (
- errorMessage.includes('authorization') ||
- errorMessage.includes('unauthorized') ||
- errorMessage.includes('auth failed') ||
- errorMessage.includes('invalid token') ||
- response?.['status'] === 401 ||
- jsData?.['error'] === 'Authorization failed'
- );
+ // Everything above is structural. What used to follow was a
+ // `JSON.stringify(responseOrError)` sweep with an UNANCHORED
+ // `authorization failed` pattern — the same false positive the body
+ // detector was just anchored against, reachable through a different
+ // door: a short proxy/WAF page containing the phrase retired the
+ // token, retried, and threw `Authorization failed after retry`,
+ // whose own message then matched the repair trigger and re-probed a
+ // portal that never refused anything. The shared detector already
+ // covers every real shape, including the `js.error`/`js.msg`
+ // envelopes, so the sweep only added the false positive.
+ return response?.['status'] === 401 || response?.['status'] === 403;
}
/**
diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts
index 3417f954b..411041f00 100644
--- a/libs/shared/interfaces/src/index.ts
+++ b/libs/shared/interfaces/src/index.ts
@@ -38,6 +38,7 @@ export * from './lib/portal-playback.interface';
export * from './lib/random-id.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
+export * from './lib/stalker-auth-failure.util';
export * from './lib/stalker-cmd-encoding.util';
export * from './lib/stalker-portal-actions.enum';
export * from './lib/stalker-portal-mode.util';
diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts
new file mode 100644
index 000000000..4332d46e7
--- /dev/null
+++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts
@@ -0,0 +1,117 @@
+import {
+ classifyStalkerAuthFailureBody,
+ isStalkerAuthFailureBody,
+ isStalkerAuthFailureMessage,
+ isStalkerAuthFailureResponse,
+} from './stalker-auth-failure.util';
+
+describe('classifyStalkerAuthFailureBody', () => {
+ it.each([
+ ['Authorization failed.', 'Authorization failed.'],
+ // The stock server appends a numeric debug counter to this body only.
+ ['Authorization failed. 75', 'Authorization failed.'],
+ ['authorization failed', 'Authorization failed.'],
+ ['Access denied.', 'Access denied.'],
+ ['access denied', 'Access denied.'],
+ ['Unauthorized request.', 'Unauthorized request.'],
+ [' Unauthorized request.\n', 'Unauthorized request.'],
+ ])('classifies %j as %j', (body, expected) => {
+ expect(classifyStalkerAuthFailureBody(body)).toBe(expected);
+ });
+
+ it('rejects a short proxy/WAF page that merely contains the phrase', () => {
+ // 38 characters — well under any length cap, so only anchoring keeps
+ // it out. Read as the middleware speaking it would trigger portal
+ // reclassification against a portal that never answered at all.
+ expect(
+ classifyStalkerAuthFailureBody(
+ 'Access denied'
+ )
+ ).toBeNull();
+ expect(
+ classifyStalkerAuthFailureBody('Error: access denied by policy')
+ ).toBeNull();
+ expect(
+ classifyStalkerAuthFailureBody(
+ 'The request Authorization failed. Try again later.'
+ )
+ ).toBeNull();
+ });
+
+ it('rejects long pages, non-strings and empty bodies', () => {
+ expect(
+ classifyStalkerAuthFailureBody(
+ `${'x'.repeat(300)} access denied`
+ )
+ ).toBeNull();
+ expect(classifyStalkerAuthFailureBody({ js: [] })).toBeNull();
+ expect(classifyStalkerAuthFailureBody(undefined)).toBeNull();
+ expect(classifyStalkerAuthFailureBody(null)).toBeNull();
+ expect(classifyStalkerAuthFailureBody('')).toBeNull();
+ expect(classifyStalkerAuthFailureBody('OK')).toBeNull();
+ });
+
+ it('exposes the same verdict through the boolean primitive', () => {
+ expect(isStalkerAuthFailureBody('Access denied.')).toBe(true);
+ expect(isStalkerAuthFailureBody('Access denied by policy')).toBe(false);
+ });
+});
+
+describe('isStalkerAuthFailureResponse', () => {
+ it('recognizes the plain-text body and the JSON envelope forms', () => {
+ expect(isStalkerAuthFailureResponse('Authorization failed.')).toBe(
+ true
+ );
+ expect(
+ isStalkerAuthFailureResponse({
+ js: { error: 'Authorization failed' },
+ })
+ ).toBe(true);
+ expect(
+ isStalkerAuthFailureResponse({ js: { msg: 'Access denied.' } })
+ ).toBe(true);
+ });
+
+ it('recognizes the wider structured-field phrases', () => {
+ expect(
+ isStalkerAuthFailureResponse({ js: { error: 'Invalid token' } })
+ ).toBe(true);
+ expect(
+ isStalkerAuthFailureResponse({ js: { msg: 'Auth failed' } })
+ ).toBe(true);
+ expect(
+ isStalkerAuthFailureResponse({ js: { error: 'unauthorized' } })
+ ).toBe(true);
+ });
+
+ it('recognizes a phrase placed directly in js', () => {
+ expect(
+ isStalkerAuthFailureResponse({ js: 'Authorization failed. 75' })
+ ).toBe(true);
+ });
+
+ it('leaves ordinary responses alone', () => {
+ expect(isStalkerAuthFailureResponse({ js: { data: [] } })).toBe(false);
+ expect(isStalkerAuthFailureResponse({ js: [] })).toBe(false);
+ expect(isStalkerAuthFailureResponse({ js: false })).toBe(false);
+ expect(isStalkerAuthFailureResponse(undefined)).toBe(false);
+ });
+});
+
+describe('isStalkerAuthFailureMessage', () => {
+ it('accepts messages our own auth layer produces', () => {
+ expect(
+ isStalkerAuthFailureMessage('Profile error: Invalid token')
+ ).toBe(true);
+ expect(isStalkerAuthFailureMessage('Authorization failed. 75')).toBe(
+ true
+ );
+ });
+
+ it('rejects unrelated messages', () => {
+ expect(isStalkerAuthFailureMessage('HTTP Error 404: Not Found')).toBe(
+ false
+ );
+ expect(isStalkerAuthFailureMessage(undefined)).toBe(false);
+ });
+});
diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts
new file mode 100644
index 000000000..eb2d44edc
--- /dev/null
+++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts
@@ -0,0 +1,166 @@
+/**
+ * Stalker/Ministra authorization-failure detection, shared by both
+ * transports.
+ *
+ * The stock middleware answers an unauthenticated or unauthorized request
+ * with **HTTP 200 and a bare plain-text body** — never a 401/403 — because it
+ * exits before the JSON envelope is built. The three exact bodies
+ * (Stalker 4.9.35), optionally followed by a numeric debug counter:
+ *
+ * - `Authorization failed.` — token missing, stale, or replaced by another
+ * device's session
+ * - `Access denied.` — the account is blocked or disabled
+ * - `Unauthorized request.` — the `mac` cookie is missing entirely
+ *
+ * Some reseller panels answer a JSON envelope (`{js: {error: …}}` /
+ * `{js: {msg: …}}`) instead, with a wider vocabulary.
+ *
+ * This lives in `shared/interfaces` rather than in the Stalker renderer lib
+ * because the Electron main process is where these bodies actually arrive,
+ * and it cannot import renderer libraries — the same reason the identity and
+ * URL builders were centralised here. Two copies would drift.
+ */
+
+/** The exact plain-text bodies the stock middleware emits. */
+export const STALKER_AUTH_FAILURE_BODIES = [
+ 'Authorization failed.',
+ 'Access denied.',
+ 'Unauthorized request.',
+] as const;
+
+export type StalkerAuthFailureBody =
+ (typeof STALKER_AUTH_FAILURE_BODIES)[number];
+
+/**
+ * Body patterns, anchored to the WHOLE trimmed body.
+ *
+ * Anchoring matters: these bodies are bare phrases, so a substring match
+ * would also accept any short page that merely contains one — a reverse
+ * proxy or WAF answering `Access denied` is well
+ * under any sane length cap and would otherwise be read as the middleware
+ * speaking, triggering portal reclassification against a portal that never
+ * answered at all. Only the `Authorization failed.` body carries the stock
+ * server's optional trailing counter.
+ */
+const AUTH_FAILURE_PATTERNS: ReadonlyArray<{
+ body: StalkerAuthFailureBody;
+ pattern: RegExp;
+}> = [
+ {
+ body: 'Authorization failed.',
+ pattern: /^authorization\s+failed[.!]*(?:\s+\d+)?$/i,
+ },
+ { body: 'Access denied.', pattern: /^access\s+denied[.!]*$/i },
+ { body: 'Unauthorized request.', pattern: /^unauthorized\s+request[.!]*$/i },
+];
+
+/** Bodies longer than this are never the middleware's bare phrase. */
+const MAX_BODY_LENGTH = 64;
+
+/**
+ * Classifies a raw portal response body, returning the canonical failure
+ * body when the value is one of the three plain-text auth failures.
+ */
+export function classifyStalkerAuthFailureBody(
+ value: unknown
+): StalkerAuthFailureBody | null {
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ const body = value.trim();
+ if (body.length === 0 || body.length > MAX_BODY_LENGTH) {
+ return null;
+ }
+
+ return (
+ AUTH_FAILURE_PATTERNS.find(({ pattern }) => pattern.test(body))?.body ??
+ null
+ );
+}
+
+/**
+ * Whether a portal response body is one of the middleware's plain-text auth
+ * failures.
+ */
+export function isStalkerAuthFailureBody(value: unknown): boolean {
+ return classifyStalkerAuthFailureBody(value) !== null;
+}
+
+/**
+ * Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg`
+ * fields, and in error messages our own auth layer produces. Deliberately
+ * wider than the body patterns above — the input is a field a panel filled
+ * in deliberately, not an arbitrary document, so `Invalid token`,
+ * `Auth failed` and a bare `unauthorized` are all meaningful there.
+ */
+const JSON_AUTH_FAILURE_PATTERNS = [
+ /authorization\s+failed/i,
+ /access\s+denied/i,
+ /unauthorized\s+request/i,
+ /auth\s+failed/i,
+ /invalid\s+token/i,
+ /\bunauthorized\b/i,
+ /authorization/i,
+];
+
+const MAX_PHRASE_LENGTH = 200;
+
+function isStalkerJsonAuthFailurePhrase(value: string): boolean {
+ const phrase = value.trim();
+ if (phrase.length === 0 || phrase.length > MAX_PHRASE_LENGTH) {
+ return false;
+ }
+
+ return JSON_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(phrase));
+}
+
+/**
+ * Whether an ERROR MESSAGE reports an authorization failure. Uses the wide
+ * phrase set because the input is a controlled string produced by our own
+ * auth layer — e.g. `Error('Profile error: Invalid token')` — not an
+ * arbitrary portal body, where the same breadth would false-positive.
+ */
+export function isStalkerAuthFailureMessage(message: unknown): boolean {
+ return (
+ typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message)
+ );
+}
+
+/**
+ * Whether a portal response is an authorization failure in EITHER wire
+ * shape: the middleware's plain-text body, or the JSON envelope some panels
+ * answer instead. Classification and the lazy-repair trigger must use this,
+ * not the string-only primitive: a JSON-failing panel would otherwise be
+ * persisted as token-free and never repaired.
+ */
+export function isStalkerAuthFailureResponse(response: unknown): boolean {
+ if (isStalkerAuthFailureBody(response)) {
+ return true;
+ }
+
+ if (
+ response === null ||
+ typeof response !== 'object' ||
+ !('js' in (response as Record))
+ ) {
+ return false;
+ }
+
+ const js = (response as { js?: unknown }).js;
+
+ // Some panels put the phrase directly in `js` instead of an object.
+ if (typeof js === 'string') {
+ return isStalkerJsonAuthFailurePhrase(js);
+ }
+
+ if (js === null || typeof js !== 'object') {
+ return false;
+ }
+
+ const { error, msg } = js as { error?: unknown; msg?: unknown };
+ return [error, msg].some(
+ (value) =>
+ typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value)
+ );
+}