feat(stalker): add pure session protocol contracts

This commit is contained in:
4gray committed 2026-07-27 03:01:52 +02:00
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',
};
+20
View File
@@ -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"
]
}
+1
View File
@@ -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;
+7
View File
@@ -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",
+3
View File
@@ -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"
],