fix(server-storage): one registry key per page load, server token only in server mode

- The unconfirmed-writes registry stores each page load's record under
  its own key (iptvnator-server:unconfirmed:<instance>:<page>), listed
  through a new keys() on the user-data storage adapter, so tabs never
  overwrite each other's records. Saved writes without a fresh record,
  also when the record was lost, are adopted as a closed tab's (#1920).
- PwaService sends the stored server token on its raw fetch calls only
  while server mode runs, and a backend that answers without server
  storage makes the browser forget the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 19:54:40 +02:00
1 parent be2962f97d
commit cf715a7573
10 files changed
+263 -82

No files matched your search

@@ -45,6 +45,16 @@ describe('resolveServerStorageStart', () => {
).toBeNull();
});
it('forgets the token of a server that no longer offers storage', () => {
localStorage.setItem('iptvnator-server:access-token', 'old');
resolveServerStorageStart({ kind: 'none' }, '/api');
expect(
localStorage.getItem('iptvnator-server:access-token')
).toBeNull();
});
it('uses browser mode for an unknown or different server', () => {
expect(
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
@@ -1,3 +1,4 @@
import { clearServerAccessToken } from '@iptvnator/services';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import type { ServerInstanceProbe } from './server-instance-probe';
@@ -36,6 +37,8 @@ export function resolveServerStorageStart(
return { instance: probe.instance, startedOffline: false };
case 'none':
remove(storage);
// The token belonged to that server; another must not see it.
clearServerAccessToken();
return null;
case 'unreachable': {
const remembered = read(storage);
+60 -1
View File
@@ -20,7 +20,10 @@ import {
getStalkerRequestErrorStatus,
isStalkerProbeTimeout,
} from '@iptvnator/portal/stalker/data-access';
import { ServerStorageStatusService } from '@iptvnator/services';
import {
SERVER_ACCESS_TOKEN_STORAGE_KEY,
ServerStorageStatusService,
} from '@iptvnator/services';
import { PwaService } from './pwa.service';
describe('PwaService', () => {
@@ -280,6 +283,62 @@ describe('PwaService', () => {
globalThis.fetch = originalFetch;
});
it('sends a stored server token only while server mode runs', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'kept-token');
const originalFetch = globalThis.fetch;
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
json: async () => ({ js: [] }),
} as unknown as Response);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const call = async () => {
const outcome = (
service.sendIpcEvent(STALKER_REQUEST, {
url: 'http://portal.example/portal.php',
macAddress: '00:1A:79:AA:BB:CC',
params: { action: 'get_genres' },
silent: true,
}) as Promise<unknown>
).catch((error: unknown) => error);
await Promise.resolve();
for (const request of http.match((candidate) =>
candidate.url.endsWith('/provider-targets')
)) {
request.flush({ targetId: 'target-1' });
}
await outcome;
};
try {
// A token kept from another backend: server mode is not active.
await call();
expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty(
'Authorization'
);
TestBed.inject(ServerStorageStatusService).activate(
{
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i',
storage: 'server',
auth: 'token',
},
() => undefined
);
await call();
expect(fetchMock.mock.calls[1][1].headers).toMatchObject({
Authorization: 'Bearer kept-token',
});
} finally {
globalThis.fetch = originalFetch;
localStorage.removeItem(SERVER_ACCESS_TOKEN_STORAGE_KEY);
}
});
it('asks for a new server token when the access guard refuses a Stalker call', async () => {
const status = TestBed.inject(ServerStorageStatusService);
status.activate(
+16 -5
View File
@@ -17,7 +17,6 @@ import {
DataService,
isServerAccessChallenge,
readServerAccessToken,
serverAuthorizationHeaders,
ServerStorageStatusService,
} from '@iptvnator/services';
import {
@@ -202,14 +201,14 @@ export class PwaService extends DataService {
);
try {
const token = readServerAccessToken();
const token = this.serverAccessToken();
const response = await fetch(
`${this.corsProxyUrl}/connectivity-guard/reset`,
{
body: JSON.stringify({ url: payload.url }),
headers: {
'content-type': 'application/json',
...serverAuthorizationHeaders(),
...bearer(token),
},
method: 'POST',
signal: controller.signal,
@@ -649,9 +648,9 @@ export class PwaService extends DataService {
logPortalDebugRequest(context);
// Make the fetch request
const accessToken = readServerAccessToken();
const accessToken = this.serverAccessToken();
const response = await fetch(requestUrl, {
headers: serverAuthorizationHeaders(),
headers: bearer(accessToken),
});
this.noteServerAccess(response, accessToken);
@@ -806,6 +805,14 @@ export class PwaService extends DataService {
return 'pwa';
}
/**
* The stored server token, only while server mode runs for this
* backend: a token kept from another backend must not reach this one.
*/
private serverAccessToken(): string | null {
return this.serverStatus.isActive() ? readServerAccessToken() : null;
}
/** A token changed on the server shows up on any backend call. */
private noteServerAccess(response: Response, token: string | null): void {
if (
@@ -819,3 +826,7 @@ export class PwaService extends DataService {
}
}
}
function bearer(token: string | null): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
+10 -6
View File
@@ -268,11 +268,13 @@ settings and guards read them in every build.
arrives (`applyEdit`): a new device or a stale cache adds its favorite
instead of replacing the server's list. On this tab's recovered unsent
write they are kept as their own steps after it. They are saved per page load too
(`iptvnator-server:deferred-edits:<instanceId>:<page>`). A shared
registry in `localStorage` (`iptvnator-server:unconfirmed:<instanceId>`,
one record per page load, renewed every 30 s while it has unsent writes,
stale after ten minutes) lets a re-read in one tab keep the cache values
another tab is still sending. A value too big for `localStorage`, or any
(`iptvnator-server:deferred-edits:<instanceId>:<page>`). Records in
`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
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
@@ -295,7 +297,9 @@ settings and guards read them in every build.
before it stores it under `iptvnator-server:access-token` and reloads (a
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. Both turn
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
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.
@@ -10,6 +10,7 @@ function memoryStorage() {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
removeItem: (key: string) => void values.delete(key),
keys: () => [...values.keys()],
};
}
@@ -22,14 +22,15 @@ const writesKey = (instanceId: string, page: string) =>
`${UNSENT_WRITES_KEY}:${instanceId}:${page}`;
/**
* Which entries each page load has not had confirmed yet, so a re-read in
* one tab keeps the cache values another tab is still sending. A live tab
* renews its record; one older than `UNCONFIRMED_TTL_MS` belongs to a tab
* that is gone. The records also list whose saved writes exist.
* one tab keeps the cache values another tab is still sending. Each page
* load writes only its own key, so tabs never overwrite each other's
* record. A live tab renews it; one older than `UNCONFIRMED_TTL_MS`
* belongs to a tab that is gone.
*/
export const UNCONFIRMED_KEY = 'iptvnator-server:unconfirmed';
export const UNCONFIRMED_TTL_MS = 10 * 60 * 1000;
const unconfirmedKey = (instanceId: string) =>
`${UNCONFIRMED_KEY}:${instanceId}`;
const recordKey = (instanceId: string, page: string) =>
`${UNCONFIRMED_KEY}:${instanceId}:${page}`;
/** Edits made before the first read of a visit, kept as before → after. */
export const DEFERRED_EDITS_KEY = 'iptvnator-server:deferred-edits';
@@ -51,11 +52,11 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
local.removeItem(`iptvnator-server:${namespace}`);
}
if (owner) {
for (const page of Object.keys(readRegistry(local, owner))) {
local.removeItem(writesKey(owner, page));
local.removeItem(deferredKey(owner, page));
for (const prefix of PAGE_PREFIXES) {
for (const page of pagesUnder(local, `${prefix}:${owner}:`)) {
local.removeItem(`${prefix}:${owner}:${page}`);
}
}
local.removeItem(unconfirmedKey(owner));
}
local.setItem(CACHE_OWNER_KEY, instanceId);
} catch {
@@ -118,20 +119,30 @@ export function swapPage(
}
}
/** Page loads whose record went stale: closed tabs nobody sends for. */
/**
* Page loads with saved writes or a record but no fresh record: closed
* tabs nobody sends for (also when their record was lost).
*/
export function orphanedPages(
local: UserDataStorage,
instanceId: string,
page: string,
now = Date.now()
): string[] {
return Object.entries(readRegistry(local, instanceId))
.filter(
([owner, record]) =>
owner !== page &&
!(now - Number(record?.at) < UNCONFIRMED_TTL_MS)
)
.map(([owner]) => owner);
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)
);
});
}
/** Forgets a page load's saved writes, early edits and record. */
@@ -293,10 +304,54 @@ export function saveDeferredEdits(
}
/** `closed`: the page unloaded (not into the back-forward cache). */
type UnconfirmedRegistry = Record<
string,
{ at: number; keys: string[]; closed?: boolean }
>;
interface PageRecord {
readonly at: number;
readonly keys: string[];
readonly closed?: boolean;
}
/** Key prefixes of what a page load keeps, each followed by `:<instance>:<page>`. */
const PAGE_PREFIXES = [UNCONFIRMED_KEY, UNSENT_WRITES_KEY, DEFERRED_EDITS_KEY];
/** The page loads of keys that start with `prefix`. */
function pagesUnder(local: UserDataStorage, prefix: string): string[] {
try {
return (local.keys?.() ?? [])
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
} catch {
return [];
}
}
function readRecord(
local: UserDataStorage,
instanceId: string,
page: string
): PageRecord | null {
try {
const parsed = JSON.parse(
local.getItem(recordKey(instanceId, page)) ?? 'null'
) as PageRecord | null;
return parsed && Array.isArray(parsed.keys) ? parsed : null;
} catch {
return null;
}
}
function readRegistry(
local: UserDataStorage,
instanceId: string
): Record<string, PageRecord> {
const registry: Record<string, PageRecord> = Object.create(null);
for (const page of pagesUnder(local, `${UNCONFIRMED_KEY}:${instanceId}:`)) {
const record = readRecord(local, instanceId, page);
if (record) {
registry[page] = record;
}
}
return registry;
}
/** Marks a page load's record as unloaded: a reload may take it over. */
export function markClosed(
@@ -304,13 +359,15 @@ export function markClosed(
instanceId: string,
page: string
): void {
const registry = readRegistry(local, instanceId);
if (!registry[page]) {
const record = readRecord(local, instanceId, page);
if (!record) {
return;
}
registry[page] = { ...registry[page], closed: true };
try {
local.setItem(unconfirmedKey(instanceId), JSON.stringify(registry));
local.setItem(
recordKey(instanceId, page),
JSON.stringify({ ...record, closed: true })
);
} catch {
// Storage full: the record goes stale instead.
}
@@ -327,7 +384,7 @@ export function pageIsLive(
page: string,
now = Date.now()
): boolean {
const record = readRegistry(local, instanceId)[page];
const record = readRecord(local, instanceId, page);
return (
!!record &&
!record.closed &&
@@ -335,23 +392,7 @@ export function pageIsLive(
);
}
function readRegistry(
local: UserDataStorage,
instanceId: string
): UnconfirmedRegistry {
try {
const parsed: unknown = JSON.parse(
local.getItem(unconfirmedKey(instanceId)) ?? '{}'
);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as UnconfirmedRegistry)
: {};
} catch {
return {};
}
}
/** Replaces only this tab's record. */
/** Writes only this page load's record (none without keys). */
export function markUnconfirmed(
local: UserDataStorage,
instanceId: string,
@@ -359,17 +400,15 @@ export function markUnconfirmed(
keys: readonly string[],
now = Date.now()
): void {
const registry = readRegistry(local, instanceId);
if (keys.length === 0) {
if (!(tabId in registry)) {
return;
}
delete registry[tabId];
} else {
registry[tabId] = { at: now, keys: [...keys] };
}
try {
local.setItem(unconfirmedKey(instanceId), JSON.stringify(registry));
if (keys.length === 0) {
local.removeItem(recordKey(instanceId, tabId));
} else {
local.setItem(
recordKey(instanceId, tabId),
JSON.stringify({ at: now, keys: [...keys] })
);
}
} catch {
// Storage full: re-reads in other tabs lose this protection only.
}
@@ -386,11 +425,7 @@ export function othersUnconfirmed(
for (const [owner, record] of Object.entries(
readRegistry(local, instanceId)
)) {
if (
owner !== tabId &&
Array.isArray(record?.keys) &&
now - Number(record.at) < UNCONFIRMED_TTL_MS
) {
if (owner !== tabId && now - Number(record.at) < UNCONFIRMED_TTL_MS) {
for (const key of record.keys) {
keys.add(String(key));
}
@@ -8,6 +8,7 @@ function memoryStorage() {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
removeItem: (key: string) => void values.delete(key),
keys: () => [...values.keys()],
};
}
@@ -536,8 +537,8 @@ describe('ServerUserDataStorage', () => {
local.setItem('iptvnator-server:cache-owner', 'old-db');
local.setItem('iptvnator-server:xtream-favorites', '{"p1":["a"]}');
local.setItem(
'iptvnator-server:unconfirmed:old-db',
JSON.stringify({ tab: { at: 0, keys: ['xtream-favorites|p1'] } })
'iptvnator-server:unconfirmed:old-db:tab',
JSON.stringify({ at: 0, keys: ['xtream-favorites|p1'] })
);
local.setItem(
'iptvnator-server:unsent-writes:old-db:tab',
@@ -562,9 +563,9 @@ describe('ServerUserDataStorage', () => {
false
);
expect(savedWrites(local, 'old-db')).toEqual([]);
expect(local.values.has('iptvnator-server:unconfirmed:old-db')).toBe(
false
);
expect(
local.values.has('iptvnator-server:unconfirmed:old-db:tab')
).toBe(false);
expect(local.values.get('iptvnator-server:cache-owner')).toBe('new-db');
});
@@ -9,6 +9,7 @@ function memoryStorage() {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
removeItem: (key: string) => void values.delete(key),
keys: () => [...values.keys()],
};
}
@@ -523,12 +524,14 @@ describe('ServerUserDataStorage with other devices', () => {
closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await closed.flush();
// The tab is gone: its record stops being renewed.
const registry = 'iptvnator-server:unconfirmed:default';
const records = JSON.parse(shared.values.get(registry) ?? '{}');
for (const record of Object.values(records)) {
(record as { at: number }).at = 0;
for (const [key, value] of shared.values) {
if (key.startsWith('iptvnator-server:unconfirmed:default:')) {
shared.setItem(
key,
JSON.stringify({ ...JSON.parse(value), at: 0 })
);
}
}
shared.setItem(registry, JSON.stringify(records));
const next = new ServerUserDataStorage(
server.api as never,
@@ -1280,4 +1283,44 @@ describe('ServerUserDataStorage with other devices', () => {
JSON.parse('{"__proto__":["a","c"],"p2":["b"]}')
);
});
it("sends a closed tab's saved writes whose record was lost", 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,
shared,
jest.fn(),
'default',
memoryStorage()
);
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.
for (const key of [...shared.values.keys()]) {
if (key.startsWith('iptvnator-server:unconfirmed:')) {
shared.removeItem(key);
}
}
const next = new ServerUserDataStorage(
server.api as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
await next.hydrate();
await next.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
});
});
+16 -2
View File
@@ -1,10 +1,13 @@
import { InjectionToken } from '@angular/core';
/** The slice of the Web Storage API that PWA user-data stores rely on. */
/**
* The slice of the Web Storage API that PWA user-data stores rely on, plus
* the stored keys where the store can list them.
*/
export type UserDataStorage = Pick<
Storage,
'getItem' | 'setItem' | 'removeItem'
>;
> & { keys?(): string[] };
/**
* `localStorage` behind an adapter that reads the global only inside each
@@ -17,6 +20,17 @@ export function createBrowserUserDataStorage(): UserDataStorage {
getItem: (key) => globalThis.localStorage.getItem(key),
setItem: (key, value) => globalThis.localStorage.setItem(key, value),
removeItem: (key) => globalThis.localStorage.removeItem(key),
keys: () => {
const storage = globalThis.localStorage;
const keys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key !== null) {
keys.push(key);
}
}
return keys;
},
};
}