fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records

- A server that answers as another backend URL or instance id than the
  remembered one makes the browser forget the stored token, so server A's
  token never reaches server B.
- The session installs its page-hide and heartbeat listeners before it
  returns for sign-in, so a page holding another page load's writes still
  marks itself closed on unload.
- Closed tabs are found by stale records only again: saved writes without
  a record may belong to a running tab whose record storage refused, and
  are left to that tab's own reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 20:08:33 +02:00
1 parent cf715a7573
commit 4908803ceb
7 files changed
+94 -41

No files matched your search

@@ -45,6 +45,28 @@ describe('resolveServerStorageStart', () => {
).toBeNull();
});
it('keeps the token only for the same server and database', () => {
const token = 'iptvnator-server:access-token';
resolveServerStorageStart({ kind: 'server', instance }, '/api');
localStorage.setItem(token, 'secret');
resolveServerStorageStart({ kind: 'server', instance }, '/api/');
expect(localStorage.getItem(token)).toBe('secret');
resolveServerStorageStart(
{ kind: 'server', instance: { ...instance, instanceId: 'i-2' } },
'/api'
);
expect(localStorage.getItem(token)).toBeNull();
localStorage.setItem(token, 'secret');
resolveServerStorageStart(
{ kind: 'server', instance: { ...instance, instanceId: 'i-2' } },
'/other'
);
expect(localStorage.getItem(token)).toBeNull();
});
it('forgets the token of a server that no longer offers storage', () => {
localStorage.setItem('iptvnator-server:access-token', 'old');
@@ -32,9 +32,21 @@ export function resolveServerStorageStart(
> | null = safeLocalStorage()
): ServerStorageStart | null {
switch (probe.kind) {
case 'server':
case 'server': {
const remembered = read(storage);
// Another server, or another database behind this one: the
// stored token was not given for it.
if (
!remembered ||
backendIdentity(remembered.baseUrl) !==
backendIdentity(baseUrl) ||
remembered.instance.instanceId !== probe.instance.instanceId
) {
clearServerAccessToken();
}
write(storage, { baseUrl, instance: probe.instance });
return { instance: probe.instance, startedOffline: false };
}
case 'none':
remove(storage);
// The token belonged to that server; another must not see it.
+7 -5
View File
@@ -272,9 +272,10 @@ settings and guards read them in every build.
`localStorage` (`iptvnator-server:unconfirmed:<instanceId>:<page>`, one
key per page load so tabs never overwrite each other's, renewed every
30 s while it has unsent writes, stale after ten minutes) let a re-read
in one tab keep the cache values another tab is still sending; saved
writes without a fresh record, also when the record was lost, count as a
closed tab's (the storage adapter lists its keys for this). A value too big for `localStorage`, or any
in one tab keep the cache values another tab is still sending, and a
stale record marks a closed tab whose writes another tab adopts (the
storage adapter lists its keys for this). Saved writes without any record
are left to the tab's own reload: that tab may still run. A value too big for `localStorage`, or any
value when the browser refuses storage access, stays readable from
memory, so callers and the diff baseline agree. A re-read
updates the cache; views already open show another device's changes on
@@ -298,8 +299,9 @@ settings and guards read them in every build.
browser that refuses to store it is told so instead).
`ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the
backend, and `PwaService` adds it to its two raw `fetch` calls only while
server mode runs (a backend that answers without server storage also
makes the browser forget the token). Both turn
server mode runs. The browser forgets the token when the backend answers
without server storage, or as another server or database (backend URL or
instance id) than the one it remembers. Both turn
the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`,
so a token changed on the server sends an open page back to sign-in; a
late 401 for a token replaced since (a new sign-in) changes nothing.
@@ -91,6 +91,16 @@ describe('ServerStorageSession', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it('marks the page closed on page hide also while sign-in is pending', async () => {
const { session } = setup('token');
const close = jest.spyOn(session.userData, 'close');
await session.start();
window.dispatchEvent(new Event('pagehide'));
expect(close).toHaveBeenCalled();
});
it('starts with a stored token', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
const { session, status } = setup('token');
@@ -76,6 +76,9 @@ export class ServerStorageSession {
async start(): Promise<void> {
this.backend.useServerStore(this.documentStore);
this.status.activate(this.config.instance, () => void this.refresh());
// Also while sign-in is pending: this page may already hold another
// page load's writes, so it must mark itself closed on page hide.
this.listen();
if (this.config.instance.auth === 'token' && !readServerAccessToken()) {
this.status.reportUnauthorized();
return;
@@ -86,7 +89,6 @@ export class ServerStorageSession {
setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS)
),
]);
this.listen();
}
/** Re-reads synced user data; also the offline retry. */
@@ -120,8 +120,9 @@ export function swapPage(
}
/**
* Page loads with saved writes or a record but no fresh record: closed
* tabs nobody sends for (also when their record was lost).
* Page loads whose record went stale: closed tabs nobody sends for. Saved
* writes without any record are left alone: a tab that could save them but
* not its record may still run, and only its own reload takes them over.
*/
export function orphanedPages(
local: UserDataStorage,
@@ -129,20 +130,13 @@ export function orphanedPages(
page: string,
now = Date.now()
): string[] {
const registry = readRegistry(local, instanceId);
const pages = new Set<string>();
for (const prefix of PAGE_PREFIXES) {
for (const owner of pagesUnder(local, `${prefix}:${instanceId}:`)) {
pages.add(owner);
}
}
return [...pages].filter((owner) => {
const record = registry[owner];
return (
owner !== page &&
!(record && now - Number(record.at) < UNCONFIRMED_TTL_MS)
);
});
return Object.entries(readRegistry(local, instanceId))
.filter(
([owner, record]) =>
owner !== page &&
!(now - Number(record.at) < UNCONFIRMED_TTL_MS)
)
.map(([owner]) => owner);
}
/** Forgets a page load's saved writes, early edits and record. */
@@ -1284,43 +1284,54 @@ describe('ServerUserDataStorage with other devices', () => {
);
});
it("sends a closed tab's saved writes whose record was lost", async () => {
it('leaves saved writes without a record to the tab that saved them', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const shared = memoryStorage();
const closed = new ServerUserDataStorage(
{
...server.api,
sendJson: jest
.fn()
.mockRejectedValue(
new ServerApiError(0, 'network', 'offline')
),
} as never,
const tabStore = memoryStorage();
const offline = {
...server.api,
sendJson: jest
.fn()
.mockRejectedValue(new ServerApiError(0, 'network', 'offline')),
};
const open = new ServerUserDataStorage(
offline as never,
shared,
jest.fn(),
'default',
memoryStorage()
tabStore
);
await closed.hydrate();
closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await closed.flush();
// Another tab's save once replaced the shared record: now it is gone.
await open.hydrate();
open.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await open.flush();
// Storage refused the record but kept the saved writes.
for (const key of [...shared.values.keys()]) {
if (key.startsWith('iptvnator-server:unconfirmed:')) {
shared.removeItem(key);
}
}
const next = new ServerUserDataStorage(
const other = new ServerUserDataStorage(
server.api as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
await next.hydrate();
await next.flush();
await other.hydrate();
await other.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S']);
// The tab's own reload takes them over.
const reloaded = new ServerUserDataStorage(
server.api as never,
shared,
jest.fn(),
'default',
tabStore
);
await reloaded.hydrate();
await reloaded.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
});
});