diff --git a/libs/portal/stalker/protocol/jest.config.ts b/libs/portal/stalker/protocol/jest.config.ts new file mode 100644 index 000000000..eac86db3d --- /dev/null +++ b/libs/portal/stalker/protocol/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'portal-stalker-protocol', + preset: '../../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js'], + coverageDirectory: '../../../../coverage/libs/portal/stalker/protocol', +}; diff --git a/libs/portal/stalker/protocol/project.json b/libs/portal/stalker/protocol/project.json new file mode 100644 index 000000000..4cff4327d --- /dev/null +++ b/libs/portal/stalker/protocol/project.json @@ -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" + } + } +} diff --git a/libs/portal/stalker/protocol/src/index.ts b/libs/portal/stalker/protocol/src/index.ts new file mode 100644 index 000000000..3096f6614 --- /dev/null +++ b/libs/portal/stalker/protocol/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/protocol/src/lib/stalker-auth-state-machine.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-auth-state-machine.spec.ts new file mode 100644 index 000000000..5e34753f8 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-auth-state-machine.spec.ts @@ -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', + }); + }); +}); diff --git a/libs/portal/stalker/protocol/src/lib/stalker-auth-state-machine.ts b/libs/portal/stalker/protocol/src/lib/stalker-auth-state-machine.ts new file mode 100644 index 000000000..5eb6dea06 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-auth-state-machine.ts @@ -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, + }; +} diff --git a/libs/portal/stalker/protocol/src/lib/stalker-identity-profile.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-identity-profile.spec.ts new file mode 100644 index 000000000..d595e8ba9 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-identity-profile.spec.ts @@ -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' + ); + }); +}); diff --git a/libs/portal/stalker/protocol/src/lib/stalker-identity-profile.ts b/libs/portal/stalker/protocol/src/lib/stalker-identity-profile.ts new file mode 100644 index 000000000..d851b1d13 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-identity-profile.ts @@ -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 = { + '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 = { + 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 = { + 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, + key: string, + value: string | undefined +): void { + if (value !== undefined) target[key] = value; +} diff --git a/libs/portal/stalker/protocol/src/lib/stalker-identity-revision.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-identity-revision.spec.ts new file mode 100644 index 000000000..8caa4f23a --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-identity-revision.spec.ts @@ -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', + }) + ); + }); +}); diff --git a/libs/portal/stalker/protocol/src/lib/stalker-identity-revision.ts b/libs/portal/stalker/protocol/src/lib/stalker-identity-revision.ts new file mode 100644 index 000000000..ae4596adb --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-identity-revision.ts @@ -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>; +} + +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}`; +} diff --git a/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts b/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts new file mode 100644 index 000000000..c706b0273 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts @@ -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; diff --git a/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts b/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts new file mode 100644 index 000000000..3ef9edae1 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts @@ -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>; + status: 0 | 'statusless'; +} + +export interface StalkerNormalizedProfileBlocked { + kind: typeof STALKER_PROFILE_RESULT_KINDS.Blocked; + profile: Readonly>; + status: 1; +} + +export interface StalkerNormalizedProfileCredentialsRequired { + kind: typeof STALKER_PROFILE_RESULT_KINDS.CredentialsRequired; + profile: Readonly>; + 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>; + managedCookies: Readonly>; + profileParameters: Readonly>; + metrics: Readonly>; +} diff --git a/libs/portal/stalker/protocol/src/lib/stalker-request-policy.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.spec.ts new file mode 100644 index 000000000..7b75e56ce --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.spec.ts @@ -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' }); + }); +}); diff --git a/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts new file mode 100644 index 000000000..d9968319c --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts @@ -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>; + headers?: Readonly>; +} + +interface StalkerRequestAccepted { + kind: 'accepted'; +} + +interface StalkerRequestRejected { + kind: 'rejected'; + reason: + | 'unsupported-operation' + | 'reserved-parameter' + | 'reserved-header'; + field?: string; +} + +const APPLICATION_OPERATION_SET = new Set( + Object.values(STALKER_APPLICATION_OPERATIONS) +); +const RESERVED_PARAMETER_SET = new Set( + STALKER_RESERVED_REQUEST_PARAMETERS +); +const MANAGED_HEADER_SET = new Set(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' }; +} diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts new file mode 100644 index 000000000..46f45ad8f --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts @@ -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: 'Cloudflare challenge', + }) + ).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', + }); + }); +}); diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts new file mode 100644 index 000000000..79a2e0387 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts @@ -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> +): 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> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : 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, + }; +} diff --git a/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.spec.ts new file mode 100644 index 000000000..0721a7ee5 --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.spec.ts @@ -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') + ); + }); +}); diff --git a/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts new file mode 100644 index 000000000..ff926124a --- /dev/null +++ b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts @@ -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(); +} diff --git a/libs/portal/stalker/protocol/tsconfig.json b/libs/portal/stalker/protocol/tsconfig.json new file mode 100644 index 000000000..f597d3e28 --- /dev/null +++ b/libs/portal/stalker/protocol/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/portal/stalker/protocol/tsconfig.lib.json b/libs/portal/stalker/protocol/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/portal/stalker/protocol/tsconfig.lib.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"] +} diff --git a/libs/portal/stalker/protocol/tsconfig.spec.json b/libs/portal/stalker/protocol/tsconfig.spec.json new file mode 100644 index 000000000..38a1e9350 --- /dev/null +++ b/libs/portal/stalker/protocol/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f1a1660ea..f16e2ea4f 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.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) diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 08214323f..37ed349fe 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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>; + stalkerSessionOpen?: ( + request: StalkerSessionOpenRequest + ) => Promise; + stalkerSessionContinue?: ( + request: StalkerSessionContinueRequest + ) => Promise; + stalkerSessionRequest?: ( + request: StalkerSessionRequest + ) => Promise; + stalkerSessionControl?: ( + request: StalkerSessionControlRequest + ) => Promise; xtreamRequest: ( payload: ElectronBridgeXtreamRequestPayload ) => Promise; diff --git a/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts b/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts new file mode 100644 index 000000000..05af841e6 --- /dev/null +++ b/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts @@ -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 = { + 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 = { + 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] + >(); + + 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 = { + challengeRef: 'opaque-credential-challenge', + response: { + kind: 'credentials', + password: 'test-only-password', + username: 'test-only-user', + }, + } satisfies StalkerSessionContinueRequest; + const response: IpcCommandResponse = { + 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 = { + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + parameters: { category: '10', page: 1 }, + requestId: 'request-id', + } satisfies StalkerSessionRequest; + const response: IpcCommandResponse = { + 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 = { + action: 'commit', + attemptRef: 'opaque-attempt', + requestId: 'request-id', + } satisfies StalkerSessionControlRequest; + const response: IpcCommandResponse = { + 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 = { + 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), + ]); +} diff --git a/libs/shared/interfaces/src/lib/ipc-command.class.ts b/libs/shared/interfaces/src/lib/ipc-command.class.ts index 504683c37..3ed86af19 100644 --- a/libs/shared/interfaces/src/lib/ipc-command.class.ts +++ b/libs/shared/interfaces/src/lib/ipc-command.class.ts @@ -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 = + IpcCommandContractMap[Command]['request']; +export type IpcCommandResponse = + IpcCommandContractMap[Command]['response']; + +export class IpcCommand { constructor( - public id: string, - public callback: (payload: any) => void + public readonly id: Command, + public readonly callback: ( + payload: IpcCommandResponse + ) => void ) { this.id = id; this.callback = callback; diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 7bd02a66b..42941fafc 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -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 diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts index c06ebb8bc..87f9e8179 100644 --- a/libs/shared/interfaces/src/lib/portal-playback.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -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; } diff --git a/libs/shared/interfaces/src/lib/stalker-session.interface.ts b/libs/shared/interfaces/src/lib/stalker-session.interface.ts new file mode 100644 index 000000000..85e05e647 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-session.interface.ts @@ -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>; + 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; diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 2c1b8011e..ef282fec5 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -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", diff --git a/tsconfig.base.json b/tsconfig.base.json index 05edde3ed..1fc707d98 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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" ],