// Login state machine — consumes LoginEvent (one per inbound bridge bot // reply) and emits a typed UI state. The widget renders the QR panel and // the status pill from this state, never from raw reply strings. // // Discord vs Telegram differences: // - QR-only: there's no phone/code/password ladder, so the state space // is much smaller than the Telegram reducer. // - status comes from `ping` (legacy mautrix command system), not // `list-logins` (bridgev2). The four ping replies map to four states: // disconnected / connected / connection_dead / token_stored. // - no list-logins-derived `loginId`; logout is the bare `logout` verb, // so the connected state doesn't need to gate on a login id. // - the QR is NOT rotated by Discord remoteauth (single image per // login attempt). The state machine still tracks `qrEventId` so the // redaction handler can match against it and ignore unrelated cleanup. // // State-gating policy: late-arriving replies from cancelled flows must // not resurrect dead state. The `cancel_pending` action ALWAYS lands us // in `disconnected` immediately; later bridge events arriving after // cancel are filtered by the live reducer. import type { LoginEvent } from './bridge-protocol/types'; export type LoginErrorFlag = | { kind: 'login_failed'; reason?: string } | { kind: 'captcha_required' } | { kind: 'login_websocket_failed'; reason?: string } | { kind: 'connect_after_login_failed'; reason?: string } | { kind: 'prepare_login_failed'; reason?: string } | { kind: 'already_logged_in' } | { kind: 'unknown_command' }; // `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live // reducer routes that event back to `connected_dead` (no error surface // there — the connected-dead pill IS the error indicator) without // staging a reason for `localizeError`. If a future UI change wants to // surface the reason, add `lastError?: ...` to the connected_dead state // shape and route `reconnect_failed` through it. // A live form is open and waiting for user action. M-discord ships with // only one: the QR panel. Hydrate's restorable shape collapses to this // single variant + the `qr_verifying` interstitial. export type PendingFormState = { kind: 'awaiting_qr_scan'; discordUrl: string; qrEventId: string; firstShownAt: number; lastError?: LoginErrorFlag; }; export type LoginState = // Pre-handshake / pre-ping. Status pill: --faint. | { kind: 'unknown' } // ping returned `not_logged_in`, OR logout completed. Status pill: // --rose. The card grid offers the QR-login affordance. | { kind: 'disconnected'; lastError?: LoginErrorFlag } // QR-login in progress. Optimistically transitioned by `start_qr_login`; // overwritten with real discordUrl/qrEventId by the live `qr_displayed` // event. Status pill: --amber. | PendingFormState // QR was redacted (i.e. the bridge accepted a scan), but we don't yet // know whether login succeeded. Held as an intermediate spinner until // the next bridge signal arrives. Status pill: --amber. | { kind: 'qr_verifying' } // logout in flight — waiting for `Logged out successfully.`. Status // pill: --amber. | { kind: 'logging_out' } // reconnect in flight (recovery from connection_dead / token_stored). // Waiting for `Successfully reconnected` or `You're already connected`. // Status pill: --amber. `handle` is carried through from the // connected_dead state so a successful reconnect can flip directly to // `connected{handle}` without bouncing through a transient `unknown` // (which would briefly paint a faint «Проверка статуса…» pill — bad // UX immediately after the user took an action). | { kind: 'reconnecting'; handle?: string } // Live session — ping or login_success confirmed. Discord legacy bridge // doesn't have a per-account loginId concept (single Discord account // per Matrix user), so logout doesn't need an id. | { kind: 'connected'; handle: string; discordId?: string } // ping says we have a token but the connection's down. Status pill: // green-ish but with a Reconnect recovery action exposed. The reducer // distinguishes `connection_dead` (Discord WS dropped) from `token_stored` // (we have the token but never got far enough to connect), but the UI // collapses both into the same shape — they share the recovery path. | { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string }; // States that the hydrate path can restore after a reload. The QR panel // (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction // timeline; `qr_verifying` covers the post-scan pre-success interstitial. // Other transient states (logging_out, reconnecting) deliberately don't // survive — those are tied to live in-flight commands and would feel // stuck on reload; the hydrate path falls through to live ping. export type HydrateRestoredState = | PendingFormState | { kind: 'qr_verifying' }; // Outbound user actions the App dispatches. Form-submit actions clear any // pending lastError; structural transitions optimistically advance state — // the App rolls them back on send-failure where the bot would otherwise // leave us stuck. export type LoginAction = | { kind: 'event'; event: LoginEvent } | { kind: 'start_qr_login' } // user clicked «Войти по QR» | { kind: 'request_logout' } // user clicked «Выйти из Discord» // user clicked «Переподключиться» — App passes the current handle // (from `connected_dead.handle` or `connected.handle`) so the // transient `reconnecting` state carries it forward; without this the // post-reconnect_ok branch can't paint the connected pill until the // follow-up ping resolves. | { kind: 'request_reconnect'; handle?: string } // Discord legacy mautrix has no `cancel` command. Cancel is LOCAL — // returns the widget to disconnected immediately; the bridge's // remoteauth websocket eventually times out on its own. The action is // kept symmetrical with TG's reducer for shape consistency, but // dispatching it doesn't trigger any send. | { kind: 'cancel_pending' } | { kind: 'hydrate'; state: HydrateRestoredState }; export const initialLoginState: LoginState = { kind: 'unknown' }; const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan'; export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { if (action.kind === 'hydrate') { // hydrate is a one-shot mount-time seed. If a live event already // moved us off `unknown`, the live truth wins; the cached timeline // snapshot is by definition older than what the live event just told // us. Without this gate, a stale `awaiting_qr_scan` from a previous // session could overwrite a legitimate `connected` that arrived // during the readTimeline await. if (state.kind !== 'unknown') return state; return action.state; } if (action.kind === 'start_qr_login') { // Optimistic placeholder; the live `qr_displayed` event overwrites // discordUrl + qrEventId + firstShownAt. If the `!discord login-qr` // send fails, the App rolls back to `disconnected`. return { kind: 'awaiting_qr_scan', discordUrl: '', qrEventId: '', firstShownAt: Date.now(), }; } if (action.kind === 'request_logout') { return { kind: 'logging_out' }; } if (action.kind === 'request_reconnect') { return { kind: 'reconnecting', handle: action.handle }; } if (action.kind === 'cancel_pending') { // Optimistic: drop straight back to disconnected. Discord legacy mautrix // has no `cancel` command — the bridge's remoteauth websocket continues // until it succeeds or times out internally. From the user's POV the // widget returns to disconnected, and any later QR redaction / login // success / login failure event from the abandoned flow is filtered // by the per-event gates below (qr_redacted gated on awaiting_qr_scan, // login_success / login_failed gated on awaiting_qr_scan|qr_verifying). return { kind: 'disconnected' }; } const event = action.event; switch (event.kind) { // --- ping replies ---------------------------------------------------- case 'not_logged_in': // Accept from states where flipping to disconnected is correct. // Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan // (which was started after the ping was fired but before the reply // landed) — that's the same race the TG reducer guards against. if ( state.kind === 'unknown' || state.kind === 'disconnected' || state.kind === 'logging_out' || state.kind === 'qr_verifying' || state.kind === 'reconnecting' || state.kind === 'connected_dead' ) { return { kind: 'disconnected' }; } return state; case 'logged_in': // Authoritative source — accept from any state. Used by both the // initial ping AND the post-`login_success` re-ping that picks up // the discordId snowflake. return { kind: 'connected', handle: event.handle, discordId: event.discordId, }; case 'connection_dead': // ping says token's good but the WS is down. Show the connected // chrome with a Reconnect recovery action. return { kind: 'connected_dead', reason: 'connection_dead', handle: state.kind === 'connected' ? state.handle : undefined, }; case 'token_stored_not_connected': return { kind: 'connected_dead', reason: 'token_stored', handle: state.kind === 'connected' ? state.handle : undefined, }; // --- QR lifecycle ---------------------------------------------------- case 'qr_displayed': { // Defence-in-depth: an inbound qr_displayed MUST carry a non-empty // event id (the host driver rejects empty event_id at the sanitizer; // this is a redundant guard). if (event.eventId.length === 0) return state; // Initial QR from a fresh login attempt — accept from: // * `unknown` — cold-start before ping resolves; // * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login. // // We DO NOT accept from `disconnected`. Discord legacy mautrix has // no cancel command, so when the user clicks Cancel locally the // bridge's remoteauth goroutine continues until success / failure // / internal timeout. The widget transitions to `disconnected` // immediately, but the bridge eventually emits the m.image. If we // accepted that here, the user would see a QR they didn't ask for // — the bridge has no way to know the user moved on. Drop it // silently; the user has to click «Войти по QR» again to express // intent (which resets the placeholder and lets the next m.image // land). if ( state.kind === 'unknown' || (state.kind === 'awaiting_qr_scan' && state.qrEventId === '') ) { return { kind: 'awaiting_qr_scan', discordUrl: event.discordUrl, qrEventId: event.eventId, firstShownAt: state.kind === 'awaiting_qr_scan' && state.firstShownAt ? state.firstShownAt : Date.now(), }; } if (state.kind !== 'awaiting_qr_scan') return state; // Hypothetical edit pointing at our anchor — repaint URL, keep id. // Discord doesn't currently edit QRs but the path stays for // forward-compat (cheaper to keep than to reconstruct). if (event.replacesEventId === state.qrEventId) { return { ...state, discordUrl: event.discordUrl }; } // Fresh non-edit qr_displayed while we're already tracking one — // could be a bridge-side restart (rare). Adopt as new anchor. if (!event.replacesEventId) { return { kind: 'awaiting_qr_scan', discordUrl: event.discordUrl, qrEventId: event.eventId, firstShownAt: Date.now(), }; } // Edit pointing at something we don't track — ignore. return state; } case 'qr_redacted': { // Bridge cleaned up the QR after a successful scan (commands.go // l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)` // — only fires on the success path). Held as `qr_verifying` until // the success line lands. Only honour from awaiting_qr_scan with a // matching event id. if (state.kind !== 'awaiting_qr_scan') return state; if (state.qrEventId !== event.redactsEventId) return state; return { kind: 'qr_verifying' }; } case 'login_success': // Honour from any non-terminal state. The bridge's success line // doesn't include the discordId; the App fires `ping` afterwards // to upgrade to the full `connected{handle, discordId}` shape. return { kind: 'connected', handle: event.handle }; case 'login_failed': // Generic Discord-side login failure — bridge replies «Error logging // in: ». Routes back to disconnected with the verbatim // reason as a warn line. Only honour when a QR flow is in flight; // otherwise it's stale (e.g. an old failure replaying after page // reload while the user is already connected). if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; return { kind: 'disconnected', lastError: { kind: 'login_failed', reason: event.reason }, }; case 'captcha_required': // Discord presented a captcha during remoteauth — QR flow is dead // for this attempt. Surface as a hint suggesting token-login (which // we don't expose in the widget; users can do it via chat-fallback). if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; return { kind: 'disconnected', lastError: { kind: 'captcha_required' } }; case 'login_websocket_failed': // Pre-QR failure: couldn't reach Discord remoteauth. The QR was // never displayed in the first place. State `awaiting_qr_scan` with // empty discordUrl is the placeholder set by `start_qr_login`; // this fires before the first qr_displayed lands. if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; return { kind: 'disconnected', lastError: { kind: 'login_websocket_failed', reason: event.reason }, }; case 'connect_after_login_failed': // Post-scan rare: remoteauth gave us a token, but the bridge couldn't // connect to Discord with it. The bridge has the token cached and // might recover on next ping; we still route to disconnected so the // user can retry. if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; return { kind: 'disconnected', lastError: { kind: 'connect_after_login_failed', reason: event.reason }, }; case 'prepare_login_failed': if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state; return { kind: 'disconnected', lastError: { kind: 'prepare_login_failed', reason: event.reason }, }; case 'already_logged_in': // The user clicked «Войти по QR» but the bridge is already logged // in — race against ping. Surface a soft warning and let the App's // re-ping reconcile to the connected state. if (isFormState(state)) { return { ...state, lastError: { kind: 'already_logged_in' } }; } return state; // --- logout ---------------------------------------------------------- case 'logout_ok': case 'logout_no_op': // Late `Logged out` from a previous session can arrive while the // user is mid-new-flow. Only honour from logging_out; other states // keep their flow. if (state.kind !== 'logging_out') return state; return { kind: 'disconnected' }; // --- disconnect (read-only, never sent by widget) ------------------- case 'disconnect_ok': case 'disconnect_no_op': // User typed `disconnect` manually in chat-fallback while the widget // was open. Reflect the bridge's truth: no token-loss, but no live // connection either — same shape as `token_stored`. Both // `connected` (string handle) and `connected_dead` (handle?: // string) expose `handle` on the same key, so a single read works. if (state.kind === 'connected' || state.kind === 'connected_dead') { return { kind: 'connected_dead', reason: 'token_stored', handle: state.handle, }; } return state; case 'disconnect_failed': // Manual disconnect attempt failed — keep current state, the widget // doesn't surface a UI for this since it never sent the command. return state; // --- reconnect ------------------------------------------------------- case 'reconnect_ok': case 'reconnect_no_op': // After a successful reconnect, ping is the source of truth for the // handle. The App fires `ping` after this event lands to refresh. // We flip to `connected` immediately so the user sees an immediate // green pill confirming their click; the post-event ping refreshes // the handle / discordId within ~100ms. Both `reconnecting` and // `connected_dead` carry `handle?` — a missing handle still flips // green with an empty handle, which the UI's // `state.handle ? connected-as : connected` ternary tolerates. // This avoids the `unknown` flap that the previous draft would // produce when no handle was stashed. if (state.kind === 'reconnecting' || state.kind === 'connected_dead') { return { kind: 'connected', handle: state.handle ?? '' }; } return state; case 'reconnect_failed': if (state.kind !== 'reconnecting') return state; // Roll back to connected_dead carrying the previous handle. The // user can hit Reconnect again or refresh. We don't surface the // error reason here — the connected_dead pill itself reads as // «something is wrong, try Reconnect» — adding a transient red // banner adjacent to a recovery affordance is overkill. return { kind: 'connected_dead', reason: 'connection_dead', handle: state.handle, }; // --- bridge-side errors --------------------------------------------- case 'unknown_command': // Shouldn't happen — we only send commands the bridge knows. Visible // when /config.json's commandPrefix drifts from the bridge's actual // command_prefix. Surface loudly on disconnected. return { kind: 'disconnected', lastError: { kind: 'unknown_command' } }; case 'unknown': return state; default: { // Exhaustiveness check — TS flags this if a new LoginEvent kind is // added without a case here. const exhaustive: never = event; return exhaustive; } } }; // --- hydrate-from-timeline ----------------------------------------------- // // Discord's hydrate is simpler than Telegram's because the QR flow has // fewer states. We walk past→present and let each event freely transition // the state — like the TG hydrate, this is permissive (no out-of-thin-air // rejection) because we trust the bridge's durable timeline. // 3 minutes — Discord remoteauth's server-side timeout sits around 2 // minutes (verified empirically against v0.7.6's remoteauth/client.go; // no explicit constant in the lib, the server-side gateway closes the // websocket on inactivity). We use 3 min as a slight safety margin so // reload-after-success grace still works while the panel is still // fresh enough to scan. Telegram's QR rotates internally and lives ~10 // min, which is why the TG widget uses 10 min — Discord's single-shot // remoteauth needs the tighter window. const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000; export type HydrateInput = { ev: LoginEvent; ts: number; }; type HydrateAccumulator = { state: LoginState; pendingTs: number | null; terminated: boolean; }; const stepHydrate = ( prevAcc: HydrateAccumulator, input: HydrateInput ): HydrateAccumulator => { const { ev, ts } = input; // After a terminal event we normally stop — except if a fresh // `qr_displayed` shows up, that's the bridge signature of a NEW login // flow. The user cancelled (or finished) and is now logging in again; // the chain should resume tracking from the new start. Without this // re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then- // re-login-mid-QR) would return null. if (prevAcc.terminated && ev.kind !== 'qr_displayed') { return prevAcc; } // Restart-on-re-entry: clear the terminated bit AND any prior tracked // state so the new flow's first event becomes the new anchor without // inheriting the old QR's eventId. const acc: HydrateAccumulator = prevAcc.terminated ? { state: { kind: 'unknown' }, pendingTs: null, terminated: false } : prevAcc; switch (ev.kind) { case 'qr_displayed': { // Same anchor logic as the live reducer. if (acc.state.kind !== 'awaiting_qr_scan') { return { state: { kind: 'awaiting_qr_scan', discordUrl: ev.discordUrl, qrEventId: ev.eventId, firstShownAt: ts, }, pendingTs: ts, terminated: false, }; } if (ev.replacesEventId === acc.state.qrEventId) { return { state: { ...acc.state, discordUrl: ev.discordUrl }, pendingTs: ts, terminated: false, }; } if (!ev.replacesEventId) { return { state: { kind: 'awaiting_qr_scan', discordUrl: ev.discordUrl, qrEventId: ev.eventId, firstShownAt: ts, }, pendingTs: ts, terminated: false, }; } return acc; } case 'qr_redacted': { if (acc.state.kind !== 'awaiting_qr_scan') return acc; if (acc.state.qrEventId !== ev.redactsEventId) return acc; // Move into qr_verifying and keep the chain open — the success line // typically follows in the same scan window. return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; } // Terminal events — collapse the chain. State becomes whatever the // bot confirmed last; the caller returns null and lets live `ping` // reconcile. case 'login_success': case 'logged_in': case 'logout_ok': case 'logout_no_op': case 'not_logged_in': case 'connection_dead': case 'token_stored_not_connected': case 'reconnect_ok': case 'reconnect_no_op': case 'reconnect_failed': case 'disconnect_ok': case 'disconnect_no_op': case 'disconnect_failed': case 'login_failed': case 'captcha_required': case 'login_websocket_failed': case 'connect_after_login_failed': case 'prepare_login_failed': case 'unknown_command': return { state: acc.state, pendingTs: null, terminated: true }; case 'already_logged_in': case 'unknown': // Soft no-op for hydrate. already_logged_in is a live-flow warning // that doesn't reflect persistent state; unknown is a wording-drift // catch-all. return acc; default: { const exhaustive: never = ev; return exhaustive; } } }; export const hydrateFromTimeline = ( inputs: ReadonlyArray, now: number = Date.now() ): HydrateRestoredState | null => { const acc = inputs.reduce(stepHydrate, { state: { kind: 'unknown' }, pendingTs: null, terminated: false, }); if (acc.terminated) return null; if (acc.pendingTs === null) return null; if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null; if (acc.state.kind === 'qr_verifying') return acc.state; if (!isFormState(acc.state)) return null; return acc.state; }; // --- DEV sanity assertions ------------------------------------------------ if (import.meta.env.DEV) { runHydrateSanity(); } function runHydrateSanity(): void { const t0 = 1_700_000_000_000; const recent = (offset: number) => t0 + offset; const now = t0 + 60 * 1000; const cases: Array<{ name: string; inputs: HydrateInput[]; expected: LoginState | null; nowOverride?: number; }> = [ { name: 'empty timeline → null', inputs: [], expected: null }, { name: 'lone qr_displayed → awaiting_qr_scan', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, ts: recent(0), }, ], expected: { kind: 'awaiting_qr_scan', discordUrl: 'https://discord.com/ra/A', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr_redacted with mismatched target → ignored', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) }, ], expected: { kind: 'awaiting_qr_scan', discordUrl: 'https://discord.com/ra/A', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr scan → no follow-up → qr_verifying (reload during the gap)', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, ], expected: { kind: 'qr_verifying' }, }, { name: 'qr scan → login_success → null (terminal — let ping reconcile)', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, { ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) }, ], expected: null, }, { name: 'login_failed after qr → null (terminal)', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) }, ], expected: null, }, { name: 'captcha_required after qr → null (terminal)', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'captcha_required' }, ts: recent(10000) }, ], expected: null, }, { name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) }, { ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) }, { ev: { kind: 'logout_ok' }, ts: recent(20000) }, { ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' }, ts: recent(25000), }, ], expected: { kind: 'awaiting_qr_scan', discordUrl: 'https://discord.com/ra/NEW', qrEventId: '$qrNew', firstShownAt: recent(25000), }, }, { name: 'pending too old (5 min) → null (freshness guard, 3-min window)', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discordapp.com/ra/A', eventId: '$qrA', }, ts: t0 - 5 * 60 * 1000, }, ], expected: null, nowOverride: t0, }, { name: 'pending just inside window (2 min) → state', inputs: [ { ev: { kind: 'qr_displayed', discordUrl: 'https://discordapp.com/ra/A', eventId: '$qrA', }, ts: t0 - 2 * 60 * 1000, }, ], expected: { kind: 'awaiting_qr_scan', discordUrl: 'https://discordapp.com/ra/A', qrEventId: '$qrA', firstShownAt: t0 - 2 * 60 * 1000, }, nowOverride: t0, }, { name: 'connection_dead alone → null (terminal — let live ping reconcile)', inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }], expected: null, }, { name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)', inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }], expected: null, }, { name: 'logged_in alone → null (terminal — let live ping reconcile)', inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, ts: recent(0) }], expected: null, }, { name: 'unknown alone → null', inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }], expected: null, }, ]; for (const c of cases) { const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now); if (!sameLoginState(actual, c.expected)) { // eslint-disable-next-line no-console console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected }); throw new Error(`hydrate sanity failed: ${c.name}`); } } } function sameLoginState(a: LoginState | null, b: LoginState | null): boolean { if (a === null || b === null) return a === b; return JSON.stringify(a) === JSON.stringify(b); }