/** * [Gitea #201 KE-1] Notice when this device's encryption store has diverged * from the server: every POST /keys/upload answers 400 "One time key ... * already exists", and the SDK retries forever (matrix-rust-sdk#5200, open), * so new encrypted messages and calls quietly degrade. The server saw 474k of * these between May 30 and Jul 8 2026. The only fix is a fresh login on that * device, so we tell the user (KeyUploadBanner) instead of looping silently. * * Watched at the fetch layer (MatrixClient `fetchFn`), so it doesn't depend on * SDK log wording. Pure state + a tiny subscribe API; no network I/O of its own. */ /** Conflicts needed before we call the device stuck. The SDK retries within * seconds, so a real divergence crosses this quickly; a one-off race doesn't. */ export const STUCK_AFTER = 3; /** Conflicts older than this don't count (a long-ago blip isn't "now"). */ export const WINDOW_MS = 15 * 60 * 1000; export type KeyUploadWatchState = { stuck: boolean; conflicts: number; lastError?: string }; let conflictTimes: number[] = []; let lastError: string | undefined; let state: KeyUploadWatchState = { stuck: false, conflicts: 0 }; const listeners = new Set<() => void>(); const publish = (next: KeyUploadWatchState) => { if (next.stuck === state.stuck && next.conflicts === state.conflicts) return; state = next; listeners.forEach((l) => l()); }; export const isKeyUploadUrl = (url: string): boolean => { try { return /\/_matrix\/client\/[^/]+\/keys\/upload\/?$/.test(new URL(url, 'http://x').pathname); } catch { return false; } }; /** The 400 body that marks a diverged one-time-key store. */ export const isOtkConflict = (status: number, body: string): boolean => status === 400 && /one time key .*already exists/i.test(body); /** Feed one /keys/upload response. `now` is injectable for tests. */ export const recordKeyUpload = (status: number, body: string, now = Date.now()): void => { if (status >= 200 && status < 300) { // A successful upload means the stores agree again. conflictTimes = []; lastError = undefined; publish({ stuck: false, conflicts: 0 }); return; } if (!isOtkConflict(status, body)) return; conflictTimes = [...conflictTimes, now].filter((t) => now - t <= WINDOW_MS); lastError = body.slice(0, 300); publish({ stuck: conflictTimes.length >= STUCK_AFTER, conflicts: conflictTimes.length, lastError, }); }; export const getKeyUploadWatch = (): KeyUploadWatchState => state; export const subscribeKeyUploadWatch = (cb: () => void): (() => void) => { listeners.add(cb); return () => listeners.delete(cb); }; /** Test/logout helper: forget everything (a new session starts clean). */ export const resetKeyUploadWatch = (): void => { conflictTimes = []; lastError = undefined; publish({ stuck: false, conflicts: 0 }); }; const urlOf = (input: RequestInfo | URL): string => typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; /** * Wrap a fetch so /keys/upload responses are observed. The response the SDK * gets is untouched; a clone is read in the background, never awaited. */ export const watchKeyUploads = (inner: typeof fetch): typeof fetch => async (input, init) => { const res = await inner(input, init); try { if (isKeyUploadUrl(urlOf(input))) { if (res.ok) recordKeyUpload(res.status, ''); else if (res.status === 400) res .clone() .text() .then((body) => recordKeyUpload(res.status, body)) .catch(() => undefined); } } catch { // observation must never break the request } return res; };