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

@@ -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 =