feat(stalker): load matching saved credentials

This commit is contained in:
4gray committed 2026-07-27 11:22:50 +02:00
1 parent 0022604c47
commit c042af85db
2 files changed
+298

No files matched your search

@@ -0,0 +1,109 @@
import type { StalkerSessionConnectionDescriptor } from '@iptvnator/shared/interfaces';
import { createStalkerSavedCredentialsLoader } from './stalker-saved-credentials';
const DESCRIPTOR: StalkerSessionConnectionDescriptor = {
connectionMode: 'persisted-open',
identityOverrides: {
deviceId1: 'DEVICE-1',
serialNumber: 'SERIAL-1',
},
learnedEndpointHint:
'https://portal.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:12:34:56',
playlistRef: 'playlist-1',
profilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
sourceUrl: 'https://portal.example/c/',
};
const SAVED_PLAYLIST = {
_id: 'playlist-1',
macAddress: '00-1a-79-12-34-56',
password: 'saved password',
stalkerIdentityOverrides: {
deviceId1: 'DEVICE-1',
serialNumber: 'SERIAL-1',
},
stalkerProfilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
stalkerSourceUrl: 'https://portal.example/c/#ignored',
type: 'stalker',
username: ' saved-user ',
};
describe('createStalkerSavedCredentialsLoader', () => {
it('returns an exact matching Stalker credential pair without exposing the row', async () => {
const readPlaylist = jest.fn().mockResolvedValue(SAVED_PLAYLIST);
const load = createStalkerSavedCredentialsLoader(readPlaylist);
await expect(load(DESCRIPTOR)).resolves.toEqual({
password: 'saved password',
username: 'saved-user',
});
expect(readPlaylist).toHaveBeenCalledWith('playlist-1');
});
it.each([
['source', { stalkerSourceUrl: 'https://other.example/c/' }],
['MAC', { macAddress: '00:1A:79:AA:BB:CC' }],
[
'profile',
{
stalkerProfilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 2,
},
},
],
[
'identity',
{
stalkerIdentityOverrides: {
deviceId1: 'OTHER-DEVICE',
serialNumber: 'SERIAL-1',
},
},
],
['provider type', { type: 'xtream' }],
])('rejects a descriptor/%s mismatch', async (_label, patch) => {
const load = createStalkerSavedCredentialsLoader(async () => ({
...SAVED_PLAYLIST,
...patch,
}));
await expect(load(DESCRIPTOR)).resolves.toBeUndefined();
});
it.each([
['missing row', null],
['missing username', { ...SAVED_PLAYLIST, username: undefined }],
['blank username', { ...SAVED_PLAYLIST, username: ' ' }],
['missing password', { ...SAVED_PLAYLIST, password: undefined }],
['empty password', { ...SAVED_PLAYLIST, password: '' }],
])('ignores %s', async (_label, row) => {
const load = createStalkerSavedCredentialsLoader(async () => row);
await expect(load(DESCRIPTOR)).resolves.toBeUndefined();
});
it('matches legacy identity columns when the structured override is absent', async () => {
const {
stalkerIdentityOverrides: _structured,
...legacyPlaylist
} = SAVED_PLAYLIST;
const load = createStalkerSavedCredentialsLoader(async () => ({
...legacyPlaylist,
stalkerDeviceId1: 'DEVICE-1',
stalkerSerialNumber: 'SERIAL-1',
}));
await expect(load(DESCRIPTOR)).resolves.toEqual({
password: 'saved password',
username: 'saved-user',
});
});
});
@@ -0,0 +1,189 @@
import {
normalizeStalkerMacAddress,
normalizeStalkerSourceUrl,
} from '@iptvnator/portal/stalker/protocol';
import {
LEGACY_DEFAULT_STALKER_SERIAL,
type StalkerSessionConnectionDescriptor,
type StalkerSessionIdentityOverrides,
} from '@iptvnator/shared/interfaces';
import type { StalkerAuthCredentials } from './stalker-auth-session';
type SavedPlaylistReader = (
playlistRef: string
) => Promise<unknown>;
const IDENTITY_KEYS = [
'apiSignature',
'deviceId1',
'deviceId2',
'firmwareVersion',
'hardwareVersion',
'hardwareVersion2',
'imageVersion',
'numberOfBanks',
'prehash',
'serialNumber',
'signature1',
'signature2',
'videoOutput',
] as const satisfies readonly (keyof StalkerSessionIdentityOverrides)[];
export function createStalkerSavedCredentialsLoader(
readPlaylist: SavedPlaylistReader
): (
descriptor: StalkerSessionConnectionDescriptor
) => Promise<StalkerAuthCredentials | undefined> {
return async (descriptor) => {
const value = await readPlaylist(descriptor.playlistRef);
if (!isRecord(value) || !matchesDescriptor(value, descriptor)) {
return undefined;
}
const username = presentString(value['username'])?.trim();
const password = value['password'];
if (
username === undefined ||
typeof password !== 'string' ||
password.length === 0
) {
return undefined;
}
return {
password,
username,
};
};
}
function matchesDescriptor(
playlist: Readonly<Record<string, unknown>>,
descriptor: StalkerSessionConnectionDescriptor
): boolean {
if (
(playlist['type'] !== undefined &&
playlist['type'] !== 'stalker') ||
!matchesSource(playlist, descriptor.sourceUrl) ||
!matchesMac(playlist['macAddress'], descriptor.macAddress) ||
!matchesProfile(playlist['stalkerProfilePreset'], descriptor) ||
!matchesIdentity(playlist, descriptor.identityOverrides)
) {
return false;
}
return true;
}
function matchesSource(
playlist: Readonly<Record<string, unknown>>,
descriptorSourceUrl: string
): boolean {
const source = firstPresentString(
playlist['stalkerSourceUrl'],
playlist['portalUrl'],
playlist['url']
);
if (source === undefined) {
return false;
}
try {
return (
normalizeStalkerSourceUrl(source) ===
normalizeStalkerSourceUrl(descriptorSourceUrl)
);
} catch {
return false;
}
}
function matchesMac(value: unknown, descriptorMac: string): boolean {
if (typeof value !== 'string') {
return false;
}
try {
return (
normalizeStalkerMacAddress(value) ===
normalizeStalkerMacAddress(descriptorMac)
);
} catch {
return false;
}
}
function matchesProfile(
value: unknown,
descriptor: StalkerSessionConnectionDescriptor
): boolean {
if (value === undefined) {
return (
descriptor.profilePreset.id ===
'mag250-public-5_1-minimal-v1' &&
descriptor.profilePreset.version === 1
);
}
return (
isRecord(value) &&
value['id'] === descriptor.profilePreset.id &&
value['version'] === descriptor.profilePreset.version
);
}
function matchesIdentity(
playlist: Readonly<Record<string, unknown>>,
descriptorIdentity: StalkerSessionIdentityOverrides | undefined
): boolean {
const saved = readSavedIdentity(playlist);
const descriptor = descriptorIdentity ?? {};
return IDENTITY_KEYS.every(
(key) => saved[key] === presentString(descriptor[key])
);
}
function readSavedIdentity(
playlist: Readonly<Record<string, unknown>>
): StalkerSessionIdentityOverrides {
const structured = playlist['stalkerIdentityOverrides'];
if (isRecord(structured)) {
return copyIdentity(structured);
}
const serialNumber = presentString(playlist['stalkerSerialNumber']);
return copyIdentity({
deviceId1: playlist['stalkerDeviceId1'],
deviceId2: playlist['stalkerDeviceId2'],
serialNumber:
serialNumber?.toUpperCase() === LEGACY_DEFAULT_STALKER_SERIAL
? undefined
: serialNumber,
signature1: playlist['stalkerSignature1'],
signature2: playlist['stalkerSignature2'],
});
}
function copyIdentity(
value: Readonly<Record<string, unknown>>
): StalkerSessionIdentityOverrides {
return Object.fromEntries(
IDENTITY_KEYS.flatMap((key) => {
const present = presentString(value[key]);
return present === undefined ? [] : [[key, present]];
})
) as StalkerSessionIdentityOverrides;
}
function firstPresentString(...values: readonly unknown[]): string | undefined {
for (const value of values) {
const present = presentString(value);
if (present !== undefined) {
return present;
}
}
return undefined;
}
function presentString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}