mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(stalker): anchor auth-failure body detection and share it across transports (#1358)
* 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 `<html><body>Access denied</body></html>` (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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c741815b97
commit
d3cc18dc72
8 files changed
+347
-126
No files matched your search
@@ -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.
|
||||
@@ -71,7 +71,17 @@ Two portal modes exist, persisted per playlist as
|
||||
except `handshake`, `get_profile`, `get_localization`, and `do_auth`
|
||||
requires `Authorization: Bearer <token>`; 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
|
||||
|
||||
@@ -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<string, unknown>))
|
||||
) {
|
||||
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';
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
'<html><body>Access denied</body></html>'
|
||||
)
|
||||
).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(
|
||||
`<html>${'x'.repeat(300)} access denied</html>`
|
||||
)
|
||||
).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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<html><body>Access denied</body></html>` 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<string, unknown>))
|
||||
) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user