mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat(stalker): add pure session protocol contracts
This commit is contained in:
1 parent
f928e06afb
commit
29fa4d0f87
29 files changed
+2470
-3
No files matched your search
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'portal-stalker-protocol',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/stalker/protocol',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "portal-stalker-protocol",
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/stalker/protocol/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:stalker", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/stalker/protocol/jest.config.ts",
|
||||
"tsConfig": "libs/portal/stalker/protocol/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './lib/stalker-auth-state-machine';
|
||||
export * from './lib/stalker-identity-profile';
|
||||
export * from './lib/stalker-identity-revision';
|
||||
export * from './lib/stalker-protocol.constants';
|
||||
export * from './lib/stalker-protocol.types';
|
||||
export * from './lib/stalker-request-policy';
|
||||
export * from './lib/stalker-response-classifier';
|
||||
export * from './lib/stalker-url-recipes';
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
StalkerAuthTransitionError,
|
||||
createStalkerAuthState,
|
||||
reduceStalkerAuthState,
|
||||
} from './stalker-auth-state-machine';
|
||||
|
||||
function reachFirstProfile() {
|
||||
const resolving = reduceStalkerAuthState(createStalkerAuthState(), {
|
||||
type: 'start',
|
||||
});
|
||||
const handshaking = reduceStalkerAuthState(resolving, {
|
||||
type: 'endpoint-resolved',
|
||||
});
|
||||
return reduceStalkerAuthState(handshaking, {
|
||||
type: 'handshake-succeeded',
|
||||
});
|
||||
}
|
||||
|
||||
describe('Stalker authentication state machine', () => {
|
||||
it('always enters the first profile with auth_second_step zero', () => {
|
||||
expect(reachFirstProfile()).toEqual({
|
||||
authSecondStep: 0,
|
||||
credentialAttempts: 0,
|
||||
stage: 'profile-first',
|
||||
});
|
||||
});
|
||||
|
||||
it('permits do_auth only after profile status two', () => {
|
||||
expect(() =>
|
||||
reduceStalkerAuthState(reachFirstProfile(), {
|
||||
type: 'credentials-submitted',
|
||||
})
|
||||
).toThrow(StalkerAuthTransitionError);
|
||||
|
||||
const awaitingCredentials = reduceStalkerAuthState(
|
||||
reachFirstProfile(),
|
||||
{
|
||||
result: 'credentials-required',
|
||||
type: 'profile-classified',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
reduceStalkerAuthState(awaitingCredentials, {
|
||||
type: 'credentials-submitted',
|
||||
})
|
||||
).toMatchObject({ credentialAttempts: 1, stage: 'do-auth' });
|
||||
});
|
||||
|
||||
it('permits the second profile only after canonical do_auth success', () => {
|
||||
const awaitingCredentials = reduceStalkerAuthState(
|
||||
reachFirstProfile(),
|
||||
{
|
||||
result: 'credentials-required',
|
||||
type: 'profile-classified',
|
||||
}
|
||||
);
|
||||
const doingAuth = reduceStalkerAuthState(awaitingCredentials, {
|
||||
type: 'credentials-submitted',
|
||||
});
|
||||
const secondProfile = reduceStalkerAuthState(doingAuth, {
|
||||
result: 'success',
|
||||
type: 'do-auth-classified',
|
||||
});
|
||||
|
||||
expect(secondProfile).toEqual({
|
||||
authSecondStep: 1,
|
||||
credentialAttempts: 1,
|
||||
stage: 'profile-second',
|
||||
});
|
||||
expect(secondProfile.stage).not.toBe('ready');
|
||||
expect(
|
||||
reduceStalkerAuthState(secondProfile, {
|
||||
result: 'ready',
|
||||
type: 'profile-classified',
|
||||
})
|
||||
).toMatchObject({ stage: 'ready' });
|
||||
});
|
||||
|
||||
it('rejects the third explicit credential failure with the attempt limit', () => {
|
||||
let state = reduceStalkerAuthState(reachFirstProfile(), {
|
||||
result: 'credentials-required',
|
||||
type: 'profile-classified',
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
state = reduceStalkerAuthState(state, {
|
||||
type: 'credentials-submitted',
|
||||
});
|
||||
state = reduceStalkerAuthState(state, {
|
||||
result: 'credentials-rejected',
|
||||
type: 'do-auth-classified',
|
||||
});
|
||||
}
|
||||
|
||||
expect(state).toEqual({
|
||||
credentialAttempts: 3,
|
||||
reason: 'credentials-attempt-limit',
|
||||
stage: 'rejected',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'network-unreachable',
|
||||
'portal-protection-blocked',
|
||||
'rate-limited',
|
||||
] as const)('preserves a %s failure', (reason) => {
|
||||
expect(
|
||||
reduceStalkerAuthState(reachFirstProfile(), {
|
||||
reason,
|
||||
type: 'failed',
|
||||
})
|
||||
).toEqual({
|
||||
credentialAttempts: 0,
|
||||
reason,
|
||||
stage: 'failure',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
STALKER_CONNECTION_STAGES,
|
||||
STALKER_FAILURE_REASONS,
|
||||
} from './stalker-protocol.constants';
|
||||
import type {
|
||||
StalkerConnectionStage,
|
||||
StalkerFailureReason,
|
||||
} from './stalker-protocol.types';
|
||||
|
||||
export interface StalkerAuthState {
|
||||
stage: StalkerConnectionStage;
|
||||
credentialAttempts: number;
|
||||
authSecondStep?: 0 | 1;
|
||||
reason?: StalkerFailureReason;
|
||||
}
|
||||
|
||||
export type StalkerAuthTransitionEvent =
|
||||
| { type: 'start' }
|
||||
| { type: 'origin-approval-required' }
|
||||
| { type: 'origin-approved' }
|
||||
| { type: 'endpoint-resolved' }
|
||||
| { type: 'handshake-succeeded' }
|
||||
| {
|
||||
type: 'profile-classified';
|
||||
result: 'ready' | 'blocked' | 'credentials-required';
|
||||
}
|
||||
| { type: 'credentials-submitted' }
|
||||
| {
|
||||
type: 'do-auth-classified';
|
||||
result: 'success' | 'credentials-rejected';
|
||||
}
|
||||
| { type: 'failed'; reason: StalkerFailureReason };
|
||||
|
||||
export class StalkerAuthTransitionError extends Error {
|
||||
constructor(stage: StalkerConnectionStage, event: string) {
|
||||
super(`Invalid Stalker auth transition: ${stage} -> ${event}`);
|
||||
this.name = 'StalkerAuthTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export function createStalkerAuthState(): StalkerAuthState {
|
||||
return {
|
||||
credentialAttempts: 0,
|
||||
stage: STALKER_CONNECTION_STAGES.New,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceStalkerAuthState(
|
||||
state: StalkerAuthState,
|
||||
event: StalkerAuthTransitionEvent
|
||||
): StalkerAuthState {
|
||||
if (event.type === 'failed') {
|
||||
return {
|
||||
credentialAttempts: state.credentialAttempts,
|
||||
reason: event.reason,
|
||||
stage: STALKER_CONNECTION_STAGES.Failure,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'start' && state.stage === STALKER_CONNECTION_STAGES.New) {
|
||||
return withStage(state, STALKER_CONNECTION_STAGES.Resolving);
|
||||
}
|
||||
if (
|
||||
event.type === 'origin-approval-required' &&
|
||||
state.stage === STALKER_CONNECTION_STAGES.Resolving
|
||||
) {
|
||||
return withStage(
|
||||
state,
|
||||
STALKER_CONNECTION_STAGES.AwaitingOriginApproval
|
||||
);
|
||||
}
|
||||
if (
|
||||
event.type === 'origin-approved' &&
|
||||
state.stage === STALKER_CONNECTION_STAGES.AwaitingOriginApproval
|
||||
) {
|
||||
return withStage(state, STALKER_CONNECTION_STAGES.Handshaking);
|
||||
}
|
||||
if (
|
||||
event.type === 'endpoint-resolved' &&
|
||||
state.stage === STALKER_CONNECTION_STAGES.Resolving
|
||||
) {
|
||||
return withStage(state, STALKER_CONNECTION_STAGES.Handshaking);
|
||||
}
|
||||
if (
|
||||
event.type === 'handshake-succeeded' &&
|
||||
state.stage === STALKER_CONNECTION_STAGES.Handshaking
|
||||
) {
|
||||
return {
|
||||
authSecondStep: 0,
|
||||
credentialAttempts: state.credentialAttempts,
|
||||
stage: STALKER_CONNECTION_STAGES.ProfileFirst,
|
||||
};
|
||||
}
|
||||
if (event.type === 'profile-classified') {
|
||||
return reduceProfileResult(state, event.result);
|
||||
}
|
||||
if (
|
||||
event.type === 'credentials-submitted' &&
|
||||
state.stage === STALKER_CONNECTION_STAGES.AwaitingCredentials
|
||||
) {
|
||||
return {
|
||||
credentialAttempts: state.credentialAttempts + 1,
|
||||
stage: STALKER_CONNECTION_STAGES.DoAuth,
|
||||
};
|
||||
}
|
||||
if (
|
||||
event.type === 'do-auth-classified' &&
|
||||
state.stage === STALKER_CONNECTION_STAGES.DoAuth
|
||||
) {
|
||||
if (event.result === 'success') {
|
||||
return {
|
||||
authSecondStep: 1,
|
||||
credentialAttempts: state.credentialAttempts,
|
||||
stage: STALKER_CONNECTION_STAGES.ProfileSecond,
|
||||
};
|
||||
}
|
||||
return credentialRejection(state);
|
||||
}
|
||||
|
||||
throw new StalkerAuthTransitionError(state.stage, event.type);
|
||||
}
|
||||
|
||||
function reduceProfileResult(
|
||||
state: StalkerAuthState,
|
||||
result: 'ready' | 'blocked' | 'credentials-required'
|
||||
): StalkerAuthState {
|
||||
if (
|
||||
state.stage !== STALKER_CONNECTION_STAGES.ProfileFirst &&
|
||||
state.stage !== STALKER_CONNECTION_STAGES.ProfileSecond
|
||||
) {
|
||||
throw new StalkerAuthTransitionError(state.stage, 'profile-classified');
|
||||
}
|
||||
if (result === 'ready') {
|
||||
return withStage(state, STALKER_CONNECTION_STAGES.Ready);
|
||||
}
|
||||
if (result === 'blocked') {
|
||||
return {
|
||||
credentialAttempts: state.credentialAttempts,
|
||||
reason: STALKER_FAILURE_REASONS.AccountOrDeviceBlocked,
|
||||
stage: STALKER_CONNECTION_STAGES.Rejected,
|
||||
};
|
||||
}
|
||||
return state.credentialAttempts >= 3
|
||||
? credentialRejection(state)
|
||||
: withStage(state, STALKER_CONNECTION_STAGES.AwaitingCredentials);
|
||||
}
|
||||
|
||||
function credentialRejection(state: StalkerAuthState): StalkerAuthState {
|
||||
if (state.credentialAttempts >= 3) {
|
||||
return {
|
||||
credentialAttempts: state.credentialAttempts,
|
||||
reason: STALKER_FAILURE_REASONS.CredentialsAttemptLimit,
|
||||
stage: STALKER_CONNECTION_STAGES.Rejected,
|
||||
};
|
||||
}
|
||||
return withStage(state, STALKER_CONNECTION_STAGES.AwaitingCredentials);
|
||||
}
|
||||
|
||||
function withStage(
|
||||
state: StalkerAuthState,
|
||||
stage: StalkerConnectionStage
|
||||
): StalkerAuthState {
|
||||
return {
|
||||
credentialAttempts: state.credentialAttempts,
|
||||
stage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
MAG250_MINIMAL_PROFILE_ID,
|
||||
createStalkerIdentityProfile,
|
||||
} from './stalker-identity-profile';
|
||||
|
||||
describe('Stalker coherent identity profile', () => {
|
||||
it('builds the versioned public MAG250 minimal preset', () => {
|
||||
const profile = createStalkerIdentityProfile({
|
||||
macAddress: '00-1a-79-00-00-01',
|
||||
});
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
preset: { id: MAG250_MINIMAL_PROFILE_ID, version: 1 },
|
||||
headers: {
|
||||
'Accept-Language': 'en-US',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
|
||||
'X-User-Agent': 'Model: MAG250',
|
||||
},
|
||||
managedCookies: {
|
||||
mac: '00:1A:79:00:00:01',
|
||||
stb_lang: 'en',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits blank native identity fields instead of inventing values', () => {
|
||||
const profile = createStalkerIdentityProfile({
|
||||
deviceId1: '',
|
||||
deviceId2: ' ',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
serialNumber: '',
|
||||
signature1: ' ',
|
||||
signature2: '',
|
||||
});
|
||||
|
||||
expect(profile.profileParameters).not.toEqual(
|
||||
expect.objectContaining({
|
||||
device_id: expect.anything(),
|
||||
device_id2: expect.anything(),
|
||||
serial: expect.anything(),
|
||||
signature: expect.anything(),
|
||||
signature2: expect.anything(),
|
||||
})
|
||||
);
|
||||
expect(profile.metrics).toEqual({
|
||||
mac: '00:1A:79:00:00:01',
|
||||
model: 'MAG250',
|
||||
type: 'STB',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards explicit identity and transport overrides unchanged', () => {
|
||||
const profile = createStalkerIdentityProfile({
|
||||
deviceId1: 'Device-One',
|
||||
deviceId2: 'Device-Two',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
origin: 'https://origin.test',
|
||||
referer: 'https://landing.test/c/',
|
||||
serialNumber: 'Serial-AbC',
|
||||
signature1: 'Sig-One',
|
||||
signature2: 'Sig-Two',
|
||||
userAgent: 'Custom UA/1.0',
|
||||
xUserAgent: 'Model: Custom',
|
||||
});
|
||||
|
||||
expect(profile.headers).toMatchObject({
|
||||
Origin: 'https://origin.test',
|
||||
Referer: 'https://landing.test/c/',
|
||||
'User-Agent': 'Custom UA/1.0',
|
||||
'X-User-Agent': 'Model: Custom',
|
||||
});
|
||||
expect(profile.profileParameters).toMatchObject({
|
||||
device_id: 'Device-One',
|
||||
device_id2: 'Device-Two',
|
||||
serial: 'Serial-AbC',
|
||||
signature: 'Sig-One',
|
||||
signature2: 'Sig-Two',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes one locale, language, and timezone tuple', () => {
|
||||
const profile = createStalkerIdentityProfile({
|
||||
language: 'DE',
|
||||
locale: 'de_DE',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
timezone: ' Europe/Berlin ',
|
||||
});
|
||||
|
||||
expect(profile.headers['Accept-Language']).toBe('de-DE');
|
||||
expect(profile.managedCookies).toMatchObject({
|
||||
stb_lang: 'de',
|
||||
timezone: 'Europe/Berlin',
|
||||
});
|
||||
expect(profile.profileParameters).toMatchObject({
|
||||
language: 'de',
|
||||
timezone: 'Europe/Berlin',
|
||||
});
|
||||
expect(profile.profileParameters).not.toHaveProperty('locale');
|
||||
});
|
||||
|
||||
it('adds only present random, serial, and device_id2 values to metrics', () => {
|
||||
const profile = createStalkerIdentityProfile({
|
||||
deviceId1: 'not-the-uid',
|
||||
deviceId2: 'actual-uid',
|
||||
handshakeRandom: 'run-random',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
serialNumber: 'serial-value',
|
||||
});
|
||||
|
||||
expect(profile.metrics).toEqual({
|
||||
mac: '00:1A:79:00:00:01',
|
||||
model: 'MAG250',
|
||||
random: 'run-random',
|
||||
serial: 'serial-value',
|
||||
type: 'STB',
|
||||
uid: 'actual-uid',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an explicitly traced firmware tuple without deriving it', () => {
|
||||
const profile = createStalkerIdentityProfile({
|
||||
apiSignature: 'api-signature',
|
||||
firmwareVersion: 'ImageDescription: traced',
|
||||
hardwareVersion: '1.7-BD-00',
|
||||
hardwareVersion2: 'native-hash',
|
||||
imageVersion: '218',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
numberOfBanks: '2',
|
||||
prehash: 'traced-prehash',
|
||||
videoOutput: 'hdmi',
|
||||
});
|
||||
|
||||
expect(profile.profileParameters).toMatchObject({
|
||||
api_signature: 'api-signature',
|
||||
hw_version: '1.7-BD-00',
|
||||
hw_version_2: 'native-hash',
|
||||
image_version: '218',
|
||||
num_banks: '2',
|
||||
prehash: 'traced-prehash',
|
||||
ver: 'ImageDescription: traced',
|
||||
video_out: 'hdmi',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'001A79000001',
|
||||
'00:1A:79:00:00',
|
||||
'GG:1A:79:00:00:01',
|
||||
])('rejects invalid MAC input %s', (macAddress) => {
|
||||
expect(() => createStalkerIdentityProfile({ macAddress })).toThrow(
|
||||
'invalid-identity-input'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { STALKER_FAILURE_REASONS } from './stalker-protocol.constants';
|
||||
import type {
|
||||
StalkerIdentityProfile,
|
||||
StalkerIdentityProfileInput,
|
||||
} from './stalker-protocol.types';
|
||||
|
||||
export const MAG250_MINIMAL_PROFILE_ID =
|
||||
'mag250-public-5_1-minimal-v1' as const;
|
||||
export const MAG250_MINIMAL_PROFILE_VERSION = 1 as const;
|
||||
export const MAG250_LEGACY_BROWSER_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
export const MAG250_X_USER_AGENT = 'Model: MAG250';
|
||||
|
||||
const MAC_PATTERN = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/;
|
||||
|
||||
export function createStalkerIdentityProfile(
|
||||
input: StalkerIdentityProfileInput
|
||||
): StalkerIdentityProfile {
|
||||
const macAddress = normalizeStalkerMacAddress(input.macAddress);
|
||||
const locale = normalizeLocale(input.locale);
|
||||
const language = normalizeLanguage(input.language, locale);
|
||||
const timezone = input.timezone?.trim() || 'UTC';
|
||||
const serialNumber = presentValue(input.serialNumber);
|
||||
const deviceId1 = presentValue(input.deviceId1);
|
||||
const deviceId2 = presentValue(input.deviceId2);
|
||||
const signature1 = presentValue(input.signature1);
|
||||
const signature2 = presentValue(input.signature2);
|
||||
const handshakeRandom = presentValue(input.handshakeRandom);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Language': locale,
|
||||
'User-Agent': input.userAgent ?? MAG250_LEGACY_BROWSER_USER_AGENT,
|
||||
'X-User-Agent': input.xUserAgent ?? MAG250_X_USER_AGENT,
|
||||
};
|
||||
if (presentValue(input.referer)) headers['Referer'] = input.referer as string;
|
||||
if (presentValue(input.origin)) headers['Origin'] = input.origin as string;
|
||||
|
||||
const profileParameters: Record<string, string | number> = {
|
||||
client_type: 'STB',
|
||||
hd: 1,
|
||||
language,
|
||||
stb_type: 'MAG250',
|
||||
timezone,
|
||||
};
|
||||
assignPresent(profileParameters, 'serial', serialNumber);
|
||||
assignPresent(profileParameters, 'device_id', deviceId1);
|
||||
assignPresent(profileParameters, 'device_id2', deviceId2);
|
||||
assignPresent(profileParameters, 'signature', signature1);
|
||||
assignPresent(profileParameters, 'signature2', signature2);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'prehash',
|
||||
presentValue(input.prehash)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'api_signature',
|
||||
presentValue(input.apiSignature)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'ver',
|
||||
presentValue(input.firmwareVersion)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'image_version',
|
||||
presentValue(input.imageVersion)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'hw_version',
|
||||
presentValue(input.hardwareVersion)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'hw_version_2',
|
||||
presentValue(input.hardwareVersion2)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'num_banks',
|
||||
presentValue(input.numberOfBanks)
|
||||
);
|
||||
assignPresent(
|
||||
profileParameters,
|
||||
'video_out',
|
||||
presentValue(input.videoOutput)
|
||||
);
|
||||
|
||||
const metrics: Record<string, string | number> = {
|
||||
mac: macAddress,
|
||||
model: 'MAG250',
|
||||
type: 'STB',
|
||||
};
|
||||
assignPresent(metrics, 'random', handshakeRandom);
|
||||
assignPresent(metrics, 'serial', serialNumber);
|
||||
assignPresent(metrics, 'uid', deviceId2);
|
||||
|
||||
return {
|
||||
headers,
|
||||
macAddress,
|
||||
managedCookies: {
|
||||
mac: macAddress,
|
||||
stb_lang: language,
|
||||
timezone,
|
||||
},
|
||||
metrics,
|
||||
preset: {
|
||||
id: MAG250_MINIMAL_PROFILE_ID,
|
||||
version: MAG250_MINIMAL_PROFILE_VERSION,
|
||||
},
|
||||
profileParameters,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStalkerMacAddress(macAddress: string): string {
|
||||
const normalized = macAddress.replace(/-/g, ':').toUpperCase();
|
||||
if (!MAC_PATTERN.test(normalized)) {
|
||||
throw new Error(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | undefined): string {
|
||||
const segments = (value?.trim() || 'en-US')
|
||||
.replace(/_/g, '-')
|
||||
.split('-')
|
||||
.filter(Boolean);
|
||||
if (segments.length === 0) return 'en-US';
|
||||
return segments
|
||||
.map((segment, index) => {
|
||||
if (index === 0) return segment.toLowerCase();
|
||||
if (index === 1 && segment.length === 2) return segment.toUpperCase();
|
||||
return segment;
|
||||
})
|
||||
.join('-');
|
||||
}
|
||||
|
||||
function normalizeLanguage(
|
||||
language: string | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
return (language?.trim() || locale.split('-')[0] || 'en').toLowerCase();
|
||||
}
|
||||
|
||||
function presentValue(value: string | undefined): string | undefined {
|
||||
return value !== undefined && value.trim() !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
function assignPresent(
|
||||
target: Record<string, string | number>,
|
||||
key: string,
|
||||
value: string | undefined
|
||||
): void {
|
||||
if (value !== undefined) target[key] = value;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
serializeStalkerIdentityRevision,
|
||||
serializeStalkerPrincipalInput,
|
||||
} from './stalker-identity-revision';
|
||||
|
||||
describe('Stalker identity revision serialization', () => {
|
||||
it('uses a length-prefixed UTF-8 principal without normalization', () => {
|
||||
expect([...serializeStalkerPrincipalInput(' é ')]).toEqual([
|
||||
0, 0, 0, 4, 0x20, 0xc3, 0xa9, 0x20,
|
||||
]);
|
||||
expect(serializeStalkerPrincipalInput('User')).not.toEqual(
|
||||
serializeStalkerPrincipalInput('user')
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes identity fields in a stable canonical order', () => {
|
||||
const first = serializeStalkerIdentityRevision({
|
||||
deviceId1: 'device|one',
|
||||
locale: 'de-DE',
|
||||
presetId: 'mag250-public-5_1-minimal-v1',
|
||||
presetVersion: 1,
|
||||
timezone: 'Europe/Berlin',
|
||||
userAgent: 'UA/1',
|
||||
});
|
||||
const second = serializeStalkerIdentityRevision({
|
||||
userAgent: 'UA/1',
|
||||
timezone: 'Europe/Berlin',
|
||||
presetVersion: 1,
|
||||
presetId: 'mag250-public-5_1-minimal-v1',
|
||||
locale: 'de-DE',
|
||||
deviceId1: 'device|one',
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toContain('stalker-identity-revision:v1');
|
||||
});
|
||||
|
||||
it('distinguishes absent, empty, and delimiter-containing values', () => {
|
||||
const base = {
|
||||
presetId: 'mag250-public-5_1-minimal-v1',
|
||||
presetVersion: 1,
|
||||
};
|
||||
|
||||
expect(serializeStalkerIdentityRevision(base)).not.toBe(
|
||||
serializeStalkerIdentityRevision({ ...base, serialNumber: '' })
|
||||
);
|
||||
expect(
|
||||
serializeStalkerIdentityRevision({
|
||||
...base,
|
||||
serialNumber: 'a|b',
|
||||
})
|
||||
).not.toBe(
|
||||
serializeStalkerIdentityRevision({
|
||||
...base,
|
||||
serialNumber: 'a',
|
||||
signature1: 'b',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface StalkerIdentityRevisionInput {
|
||||
presetId: string;
|
||||
presetVersion: number;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
prehash?: string;
|
||||
apiSignature?: string;
|
||||
firmwareVersion?: string;
|
||||
imageVersion?: string;
|
||||
hardwareVersion?: string;
|
||||
hardwareVersion2?: string;
|
||||
numberOfBanks?: string;
|
||||
videoOutput?: string;
|
||||
userAgent?: string;
|
||||
xUserAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
refererPolicy?: string;
|
||||
originPolicy?: string;
|
||||
locale?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
profileOverrides?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const IDENTITY_FIELD_ORDER: readonly (keyof Omit<
|
||||
StalkerIdentityRevisionInput,
|
||||
'profileOverrides'
|
||||
>)[] = [
|
||||
'presetId',
|
||||
'presetVersion',
|
||||
'serialNumber',
|
||||
'deviceId1',
|
||||
'deviceId2',
|
||||
'signature1',
|
||||
'signature2',
|
||||
'prehash',
|
||||
'apiSignature',
|
||||
'firmwareVersion',
|
||||
'imageVersion',
|
||||
'hardwareVersion',
|
||||
'hardwareVersion2',
|
||||
'numberOfBanks',
|
||||
'videoOutput',
|
||||
'userAgent',
|
||||
'xUserAgent',
|
||||
'referer',
|
||||
'origin',
|
||||
'refererPolicy',
|
||||
'originPolicy',
|
||||
'locale',
|
||||
'language',
|
||||
'timezone',
|
||||
];
|
||||
|
||||
export function serializeStalkerPrincipalInput(username: string): Uint8Array {
|
||||
const value = new TextEncoder().encode(username);
|
||||
const result = new Uint8Array(value.byteLength + 4);
|
||||
const view = new DataView(result.buffer);
|
||||
view.setUint32(0, value.byteLength, false);
|
||||
result.set(value, 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeStalkerIdentityRevision(
|
||||
input: StalkerIdentityRevisionInput
|
||||
): string {
|
||||
const fields = IDENTITY_FIELD_ORDER.map((field) =>
|
||||
serializeField(field, input[field])
|
||||
);
|
||||
const overrideFields = Object.entries(input.profileOverrides ?? {})
|
||||
.sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0
|
||||
)
|
||||
.map(([key, value]) => serializeField(`override.${key}`, value));
|
||||
|
||||
return ['stalker-identity-revision:v1', ...fields, ...overrideFields].join(
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
|
||||
function serializeField(
|
||||
field: string,
|
||||
value: string | number | undefined
|
||||
): string {
|
||||
if (value === undefined) {
|
||||
return `${field}=absent`;
|
||||
}
|
||||
const serialized = String(value);
|
||||
const length = new TextEncoder().encode(serialized).byteLength;
|
||||
return `${field}=present:${length}:${serialized}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export const STALKER_RECIPE_CLASSIFIER_VERSION = 1;
|
||||
export const STALKER_ENDPOINT_CANDIDATE_LIMIT = 6;
|
||||
|
||||
export const STALKER_REQUEST_RECIPES = {
|
||||
FullSession: 'full-session',
|
||||
StatelessMac: 'stateless-mac',
|
||||
} as const;
|
||||
|
||||
export const STALKER_CONNECTION_STAGES = {
|
||||
New: 'new',
|
||||
Resolving: 'resolving',
|
||||
AwaitingOriginApproval: 'awaiting-origin-approval',
|
||||
Handshaking: 'handshaking',
|
||||
ProfileFirst: 'profile-first',
|
||||
AwaitingCredentials: 'awaiting-credentials',
|
||||
DoAuth: 'do-auth',
|
||||
ProfileSecond: 'profile-second',
|
||||
Ready: 'ready',
|
||||
Refreshing: 'refreshing',
|
||||
Rejected: 'rejected',
|
||||
Failure: 'failure',
|
||||
Persisting: 'persisting',
|
||||
Promoting: 'promoting',
|
||||
} as const;
|
||||
|
||||
export const STALKER_FAILURE_REASONS = {
|
||||
InvalidUrl: 'invalid-url',
|
||||
InvalidIdentityInput: 'invalid-identity-input',
|
||||
RedirectLoop: 'redirect-loop',
|
||||
EndpointNotFound: 'endpoint-not-found',
|
||||
OriginNotApproved: 'origin-not-approved',
|
||||
DnsFailure: 'dns-failure',
|
||||
NetworkUnreachable: 'network-unreachable',
|
||||
RequestTimeout: 'request-timeout',
|
||||
TlsFailure: 'tls-failure',
|
||||
AccountOrDeviceBlocked: 'account-or-device-blocked',
|
||||
DeviceIdentityConflict: 'device-identity-conflict',
|
||||
CredentialsAttemptLimit: 'credentials-attempt-limit',
|
||||
PrincipalTransitionRequired: 'principal-transition-required',
|
||||
AuthRefreshExhausted: 'auth-refresh-exhausted',
|
||||
AccountAccessDenied: 'account-access-denied',
|
||||
PortalProtectionBlocked: 'portal-protection-blocked',
|
||||
RateLimited: 'rate-limited',
|
||||
PortalUnavailable: 'portal-unavailable',
|
||||
ResponseTooLarge: 'response-too-large',
|
||||
IncompatibleResponse: 'incompatible-response',
|
||||
LocalPersistenceFailed: 'local-persistence-failed',
|
||||
SessionPromotionFailed: 'session-promotion-failed',
|
||||
} as const;
|
||||
|
||||
export const STALKER_PROFILE_RESULT_KINDS = {
|
||||
Ready: 'ready',
|
||||
Blocked: 'blocked',
|
||||
CredentialsRequired: 'credentials-required',
|
||||
Failure: 'failure',
|
||||
} as const;
|
||||
|
||||
export const STALKER_APPLICATION_OPERATIONS = {
|
||||
CatalogCategories: 'get-categories',
|
||||
CatalogGenres: 'get-genres',
|
||||
CatalogItems: 'get-ordered-list',
|
||||
CatalogAllChannels: 'get-all-channels',
|
||||
CatalogSearch: 'search',
|
||||
SeriesSeasons: 'get-series-seasons',
|
||||
SeriesEpisodes: 'get-series-episodes',
|
||||
ShortEpg: 'get-short-epg',
|
||||
EpgInfo: 'get-epg-info',
|
||||
CreateLink: 'create-link',
|
||||
Favorites: 'favorites',
|
||||
MainInfo: 'get-main-info',
|
||||
} as const;
|
||||
|
||||
export const STALKER_RESERVED_REQUEST_PARAMETERS = [
|
||||
'action',
|
||||
'api_signature',
|
||||
'auth_second_step',
|
||||
'client_type',
|
||||
'device_id',
|
||||
'device_id2',
|
||||
'hd',
|
||||
'hw_version',
|
||||
'hw_version_2',
|
||||
'image_version',
|
||||
'jshttprequest',
|
||||
'language',
|
||||
'login',
|
||||
'mac',
|
||||
'metrics',
|
||||
'not_valid_token',
|
||||
'num_banks',
|
||||
'password',
|
||||
'prehash',
|
||||
'random',
|
||||
'serial',
|
||||
'signature',
|
||||
'signature2',
|
||||
'sn',
|
||||
'stb_lang',
|
||||
'stb_type',
|
||||
'timestamp',
|
||||
'timezone',
|
||||
'token',
|
||||
'ver',
|
||||
'video_out',
|
||||
] as const;
|
||||
|
||||
export const STALKER_MANAGED_REQUEST_HEADERS = [
|
||||
'accept-language',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'origin',
|
||||
'referer',
|
||||
'sn',
|
||||
'user-agent',
|
||||
'x-user-agent',
|
||||
] as const;
|
||||
|
||||
export const STALKER_JSONP_CALLBACKS = [
|
||||
'callback',
|
||||
'stalkerCallback',
|
||||
] as const;
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
STALKER_APPLICATION_OPERATIONS,
|
||||
STALKER_CONNECTION_STAGES,
|
||||
STALKER_FAILURE_REASONS,
|
||||
STALKER_PROFILE_RESULT_KINDS,
|
||||
STALKER_REQUEST_RECIPES,
|
||||
} from './stalker-protocol.constants';
|
||||
|
||||
export type StalkerRequestRecipe =
|
||||
(typeof STALKER_REQUEST_RECIPES)[keyof typeof STALKER_REQUEST_RECIPES];
|
||||
|
||||
export type StalkerConnectionStage =
|
||||
(typeof STALKER_CONNECTION_STAGES)[keyof typeof STALKER_CONNECTION_STAGES];
|
||||
|
||||
export type StalkerFailureReason =
|
||||
(typeof STALKER_FAILURE_REASONS)[keyof typeof STALKER_FAILURE_REASONS];
|
||||
|
||||
export type StalkerProfileResultKind =
|
||||
(typeof STALKER_PROFILE_RESULT_KINDS)[keyof typeof STALKER_PROFILE_RESULT_KINDS];
|
||||
|
||||
export type StalkerApplicationOperation =
|
||||
(typeof STALKER_APPLICATION_OPERATIONS)[keyof typeof STALKER_APPLICATION_OPERATIONS];
|
||||
|
||||
export interface StalkerNormalizedProfileReady {
|
||||
kind: typeof STALKER_PROFILE_RESULT_KINDS.Ready;
|
||||
profile: Readonly<Record<string, unknown>>;
|
||||
status: 0 | 'statusless';
|
||||
}
|
||||
|
||||
export interface StalkerNormalizedProfileBlocked {
|
||||
kind: typeof STALKER_PROFILE_RESULT_KINDS.Blocked;
|
||||
profile: Readonly<Record<string, unknown>>;
|
||||
status: 1;
|
||||
}
|
||||
|
||||
export interface StalkerNormalizedProfileCredentialsRequired {
|
||||
kind: typeof STALKER_PROFILE_RESULT_KINDS.CredentialsRequired;
|
||||
profile: Readonly<Record<string, unknown>>;
|
||||
status: 2;
|
||||
}
|
||||
|
||||
export interface StalkerNormalizedProfileFailure {
|
||||
kind: typeof STALKER_PROFILE_RESULT_KINDS.Failure;
|
||||
reason: typeof STALKER_FAILURE_REASONS.IncompatibleResponse;
|
||||
}
|
||||
|
||||
export type StalkerNormalizedProfileResult =
|
||||
| StalkerNormalizedProfileReady
|
||||
| StalkerNormalizedProfileBlocked
|
||||
| StalkerNormalizedProfileCredentialsRequired
|
||||
| StalkerNormalizedProfileFailure;
|
||||
|
||||
export interface StalkerIdentityProfileInput {
|
||||
macAddress: string;
|
||||
locale?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
userAgent?: string;
|
||||
xUserAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
prehash?: string;
|
||||
apiSignature?: string;
|
||||
firmwareVersion?: string;
|
||||
imageVersion?: string;
|
||||
hardwareVersion?: string;
|
||||
hardwareVersion2?: string;
|
||||
numberOfBanks?: string;
|
||||
videoOutput?: string;
|
||||
handshakeRandom?: string;
|
||||
}
|
||||
|
||||
export interface StalkerIdentityPreset {
|
||||
id: 'mag250-public-5_1-minimal-v1';
|
||||
version: 1;
|
||||
}
|
||||
|
||||
export interface StalkerIdentityProfile {
|
||||
preset: StalkerIdentityPreset;
|
||||
macAddress: string;
|
||||
headers: Readonly<Record<string, string>>;
|
||||
managedCookies: Readonly<Record<string, string>>;
|
||||
profileParameters: Readonly<Record<string, string | number>>;
|
||||
metrics: Readonly<Record<string, string | number>>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
STALKER_APPLICATION_OPERATIONS,
|
||||
validateStalkerApplicationRequest,
|
||||
} from './stalker-request-policy';
|
||||
|
||||
describe('Stalker application request policy', () => {
|
||||
it.each(['handshake', 'get_profile', 'do_auth'])(
|
||||
'rejects the raw auth action %s',
|
||||
(operation) => {
|
||||
expect(
|
||||
validateStalkerApplicationRequest({ operation, parameters: {} })
|
||||
).toEqual({
|
||||
kind: 'rejected',
|
||||
reason: 'unsupported-operation',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
'action',
|
||||
'token',
|
||||
'prehash',
|
||||
'auth_second_step',
|
||||
'login',
|
||||
'password',
|
||||
'mac',
|
||||
'serial',
|
||||
'device_id',
|
||||
'signature',
|
||||
'metrics',
|
||||
'stb_lang',
|
||||
'timezone',
|
||||
'JsHttpRequest',
|
||||
])('rejects the reserved parameter %s', (parameter) => {
|
||||
expect(
|
||||
validateStalkerApplicationRequest({
|
||||
operation: STALKER_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: { [parameter]: 'renderer-value' },
|
||||
})
|
||||
).toEqual({
|
||||
field: parameter,
|
||||
kind: 'rejected',
|
||||
reason: 'reserved-parameter',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Authorization',
|
||||
'Cookie',
|
||||
'X-User-Agent',
|
||||
'SN',
|
||||
'User-Agent',
|
||||
'Origin',
|
||||
'Referer',
|
||||
'Accept-Language',
|
||||
])('rejects the managed header %s', (header) => {
|
||||
expect(
|
||||
validateStalkerApplicationRequest({
|
||||
headers: { [header]: 'renderer-value' },
|
||||
operation: STALKER_APPLICATION_OPERATIONS.CreateLink,
|
||||
parameters: {},
|
||||
})
|
||||
).toEqual({
|
||||
field: header,
|
||||
kind: 'rejected',
|
||||
reason: 'reserved-header',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an allowlisted operation with non-reserved parameters', () => {
|
||||
expect(
|
||||
validateStalkerApplicationRequest({
|
||||
operation: STALKER_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: {
|
||||
category: '10',
|
||||
page: 2,
|
||||
search: 'News',
|
||||
},
|
||||
})
|
||||
).toEqual({ kind: 'accepted' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
STALKER_APPLICATION_OPERATIONS,
|
||||
STALKER_MANAGED_REQUEST_HEADERS,
|
||||
STALKER_RESERVED_REQUEST_PARAMETERS,
|
||||
} from './stalker-protocol.constants';
|
||||
|
||||
export { STALKER_APPLICATION_OPERATIONS };
|
||||
|
||||
interface StalkerApplicationRequestInput {
|
||||
operation: string;
|
||||
parameters: Readonly<Record<string, unknown>>;
|
||||
headers?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface StalkerRequestAccepted {
|
||||
kind: 'accepted';
|
||||
}
|
||||
|
||||
interface StalkerRequestRejected {
|
||||
kind: 'rejected';
|
||||
reason:
|
||||
| 'unsupported-operation'
|
||||
| 'reserved-parameter'
|
||||
| 'reserved-header';
|
||||
field?: string;
|
||||
}
|
||||
|
||||
const APPLICATION_OPERATION_SET = new Set<string>(
|
||||
Object.values(STALKER_APPLICATION_OPERATIONS)
|
||||
);
|
||||
const RESERVED_PARAMETER_SET = new Set<string>(
|
||||
STALKER_RESERVED_REQUEST_PARAMETERS
|
||||
);
|
||||
const MANAGED_HEADER_SET = new Set<string>(STALKER_MANAGED_REQUEST_HEADERS);
|
||||
|
||||
export function validateStalkerApplicationRequest(
|
||||
input: StalkerApplicationRequestInput
|
||||
): StalkerRequestAccepted | StalkerRequestRejected {
|
||||
if (!APPLICATION_OPERATION_SET.has(input.operation)) {
|
||||
return { kind: 'rejected', reason: 'unsupported-operation' };
|
||||
}
|
||||
|
||||
for (const parameter of Object.keys(input.parameters)) {
|
||||
if (RESERVED_PARAMETER_SET.has(parameter.toLowerCase())) {
|
||||
return {
|
||||
field: parameter,
|
||||
kind: 'rejected',
|
||||
reason: 'reserved-parameter',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const header of Object.keys(input.headers ?? {})) {
|
||||
if (MANAGED_HEADER_SET.has(header.toLowerCase())) {
|
||||
return {
|
||||
field: header,
|
||||
kind: 'rejected',
|
||||
reason: 'reserved-header',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'accepted' };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
classifyStalkerDoAuth,
|
||||
classifyStalkerProfile,
|
||||
classifyStalkerResponseFailure,
|
||||
parseStalkerResponseEnvelope,
|
||||
} from './stalker-response-classifier';
|
||||
|
||||
describe('Stalker response classifier', () => {
|
||||
it.each([
|
||||
[0, 'ready'],
|
||||
['0', 'ready'],
|
||||
[1, 'blocked'],
|
||||
['1', 'blocked'],
|
||||
[2, 'credentials-required'],
|
||||
['2', 'credentials-required'],
|
||||
])('normalizes profile status %p', (status, expectedKind) => {
|
||||
expect(classifyStalkerProfile({ js: { id: 7, status } })).toMatchObject({
|
||||
kind: expectedKind,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a recognized status-less profile success', () => {
|
||||
expect(
|
||||
classifyStalkerProfile({
|
||||
js: { id: 7, mac: '00:1A:79:00:00:01' },
|
||||
})
|
||||
).toMatchObject({ kind: 'ready', status: 'statusless' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ js: { id: 7, status: 3 } },
|
||||
{ js: { status: 'unknown' } },
|
||||
{ status: 0 },
|
||||
{ js: { unrelated: true } },
|
||||
])('rejects unknown status or unsupported envelopes', (value) => {
|
||||
expect(classifyStalkerProfile(value)).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires canonical boolean do_auth outcomes', () => {
|
||||
expect(classifyStalkerDoAuth({ js: true })).toEqual({
|
||||
kind: 'success',
|
||||
});
|
||||
expect(classifyStalkerDoAuth({ js: false })).toEqual({
|
||||
kind: 'credentials-rejected',
|
||||
});
|
||||
expect(classifyStalkerDoAuth({ js: 1 })).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
expect(classifyStalkerDoAuth({ js: 'true' })).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps token rejection, access denial, WAF, and ambiguous 403 distinct', () => {
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 401,
|
||||
value: { js: { error: 'ignored' } },
|
||||
})
|
||||
).toEqual({ kind: 'token-rejected' });
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 200,
|
||||
value: { js: { error: 'Authorization failed' } },
|
||||
})
|
||||
).toEqual({ kind: 'token-rejected' });
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 200,
|
||||
value: { js: { error: 'Access denied' } },
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'account-access-denied',
|
||||
});
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 403,
|
||||
rawBody: '<html>Cloudflare challenge</html>',
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'portal-protection-blocked',
|
||||
});
|
||||
expect(
|
||||
classifyStalkerResponseFailure({
|
||||
httpStatus: 403,
|
||||
value: { js: { error: 'Forbidden' } },
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['application/json', 'text/json; charset=utf-8'])(
|
||||
'accepts JSON for %s',
|
||||
(contentType) => {
|
||||
expect(
|
||||
parseStalkerResponseEnvelope({
|
||||
body: '{"js":{"status":0}}',
|
||||
contentType,
|
||||
maxBodyBytes: 1024,
|
||||
})
|
||||
).toEqual({
|
||||
format: 'json',
|
||||
kind: 'parsed',
|
||||
value: { js: { status: 0 } },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts only an allowlisted JSONP callback for JavaScript media', () => {
|
||||
expect(
|
||||
parseStalkerResponseEnvelope({
|
||||
body: 'callback({"js":{"status":0}});',
|
||||
contentType: 'application/javascript',
|
||||
maxBodyBytes: 1024,
|
||||
})
|
||||
).toMatchObject({ format: 'jsonp', kind: 'parsed' });
|
||||
expect(
|
||||
parseStalkerResponseEnvelope({
|
||||
body: 'evil({"js":{"status":0}});',
|
||||
contentType: 'application/javascript',
|
||||
maxBodyBytes: 1024,
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, 'text/plain; charset=utf-8'])(
|
||||
'uses bounded compatibility sniffing for %s',
|
||||
(contentType) => {
|
||||
expect(
|
||||
parseStalkerResponseEnvelope({
|
||||
body: '{"js":{"status":0}}',
|
||||
contentType,
|
||||
maxBodyBytes: 1024,
|
||||
})
|
||||
).toMatchObject({ format: 'json', kind: 'parsed' });
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects HTML media even when its body is valid JSON', () => {
|
||||
expect(
|
||||
parseStalkerResponseEnvelope({
|
||||
body: '{"js":{"status":0}}',
|
||||
contentType: 'text/html',
|
||||
maxBodyBytes: 1024,
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a body before parsing when it crosses the operation cap', () => {
|
||||
expect(
|
||||
parseStalkerResponseEnvelope({
|
||||
body: '{"js":{"status":0}}',
|
||||
contentType: 'application/json',
|
||||
maxBodyBytes: 4,
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'response-too-large',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
STALKER_FAILURE_REASONS,
|
||||
STALKER_JSONP_CALLBACKS,
|
||||
STALKER_PROFILE_RESULT_KINDS,
|
||||
} from './stalker-protocol.constants';
|
||||
import type {
|
||||
StalkerFailureReason,
|
||||
StalkerNormalizedProfileResult,
|
||||
} from './stalker-protocol.types';
|
||||
|
||||
interface StalkerResponseEnvelopeInput {
|
||||
body: string;
|
||||
contentType?: string;
|
||||
maxBodyBytes: number;
|
||||
}
|
||||
|
||||
interface StalkerParsedEnvelope {
|
||||
kind: 'parsed';
|
||||
format: 'json' | 'jsonp';
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface StalkerClassifierFailure {
|
||||
kind: 'failure';
|
||||
reason: StalkerFailureReason;
|
||||
}
|
||||
|
||||
interface StalkerIncompatibleResponseFailure {
|
||||
kind: 'failure';
|
||||
reason: typeof STALKER_FAILURE_REASONS.IncompatibleResponse;
|
||||
}
|
||||
|
||||
interface StalkerResponseFailureInput {
|
||||
httpStatus: number;
|
||||
rawBody?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export function parseStalkerResponseEnvelope(
|
||||
input: StalkerResponseEnvelopeInput
|
||||
): StalkerParsedEnvelope | StalkerClassifierFailure {
|
||||
if (new TextEncoder().encode(input.body).byteLength > input.maxBodyBytes) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.ResponseTooLarge,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaType = normalizeMediaType(input.contentType);
|
||||
if (mediaType === 'invalid' || !isCompatibleMediaType(mediaType)) {
|
||||
return incompatibleResponse();
|
||||
}
|
||||
|
||||
const allowJsonp =
|
||||
mediaType === 'application/javascript' ||
|
||||
mediaType === 'text/javascript' ||
|
||||
mediaType === 'text/plain' ||
|
||||
mediaType === undefined;
|
||||
|
||||
const json = parseJson(input.body);
|
||||
if (json.parsed) {
|
||||
return { format: 'json', kind: 'parsed', value: json.value };
|
||||
}
|
||||
|
||||
if (allowJsonp) {
|
||||
const jsonp = parseAllowlistedJsonp(input.body);
|
||||
if (jsonp.parsed) {
|
||||
return { format: 'jsonp', kind: 'parsed', value: jsonp.value };
|
||||
}
|
||||
}
|
||||
|
||||
return incompatibleResponse();
|
||||
}
|
||||
|
||||
export function classifyStalkerProfile(
|
||||
value: unknown
|
||||
): StalkerNormalizedProfileResult {
|
||||
const envelope = asRecord(value);
|
||||
const profile = asRecord(envelope?.['js']);
|
||||
if (!profile) {
|
||||
return incompatibleResponse();
|
||||
}
|
||||
|
||||
const status = normalizeProfileStatus(profile['status']);
|
||||
if (status === 0) {
|
||||
return {
|
||||
kind: STALKER_PROFILE_RESULT_KINDS.Ready,
|
||||
profile,
|
||||
status,
|
||||
};
|
||||
}
|
||||
if (status === 1) {
|
||||
return {
|
||||
kind: STALKER_PROFILE_RESULT_KINDS.Blocked,
|
||||
profile,
|
||||
status,
|
||||
};
|
||||
}
|
||||
if (status === 2) {
|
||||
return {
|
||||
kind: STALKER_PROFILE_RESULT_KINDS.CredentialsRequired,
|
||||
profile,
|
||||
status,
|
||||
};
|
||||
}
|
||||
if (
|
||||
profile['status'] === undefined &&
|
||||
hasRecognizedStatuslessProfileField(profile) &&
|
||||
typeof profile['error'] !== 'string'
|
||||
) {
|
||||
return {
|
||||
kind: STALKER_PROFILE_RESULT_KINDS.Ready,
|
||||
profile,
|
||||
status: 'statusless',
|
||||
};
|
||||
}
|
||||
|
||||
return incompatibleResponse();
|
||||
}
|
||||
|
||||
export function classifyStalkerDoAuth(
|
||||
value: unknown
|
||||
):
|
||||
| { kind: 'success' }
|
||||
| { kind: 'credentials-rejected' }
|
||||
| StalkerClassifierFailure {
|
||||
const envelope = asRecord(value);
|
||||
if (envelope?.['js'] === true) {
|
||||
return { kind: 'success' };
|
||||
}
|
||||
if (envelope?.['js'] === false) {
|
||||
return { kind: 'credentials-rejected' };
|
||||
}
|
||||
return incompatibleResponse();
|
||||
}
|
||||
|
||||
export function classifyStalkerResponseFailure(
|
||||
input: StalkerResponseFailureInput
|
||||
):
|
||||
| { kind: 'none' }
|
||||
| { kind: 'token-rejected' }
|
||||
| StalkerClassifierFailure {
|
||||
const bodyError = extractExactBodyError(input.value, input.rawBody);
|
||||
if (
|
||||
input.httpStatus === 401 ||
|
||||
bodyError === 'Authorization failed'
|
||||
) {
|
||||
return { kind: 'token-rejected' };
|
||||
}
|
||||
if (bodyError === 'Access denied') {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.AccountAccessDenied,
|
||||
};
|
||||
}
|
||||
if (input.httpStatus === 403) {
|
||||
if (looksLikePortalProtection(input.rawBody)) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.PortalProtectionBlocked,
|
||||
};
|
||||
}
|
||||
return incompatibleResponse();
|
||||
}
|
||||
if (input.httpStatus === 429) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.RateLimited,
|
||||
};
|
||||
}
|
||||
if (input.httpStatus >= 500) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.PortalUnavailable,
|
||||
};
|
||||
}
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
function normalizeProfileStatus(value: unknown): 0 | 1 | 2 | undefined {
|
||||
if (value === 0 || value === '0') return 0;
|
||||
if (value === 1 || value === '1') return 1;
|
||||
if (value === 2 || value === '2') return 2;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasRecognizedStatuslessProfileField(
|
||||
profile: Readonly<Record<string, unknown>>
|
||||
): boolean {
|
||||
return [
|
||||
'id',
|
||||
'login',
|
||||
'mac',
|
||||
'name',
|
||||
'stb_type',
|
||||
'tariff_plan_id',
|
||||
'timeslot',
|
||||
'watchdog_timeout',
|
||||
].some((field) => profile[field] !== undefined);
|
||||
}
|
||||
|
||||
function normalizeMediaType(
|
||||
contentType: string | undefined
|
||||
): string | undefined | 'invalid' {
|
||||
if (contentType === undefined || contentType.trim() === '') return undefined;
|
||||
const [rawType, ...parameters] = contentType.split(';');
|
||||
if (
|
||||
!rawType ||
|
||||
parameters.some(
|
||||
(parameter) =>
|
||||
!/^\s*[!#$%&'*+\-.^_`|~0-9A-Za-z]+=(?:"[^"]*"|[!#$%&'*+\-.^_`|~0-9A-Za-z]+)\s*$/.test(
|
||||
parameter
|
||||
)
|
||||
)
|
||||
) {
|
||||
return 'invalid';
|
||||
}
|
||||
return rawType.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isCompatibleMediaType(mediaType: string | undefined): boolean {
|
||||
return (
|
||||
mediaType === undefined ||
|
||||
mediaType === 'application/json' ||
|
||||
mediaType === 'text/json' ||
|
||||
mediaType === 'application/javascript' ||
|
||||
mediaType === 'text/javascript' ||
|
||||
mediaType === 'text/plain'
|
||||
);
|
||||
}
|
||||
|
||||
function parseJson(
|
||||
body: string
|
||||
): { parsed: true; value: unknown } | { parsed: false } {
|
||||
try {
|
||||
return { parsed: true, value: JSON.parse(body) };
|
||||
} catch {
|
||||
return { parsed: false };
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowlistedJsonp(
|
||||
body: string
|
||||
): { parsed: true; value: unknown } | { parsed: false } {
|
||||
const callbacks = STALKER_JSONP_CALLBACKS.join('|');
|
||||
const match = new RegExp(
|
||||
`^\\s*(?:${callbacks})\\((.*)\\);?\\s*$`,
|
||||
's'
|
||||
).exec(body);
|
||||
return match?.[1] === undefined ? { parsed: false } : parseJson(match[1]);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Readonly<Record<string, unknown>>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function extractExactBodyError(
|
||||
value: unknown,
|
||||
rawBody: string | undefined
|
||||
): 'Access denied' | 'Authorization failed' | undefined {
|
||||
const envelope = asRecord(value);
|
||||
const js = asRecord(envelope?.['js']);
|
||||
const candidate =
|
||||
typeof js?.['error'] === 'string'
|
||||
? js['error']
|
||||
: typeof envelope?.['error'] === 'string'
|
||||
? envelope['error']
|
||||
: rawBody;
|
||||
if (candidate === 'Authorization failed' || candidate === 'Authorization failed.') {
|
||||
return 'Authorization failed';
|
||||
}
|
||||
if (candidate === 'Access denied' || candidate === 'Access denied.') {
|
||||
return 'Access denied';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikePortalProtection(body: string | undefined): boolean {
|
||||
return body === undefined
|
||||
? false
|
||||
: /(cloudflare|captcha|cf-chl|web application firewall|just a moment)/i.test(
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
function incompatibleResponse(): StalkerIncompatibleResponseFailure {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_FAILURE_REASONS.IncompatibleResponse,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
StalkerProtocolInputError,
|
||||
deduplicateAndLimitStalkerEndpointCandidates,
|
||||
deriveStalkerEndpointCandidates,
|
||||
normalizeStalkerSourceUrl,
|
||||
} from './stalker-url-recipes';
|
||||
|
||||
describe('Stalker endpoint URL recipes', () => {
|
||||
it.each([
|
||||
[
|
||||
'https://portal.test/custom/portal.php',
|
||||
['https://portal.test/custom/portal.php'],
|
||||
],
|
||||
[
|
||||
'https://portal.test/custom/server/load.php',
|
||||
['https://portal.test/custom/server/load.php'],
|
||||
],
|
||||
])('keeps a direct API endpoint first', (sourceUrl, expected) => {
|
||||
expect(deriveStalkerEndpointCandidates(sourceUrl)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('derives root and conventional stalker_portal candidates', () => {
|
||||
expect(deriveStalkerEndpointCandidates('https://portal.test/')).toEqual([
|
||||
'https://portal.test/server/load.php',
|
||||
'https://portal.test/portal.php',
|
||||
'https://portal.test/stalker_portal/server/load.php',
|
||||
'https://portal.test/stalker_portal/portal.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://portal.test/c', 'https://portal.test'],
|
||||
['https://portal.test/c/', 'https://portal.test'],
|
||||
['https://portal.test/c/index.html', 'https://portal.test'],
|
||||
['https://portal.test/prefix/c/', 'https://portal.test/prefix'],
|
||||
])('derives /c/ candidates from its parent', (sourceUrl, parent) => {
|
||||
expect(deriveStalkerEndpointCandidates(sourceUrl)).toEqual([
|
||||
`${parent}/server/load.php`,
|
||||
`${parent}/portal.php`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://portal.test/custom', 'https://portal.test/custom'],
|
||||
['https://portal.test/custom/', 'https://portal.test/custom'],
|
||||
['https://portal.test/custom/index.html', 'https://portal.test/custom'],
|
||||
])('derives same-directory candidates', (sourceUrl, directory) => {
|
||||
expect(deriveStalkerEndpointCandidates(sourceUrl)).toEqual([
|
||||
`${directory}/server/load.php`,
|
||||
`${directory}/portal.php`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates in order and enforces the six-candidate cap', () => {
|
||||
expect(
|
||||
deduplicateAndLimitStalkerEndpointCandidates([
|
||||
'https://portal.test/1',
|
||||
'https://portal.test/2',
|
||||
'https://portal.test/1',
|
||||
'https://portal.test/3',
|
||||
'https://portal.test/4',
|
||||
'https://portal.test/5',
|
||||
'https://portal.test/6',
|
||||
'https://portal.test/7',
|
||||
])
|
||||
).toEqual([
|
||||
'https://portal.test/1',
|
||||
'https://portal.test/2',
|
||||
'https://portal.test/3',
|
||||
'https://portal.test/4',
|
||||
'https://portal.test/5',
|
||||
'https://portal.test/6',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips fragments while preserving a non-sensitive landing query', () => {
|
||||
expect(
|
||||
normalizeStalkerSourceUrl(
|
||||
'https://portal.test/c/?theme=dark#credentials'
|
||||
)
|
||||
).toBe('https://portal.test/c/?theme=dark');
|
||||
expect(
|
||||
deriveStalkerEndpointCandidates(
|
||||
'https://portal.test/c/?theme=dark#credentials'
|
||||
)
|
||||
).toEqual([
|
||||
'https://portal.test/server/load.php',
|
||||
'https://portal.test/portal.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://user:password@portal.test/c/',
|
||||
'https://portal.test/c/?username=account',
|
||||
'https://portal.test/c/?PASSWORD=secret',
|
||||
'https://portal.test/c/?token=secret',
|
||||
'https://portal.test/c/?mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
'ftp://portal.test/c/',
|
||||
])('rejects credential-bearing or unsupported URLs', (sourceUrl) => {
|
||||
expect(() => normalizeStalkerSourceUrl(sourceUrl)).toThrow(
|
||||
new StalkerProtocolInputError('invalid-url')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { STALKER_ENDPOINT_CANDIDATE_LIMIT } from './stalker-protocol.constants';
|
||||
|
||||
const CREDENTIAL_QUERY_KEYS = new Set([
|
||||
'api_signature',
|
||||
'authorization',
|
||||
'device_id',
|
||||
'device_id2',
|
||||
'login',
|
||||
'mac',
|
||||
'password',
|
||||
'prehash',
|
||||
'serial',
|
||||
'signature',
|
||||
'signature2',
|
||||
'token',
|
||||
'username',
|
||||
]);
|
||||
|
||||
export class StalkerProtocolInputError extends Error {
|
||||
constructor(public readonly reason: 'invalid-url') {
|
||||
super(reason);
|
||||
this.name = 'StalkerProtocolInputError';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStalkerSourceUrl(sourceUrl: string): string {
|
||||
let parsed: URL;
|
||||
|
||||
try {
|
||||
parsed = new URL(sourceUrl);
|
||||
} catch {
|
||||
throw new StalkerProtocolInputError('invalid-url');
|
||||
}
|
||||
|
||||
if (
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
hasCredentialQueryKey(parsed)
|
||||
) {
|
||||
throw new StalkerProtocolInputError('invalid-url');
|
||||
}
|
||||
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function deriveStalkerEndpointCandidates(
|
||||
sourceUrl: string
|
||||
): readonly string[] {
|
||||
const normalizedSourceUrl = normalizeStalkerSourceUrl(sourceUrl);
|
||||
const parsed = new URL(normalizedSourceUrl);
|
||||
const pathname = parsed.pathname;
|
||||
const lowerPathname = pathname.toLowerCase();
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (
|
||||
lowerPathname.endsWith('/portal.php') ||
|
||||
lowerPathname.endsWith('/server/load.php')
|
||||
) {
|
||||
candidates.push(toCandidateUrl(parsed, pathname));
|
||||
return deduplicateAndLimitStalkerEndpointCandidates(candidates);
|
||||
}
|
||||
|
||||
const cParentPath = getCParentPath(pathname);
|
||||
if (cParentPath !== undefined) {
|
||||
candidates.push(
|
||||
toCandidateUrl(parsed, joinPath(cParentPath, 'server/load.php')),
|
||||
toCandidateUrl(parsed, joinPath(cParentPath, 'portal.php'))
|
||||
);
|
||||
return deduplicateAndLimitStalkerEndpointCandidates(candidates);
|
||||
}
|
||||
|
||||
const directoryPath = getDirectoryPath(pathname);
|
||||
candidates.push(
|
||||
toCandidateUrl(parsed, joinPath(directoryPath, 'server/load.php')),
|
||||
toCandidateUrl(parsed, joinPath(directoryPath, 'portal.php'))
|
||||
);
|
||||
|
||||
if (directoryPath === '/') {
|
||||
candidates.push(
|
||||
toCandidateUrl(parsed, '/stalker_portal/server/load.php'),
|
||||
toCandidateUrl(parsed, '/stalker_portal/portal.php')
|
||||
);
|
||||
}
|
||||
|
||||
return deduplicateAndLimitStalkerEndpointCandidates(candidates);
|
||||
}
|
||||
|
||||
export function deduplicateAndLimitStalkerEndpointCandidates(
|
||||
candidates: readonly string[]
|
||||
): readonly string[] {
|
||||
return [...new Set(candidates)].slice(0, STALKER_ENDPOINT_CANDIDATE_LIMIT);
|
||||
}
|
||||
|
||||
function hasCredentialQueryKey(url: URL): boolean {
|
||||
let credentialKeyFound = false;
|
||||
url.searchParams.forEach((_value, key) => {
|
||||
if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) {
|
||||
credentialKeyFound = true;
|
||||
}
|
||||
});
|
||||
return credentialKeyFound;
|
||||
}
|
||||
|
||||
function getCParentPath(pathname: string): string | undefined {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const cIndex = segments.findIndex(
|
||||
(segment) => segment.toLowerCase() === 'c'
|
||||
);
|
||||
|
||||
if (cIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remaining = segments.slice(cIndex + 1);
|
||||
if (
|
||||
remaining.length > 1 ||
|
||||
(remaining.length === 1 &&
|
||||
!remaining[0]?.toLowerCase().startsWith('index.'))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `/${segments.slice(0, cIndex).join('/')}`;
|
||||
}
|
||||
|
||||
function getDirectoryPath(pathname: string): string {
|
||||
if (pathname.endsWith('/')) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
const lastSlash = pathname.lastIndexOf('/');
|
||||
const lastSegment = pathname.slice(lastSlash + 1);
|
||||
return lastSegment.includes('.')
|
||||
? pathname.slice(0, lastSlash + 1)
|
||||
: pathname;
|
||||
}
|
||||
|
||||
function joinPath(directory: string, suffix: string): string {
|
||||
const normalizedDirectory =
|
||||
directory === '/' ? '' : directory.replace(/\/+$/, '');
|
||||
return `${normalizedDirectory}/${suffix}`;
|
||||
}
|
||||
|
||||
function toCandidateUrl(base: URL, pathname: string): string {
|
||||
const candidate = new URL(base.origin);
|
||||
candidate.pathname = pathname;
|
||||
return candidate.toString();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-identity.utils';
|
||||
export * from './lib/stalker-portal-item.interface';
|
||||
export * from './lib/stalker-serial-details.interface';
|
||||
export * from './lib/stalker-session.interface';
|
||||
export * from './lib/stalker-vod-details.interface';
|
||||
|
||||
// Unified VOD details (discriminated union + adapters)
|
||||
|
||||
@@ -33,6 +33,15 @@ import {
|
||||
import { PortalDebugEvent } from './portal-debug.interface';
|
||||
import { CatalogTitleMatch } from './catalog-title-match.interface';
|
||||
import { Settings } from './settings.interface';
|
||||
import {
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionContinueRequest,
|
||||
StalkerSessionControlOutcome,
|
||||
StalkerSessionControlRequest,
|
||||
StalkerSessionOpenRequest,
|
||||
StalkerSessionRequest,
|
||||
StalkerSessionRequestOutcome,
|
||||
} from './stalker-session.interface';
|
||||
import {
|
||||
TmdbCacheEntry,
|
||||
TmdbCacheMediaType,
|
||||
@@ -662,6 +671,18 @@ export interface ElectronBridgeApi {
|
||||
stalkerRequest: (
|
||||
payload: ElectronBridgeStalkerRequestPayload
|
||||
) => Promise<Record<string, unknown>>;
|
||||
stalkerSessionOpen?: (
|
||||
request: StalkerSessionOpenRequest
|
||||
) => Promise<StalkerSessionConnectionOutcome>;
|
||||
stalkerSessionContinue?: (
|
||||
request: StalkerSessionContinueRequest
|
||||
) => Promise<StalkerSessionConnectionOutcome>;
|
||||
stalkerSessionRequest?: (
|
||||
request: StalkerSessionRequest
|
||||
) => Promise<StalkerSessionRequestOutcome>;
|
||||
stalkerSessionControl?: (
|
||||
request: StalkerSessionControlRequest
|
||||
) => Promise<StalkerSessionControlOutcome>;
|
||||
xtreamRequest: (
|
||||
payload: ElectronBridgeXtreamRequestPayload
|
||||
) => Promise<ElectronBridgeXtreamResponse>;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
STALKER_SESSION_CONTINUE,
|
||||
STALKER_SESSION_CONTROL,
|
||||
STALKER_SESSION_OPEN,
|
||||
STALKER_SESSION_REQUEST,
|
||||
} from './ipc-commands';
|
||||
import {
|
||||
IpcCommand,
|
||||
type IpcCommandRequest,
|
||||
type IpcCommandResponse,
|
||||
} from './ipc-command.class';
|
||||
import {
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS,
|
||||
type StalkerSessionContinueRequest,
|
||||
type StalkerSessionControlRequest,
|
||||
type StalkerSessionOpenRequest,
|
||||
type StalkerSessionRequest,
|
||||
} from './stalker-session.interface';
|
||||
|
||||
describe('typed IPC commands', () => {
|
||||
it('maps STALKER_SESSION_OPEN to exactly one request and response type', () => {
|
||||
const request: IpcCommandRequest<typeof STALKER_SESSION_OPEN> = {
|
||||
descriptor: {
|
||||
connectionMode: 'provisional',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
playlistRef: 'playlist-ref',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
sourceUrl: 'https://portal.test/c/',
|
||||
},
|
||||
} satisfies StalkerSessionOpenRequest;
|
||||
const response: IpcCommandResponse<typeof STALKER_SESSION_OPEN> = {
|
||||
challengeRef: 'opaque-origin-challenge',
|
||||
finalOrigin: 'https://target.test',
|
||||
kind: 'origin-approval-required',
|
||||
requestId: 'request-id',
|
||||
sourceOrigin: 'https://portal.test',
|
||||
};
|
||||
const callback = jest.fn<
|
||||
void,
|
||||
[IpcCommandResponse<typeof STALKER_SESSION_OPEN>]
|
||||
>();
|
||||
|
||||
const command = new IpcCommand(STALKER_SESSION_OPEN, callback);
|
||||
command.callback(response);
|
||||
|
||||
expect(request.descriptor.sourceUrl).toBe('https://portal.test/c/');
|
||||
expect(command.id).toBe(STALKER_SESSION_OPEN);
|
||||
expect(callback).toHaveBeenCalledWith(response);
|
||||
});
|
||||
|
||||
it('maps STALKER_SESSION_CONTINUE to exactly one request and response type', () => {
|
||||
const request: IpcCommandRequest<typeof STALKER_SESSION_CONTINUE> = {
|
||||
challengeRef: 'opaque-credential-challenge',
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: 'test-only-password',
|
||||
username: 'test-only-user',
|
||||
},
|
||||
} satisfies StalkerSessionContinueRequest;
|
||||
const response: IpcCommandResponse<typeof STALKER_SESSION_CONTINUE> = {
|
||||
attemptRef: 'opaque-attempt',
|
||||
capabilities: {
|
||||
authenticatedSession: true,
|
||||
playbackContext: true,
|
||||
},
|
||||
endpoint: 'https://portal.test/server/load.php',
|
||||
kind: 'ready',
|
||||
landingUrl: 'https://portal.test/c/',
|
||||
leaseRef: 'opaque-lease',
|
||||
persistenceDraft: {
|
||||
isFullStalkerPortal: true,
|
||||
portalUrl: 'https://portal.test/server/load.php',
|
||||
stalkerLandingUrl: 'https://portal.test/c/',
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
},
|
||||
recipe: 'full-session',
|
||||
requestId: 'request-id',
|
||||
};
|
||||
|
||||
expect(request.response.kind).toBe('credentials');
|
||||
expect(response.kind).toBe('ready');
|
||||
});
|
||||
|
||||
it('maps STALKER_SESSION_REQUEST to exactly one request and response type', () => {
|
||||
const request: IpcCommandRequest<typeof STALKER_SESSION_REQUEST> = {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: { category: '10', page: 1 },
|
||||
requestId: 'request-id',
|
||||
} satisfies StalkerSessionRequest;
|
||||
const response: IpcCommandResponse<typeof STALKER_SESSION_REQUEST> = {
|
||||
kind: 'success',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
payload: { js: { data: [] } },
|
||||
requestId: 'request-id',
|
||||
};
|
||||
|
||||
expect(request.operation).toBe('get-ordered-list');
|
||||
expect(response.kind).toBe('success');
|
||||
});
|
||||
|
||||
it('maps STALKER_SESSION_CONTROL to exactly one request and response type', () => {
|
||||
const request: IpcCommandRequest<typeof STALKER_SESSION_CONTROL> = {
|
||||
action: 'commit',
|
||||
attemptRef: 'opaque-attempt',
|
||||
requestId: 'request-id',
|
||||
} satisfies StalkerSessionControlRequest;
|
||||
const response: IpcCommandResponse<typeof STALKER_SESSION_CONTROL> = {
|
||||
action: 'commit',
|
||||
kind: 'success',
|
||||
requestId: 'request-id',
|
||||
};
|
||||
|
||||
expect(request.action).toBe('commit');
|
||||
expect(response.kind).toBe('success');
|
||||
});
|
||||
|
||||
it('keeps secret runtime state out of result DTO examples', () => {
|
||||
const outcome: IpcCommandResponse<typeof STALKER_SESSION_OPEN> = {
|
||||
kind: 'failure',
|
||||
reason: 'request-timeout',
|
||||
requestId: 'request-id',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
};
|
||||
const serializedKeys = collectKeys(outcome);
|
||||
|
||||
expect(serializedKeys).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
'authorization',
|
||||
'cookie',
|
||||
'password',
|
||||
'random',
|
||||
'token',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function collectKeys(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(collectKeys);
|
||||
}
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(value).flatMap(([key, child]) => [
|
||||
key.toLowerCase(),
|
||||
...collectKeys(child),
|
||||
]);
|
||||
}
|
||||
@@ -1,7 +1,50 @@
|
||||
export class IpcCommand {
|
||||
import {
|
||||
STALKER_SESSION_CONTINUE,
|
||||
STALKER_SESSION_CONTROL,
|
||||
STALKER_SESSION_OPEN,
|
||||
STALKER_SESSION_REQUEST,
|
||||
} from './ipc-commands';
|
||||
import type {
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionContinueRequest,
|
||||
StalkerSessionControlOutcome,
|
||||
StalkerSessionControlRequest,
|
||||
StalkerSessionOpenRequest,
|
||||
StalkerSessionRequest,
|
||||
StalkerSessionRequestOutcome,
|
||||
} from './stalker-session.interface';
|
||||
|
||||
export interface IpcCommandContractMap {
|
||||
[STALKER_SESSION_OPEN]: {
|
||||
request: StalkerSessionOpenRequest;
|
||||
response: StalkerSessionConnectionOutcome;
|
||||
};
|
||||
[STALKER_SESSION_CONTINUE]: {
|
||||
request: StalkerSessionContinueRequest;
|
||||
response: StalkerSessionConnectionOutcome;
|
||||
};
|
||||
[STALKER_SESSION_REQUEST]: {
|
||||
request: StalkerSessionRequest;
|
||||
response: StalkerSessionRequestOutcome;
|
||||
};
|
||||
[STALKER_SESSION_CONTROL]: {
|
||||
request: StalkerSessionControlRequest;
|
||||
response: StalkerSessionControlOutcome;
|
||||
};
|
||||
}
|
||||
|
||||
export type IpcCommandId = keyof IpcCommandContractMap;
|
||||
export type IpcCommandRequest<Command extends IpcCommandId> =
|
||||
IpcCommandContractMap[Command]['request'];
|
||||
export type IpcCommandResponse<Command extends IpcCommandId> =
|
||||
IpcCommandContractMap[Command]['response'];
|
||||
|
||||
export class IpcCommand<Command extends IpcCommandId> {
|
||||
constructor(
|
||||
public id: string,
|
||||
public callback: (payload: any) => void
|
||||
public readonly id: Command,
|
||||
public readonly callback: (
|
||||
payload: IpcCommandResponse<Command>
|
||||
) => void
|
||||
) {
|
||||
this.id = id;
|
||||
this.callback = callback;
|
||||
|
||||
@@ -78,6 +78,10 @@ export const XTREAM_CANCEL_SESSION = 'XTREAM_CANCEL_SESSION';
|
||||
// Stalker
|
||||
export const STALKER_REQUEST = 'STALKER_REQUEST';
|
||||
export const STALKER_RESPONSE = 'STALKER_RESPONSE';
|
||||
export const STALKER_SESSION_OPEN = 'STALKER_SESSION_OPEN';
|
||||
export const STALKER_SESSION_CONTINUE = 'STALKER_SESSION_CONTINUE';
|
||||
export const STALKER_SESSION_REQUEST = 'STALKER_SESSION_REQUEST';
|
||||
export const STALKER_SESSION_CONTROL = 'STALKER_SESSION_CONTROL';
|
||||
export const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChannelDrm } from './channel-drm.interface';
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
import type { StalkerPlaybackContextRef } from './stalker-session.interface';
|
||||
|
||||
export interface PlayerContentInfo extends Omit<
|
||||
PlaybackPositionData,
|
||||
@@ -19,5 +20,6 @@ export interface ResolvedPortalPlayback {
|
||||
userAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
playbackContextRef?: StalkerPlaybackContextRef;
|
||||
drm?: ChannelDrm;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
export const STALKER_SESSION_CONNECTION_MODES = {
|
||||
PersistedOpen: 'persisted-open',
|
||||
Provisional: 'provisional',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionConnectionMode =
|
||||
(typeof STALKER_SESSION_CONNECTION_MODES)[keyof typeof STALKER_SESSION_CONNECTION_MODES];
|
||||
|
||||
export const STALKER_SESSION_PROVISIONAL_REASONS = {
|
||||
Edit: 'edit',
|
||||
Import: 'import',
|
||||
Migration: 'migration',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionProvisionalReason =
|
||||
(typeof STALKER_SESSION_PROVISIONAL_REASONS)[keyof typeof STALKER_SESSION_PROVISIONAL_REASONS];
|
||||
|
||||
export const STALKER_SESSION_REQUEST_RECIPES = {
|
||||
FullSession: 'full-session',
|
||||
StatelessMac: 'stateless-mac',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionRequestRecipe =
|
||||
(typeof STALKER_SESSION_REQUEST_RECIPES)[keyof typeof STALKER_SESSION_REQUEST_RECIPES];
|
||||
|
||||
export const STALKER_SESSION_APPLICATION_OPERATIONS = {
|
||||
CatalogCategories: 'get-categories',
|
||||
CatalogGenres: 'get-genres',
|
||||
CatalogItems: 'get-ordered-list',
|
||||
CatalogAllChannels: 'get-all-channels',
|
||||
CatalogSearch: 'search',
|
||||
SeriesSeasons: 'get-series-seasons',
|
||||
SeriesEpisodes: 'get-series-episodes',
|
||||
ShortEpg: 'get-short-epg',
|
||||
EpgInfo: 'get-epg-info',
|
||||
CreateLink: 'create-link',
|
||||
Favorites: 'favorites',
|
||||
MainInfo: 'get-main-info',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionApplicationOperation =
|
||||
(typeof STALKER_SESSION_APPLICATION_OPERATIONS)[keyof typeof STALKER_SESSION_APPLICATION_OPERATIONS];
|
||||
|
||||
export const STALKER_SESSION_CONTROL_ACTIONS = {
|
||||
Activate: 'activate',
|
||||
Deactivate: 'deactivate',
|
||||
Commit: 'commit',
|
||||
Discard: 'discard',
|
||||
Close: 'close',
|
||||
ForceRedetect: 'force-redetect',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionControlAction =
|
||||
(typeof STALKER_SESSION_CONTROL_ACTIONS)[keyof typeof STALKER_SESSION_CONTROL_ACTIONS];
|
||||
|
||||
export const STALKER_SESSION_FAILURE_REASONS = {
|
||||
InvalidUrl: 'invalid-url',
|
||||
InvalidIdentityInput: 'invalid-identity-input',
|
||||
RedirectLoop: 'redirect-loop',
|
||||
EndpointNotFound: 'endpoint-not-found',
|
||||
OriginNotApproved: 'origin-not-approved',
|
||||
DnsFailure: 'dns-failure',
|
||||
NetworkUnreachable: 'network-unreachable',
|
||||
RequestTimeout: 'request-timeout',
|
||||
TlsFailure: 'tls-failure',
|
||||
AccountOrDeviceBlocked: 'account-or-device-blocked',
|
||||
DeviceIdentityConflict: 'device-identity-conflict',
|
||||
CredentialsAttemptLimit: 'credentials-attempt-limit',
|
||||
PrincipalTransitionRequired: 'principal-transition-required',
|
||||
AuthRefreshExhausted: 'auth-refresh-exhausted',
|
||||
AccountAccessDenied: 'account-access-denied',
|
||||
PortalProtectionBlocked: 'portal-protection-blocked',
|
||||
RateLimited: 'rate-limited',
|
||||
PortalUnavailable: 'portal-unavailable',
|
||||
ResponseTooLarge: 'response-too-large',
|
||||
IncompatibleResponse: 'incompatible-response',
|
||||
LocalPersistenceFailed: 'local-persistence-failed',
|
||||
SessionPromotionFailed: 'session-promotion-failed',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionFailureReason =
|
||||
(typeof STALKER_SESSION_FAILURE_REASONS)[keyof typeof STALKER_SESSION_FAILURE_REASONS];
|
||||
|
||||
export const STALKER_SESSION_STAGES = {
|
||||
New: 'new',
|
||||
Resolving: 'resolving',
|
||||
AwaitingOriginApproval: 'awaiting-origin-approval',
|
||||
Handshaking: 'handshaking',
|
||||
ProfileFirst: 'profile-first',
|
||||
AwaitingCredentials: 'awaiting-credentials',
|
||||
DoAuth: 'do-auth',
|
||||
ProfileSecond: 'profile-second',
|
||||
Ready: 'ready',
|
||||
Refreshing: 'refreshing',
|
||||
Persisting: 'persisting',
|
||||
Promoting: 'promoting',
|
||||
} as const;
|
||||
|
||||
export type StalkerSessionStage =
|
||||
(typeof STALKER_SESSION_STAGES)[keyof typeof STALKER_SESSION_STAGES];
|
||||
|
||||
export type StalkerSessionLeaseRef = string;
|
||||
export type StalkerSessionAttemptRef = string;
|
||||
export type StalkerSessionChallengeRef = string;
|
||||
export type StalkerPlaybackContextRef = string;
|
||||
|
||||
export interface StalkerSessionProfilePreset {
|
||||
id: 'mag250-public-5_1-minimal-v1';
|
||||
version: 1;
|
||||
}
|
||||
|
||||
export interface StalkerSessionIdentityOverrides {
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
prehash?: string;
|
||||
apiSignature?: string;
|
||||
firmwareVersion?: string;
|
||||
imageVersion?: string;
|
||||
hardwareVersion?: string;
|
||||
hardwareVersion2?: string;
|
||||
numberOfBanks?: string;
|
||||
videoOutput?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionTransportConfiguration {
|
||||
userAgent?: string;
|
||||
xUserAgent?: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
refererPolicy?: string;
|
||||
originPolicy?: string;
|
||||
locale?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionConnectionDescriptor {
|
||||
playlistRef: string;
|
||||
connectionMode: StalkerSessionConnectionMode;
|
||||
provisionalReason?: StalkerSessionProvisionalReason;
|
||||
sourceUrl: string;
|
||||
learnedEndpointHint?: string;
|
||||
macAddress: string;
|
||||
profilePreset: StalkerSessionProfilePreset;
|
||||
identityOverrides?: StalkerSessionIdentityOverrides;
|
||||
transportConfiguration?: StalkerSessionTransportConfiguration;
|
||||
}
|
||||
|
||||
export interface StalkerSessionOpenRequest {
|
||||
descriptor: StalkerSessionConnectionDescriptor;
|
||||
}
|
||||
|
||||
export interface StalkerSessionOriginApprovalResponse {
|
||||
kind: 'origin-approval';
|
||||
approved: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSessionCredentialsResponse {
|
||||
kind: 'credentials';
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type StalkerSessionChallengeResponse =
|
||||
| StalkerSessionOriginApprovalResponse
|
||||
| StalkerSessionCredentialsResponse;
|
||||
|
||||
export interface StalkerSessionContinueRequest {
|
||||
challengeRef: StalkerSessionChallengeRef;
|
||||
response: StalkerSessionChallengeResponse;
|
||||
}
|
||||
|
||||
export interface StalkerSessionCapabilities {
|
||||
authenticatedSession: boolean;
|
||||
playbackContext: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSessionAccountSummary {
|
||||
name?: string;
|
||||
status?: string;
|
||||
tariffPlan?: string;
|
||||
accountBalance?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionPersistenceDraft {
|
||||
stalkerSourceUrl?: string;
|
||||
portalUrl: string;
|
||||
stalkerLandingUrl: string;
|
||||
stalkerRequestRecipe: StalkerSessionRequestRecipe;
|
||||
stalkerRecipeClassifierVersion: number;
|
||||
stalkerLastVerifiedAt?: string;
|
||||
isFullStalkerPortal: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSessionFullReadyOutcome {
|
||||
kind: 'ready';
|
||||
recipe: 'full-session';
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
landingUrl: string;
|
||||
leaseRef: StalkerSessionLeaseRef;
|
||||
attemptRef?: StalkerSessionAttemptRef;
|
||||
capabilities: StalkerSessionCapabilities;
|
||||
accountSummary?: StalkerSessionAccountSummary;
|
||||
persistenceDraft: StalkerSessionPersistenceDraft;
|
||||
}
|
||||
|
||||
export interface StalkerSessionStatelessReadyOutcome {
|
||||
kind: 'ready';
|
||||
recipe: 'stateless-mac';
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
landingUrl: string;
|
||||
attemptRef?: StalkerSessionAttemptRef;
|
||||
persistenceDraft: StalkerSessionPersistenceDraft;
|
||||
}
|
||||
|
||||
export interface StalkerSessionOriginApprovalRequiredOutcome {
|
||||
kind: 'origin-approval-required';
|
||||
requestId: string;
|
||||
challengeRef: StalkerSessionChallengeRef;
|
||||
sourceOrigin: string;
|
||||
finalOrigin: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionCredentialsRequiredOutcome {
|
||||
kind: 'credentials-required';
|
||||
requestId: string;
|
||||
challengeRef: StalkerSessionChallengeRef;
|
||||
attemptNumber: number;
|
||||
savedCredentialsRejected?: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSessionFailureOutcome {
|
||||
kind: 'failure';
|
||||
requestId: string;
|
||||
reason: StalkerSessionFailureReason;
|
||||
stage: StalkerSessionStage;
|
||||
retryable: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export type StalkerSessionConnectionOutcome =
|
||||
| StalkerSessionFullReadyOutcome
|
||||
| StalkerSessionStatelessReadyOutcome
|
||||
| StalkerSessionOriginApprovalRequiredOutcome
|
||||
| StalkerSessionCredentialsRequiredOutcome
|
||||
| StalkerSessionFailureOutcome;
|
||||
|
||||
export type StalkerSessionParameterValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
|
||||
export interface StalkerSessionRequest {
|
||||
leaseRef: StalkerSessionLeaseRef;
|
||||
operation: StalkerSessionApplicationOperation;
|
||||
parameters: Readonly<Record<string, StalkerSessionParameterValue>>;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionRequestSuccess {
|
||||
kind: 'success';
|
||||
requestId: string;
|
||||
operation: StalkerSessionApplicationOperation;
|
||||
payload: unknown;
|
||||
playbackContextRef?: StalkerPlaybackContextRef;
|
||||
}
|
||||
|
||||
export type StalkerSessionRequestOutcome =
|
||||
| StalkerSessionRequestSuccess
|
||||
| StalkerSessionConnectionOutcome;
|
||||
|
||||
export interface StalkerSessionLeaseControlRequest {
|
||||
action: 'activate' | 'deactivate' | 'close' | 'force-redetect';
|
||||
leaseRef: StalkerSessionLeaseRef;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface StalkerSessionAttemptControlRequest {
|
||||
action: 'commit' | 'discard';
|
||||
attemptRef: StalkerSessionAttemptRef;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type StalkerSessionControlRequest =
|
||||
| StalkerSessionLeaseControlRequest
|
||||
| StalkerSessionAttemptControlRequest;
|
||||
|
||||
export interface StalkerSessionControlSuccess {
|
||||
kind: 'success';
|
||||
requestId: string;
|
||||
action: StalkerSessionControlAction;
|
||||
}
|
||||
|
||||
export type StalkerSessionControlOutcome =
|
||||
| StalkerSessionControlSuccess
|
||||
| StalkerSessionConnectionOutcome;
|
||||
@@ -114,6 +114,13 @@
|
||||
"validationCommand": "pnpm nx test portal-stalker-data-access",
|
||||
"e2eTags": ["@stalker"]
|
||||
},
|
||||
{
|
||||
"name": "portal-stalker-protocol",
|
||||
"root": "libs/portal/stalker/protocol",
|
||||
"sourceRoot": "libs/portal/stalker/protocol/src",
|
||||
"validationCommand": "pnpm nx test portal-stalker-protocol",
|
||||
"e2eTags": ["@stalker"]
|
||||
},
|
||||
{
|
||||
"name": "portal-stalker-feature",
|
||||
"root": "libs/portal/stalker/feature",
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"@iptvnator/portal/stalker/data-access": [
|
||||
"libs/portal/stalker/data-access/src/index.ts"
|
||||
],
|
||||
"@iptvnator/portal/stalker/protocol": [
|
||||
"libs/portal/stalker/protocol/src/index.ts"
|
||||
],
|
||||
"@iptvnator/portal/xtream/feature": [
|
||||
"libs/portal/xtream/feature/src/index.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user