fix(stalker): tighten session protocol boundaries

This commit is contained in:
4gray committed 2026-07-27 03:37:42 +02:00
1 parent f8a544d287
commit 92238ecbbb
13 files changed
+359 -78

No files matched your search

@@ -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',
@@ -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;
@@ -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) => {
@@ -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;
@@ -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: '<html>Cloudflare challenge</html>',
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) => {
@@ -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<Record<string, unknown>>,
profile: Readonly<Record<string, unknown>>
): 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<Record<string, unknown>> | 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 {
@@ -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(
@@ -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[] {
@@ -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<StalkerSessionConnectionOutcome>;
stalkerSessionRequest?: (
request: StalkerSessionRequest
) => Promise<StalkerSessionRequestOutcome>;
stalkerSessionRequest?: <
Operation extends StalkerSessionApplicationOperation,
>(
request: StalkerSessionRequest<Operation>
) => Promise<StalkerSessionRequestOutcome<Operation>>;
stalkerSessionControl?: (
request: StalkerSessionControlRequest
) => Promise<StalkerSessionControlOutcome>;
@@ -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<typeof STALKER_SESSION_REQUEST> = {
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<typeof STALKER_SESSION_CONTROL> = {
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<typeof STALKER_SESSION_CONTROL> = {
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<typeof STALKER_SESSION_OPEN> = {
kind: 'failure',
@@ -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<Command extends IpcCommandId> =
IpcCommandContractMap[Command]['request'];
export type IpcCommandResponse<Command extends IpcCommandId> =
IpcCommandContractMap[Command]['response'];
export type IpcCommandRequest<
Command extends IpcCommandId,
Operation extends
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
> = Command extends typeof STALKER_SESSION_REQUEST
? StalkerSessionRequest<Operation>
: IpcCommandContractMap[Command]['request'];
export type IpcCommandResponse<
Command extends IpcCommandId,
Operation extends
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
> = Command extends typeof STALKER_SESSION_REQUEST
? StalkerSessionRequestOutcome<Operation>
: IpcCommandContractMap[Command]['response'];
export class IpcCommand<Command extends IpcCommandId> {
export class IpcCommand<
Command extends IpcCommandId,
Operation extends
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
> {
constructor(
public readonly id: Command,
public readonly callback: (
payload: IpcCommandResponse<Command>
payload: IpcCommandResponse<Command, Operation>
) => void
) {
this.id = id;
@@ -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<Record<string, never>>;
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;
@@ -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<Operation>;
requestId?: string;
operation: CurrentOperation;
parameters: StalkerSessionOperationParameters<CurrentOperation>;
};
}[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<Operation>;
playbackContextRef?: StalkerPlaybackContextRef;
operation: CurrentOperation;
payload: StalkerSessionOperationResult<CurrentOperation>;
};
}[StalkerSessionApplicationOperation];
}[Operation];
export type StalkerSessionRequestOutcome =
| StalkerSessionRequestSuccess
| StalkerSessionConnectionOutcome;
export type StalkerSessionRequestOutcome<
Operation extends
StalkerSessionApplicationOperation = StalkerSessionApplicationOperation,
> =
| StalkerSessionRequestSuccess<Operation>
| 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 =