15c0c35df6
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
22 KiB
22 KiB
id, title, status, wave, depends_on, plan, spec
| id | title | status | wave | depends_on | plan | spec | ||
|---|---|---|---|---|---|---|---|---|
| session-hostname-resolution-03 | Frontend hostname streaming and security-page UI | pending | 3 |
|
docs/plans/session-hostname-resolution/plan.md | docs/specs/session-hostname-resolution/spec.md |
Frontend hostname streaming and security-page UI
Inputs
The spec (Frontend,
Streaming sections), Task 01's userSettings.resolveSessionHostnames, Task
02's sessions hostname field and auth.session.hostname.resolved WS
action. Existing patterns: components/settings/agent-generated-task-title-settings.tsx
(Switch card), lib/ws/handlers/users.ts (store handler),
lib/state/slices/auth (auth slice), components/settings/account/security-settings.tsx
(SessionsCard).
TDD sequence
- Add failing tests: auth slice
setSessionHostnameupsert AND ordering — the shared entry comparator rejects a delayed older event (newer event then delayed older event keeps the newer), durable beats null, ties to non-empty, identical entries keep the existing one; andsetAuthStatesemantics — same identity preservessessionHostnames, a different identity (or null/logout) clears it AND incrementssessionHostnamesEpoch, andclearAuthenticated(the 401 path) also clears it and bumps the epoch; the session-hostnames WS handler ignores events whose store epoch differs from its registration epoch (stale-identity regression: deliver an old-user notification after identity change, map stays empty); WS handlerauth.session.hostname.resolvedupdates the store map; SessionsCard component tests (column hidden while off, shown while on,N/Afor empty values — including the cached-no-answer case that rendersN/Awith no spinner while fresh, and updates when a post-recentResolveWindowretry yields a valid answer, streamed value overrides response value, newerresolved_atwins: a retained map entry is replaced by a newer sessions-response entry after a WS gap (event → disconnect → list refresh regression), a fresh streamed event beats an in-flight stale response, and a transient/unknown empty never masks a durable response value, toggle PATCHes the setting and flips the column);useSessionsListrefetches on window focus and on thefalse -> truesetting transition (same-tab toggle AND cross-tabuser.settings.updatedpush both trigger exactly one reload, with trailing coalescing — a trigger during an in-flight fetch sets a pending flag and runs exactly one trailing reload after it settles, and generation-guarded responses discard stale results; the toggle PATCH is awaited before the store flip, so the transition refetch always runs against a persisted-true gate — deferred-PATCH test); the WS client lifecycle test proves an identity change disposes and recreates the client with a fresh handler registration at the new epoch (reactive identity selector, not astore.getState()read); store composition:createAppStore({ sessionHostnames })keeps the supplied map (default-state + overrides paths). Router integration: obtain the handlers fromregisterWsHandlers(store)and apply a syntheticauth.session.hostname.resolvednotification, asserting the store map updates — a missing...registerSessionHostnamesHandlers(store)entry inrouter.tsotherwise passes every direct-handler test and theN/A-tolerant E2E while the streaming path is dead. - Implement the store/handler/type changes, then the UI, keeping tests green.
- Run
pnpm run i18n:pseudoafter adding locale keys and re-runi18n:ratchet.
Implementation
apps/web/lib/api/domains/auth-api.ts:AuthSessiongainshostname: stringandhostname_resolved_at: string | null.apps/web/lib/types/backend.ts: add"auth.session.hostname.resolved": BackendMessage<"auth.session.hostname.resolved", SessionHostnameResolvedPayload>withSessionHostnameResolvedPayload = { ip: string; hostname: string; resolved_at: string | null }.apps/web/lib/state/slices/auth/types.ts+auth-slice.ts:sessionHostnames: Record<string, SessionHostnameEntry>(default{}) withSessionHostnameEntry = { hostname: string; resolvedAt: string | null },sessionHostnamesEpoch: number(default0), andsetSessionHostname(ip, hostname, resolvedAt). Shape rule (exact path, no nesting):AuthSliceStategainssessionHostnamesandsessionHostnamesEpochas sibling keys ofauth— the slice creator spreadsdefaultAuthStateinto the ROOT store, so these keys become top-levelAppStatefields; they must NEVER be placed inside theauthobject (nesting underauthis erased bysetAuthState, which replacesdraft.authwholesale). Declare both explicitly inapps/web/lib/state/store.ts'sAppStatetype (alongside the existingauth: (typeof defaultAuthState)["auth"]), inapps/web/lib/state/default-state.ts(defaultState), and inapps/web/lib/state/store-overrides.ts(buildStateOverrides). The auth actions mutate these root fields directly through the immer draft:setAuthStateclears the map + bumps the epoch on identity change (and preserves both on same-identity refresh),clearAuthenticatedclears + bumps, andsetSessionHostnameapplies the entry comparator (below). Selectors and WS handlers read/writestate.sessionHostnames/state.sessionHostnamesEpoch/state.setSessionHostname(...)— neverauth.sessionHostnames. Entry comparator (single source of truth):setSessionHostnameis NOT a blind upsert — it applies one sharedcompareEntries(a, b)(newerresolvedAtwins; a durable entry (non-nullresolvedAt) beats a transient one (null); ties break to the non-empty hostname; both empty or identical → keep the existing entry) and only replaces the map entry when the incoming event wins. This fences event-vs-event reordering: a delayed older event can never overwrite a newer map entry when delivery is reordered, and no HTTP snapshot is required to fix the row. Cell resolution reuses the same comparator against the session's response entry. CompareresolvedAtstrings lexicographically (the fixed-width UTC format is chronologically ordered as a string); do NOT useDate.parse, which truncates sub-millisecond digits and would tie two changes within the same millisecond. Add tests: newer event then a delayed older event (map keeps the newer), durable-beats-null, tie to non-empty, and a sub-millisecond ordering test. Shape rule (exact path):sessionHostnamesis a top-level sibling ofauthin the auth slice state and inAppState({ auth: {...}, sessionHostnames: {...} }) — never nested underauthand never addressed asauth.sessionHostnames.setAuthStatereplacesdraft.authwholesale, so a nested map would be erased by every login/merefresh. Updateapps/web/lib/state/store.ts(AppStatedeclaresauth: (typeof defaultAuthState)["auth"]explicitly) with the new slice state shape;defaultAuthStatemust includesessionHostnames: {}. State composition: also add the field toapps/web/lib/state/default-state.ts(defaultState) andapps/web/lib/state/store-overrides.ts(buildStateOverrides) — store construction spreadsdefaultState, then slices, then overrides, so a caller-suppliedsessionHostnamesincreateAppStore({...})would otherwise be overwritten by the slice default. Selectors and WS handlers read/writestate.sessionHostnames/state.setSessionHostname(...). Identity policy (decided): the map survives disable/enable and same-identity refreshes;setAuthStateclears it when the authenticated identity changes (the newauth.userid differs, or the user becomes null on logout), andclearAuthenticated(invoked on every 401 byapps/web/src/main.tsx) also clears it — both identity boundaries must reset the map so a session-expiry or logout can never leave one user's session IPs seeded for the next user. Identity fence for delayed WS events (required): the gateway stripsuser_idfromauth.session.hostname.resolved, so a frame queued from the prior user is indistinguishable from a fresh one once delivered. Add asessionHostnamesEpoch: numberto the auth slice, incremented whenever the map is cleared on an identity transition; theregisterSessionHostnamesHandlers(store)registration captures the epoch at registration time and ignores any event when the store's current epoch differs (a stale handler generation can never repopulate the cleared map). Belt-and-braces: the WS client is also recreated when the authenticated identity changes (seeuseWebSocket/ws-connectorbelow), dropping transport-queued frames from the old connection. Regression test: populate the map, change identity viasetAuthState(map cleared, epoch bumped), then invoke the OLD handler registration with a syntheticauth.session.hostname.resolvedevent — the map must stay empty; the same event through a registration captured at the new epoch applies.- New
apps/web/lib/ws/handlers/session-hostnames.ts:registerSessionHostnamesHandlers(store)handling the new action (with the epoch guard: ignore events whose store epoch differs from the registration epoch); register it inapps/web/lib/ws/router.ts. apps/web/lib/ws/use-websocket.tsx/apps/web/components/ws-connector.tsx: recreate the client when the authenticated identity changes. Reactivity requirement (both files must change): readingstore.getState()inside the existing[store, url]-keyed effect does NOT trigger a rerender, so the effect never re-runs onsetAuthState/clearAuthenticated.WebSocketConnectormust select the identity reactively —useAppStore((s) => s.auth.user?.id ?? null)plus theauthenticatedflag — and pass it (or a memoized identity string) as an explicit identity key intouseWebSocket, which includes it in the effect dependencies. The auth gate does NOT unmount the connector on a user-id-only change (auth-gate.tsxkeys on mode+authenticated), so the identity key is the only trigger — the lifecycle test MUST cover a user-id-only transition (authenticated staystrue, e.g. another user logs in on the same instance), not just logout/login. The effect's cleanup disposes the old client (unsubscribes + disconnect); the newWebSocketClientstarts with empty subscription maps, so re-establish every component-owned subscription the old client held (subscribeUserre-runs on connect; session/run subscriptions are re-sent by their owning hooks or via the client's resubscribe path — verify in the lifecycle test). Add a lifecycle test: a user-id-onlysetAuthStatedisposes the old client and creates a new one whose handler registration is captured at the new epoch.- Status-callback fencing (required): the old client's status
callbacks — its
onclose(unconditionaldisconnected) andonerror(unconditionalerror) — must not be able to run after the replacement client has connected; an unguarded sharedconnectionStatuswrite from the disposed client can overwriteconnectedwithdisconnected/error(or clear subscriptions) after the new client is live, leaving app state stale while the transport is up.useWebSocketmust either detach the old client's status callbacks in the effect cleanup or pass an active-client/generation guard so callbacks from a disposed client are ignored. Lifecycle regression: delay the old client'soncloseandonerroruntil after the replacement'sonopen/connected — assertconnectionStatusstaysconnectedand the replacement's subscriptions survive. - Hook-owned subscription re-establishment (required): not every
subscription is client-held and recreated automatically. Hooks that
subscribe via
getWebSocketClient()with effect deps that do NOT include the client identity (e.g.use-system-metrics-subscriptionwith[enabled],office run live syncwith[runId, status]) silently keep their subscription on the disposed client after an identity-triggered replacement, leaving those surfaces stale. Expose a reactive client generation (or reuse the identity key) and include it in every hook-owned subscription effect — at minimum system metrics and office run live sync — with a regression test proving a replacement client receives those subscriptions after a user-id-only transition. AuditgetWebSocketClient()call sites for other hook-owned subscriptions with the same gap. apps/web/components/settings/account/security-settings.tsx:- New
ResolveHostnamesCard(or a settings row in the sessions card header) with aSwitchbound touserSettings.resolveSessionHostnames, immediateupdateUserSettings({ resolve_session_hostnames })save and store update on toggle,data-testid="account-resolve-hostnames-switch", switch row at least 44 px tall. SessionsCard: render the Hostname<TableHead>+ cells only while the toggle is on. Cell resolution compares the streamed map entry ({hostname, resolvedAt}) with the session's response entry (hostname,hostname_resolved_at): newerresolved_atwins; a durable entry (non-null) beats a transient/unknown one (null); ties break to the non-empty value; both empty →N/A(t("account:hostnameNotAvailable")). This reconciles the retained map against newer list snapshots after a WebSocket gap while still letting a fresh streamed event beat an in-flight stale response. No pending placeholder: an empty value is indistinguishable from a cached no-answer, so it always rendersN/A; a streamed event replaces it when the lookup completes. Readstate.sessionHostnames(top-level) viauseAppStore.useSessionsList: refetch on windowfocus(in addition to mount) and on thefalse -> truesetting transition (see below), so sessions created elsewhere appear and backend-cached hostnames render without a full reload. No session-created WS event is added.- Reload protocol (trailing coalescing + generation guard): a single
reload function with (a) an in-flight flag, (b) a
pendingTriggerboolean, and (c) a monotonically increasing generation counter. A trigger (focus or enable-transition) that arrives while a fetch is in flight setspendingTriggerinstead of starting a second fetch; when the in-flight fetch settles, one more reload runs ifpendingTriggerwas set — no trigger is ever dropped. Responses carry the generation they started with; only the latest generation's response is applied (stale responses from earlier generations are ignored), so an older slow response can never overwrite newer rows. Tests: focus during an in-flight request and enable-transition during an in-flight request each produce exactly one trailing reload; a stale response arriving after a newer one is discarded. - Enable-transition refetch (sequenced after persistence): the toggle
card's PATCH handler awaits
updateUserSettings(...)and applies the PATCH response to the store — it must NOT optimistically flip the setting before the PATCH commits. The single effect watchinguserSettings.resolveSessionHostnamesthen observes thefalse -> truetransition only after persistence, and its refetch hits a backend whose settings gate is alreadytrue(an un-sequenced optimistic update races: the refetch GET can run while the durable setting is stillfalse, returning empty hostnames and scheduling no DNS, with no later trigger to recover). This same effect is the ONLY refetch path (same-tab and cross-tabuser.settings.updatedbehave identically); it uses trailing coalescing — a trigger during an in-flight fetch sets a pending flag and runs exactly one trailing reload after it settles — and a generation guard discards stale responses. Add a deferred-PATCH test asserting the refetch GET occurs after the PATCH commits and lookups start. - Keep component files under the 600-line/100-line lint limits; extract the column into a small sub-component if needed.
- New
- Mobile composition: the toggle row is a standard touch target; the sessions table remains the sole internal scroll owner; adding the Hostname column must not introduce document-level horizontal overflow on the Pixel 5 path. No mobile-specific page or modal.
- i18n: add
account.jsonkeys to every catalog (en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw) underapps/web/src/locales/(the actual catalog directory):resolveDeviceHostnames,resolveDeviceHostnamesDescription,hostname,hostnameNotAvailable(English valueN/A). Regenerate pseudo withpnpm run i18n:pseudo; never hand-edit pseudo output.
Files likely touched
apps/web/lib/api/domains/auth-api.tsapps/web/lib/types/backend.tsapps/web/lib/state/slices/auth/types.tsapps/web/lib/state/slices/auth/auth-slice.tsapps/web/lib/state/slices/auth/auth-slice.test.tsapps/web/lib/state/store.tsapps/web/lib/state/default-state.tsapps/web/lib/state/store-overrides.tsapps/web/lib/ws/handlers/session-hostnames.tsapps/web/lib/ws/handlers/session-hostnames.test.tsapps/web/lib/ws/router.test.ts(or existing router test file)apps/web/lib/ws/router.tsapps/web/lib/ws/use-websocket.tsx(identity-dependent client lifecycle)apps/web/components/ws-connector.tsxapps/web/components/settings/account/security-settings.tsxapps/web/components/settings/account/security-settings.test.tsxapps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/account.json
Acceptance
- Toggle off by default: no Hostname column renders.
- Toggling on PATCHes the setting and shows the column immediately with
cached values or
N/A; every empty value rendersN/Awith no pending spinner, and streamed events replace it. - A streamed
auth.session.hostname.resolvedevent updates matching rows without a reload; cell resolution is byresolved_at(newer wins, durable beats transient, ties to non-empty), so a WS gap is reconciled by the next list snapshot, a fresh event beats a stale in-flight response, and an unchanged cached no-answer row rendersN/A(its first absent → no-answer resolution DID emit an event with a persistedresolved_at; subsequent same-value refreshes publish nothing until the row's freshness expires and a retry finds a valid answer, which emits the changed hostname). - Toggling off hides the column; toggling on again restores it with the in-memory cached map values before new streams arrive.
setAuthStatepreservessessionHostnamesfor the same identity and clears it when the identity changes (different user id or logout);clearAuthenticated(401 path) clears it too; the auth slice shape change compiles withstore.ts's explicitauthAppState type; every reference uses the top-levelsessionHostnamespath, neverauth.sessionHostnames.- The
false -> truesetting transition (same tab or cross-tab) triggers a guarded sessions refetch; backend-cached hostnames render even when the list was last loaded while the toggle was off. Triggers during an in-flight fetch coalesce into exactly one trailing reload; stale responses never overwrite newer rows. - The sessions list refetches on window focus (same coalescing protocol); a session created elsewhere appears without a full reload.
- All new copy passes
i18n:ratchetand the pseudo locale renders; catalog files live underapps/web/src/locales/. AuthSessionand the WS payload types match the backend wire shapes.- Mobile: toggle and table remain reachable/touchable at Pixel 5 width with no document horizontal overflow.
Verification
(cd apps/web && pnpm exec vitest run lib/state/slices/auth/auth-slice.test.ts lib/ws/handlers/session-hostnames.test.ts components/settings/account/security-settings.test.tsx lib/api/domains/auth-api.test.ts)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm run i18n:ratchet)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run lint)
git diff --check
Dependencies
Tasks 01 and 02 (setting, sessions hostname, WS action).
Risks
- Nesting
sessionHostnamesunderauthletssetAuthStateerase it on every login/merefresh; keep it a top-level sibling ofauthin the slice andAppState, address it only assessionHostnames, and pin the preservation-in-same-identity / clear-on-identity-change semantics in tests. - A toggle handler that reloads sessions itself (instead of the single setting-transition effect) double-fetches and behaves differently for same-tab vs cross-tab; the effect watching the store value is the one refetch path.
- Forgetting
default-state.ts/store-overrides.tsin the state shape change makescreateAppStore({ sessionHostnames })silently drop the supplied map (slices spread after the default state). - A pending spinner is not representable: empty values (unresolved AND
cached no-answer) render identically. An unchanged no-answer row emits no
further events while fresh, but after
recentResolveWindowa retry may replace the empty with a valid answer and publish it; never render a pending state; empty always meansN/Auntil such a changed event or a newer list snapshot. - Column visibility and the toggle must read the same store value; a local
copy that does not update on the WS
user.settings.updatedpush leaves the column stuck. - A retained map with no ordering information can permanently override a
newer sessions response after a WS gap; entries must carry
resolvedAtand cells must compareresolved_at(newer wins, durable beats transient), with the event → disconnect → list-refresh regression test. - A focus/transition refetch that drops triggers while a fetch is in flight misses the only refresh for that event (stale list forever); use trailing coalescing (pending flag + exactly one post-settle reload) and a generation guard so stale responses never overwrite newer rows.
- Missing pseudo entries fail
i18n:check; regenerate, do not hand-edit. - Forgetting
apps/web/lib/state/store.tsin the slice-shape change breaks typecheck (AppState pins theauthfield shape).