Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
25 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/session-hostname-resolution/spec.md | 2026-08-16 | draft |
Implementation Plan: Optional reverse-DNS hostnames for account sessions
Outcome
A per-user Resolve device hostnames toggle on
/settings/account/security, off by default. Enabling it shows a Hostname
column on the sessions table; the backend reverse-resolves the user's session
IPs through the OS resolver (net.LookupAddr) in the background, caches
results per IP in the configured database, and streams changed values to the
page over the existing WebSocket gateway. No-answer IPs and generic .in-addr.arpa /
.ip6.arpa answers render N/A. Disabling hides the column; re-enabling
restores cached values instantly and streams only deltas.
Fixed decisions
- Wire setting
resolve_session_hostnames: boolean; frontend stateuserSettings.resolveSessionHostnames. Missing stored values and initial payloads default tofalse; PATCH omission preserves the current value. - Turning the setting on publishes the persisted user-settings event. The
hostname resolver consumes that event and starts background resolution for
the user's session IPs. The sessions list response also triggers resolution
for uncached/new IPs and carries the cached
hostnameper session. - New cache table
auth_hostname_cache, owned by the hostnames cache store (internal/auth/hostnames/store.go) whoseinitSchemacreates it (new table, soCREATE TABLE IF NOT EXISTSsuffices; noADD COLUMNmigration). - Cache is global by IP;
hostname == ""means no answer and is cached.recentResolveWindow = 30sskips re-query of freshly resolved IPs;lookupTimeout = 3s;lookupWorkers = 4; in-flight IPs are deduplicated with an interested-user set per IP. - Streaming: the internal event-bus event is
auth.session.hostname.resolvedwith payload{user_id, ip, hostname, resolved_at}(fixed 9-digit RFC3339 UTC or null) — theUserEventBroadcasterroutes byuser_idand strips it — and the WS actionauth.session.hostname.resolvedcarries the reduced{ip, hostname, resolved_at}to the client; published only when the hostname value changed versus the cache. Every successful lookup (valid orIsNotFound) persists and refreshesresolved_atviaSeteven when the hostname is unchanged, sorecentResolveWindowengages for stable and no-answer rows. Cache/job keys are canonical IPs; the eventipis each interested (user, raw stored session-IP spelling) pair so frontend row matching needs no IP parsing; one user with two spellings of the same IP gets events for both. The sessions response carrieshostnameandhostname_resolved_atper session so the frontend can order snapshots against streamed events. - Frontend keeps an in-memory per-IP streamed map in the auth store slice as
a top-level
sessionHostnamessibling ofauth(never nested, never addressed asauth.sessionHostnames; also wired throughdefault-state.tsandstore-overrides.tsso caller-supplied maps survive store construction). Entries are{hostname, resolvedAt}(fixed 9-digit RFC3339 UTC or null). It survives disable/enable and same-identity refreshes and is cleared when the authenticated identity changes (setAuthState with a different user, logout, and the 401/clearAuthenticated path). The The SessionsCard shows the Hostname column only while the toggle is on; cell resolution comparesresolved_at(newer wins; durable beats transient; ties to non-empty; both empty →N/A), which 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. The SAME comparator lives insetSessionHostname(not a blind upsert) so a delayed older event can never overwrite a newer map entry. There is no pending spinner: empty values always renderN/A. A first absent → no-answer resolution IS a change and publishes (anIsNotFoundnegative with a persistedresolved_at; a transient DNS error over an absent cache withresolved_at: null); a subsequent same-value cached no-answer only refreshesresolved_atviaSetand publishes nothing, so an unchanged settled no-answer row emits no further events and rendersN/Auntil its freshness expires (recentResolveWindow) and a retry replaces the empty with a valid answer, which publishes the changed hostname. Afalse -> truesetting transition (same tab or cross-tab) refetches the sessions list once (single guarded effect with trailing coalescing — a trigger during an in-flight fetch schedules exactly one post-settle reload — and a generation guard that discards stale responses), so backend-cached hostnames render even when the list was last loaded while off; the list also refetches on window focus with the same protocol. No session-created push event. - The settings gate fails closed: a settings read error behaves exactly like the toggle being off (empty hostname map, no lookups, logged) — DNS must never run while opt-in is unknown.
- DNS errors are classified:
*net.DNSErrorwithIsNotFound(NXDOMAIN / no PTR, the common no-answer case) is a successful negative answer persisted ashostname = ""; timeout/temporary/server errors are never persisted and are published""only for IPs with no cache entry (never over an existing cached value, which would mask it client-side). - Resolver API contract:
HostnamesForSessionIPs(ctx, userID string, ips []string) map[string]CacheEntry— raw-input-keyed,ErrNotFoundtreated as a successful absent baseline, all degradations (settings/cache errors) logged internally, never returns an error and never fails the sessions handler. - Production wiring calls
Start(ctx)explicitly, registersCloseas cleanup, queue-subscribes the resolver to the persistedUserSettingsUpdatedevent, and threads the resolver toregisterRoutesvia theServicesstruct (types.go). - The toggle hook is fire-and-forget:
TriggerUserSessionsResolved(userID)enqueues on a resolver-owned trigger queue and returns immediately (PATCH never blocks on settings/session/cache reads); the trigger goroutine and workers run on the resolver-owned context andClosejoins them. - The lookup function is injectable for tests; default is
net.DefaultResolver.LookupAddr. No HTTP/API calls in the lookup path. - The resolver depends on a narrow
Cacheinterface (GetMany/Get/SetoverCacheEntry{Hostname string, ResolvedAt *time.Time}, absent rows absent from results,ErrNotFoundtreated as a successful absent baseline) for deterministic error injection andrecentResolveWindow; timestamps are fixed 9-digit fractional-second RFC3339 UTC text (lexicographically comparable, so same-millisecond changes still order). ASeterror publishes nothing (changed-only semantics; the next trigger retries the persist, and a later successfulSetthat changes the cache publishes);resolved_at: nullin events is reserved for transient DNS errors over an absent cache (empty strings), never for failed writes. Admission records the observed cache value per pending IP; a worker that finds an IP fresh withinrecentResolveWindowstill publishes the current value to interested users when it differs from what the requester saw (stale-read → concurrent completion → enqueue must not silently drop the update), and re-checks the settings gate at dequeue so DNS never runs while the toggle is off (disabled users' interests are dropped; a job with no enabled interested users is skipped). Session-IP providers return raw validated spellings; canonicalization stays inside the resolver. Gateway tests assertresolved_at(fixed-width RFC3339 and null) survives both memory and NATS transports; a frontend router integration test pinsregisterSessionHostnamesHandlersinregisterWsHandlers.
Backend
Task 01 — portable setting contract
AppStatusBarEnabled-style fieldResolveSessionHostnames boolininternal/user/models/models.go.- Response + PATCH
*boolininternal/user/dto/dto.go, controller mapping,applyBasicSettingsininternal/user/service/service.go, event payload keyresolve_session_hostnamesinpublishUserSettingsEvent. - Store: default false, JSON marshal key,
*boolscan decode, overwrite only when present (internal/user/store/sqlite.go). - Boot mapping
resolveSessionHostnamesininternal/backendapp/boot_state_routes.go. - Frontend: HTTP types (
http-user-settings.ts),UserSettingsStatefield, shared mapper inlib/ssr/user-settings.ts(default false, omission preserves), revision-ordered WS handler fixture updates. - Tests: model/DTO/service/store/boot/event round trip; frontend mapper and WS handler tests.
Task 02 — backend reverse-DNS service
- New package
internal/auth/hostnames:store.go: database-portable cache store (auth_hostname_cache),Get/Set;normalize.go:NormalizeHostname([]string) string(first-valid-record: strip trailing dot, reject empty and.in-addr.arpa/.ip6.arpasuffixes);resolver.go:ResolverwithStart/Closeworker pool (goleak), in-flight + interested-user dedup,recentResolveWindowguard,HostnamesForSessionIPs(ctx, userID, ips)(enabled gate, cached values, schedules lookups) and fire-and-forgetTriggerUserSessionsResolved(userID)(owned trigger queue, PATCH never blocks); changed-only event publishing withIsNotFoundpersisting as a cached no-answer and timeout/error →""not persisted.
auth.Service.SessionIPs(ctx, userID)(distinct raw spellings of the user's sessions, skipping empty/invalid).eventsconstantAuthSessionHostnameResolved.pkg/websocket/actions.go:ActionSessionHostnameResolved.gateway/websocket/user_notifications.go: subscribe the new event to the new action.auth/httpapi:RegisterRoutes(router, svc, resolver, log);listSessionsreturns per-sessionhostnameand schedules resolution.user/service: publish the complete persisted setting through the existingUserSettingsUpdatedevent.backendapp: construct the resolver (store overdbPool, session-IPs =authSvc, settings gate overrepos.User, event bus, log), subscribe it to setting updates, and pass it toauthhttpapi.RegisterRoutes.- Tests: store, normalize, resolver (dedup, changed-only, interested-user routing, disabled gate, recent-window, timeout-not-persisted), goleak, httpapi sessions payload, and persisted settings event handling.
Frontend
Task 03 — streaming and UI
AuthSessiontype gainshostname: stringandhostname_resolved_at: string | null(fixed 9-digit timestamp or null).BackendMessageMapentry +SessionHostnameResolvedPayload({ip, hostname, resolved_at}); WS handlerlib/ws/handlers/session-hostnames.tsregistered inrouter.tsupdating top-levelsessionHostnames; router integration test pins the registration.- Auth slice:
sessionHostnames: Record<string, SessionHostnameEntry>withSessionHostnameEntry = { hostname: string; resolvedAt: string | null }andsetSessionHostname(ip, hostname, resolvedAt)(default empty). security-settings.tsx: toggle card (Switch, direct save viaupdateUserSettings,data-testid), SessionsCard Hostname column gated on the setting, cell resolution byresolved_at(newer wins, durable beats transient, ties to non-empty, both empty →N/A; lexicographic string comparison, neverDate.parse).- i18n:
account.jsonkeys (resolveDeviceHostnames,resolveDeviceHostnamesDescription,hostname,hostnameNotAvailable) in all catalogs; regenerate pseudo. - Tests: auth slice action, WS handler, router registration, mapper, component test for toggle + column rendering + streamed update + resolved_at ordering (WS-gap reconciliation, same-millisecond ordering) + N/A.
E2E and verification
Task 04 — E2E and final verification
- Playwright spec under
apps/web/e2e/tests/auth/— files there run in theauthproject (backend restarted withKANDEV_FEATURES_AUTH: true). Setup issetupAdmin(...)only for the main flow (/auth/setupcreates the admin session and sets the cookie, so the baseline is exactly one row — do NOT callloginbefore navigation, which would create a second baseline row); the secondlogin(...)is reserved for the focus-refetch step. Reset the setting tofalseinafterEach: toggle off default (no Hostname column), toggle on (column appears immediately; each row's cell is hostname-or-N/A— no WS-event waits, the worker backend cache persists across tests and e2e logins share one loopback IP so first-resolution events are not guaranteed), toggle off (column hidden), toggle on again (column reappears with cached values), setting survives reload, and focus-refetch discovers a second login (login again through the same context's request client, navigate a second same-context page,bringToFrontpage 1, row count grows by one). - Mobile:
tests/auth/mobile-account-security-hostnames.spec.ts(runs in themobile-chromeproject; the auth project'stestIgnoreexcludesmobile-*) covering the same enable/disable/re-enable flow and column rendering at Pixel 5 viewport. - Run backend focused tests + lint, frontend focused tests,
i18n:check+i18n:ratchet, typecheck, lint, targeted E2E (auth + mobile-chrome projects),git diff --check.
Implementation waves
Execution is sequential in the primary conversation.
| Wave | Task | Status | Gate |
|---|---|---|---|
| 1 | 01 portable setting contract | pending | resolve_session_hostnames round trips backend→boot→frontend with default false. |
| 2 | 02 backend reverse-DNS service | pending | Lookup, cache (GetMany/CacheEntry), changed-only streaming and sessions hostname/hostname_resolved_at fields green (fail-closed settings gate, IsNotFound negative caching, ErrNotFound-as-absent, transient-error masking rule, fire-and-forget trigger, off-state empty map, pending-set admission, resolver-owned contexts, IP canonicalization + raw-IP event keys, Cache-interface error semantics, production Start wiring + settings-event subscription, gateway routing tests, schema replay); toggle still has no UI. |
| 3 | 03 frontend streaming and UI | pending | Toggle + Hostname column (empty always N/A, no spinner) + streamed updates + focus-refetch, desktop and mobile. |
| 4 | 04 E2E and final verification | pending | Auth-project desktop E2E + mobile-chrome E2E (persistence/column behavior, no WS-event waits) and full checks pass. |
Task 02 depends on Task 01 (the resolver reads the setting); Task 03 depends on 01 + 02; Task 04 depends on 03. No tasks are parallel-safe.
Verification order
If apps/node_modules is absent, run once before frontend checks:
(cd apps && pnpm install --frozen-lockfile)
Each task runs its focused commands. Final verification:
(cd apps/backend && go test ./internal/auth/... ./internal/user/... ./internal/gateway/websocket/... ./internal/backendapp/... ./pkg/websocket/...)
make -C apps/backend lint
(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)
(cd apps/web && pnpm exec vitest run lib/ssr/user-settings.test.ts lib/ws/handlers/session-hostnames.test.ts lib/state/slices/auth/auth-slice.test.ts components/settings/account/security-settings.test.tsx)
(cd apps/web && pnpm e2e:run --project auth tests/auth/account-security-hostnames.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/auth/mobile-account-security-hostnames.spec.ts)
git diff --check
Risks
- A backend or frontend default that stays
truewould resolve IPs for users who never opted in. Pin default-false at every boundary (store, boot, mapper, state), and gate the sessions-response map so cached hostnames never leak while the toggle is off. - Forgetting the sessions-response
hostnamefield or the event key in the complete settings event breaks hydration/streaming silently; pin both in tests. - DNS behavior varies by environment (no PTR,
localhost, synthesized names); E2E must assert column presence/absence and hostname-or-N/Acells, never an exact hostname, and must NOT wait for WS events (fresh cache and unchanged results intentionally emit nothing). - Multi-user: streaming must be routed per user; the interested-user set prevents user B from missing updates when user A's lookup for a shared IP completes.
- Rapid toggle cycles must not hammer DNS; the
recentResolveWindowguard and in-flight dedup cover this. Admission must never drop a required IP: workers drain a pending set rather than a drop-on-full queue. - Lookups must run on a resolver-owned context (canceled by
Close), never a request context, or they die with the HTTP response. - The auth slice's
sessionHostnamesmap must be a top-level sibling ofauth(never nested, neverauth.sessionHostnames), orsetAuthState(login/me refresh) erases it;store.tsmust be updated with the slice shape. - A cache interface that cannot distinguish absent from cached-empty or
expose
ResolvedAtmakes no-answer caching andrecentResolveWindowunimplementable; the resolver'sCachereturnsCacheEntryand absent rows are absent fromGetManyresults. - Raw session-IP spellings must survive to the event payload (per user, per
spelling), or hook-triggered resolutions publish canonical
ipvalues the frontend cannot match; providers return raw validated spellings and only the resolver canonicalizes for job/cache keys. - A settings-gate read error must fail closed (no lookups, empty map); a default-to-enabled fallback would run DNS while opt-in is unknown.
- A pending spinner is not representable (cached no-answer rows emit no
event): empty always renders
N/A, and E2E must not depend on WS events (the worker backend cache persists across tests and e2e logins share one loopback IP, so first-resolution/changed events are not guaranteed). - The pending set needs the complete-then-recheck invariant: workers must re-drain after every completion or IPs admitted while all workers are busy strand forever.
- Without the enable-transition refetch, enabling after a list load that
happened while off shows
N/Aforever (the off-state response carried no cached hostnames and changed-only events may never come); the single guarded effect watching the setting is the fix. - The in-memory map must clear on identity change (different user or logout) or one user's session IPs seed another user's rows; keep the disable/enable survival separate and test both.
createAppStore({ sessionHostnames })silently drops the map unless the field is added todefault-state.tsandstore-overrides.ts(slices spread after the default state).- E2E focus-refetch needs a second page in the same context that actually
navigates (the
loginhelper never focuses a page), then page 1bringToFront; it must not be skippable. - The common no-PTR case surfaces as
*net.DNSErrorwithIsNotFound, not as records; failing to classify it means no-answer IPs are never cached and re-queried on every trigger. PersistIsNotFoundas a cached no-answer and only treat timeout/temporary/server errors as non-persisted. - Transient failures must not mask durable values: the backend publishes
transient
""only for IPs with no cache entry, and the frontend orders byresolved_at(newer wins, durable beats transient), or a stale-cache + transient-error + re-enable cycle pins rows toN/A. - Without a per-IP timestamp, the retained streamed map can permanently
override a newer sessions response after a WS gap (events during a
disconnect are lost and never replayed); both the API and the WS payload
must carry
resolved_atand cells must compare it. ErrNotFoundmust be treated as an absent baseline (never as a generic cache error) or first-resolution transient behavior and negative caching break; pin the distinction in resolver tests.- A same-value successful lookup that skips
Setnever refreshesresolved_at, sorecentResolveWindownever engages and stable/no-answer rows re-query on every trigger; successful lookups always persist and refresh the timestamp, while publishing stays changed-only. - A missing
UserSettingsUpdatedsubscription lets a successful PATCH stop before it starts lookup work. The resolver event-handler test pins that boundary without adding feature knowledge to the user settings service. - Clearing
sessionHostnameson identity change is not enough: a delayed WS frame from the prior user (the gateway stripsuser_id) can repopulate the cleared map. Fence the stream with asessionHostnamesEpochhandler guard plus WS client recreation on identity change, and pin the stale-event regression test. Client - Client recreation must be reactive (an identity selector in
WebSocketConnector/useWebSocketdeps — astore.getState()read inside the effect does not rerender), must cover user-id-only transitions (the auth gate does not unmount the connector on those), and must re-establish client-held subscriptions — and the disposed client's status callbacks (oncloseANDonerror) must be fenced (detached or generation-guarded) so a delayed callback cannot overwrite the replacement'sconnectedstatus. Hook-owned subscriptions obtained viagetWebSocketClient()(system metrics, office run live sync, others) must include the reactive client generation in their deps or they silently stay on the disposed client. - Streamed hostname notifications fan out to the user's own WS connections (all tabs); the spec's non-goal is rendering-scoped — only the security page renders the data, other tabs retain but never show it (this is what powers instant re-enable/cross-tab cached results).
- The enable-transition refetch must run after the PATCH commits (apply the PATCH response to the store, no optimistic flip), or the refetch GET can race a still-false settings gate and permanently miss cached hostnames and DNS scheduling.
- A
Set-error publish without explicit semantics makes an unpersisted value look durable (or linger transient); publish nothing onSeterror and let the retry persist-then-publish. - The interested-user set must be snapshot+cleared exactly once on every job terminal path (publish, no-change, dequeue-gate skip, cache errors, nil bus) or stale routing state accumulates unboundedly under session/IP/user churn; in-flight merges are the only retained interests.
- The timestamp formatter must be an exported
hostnamesAPI (FormatTimestamp/ParseTimestamp) used by the store, resolver, and httpapi handler — unexported helpers would force layout duplication across packages and drift the fixed-width ordering contract. - The controller's field-by-field PATCH mapping can silently drop
resolve_session_hostnameswhile DTO/service/store tests pass; pin the pointer mapping incontroller_test.go. - A forgotten production
Start(ctx)passes every unit test (they call Start themselves) and every E2E (which toleratesN/A); the backendapp wiring smoke test is the only guard. - A missing router registration (
registerSessionHostnamesHandlers) passes direct-handler tests andN/A-tolerant E2E while the streaming path is dead; the router integration test is the guard. - Without a dequeue-time settings recheck, DNS can run after the toggle is disabled for work admitted while it was on; re-check at dequeue, drop disabled users' interests, and skip jobs with no enabled users.
Date.parse/RFC3339Nanocannot order same-millisecond changes; the fixed-width 9-digit fraction keeps timestamps lexicographically comparable in Go and JS.- The desktop E2E must use an isolated
KANDEV_DATABASE_PATHlike the mobile spec; the auth worker is serial/shared andbackend.restartpreserves the DB, so leaked sessions/cache/admin state makes row-count and setup assertions order-dependent. - A synchronous hook or a naive goroutine in
UpdateUserSettingsblocks the PATCH or dies with the request context; the hook must enqueue on the resolver-owned trigger queue and return immediately. - E2E focus-refetch must create the second session by calling
loginagain through the same context's request client (navigation alone reuses the cookie and adds no session row), then navigate a second same-context page for a real blur/focus, thenbringToFrontpage 1; it must not be skippable. - The mobile spec runs in
mobile-chrome, which does not inherit the auth project's backend restart; it must restart auth-enabled with an isolated DB itself or the page never loads sessions. - The sessions-list refetch must use trailing coalescing + generation guard: a trigger dropped during an in-flight fetch loses the only refresh for that event, and an older slow response can overwrite newer rows.
- E2E specs under
tests/auth/only run in theauthproject; auth-mode setup (setupAdmin/login) and per-test cleanup are required, and the mobile spec must be a separatemobile-*.spec.tsfile for themobile-chromeproject.