fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes

- A served copy carries the version it came from (a symbol property that
  spreads keep and JSON drops), so a mutation of a portal document names
  the version it was built on, not the latest read.
- A 401 for a token replaced since (a new sign-in) neither clears the new
  token nor reports unauthorized, in the API client, the HttpClient
  interceptor and PwaService's proxy calls.
- Moving browser data counts a browser user-data map that cannot be read
  as a failure, so the move stays on offer.
- An edit made before the first read on top of this tab's recovered
  unsent write is applied to that write, not to the server's value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 23:11:26 +02:00
1 parent 067190e1df
commit 115465755b
14 files changed
+232 -58

No files matched your search

+7 -4
View File
@@ -16,6 +16,7 @@ import {
import {
DataService,
isServerAccessChallenge,
readServerAccessToken,
serverAuthorizationHeaders,
ServerStorageStatusService,
} from '@iptvnator/services';
@@ -201,6 +202,7 @@ export class PwaService extends DataService {
);
try {
const token = readServerAccessToken();
const response = await fetch(
`${this.corsProxyUrl}/connectivity-guard/reset`,
{
@@ -213,7 +215,7 @@ export class PwaService extends DataService {
signal: controller.signal,
}
);
this.noteServerAccess(response);
this.noteServerAccess(response, token);
if (!response.ok) {
return { reset: false };
@@ -647,10 +649,11 @@ export class PwaService extends DataService {
logPortalDebugRequest(context);
// Make the fetch request
const accessToken = readServerAccessToken();
const response = await fetch(requestUrl, {
headers: serverAuthorizationHeaders(),
});
this.noteServerAccess(response);
this.noteServerAccess(response, accessToken);
if (!response.ok) {
throw new Error(
@@ -804,7 +807,7 @@ export class PwaService extends DataService {
}
/** A token changed on the server shows up on any backend call. */
private noteServerAccess(response: Response): void {
private noteServerAccess(response: Response, token: string | null): void {
if (
response.status === 401 &&
isServerAccessChallenge(
@@ -812,7 +815,7 @@ export class PwaService extends DataService {
response.headers.get('WWW-Authenticate')
)
) {
this.serverStatus.reportAccessDenied();
this.serverStatus.reportAccessDenied(token);
}
}
}
+9 -6
View File
@@ -198,7 +198,8 @@ settings and guards read them in every build.
ETag and uploads a document whose payload it served unchanged with
`keepPayload=1`, so a favorite or a rename does not resend a large M3U.
A mutation carries `If-Match` with the version it was read at (of its
payload, else of the playlist's last read, as for portal documents): when
payload, else of the served copy it was built from, as for portal
documents): when
another device wrote in between, the server answers `412`, the store
throws `PlaylistWriteConflict`, and `PlaylistsService`'s per-playlist
write queue runs the mutation again on a fresh read (a retry on the same,
@@ -248,8 +249,9 @@ settings and guards read them in every build.
being deleted its edits wait; the delete drops them only when it
succeeds. Edits made before the first successful read
are held as before → after and applied to the server's value once it
arrives (`applyEdit`): a new device or a stale cache adds its favorite
instead of replacing the server's list. They are saved per page load too
arrives (`applyEdit`), or to this tab's recovered unsent write when it has
one: a new device or a stale cache adds its favorite instead of replacing
the server's list. 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,
@@ -279,7 +281,8 @@ settings and guards read them in every build.
`ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the
backend, and `PwaService` adds it to its two raw `fetch` calls. Both turn
the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`,
so a token changed on the server sends an open page back to sign-in.
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.
- **Moving browser data.** `ServerMigrationService` offers, once per server
instance (marker `iptvnator-server:migration:<instanceId>`), to move the
playlists this browser kept in IndexedDB (`planServerMigration`). A
@@ -295,8 +298,8 @@ settings and guards read them in every build.
lists (playback positions by content type and Xtream id, renamed to the
server playlist) and merges maps, the server's value winning on a
conflict. Parental locks stay in the browser but move to the server
playlist id with it; while the lock store cannot be read the move counts
as failed. Browser data is never
playlist id with it; while the lock store or a browser user-data map
cannot be read the move counts as failed. Browser data is never
deleted, the move does not start while user-data writes are unsent, and
the marker is written only when nothing failed, so Settings → Server
keeps offering a partial move.
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from './server-access-token';
import { ServerStorageStatusService } from './server-storage-status.service';
const instance: ServerInstanceInfo = {
@@ -48,4 +49,21 @@ describe('ServerStorageStatusService', () => {
status.reportOffline();
expect(status.state()).toBe('unauthorized');
});
it('ignores a refusal of a token replaced since', () => {
status.activate(instance, jest.fn());
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'new');
status.reportAccessDenied('old');
expect(status.state()).toBe('online');
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
'new'
);
status.reportAccessDenied('new');
expect(status.state()).toBe('unauthorized');
expect(
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
).toBeNull();
});
});
@@ -1,6 +1,9 @@
import { computed, Injectable, signal } from '@angular/core';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import { clearServerAccessToken } from './server-access-token';
import {
clearServerAccessToken,
readServerAccessToken,
} from './server-access-token';
export type ServerConnectionState =
'inactive' | 'online' | 'offline' | 'unauthorized';
@@ -57,8 +60,9 @@ export class ServerStorageStatusService {
* The access guard refused a request outside the storage API (a
* provider proxy call): the stored token is stale, ask for a new one.
*/
reportAccessDenied(): void {
if (this.isActive()) {
reportAccessDenied(sentToken: string | null): void {
// A late answer to a token replaced since (a new sign-in) is moot.
if (this.isActive() && readServerAccessToken() === sentToken) {
clearServerAccessToken();
this.stateSignal.set('unauthorized');
}
@@ -47,7 +47,7 @@ export class ServerAccessTokenInterceptor implements HttpInterceptor {
error.headers.get('WWW-Authenticate')
)
) {
this.status.reportAccessDenied();
this.status.reportAccessDenied(token);
}
},
})
@@ -178,6 +178,23 @@ describe('ServerApiClient', () => {
expect(status.reportUnauthorized).toHaveBeenCalled();
});
it('keeps a token stored while a request with the old one ran', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
const fetchFn = jest.fn(async () => {
// The user signs in again before the old request is answered.
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'new');
return response(401, { code: 'unauthorized' });
});
await expect(
client(fetchFn).getJson('/playlists')
).rejects.toBeInstanceOf(ServerApiError);
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
'new'
);
expect(status.reportUnauthorized).not.toHaveBeenCalled();
});
it('passes 304 through and maps error bodies', async () => {
const fetchFn = jest
.fn()
@@ -5,6 +5,7 @@ import type {
} from '@iptvnator/shared/interfaces';
import {
clearServerAccessToken,
readServerAccessToken,
serverAuthorizationHeaders,
} from '../server-access-token';
import type { ServerStorageStatusService } from '../server-storage-status.service';
@@ -65,6 +66,7 @@ export class ServerApiClient {
async request(path: string, init: RequestInit = {}): Promise<Response> {
const isWrite = (init.method ?? 'GET') !== 'GET';
const token = readServerAccessToken();
let response: Response;
try {
if (isWrite && this.options.holdWrites) {
@@ -93,8 +95,11 @@ export class ServerApiClient {
}
if (response.status === 401) {
clearServerAccessToken();
this.options.status.reportUnauthorized();
// A late answer to a token replaced since (a new sign-in) is moot.
if (readServerAccessToken() === token) {
clearServerAccessToken();
this.options.status.reportUnauthorized();
}
throw new ServerApiError(
401,
'unauthorized',
@@ -386,4 +386,14 @@ describe('ServerMigrationService', () => {
expect(result.failed).toBeGreaterThan(0);
expect(parentalLock.replacePlaylistLocks).not.toHaveBeenCalled();
});
it('keeps the move on offer when browser favorites cannot be read', async () => {
localStorage.setItem('xtream-favorites', '{not json');
const migration = TestBed.inject(ServerMigrationService);
const result = await migration.moveBrowserData();
expect(result.failed).toBeGreaterThan(0);
expect(migration.browserDataMoved()).toBe(false);
});
});
@@ -5,6 +5,7 @@ import {
DbStores,
SERVER_USER_STATE_NAMESPACES,
type Playlist,
type ServerUserStateNamespace,
type ServerUserStateResponse,
} from '@iptvnator/shared/interfaces';
import {
@@ -207,12 +208,21 @@ export class ServerMigrationService {
await this.session.api.getJson<ServerUserStateResponse>(
'/user-state'
);
const legacy = new Map(
SERVER_USER_STATE_NAMESPACES.map((namespace) => [
namespace,
parseMap(readLocal(namespace)),
])
);
// A map this browser has but cannot read fails the move, so it
// stays on offer instead of being marked done without it.
let unreadable = 0;
const legacy = new Map<
ServerUserStateNamespace,
Record<string, unknown>
>();
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const map = readLegacyMap(namespace);
if (map) {
legacy.set(namespace, map);
} else {
unreadable += 1;
}
}
const copies = new Map<string, XtreamUserData[]>();
for (const [browserId, serverId] of targets) {
const copy: XtreamUserData = {};
@@ -230,7 +240,7 @@ export class ServerMigrationService {
}
}
let failed = 0;
let failed = unreadable;
for (const [serverId, browserCopies] of copies) {
const stored: XtreamUserData = {};
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
@@ -346,13 +356,23 @@ function readLocal(key: string): string | null {
}
}
function parseMap(value: string | null): Record<string, unknown> {
/** A browser map, `{}` when absent, `null` when it cannot be read. */
function readLegacyMap(key: string): Record<string, unknown> | null {
let value: string | null;
try {
const parsed: unknown = value ? JSON.parse(value) : {};
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
value = localStorage.getItem(key);
} catch {
return null;
}
if (value === null) {
return {};
}
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
@@ -51,7 +51,8 @@ describe('ServerPlaylistDocumentStore', () => {
expect(api.request.mock.calls[1][1]).toEqual({
headers: { 'If-None-Match': 'W/"b.0.1"' },
});
expect(second).toEqual(doc);
// structuredClone drops the served-version symbol, as JSON does.
expect(structuredClone(second)).toEqual(doc);
expect(second).not.toBe(first);
});
@@ -77,7 +78,11 @@ describe('ServerPlaylistDocumentStore', () => {
const [method, path, body] = api.sendJson.mock.calls[0];
expect([method, path]).toEqual(['PUT', '/playlists/p1?keepPayload=1']);
expect(body).toEqual({ _id: 'p1', title: 'Family', favorites: ['a'] });
expect(JSON.parse(JSON.stringify(body))).toEqual({
_id: 'p1',
title: 'Family',
favorites: ['a'],
});
});
it('uploads a new payload in full', async () => {
@@ -181,6 +186,27 @@ describe('ServerPlaylistDocumentStore', () => {
});
});
it('names the version of the copy a mutation was built on', async () => {
const { api, store } = setup();
const portal = { _id: 'p2', title: 'Portal', macAddress: 'm' };
api.request
.mockResolvedValueOnce(fakeResponse(200, portal, '"b.0.1"'))
// Another device wrote; a later read sees its version.
.mockResolvedValueOnce(
fakeResponse(200, { ...portal, title: 'Theirs' }, '"b.0.2"')
);
const older = await store.dbGetAppPlaylist('p2');
await store.dbGetAppPlaylist('p2');
if (!older) throw new Error('expected a document');
api.sendJson.mockResolvedValueOnce(fakeResponse(200, {}, '"b.0.3"'));
await store.dbUpsertAppPlaylist({ ...older, title: 'Mine' });
expect(api.sendJson.mock.calls[0][3]).toEqual({
'If-Match': '"b.0.1"',
});
});
it("keeps checking a playlist after another one's refusal", async () => {
const { api, store } = setup();
// Both playlists are at revision 1 of the same boot: one ETag.
@@ -11,6 +11,16 @@ import {
} from '../playlist-storage-backend';
import { ServerApiError, type ServerApiClient } from './server-api-client';
/** The version a served copy came from; JSON never sends it. */
const SERVED_VERSION = Symbol('servedVersion');
function servedVersion(playlist: Playlist): string | null {
const version = (playlist as unknown as Record<symbol, unknown>)[
SERVED_VERSION
];
return typeof version === 'string' ? version : null;
}
interface CachedDocument {
readonly etag: string;
readonly document: Playlist;
@@ -30,8 +40,6 @@ type ServerApi = Pick<
*/
export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
private readonly cache = new Map<string, CachedDocument>();
/** The version each playlist was last read or written at. */
private readonly basis = new Map<string, string>();
/** `id|version` a write was refused on (another device wrote since). */
private readonly refused = new Set<string>();
/** Served payload object → the ETag of the document it came from. */
@@ -68,7 +76,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
} catch (error) {
if (error instanceof ServerApiError && error.status === 404) {
this.cache.delete(playlistId);
this.basis.delete(playlistId);
return null;
}
throw error;
@@ -104,7 +111,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
})
);
this.cache.delete(playlistId);
this.basis.delete(playlistId);
return { success: true };
}
@@ -113,7 +119,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
this.api.request('/playlists', { method: 'DELETE' })
);
this.cache.clear();
this.basis.clear();
return { success: true };
}
@@ -145,10 +150,10 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
const id = playlist._id;
const path = `/playlists/${encodeURIComponent(id)}`;
const payloadEtag = this.servedPayloadEtag(playlist);
// The version it was read at: of its payload, else of the last read
// (portal documents have no payload). A retry on the same copy, not
// read again after a refusal, writes as before.
const read = payloadEtag ?? this.basis.get(id) ?? null;
// The version it was read at: of its payload, else of the copy it
// was built from (portal documents have no payload). A retry on the
// same copy, not read again after a refusal, writes as before.
const read = payloadEtag ?? servedVersion(playlist);
const headers: Record<string, string> =
conditional && read && !this.refused.has(`${id}|${read}`)
? { 'If-Match': read }
@@ -176,7 +181,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
if (error instanceof ServerApiError && error.status === 412) {
this.refused.add(`${id}|${read}`);
this.cache.delete(id);
this.basis.delete(id);
throw new PlaylistWriteConflict(id);
}
throw error;
@@ -213,7 +217,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
this.remember(playlist._id, response, playlist);
} else {
this.cache.delete(playlist._id);
this.noteWrite(playlist._id, response);
}
return response;
}
@@ -229,26 +232,16 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
} else {
this.cache.delete(id);
}
this.noteWrite(id, response);
return etag;
}
/** The version a read or write left the playlist at. */
private noteWrite(id: string, response: Response): void {
const etag = response.headers.get('etag');
if (etag) {
this.basis.set(id, etag);
} else {
this.basis.delete(id);
}
}
private serve(document: Playlist, etag: string | null): Playlist {
const copy = structuredClone(document);
if (!etag) {
return copy;
}
this.basis.set(document._id, etag);
// Spreads copy it along, so a mutation built on this copy names it.
(copy as unknown as Record<symbol, string>)[SERVED_VERSION] = etag;
const record = copy as unknown as Record<string, unknown>;
for (const field of SERVER_PAYLOAD_FIELDS) {
const value = record[field];
@@ -18,7 +18,9 @@ export function refreshedNamespace(
server: Readonly<Record<string, unknown>>,
cached: Readonly<Record<string, unknown>>,
keepCached: ReadonlySet<string>,
deferred: readonly DeferredEdit[]
deferred: readonly DeferredEdit[],
/** What an early edit was made on; the server's entry by default. */
baseOf: (playlistId: string) => unknown = (playlistId) => server[playlistId]
): Record<string, unknown> {
const value: Record<string, unknown> = { ...server };
for (const playlistId of keepCached) {
@@ -29,7 +31,7 @@ export function refreshedNamespace(
}
}
for (const edit of deferred) {
const merged = replaySteps(server[edit.playlistId], edit.steps);
const merged = replaySteps(baseOf(edit.playlistId), edit.steps);
if (merged === undefined) {
delete value[edit.playlistId];
} else {
@@ -324,6 +324,10 @@ export class ServerUserDataStorage implements UserDataStorage {
unsaved.add(key);
}
const deferred = [...this.deferred.values()];
// This tab's own unsent writes (recovered after a reload): an early
// edit was made on them, not on the server's value.
const own = new Set(this.writes.unconfirmedKeys());
const base = new Map<string, unknown>();
const refreshed = new Map<string, Record<string, unknown>>();
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const keepCached = new Set<string>();
@@ -333,11 +337,25 @@ export class ServerUserDataStorage implements UserDataStorage {
keepCached.add(playlistId);
}
}
const cached = this.cache.map(namespace);
const early = deferred.filter(
(edit) => edit.namespace === namespace
);
for (const { playlistId } of early) {
const key = `${namespace}|${playlistId}`;
base.set(
key,
own.has(key)
? cached[playlistId]
: entries[namespace]?.[playlistId]
);
}
const value = refreshedNamespace(
entries[namespace] ?? {},
this.cache.map(namespace),
cached,
keepCached,
deferred.filter((edit) => edit.namespace === namespace)
early,
(playlistId) => base.get(`${namespace}|${playlistId}`)
);
refreshed.set(namespace, value);
// A value too big for browser storage stays readable in memory,
@@ -363,7 +381,7 @@ export class ServerUserDataStorage implements UserDataStorage {
edit.namespace,
edit.playlistId,
refreshed.get(edit.namespace)?.[edit.playlistId],
entries[edit.namespace]?.[edit.playlistId]
base.get(`${edit.namespace}|${edit.playlistId}`)
);
}
this.saveDeferred();
@@ -822,4 +822,59 @@ describe('ServerUserDataStorage with other devices', () => {
expect(reloaded.hasUnsentWrites()).toBe(true);
expect(saved()).toHaveLength(1);
});
it('keeps a recovered write under an early edit made on it', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const local = memoryStorage();
const tabStore = memoryStorage();
const offline = {
...server.api,
sendJson: jest
.fn()
.mockRejectedValue(new ServerApiError(0, 'network', 'offline')),
};
const before = new ServerUserDataStorage(
offline as never,
local,
jest.fn(),
'default',
tabStore
);
await before.hydrate();
before.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['S', 'old'] })
);
await before.flush();
// Reloaded: the recovered write fails once, the read works.
const send = server.api.sendJson.getMockImplementation();
const api = {
...server.api,
sendJson: jest
.fn()
.mockRejectedValueOnce(
new ServerApiError(0, 'network', 'offline')
)
.mockImplementation(send),
};
const reloaded = new ServerUserDataStorage(
api as never,
local,
jest.fn(),
'default',
tabStore
);
reloaded.setItem(
'xtream-favorites',
JSON.stringify({ p1: [...favorites(reloaded).p1, 'new'] })
);
await reloaded.hydrate();
await reloaded.flush();
await reloaded.flush();
expect(
[...(server.state['xtream-favorites']['p1'] as string[])].sort()
).toEqual(['S', 'new', 'old']);
});
});