fix(stalker): retire only the token that actually failed auth

A request dispatched with the previous token can see its authorization
failure arrive after a profile refresh has already cached a fresh one.
The retry path deleted the cache blindly, killing the fresh token and
kicking off another handshake that in turn invalidated tokens of newer
requests — cascading retries on strict portals.

makeAuthenticatedRequest() now retires the cached token only while it
still equals the token that failed; a late failure of a stale token
leaves the refreshed token in place and the retry reuses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-02 09:16:00 +02:00
1 parent 79c643b42b
commit 7bc14d3693
2 files changed
+79 -4

No files matched your search

@@ -283,6 +283,65 @@ describe('StalkerSessionService identity payloads', () => {
expect(service.getCachedToken(playlist._id)).toBe('fresh-token');
});
it('keeps a freshly refreshed token when a stale request fails auth late', async () => {
const playlist = {
_id: 'playlist-5',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
// The request went out with the previous token; meanwhile a
// profile refresh has already cached a fresh one.
jest.spyOn(service, 'ensureToken')
.mockResolvedValueOnce({ token: 'stale-token' })
.mockResolvedValueOnce({ token: 'fresh-token' });
service.setCachedToken(playlist._id, 'fresh-token');
dataService.sendIpcEvent
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
.mockResolvedValueOnce({ js: { data: [] } });
await service.makeAuthenticatedRequest(playlist, {
type: 'itv',
action: 'get_ordered_list',
});
// The late failure of the stale token must not delete the fresh
// one — the retry reuses it instead of forcing a new handshake.
expect(service.getCachedToken(playlist._id)).toBe('fresh-token');
expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ token: 'fresh-token' })
);
});
it('still retires the cached token when it is the one that failed', async () => {
const playlist = {
_id: 'playlist-6',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
jest.spyOn(service, 'ensureToken')
.mockResolvedValueOnce({ token: 'dead-token' })
.mockResolvedValueOnce({ token: 'new-token' });
service.setCachedToken(playlist._id, 'dead-token');
dataService.sendIpcEvent
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
.mockResolvedValueOnce({ js: { data: [] } });
await service.makeAuthenticatedRequest(playlist, {
type: 'itv',
action: 'get_ordered_list',
});
// Existing behavior preserved: the failed token itself is gone.
expect(service.getCachedToken(playlist._id)).toBeNull();
});
it('refreshes the account profile even when a pending authentication fails', async () => {
const playlist = {
_id: 'playlist-2',
@@ -606,6 +606,22 @@ export class StalkerSessionService {
}
}
/**
* Clears the cached token only while it is still the one that just
* failed. A request dispatched with the previous token can see its
* authorization failure arrive after a profile refresh has already
* cached a fresh token — deleting blindly would kill that fresh token
* and trigger a cascade of competing handshakes on strict portals.
*/
private retireFailedToken(
playlistId: string,
failedToken: string | null
): void {
if (failedToken && this.getCachedToken(playlistId) === failedToken) {
this.clearCachedToken(playlistId);
}
}
/**
* Checks if a response or error indicates an authorization failure
*/
@@ -667,8 +683,8 @@ export class StalkerSessionService {
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
// Clear cached token to force re-authentication
this.clearCachedToken(playlist._id);
// Retire the failed token so the retry re-authenticates
this.retireFailedToken(playlist._id, token);
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
@@ -688,8 +704,8 @@ export class StalkerSessionService {
retryOnAuthFailure &&
playlist.isFullStalkerPortal
) {
// Clear cached token and retry with new handshake
this.clearCachedToken(playlist._id);
// Retire the failed token and retry with new handshake
this.retireFailedToken(playlist._id, token);
return this.makeAuthenticatedRequest<T>(
playlist,
params,