vojo/apps/widget-discord/src/state.ts

765 lines
30 KiB
TypeScript

// 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: <go-error>». 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<HydrateInput>,
now: number = Date.now()
): HydrateRestoredState | null => {
const acc = inputs.reduce<HydrateAccumulator>(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);
}