mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(stalker): expose typed session IPC
This commit is contained in:
1 parent
854e922c1e
commit
b76e65c2bf
5 files changed
+911
-3
No files matched your search
@@ -2,9 +2,9 @@ import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
|
||||
|
||||
type ElectronBridgeMethodName = Extract<
|
||||
{
|
||||
[K in keyof ElectronBridgeApi]: ElectronBridgeApi[K] extends (
|
||||
...args: never[]
|
||||
) => unknown
|
||||
[K in keyof ElectronBridgeApi]: NonNullable<
|
||||
ElectronBridgeApi[K]
|
||||
> extends (...args: never[]) => unknown
|
||||
? K
|
||||
: never;
|
||||
}[keyof ElectronBridgeApi],
|
||||
@@ -51,6 +51,105 @@ const tmdbCacheEntry = {
|
||||
payload: '{"id":603}',
|
||||
};
|
||||
|
||||
export const stalkerSessionPreloadCases: PreloadInvokeCase[] = [
|
||||
{
|
||||
method: 'stalkerSessionOpen',
|
||||
args: [
|
||||
{
|
||||
descriptor: {
|
||||
connectionMode: 'provisional',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
playlistRef: 'playlist-1',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
sourceUrl: 'https://portal.example/c/',
|
||||
},
|
||||
},
|
||||
],
|
||||
channel: 'STALKER_SESSION_OPEN',
|
||||
forwardedArgs: [
|
||||
{
|
||||
descriptor: {
|
||||
connectionMode: 'provisional',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
playlistRef: 'playlist-1',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
sourceUrl: 'https://portal.example/c/',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'stalkerSessionContinue',
|
||||
args: [
|
||||
{
|
||||
challengeRef: 'opaque-challenge',
|
||||
response: {
|
||||
approved: true,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
},
|
||||
],
|
||||
channel: 'STALKER_SESSION_CONTINUE',
|
||||
forwardedArgs: [
|
||||
{
|
||||
challengeRef: 'opaque-challenge',
|
||||
response: {
|
||||
approved: true,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'stalkerSessionRequest',
|
||||
args: [
|
||||
{
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: 'get-ordered-list',
|
||||
parameters: {
|
||||
category: '10',
|
||||
page: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
channel: 'STALKER_SESSION_REQUEST',
|
||||
forwardedArgs: [
|
||||
{
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: 'get-ordered-list',
|
||||
parameters: {
|
||||
category: '10',
|
||||
page: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'stalkerSessionControl',
|
||||
args: [
|
||||
{
|
||||
action: 'activate',
|
||||
leaseRef: 'opaque-lease',
|
||||
},
|
||||
],
|
||||
channel: 'STALKER_SESSION_CONTROL',
|
||||
forwardedArgs: [
|
||||
{
|
||||
action: 'activate',
|
||||
leaseRef: 'opaque-lease',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
{
|
||||
method: 'dbCreatePlaylist',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
dbPreloadCases,
|
||||
epgPreloadCases,
|
||||
operationId,
|
||||
stalkerSessionPreloadCases,
|
||||
} from './main.preload.spec-data';
|
||||
|
||||
type ExposedElectronApi = ElectronBridgeApi &
|
||||
@@ -121,6 +122,49 @@ describe('main preload DB IPC contract', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each(stalkerSessionPreloadCases)(
|
||||
'$method invokes $channel with the expected arguments',
|
||||
async ({ method, args, channel, forwardedArgs }) => {
|
||||
const api = getExposedApi();
|
||||
|
||||
await callExposedApiMethod(api, method, args);
|
||||
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
|
||||
channel,
|
||||
...forwardedArgs
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps Stalker session IPC narrow while preserving the legacy request bridge', async () => {
|
||||
const api = getExposedApi();
|
||||
const legacyPayload = {
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
params: {
|
||||
action: 'get_profile',
|
||||
},
|
||||
url: 'https://portal.example/server/load.php',
|
||||
};
|
||||
|
||||
expect(
|
||||
Object.keys(api)
|
||||
.filter((method) => method.startsWith('stalkerSession'))
|
||||
.sort()
|
||||
).toEqual([
|
||||
'stalkerSessionContinue',
|
||||
'stalkerSessionControl',
|
||||
'stalkerSessionOpen',
|
||||
'stalkerSessionRequest',
|
||||
]);
|
||||
|
||||
await api.stalkerRequest(legacyPayload);
|
||||
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
|
||||
'STALKER_REQUEST',
|
||||
legacyPayload
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards scoped user-agent override arguments', async () => {
|
||||
const api = getExposedApi();
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
APP_UPDATE_GET_STATUS,
|
||||
APP_UPDATE_INSTALL,
|
||||
APP_UPDATE_STATUS_CHANGED,
|
||||
STALKER_SESSION_CONTINUE,
|
||||
STALKER_SESSION_CONTROL,
|
||||
STALKER_SESSION_OPEN,
|
||||
STALKER_SESSION_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces/ipc-commands';
|
||||
import {
|
||||
attachEmbeddedMpvFrameView,
|
||||
@@ -551,6 +555,14 @@ const electronApi: ElectronBridgeApi = {
|
||||
serialNumber?: string;
|
||||
requestId?: string;
|
||||
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
|
||||
stalkerSessionOpen: (request) =>
|
||||
ipcRenderer.invoke(STALKER_SESSION_OPEN, request),
|
||||
stalkerSessionContinue: (request) =>
|
||||
ipcRenderer.invoke(STALKER_SESSION_CONTINUE, request),
|
||||
stalkerSessionRequest: (request) =>
|
||||
ipcRenderer.invoke(STALKER_SESSION_REQUEST, request),
|
||||
stalkerSessionControl: (request) =>
|
||||
ipcRenderer.invoke(STALKER_SESSION_CONTROL, request),
|
||||
xtreamRequest: (payload: {
|
||||
url: string;
|
||||
params: Record<string, string>;
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import {
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS,
|
||||
STALKER_SESSION_CONTINUE,
|
||||
STALKER_SESSION_CONTROL,
|
||||
STALKER_SESSION_OPEN,
|
||||
STALKER_SESSION_REQUEST,
|
||||
type StalkerSessionConnectionOutcome,
|
||||
type StalkerSessionControlOutcome,
|
||||
type StalkerSessionRequestOutcome,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { StalkerSessionManagerPort } from './stalker-session.events';
|
||||
|
||||
const mockHandlers = new Map<
|
||||
string,
|
||||
(event: MockInvokeEvent, request: unknown) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(
|
||||
(
|
||||
channel: string,
|
||||
handler: (
|
||||
event: MockInvokeEvent,
|
||||
request: unknown
|
||||
) => Promise<unknown>
|
||||
) => {
|
||||
mockHandlers.set(channel, handler);
|
||||
}
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
type MockSender = {
|
||||
id: number;
|
||||
once: jest.Mock;
|
||||
};
|
||||
|
||||
type MockInvokeEvent = {
|
||||
sender: MockSender;
|
||||
};
|
||||
|
||||
const OPEN_REQUEST = {
|
||||
descriptor: {
|
||||
connectionMode: 'provisional',
|
||||
macAddress: '00-1a-79-00-00-01',
|
||||
playlistRef: 'playlist-1',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
provisionalReason: 'import',
|
||||
sourceUrl: 'https://portal.example/c/#fragment',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const CONNECTION_OUTCOME: StalkerSessionConnectionOutcome = {
|
||||
kind: 'failure',
|
||||
reason: 'request-timeout',
|
||||
requestId: 'request-open',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
};
|
||||
|
||||
function createSender(id = 42): MockSender {
|
||||
return {
|
||||
id,
|
||||
once: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createManager(): StalkerSessionManagerPort {
|
||||
return {
|
||||
cleanupSender: jest.fn(),
|
||||
continue: jest.fn().mockResolvedValue(CONNECTION_OUTCOME),
|
||||
control: jest.fn().mockResolvedValue({
|
||||
action: 'activate',
|
||||
kind: 'success',
|
||||
requestId: 'request-control',
|
||||
} satisfies StalkerSessionControlOutcome),
|
||||
open: jest.fn().mockResolvedValue(CONNECTION_OUTCOME),
|
||||
request: jest.fn().mockResolvedValue({
|
||||
kind: 'success',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
payload: {
|
||||
items: [],
|
||||
},
|
||||
requestId: 'request-catalog',
|
||||
} satisfies StalkerSessionRequestOutcome),
|
||||
};
|
||||
}
|
||||
|
||||
function invoke(
|
||||
channel: string,
|
||||
sender: MockSender,
|
||||
request: unknown
|
||||
): Promise<unknown> {
|
||||
const handler = mockHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Missing test IPC handler: ${channel}`);
|
||||
}
|
||||
return handler({ sender }, request);
|
||||
}
|
||||
|
||||
describe('StalkerSessionEvents', () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockHandlers.clear();
|
||||
});
|
||||
|
||||
it('registers exactly the four typed channels and binds normalized input to the sender', async () => {
|
||||
const manager = createManager();
|
||||
const sender = createSender();
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
|
||||
expect([...mockHandlers.keys()].sort()).toEqual(
|
||||
[
|
||||
STALKER_SESSION_OPEN,
|
||||
STALKER_SESSION_CONTINUE,
|
||||
STALKER_SESSION_REQUEST,
|
||||
STALKER_SESSION_CONTROL,
|
||||
].sort()
|
||||
);
|
||||
|
||||
await expect(
|
||||
invoke(STALKER_SESSION_OPEN, sender, OPEN_REQUEST)
|
||||
).resolves.toBe(CONNECTION_OUTCOME);
|
||||
expect(manager.open).toHaveBeenCalledWith(42, {
|
||||
descriptor: {
|
||||
...OPEN_REQUEST.descriptor,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
sourceUrl: 'https://portal.example/c/',
|
||||
},
|
||||
});
|
||||
expect(sender.once).toHaveBeenCalledTimes(1);
|
||||
expect(sender.once).toHaveBeenCalledWith(
|
||||
'destroyed',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards continue, application request, and control through their typed manager methods', async () => {
|
||||
const manager = createManager();
|
||||
const sender = createSender(73);
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
const continueRequest = {
|
||||
challengeRef: 'opaque-challenge',
|
||||
response: {
|
||||
approved: true,
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
} as const;
|
||||
const operationRequest = {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: {
|
||||
category: '10',
|
||||
page: 1,
|
||||
},
|
||||
} as const;
|
||||
const controlRequest = {
|
||||
action: 'activate',
|
||||
leaseRef: 'opaque-lease',
|
||||
} as const;
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
|
||||
await invoke(STALKER_SESSION_CONTINUE, sender, continueRequest);
|
||||
await invoke(STALKER_SESSION_REQUEST, sender, operationRequest);
|
||||
await invoke(STALKER_SESSION_CONTROL, sender, controlRequest);
|
||||
|
||||
expect(manager.continue).toHaveBeenCalledWith(73, continueRequest);
|
||||
expect(manager.request).toHaveBeenCalledWith(73, operationRequest);
|
||||
expect(manager.control).toHaveBeenCalledWith(73, controlRequest);
|
||||
expect(sender.once).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
channel: STALKER_SESSION_OPEN,
|
||||
request: {
|
||||
...OPEN_REQUEST,
|
||||
token: 'raw-top-level-token',
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_OPEN,
|
||||
request: {
|
||||
descriptor: {
|
||||
...OPEN_REQUEST.descriptor,
|
||||
password: 'raw-descriptor-password',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_CONTINUE,
|
||||
request: {
|
||||
challengeRef: 'opaque-challenge',
|
||||
response: {
|
||||
approved: 'yes',
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_CONTINUE,
|
||||
request: {
|
||||
challengeRef: 'opaque-challenge',
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: 'allowed-challenge-password',
|
||||
token: 'raw-continuation-token',
|
||||
username: 'allowed-challenge-user',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_REQUEST,
|
||||
request: {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: 'handshake',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_REQUEST,
|
||||
request: {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: {
|
||||
token: 'raw-parameter-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_REQUEST,
|
||||
request: {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: {
|
||||
query: {
|
||||
credentials: {
|
||||
password: 'raw-nested-secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_REQUEST,
|
||||
request: {
|
||||
headers: {
|
||||
Authorization: 'Bearer raw-authorization',
|
||||
},
|
||||
leaseRef: 'opaque-lease',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems,
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_CONTROL,
|
||||
request: {
|
||||
action: 'commit',
|
||||
leaseRef: 'wrong-reference-kind',
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: STALKER_SESSION_CONTROL,
|
||||
request: {
|
||||
action: 'delete-everything',
|
||||
leaseRef: 'opaque-lease',
|
||||
},
|
||||
},
|
||||
])(
|
||||
'rejects malformed or secret-bearing $channel input before the manager',
|
||||
async ({ channel, request }) => {
|
||||
const manager = createManager();
|
||||
const sender = createSender();
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
|
||||
const result = invoke(channel, sender, request);
|
||||
|
||||
await expect(result).rejects.toThrow(
|
||||
'stalker-session-invalid-request'
|
||||
);
|
||||
await result.catch((error: unknown) => {
|
||||
expect(String(error)).not.toMatch(
|
||||
/raw-(?:top-level-token|descriptor-password|continuation-token|parameter-token|authorization|nested-secret)/
|
||||
);
|
||||
});
|
||||
expect(manager.open).not.toHaveBeenCalled();
|
||||
expect(manager.continue).not.toHaveBeenCalled();
|
||||
expect(manager.request).not.toHaveBeenCalled();
|
||||
expect(manager.control).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['get_profile', 'do_auth'])(
|
||||
'rejects raw full-auth operation %s',
|
||||
async (operation) => {
|
||||
const manager = createManager();
|
||||
const sender = createSender();
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
|
||||
await expect(
|
||||
invoke(STALKER_SESSION_REQUEST, sender, {
|
||||
leaseRef: 'opaque-lease',
|
||||
operation,
|
||||
parameters: {},
|
||||
})
|
||||
).rejects.toThrow('stalker-session-invalid-request');
|
||||
expect(manager.request).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves sender binding for foreign or expired refs and returns the manager outcome unchanged', async () => {
|
||||
const manager = createManager();
|
||||
const sender = createSender(91);
|
||||
const failure: StalkerSessionRequestOutcome = {
|
||||
kind: 'failure',
|
||||
reason: 'invalid-identity-input',
|
||||
requestId: 'manager-owned-request-id',
|
||||
retryable: false,
|
||||
stage: 'ready',
|
||||
};
|
||||
(manager.request as jest.Mock).mockResolvedValueOnce(failure);
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
const request = {
|
||||
leaseRef: 'foreign-or-expired-ref',
|
||||
operation: STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo,
|
||||
parameters: {},
|
||||
} as const;
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
|
||||
await expect(
|
||||
invoke(STALKER_SESSION_REQUEST, sender, request)
|
||||
).resolves.toBe(failure);
|
||||
expect(manager.request).toHaveBeenCalledWith(91, request);
|
||||
});
|
||||
|
||||
it('attaches one destroyed listener per sender and cleans that sender exactly once', async () => {
|
||||
const manager = createManager();
|
||||
const sender = createSender(64);
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
await invoke(STALKER_SESSION_OPEN, sender, OPEN_REQUEST);
|
||||
await invoke(STALKER_SESSION_CONTROL, sender, {
|
||||
action: 'activate',
|
||||
leaseRef: 'opaque-lease',
|
||||
});
|
||||
|
||||
expect(sender.once).toHaveBeenCalledTimes(1);
|
||||
const destroyed = sender.once.mock.calls[0][1] as () => void;
|
||||
|
||||
destroyed();
|
||||
destroyed();
|
||||
|
||||
expect(manager.cleanupSender).toHaveBeenCalledTimes(1);
|
||||
expect(manager.cleanupSender).toHaveBeenCalledWith(64);
|
||||
});
|
||||
|
||||
it('does not expose manager error details to the renderer', async () => {
|
||||
const manager = createManager();
|
||||
const sender = createSender();
|
||||
const secret = 'raw-manager-token';
|
||||
(manager.open as jest.Mock).mockRejectedValueOnce(
|
||||
new Error(`portal failed with ${secret}`)
|
||||
);
|
||||
const { default: StalkerSessionEvents } =
|
||||
await import('./stalker-session.events');
|
||||
|
||||
StalkerSessionEvents.bootstrapStalkerSessionEvents(manager);
|
||||
|
||||
const result = invoke(STALKER_SESSION_OPEN, sender, OPEN_REQUEST);
|
||||
|
||||
await expect(result).rejects.toThrow(
|
||||
'stalker-session-operation-failed'
|
||||
);
|
||||
await result.catch((error: unknown) => {
|
||||
expect(String(error)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
STALKER_MANAGED_REQUEST_HEADERS,
|
||||
STALKER_RESERVED_REQUEST_PARAMETERS,
|
||||
validateStalkerApplicationRequest,
|
||||
} from '@iptvnator/portal/stalker/protocol';
|
||||
import {
|
||||
STALKER_SESSION_CONTINUE,
|
||||
STALKER_SESSION_CONTROL,
|
||||
STALKER_SESSION_OPEN,
|
||||
STALKER_SESSION_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
StalkerSessionApplicationOperation,
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionContinueRequest,
|
||||
StalkerSessionControlOutcome,
|
||||
StalkerSessionControlRequest,
|
||||
StalkerSessionOpenRequest,
|
||||
StalkerSessionRequest,
|
||||
StalkerSessionRequestOutcome,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { validateStalkerRuntimeInput } from '../services/stalker-session/stalker-runtime-validation';
|
||||
|
||||
const INVALID_REQUEST_CODE = 'stalker-session-invalid-request';
|
||||
const OPERATION_FAILED_CODE = 'stalker-session-operation-failed';
|
||||
const REFERENCE_MAX_BYTES = 512;
|
||||
const CREDENTIAL_MAX_BYTES = 4096;
|
||||
const PARAMETER_NODE_LIMIT = 256;
|
||||
const FORBIDDEN_PARAMETER_KEYS = new Set<string>([
|
||||
...STALKER_MANAGED_REQUEST_HEADERS,
|
||||
...STALKER_RESERVED_REQUEST_PARAMETERS,
|
||||
'credential',
|
||||
'credentials',
|
||||
'headers',
|
||||
]);
|
||||
|
||||
type StalkerSessionSender = Pick<Electron.WebContents, 'id' | 'once'>;
|
||||
type StalkerSessionInvokeEvent = {
|
||||
sender: StalkerSessionSender;
|
||||
};
|
||||
|
||||
export interface StalkerSessionManagerPort {
|
||||
open(
|
||||
senderId: number,
|
||||
request: StalkerSessionOpenRequest
|
||||
): Promise<StalkerSessionConnectionOutcome>;
|
||||
continue(
|
||||
senderId: number,
|
||||
request: StalkerSessionContinueRequest
|
||||
): Promise<StalkerSessionConnectionOutcome>;
|
||||
request(
|
||||
senderId: number,
|
||||
request: StalkerSessionRequest
|
||||
): Promise<StalkerSessionRequestOutcome>;
|
||||
control(
|
||||
senderId: number,
|
||||
request: StalkerSessionControlRequest
|
||||
): Promise<StalkerSessionControlOutcome>;
|
||||
cleanupSender(senderId: number): void | Promise<void>;
|
||||
}
|
||||
|
||||
class StalkerSessionIpcError extends Error {
|
||||
constructor(
|
||||
code: typeof INVALID_REQUEST_CODE | typeof OPERATION_FAILED_CODE
|
||||
) {
|
||||
super(code);
|
||||
this.name = 'StalkerSessionIpcError';
|
||||
}
|
||||
}
|
||||
|
||||
export default class StalkerSessionEvents {
|
||||
static bootstrapStalkerSessionEvents(
|
||||
manager: StalkerSessionManagerPort
|
||||
): Electron.IpcMain {
|
||||
const cleanupAttached = new WeakSet<StalkerSessionSender>();
|
||||
|
||||
const bindSender = (event: StalkerSessionInvokeEvent): number => {
|
||||
const sender = event.sender;
|
||||
if (
|
||||
!sender ||
|
||||
!Number.isSafeInteger(sender.id) ||
|
||||
sender.id < 0 ||
|
||||
typeof sender.once !== 'function'
|
||||
) {
|
||||
invalidRequest();
|
||||
}
|
||||
|
||||
if (!cleanupAttached.has(sender)) {
|
||||
cleanupAttached.add(sender);
|
||||
let cleaned = false;
|
||||
const senderId = sender.id;
|
||||
sender.once('destroyed', () => {
|
||||
if (cleaned) {
|
||||
return;
|
||||
}
|
||||
cleaned = true;
|
||||
void Promise.resolve(manager.cleanupSender(senderId)).catch(
|
||||
() => undefined
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return sender.id;
|
||||
};
|
||||
|
||||
ipcMain.handle(STALKER_SESSION_OPEN, async (event, input: unknown) => {
|
||||
const senderId = bindSender(event);
|
||||
return callManager(() =>
|
||||
manager.open(senderId, validateOpenRequest(input))
|
||||
);
|
||||
});
|
||||
ipcMain.handle(
|
||||
STALKER_SESSION_CONTINUE,
|
||||
async (event, input: unknown) => {
|
||||
const senderId = bindSender(event);
|
||||
return callManager(() =>
|
||||
manager.continue(senderId, validateContinueRequest(input))
|
||||
);
|
||||
}
|
||||
);
|
||||
ipcMain.handle(
|
||||
STALKER_SESSION_REQUEST,
|
||||
async (event, input: unknown) => {
|
||||
const senderId = bindSender(event);
|
||||
return callManager(() =>
|
||||
manager.request(senderId, validateApplicationRequest(input))
|
||||
);
|
||||
}
|
||||
);
|
||||
ipcMain.handle(
|
||||
STALKER_SESSION_CONTROL,
|
||||
async (event, input: unknown) => {
|
||||
const senderId = bindSender(event);
|
||||
return callManager(() =>
|
||||
manager.control(senderId, validateControlRequest(input))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
async function callManager<Result>(
|
||||
operation: () => Promise<Result>
|
||||
): Promise<Result> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof StalkerSessionIpcError) {
|
||||
throw error;
|
||||
}
|
||||
throw new StalkerSessionIpcError(OPERATION_FAILED_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOpenRequest(input: unknown): StalkerSessionOpenRequest {
|
||||
const request = exactRecord(input, ['descriptor']);
|
||||
try {
|
||||
return {
|
||||
descriptor: validateStalkerRuntimeInput(request['descriptor'])
|
||||
.descriptor,
|
||||
};
|
||||
} catch {
|
||||
return invalidRequest();
|
||||
}
|
||||
}
|
||||
|
||||
function validateContinueRequest(
|
||||
input: unknown
|
||||
): StalkerSessionContinueRequest {
|
||||
const request = exactRecord(input, ['challengeRef', 'response']);
|
||||
const challengeRef = opaqueRef(request['challengeRef']);
|
||||
const response = request['response'];
|
||||
|
||||
if (!isRecord(response) || typeof response['kind'] !== 'string') {
|
||||
return invalidRequest();
|
||||
}
|
||||
|
||||
if (response['kind'] === 'origin-approval') {
|
||||
const approval = exactRecord(response, ['approved', 'kind']);
|
||||
if (typeof approval['approved'] !== 'boolean') {
|
||||
return invalidRequest();
|
||||
}
|
||||
return {
|
||||
challengeRef,
|
||||
response: {
|
||||
approved: approval['approved'],
|
||||
kind: 'origin-approval',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (response['kind'] === 'credentials') {
|
||||
const credentials = exactRecord(response, [
|
||||
'kind',
|
||||
'password',
|
||||
'username',
|
||||
]);
|
||||
if (
|
||||
!boundedString(credentials['username'], CREDENTIAL_MAX_BYTES) ||
|
||||
!boundedString(credentials['password'], CREDENTIAL_MAX_BYTES)
|
||||
) {
|
||||
return invalidRequest();
|
||||
}
|
||||
return {
|
||||
challengeRef,
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: credentials['password'],
|
||||
username: credentials['username'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return invalidRequest();
|
||||
}
|
||||
|
||||
function validateApplicationRequest(input: unknown): StalkerSessionRequest {
|
||||
const request = exactRecord(input, ['leaseRef', 'operation', 'parameters']);
|
||||
const operation = request['operation'];
|
||||
const parameters = request['parameters'];
|
||||
|
||||
if (
|
||||
typeof operation !== 'string' ||
|
||||
!isRecord(parameters) ||
|
||||
containsForbiddenParameter(parameters) ||
|
||||
validateStalkerApplicationRequest({
|
||||
operation,
|
||||
parameters,
|
||||
}).kind !== 'accepted'
|
||||
) {
|
||||
return invalidRequest();
|
||||
}
|
||||
|
||||
const typedOperation = operation as StalkerSessionApplicationOperation;
|
||||
return {
|
||||
leaseRef: opaqueRef(request['leaseRef']),
|
||||
operation: typedOperation,
|
||||
parameters,
|
||||
} as StalkerSessionRequest;
|
||||
}
|
||||
|
||||
function validateControlRequest(input: unknown): StalkerSessionControlRequest {
|
||||
if (!isRecord(input) || typeof input['action'] !== 'string') {
|
||||
return invalidRequest();
|
||||
}
|
||||
|
||||
switch (input['action']) {
|
||||
case 'activate':
|
||||
case 'deactivate':
|
||||
case 'close':
|
||||
case 'force-redetect': {
|
||||
const request = exactRecord(input, ['action', 'leaseRef']);
|
||||
return {
|
||||
action: input['action'],
|
||||
leaseRef: opaqueRef(request['leaseRef']),
|
||||
};
|
||||
}
|
||||
case 'commit':
|
||||
case 'discard': {
|
||||
const request = exactRecord(input, ['action', 'attemptRef']);
|
||||
return {
|
||||
action: input['action'],
|
||||
attemptRef: opaqueRef(request['attemptRef']),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return invalidRequest();
|
||||
}
|
||||
}
|
||||
|
||||
function containsForbiddenParameter(value: unknown): boolean {
|
||||
const pending = [value];
|
||||
const visited = new WeakSet<object>();
|
||||
let nodes = 0;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (typeof current !== 'object' || current === null) {
|
||||
continue;
|
||||
}
|
||||
if (visited.has(current)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(current);
|
||||
|
||||
const entries = Object.entries(current);
|
||||
nodes += entries.length;
|
||||
if (nodes > PARAMETER_NODE_LIMIT) {
|
||||
return true;
|
||||
}
|
||||
for (const [key, child] of entries) {
|
||||
if (FORBIDDEN_PARAMETER_KEYS.has(key.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
input: unknown,
|
||||
expectedKeys: readonly string[]
|
||||
): Readonly<Record<string, unknown>> {
|
||||
if (!isRecord(input)) {
|
||||
return invalidRequest();
|
||||
}
|
||||
const keys = Object.keys(input);
|
||||
if (
|
||||
keys.length !== expectedKeys.length ||
|
||||
expectedKeys.some(
|
||||
(key) => !Object.prototype.hasOwnProperty.call(input, key)
|
||||
) ||
|
||||
keys.some((key) => !expectedKeys.includes(key))
|
||||
) {
|
||||
return invalidRequest();
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function opaqueRef(value: unknown): string {
|
||||
if (!nonEmptyBoundedString(value, REFERENCE_MAX_BYTES)) {
|
||||
return invalidRequest();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, maxBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Buffer.byteLength(value, 'utf8') <= maxBytes
|
||||
);
|
||||
}
|
||||
|
||||
function nonEmptyBoundedString(
|
||||
value: unknown,
|
||||
maxBytes: number
|
||||
): value is string {
|
||||
return boundedString(value, maxBytes) && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function invalidRequest(): never {
|
||||
throw new StalkerSessionIpcError(INVALID_REQUEST_CODE);
|
||||
}
|
||||
Reference in new issue
Block a user