diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts new file mode 100644 index 000000000..0b14c2286 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.boundary.spec.ts @@ -0,0 +1,371 @@ +import { createStalkerIdentityProfile } from '@iptvnator/portal/stalker/protocol'; +import { + STALKER_SESSION_APPLICATION_OPERATIONS, + type StalkerSessionConnectionDescriptor, +} from '@iptvnator/shared/interfaces'; +import type { + StalkerAuthenticatedRequestOutcome, + StalkerAuthOutcome, +} from './stalker-auth-session'; +import type { StalkerEndpointFullSessionOutcome } from './stalker-endpoint-resolver'; +import { + StalkerSessionManager, + type StalkerSessionAuthLike, +} from './stalker-session-manager'; + +const SECRET_VALUES = [ + 'boundary-token-secret', + 'boundary-random-secret', + 'boundary-cookie-secret', + 'boundary-username-secret', + 'boundary-password-secret', + 'boundary-serial-secret', + 'boundary-device-secret', + 'boundary-signature-secret', +] as const; + +class BoundaryAuth implements StalkerSessionAuthLike { + readonly #outcomes: StalkerAuthOutcome[]; + readonly #requestOutcomes: StalkerAuthenticatedRequestOutcome[]; + + constructor( + outcomes: StalkerAuthOutcome[], + requestOutcomes: StalkerAuthenticatedRequestOutcome[] = [] + ) { + this.#outcomes = [...outcomes]; + this.#requestOutcomes = [...requestOutcomes]; + } + + getEndpoint(): string { + return 'https://portal.boundary/server/load.php'; + } + + getPrincipalKey(): string { + return SECRET_VALUES[3]; + } + + hasAcceptedCredentials(): boolean { + return true; + } + + async preparePlayback(): Promise<{ + headers: Readonly>; + streamUrl: string; + }> { + return { + headers: { + Authorization: `Bearer ${SECRET_VALUES[0]}`, + Cookie: `sid=${SECRET_VALUES[2]}`, + }, + streamUrl: 'https://media.boundary/movie.ts', + }; + } + + async request(): Promise { + return ( + this.#requestOutcomes.shift() ?? { + kind: 'success', + value: { + js: { + cookie: SECRET_VALUES[2], + random: SECRET_VALUES[1], + token: SECRET_VALUES[0], + }, + }, + } + ); + } + + async start(): Promise { + return ( + this.#outcomes.shift() ?? { + kind: 'ready', + } + ); + } + + async submitCredentials(): Promise { + return ( + this.#outcomes.shift() ?? { + kind: 'ready', + } + ); + } +} + +function descriptor( + connectionMode: 'persisted-open' | 'provisional' = 'persisted-open' +): StalkerSessionConnectionDescriptor { + const base = { + identityOverrides: { + deviceId1: SECRET_VALUES[6], + serialNumber: SECRET_VALUES[5], + signature1: SECRET_VALUES[7], + }, + macAddress: '00:1A:79:01:02:03', + playlistRef: 'boundary-playlist', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1' as const, + version: 1 as const, + }, + sourceUrl: 'https://portal.boundary/c/', + }; + return connectionMode === 'provisional' + ? { + ...base, + connectionMode, + provisionalReason: 'edit', + } + : { ...base, connectionMode }; +} + +function resolved(): StalkerEndpointFullSessionOutcome { + return { + cookieJar: { + sessionCookie: SECRET_VALUES[2], + } as never, + endpoint: 'https://portal.boundary/server/load.php', + handshakeRandom: SECRET_VALUES[1], + identity: createStalkerIdentityProfile({ + deviceId1: SECRET_VALUES[6], + macAddress: '00:1A:79:01:02:03', + serialNumber: SECRET_VALUES[5], + signature1: SECRET_VALUES[7], + }), + kind: 'full-session', + landingUrl: 'https://portal.boundary/c/', + profile: { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + profileEnvelope: { + js: { + random: SECRET_VALUES[1], + token: SECRET_VALUES[0], + }, + }, + token: SECRET_VALUES[0], + }; +} + +function assertNoPrivateMaterial(value: unknown): void { + const visit = (current: unknown): void => { + if (typeof current === 'string') { + for (const secret of SECRET_VALUES) { + expect(current).not.toContain(secret); + } + return; + } + if (Array.isArray(current)) { + current.forEach(visit); + return; + } + if (typeof current !== 'object' || current === null) { + return; + } + for (const [key, nested] of Object.entries(current)) { + expect(key).not.toMatch( + /^(?:authorization|cookie|token|random|password|username|serial(?:number)?|deviceid[12]?|signature[12]?|prehash|apisignature)$/i + ); + visit(nested); + } + }; + visit(value); +} + +describe('StalkerSessionManager secret boundary', () => { + it('recursively keeps resolver, auth, identity, credential, and playback secrets out of every public outcome', async () => { + const auth = new BoundaryAuth( + [ + { + attemptNumber: 1, + kind: 'credentials-required', + }, + { + accountSummary: { + accountBalance: SECRET_VALUES[6], + expiresAt: '2030-01-01', + name: SECRET_VALUES[3], + status: 'active', + tariffPlan: SECRET_VALUES[5], + }, + kind: 'ready', + }, + ], + [ + { + kind: 'success', + value: { + js: { + cookie: SECRET_VALUES[2], + random: SECRET_VALUES[1], + token: SECRET_VALUES[0], + }, + }, + }, + { + kind: 'success', + value: { + js: { + cmd: '/movie.ts', + cookie: SECRET_VALUES[2], + token: SECRET_VALUES[0], + }, + }, + }, + ] + ); + let reference = 0; + const playbackRegistrations: unknown[] = []; + const manager = new StalkerSessionManager({ + createAuthSession: () => auth, + createRef: (kind) => `${kind}-boundary-${reference++}`, + mapRawOperation: (operation) => { + if ( + operation === + STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink + ) { + return { + kind: 'remote', + mapResult: () => ({ + streamUrl: '/movie.ts', + }), + parameters: { + action: 'create_link', + JsHttpRequest: '1-xml', + type: 'vod', + }, + } as never; + } + return { + kind: 'remote', + mapResult: () => ({ items: [] }), + parameters: { + action: 'get_categories', + JsHttpRequest: '1-xml', + type: 'vod', + }, + } as never; + }, + playbackContexts: { + cleanupSender: jest.fn(), + clear: jest.fn(), + invalidateAuthGeneration: jest.fn(), + invalidateCoordinatorEpoch: jest.fn(), + invalidateLease: jest.fn(), + invalidateSession: jest.fn(), + register: (input) => { + playbackRegistrations.push(input); + return 'playback-context-boundary'; + }, + }, + random: (size) => Buffer.alloc(size, reference++), + resolver: { + resolve: async () => resolved(), + }, + }); + + const openOutcome = await manager.open(41, { + descriptor: descriptor(), + }); + if (openOutcome.kind !== 'credentials-required') { + throw new Error('expected-credentials-required'); + } + const continueOutcome = await manager.continue(41, { + challengeRef: openOutcome.challengeRef, + response: { + kind: 'credentials', + password: SECRET_VALUES[4], + username: SECRET_VALUES[3], + }, + }); + if ( + continueOutcome.kind !== 'ready' || + continueOutcome.recipe !== 'full-session' + ) { + throw new Error('expected-full-ready'); + } + const requestOutcome = await manager.request(41, { + leaseRef: continueOutcome.leaseRef, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + parameters: { contentType: 'vod' }, + }); + const playbackOutcome = await manager.request(41, { + leaseRef: continueOutcome.leaseRef, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink, + parameters: { + command: '/movie.ts', + contentType: 'vod', + }, + }); + const controlOutcome = await manager.control(41, { + action: 'activate', + leaseRef: continueOutcome.leaseRef, + }); + + expect(playbackRegistrations).toHaveLength(1); + expect(playbackOutcome).toMatchObject({ + kind: 'success', + payload: { + playbackContextRef: 'playback-context-boundary', + streamUrl: 'https://media.boundary/movie.ts', + }, + }); + for (const outcome of [ + openOutcome, + continueOutcome, + requestOutcome, + playbackOutcome, + controlOutcome, + ]) { + assertNoPrivateMaterial(outcome); + } + expect(JSON.stringify(manager)).toBe('{}'); + }); + + it('keeps origin challenges opaque, sender-bound, and secret-free', async () => { + let resolveCount = 0; + const manager = new StalkerSessionManager({ + createAuthSession: () => new BoundaryAuth([{ kind: 'ready' }]), + createRef: (kind) => `${kind}-origin-${resolveCount}`, + mapRawOperation: (() => { + throw new Error('not-used'); + }) as never, + random: (size) => Buffer.alloc(size, ++resolveCount), + resolver: { + resolve: async () => { + resolveCount += 1; + return resolveCount === 1 + ? { + finalOrigin: 'https://approved.boundary', + kind: 'origin-approval-required' as const, + landingUrl: + 'https://approved.boundary/customer/c/', + sourceOrigin: 'https://portal.boundary', + } + : resolved(); + }, + }, + }); + + const challenge = await manager.open(51, { + descriptor: descriptor('provisional'), + }); + if (challenge.kind !== 'origin-approval-required') { + throw new Error('expected-origin-approval'); + } + const wrongSender = await manager.continue(52, { + challengeRef: challenge.challengeRef, + response: { approved: true, kind: 'origin-approval' }, + }); + const approved = await manager.continue(51, { + challengeRef: challenge.challengeRef, + response: { approved: true, kind: 'origin-approval' }, + }); + + assertNoPrivateMaterial(challenge); + assertNoPrivateMaterial(wrongSender); + assertNoPrivateMaterial(approved); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts new file mode 100644 index 000000000..8dd2fa100 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.spec.ts @@ -0,0 +1,1357 @@ +/* eslint-disable max-lines -- Lifecycle scenarios intentionally share one state-machine harness. */ +import type { + StalkerSessionApplicationOperation, + StalkerSessionConnectionDescriptor, + StalkerSessionConnectionOutcome, + StalkerSessionOperationParameters, + StalkerSessionOperationResult, + StalkerSessionRequest, +} from '@iptvnator/shared/interfaces'; +import { + STALKER_SESSION_APPLICATION_OPERATIONS, + STALKER_SESSION_FAILURE_REASONS, +} from '@iptvnator/shared/interfaces'; +import { + MAG250_LEGACY_BROWSER_USER_AGENT, + MAG250_X_USER_AGENT, + createStalkerIdentityProfile, + type StalkerIdentityProfileInput, +} from '@iptvnator/portal/stalker/protocol'; +import type { + StalkerAuthCredentials, + StalkerAuthenticatedRequestOutcome, + StalkerAuthOutcome, +} from './stalker-auth-session'; +import type { + StalkerEndpointFullSessionOutcome, + StalkerEndpointResolverOutcome, +} from './stalker-endpoint-resolver'; +import type { + StalkerBaseIdentityCoordinator, + StalkerBaseIdentityCoordinatorPool, +} from './stalker-base-identity-coordinator'; +import { + StalkerSessionManager, + type StalkerSessionAuthLike, + type StalkerSessionManagerDependencies, + type StalkerSessionOperationMapper, + type StalkerSessionPlaybackContextPort, + type StalkerSessionWatchdogLike, +} from './stalker-session-manager'; + +const ENDPOINT = 'https://portal.test/server/load.php'; +const LANDING_URL = 'https://portal.test/c/'; +const MAC = '00:1A:79:AA:BB:CC'; + +function descriptor( + playlistRef: string, + connectionMode: 'persisted-open' | 'provisional' = 'persisted-open', + overrides: Partial = {} +): StalkerSessionConnectionDescriptor { + const safeOverrides = { + ...overrides, + } as Partial & { + connectionMode?: never; + provisionalReason?: never; + }; + delete safeOverrides.connectionMode; + delete safeOverrides.provisionalReason; + const base = { + macAddress: MAC, + playlistRef, + profilePreset: { + id: 'mag250-public-5_1-minimal-v1' as const, + version: 1 as const, + }, + sourceUrl: `https://portal.test/${playlistRef}/`, + ...safeOverrides, + }; + return connectionMode === 'provisional' + ? { + ...base, + connectionMode, + provisionalReason: 'edit', + } + : { + ...base, + connectionMode, + }; +} + +function full( + endpoint = ENDPOINT, + identity: Partial = {} +): StalkerEndpointFullSessionOutcome { + return { + cookieJar: { privateCookie: 'cookie-secret' } as never, + endpoint, + handshakeRandom: 'random-secret', + identity: createStalkerIdentityProfile({ + macAddress: MAC, + ...identity, + }), + kind: 'full-session', + landingUrl: LANDING_URL, + profile: { + kind: 'ready', + profile: { status: 0 }, + status: 0, + }, + profileEnvelope: { js: { token: 'profile-token-secret' } }, + token: 'token-secret', + }; +} + +function stateless(): StalkerEndpointResolverOutcome { + return { + endpoint: ENDPOINT, + kind: 'stateless-mac', + landingUrl: LANDING_URL, + }; +} + +class FakeAuth implements StalkerSessionAuthLike { + readonly requests: Readonly>[] = []; + readonly submittedCredentials: StalkerAuthCredentials[] = []; + startCalls = 0; + + constructor( + readonly principal = 'mac-only', + readonly acceptedCredentials = false, + private readonly startOutcomes: StalkerAuthOutcome[] = [ + { kind: 'ready' }, + ], + private readonly requestOutcomes: StalkerAuthenticatedRequestOutcome[] = [ + { kind: 'success', value: { js: [] } }, + ] + ) {} + + getEndpoint(): string { + return ENDPOINT; + } + + getPrincipalKey(): string { + return this.principal; + } + + hasAcceptedCredentials(): boolean { + return this.acceptedCredentials; + } + + async start(): Promise { + this.startCalls += 1; + return ( + this.startOutcomes.shift() ?? { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + retryable: false, + stage: 'handshaking', + } + ); + } + + async submitCredentials( + credentials: StalkerAuthCredentials + ): Promise { + this.submittedCredentials.push(credentials); + return ( + this.startOutcomes.shift() ?? { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + retryable: false, + stage: 'do-auth', + } + ); + } + + async request( + parameters: Readonly> + ): Promise { + this.requests.push(parameters); + return ( + this.requestOutcomes.shift() ?? { + kind: 'success', + value: { js: [] }, + } + ); + } +} + +const operationMapper: StalkerSessionOperationMapper = < + Operation extends StalkerSessionApplicationOperation, +>( + operation: Operation, + parameters: StalkerSessionOperationParameters +) => { + if (operation === STALKER_SESSION_APPLICATION_OPERATIONS.Favorites) { + const favorite = ( + parameters as StalkerSessionOperationParameters<'favorites'> + ).favorite; + return { + kind: 'local', + result: { + favorite, + success: true, + } as StalkerSessionOperationResult, + }; + } + return { + kind: 'remote', + mapResult: () => + ({ + items: [], + }) as StalkerSessionOperationResult, + parameters: { + action: String(operation), + JsHttpRequest: '1-xml', + type: 'itv', + }, + }; +}; + +function harness( + options: { + auths?: FakeAuth[]; + coordinatorPool?: StalkerBaseIdentityCoordinatorPool; + credentials?: Readonly>; + now?: () => number; + playbackContexts?: StalkerSessionPlaybackContextPort; + resolverOutcomes?: StalkerEndpointResolverOutcome[]; + watchdog?: StalkerSessionWatchdogLike; + } = {} +) { + const auths = [...(options.auths ?? [new FakeAuth()])]; + const resolverOutcomes = [...(options.resolverOutcomes ?? [full()])]; + const resolve = jest.fn(async () => { + const outcome = resolverOutcomes.shift(); + if (!outcome) { + throw new Error('unexpected-resolver-call'); + } + return outcome; + }); + const createAuthSession = jest.fn(() => { + const auth = auths.shift(); + if (!auth) { + throw new Error('unexpected-auth-session'); + } + return auth; + }); + let reference = 0; + const dependencies: StalkerSessionManagerDependencies = { + ...(options.coordinatorPool + ? { coordinatorPool: options.coordinatorPool } + : {}), + createAuthSession, + createRef(kind) { + reference += 1; + return `${kind}-${reference}`; + }, + loadSavedCredentials: async (currentDescriptor) => + options.credentials?.[currentDescriptor.playlistRef], + mapRawOperation: operationMapper, + ...(options.now ? { now: options.now } : {}), + ...(options.playbackContexts + ? { playbackContexts: options.playbackContexts } + : {}), + random: (size) => Buffer.alloc(size, reference++ % 255), + resolver: { resolve }, + ...(options.watchdog ? { watchdog: options.watchdog } : {}), + }; + return { + createAuthSession, + manager: new StalkerSessionManager(dependencies), + resolve, + }; +} + +function expectFullReady( + outcome: StalkerSessionConnectionOutcome +): asserts outcome is Extract< + StalkerSessionConnectionOutcome, + { kind: 'ready'; recipe: 'full-session' } +> { + expect(outcome).toMatchObject({ + kind: 'ready', + recipe: 'full-session', + }); + if (outcome.kind !== 'ready' || outcome.recipe !== 'full-session') { + throw new Error('expected-full-ready'); + } +} + +function catalogRequest( + leaseRef: string +): StalkerSessionRequest<'get-categories'> { + return { + leaseRef, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + parameters: { contentType: 'vod' }, + }; +} + +function playbackContextPort(): StalkerSessionPlaybackContextPort { + return { + cleanupSender: jest.fn(), + clear: jest.fn(), + invalidateAuthGeneration: jest.fn(), + invalidateCoordinatorEpoch: jest.fn(), + invalidateLease: jest.fn(), + invalidateSession: jest.fn(), + register: jest.fn(() => 'playback-ref'), + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('StalkerSessionManager', () => { + it('derives opaque canonical keys from endpoint, MAC, identity revision, and confirmed principal only', async () => { + const activate = jest.fn(); + const watchdog: StalkerSessionWatchdogLike = { + activate, + cleanupAll: jest.fn(), + cleanupSession: jest.fn(), + deactivate: jest.fn(), + }; + const { manager } = harness({ + auths: [ + new FakeAuth(), + new FakeAuth(), + new FakeAuth(), + new FakeAuth('user-a', true), + new FakeAuth(), + new FakeAuth(), + new FakeAuth(), + ], + credentials: { + principal: { + password: 'password', + username: 'user-a', + }, + }, + resolverOutcomes: [ + full(), + full(), + full('https://other-endpoint.test/portal.php'), + full(), + full(ENDPOINT, { serialNumber: 'explicit-serial' }), + full(), + full(), + ], + watchdog, + }); + const first = await manager.open(1, { + descriptor: descriptor('alias-one'), + }); + const same = await manager.open(2, { + descriptor: descriptor('alias-two'), + }); + const endpointChanged = await manager.open(3, { + descriptor: descriptor('endpoint'), + }); + const principalChanged = await manager.open(4, { + descriptor: descriptor('principal'), + }); + const identityChanged = await manager.open(5, { + descriptor: descriptor('identity', 'persisted-open', { + identityOverrides: { serialNumber: 'explicit-serial' }, + }), + }); + const macChanged = await manager.open(6, { + descriptor: descriptor('mac', 'persisted-open', { + macAddress: '00:1A:79:AA:BB:CD', + }), + }); + const explicitDefaults = await manager.open(7, { + descriptor: descriptor('explicit-defaults', 'persisted-open', { + transportConfiguration: { + language: 'en', + locale: 'en-US', + timezone: 'UTC', + userAgent: MAG250_LEGACY_BROWSER_USER_AGENT, + xUserAgent: MAG250_X_USER_AGENT, + }, + }), + }); + expectFullReady(first); + expectFullReady(same); + expectFullReady(endpointChanged); + expectFullReady(principalChanged); + expectFullReady(identityChanged); + expectFullReady(macChanged); + expectFullReady(explicitDefaults); + + await manager.control(1, { + action: 'activate', + leaseRef: first.leaseRef, + }); + await manager.control(2, { + action: 'activate', + leaseRef: same.leaseRef, + }); + await manager.control(3, { + action: 'activate', + leaseRef: endpointChanged.leaseRef, + }); + await manager.control(4, { + action: 'activate', + leaseRef: principalChanged.leaseRef, + }); + await manager.control(5, { + action: 'activate', + leaseRef: identityChanged.leaseRef, + }); + await manager.control(6, { + action: 'activate', + leaseRef: macChanged.leaseRef, + }); + await manager.control(7, { + action: 'activate', + leaseRef: explicitDefaults.leaseRef, + }); + + const keys = activate.mock.calls.map( + ([activation]) => activation.sessionKey as string + ); + expect(keys[0]).toBe(keys[1]); + expect(keys[2]).not.toBe(keys[0]); + expect(keys[3]).not.toBe(keys[0]); + expect(keys[4]).not.toBe(keys[0]); + expect(keys[5]).not.toBe(keys[0]); + expect(keys[6]).toBe(keys[0]); + expect(keys.every((key) => /^[a-f0-9]{64}$/.test(key))).toBe(true); + expect(keys.join(' ')).not.toContain('user-a'); + expect(keys.join(' ')).not.toContain('alias-one'); + expect(keys.join(' ')).not.toContain('alias-two'); + }); + + it('shares only a committed canonical identity while issuing sender-bound leases', async () => { + const firstAuth = new FakeAuth(); + const replacementAuth = new FakeAuth(); + const { manager } = harness({ + auths: [firstAuth, replacementAuth], + resolverOutcomes: [full(), full()], + }); + + const first = await manager.open(11, { + descriptor: descriptor('playlist-one'), + }); + const second = await manager.open(22, { + descriptor: descriptor('playlist-two'), + }); + expectFullReady(first); + expectFullReady(second); + expect(first.leaseRef).not.toBe(second.leaseRef); + + await expect( + manager.request(11, catalogRequest(first.leaseRef)) + ).resolves.toMatchObject({ kind: 'success' }); + await expect( + manager.request(22, catalogRequest(second.leaseRef)) + ).resolves.toMatchObject({ kind: 'success' }); + expect(firstAuth.requests).toHaveLength(0); + expect(replacementAuth.requests).toHaveLength(2); + + await expect( + manager.request(22, catalogRequest(first.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + }); + + it('keeps provisional leases unusable until an idempotent commit atomically replaces the generation', async () => { + const oldAuth = new FakeAuth(); + const promotedAuth = new FakeAuth(); + const { manager } = harness({ + auths: [oldAuth, promotedAuth], + resolverOutcomes: [full(), full()], + }); + const existing = await manager.open(1, { + descriptor: descriptor('playlist'), + }); + const provisional = await manager.open(1, { + descriptor: descriptor('playlist', 'provisional'), + }); + expectFullReady(existing); + expectFullReady(provisional); + expect(provisional.connectionMode).toBe('provisional'); + if (provisional.connectionMode !== 'provisional') { + throw new Error('expected-provisional-ready'); + } + + await expect( + manager.request(1, catalogRequest(provisional.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + await expect( + manager.control(1, { + action: 'commit', + attemptRef: provisional.attemptRef, + }) + ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); + await expect( + manager.control(1, { + action: 'commit', + attemptRef: provisional.attemptRef, + }) + ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); + + await manager.request(1, catalogRequest(existing.leaseRef)); + await manager.request(1, catalogRequest(provisional.leaseRef)); + expect(oldAuth.requests).toHaveLength(0); + expect(promotedAuth.requests).toHaveLength(2); + }); + + it('revalidates a stale provisional principal before promotion', async () => { + const provisionalAuth = new FakeAuth('user-a', true); + const otherPrincipalAuth = new FakeAuth('user-b', true); + const revalidatedAuth = new FakeAuth('user-a', true); + const { manager, resolve } = harness({ + auths: [provisionalAuth, otherPrincipalAuth, revalidatedAuth], + credentials: { + a: { password: 'a-password', username: 'user-a' }, + b: { password: 'b-password', username: 'user-b' }, + }, + resolverOutcomes: [full(), full(), full()], + }); + const provisional = await manager.open(10, { + descriptor: descriptor('a', 'provisional'), + }); + const other = await manager.open(20, { + descriptor: descriptor('b'), + }); + expectFullReady(provisional); + expectFullReady(other); + if (provisional.connectionMode !== 'provisional') { + throw new Error('expected-provisional-ready'); + } + + await expect( + manager.control(10, { + action: 'commit', + attemptRef: provisional.attemptRef, + }) + ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); + expect(resolve).toHaveBeenCalledTimes(3); + await manager.request(10, catalogRequest(provisional.leaseRef)); + expect(revalidatedAuth.requests).toHaveLength(1); + }); + + it('returns a typed failure and terminates a persisted open when promotion revalidation fails', async () => { + let mutationCount = 0; + const coordinator = { + get snapshot() { + return mutationCount === 1 + ? { activePrincipal: 'another-principal', epoch: 2 } + : { activePrincipal: undefined, epoch: mutationCount + 1 }; + }, + runDiscoveredPrincipalMutation: async ( + operation: ( + epoch: number + ) => Promise<{ principal: string; value: unknown }> + ) => { + mutationCount += 1; + const epoch = mutationCount === 1 ? 1 : 3; + const result = await operation(epoch); + return { + snapshot: { + activePrincipal: result.principal, + epoch, + }, + value: result.value, + }; + }, + runRead: jest.fn(), + } as unknown as StalkerBaseIdentityCoordinator; + const coordinatorPool = { + clear: jest.fn(), + get: jest.fn(() => coordinator), + } as unknown as StalkerBaseIdentityCoordinatorPool; + const { manager, resolve } = harness({ + auths: [new FakeAuth()], + coordinatorPool, + resolverOutcomes: [ + full(), + { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.EndpointNotFound, + retryable: false, + stage: 'resolving', + }, + ], + }); + + await expect( + manager.open(30, { + descriptor: descriptor('persisted-promotion-failure'), + }) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.SessionPromotionFailed, + stage: 'promoting', + }); + expect(resolve).toHaveBeenCalledTimes(2); + await expect( + manager.cleanupPlaylist('persisted-promotion-failure') + ).resolves.toBeUndefined(); + }); + + it('restores the previous ready generation after a failed provisional edit', async () => { + const originalAuth = new FakeAuth(); + const restoredAuth = new FakeAuth(); + const { manager, resolve } = harness({ + auths: [originalAuth, restoredAuth], + resolverOutcomes: [ + full(), + { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.PortalUnavailable, + retryable: true, + stage: 'resolving', + }, + full(), + ], + }); + const existing = await manager.open(4, { + descriptor: descriptor('playlist'), + }); + expectFullReady(existing); + + await expect( + manager.open(4, { + descriptor: descriptor('playlist', 'provisional'), + }) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.PortalUnavailable, + }); + expect(resolve).toHaveBeenCalledTimes(3); + await manager.request(4, catalogRequest(existing.leaseRef)); + expect(originalAuth.requests).toHaveLength(0); + expect(restoredAuth.requests).toHaveLength(1); + }); + + it('binds one-shot challenges to the sender and the exact attempt', async () => { + const auth = new FakeAuth('confirmed-user', true, [ + { attemptNumber: 1, kind: 'credentials-required' }, + { kind: 'ready' }, + ]); + const { manager } = harness({ auths: [auth] }); + + const challenge = await manager.open(7, { + descriptor: descriptor('credentials'), + }); + expect(challenge).toMatchObject({ + kind: 'credentials-required', + attemptNumber: 1, + }); + if (challenge.kind !== 'credentials-required') { + throw new Error('expected-credentials-challenge'); + } + + await expect( + manager.continue(8, { + challengeRef: challenge.challengeRef, + response: { + kind: 'credentials', + password: 'password', + username: 'confirmed-user', + }, + }) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + const ready = await manager.continue(7, { + challengeRef: challenge.challengeRef, + response: { + kind: 'credentials', + password: 'password', + username: 'confirmed-user', + }, + }); + expectFullReady(ready); + expect(auth.submittedCredentials).toEqual([ + { + password: 'password', + username: 'confirmed-user', + }, + ]); + await expect( + manager.continue(7, { + challengeRef: challenge.challengeRef, + response: { + kind: 'credentials', + password: 'password', + username: 'confirmed-user', + }, + }) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + }); + + it('resets the two-minute lifetime when a slow attempt becomes ready or issues a challenge', async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + class SlowReadyAuth extends FakeAuth { + override async start(): Promise { + this.startCalls += 1; + jest.setSystemTime(119_000); + return { kind: 'ready' }; + } + } + class SlowChallengeAuth extends FakeAuth { + override async start(): Promise { + this.startCalls += 1; + jest.setSystemTime(238_000); + return { + attemptNumber: 1, + kind: 'credentials-required', + }; + } + } + const { manager } = harness({ + auths: [ + new SlowReadyAuth(), + new SlowChallengeAuth('confirmed-user', true, [ + { kind: 'ready' }, + ]), + ], + now: Date.now, + resolverOutcomes: [full(), full()], + }); + + try { + const ready = await manager.open(41, { + descriptor: descriptor('slow-ready', 'provisional'), + }); + expectFullReady(ready); + if (ready.connectionMode !== 'provisional') { + throw new Error('expected-provisional-ready'); + } + await jest.advanceTimersByTimeAsync(2_000); + await expect( + manager.control(41, { + action: 'commit', + attemptRef: ready.attemptRef, + }) + ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); + + const challenge = await manager.open(42, { + descriptor: descriptor('slow-challenge', 'provisional'), + }); + if (challenge.kind !== 'credentials-required') { + throw new Error('expected-credentials-challenge'); + } + await jest.advanceTimersByTimeAsync(2_000); + await expect( + manager.continue(42, { + challengeRef: challenge.challengeRef, + response: { + kind: 'credentials', + password: 'password', + username: 'confirmed-user', + }, + }) + ).resolves.toMatchObject({ kind: 'ready' }); + } finally { + await manager.destroyAll(); + jest.useRealTimers(); + } + }); + + it('automatically terminates an abandoned provisional attempt at its deadline', async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + const { manager, resolve } = harness({ + auths: [ + new FakeAuth('user-a', true), + new FakeAuth('user-b', true), + new FakeAuth('user-a', true), + ], + credentials: { + 'automatic-expiry': { + password: 'a-password', + username: 'user-a', + }, + }, + now: Date.now, + resolverOutcomes: [full(), full(), full()], + }); + + try { + const existing = await manager.open(43, { + descriptor: descriptor('automatic-expiry'), + }); + expectFullReady(existing); + const ready = await manager.open(43, { + descriptor: descriptor('automatic-expiry', 'provisional'), + }); + expectFullReady(ready); + if (ready.connectionMode !== 'provisional') { + throw new Error('expected-provisional-ready'); + } + + await jest.advanceTimersByTimeAsync(120_001); + expect(resolve).toHaveBeenCalledTimes(3); + await expect( + manager.control(43, { + action: 'commit', + attemptRef: ready.attemptRef, + }) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + } finally { + await manager.destroyAll(); + jest.useRealTimers(); + } + }); + + it('keeps local operations off the wire after lease and policy validation', async () => { + const auth = new FakeAuth(); + const { manager } = harness({ auths: [auth] }); + const ready = await manager.open(3, { + descriptor: descriptor('local'), + }); + expectFullReady(ready); + + await expect( + manager.request(3, { + leaseRef: ready.leaseRef, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.Favorites, + parameters: { + contentType: 'vod', + favorite: true, + itemId: '42', + }, + }) + ).resolves.toMatchObject({ + kind: 'success', + payload: { favorite: true, success: true }, + }); + expect(auth.requests).toHaveLength(0); + }); + + it('performs one single-flight refresh and one retry for concurrent token rejections', async () => { + const expired = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [{ kind: 'token-rejected' }, { kind: 'token-rejected' }] + ); + const refreshed = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [ + { kind: 'success', value: { js: [] } }, + { kind: 'success', value: { js: [] } }, + ] + ); + const playbackContexts = playbackContextPort(); + const { manager, resolve } = harness({ + auths: [expired, refreshed], + playbackContexts, + resolverOutcomes: [full(), full()], + }); + const ready = await manager.open(5, { + descriptor: descriptor('refresh'), + }); + expectFullReady(ready); + + const outcomes = await Promise.all([ + manager.request(5, catalogRequest(ready.leaseRef)), + manager.request(5, catalogRequest(ready.leaseRef)), + ]); + + expect(outcomes).toEqual([ + expect.objectContaining({ kind: 'success' }), + expect.objectContaining({ kind: 'success' }), + ]); + expect(resolve).toHaveBeenCalledTimes(2); + expect(expired.requests).toHaveLength(2); + expect(refreshed.requests).toHaveLength(2); + expect(playbackContexts.invalidateAuthGeneration).toHaveBeenCalledTimes( + 1 + ); + }); + + it('keeps suspended-principal authentication and its triggering request in one exclusive mutation', async () => { + const refreshStarted = deferred(); + const allowRefresh = deferred(); + class PausedRefreshAuth extends FakeAuth { + override async start(): Promise { + this.startCalls += 1; + refreshStarted.resolve(); + await allowRefresh.promise; + return { kind: 'ready' }; + } + } + const refreshedA = new PausedRefreshAuth('user-a', true); + const refreshedB = new FakeAuth('user-b', true); + const { manager, resolve } = harness({ + auths: [ + new FakeAuth('user-a', true), + new FakeAuth('user-b', true), + refreshedA, + refreshedB, + ], + credentials: { + a: { password: 'a-password', username: 'user-a' }, + b: { password: 'b-password', username: 'user-b' }, + }, + resolverOutcomes: [full(), full(), full(), full()], + }); + const first = await manager.open(1, { + descriptor: descriptor('a'), + }); + const second = await manager.open(2, { + descriptor: descriptor('b'), + }); + expectFullReady(first); + expectFullReady(second); + + const firstRequest = manager.request(1, catalogRequest(first.leaseRef)); + await refreshStarted.promise; + const secondRequest = manager.request( + 2, + catalogRequest(second.leaseRef) + ); + allowRefresh.resolve(); + + await expect( + Promise.all([firstRequest, secondRequest]) + ).resolves.toEqual([ + expect.objectContaining({ kind: 'success' }), + expect.objectContaining({ kind: 'success' }), + ]); + expect(resolve).toHaveBeenCalledTimes(4); + expect(refreshedA.requests).toHaveLength(1); + expect(refreshedB.requests).toHaveLength(1); + }); + + it('rediscovers an incompatible learned endpoint once and retries on the refreshed generation', async () => { + const incompatible = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [ + { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.EndpointNotFound, + retryable: false, + stage: 'ready', + }, + ] + ); + const rediscovered = new FakeAuth(); + const { manager, resolve } = harness({ + auths: [incompatible, rediscovered], + resolverOutcomes: [full(), full()], + }); + const ready = await manager.open(3, { + descriptor: descriptor('rediscovery-success'), + }); + expectFullReady(ready); + + await expect( + manager.request(3, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ kind: 'success' }); + expect(resolve).toHaveBeenCalledTimes(2); + expect(rediscovered.requests).toHaveLength(1); + }); + + it('does not recursively rediscover when the one retry is also incompatible', async () => { + const incompatible = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [ + { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + retryable: false, + stage: 'ready', + }, + ] + ); + const stillIncompatible = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [ + { + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + retryable: false, + stage: 'ready', + }, + ] + ); + const { manager, resolve } = harness({ + auths: [incompatible, stillIncompatible], + resolverOutcomes: [full(), full()], + }); + const ready = await manager.open(4, { + descriptor: descriptor('rediscovery-budget'), + }); + expectFullReady(ready); + + await expect( + manager.request(4, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + }); + expect(resolve).toHaveBeenCalledTimes(2); + expect(stillIncompatible.requests).toHaveLength(1); + }); + + it('rejects a principal change discovered during refresh', async () => { + const expired = new FakeAuth( + 'user-a', + true, + [{ kind: 'ready' }], + [{ kind: 'token-rejected' }] + ); + const changed = new FakeAuth('user-b', true); + const playbackContexts = playbackContextPort(); + const { manager } = harness({ + auths: [expired, changed], + credentials: { + refresh: { + password: 'password', + username: 'user-a', + }, + }, + playbackContexts, + resolverOutcomes: [full(), full()], + }); + const ready = await manager.open(6, { + descriptor: descriptor('refresh'), + }); + expectFullReady(ready); + + await expect( + manager.request(6, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.PrincipalTransitionRequired, + stage: 'refreshing', + }); + expect(changed.requests).toHaveLength(0); + expect( + playbackContexts.invalidateCoordinatorEpoch + ).toHaveBeenCalledTimes(1); + }); + + it('does not recursively refresh when the one retry also rejects its token', async () => { + const expired = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [{ kind: 'token-rejected' }] + ); + const refreshed = new FakeAuth( + 'mac-only', + false, + [{ kind: 'ready' }], + [{ kind: 'token-rejected' }] + ); + const { manager, resolve } = harness({ + auths: [expired, refreshed], + resolverOutcomes: [full(), full()], + }); + const ready = await manager.open(9, { + descriptor: descriptor('retry-budget'), + }); + expectFullReady(ready); + + await expect( + manager.request(9, catalogRequest(ready.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.AuthRefreshExhausted, + stage: 'refreshing', + }); + expect(resolve).toHaveBeenCalledTimes(2); + expect(refreshed.requests).toHaveLength(1); + }); + + it('spends the same one-refresh budget when a stale generation must be revalidated first', async () => { + const stale = new FakeAuth('user-a', true); + const switcher = new FakeAuth('user-b', true); + const revalidated = new FakeAuth( + 'user-a', + true, + [{ kind: 'ready' }], + [{ kind: 'token-rejected' }] + ); + const { manager, resolve } = harness({ + auths: [stale, switcher, revalidated], + credentials: { + a: { password: 'a-password', username: 'user-a' }, + b: { password: 'b-password', username: 'user-b' }, + }, + resolverOutcomes: [full(), full(), full()], + }); + const first = await manager.open(1, { + descriptor: descriptor('a'), + }); + const second = await manager.open(2, { + descriptor: descriptor('b'), + }); + expectFullReady(first); + expectFullReady(second); + + await expect( + manager.request(1, catalogRequest(first.leaseRef)) + ).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.AuthRefreshExhausted, + }); + expect(resolve).toHaveBeenCalledTimes(3); + expect(revalidated.requests).toHaveLength(1); + }); + + it('coordinates lifecycle and cleanup through lease ownership', async () => { + const watchdog: StalkerSessionWatchdogLike = { + activate: jest.fn(), + cleanupAll: jest.fn(), + cleanupSession: jest.fn(), + deactivate: jest.fn(), + }; + const playbackContexts = playbackContextPort(); + const { manager } = harness({ + auths: [new FakeAuth(), new FakeAuth(), new FakeAuth()], + resolverOutcomes: [full(), full(), full()], + playbackContexts, + watchdog, + }); + const first = await manager.open(1, { + descriptor: descriptor('one'), + }); + const second = await manager.open(2, { + descriptor: descriptor('two'), + }); + const discarded = await manager.open(1, { + descriptor: descriptor('draft', 'provisional', { + macAddress: '00:1A:79:AA:BB:CD', + }), + }); + expectFullReady(first); + expectFullReady(second); + expectFullReady(discarded); + if (discarded.connectionMode !== 'provisional') { + throw new Error('expected-provisional-ready'); + } + + await manager.control(1, { + action: 'activate', + leaseRef: first.leaseRef, + }); + await manager.control(1, { + action: 'deactivate', + leaseRef: first.leaseRef, + }); + await manager.control(1, { + action: 'discard', + attemptRef: discarded.attemptRef, + }); + await expect( + manager.control(1, { + action: 'close', + leaseRef: first.leaseRef, + }) + ).resolves.toMatchObject({ action: 'close', kind: 'success' }); + expect(watchdog.activate).toHaveBeenCalledTimes(1); + expect(watchdog.deactivate).toHaveBeenCalledTimes(1); + + await manager.cleanupSender(1); + expect(playbackContexts.cleanupSender).toHaveBeenCalledWith(1); + await expect( + manager.request(1, catalogRequest(first.leaseRef)) + ).resolves.toMatchObject({ kind: 'failure' }); + await manager.cleanupPlaylist('two'); + await expect( + manager.request(2, catalogRequest(second.leaseRef)) + ).resolves.toMatchObject({ kind: 'failure' }); + await manager.destroyAll(); + expect(watchdog.cleanupAll).toHaveBeenCalledTimes(1); + expect(playbackContexts.invalidateLease).toHaveBeenCalledWith( + first.leaseRef + ); + expect(playbackContexts.invalidateSession).toHaveBeenCalled(); + expect(playbackContexts.clear).toHaveBeenCalledTimes(1); + }); + + it('cannot resurrect an attempt after its renderer is cleaned up mid-authentication', async () => { + const resolution = deferred(); + const manager = new StalkerSessionManager({ + createAuthSession: () => new FakeAuth(), + createRef: (() => { + let ref = 0; + return (kind: 'attempt' | 'lease' | 'request') => + `${kind}-cleanup-race-${ref++}`; + })(), + mapRawOperation: operationMapper, + resolver: { + resolve: () => resolution.promise, + }, + }); + + const opening = manager.open(77, { + descriptor: descriptor('cleanup-race'), + }); + await Promise.resolve(); + await manager.cleanupSender(77); + resolution.resolve(full()); + + await expect(opening).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + }); + + it('does not publish an in-flight request after its sender lease is cleaned up', async () => { + const response = deferred(); + class PendingRequestAuth extends FakeAuth { + override async request( + parameters: Readonly> + ): Promise { + this.requests.push(parameters); + return response.promise; + } + } + const auth = new PendingRequestAuth(); + const { manager } = harness({ auths: [auth] }); + const ready = await manager.open(88, { + descriptor: descriptor('request-cleanup-race'), + }); + expectFullReady(ready); + + const pending = manager.request(88, catalogRequest(ready.leaseRef)); + await Promise.resolve(); + await manager.cleanupSender(88); + response.resolve({ kind: 'success', value: { js: [] } }); + + await expect(pending).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + }); + }); + + it('cannot bind old playback headers to a replacement auth generation', async () => { + const prepareStarted = deferred(); + const prepared = deferred<{ + headers: Readonly>; + streamUrl: string; + }>(); + class SlowPlaybackAuth extends FakeAuth { + async preparePlayback() { + prepareStarted.resolve(); + return prepared.promise; + } + } + const playbackContexts = playbackContextPort(); + const auths: StalkerSessionAuthLike[] = [ + new SlowPlaybackAuth(), + new FakeAuth(), + ]; + const resolverOutcomes = [full(), full()]; + let ref = 0; + const manager = new StalkerSessionManager({ + createAuthSession: () => { + const auth = auths.shift(); + if (!auth) { + throw new Error('unexpected-auth-session'); + } + return auth; + }, + createRef: (kind) => `${kind}-playback-race-${ref++}`, + mapRawOperation: ((operation) => ({ + kind: 'remote', + mapResult: () => ({ streamUrl: '/old.ts' }), + parameters: { + action: String(operation), + JsHttpRequest: '1-xml', + type: 'vod', + }, + })) as StalkerSessionOperationMapper, + playbackContexts, + resolver: { + resolve: async () => { + const outcome = resolverOutcomes.shift(); + if (!outcome) { + throw new Error('unexpected-resolver-call'); + } + return outcome; + }, + }, + }); + const first = await manager.open(91, { + descriptor: descriptor('first-playback-row'), + }); + expectFullReady(first); + const pending = manager.request(91, { + leaseRef: first.leaseRef, + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink, + parameters: { + command: '/old.ts', + contentType: 'vod', + }, + }); + await prepareStarted.promise; + + const replacement = await manager.open(92, { + descriptor: descriptor('replacement-playback-row'), + }); + expectFullReady(replacement); + prepared.resolve({ + headers: { Authorization: 'Bearer old-generation' }, + streamUrl: 'https://portal.test/old.ts', + }); + + await expect(pending).resolves.toMatchObject({ + kind: 'failure', + reason: STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + }); + expect(playbackContexts.register).not.toHaveBeenCalled(); + }); + + it('keeps stateless provisional attempts outside the lease registry', async () => { + const { manager } = harness({ + auths: [], + resolverOutcomes: [stateless()], + }); + const ready = await manager.open(1, { + descriptor: descriptor('simple', 'provisional'), + }); + expect(ready).toMatchObject({ + connectionMode: 'provisional', + kind: 'ready', + recipe: 'stateless-mac', + }); + if (ready.kind !== 'ready' || ready.connectionMode !== 'provisional') { + throw new Error('expected-stateless-provisional'); + } + await expect( + manager.control(1, { + action: 'commit', + attemptRef: ready.attemptRef, + }) + ).resolves.toMatchObject({ action: 'commit', kind: 'success' }); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts new file mode 100644 index 000000000..0577c625d --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session-manager.ts @@ -0,0 +1,2663 @@ +/* eslint-disable max-lines -- Session ownership stays in one auditable main-process state machine. */ +import { createHash, randomBytes } from 'node:crypto'; +import { + normalizeStalkerMacAddress, + normalizeStalkerSourceUrl, + serializeStalkerIdentityRevision, + serializeStalkerPrincipalInput, + type StalkerIdentityProfile, + validateStalkerApplicationRequest, +} from '@iptvnator/portal/stalker/protocol'; +import { + STALKER_SESSION_APPLICATION_OPERATIONS, + STALKER_SESSION_FAILURE_REASONS, + type StalkerSessionAccountSummary, + type StalkerSessionApplicationOperation, + type StalkerSessionConnectionDescriptor, + type StalkerSessionConnectionOutcome, + type StalkerSessionContinueRequest, + type StalkerSessionControlAction, + type StalkerSessionControlOutcome, + type StalkerSessionControlRequest, + type StalkerSessionFailureOutcome, + type StalkerSessionOpenRequest, + type StalkerSessionOperationParameters, + type StalkerSessionOperationResult, + type StalkerSessionPersistenceDraft, + type StalkerSessionRequest, + type StalkerSessionRequestOutcome, + type StalkerSessionStage, +} from '@iptvnator/shared/interfaces'; +import { + StalkerAuthSession, + type StalkerAuthCredentials, + type StalkerAuthenticatedRequestOutcome, + type StalkerAuthOutcome, + type StalkerAuthReadyOutcome, +} from './stalker-auth-session'; +import { + StalkerBaseIdentityCoordinator, + StalkerBaseIdentityCoordinatorPool, +} from './stalker-base-identity-coordinator'; +import { + StalkerChallengeRegistry, + type StalkerChallenge, +} from './stalker-challenge-registry'; +import { + StalkerEndpointResolver, + type StalkerEndpointFullSessionOutcome, + type StalkerEndpointResolverInput, + type StalkerEndpointResolverOutcome, + type StalkerEndpointStatelessOutcome, +} from './stalker-endpoint-resolver'; +import { + StalkerRuntimeValidationError, + validateStalkerRuntimeInput, +} from './stalker-runtime-validation'; +import type { + StalkerTransportConfig, + StalkerValidatedRuntimeInput, +} from './stalker-session.types'; +import { + StalkerWatchdog, + type StalkerWatchdogActivation, + type StalkerWatchdogDeactivation, +} from './stalker-watchdog'; + +const ATTEMPT_TTL_MS = 120_000; +const REFERENCE_BYTES = 32; +const MAX_REFERENCE_ATTEMPTS = 4; +const RECIPE_CLASSIFIER_VERSION = 1; + +export interface StalkerPreparedPlayback { + readonly headers: Readonly>; + readonly streamUrl: string; +} + +export interface StalkerSessionAuthLike { + getEndpoint(): string; + getPrincipalKey(): string; + hasAcceptedCredentials(): boolean; + preparePlayback?(streamUrl: string): Promise; + request( + parameters: Readonly> + ): Promise; + start( + savedCredentials?: StalkerAuthCredentials + ): Promise; + submitCredentials( + credentials: StalkerAuthCredentials, + savedCredentials?: boolean + ): Promise; +} + +export type StalkerSessionMappedOperation< + Operation extends StalkerSessionApplicationOperation, +> = + | { + readonly kind: 'local'; + readonly result: StalkerSessionOperationResult; + } + | { + readonly kind: 'remote'; + readonly mapResult: ( + value: unknown + ) => StalkerSessionOperationResult; + readonly parameters: Readonly>; + }; + +export interface StalkerSessionOperationMapper { + ( + operation: Operation, + parameters: StalkerSessionOperationParameters + ): StalkerSessionMappedOperation; +} + +export interface StalkerSessionWatchdogLike { + activate(activation: StalkerWatchdogActivation): void; + cleanupAll(): void; + cleanupSession(sessionKey: string): void; + deactivate(deactivation: StalkerWatchdogDeactivation): void; +} + +export interface StalkerPlaybackContextRegistration { + readonly authGeneration: number; + readonly coordinatorEpoch: number; + readonly headers: Readonly>; + readonly leaseRef: string; + readonly senderId: number; + readonly sessionKey: string; + readonly streamUrl: string; +} + +export interface StalkerSessionPlaybackContextPort { + cleanupSender(senderId: number): void; + clear(): void; + invalidateAuthGeneration(sessionKey: string, authGeneration: number): void; + invalidateCoordinatorEpoch( + sessionKey: string, + coordinatorEpoch: number + ): void; + invalidateLease(leaseRef: string): void; + invalidateSession(sessionKey: string): void; + register(input: StalkerPlaybackContextRegistration): string; +} + +interface StalkerEndpointResolverLike { + resolve( + input: StalkerEndpointResolverInput + ): Promise; +} + +export interface StalkerSessionManagerDependencies { + readonly coordinatorPool?: StalkerBaseIdentityCoordinatorPool; + readonly createAuthSession?: ( + resolved: StalkerEndpointFullSessionOutcome, + transport: StalkerTransportConfig + ) => StalkerSessionAuthLike; + readonly createRef?: (kind: 'attempt' | 'lease' | 'request') => string; + readonly loadSavedCredentials?: ( + descriptor: StalkerSessionConnectionDescriptor + ) => Promise; + readonly mapRawOperation: StalkerSessionOperationMapper; + readonly now?: () => number; + readonly playbackContexts?: StalkerSessionPlaybackContextPort; + readonly random?: (size: number) => Buffer; + readonly resolver?: StalkerEndpointResolverLike; + readonly watchdog?: StalkerSessionWatchdogLike; +} + +interface FullReadyProgress { + readonly accountSummary?: StalkerSessionAccountSummary; + readonly auth: StalkerSessionAuthLike; + readonly credentials?: StalkerAuthCredentials; + readonly endpoint: string; + readonly epoch: number; + readonly identityRevision: string; + readonly kind: 'full-ready'; + readonly landingUrl: string; + readonly principal: string; + readonly watchdogIntervalSeconds?: number; +} + +interface CredentialsProgress { + readonly auth: StalkerSessionAuthLike; + readonly endpoint: string; + readonly epoch: number; + readonly kind: 'credentials-required'; + readonly landingUrl: string; + readonly outcome: Extract< + StalkerAuthOutcome, + { kind: 'credentials-required' } + >; +} + +interface OriginProgress { + readonly finalOrigin: string; + readonly kind: 'origin-approval-required'; + readonly landingUrl: string; + readonly sourceOrigin: string; +} + +type AuthenticationProgress = + | FullReadyProgress + | CredentialsProgress + | OriginProgress + | StalkerEndpointStatelessOutcome + | Extract; + +interface AttemptRecord { + approvedOrigins: Set; + auth?: StalkerSessionAuthLike; + challengeRef?: string; + coordinator: StalkerBaseIdentityCoordinator; + credentialCandidate?: StalkerAuthCredentials; + descriptor: StalkerSessionConnectionDescriptor; + expiresAt: number; + lastEndpoint?: string; + lastIdentityRevision?: string; + lastLandingUrl?: string; + mutationEpoch?: number; + previousSessions: Set; + ready?: AttemptReady; + readonly ref: string; + readonly senderId: number; + state: 'authenticating' | 'challenge' | 'ready'; + readonly transport: StalkerTransportConfig; +} + +interface FullAttemptReady { + readonly accountSummary?: StalkerSessionAccountSummary; + readonly auth: StalkerSessionAuthLike; + readonly credentials?: StalkerAuthCredentials; + readonly endpoint: string; + readonly epoch: number; + readonly identityRevision: string; + readonly key: string; + readonly kind: 'full-session'; + readonly landingUrl: string; + readonly leaseRef: string; + readonly principal: string; + readonly watchdogIntervalSeconds?: number; +} + +interface StatelessAttemptReady { + readonly endpoint: string; + readonly kind: 'stateless-mac'; + readonly landingUrl: string; +} + +type AttemptReady = FullAttemptReady | StatelessAttemptReady; + +interface AttemptTombstone { + readonly action: 'commit' | 'discard'; + readonly expiresAt: number; + readonly playlistRef: string; + readonly senderId: number; +} + +interface LeaseRecord { + active: boolean; + readonly playlistRef: string; + readonly ref: string; + readonly senderId: number; + session: SessionRecord; +} + +interface SessionRecord { + accountSummary?: StalkerSessionAccountSummary; + readonly activeLeases: Set; + approvedOrigins: Set; + auth: StalkerSessionAuthLike; + credentials?: StalkerAuthCredentials; + descriptor: StalkerSessionConnectionDescriptor; + endpoint: string; + epoch: number; + generation: number; + identityRevision: string; + key: string; + landingUrl: string; + readonly leases: Set; + principal: string; + refreshPromise?: Promise; + readonly coordinator: StalkerBaseIdentityCoordinator; + transport: StalkerTransportConfig; + watchdogIntervalSeconds?: number; +} + +interface SessionGenerationBinding { + readonly auth: StalkerSessionAuthLike; + readonly epoch: number; + readonly generation: number; + readonly key: string; + readonly principal: string; + readonly session: SessionRecord; +} + +type SessionRequestRunResult = + | { + readonly binding: SessionGenerationBinding; + readonly kind: 'completed'; + readonly outcome: StalkerAuthenticatedRequestOutcome; + } + | { readonly kind: 'suspended' }; + +type RefreshResult = + | { readonly kind: 'ready'; readonly session: SessionRecord } + | { + readonly kind: 'origin-approval-required'; + readonly progress: OriginProgress; + } + | { + readonly kind: 'failure'; + readonly outcome: StalkerSessionFailureOutcome; + }; + +type RefreshRequestResult = + | Extract + | Exclude + | { readonly kind: 'suspended' }; + +class ProgressSignal { + constructor(readonly progress: AuthenticationProgress) {} +} + +class PrincipalTransitionSignal {} + +/** + * Main-process owner for Stalker auth generations, attempts and opaque leases. + * + * All secret-bearing objects are reachable only through ECMAScript private + * fields. Public methods always project their results onto shared DTOs. + */ +export class StalkerSessionManager { + readonly #attempts = new Map(); + readonly #attemptTombstones = new Map(); + readonly #challengeRegistry: StalkerChallengeRegistry; + readonly #coordinatorPool: StalkerBaseIdentityCoordinatorPool; + readonly #createAuthSession: ( + resolved: StalkerEndpointFullSessionOutcome, + transport: StalkerTransportConfig + ) => StalkerSessionAuthLike; + readonly #createRef: (kind: 'attempt' | 'lease' | 'request') => string; + readonly #leases = new Map(); + readonly #loadSavedCredentials: + | (( + descriptor: StalkerSessionConnectionDescriptor + ) => Promise) + | undefined; + readonly #mapRawOperation: StalkerSessionOperationMapper; + readonly #now: () => number; + readonly #playbackContexts: StalkerSessionPlaybackContextPort | undefined; + readonly #resolver: StalkerEndpointResolverLike; + readonly #sessions = new Map(); + readonly #watchdog: StalkerSessionWatchdogLike; + #expiryCleanupPromise?: Promise; + #expiryTimer?: ReturnType; + + constructor(dependencies: StalkerSessionManagerDependencies) { + this.#now = dependencies.now ?? Date.now; + const random = dependencies.random ?? randomBytes; + this.#createRef = + dependencies.createRef ?? + (() => random(REFERENCE_BYTES).toString('base64url')); + this.#challengeRegistry = new StalkerChallengeRegistry({ + now: this.#now, + random, + }); + this.#coordinatorPool = + dependencies.coordinatorPool ?? + new StalkerBaseIdentityCoordinatorPool(); + this.#resolver = dependencies.resolver ?? new StalkerEndpointResolver(); + this.#createAuthSession = + dependencies.createAuthSession ?? + ((resolved, transport) => + new StalkerAuthSession(resolved, transport)); + this.#loadSavedCredentials = dependencies.loadSavedCredentials; + this.#mapRawOperation = dependencies.mapRawOperation; + this.#playbackContexts = dependencies.playbackContexts; + this.#watchdog = + dependencies.watchdog ?? + new StalkerWatchdog({ + isWireActive: (target) => { + const session = this.#sessions.get(target.sessionKey); + return ( + session !== undefined && + session.principal === target.principal && + session.activeLeases.size > 0 && + session.coordinator.snapshot.epoch === session.epoch && + session.coordinator.snapshot.activePrincipal === + session.principal + ); + }, + joinRefresh: (target) => + this.#sessions + .get(target.sessionKey) + ?.refreshPromise?.then(() => undefined), + ping: async (target) => { + const session = this.#sessions.get(target.sessionKey); + if (!session || session.principal !== target.principal) { + return; + } + const read = await session.coordinator.runRead( + session.principal, + session.epoch, + () => + session.auth.request({ + action: 'get_profile', + auth_second_step: 0, + JsHttpRequest: '1-xml', + type: 'stb', + }) + ); + if ( + read.kind === 'completed' && + read.value.kind === 'token-rejected' + ) { + await this.#refreshSession(session); + } + }, + }); + } + + async open( + senderId: number, + request: StalkerSessionOpenRequest + ): Promise { + const requestId = this.#requestId(); + await this.#pruneExpired(); + if (!validSender(senderId)) { + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + 'new', + false + ); + } + + let validated: StalkerValidatedRuntimeInput; + try { + validated = validateStalkerRuntimeInput(request?.descriptor); + } catch (error) { + const reason = + error instanceof StalkerRuntimeValidationError + ? error.reason + : STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput; + return failure(requestId, reason, 'new', false); + } + + const attempt = this.#createAttempt(senderId, validated); + this.#attempts.set(attempt.ref, attempt); + this.#scheduleExpiryCleanup(); + try { + attempt.credentialCandidate = await this.#loadSavedCredentials?.( + attempt.descriptor + ); + } catch { + await this.#terminateAttempt(attempt, true); + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.LocalPersistenceFailed, + 'new', + false + ); + } + + const progress = await this.#authenticateFresh( + attempt, + attempt.credentialCandidate + ); + return this.#publishProgress(attempt, progress, requestId); + } + + async continue( + senderId: number, + request: StalkerSessionContinueRequest + ): Promise { + const requestId = this.#requestId(); + await this.#pruneExpired(); + if ( + !validSender(senderId) || + typeof request?.challengeRef !== 'string' || + (request.response?.kind !== 'credentials' && + request.response?.kind !== 'origin-approval') + ) { + return invalidIdentityFailure(requestId, 'awaiting-credentials'); + } + + const attempt = [...this.#attempts.values()].find( + (candidate) => candidate.challengeRef === request.challengeRef + ); + if (!attempt) { + return invalidIdentityFailure(requestId, 'awaiting-credentials'); + } + const consumed = this.#challengeRegistry.consume({ + attemptRef: attempt.ref, + challengeRef: request.challengeRef, + responseKind: request.response.kind, + senderId, + }); + if (consumed.kind !== 'consumed') { + return invalidIdentityFailure(requestId, 'awaiting-credentials'); + } + attempt.challengeRef = undefined; + attempt.state = 'authenticating'; + + if ( + request.response.kind === 'origin-approval' && + consumed.challenge.kind === 'origin-approval' + ) { + if (!request.response.approved) { + await this.#terminateAttempt(attempt, true); + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.OriginNotApproved, + 'awaiting-origin-approval', + false + ); + } + attempt.approvedOrigins.add(consumed.challenge.finalOrigin); + attempt.coordinator = this.#coordinatorPool.get( + consumed.challenge.finalOrigin, + attempt.descriptor.macAddress + ); + const progress = await this.#authenticateFresh( + attempt, + attempt.credentialCandidate + ); + return this.#publishProgress(attempt, progress, requestId); + } + + if ( + request.response.kind === 'credentials' && + consumed.challenge.kind === 'credentials' + ) { + const credentials = { + password: request.response.password, + username: request.response.username, + }; + attempt.credentialCandidate = credentials; + const coordinatorSnapshot = attempt.coordinator.snapshot; + const mayReuseAuth = + attempt.auth !== undefined && + attempt.mutationEpoch === coordinatorSnapshot.epoch && + coordinatorSnapshot.activePrincipal === undefined; + const progress = mayReuseAuth + ? await this.#submitCredentials(attempt, credentials) + : await this.#authenticateFresh(attempt, credentials); + return this.#publishProgress(attempt, progress, requestId); + } + + await this.#terminateAttempt(attempt, true); + return invalidIdentityFailure(requestId, 'awaiting-credentials'); + } + + async request( + senderId: number, + request: StalkerSessionRequest + ): Promise> { + const requestId = this.#requestId(); + await this.#pruneExpired(); + const lease = this.#ownedLease(senderId, request?.leaseRef); + if (!lease) { + return invalidIdentityFailure( + requestId, + 'ready' + ) as StalkerSessionRequestOutcome; + } + + let policy: ReturnType; + try { + policy = validateStalkerApplicationRequest({ + operation: request.operation, + parameters: request.parameters as Readonly< + Record + >, + }); + } catch { + return invalidIdentityFailure( + requestId, + 'ready' + ) as StalkerSessionRequestOutcome; + } + if (policy.kind !== 'accepted') { + return invalidIdentityFailure( + requestId, + 'ready' + ) as StalkerSessionRequestOutcome; + } + + let mapped: StalkerSessionMappedOperation; + try { + mapped = this.#mapRawOperation( + request.operation, + request.parameters + ); + } catch { + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + 'ready', + false + ) as StalkerSessionRequestOutcome; + } + if (mapped.kind === 'local') { + return { + kind: 'success', + operation: request.operation, + payload: mapped.result, + requestId, + } as StalkerSessionRequestOutcome; + } + + let execution = await this.#runSessionRequest( + lease.session, + mapped.parameters + ); + if (!this.#isCurrentLease(lease)) { + return invalidIdentityFailure( + requestId, + 'ready' + ) as StalkerSessionRequestOutcome; + } + let retryBudgetSpent = false; + if ( + execution.kind === 'suspended' || + !this.#isCurrentGenerationBinding(lease, execution.binding) + ) { + retryBudgetSpent = true; + const retrySession = lease.session; + const retried = await this.#refreshSessionAndRequest( + retrySession, + mapped.parameters + ); + if (retried.kind === 'suspended') { + return authRefreshExhausted( + requestId + ) as StalkerSessionRequestOutcome; + } + if (retried.kind !== 'completed') { + return this.#projectRefreshFailure( + senderId, + retrySession, + retried, + requestId + ) as StalkerSessionRequestOutcome; + } + execution = retried; + } + + let binding = execution.binding; + let outcome = execution.outcome; + if ( + outcome.kind === 'token-rejected' || + requiresEndpointRediscovery(outcome) + ) { + if (retryBudgetSpent) { + if (outcome.kind === 'token-rejected') { + return authRefreshExhausted( + requestId + ) as StalkerSessionRequestOutcome; + } + } else { + retryBudgetSpent = true; + const retrySession = binding.session; + const retried = await this.#refreshSessionAndRequest( + retrySession, + mapped.parameters + ); + if (retried.kind === 'suspended') { + return authRefreshExhausted( + requestId + ) as StalkerSessionRequestOutcome; + } + if (retried.kind !== 'completed') { + return this.#projectRefreshFailure( + senderId, + retrySession, + retried, + requestId + ) as StalkerSessionRequestOutcome; + } + binding = retried.binding; + outcome = retried.outcome; + if (outcome.kind === 'token-rejected') { + return authRefreshExhausted( + requestId + ) as StalkerSessionRequestOutcome; + } + } + } + + if (!this.#isCurrentGenerationBinding(lease, binding)) { + return invalidIdentityFailure( + requestId, + 'ready' + ) as StalkerSessionRequestOutcome; + } + if (outcome.kind === 'success') { + try { + let payload = mapped.mapResult(outcome.value); + payload = await this.#registerPlaybackIfNeeded( + senderId, + lease, + binding, + request.operation, + payload + ); + return { + kind: 'success', + operation: request.operation, + payload, + requestId, + } as StalkerSessionRequestOutcome; + } catch { + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse, + 'ready', + false + ) as StalkerSessionRequestOutcome; + } + } + if (outcome.kind === 'origin-approval-required') { + return this.#issueRecoveryOriginChallenge( + senderId, + binding.session, + outcome, + requestId + ) as StalkerSessionRequestOutcome; + } + return projectAuthFailure( + requestId, + outcome + ) as StalkerSessionRequestOutcome; + } + + async control( + senderId: number, + request: StalkerSessionControlRequest + ): Promise { + const requestId = this.#requestId(); + await this.#pruneExpired(); + if (!validSender(senderId) || !request) { + return invalidIdentityFailure(requestId, 'ready'); + } + + if (request.action === 'commit' || request.action === 'discard') { + return this.#controlAttempt( + senderId, + request.action, + request.attemptRef, + requestId + ); + } + + if (!('leaseRef' in request)) { + return invalidIdentityFailure(requestId, 'ready'); + } + const lease = this.#ownedLease(senderId, request.leaseRef); + if (!lease) { + return invalidIdentityFailure(requestId, 'ready'); + } + if (request.action === 'activate') { + this.#activateLease(lease); + return controlSuccess(requestId, request.action); + } + if (request.action === 'deactivate') { + this.#deactivateLease(lease); + return controlSuccess(requestId, request.action); + } + if (request.action === 'close') { + this.#closeLease(lease); + return controlSuccess(requestId, request.action); + } + if (request.action === 'force-redetect') { + const refreshed = await this.#refreshSession(lease.session); + if (refreshed.kind === 'ready') { + return controlSuccess(requestId, request.action); + } + return this.#projectRefreshFailure( + senderId, + lease.session, + refreshed, + requestId + ); + } + return invalidIdentityFailure(requestId, 'ready'); + } + + async cleanupSender(senderId: number): Promise { + await this.#pruneExpired(); + this.#challengeRegistry.invalidateSender(senderId); + for (const attempt of [...this.#attempts.values()]) { + if (attempt.senderId === senderId) { + await this.#terminateAttempt(attempt, false); + } + } + for (const [ref, tombstone] of this.#attemptTombstones) { + if (tombstone.senderId === senderId) { + this.#attemptTombstones.delete(ref); + } + } + for (const lease of [...this.#leases.values()]) { + if (lease.senderId === senderId) { + this.#closeLease(lease); + } + } + this.#playbackContexts?.cleanupSender(senderId); + this.#scheduleExpiryCleanup(); + } + + async cleanupPlaylist(playlistRef: string): Promise { + await this.#pruneExpired(); + for (const attempt of [...this.#attempts.values()]) { + if (attempt.descriptor.playlistRef === playlistRef) { + await this.#terminateAttempt(attempt, false); + } + } + for (const [ref, tombstone] of this.#attemptTombstones) { + if (tombstone.playlistRef === playlistRef) { + this.#attemptTombstones.delete(ref); + } + } + for (const lease of [...this.#leases.values()]) { + if (lease.playlistRef === playlistRef) { + this.#closeLease(lease); + } + } + this.#scheduleExpiryCleanup(); + } + + async destroyAll(): Promise { + this.#clearExpiryTimer(); + for (const attempt of [...this.#attempts.values()]) { + await this.#terminateAttempt(attempt, false); + } + for (const lease of [...this.#leases.values()]) { + this.#closeLease(lease); + } + this.#attempts.clear(); + this.#attemptTombstones.clear(); + this.#leases.clear(); + this.#sessions.clear(); + this.#watchdog.cleanupAll(); + this.#playbackContexts?.clear(); + this.#coordinatorPool.clear(); + this.#clearExpiryTimer(); + } + + #createAttempt( + senderId: number, + validated: StalkerValidatedRuntimeInput + ): AttemptRecord { + const ref = this.#uniqueReference( + 'attempt', + (candidate) => + this.#attempts.has(candidate) || + this.#attemptTombstones.has(candidate) + ); + return { + approvedOrigins: new Set(), + coordinator: this.#coordinatorPool.get( + validated.descriptor.sourceUrl, + validated.descriptor.macAddress + ), + descriptor: validated.descriptor, + expiresAt: this.#now() + ATTEMPT_TTL_MS, + previousSessions: + validated.descriptor.connectionMode === 'provisional' + ? this.#findPreviousSessions( + senderId, + validated.descriptor.playlistRef + ) + : new Set(), + ref, + senderId, + state: 'authenticating', + transport: validated.transport, + }; + } + + async #authenticateFresh( + attempt: AttemptRecord, + credentials?: StalkerAuthCredentials, + expectedPrincipal?: string, + beforeMutationRelease?: (progress: FullReadyProgress) => Promise + ): Promise { + try { + const mutation = + await attempt.coordinator.runDiscoveredPrincipalMutation( + async (epoch) => { + attempt.mutationEpoch = epoch; + this.#invalidateOlderCoordinatorEpochs( + attempt.coordinator, + epoch + ); + const resolved = await this.#resolver.resolve({ + approvedOrigins: [...attempt.approvedOrigins], + descriptor: attempt.descriptor, + transport: attempt.transport, + }); + if (resolved.kind !== 'full-session') { + throw new ProgressSignal( + endpointProgress(resolved) + ); + } + + const auth = this.#createAuthSession( + resolved, + attempt.transport + ); + attempt.auth = auth; + attempt.lastEndpoint = resolved.endpoint; + attempt.lastIdentityRevision = + effectiveIdentityRevision( + resolved.identity, + attempt.descriptor + ); + attempt.lastLandingUrl = resolved.landingUrl; + const authOutcome = await auth.start(credentials); + if (authOutcome.kind !== 'ready') { + throw new ProgressSignal( + authProgress(authOutcome, auth, resolved, epoch) + ); + } + const principal = confirmedPrincipal(auth); + if ( + expectedPrincipal !== undefined && + principal !== expectedPrincipal + ) { + throw new PrincipalTransitionSignal(); + } + const progress = fullReadyProgress( + auth, + authOutcome, + resolved, + epoch, + principal, + auth.hasAcceptedCredentials() + ? credentials + : undefined, + attempt + ); + await beforeMutationRelease?.(progress); + return { principal, value: progress }; + } + ); + attempt.mutationEpoch = mutation.snapshot.epoch; + return mutation.value; + } catch (error) { + attempt.mutationEpoch = attempt.coordinator.snapshot.epoch; + if (error instanceof ProgressSignal) { + return error.progress; + } + if (error instanceof PrincipalTransitionSignal) { + return failure( + '', + STALKER_SESSION_FAILURE_REASONS.PrincipalTransitionRequired, + 'refreshing', + false + ); + } + return failure( + '', + STALKER_SESSION_FAILURE_REASONS.PortalUnavailable, + 'resolving', + true + ); + } + } + + async #submitCredentials( + attempt: AttemptRecord, + credentials: StalkerAuthCredentials + ): Promise { + const auth = attempt.auth; + if ( + !auth || + !attempt.lastEndpoint || + !attempt.lastIdentityRevision || + !attempt.lastLandingUrl + ) { + return this.#authenticateFresh(attempt, credentials); + } + try { + const mutation = + await attempt.coordinator.runDiscoveredPrincipalMutation( + async (epoch) => { + attempt.mutationEpoch = epoch; + this.#invalidateOlderCoordinatorEpochs( + attempt.coordinator, + epoch + ); + const outcome = await auth.submitCredentials( + credentials, + false + ); + if (outcome.kind !== 'ready') { + throw new ProgressSignal( + authProgressFromKnownEndpoint( + outcome, + auth, + attempt.lastEndpoint as string, + attempt.lastLandingUrl as string, + epoch + ) + ); + } + const principal = confirmedPrincipal(auth); + return { + principal, + value: { + ...fullReadyFromKnownEndpoint( + auth, + outcome, + attempt.lastEndpoint as string, + attempt.lastLandingUrl as string, + epoch, + attempt.lastIdentityRevision as string, + principal, + auth.hasAcceptedCredentials() + ? credentials + : undefined, + attempt + ), + }, + }; + } + ); + attempt.mutationEpoch = mutation.snapshot.epoch; + return mutation.value; + } catch (error) { + attempt.mutationEpoch = attempt.coordinator.snapshot.epoch; + if (error instanceof ProgressSignal) { + return error.progress; + } + return failure( + '', + STALKER_SESSION_FAILURE_REASONS.PortalUnavailable, + 'do-auth', + true + ); + } + } + + async #publishProgress( + attempt: AttemptRecord, + progress: AuthenticationProgress, + requestId: string + ): Promise { + if (this.#attempts.get(attempt.ref) !== attempt) { + return invalidIdentityFailure(requestId, 'resolving'); + } + if (progress.kind === 'full-ready') { + attempt.auth = progress.auth; + attempt.credentialCandidate = progress.credentials; + attempt.ready = this.#createFullAttemptReady(attempt, progress); + attempt.state = 'ready'; + this.#refreshAttemptExpiry(attempt); + if (attempt.descriptor.connectionMode === 'persisted-open') { + let lease: { + readonly ref: string; + readonly session: SessionRecord; + }; + try { + lease = await this.#promoteAttempt(attempt); + } catch { + if (this.#attempts.get(attempt.ref) === attempt) { + await this.#terminateAttempt(attempt, true); + } + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.SessionPromotionFailed, + 'promoting', + false + ); + } + this.#finishAttempt(attempt, 'commit'); + return fullReadyOutcome( + requestId, + attempt.descriptor, + lease, + progress, + undefined, + this.#now() + ); + } + return fullReadyOutcome( + requestId, + attempt.descriptor, + { + ref: attempt.ready.leaseRef, + session: undefined, + }, + progress, + attempt.ref, + this.#now() + ); + } + if (progress.kind === 'stateless-mac') { + attempt.ready = { + endpoint: progress.endpoint, + kind: progress.kind, + landingUrl: progress.landingUrl, + }; + attempt.state = 'ready'; + this.#refreshAttemptExpiry(attempt); + const outcome = statelessReadyOutcome( + requestId, + attempt.descriptor, + progress, + attempt.descriptor.connectionMode === 'provisional' + ? attempt.ref + : undefined, + this.#now() + ); + if (attempt.descriptor.connectionMode === 'persisted-open') { + this.#finishAttempt(attempt, 'commit'); + } + return outcome; + } + if (progress.kind === 'origin-approval-required') { + return this.#issueChallenge( + attempt, + { + finalOrigin: progress.finalOrigin, + kind: 'origin-approval', + landingPath: landingPath(progress.landingUrl), + sourceOrigin: progress.sourceOrigin, + }, + requestId + ); + } + if (progress.kind === 'credentials-required') { + attempt.auth = progress.auth; + attempt.lastEndpoint = progress.endpoint; + attempt.lastLandingUrl = progress.landingUrl; + attempt.mutationEpoch = progress.epoch; + if (progress.outcome.savedCredentialsRejected) { + attempt.credentialCandidate = undefined; + } + return this.#issueChallenge( + attempt, + { + attemptNumber: progress.outcome.attemptNumber, + kind: 'credentials', + ...(progress.outcome.savedCredentialsRejected + ? { savedCredentialsRejected: true } + : {}), + }, + requestId + ); + } + + const projected = withRequestId(progress, requestId); + await this.#terminateAttempt(attempt, true); + return projected; + } + + #issueChallenge( + attempt: AttemptRecord, + challenge: StalkerChallenge, + requestId: string + ): StalkerSessionConnectionOutcome { + this.#challengeRegistry.invalidateAttempt( + attempt.senderId, + attempt.ref + ); + const challengeRef = this.#challengeRegistry.issue( + attempt.senderId, + attempt.ref, + challenge + ); + attempt.challengeRef = challengeRef; + attempt.state = 'challenge'; + this.#refreshAttemptExpiry(attempt); + if (challenge.kind === 'origin-approval') { + return { + challengeRef, + finalOrigin: challenge.finalOrigin, + kind: 'origin-approval-required', + requestId, + sourceOrigin: challenge.sourceOrigin, + }; + } + return { + attemptNumber: challenge.attemptNumber, + challengeRef, + kind: 'credentials-required', + requestId, + ...(challenge.savedCredentialsRejected + ? { savedCredentialsRejected: true } + : {}), + }; + } + + #createFullAttemptReady( + attempt: AttemptRecord, + progress: FullReadyProgress, + existingLeaseRef?: string + ): FullAttemptReady { + return { + ...(progress.accountSummary === undefined + ? {} + : { accountSummary: progress.accountSummary }), + auth: progress.auth, + ...(progress.credentials === undefined + ? {} + : { credentials: progress.credentials }), + endpoint: progress.endpoint, + epoch: progress.epoch, + identityRevision: progress.identityRevision, + key: sessionKey( + progress.endpoint, + attempt.descriptor, + progress.identityRevision, + progress.principal + ), + kind: 'full-session', + landingUrl: progress.landingUrl, + leaseRef: + existingLeaseRef ?? + this.#uniqueReference( + 'lease', + (candidate) => + this.#leases.has(candidate) || + [...this.#attempts.values()].some( + (other) => + other.ready?.kind === 'full-session' && + other.ready.leaseRef === candidate + ) + ), + principal: progress.principal, + ...(progress.watchdogIntervalSeconds === undefined + ? {} + : { + watchdogIntervalSeconds: progress.watchdogIntervalSeconds, + }), + }; + } + + async #promoteAttempt(attempt: AttemptRecord): Promise<{ + readonly ref: string; + readonly session: SessionRecord; + }> { + if (this.#attempts.get(attempt.ref) !== attempt) { + throw new Error('stalker-attempt-no-longer-active'); + } + let ready = attempt.ready; + if (!ready) { + throw new Error('stalker-attempt-not-ready'); + } + if (ready.kind === 'stateless-mac') { + throw new Error('stalker-stateless-attempt-has-no-lease'); + } + + const snapshot = attempt.coordinator.snapshot; + if ( + snapshot.epoch !== ready.epoch || + snapshot.activePrincipal !== ready.principal + ) { + const progress = await this.#authenticateFresh( + attempt, + ready.credentials, + ready.principal + ); + if (progress.kind !== 'full-ready') { + throw new Error( + 'stalker-session-promotion-revalidation-failed' + ); + } + ready = this.#createFullAttemptReady( + attempt, + progress, + ready.leaseRef + ); + attempt.ready = ready; + } + const finalSnapshot = attempt.coordinator.snapshot; + if ( + this.#attempts.get(attempt.ref) !== attempt || + finalSnapshot.epoch !== ready.epoch || + finalSnapshot.activePrincipal !== ready.principal + ) { + throw new Error('stalker-session-promotion-became-stale'); + } + + const destination = this.#sessions.get(ready.key); + const promoted: SessionRecord = { + ...(ready.accountSummary === undefined + ? {} + : { + accountSummary: sanitizeAccountSummary( + ready.accountSummary, + attempt.descriptor, + ready.credentials + ), + }), + activeLeases: new Set(), + approvedOrigins: new Set(attempt.approvedOrigins), + auth: ready.auth, + ...(ready.credentials === undefined + ? {} + : { credentials: ready.credentials }), + coordinator: attempt.coordinator, + descriptor: persistedDescriptor(attempt.descriptor), + endpoint: ready.endpoint, + epoch: ready.epoch, + generation: (destination?.generation ?? 0) + 1, + identityRevision: ready.identityRevision, + key: ready.key, + landingUrl: ready.landingUrl, + leases: new Set(), + principal: ready.principal, + transport: attempt.transport, + ...(ready.watchdogIntervalSeconds === undefined + ? {} + : { + watchdogIntervalSeconds: ready.watchdogIntervalSeconds, + }), + }; + + if (destination) { + this.#playbackContexts?.invalidateAuthGeneration( + destination.key, + destination.generation + ); + this.#transferAllLeases(destination, promoted); + this.#sessions.delete(destination.key); + this.#watchdog.cleanupSession(destination.key); + } + for (const previous of attempt.previousSessions) { + this.#transferPlaylistLeases( + previous, + promoted, + attempt.senderId, + attempt.descriptor.playlistRef + ); + } + + const lease: LeaseRecord = { + active: false, + playlistRef: attempt.descriptor.playlistRef, + ref: ready.leaseRef, + senderId: attempt.senderId, + session: promoted, + }; + promoted.leases.add(lease.ref); + this.#leases.set(lease.ref, lease); + this.#sessions.set(promoted.key, promoted); + this.#reactivateTransferredLeases(promoted); + return { ref: lease.ref, session: promoted }; + } + + async #controlAttempt( + senderId: number, + action: 'commit' | 'discard', + attemptRef: string, + requestId: string + ): Promise { + const tombstone = this.#attemptTombstones.get(attemptRef); + if (tombstone) { + return tombstone.senderId === senderId && + tombstone.action === action + ? controlSuccess(requestId, action) + : invalidIdentityFailure(requestId, 'promoting'); + } + const attempt = this.#attempts.get(attemptRef); + if (!attempt || attempt.senderId !== senderId) { + return invalidIdentityFailure(requestId, 'promoting'); + } + if (action === 'discard') { + await this.#terminateAttempt(attempt, true); + this.#rememberAttempt(attempt, action); + return controlSuccess(requestId, action); + } + if (!attempt.ready) { + return invalidIdentityFailure(requestId, 'promoting'); + } + if (attempt.ready.kind === 'stateless-mac') { + this.#finishAttempt(attempt, action); + return controlSuccess(requestId, action); + } + try { + await this.#promoteAttempt(attempt); + } catch { + if (this.#attempts.get(attempt.ref) === attempt) { + await this.#terminateAttempt(attempt, true); + } + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.SessionPromotionFailed, + 'promoting', + false + ); + } + this.#finishAttempt(attempt, action); + return controlSuccess(requestId, action); + } + + #finishAttempt(attempt: AttemptRecord, action: 'commit' | 'discard'): void { + this.#challengeRegistry.invalidateAttempt( + attempt.senderId, + attempt.ref + ); + this.#attempts.delete(attempt.ref); + this.#rememberAttempt(attempt, action); + attempt.auth = undefined; + attempt.credentialCandidate = undefined; + attempt.ready = undefined; + } + + #rememberAttempt( + attempt: AttemptRecord, + action: 'commit' | 'discard' + ): void { + this.#attemptTombstones.set(attempt.ref, { + action, + expiresAt: this.#now() + ATTEMPT_TTL_MS, + playlistRef: attempt.descriptor.playlistRef, + senderId: attempt.senderId, + }); + this.#scheduleExpiryCleanup(); + } + + async #terminateAttempt( + attempt: AttemptRecord, + restorePrevious: boolean + ): Promise { + this.#challengeRegistry.invalidateAttempt( + attempt.senderId, + attempt.ref + ); + this.#attempts.delete(attempt.ref); + if (attempt.ready?.kind === 'full-session') { + this.#playbackContexts?.invalidateLease(attempt.ready.leaseRef); + } + attempt.auth = undefined; + attempt.credentialCandidate = undefined; + attempt.ready = undefined; + this.#scheduleExpiryCleanup(); + if (restorePrevious) { + await this.#restorePreviousSessions(attempt); + } + } + + async #restorePreviousSessions(attempt: AttemptRecord): Promise { + for (const previous of attempt.previousSessions) { + if ( + [...previous.leases].some( + (ref) => this.#leases.get(ref)?.session === previous + ) + ) { + const snapshot = previous.coordinator.snapshot; + if ( + snapshot.epoch !== previous.epoch || + snapshot.activePrincipal !== previous.principal + ) { + await this.#refreshSession(previous); + } + } + } + } + + async #refreshSession(session: SessionRecord): Promise { + if (session.refreshPromise) { + return session.refreshPromise; + } + const refresh = this.#performRefresh(session); + session.refreshPromise = refresh; + try { + return await refresh; + } finally { + if (session.refreshPromise === refresh) { + session.refreshPromise = undefined; + } + } + } + + async #refreshSessionAndRequest( + session: SessionRecord, + parameters: Readonly> + ): Promise { + if (session.refreshPromise) { + const joined = await session.refreshPromise; + return joined.kind === 'ready' + ? this.#runSessionRequest(joined.session, parameters) + : joined; + } + + const operation = this.#performRefreshAndRequest(session, parameters); + const refresh = operation.then( + (result): RefreshResult => + result.kind === 'completed' + ? { kind: 'ready', session: result.binding.session } + : result.kind === 'suspended' + ? { + kind: 'failure', + outcome: authRefreshExhausted(''), + } + : result + ); + session.refreshPromise = refresh; + try { + return await operation; + } finally { + if (session.refreshPromise === refresh) { + session.refreshPromise = undefined; + } + } + } + + async #performRefresh(session: SessionRecord): Promise { + const attempt = this.#createRefreshAttempt(session); + const installed: { session?: SessionRecord } = {}; + const progress = await this.#authenticateFresh( + attempt, + session.credentials, + session.principal, + async (ready) => { + if (this.#isLiveSession(session)) { + installed.session = this.#installRefresh(session, ready); + } + } + ); + if (progress.kind !== 'full-ready') { + return this.#refreshFailureFromProgress(progress); + } + if (!installed.session) { + return { + kind: 'failure', + outcome: authRefreshExhausted(''), + }; + } + return { kind: 'ready', session: installed.session }; + } + + async #performRefreshAndRequest( + session: SessionRecord, + parameters: Readonly> + ): Promise { + const attempt = this.#createRefreshAttempt(session); + const triggered: { + execution?: Extract; + } = {}; + const progress = await this.#authenticateFresh( + attempt, + session.credentials, + session.principal, + async (ready) => { + if (!this.#isLiveSession(session)) { + return; + } + const installed = this.#installRefresh(session, ready); + const binding = this.#captureBinding(installed); + triggered.execution = { + binding, + kind: 'completed', + outcome: await binding.auth.request(parameters), + }; + } + ); + if (progress.kind !== 'full-ready') { + return this.#refreshFailureFromProgress(progress); + } + return ( + triggered.execution ?? { + kind: 'failure', + outcome: authRefreshExhausted(''), + } + ); + } + + #createRefreshAttempt(session: SessionRecord): AttemptRecord { + return { + approvedOrigins: new Set(session.approvedOrigins), + coordinator: session.coordinator, + credentialCandidate: session.credentials, + descriptor: session.descriptor, + expiresAt: this.#now() + ATTEMPT_TTL_MS, + lastIdentityRevision: session.identityRevision, + previousSessions: new Set(), + ref: '', + senderId: -1, + state: 'authenticating', + transport: session.transport, + }; + } + + #refreshFailureFromProgress( + progress: Exclude + ): Exclude { + if (progress.kind === 'origin-approval-required') { + return { kind: progress.kind, progress }; + } + if ( + progress.kind === 'failure' && + progress.reason === + STALKER_SESSION_FAILURE_REASONS.PrincipalTransitionRequired + ) { + return { + kind: 'failure', + outcome: failure('', progress.reason, 'refreshing', false), + }; + } + const outcome = + progress.kind === 'failure' + ? withRequestId(progress, '') + : failure( + '', + STALKER_SESSION_FAILURE_REASONS.PrincipalTransitionRequired, + 'refreshing', + false + ); + return { kind: 'failure', outcome }; + } + + #installRefresh( + session: SessionRecord, + progress: FullReadyProgress + ): SessionRecord { + const nextKey = sessionKey( + progress.endpoint, + session.descriptor, + progress.identityRevision, + progress.principal + ); + const collision = this.#sessions.get(nextKey); + this.#playbackContexts?.invalidateAuthGeneration( + session.key, + session.generation + ); + if (collision && collision !== session) { + this.#playbackContexts?.invalidateAuthGeneration( + collision.key, + collision.generation + ); + const replacement: SessionRecord = { + ...(progress.accountSummary === undefined + ? {} + : { + accountSummary: sanitizeAccountSummary( + progress.accountSummary, + session.descriptor, + progress.credentials + ), + }), + activeLeases: new Set(), + approvedOrigins: new Set(session.approvedOrigins), + auth: progress.auth, + ...(progress.credentials === undefined + ? {} + : { credentials: progress.credentials }), + coordinator: session.coordinator, + descriptor: session.descriptor, + endpoint: progress.endpoint, + epoch: progress.epoch, + generation: + Math.max(session.generation, collision.generation) + 1, + identityRevision: progress.identityRevision, + key: nextKey, + landingUrl: progress.landingUrl, + leases: new Set(), + principal: progress.principal, + transport: session.transport, + ...(progress.watchdogIntervalSeconds === undefined + ? {} + : { + watchdogIntervalSeconds: + progress.watchdogIntervalSeconds, + }), + }; + this.#transferAllLeases(collision, replacement); + this.#transferAllLeases(session, replacement); + this.#sessions.delete(collision.key); + this.#sessions.delete(session.key); + this.#watchdog.cleanupSession(collision.key); + this.#watchdog.cleanupSession(session.key); + this.#sessions.set(nextKey, replacement); + this.#reactivateTransferredLeases(replacement); + return replacement; + } + + const previousKey = session.key; + this.#watchdog.cleanupSession(previousKey); + if (previousKey !== nextKey) { + this.#sessions.delete(previousKey); + } + session.auth = progress.auth; + session.endpoint = progress.endpoint; + session.epoch = progress.epoch; + session.generation += 1; + session.identityRevision = progress.identityRevision; + session.key = nextKey; + session.landingUrl = progress.landingUrl; + session.accountSummary = + progress.accountSummary === undefined + ? undefined + : sanitizeAccountSummary( + progress.accountSummary, + session.descriptor, + progress.credentials + ); + session.credentials = progress.credentials; + session.watchdogIntervalSeconds = progress.watchdogIntervalSeconds; + this.#sessions.set(nextKey, session); + this.#reactivateTransferredLeases(session); + return session; + } + + async #runSessionRequest( + session: SessionRecord, + parameters: Readonly> + ): Promise { + const binding = this.#captureBinding(session); + const read = await session.coordinator.runRead( + binding.principal, + binding.epoch, + () => binding.auth.request(parameters) + ); + return read.kind === 'completed' + ? { binding, kind: 'completed', outcome: read.value } + : { kind: 'suspended' }; + } + + #captureBinding(session: SessionRecord): SessionGenerationBinding { + return { + auth: session.auth, + epoch: session.epoch, + generation: session.generation, + key: session.key, + principal: session.principal, + session, + }; + } + + #projectRefreshFailure( + senderId: number, + session: SessionRecord, + result: Exclude, + requestId: string + ): StalkerSessionConnectionOutcome { + if (result.kind === 'origin-approval-required') { + return this.#issueRecoveryOriginChallenge( + senderId, + session, + { + finalOrigin: result.progress.finalOrigin, + kind: 'origin-approval-required', + sourceOrigin: result.progress.sourceOrigin, + targetUrl: result.progress.landingUrl, + }, + requestId + ); + } + return withRequestId(result.outcome, requestId); + } + + #issueRecoveryOriginChallenge( + senderId: number, + session: SessionRecord, + outcome: Extract< + StalkerAuthenticatedRequestOutcome, + { kind: 'origin-approval-required' } + >, + requestId: string + ): StalkerSessionConnectionOutcome { + const descriptor: StalkerSessionConnectionDescriptor = { + ...session.descriptor, + connectionMode: 'provisional', + provisionalReason: 'migration', + }; + const attempt = this.#createAttempt(senderId, { + descriptor, + transport: session.transport, + }); + attempt.previousSessions.add(session); + attempt.credentialCandidate = session.credentials; + attempt.lastEndpoint = session.endpoint; + attempt.lastIdentityRevision = session.identityRevision; + attempt.lastLandingUrl = session.landingUrl; + this.#attempts.set(attempt.ref, attempt); + this.#scheduleExpiryCleanup(); + return this.#issueChallenge( + attempt, + { + finalOrigin: outcome.finalOrigin, + kind: 'origin-approval', + landingPath: landingPath(outcome.targetUrl), + sourceOrigin: outcome.sourceOrigin, + }, + requestId + ); + } + + async #registerPlaybackIfNeeded< + Operation extends StalkerSessionApplicationOperation, + >( + senderId: number, + lease: LeaseRecord, + binding: SessionGenerationBinding, + operation: Operation, + payload: StalkerSessionOperationResult + ): Promise> { + if ( + operation !== STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink || + !this.#playbackContexts || + !binding.auth.preparePlayback || + typeof (payload as { streamUrl?: unknown }).streamUrl !== 'string' + ) { + return payload; + } + const prepared = await binding.auth.preparePlayback( + (payload as { streamUrl: string }).streamUrl + ); + if (!this.#isCurrentBinding(lease, binding)) { + throw new Error('stalker-playback-lease-became-stale'); + } + const playbackContextRef = this.#playbackContexts.register({ + authGeneration: binding.generation, + coordinatorEpoch: binding.epoch, + headers: prepared.headers, + leaseRef: lease.ref, + senderId, + sessionKey: binding.key, + streamUrl: prepared.streamUrl, + }); + return { + ...(payload as object), + playbackContextRef, + streamUrl: prepared.streamUrl, + } as StalkerSessionOperationResult; + } + + #activateLease(lease: LeaseRecord): void { + if (lease.active) { + return; + } + lease.active = true; + lease.session.activeLeases.add(lease.ref); + this.#watchdog.activate(watchdogActivation(lease)); + } + + #deactivateLease(lease: LeaseRecord): void { + if (!lease.active) { + return; + } + lease.active = false; + lease.session.activeLeases.delete(lease.ref); + this.#watchdog.deactivate(watchdogDeactivation(lease)); + } + + #closeLease(lease: LeaseRecord): void { + this.#playbackContexts?.invalidateLease(lease.ref); + this.#deactivateLease(lease); + this.#leases.delete(lease.ref); + lease.session.leases.delete(lease.ref); + if (lease.session.leases.size === 0) { + this.#destroySession(lease.session); + } + } + + #destroySession(session: SessionRecord): void { + this.#playbackContexts?.invalidateSession(session.key); + if (this.#sessions.get(session.key) === session) { + this.#sessions.delete(session.key); + } + this.#watchdog.cleanupSession(session.key); + session.activeLeases.clear(); + session.leases.clear(); + session.credentials = undefined; + session.refreshPromise = undefined; + } + + #transferAllLeases( + source: SessionRecord, + destination: SessionRecord + ): void { + for (const ref of [...source.leases]) { + const lease = this.#leases.get(ref); + if (!lease || lease.session !== source) { + continue; + } + this.#playbackContexts?.invalidateLease(ref); + lease.session = destination; + destination.leases.add(ref); + if (lease.active) { + destination.activeLeases.add(ref); + } + } + source.leases.clear(); + source.activeLeases.clear(); + source.credentials = undefined; + } + + #transferPlaylistLeases( + source: SessionRecord, + destination: SessionRecord, + senderId: number, + playlistRef: string + ): void { + if (source === destination) { + return; + } + for (const ref of [...source.leases]) { + const lease = this.#leases.get(ref); + if ( + !lease || + lease.session !== source || + lease.senderId !== senderId || + lease.playlistRef !== playlistRef + ) { + continue; + } + this.#playbackContexts?.invalidateLease(ref); + source.leases.delete(ref); + source.activeLeases.delete(ref); + lease.session = destination; + destination.leases.add(ref); + if (lease.active) { + destination.activeLeases.add(ref); + } + } + if (source.leases.size === 0) { + this.#destroySession(source); + } + } + + #reactivateTransferredLeases(session: SessionRecord): void { + for (const ref of session.activeLeases) { + const lease = this.#leases.get(ref); + if (lease?.session === session) { + this.#watchdog.activate(watchdogActivation(lease)); + } + } + } + + #ownedLease( + senderId: number, + leaseRef: string | undefined + ): LeaseRecord | undefined { + if (typeof leaseRef !== 'string') { + return undefined; + } + const lease = this.#leases.get(leaseRef); + return lease?.senderId === senderId ? lease : undefined; + } + + #isCurrentLease(lease: LeaseRecord): boolean { + return ( + this.#leases.get(lease.ref) === lease && + lease.session.leases.has(lease.ref) + ); + } + + #isCurrentBinding( + lease: LeaseRecord, + binding: SessionGenerationBinding + ): boolean { + const snapshot = binding.session.coordinator.snapshot; + return ( + this.#isCurrentGenerationBinding(lease, binding) && + snapshot.epoch === binding.epoch && + snapshot.activePrincipal === binding.principal + ); + } + + #isCurrentGenerationBinding( + lease: LeaseRecord, + binding: SessionGenerationBinding + ): boolean { + const session = binding.session; + return ( + this.#isCurrentLease(lease) && + lease.session === session && + session.auth === binding.auth && + session.epoch === binding.epoch && + session.generation === binding.generation && + session.key === binding.key && + session.principal === binding.principal + ); + } + + #isLiveSession(session: SessionRecord): boolean { + return ( + this.#sessions.get(session.key) === session && + [...session.leases].some( + (ref) => this.#leases.get(ref)?.session === session + ) + ); + } + + #invalidateOlderCoordinatorEpochs( + coordinator: StalkerBaseIdentityCoordinator, + mutationEpoch: number + ): void { + for (const session of this.#sessions.values()) { + if ( + session.coordinator === coordinator && + session.epoch < mutationEpoch + ) { + this.#playbackContexts?.invalidateCoordinatorEpoch( + session.key, + session.epoch + ); + } + } + } + + #findPreviousSessions( + senderId: number, + playlistRef: string + ): Set { + const sessions = new Set(); + for (const lease of this.#leases.values()) { + if ( + lease.senderId === senderId && + lease.playlistRef === playlistRef + ) { + sessions.add(lease.session); + } + } + return sessions; + } + + #refreshAttemptExpiry(attempt: AttemptRecord): void { + if (this.#attempts.get(attempt.ref) !== attempt) { + return; + } + attempt.expiresAt = this.#now() + ATTEMPT_TTL_MS; + this.#scheduleExpiryCleanup(); + } + + async #pruneExpired(): Promise { + if (this.#expiryCleanupPromise) { + return this.#expiryCleanupPromise; + } + const cleanup = this.#performExpiryCleanup(); + this.#expiryCleanupPromise = cleanup; + try { + await cleanup; + } finally { + if (this.#expiryCleanupPromise === cleanup) { + this.#expiryCleanupPromise = undefined; + } + this.#scheduleExpiryCleanup(); + } + } + + async #performExpiryCleanup(): Promise { + const now = this.#now(); + for (const attempt of [...this.#attempts.values()]) { + if (attempt.expiresAt <= now) { + await this.#terminateAttempt(attempt, true); + } + } + for (const [ref, tombstone] of this.#attemptTombstones) { + if (tombstone.expiresAt <= now) { + this.#attemptTombstones.delete(ref); + } + } + } + + #scheduleExpiryCleanup(): void { + this.#clearExpiryTimer(); + const expiries = [ + ...[...this.#attempts.values()].map((attempt) => attempt.expiresAt), + ...[...this.#attemptTombstones.values()].map( + (tombstone) => tombstone.expiresAt + ), + ]; + if (expiries.length === 0) { + return; + } + const delay = Math.max(0, Math.min(...expiries) - this.#now()); + const timer = setTimeout(() => { + if (this.#expiryTimer === timer) { + this.#expiryTimer = undefined; + } + void this.#pruneExpired().catch(() => undefined); + }, delay); + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref(); + } + this.#expiryTimer = timer; + } + + #clearExpiryTimer(): void { + if (this.#expiryTimer === undefined) { + return; + } + clearTimeout(this.#expiryTimer); + this.#expiryTimer = undefined; + } + + #requestId(): string { + return this.#createReference('request'); + } + + #uniqueReference( + kind: 'attempt' | 'lease', + exists: (candidate: string) => boolean + ): string { + for (let index = 0; index < MAX_REFERENCE_ATTEMPTS; index += 1) { + const candidate = this.#createReference(kind); + if (!exists(candidate)) { + return candidate; + } + } + throw new Error(`stalker-${kind}-reference-generation-failed`); + } + + #createReference(kind: 'attempt' | 'lease' | 'request'): string { + const value = this.#createRef(kind); + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + hasControlCharacters(value) + ) { + throw new Error(`invalid-stalker-${kind}-reference`); + } + return value; + } +} + +function endpointProgress( + outcome: Exclude +): AuthenticationProgress { + if (outcome.kind === 'origin-approval-required') { + return { + finalOrigin: outcome.finalOrigin, + kind: outcome.kind, + landingUrl: outcome.landingUrl, + sourceOrigin: outcome.sourceOrigin, + }; + } + if (outcome.kind === 'failure') { + return failure( + '', + outcome.reason, + outcome.stage, + outcome.retryable, + outcome.retryAfterSeconds + ); + } + return outcome; +} + +function authProgress( + outcome: Exclude, + auth: StalkerSessionAuthLike, + resolved: StalkerEndpointFullSessionOutcome, + epoch: number +): AuthenticationProgress { + return authProgressFromKnownEndpoint( + outcome, + auth, + resolved.endpoint, + resolved.landingUrl, + epoch + ); +} + +function authProgressFromKnownEndpoint( + outcome: Exclude, + auth: StalkerSessionAuthLike, + endpoint: string, + landingUrl: string, + epoch: number +): AuthenticationProgress { + if (outcome.kind === 'credentials-required') { + return { + auth, + endpoint, + epoch, + kind: outcome.kind, + landingUrl, + outcome, + }; + } + if (outcome.kind === 'origin-approval-required') { + return { + finalOrigin: outcome.finalOrigin, + kind: outcome.kind, + landingUrl: outcome.targetUrl, + sourceOrigin: outcome.sourceOrigin, + }; + } + return failure( + '', + outcome.reason, + outcome.stage, + outcome.retryable, + outcome.retryAfterSeconds + ); +} + +function fullReadyProgress( + auth: StalkerSessionAuthLike, + outcome: StalkerAuthReadyOutcome, + resolved: StalkerEndpointFullSessionOutcome, + epoch: number, + principal: string, + credentials: StalkerAuthCredentials | undefined, + attempt: AttemptRecord +): FullReadyProgress { + return fullReadyFromKnownEndpoint( + auth, + outcome, + resolved.endpoint, + resolved.landingUrl, + epoch, + attempt.lastIdentityRevision ?? + effectiveIdentityRevision(resolved.identity, attempt.descriptor), + principal, + credentials, + attempt + ); +} + +function fullReadyFromKnownEndpoint( + auth: StalkerSessionAuthLike, + outcome: StalkerAuthReadyOutcome, + endpoint: string, + landingUrl: string, + epoch: number, + identityRevision: string, + principal: string, + credentials: StalkerAuthCredentials | undefined, + attempt: AttemptRecord +): FullReadyProgress { + return { + ...(outcome.accountSummary === undefined + ? {} + : { + accountSummary: sanitizeAccountSummary( + outcome.accountSummary, + attempt.descriptor, + credentials + ), + }), + auth, + ...(credentials === undefined ? {} : { credentials }), + endpoint, + epoch, + identityRevision, + kind: 'full-ready', + landingUrl, + principal, + ...(outcome.watchdogIntervalSeconds === undefined + ? {} + : { + watchdogIntervalSeconds: outcome.watchdogIntervalSeconds, + }), + }; +} + +function confirmedPrincipal(auth: StalkerSessionAuthLike): string { + if (!auth.hasAcceptedCredentials()) { + return 'mac-only'; + } + const confirmed = auth.getPrincipalKey(); + if ( + typeof confirmed !== 'string' || + confirmed.length === 0 || + Buffer.byteLength(confirmed, 'utf8') > 512 + ) { + throw new PrincipalTransitionSignal(); + } + return `username:${createHash('sha256') + .update(serializeStalkerPrincipalInput(confirmed)) + .digest('hex')}`; +} + +function effectiveIdentityRevision( + identity: StalkerIdentityProfile, + descriptor: StalkerSessionConnectionDescriptor +): string { + const headers = new Map( + Object.entries(identity.headers).map(([name, value]) => [ + name.toLowerCase(), + value, + ]) + ); + const profile = identity.profileParameters; + const transport = descriptor.transportConfiguration ?? {}; + const profileOverrides: Record = {}; + for (const [name, value] of [...headers].sort(compareRecordEntries)) { + if (name !== 'authorization' && name !== 'cookie') { + profileOverrides[`header.${name}`] = value; + } + } + addEffectiveRecord( + profileOverrides, + 'cookie', + identity.managedCookies, + new Set(['mac']) + ); + addEffectiveRecord(profileOverrides, 'profile', profile); + addEffectiveRecord( + profileOverrides, + 'metric', + identity.metrics, + new Set(['mac', 'random']) + ); + + return serializeStalkerIdentityRevision({ + apiSignature: effectiveString(profile['api_signature']), + deviceId1: effectiveString(profile['device_id']), + deviceId2: effectiveString(profile['device_id2']), + firmwareVersion: effectiveString(profile['ver']), + hardwareVersion: effectiveString(profile['hw_version']), + hardwareVersion2: effectiveString(profile['hw_version_2']), + imageVersion: effectiveString(profile['image_version']), + language: effectiveString(profile['language']), + locale: headers.get('accept-language'), + numberOfBanks: effectiveString(profile['num_banks']), + origin: headers.get('origin'), + originPolicy: transport.originPolicy, + prehash: effectiveString(profile['prehash']), + presetId: identity.preset.id, + presetVersion: identity.preset.version, + profileOverrides, + referer: headers.get('referer'), + refererPolicy: transport.refererPolicy, + serialNumber: effectiveString(profile['sn']), + signature1: effectiveString(profile['signature']), + signature2: effectiveString(profile['signature2']), + timezone: effectiveString(profile['timezone']), + userAgent: headers.get('user-agent'), + videoOutput: effectiveString(profile['video_out']), + xUserAgent: headers.get('x-user-agent'), + }); +} + +function addEffectiveRecord( + target: Record, + prefix: string, + source: Readonly>, + excluded = new Set() +): void { + for (const [name, value] of Object.entries(source).sort( + compareRecordEntries + )) { + if (!excluded.has(name)) { + target[`${prefix}.${name}`] = String(value); + } + } +} + +function compareRecordEntries( + [left]: readonly [string, unknown], + [right]: readonly [string, unknown] +): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function effectiveString( + value: string | number | undefined +): string | undefined { + return value === undefined ? undefined : String(value); +} + +function sessionKey( + endpoint: string, + descriptor: StalkerSessionConnectionDescriptor, + identityRevision: string, + principal: string +): string { + return createHash('sha256') + .update( + JSON.stringify([ + normalizeStalkerSourceUrl(endpoint), + normalizeStalkerMacAddress(descriptor.macAddress), + identityRevision, + principal, + ]) + ) + .digest('hex'); +} + +function persistedDescriptor( + descriptor: StalkerSessionConnectionDescriptor +): StalkerSessionConnectionDescriptor { + return { + connectionMode: 'persisted-open', + ...(descriptor.identityOverrides === undefined + ? {} + : { identityOverrides: descriptor.identityOverrides }), + ...(descriptor.learnedEndpointHint === undefined + ? {} + : { learnedEndpointHint: descriptor.learnedEndpointHint }), + macAddress: descriptor.macAddress, + playlistRef: descriptor.playlistRef, + profilePreset: descriptor.profilePreset, + sourceUrl: descriptor.sourceUrl, + ...(descriptor.transportConfiguration === undefined + ? {} + : { + transportConfiguration: descriptor.transportConfiguration, + }), + }; +} + +function fullReadyOutcome( + requestId: string, + descriptor: StalkerSessionConnectionDescriptor, + lease: { readonly ref: string; readonly session?: SessionRecord }, + progress: FullReadyProgress, + attemptRef: string | undefined, + now: number +): StalkerSessionConnectionOutcome { + const accountSummary = + lease.session?.accountSummary ?? + sanitizeAccountSummary( + progress.accountSummary, + descriptor, + progress.credentials + ); + return { + ...(accountSummary === undefined ? {} : { accountSummary }), + capabilities: { + authenticatedSession: true, + playbackContext: true, + }, + ...connectionState(descriptor, attemptRef), + endpoint: progress.endpoint, + kind: 'ready', + landingUrl: progress.landingUrl, + leaseRef: lease.ref, + persistenceDraft: persistenceDraft( + descriptor, + progress.endpoint, + progress.landingUrl, + 'full-session', + now + ), + recipe: 'full-session', + requestId, + }; +} + +function statelessReadyOutcome( + requestId: string, + descriptor: StalkerSessionConnectionDescriptor, + ready: StatelessAttemptReady, + attemptRef: string | undefined, + now: number +): StalkerSessionConnectionOutcome { + return { + ...connectionState(descriptor, attemptRef), + endpoint: ready.endpoint, + kind: 'ready', + landingUrl: ready.landingUrl, + persistenceDraft: persistenceDraft( + descriptor, + ready.endpoint, + ready.landingUrl, + 'stateless-mac', + now + ), + recipe: 'stateless-mac', + requestId, + }; +} + +function connectionState( + descriptor: StalkerSessionConnectionDescriptor, + attemptRef?: string +): + | { + readonly connectionMode: 'persisted-open'; + } + | { + readonly attemptRef: string; + readonly connectionMode: 'provisional'; + readonly provisionalReason: 'edit' | 'import' | 'migration'; + } { + if (descriptor.connectionMode === 'persisted-open') { + return { connectionMode: descriptor.connectionMode }; + } + if (!attemptRef) { + throw new Error('missing-stalker-provisional-attempt-ref'); + } + return { + attemptRef, + connectionMode: descriptor.connectionMode, + provisionalReason: descriptor.provisionalReason, + }; +} + +function persistenceDraft( + descriptor: StalkerSessionConnectionDescriptor, + endpoint: string, + landingUrl: string, + recipe: 'full-session' | 'stateless-mac', + now: number +): StalkerSessionPersistenceDraft { + return { + isFullStalkerPortal: recipe === 'full-session', + portalUrl: endpoint, + stalkerLandingUrl: landingUrl, + stalkerLastVerifiedAt: new Date(now).toISOString(), + stalkerRecipeClassifierVersion: RECIPE_CLASSIFIER_VERSION, + stalkerRequestRecipe: recipe, + stalkerSourceUrl: descriptor.sourceUrl, + }; +} + +function sanitizeAccountSummary( + summary: StalkerSessionAccountSummary | undefined, + descriptor: StalkerSessionConnectionDescriptor, + credentials: StalkerAuthCredentials | undefined +): StalkerSessionAccountSummary | undefined { + if (!summary) { + return undefined; + } + const forbidden = new Set( + [ + credentials?.username, + credentials?.password, + ...Object.values(descriptor.identityOverrides ?? {}), + ].filter( + (value): value is string => + typeof value === 'string' && value.length > 0 + ) + ); + const sanitized = Object.fromEntries( + Object.entries(summary).filter( + ([, value]) => + typeof value === 'string' && + value.length > 0 && + !forbidden.has(value) + ) + ) as StalkerSessionAccountSummary; + return Object.keys(sanitized).length === 0 ? undefined : sanitized; +} + +function projectAuthFailure( + requestId: string, + outcome: Exclude< + StalkerAuthenticatedRequestOutcome, + { kind: 'success' | 'token-rejected' | 'origin-approval-required' } + > +): StalkerSessionFailureOutcome { + return failure( + requestId, + outcome.reason, + outcome.stage, + outcome.retryable, + outcome.retryAfterSeconds + ); +} + +function requiresEndpointRediscovery( + outcome: StalkerAuthenticatedRequestOutcome +): outcome is Extract { + return ( + outcome.kind === 'failure' && + (outcome.reason === STALKER_SESSION_FAILURE_REASONS.EndpointNotFound || + outcome.reason === + STALKER_SESSION_FAILURE_REASONS.IncompatibleResponse) + ); +} + +function failure( + requestId: string, + reason: StalkerSessionFailureOutcome['reason'], + stage: StalkerSessionStage, + retryable: boolean, + retryAfterSeconds?: number +): StalkerSessionFailureOutcome { + return { + kind: 'failure', + reason, + requestId, + retryable, + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), + stage, + }; +} + +function invalidIdentityFailure( + requestId: string, + stage: StalkerSessionStage +): StalkerSessionFailureOutcome { + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.InvalidIdentityInput, + stage, + false + ); +} + +function authRefreshExhausted(requestId: string): StalkerSessionFailureOutcome { + return failure( + requestId, + STALKER_SESSION_FAILURE_REASONS.AuthRefreshExhausted, + 'refreshing', + false + ); +} + +function withRequestId( + outcome: StalkerSessionFailureOutcome, + requestId: string +): StalkerSessionFailureOutcome { + return { ...outcome, requestId }; +} + +function controlSuccess( + requestId: string, + action: StalkerSessionControlAction +): StalkerSessionControlOutcome { + return { action, kind: 'success', requestId }; +} + +function validSender(senderId: number): boolean { + return Number.isSafeInteger(senderId) && senderId >= 0; +} + +function hasControlCharacters(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + if (code <= 0x1f || code === 0x7f) { + return true; + } + } + return false; +} + +function landingPath(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } catch { + return '/'; + } +} + +function watchdogActivation(lease: LeaseRecord): StalkerWatchdogActivation { + return { + leaseRef: lease.ref, + principal: lease.session.principal, + ...(lease.session.watchdogIntervalSeconds === undefined + ? {} + : { + profile: { + watchdog_timeout: lease.session.watchdogIntervalSeconds, + }, + }), + sessionKey: lease.session.key, + }; +} + +function watchdogDeactivation(lease: LeaseRecord): StalkerWatchdogDeactivation { + return { + leaseRef: lease.ref, + principal: lease.session.principal, + sessionKey: lease.session.key, + }; +}