feat(stalker): protocol-correct auth lifecycle (#1354)

This commit is contained in:
4gray authored and GitHub committed 2026-08-03 23:06:47 +02:00
1 parent e197409b10
commit d2a83164ec
56 files changed
+4807 -709

No files matched your search

@@ -441,6 +441,115 @@ describe('PlaylistsService', () => {
).toBe('1');
});
it('carries the Stalker session fields through the SQLite fallback read', async () => {
// Without these, every cold Electron session loses the persisted
// cadence AND the identity the token was negotiated for — so the
// mismatch check cannot run and the watchdog falls back to default.
const electron = {
dbGetAppPlaylist: jest.fn(async () => ({
id: 'stalker-1',
stalkerToken: 'TOKEN',
stalkerSessionIdentity: 'fingerprint-1',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 7,
})),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async () => '1'),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
const playlist = await firstValueFrom(
service.getPlaylistById('stalker-1')
);
expect(playlist).toEqual(
expect.objectContaining({
stalkerToken: 'TOKEN',
stalkerSessionIdentity: 'fingerprint-1',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 7,
})
);
});
it('clears a stale watchdog cadence when the portal stops advertising one', async () => {
// Reusing the token skips the profile that would correct the cadence,
// so leaving the old value on the row means the next restart applies
// a cadence the portal no longer asks for.
const existingPlaylist: Playlist = {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
autoRefresh: false,
stalkerToken: 'OLD',
stalkerWatchdogTimeout: 45,
stalkerTimeslot: 7,
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' })
);
const written = dbService.update.mock.calls[0][1] as Playlist;
expect(written.stalkerToken).toBe('NEW');
expect(written.stalkerWatchdogTimeout).toBeUndefined();
expect(written.stalkerTimeslot).toBeUndefined();
});
it('persists the watchdog cadence alongside the stalker token', async () => {
const existingPlaylist: Playlist = {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
autoRefresh: false,
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updateStalkerSession('stalker-1', {
stalkerToken: 'TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
})
);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect.objectContaining({
stalkerToken: 'TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
})
);
});
it('persists hiddenGroupTitles in playlist meta updates', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-1',
@@ -242,6 +242,13 @@ export class PlaylistsService {
stalkerSignature2: playlist.stalkerSignature2,
isFullStalkerPortal: playlist.isFullStalkerPortal,
stalkerToken: playlist.stalkerToken,
// Without these the Electron cold read loses the persisted
// cadence AND the identity the token was negotiated for, so the
// mismatch check cannot run and the watchdog falls back to the
// default.
stalkerSessionIdentity: playlist.stalkerSessionIdentity,
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
stalkerTimeslot: playlist.stalkerTimeslot,
stalkerAccountInfo: playlist.stalkerAccountInfo,
} as Playlist;
}
@@ -729,6 +736,45 @@ export class PlaylistsService {
});
}
/**
* Persists a freshly negotiated Stalker session on the playlist so the
* next app start can re-present the token (the portal handshake is
* idempotent) and keep the portal's own watchdog cadence — reusing a
* token skips the `get_profile` that carries the cadence, so it has to
* survive with the token. No-ops when the playlist row does not exist
* yet: the import flow saves both with the playlist itself.
*/
updateStalkerSession(
playlistId: string,
session: {
stalkerToken: string;
stalkerSessionIdentity?: string;
stalkerWatchdogTimeout?: number;
stalkerTimeslot?: number;
}
) {
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
// The cadence is written unconditionally, including as
// `undefined`: a portal that stops advertising one must not leave
// a stale value behind for the next restart to re-apply, since
// reusing the token skips the profile that would correct it.
return this.persistPlaylistMutation({
...playlist,
stalkerToken: session.stalkerToken,
stalkerSessionIdentity: session.stalkerSessionIdentity,
stalkerWatchdogTimeout: session.stalkerWatchdogTimeout,
stalkerTimeslot: session.stalkerTimeslot,
});
});
}
updateFavorites(id: string, favorites: string[]) {
return this.serializePlaylistWrite(id, async () => {
const playlist = await firstValueFrom(this.getPlaylistById(id));