diff --git a/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts b/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts index c706b0273..be4aa0948 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-protocol.constants.ts @@ -55,21 +55,6 @@ export const STALKER_PROFILE_RESULT_KINDS = { 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', diff --git a/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts b/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts index 3ef9edae1..e9b4e9c36 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-protocol.types.ts @@ -1,5 +1,5 @@ +import type { StalkerSessionApplicationOperation } from '@iptvnator/shared/interfaces'; import { - STALKER_APPLICATION_OPERATIONS, STALKER_CONNECTION_STAGES, STALKER_FAILURE_REASONS, STALKER_PROFILE_RESULT_KINDS, @@ -19,7 +19,7 @@ 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]; + StalkerSessionApplicationOperation; export interface StalkerNormalizedProfileReady { kind: typeof STALKER_PROFILE_RESULT_KINDS.Ready; 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 index 7b75e56ce..7d917e5da 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-request-policy.spec.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.spec.ts @@ -1,9 +1,16 @@ +import { STALKER_SESSION_APPLICATION_OPERATIONS } from '@iptvnator/shared/interfaces'; import { STALKER_APPLICATION_OPERATIONS, validateStalkerApplicationRequest, } from './stalker-request-policy'; describe('Stalker application request policy', () => { + it('uses the shared operation allowlist as its single source of truth', () => { + expect(STALKER_APPLICATION_OPERATIONS).toBe( + STALKER_SESSION_APPLICATION_OPERATIONS + ); + }); + it.each(['handshake', 'get_profile', 'do_auth'])( 'rejects the raw auth action %s', (operation) => { diff --git a/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts index d9968319c..078bf1c77 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-request-policy.ts @@ -1,10 +1,11 @@ +import { STALKER_SESSION_APPLICATION_OPERATIONS } from '@iptvnator/shared/interfaces'; import { - STALKER_APPLICATION_OPERATIONS, STALKER_MANAGED_REQUEST_HEADERS, STALKER_RESERVED_REQUEST_PARAMETERS, } from './stalker-protocol.constants'; -export { STALKER_APPLICATION_OPERATIONS }; +export const STALKER_APPLICATION_OPERATIONS = + STALKER_SESSION_APPLICATION_OPERATIONS; interface StalkerApplicationRequestInput { operation: string; 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 index 46f45ad8f..21f7e9755 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts @@ -27,6 +27,21 @@ describe('Stalker response classifier', () => { ).toMatchObject({ kind: 'ready', status: 'statusless' }); }); + it.each([ + { js: { error: 'Access denied', id: 7 } }, + { js: { blocked: true, id: 7 } }, + { js: { credentials_required: true, id: 7 } }, + { error: 'Authorization failed', js: { id: 7 } }, + ])( + 'rejects status-less profiles carrying failure indicators', + (value) => { + expect(classifyStalkerProfile(value)).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + } + ); + it.each([ { js: { id: 7, status: 3 } }, { js: { status: 'unknown' } }, @@ -98,6 +113,34 @@ describe('Stalker response classifier', () => { }); }); + it('gives recognized 403 protection evidence precedence over token text', () => { + expect( + classifyStalkerResponseFailure({ + httpStatus: 403, + rawBody: 'Cloudflare challenge', + value: { js: { error: 'Authorization failed' } }, + }) + ).toEqual({ + kind: 'failure', + reason: 'portal-protection-blocked', + }); + }); + + it.each(['Authorization failed.', 'Access denied.'])( + 'rejects the noncanonical error variant %s', + (error) => { + expect( + classifyStalkerResponseFailure({ + httpStatus: 200, + value: { js: { error } }, + }) + ).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + } + ); + it.each(['application/json', 'text/json; charset=utf-8'])( 'accepts JSON for %s', (contentType) => { diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts index 79a2e0387..13125693a 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts @@ -106,7 +106,7 @@ export function classifyStalkerProfile( if ( profile['status'] === undefined && hasRecognizedStatuslessProfileField(profile) && - typeof profile['error'] !== 'string' + !hasProfileFailureIndicator(envelope ?? {}, profile) ) { return { kind: STALKER_PROFILE_RESULT_KINDS.Ready, @@ -140,11 +140,14 @@ export function classifyStalkerResponseFailure( | { kind: 'none' } | { kind: 'token-rejected' } | StalkerClassifierFailure { - const bodyError = extractExactBodyError(input.value, input.rawBody); - if ( - input.httpStatus === 401 || - bodyError === 'Authorization failed' - ) { + const bodyError = extractBodyError(input.value, input.rawBody); + if (input.httpStatus === 403 && looksLikePortalProtection(input.rawBody)) { + return { + kind: 'failure', + reason: STALKER_FAILURE_REASONS.PortalProtectionBlocked, + }; + } + if (input.httpStatus === 401 || bodyError === 'Authorization failed') { return { kind: 'token-rejected' }; } if (bodyError === 'Access denied') { @@ -153,13 +156,7 @@ export function classifyStalkerResponseFailure( reason: STALKER_FAILURE_REASONS.AccountAccessDenied, }; } - if (input.httpStatus === 403) { - if (looksLikePortalProtection(input.rawBody)) { - return { - kind: 'failure', - reason: STALKER_FAILURE_REASONS.PortalProtectionBlocked, - }; - } + if (bodyError !== undefined || input.httpStatus === 403) { return incompatibleResponse(); } if (input.httpStatus === 429) { @@ -199,6 +196,48 @@ function hasRecognizedStatuslessProfileField( ].some((field) => profile[field] !== undefined); } +function hasProfileFailureIndicator( + envelope: Readonly>, + profile: Readonly> +): boolean { + const errorFields = ['error', 'errors', 'error_message', 'error_msg']; + const blockFields = [ + 'blocked', + 'is_blocked', + 'account_blocked', + 'disabled', + 'banned', + ]; + const credentialFields = [ + 'credentials_required', + 'credential_required', + 'login_required', + 'auth_required', + 'authentication_required', + 'need_auth', + 'requires_auth', + ]; + return [...errorFields, ...blockFields, ...credentialFields].some( + (field) => + isFailureIndicator(envelope[field]) || + isFailureIndicator(profile[field]) + ); +} + +function isFailureIndicator(value: unknown): boolean { + if (value === true) return true; + if (typeof value === 'number') return value !== 0; + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return ( + normalized !== '' && + normalized !== '0' && + normalized !== 'false' && + normalized !== 'none' && + normalized !== 'ok' + ); +} + function normalizeMediaType( contentType: string | undefined ): string | undefined | 'invalid' { @@ -256,10 +295,10 @@ function asRecord(value: unknown): Readonly> | undefined : undefined; } -function extractExactBodyError( +function extractBodyError( value: unknown, rawBody: string | undefined -): 'Access denied' | 'Authorization failed' | undefined { +): string | undefined { const envelope = asRecord(value); const js = asRecord(envelope?.['js']); const candidate = @@ -268,13 +307,7 @@ function extractExactBodyError( : 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; + return typeof candidate === 'string' ? candidate : undefined; } function looksLikePortalProtection(body: string | undefined): boolean { 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 index 0721a7ee5..4ba8a5959 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.spec.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.spec.ts @@ -91,10 +91,15 @@ describe('Stalker endpoint URL recipes', () => { it.each([ 'https://user:password@portal.test/c/', + 'https://@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', + 'https://portal.test/c/?sn=serial', + 'https://portal.test/c/?auth_second_step=1', + 'https://portal.test/c/?random=handshake-random', + 'https://portal.test/c/?cookie=session-cookie', 'ftp://portal.test/c/', ])('rejects credential-bearing or unsupported URLs', (sourceUrl) => { expect(() => normalizeStalkerSourceUrl(sourceUrl)).toThrow( diff --git a/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts index ff926124a..56c2ae6a0 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-url-recipes.ts @@ -1,17 +1,31 @@ import { STALKER_ENDPOINT_CANDIDATE_LIMIT } from './stalker-protocol.constants'; const CREDENTIAL_QUERY_KEYS = new Set([ + 'access_token', 'api_signature', + 'auth', + 'auth_second_step', 'authorization', + 'bearer', + 'cookie', + 'credentials', 'device_id', + 'device_id1', 'device_id2', 'login', 'mac', + 'not_valid_token', 'password', 'prehash', + 'random', + 'refresh_token', 'serial', + 'serial_number', + 'session', + 'session_id', 'signature', 'signature2', + 'sn', 'token', 'username', ]); @@ -33,6 +47,7 @@ export function normalizeStalkerSourceUrl(sourceUrl: string): string { } if ( + hasAuthorityUserInfo(sourceUrl) || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password || @@ -45,6 +60,23 @@ export function normalizeStalkerSourceUrl(sourceUrl: string): string { return parsed.toString(); } +function hasAuthorityUserInfo(sourceUrl: string): boolean { + const trimmed = sourceUrl.trim(); + const schemeSeparator = trimmed.indexOf('://'); + if (schemeSeparator === -1) { + return false; + } + const authorityStart = schemeSeparator + 3; + const authorityEndOffset = trimmed + .slice(authorityStart) + .search(/[/?#]/); + const authorityEnd = + authorityEndOffset === -1 + ? trimmed.length + : authorityStart + authorityEndOffset; + return trimmed.slice(authorityStart, authorityEnd).includes('@'); +} + export function deriveStalkerEndpointCandidates( sourceUrl: string ): readonly string[] { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 37ed349fe..aa4ba74d8 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -35,6 +35,7 @@ import { CatalogTitleMatch } from './catalog-title-match.interface'; import { Settings } from './settings.interface'; import { StalkerSessionConnectionOutcome, + StalkerSessionApplicationOperation, StalkerSessionContinueRequest, StalkerSessionControlOutcome, StalkerSessionControlRequest, @@ -677,9 +678,11 @@ export interface ElectronBridgeApi { stalkerSessionContinue?: ( request: StalkerSessionContinueRequest ) => Promise; - stalkerSessionRequest?: ( - request: StalkerSessionRequest - ) => Promise; + stalkerSessionRequest?: < + Operation extends StalkerSessionApplicationOperation, + >( + request: StalkerSessionRequest + ) => Promise>; stalkerSessionControl?: ( request: StalkerSessionControlRequest ) => 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 index 40374441c..9740da1aa 100644 --- a/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts +++ b/libs/shared/interfaces/src/lib/ipc-command.class.spec.ts @@ -9,6 +9,7 @@ import { type IpcCommandRequest, type IpcCommandResponse, } from './ipc-command.class'; +import type { ElectronBridgeApi } from './electron-api.interface'; import { STALKER_SESSION_APPLICATION_OPERATIONS, type StalkerSessionApplicationOperation, @@ -70,6 +71,7 @@ describe('typed IPC commands', () => { authenticatedSession: true, playbackContext: true, }, + connectionMode: 'provisional', endpoint: 'https://portal.test/server/load.php', kind: 'ready', landingUrl: 'https://portal.test/c/', @@ -81,6 +83,7 @@ describe('typed IPC commands', () => { stalkerRecipeClassifierVersion: 1, stalkerRequestRecipe: 'full-session', }, + provisionalReason: 'import', recipe: 'full-session', requestId: 'request-id', }; @@ -94,7 +97,8 @@ describe('typed IPC commands', () => { leaseRef: 'opaque-lease', operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, parameters: { category: '10', page: 1 }, - requestId: 'request-id', + // @ts-expect-error Request IDs are generated by Electron main. + requestId: 'renderer-request-id', } satisfies StalkerSessionRequest; const response: IpcCommandResponse = { kind: 'success', @@ -107,6 +111,43 @@ describe('typed IPC commands', () => { expect(response.kind).toBe('success'); }); + it('preserves operation correlation through IPC and Electron API aliases', () => { + type CatalogOperation = + typeof STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems; + const request: IpcCommandRequest< + typeof STALKER_SESSION_REQUEST, + CatalogOperation + > = { + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + parameters: { page: 1 }, + }; + const response: IpcCommandResponse< + typeof STALKER_SESSION_REQUEST, + CatalogOperation + > = { + kind: 'success', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + payload: { items: [] }, + requestId: 'main-request-id', + }; + + const assertApi = (api: ElectronBridgeApi) => { + const outcome = api.stalkerSessionRequest?.(request); + outcome?.then((value) => { + if (value.kind === 'success') { + expect(value.payload.items).toEqual([]); + // @ts-expect-error Catalog results never expose streamUrl. + void value.payload.streamUrl; + } + }); + }; + + expect(request.operation).toBe('get-ordered-list'); + expect(response.kind).toBe('success'); + expect(assertApi).toBeDefined(); + }); + it('correlates each operation with its request and sanitized result', () => { type MissingOperations = Exclude< StalkerSessionApplicationOperation, @@ -146,7 +187,8 @@ describe('typed IPC commands', () => { const request: IpcCommandRequest = { action: 'commit', attemptRef: 'opaque-attempt', - requestId: 'request-id', + // @ts-expect-error Request IDs are generated by Electron main. + requestId: 'renderer-request-id', } satisfies StalkerSessionControlRequest; const response: IpcCommandResponse = { action: 'commit', @@ -158,6 +200,82 @@ describe('typed IPC commands', () => { expect(response.kind).toBe('success'); }); + it('discriminates persisted and provisional connection descriptors', () => { + const persistedWithReason: StalkerSessionOpenRequest = { + descriptor: { + connectionMode: 'persisted-open', + macAddress: '00:1A:79:00:00:01', + playlistRef: 'playlist-ref', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + // @ts-expect-error Persisted opens never carry a provisional reason. + provisionalReason: 'edit', + sourceUrl: 'https://portal.test/c/', + }, + }; + const provisionalWithoutReason: StalkerSessionOpenRequest = { + // @ts-expect-error Provisional opens require their reason. + descriptor: { + connectionMode: 'provisional', + macAddress: '00:1A:79:00:00:01', + playlistRef: 'playlist-ref', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + sourceUrl: 'https://portal.test/c/', + }, + }; + + expect(persistedWithReason.descriptor.connectionMode).toBe( + 'persisted-open' + ); + expect(provisionalWithoutReason.descriptor.connectionMode).toBe( + 'provisional' + ); + }); + + it('keeps playback context exclusive to a create-link result', () => { + const catalogOutcome: StalkerSessionRequestOutcome = { + kind: 'success', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + payload: { items: [] }, + // @ts-expect-error Catalog success cannot carry playback context. + playbackContextRef: 'opaque-context', + requestId: 'main-request-id', + }; + + expect(catalogOutcome.kind).toBe('success'); + }); + + it('excludes ready outcomes from application request results', () => { + const requestOutcome: StalkerSessionRequestOutcome = { + attemptRef: 'opaque-attempt', + capabilities: { + authenticatedSession: true, + playbackContext: true, + }, + endpoint: 'https://portal.test/server/load.php', + // @ts-expect-error Ready belongs only to open/continue outcomes. + 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: 'main-request-id', + }; + + expect(requestOutcome.kind).toBe('ready'); + }); + it('keeps secret runtime state out of result DTO examples', () => { const outcome: IpcCommandResponse = { kind: 'failure', diff --git a/libs/shared/interfaces/src/lib/ipc-command.class.ts b/libs/shared/interfaces/src/lib/ipc-command.class.ts index 3ed86af19..4a75cef3d 100644 --- a/libs/shared/interfaces/src/lib/ipc-command.class.ts +++ b/libs/shared/interfaces/src/lib/ipc-command.class.ts @@ -10,6 +10,7 @@ import type { StalkerSessionControlOutcome, StalkerSessionControlRequest, StalkerSessionOpenRequest, + StalkerSessionApplicationOperation, StalkerSessionRequest, StalkerSessionRequestOutcome, } from './stalker-session.interface'; @@ -34,16 +35,30 @@ export interface IpcCommandContractMap { } export type IpcCommandId = keyof IpcCommandContractMap; -export type IpcCommandRequest = - IpcCommandContractMap[Command]['request']; -export type IpcCommandResponse = - IpcCommandContractMap[Command]['response']; +export type IpcCommandRequest< + Command extends IpcCommandId, + Operation extends + StalkerSessionApplicationOperation = StalkerSessionApplicationOperation, +> = Command extends typeof STALKER_SESSION_REQUEST + ? StalkerSessionRequest + : IpcCommandContractMap[Command]['request']; +export type IpcCommandResponse< + Command extends IpcCommandId, + Operation extends + StalkerSessionApplicationOperation = StalkerSessionApplicationOperation, +> = Command extends typeof STALKER_SESSION_REQUEST + ? StalkerSessionRequestOutcome + : IpcCommandContractMap[Command]['response']; -export class IpcCommand { +export class IpcCommand< + Command extends IpcCommandId, + Operation extends + StalkerSessionApplicationOperation = StalkerSessionApplicationOperation, +> { constructor( public readonly id: Command, public readonly callback: ( - payload: IpcCommandResponse + payload: IpcCommandResponse ) => void ) { this.id = id; diff --git a/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts b/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts index f1bb20989..24899d2f4 100644 --- a/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-session-operations.interface.ts @@ -27,6 +27,7 @@ export type StalkerSessionContentType = (typeof STALKER_SESSION_CONTENT_TYPES)[keyof typeof STALKER_SESSION_CONTENT_TYPES]; export type StalkerSessionEntityId = string | number; +export type StalkerPlaybackContextRef = string; export type StalkerSessionNoParameters = Readonly>; export interface StalkerSessionCategoryParameters { @@ -165,7 +166,7 @@ export interface StalkerSessionEpgResult { export interface StalkerSessionCreateLinkResult { streamUrl: string; - playbackContextRef?: string; + playbackContextRef?: StalkerPlaybackContextRef; title?: string; thumbnail?: string | null; isLive?: boolean; diff --git a/libs/shared/interfaces/src/lib/stalker-session.interface.ts b/libs/shared/interfaces/src/lib/stalker-session.interface.ts index 4ea45ae27..7d032e7e0 100644 --- a/libs/shared/interfaces/src/lib/stalker-session.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-session.interface.ts @@ -93,7 +93,6 @@ export type StalkerSessionStage = 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'; @@ -128,10 +127,8 @@ export interface StalkerSessionTransportConfiguration { timezone?: string; } -export interface StalkerSessionConnectionDescriptor { +export interface StalkerSessionConnectionDescriptorBase { playlistRef: string; - connectionMode: StalkerSessionConnectionMode; - provisionalReason?: StalkerSessionProvisionalReason; sourceUrl: string; learnedEndpointHint?: string; macAddress: string; @@ -140,6 +137,22 @@ export interface StalkerSessionConnectionDescriptor { transportConfiguration?: StalkerSessionTransportConfiguration; } +export interface StalkerSessionPersistedConnectionDescriptor + extends StalkerSessionConnectionDescriptorBase { + connectionMode: 'persisted-open'; + provisionalReason?: never; +} + +export interface StalkerSessionProvisionalConnectionDescriptor + extends StalkerSessionConnectionDescriptorBase { + connectionMode: 'provisional'; + provisionalReason: StalkerSessionProvisionalReason; +} + +export type StalkerSessionConnectionDescriptor = + | StalkerSessionPersistedConnectionDescriptor + | StalkerSessionProvisionalConnectionDescriptor; + export interface StalkerSessionOpenRequest { descriptor: StalkerSessionConnectionDescriptor; } @@ -179,29 +192,47 @@ export interface StalkerSessionPersistenceDraft { isFullStalkerPortal: boolean; } -export interface StalkerSessionFullReadyOutcome { +export interface StalkerSessionPersistedReadyState { + connectionMode: 'persisted-open'; + provisionalReason?: never; + attemptRef?: never; +} + +export interface StalkerSessionProvisionalReadyState { + connectionMode: 'provisional'; + provisionalReason: StalkerSessionProvisionalReason; + attemptRef: StalkerSessionAttemptRef; +} + +export interface StalkerSessionFullReadyOutcomeBase { kind: 'ready'; recipe: 'full-session'; requestId: string; endpoint: string; landingUrl: string; leaseRef: StalkerSessionLeaseRef; - attemptRef?: StalkerSessionAttemptRef; capabilities: StalkerSessionCapabilities; accountSummary?: StalkerSessionAccountSummary; persistenceDraft: StalkerSessionPersistenceDraft; } -export interface StalkerSessionStatelessReadyOutcome { +export type StalkerSessionFullReadyOutcome = + StalkerSessionFullReadyOutcomeBase & + (StalkerSessionPersistedReadyState | StalkerSessionProvisionalReadyState); + +export interface StalkerSessionStatelessReadyOutcomeBase { kind: 'ready'; recipe: 'stateless-mac'; requestId: string; endpoint: string; landingUrl: string; - attemptRef?: StalkerSessionAttemptRef; persistenceDraft: StalkerSessionPersistenceDraft; } +export type StalkerSessionStatelessReadyOutcome = + StalkerSessionStatelessReadyOutcomeBase & + (StalkerSessionPersistedReadyState | StalkerSessionProvisionalReadyState); + export interface StalkerSessionOriginApprovalRequiredOutcome { kind: 'origin-approval-required'; requestId: string; @@ -234,39 +265,46 @@ export type StalkerSessionConnectionOutcome = | StalkerSessionCredentialsRequiredOutcome | StalkerSessionFailureOutcome; -export type StalkerSessionRequest = { - [Operation in StalkerSessionApplicationOperation]: { +export type StalkerSessionRequest< + Operation extends + StalkerSessionApplicationOperation = StalkerSessionApplicationOperation, +> = { + [CurrentOperation in Operation]: { leaseRef: StalkerSessionLeaseRef; - operation: Operation; - parameters: StalkerSessionOperationParameters; - requestId?: string; + operation: CurrentOperation; + parameters: StalkerSessionOperationParameters; }; -}[StalkerSessionApplicationOperation]; +}[Operation]; -export type StalkerSessionRequestSuccess = { - [Operation in StalkerSessionApplicationOperation]: { +export type StalkerSessionRequestSuccess< + Operation extends + StalkerSessionApplicationOperation = StalkerSessionApplicationOperation, +> = { + [CurrentOperation in Operation]: { kind: 'success'; requestId: string; - operation: Operation; - payload: StalkerSessionOperationResult; - playbackContextRef?: StalkerPlaybackContextRef; + operation: CurrentOperation; + payload: StalkerSessionOperationResult; }; -}[StalkerSessionApplicationOperation]; +}[Operation]; -export type StalkerSessionRequestOutcome = - | StalkerSessionRequestSuccess - | StalkerSessionConnectionOutcome; +export type StalkerSessionRequestOutcome< + Operation extends + StalkerSessionApplicationOperation = StalkerSessionApplicationOperation, +> = + | StalkerSessionRequestSuccess + | StalkerSessionOriginApprovalRequiredOutcome + | StalkerSessionCredentialsRequiredOutcome + | StalkerSessionFailureOutcome; 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 =