From 15c0c35df62b9233383d6606506644fbd91eef91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Fri, 4 Sep 2026 13:06:33 +0000 Subject: [PATCH] feat(auth): resolve session hostnames (#2741) Co-authored-by: Carlos Florencio --- .../internal/auth/hostnames/goleak_test.go | 11 + .../internal/auth/hostnames/normalize.go | 16 + .../internal/auth/hostnames/normalize_test.go | 71 ++ .../internal/auth/hostnames/resolver.go | 488 +++++++++++++ .../internal/auth/hostnames/resolver_test.go | 651 ++++++++++++++++++ apps/backend/internal/auth/hostnames/store.go | 124 ++++ .../internal/auth/hostnames/store_test.go | 82 +++ .../backend/internal/auth/httpapi/handlers.go | 37 +- .../internal/auth/httpapi/handlers_test.go | 112 ++- .../internal/auth/httpmw/middleware_test.go | 2 +- .../internal/auth/service_credentials.go | 21 + .../app_status_bar_visibility_test.go | 10 + .../internal/backendapp/boot_state_routes.go | 1 + apps/backend/internal/backendapp/helpers.go | 2 +- apps/backend/internal/backendapp/main.go | 32 + apps/backend/internal/backendapp/types.go | 4 +- apps/backend/internal/events/types.go | 1 + .../gateway/websocket/user_notifications.go | 1 + .../websocket/user_notifications_test.go | 49 ++ .../internal/user/controller/controller.go | 1 + .../user/controller/controller_test.go | 64 +- .../dto/app_status_bar_visibility_test.go | 36 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/models/models.go | 1 + .../service/app_status_bar_visibility_test.go | 47 +- apps/backend/internal/user/service/service.go | 11 +- .../store/app_status_bar_visibility_test.go | 29 + apps/backend/internal/user/store/sqlite.go | 6 + apps/backend/pkg/websocket/actions.go | 1 + .../runs/[runId]/use-run-live-sync.test.tsx | 29 +- .../[id]/runs/[runId]/use-run-live-sync.ts | 7 +- .../tasks/[id]/use-session-live-sync.test.tsx | 32 +- .../tasks/[id]/use-session-live-sync.ts | 11 +- .../account/security-settings.test.tsx | 62 ++ .../settings/account/security-settings.tsx | 135 +++- .../auth/account-security-hostnames.spec.ts | 121 ++++ .../mobile-account-security-hostnames.spec.ts | 45 ++ .../hooks/use-ensure-user-settings.test.ts | 1 + .../use-system-metrics-subscription.test.ts | 30 + .../hooks/use-system-metrics-subscription.ts | 10 +- apps/web/lib/api/domains/auth-api.ts | 2 + apps/web/lib/plugins/host.test.ts | 12 - .../settings/user-settings-revision.test.ts | 15 + .../lib/settings/user-settings-revision.ts | 9 + apps/web/lib/ssr/user-settings.test.ts | 18 + apps/web/lib/ssr/user-settings.ts | 2 + apps/web/lib/state/app-state-types.ts | 2 + apps/web/lib/state/default-state.ts | 27 +- .../lib/state/slices/auth/auth-slice.test.ts | 31 + apps/web/lib/state/slices/auth/auth-slice.ts | 29 + apps/web/lib/state/slices/auth/types.ts | 9 +- apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/state/store-overrides.ts | 2 + .../test-support/happy-dom-network.test.ts | 9 + .../lib/test-support/noop-websocket.test.ts | 13 + apps/web/lib/test-support/noop-websocket.ts | 12 + apps/web/lib/types/backend.ts | 10 + apps/web/lib/types/http-user-settings.ts | 2 + apps/web/lib/ws/connection.test.tsx | 22 + apps/web/lib/ws/connection.ts | 12 + .../lib/ws/handlers/session-hostnames.test.ts | 50 ++ apps/web/lib/ws/handlers/session-hostnames.ts | 19 + apps/web/lib/ws/handlers/users.test.ts | 17 + apps/web/lib/ws/router.ts | 2 + apps/web/lib/ws/use-websocket.test.tsx | 53 +- apps/web/lib/ws/use-websocket.tsx | 27 +- apps/web/src/locales/en/account.json | 5 + apps/web/src/locales/pseudo/account.json | 5 + apps/web/src/locales/pt-pt/account.json | 5 + apps/web/src/locales/zh-cn/account.json | 5 + apps/web/src/locales/zh-hk/account.json | 5 + apps/web/src/locales/zh-tw/account.json | 5 + apps/web/vitest.setup.ts | 25 + ...viewport-activation-owns-thread-streams.md | 1 - .../plans/session-hostname-resolution/plan.md | 421 +++++++++++ .../task-01-portable-setting-contract.md | 123 ++++ .../task-02-backend-reverse-dns-service.md | 522 ++++++++++++++ .../task-03-frontend-streaming-and-ui.md | 377 ++++++++++ .../task-04-e2e-and-final-verification.md | 216 ++++++ .../viewport-bounded-session-delivery.md | 1 - .../specs/session-hostname-resolution/spec.md | 412 +++++++++++ 81 files changed, 4850 insertions(+), 82 deletions(-) create mode 100644 apps/backend/internal/auth/hostnames/goleak_test.go create mode 100644 apps/backend/internal/auth/hostnames/normalize.go create mode 100644 apps/backend/internal/auth/hostnames/normalize_test.go create mode 100644 apps/backend/internal/auth/hostnames/resolver.go create mode 100644 apps/backend/internal/auth/hostnames/resolver_test.go create mode 100644 apps/backend/internal/auth/hostnames/store.go create mode 100644 apps/backend/internal/auth/hostnames/store_test.go create mode 100644 apps/web/e2e/tests/auth/account-security-hostnames.spec.ts create mode 100644 apps/web/e2e/tests/auth/mobile-account-security-hostnames.spec.ts create mode 100644 apps/web/hooks/use-system-metrics-subscription.test.ts create mode 100644 apps/web/lib/settings/user-settings-revision.test.ts create mode 100644 apps/web/lib/test-support/happy-dom-network.test.ts create mode 100644 apps/web/lib/test-support/noop-websocket.test.ts create mode 100644 apps/web/lib/test-support/noop-websocket.ts create mode 100644 apps/web/lib/ws/connection.test.tsx create mode 100644 apps/web/lib/ws/handlers/session-hostnames.test.ts create mode 100644 apps/web/lib/ws/handlers/session-hostnames.ts create mode 100644 docs/plans/session-hostname-resolution/plan.md create mode 100644 docs/plans/session-hostname-resolution/task-01-portable-setting-contract.md create mode 100644 docs/plans/session-hostname-resolution/task-02-backend-reverse-dns-service.md create mode 100644 docs/plans/session-hostname-resolution/task-03-frontend-streaming-and-ui.md create mode 100644 docs/plans/session-hostname-resolution/task-04-e2e-and-final-verification.md create mode 100644 docs/specs/session-hostname-resolution/spec.md diff --git a/apps/backend/internal/auth/hostnames/goleak_test.go b/apps/backend/internal/auth/hostnames/goleak_test.go new file mode 100644 index 0000000000..ef79ef6cad --- /dev/null +++ b/apps/backend/internal/auth/hostnames/goleak_test.go @@ -0,0 +1,11 @@ +package hostnames + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/auth/hostnames/normalize.go b/apps/backend/internal/auth/hostnames/normalize.go new file mode 100644 index 0000000000..98926bad09 --- /dev/null +++ b/apps/backend/internal/auth/hostnames/normalize.go @@ -0,0 +1,16 @@ +package hostnames + +import "strings" + +// NormalizeHostname returns the first DNS record suitable for display. +func NormalizeHostname(names []string) string { + for _, name := range names { + name = strings.TrimSuffix(strings.TrimSpace(name), ".") + lower := strings.ToLower(name) + if name == "" || strings.HasSuffix(lower, ".in-addr.arpa") || strings.HasSuffix(lower, ".ip6.arpa") { + continue + } + return name + } + return "" +} diff --git a/apps/backend/internal/auth/hostnames/normalize_test.go b/apps/backend/internal/auth/hostnames/normalize_test.go new file mode 100644 index 0000000000..71838da2cd --- /dev/null +++ b/apps/backend/internal/auth/hostnames/normalize_test.go @@ -0,0 +1,71 @@ +package hostnames + +import "testing" + +func TestNormalizeHostnameUsesFirstValidRecord(t *testing.T) { + got := NormalizeHostname([]string{"10.2.0.192.in-addr.arpa.", "mail.example.test."}) + if got != "mail.example.test" { + t.Fatalf("NormalizeHostname() = %q, want %q", got, "mail.example.test") + } +} + +func TestNormalizeHostname(t *testing.T) { + tests := []struct { + name string + input []string + want string + }{ + { + name: "empty input", + input: nil, + want: "", + }, + { + name: "all reverse-DNS records", + input: []string{"10.2.0.192.in-addr.arpa.", "2001:db8::1.ip6.arpa."}, + want: "", + }, + { + name: "skips arpa prefix then returns the first valid record", + input: []string{"10.2.0.192.in-addr.arpa.", "mail.example.test."}, + want: "mail.example.test", + }, + { + name: "IPv6 ip6.arpa suffix is filtered", + input: []string{"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.", "web.example.test"}, + want: "web.example.test", + }, + { + name: "single valid entry", + input: []string{"mail.example.test."}, + want: "mail.example.test", + }, + { + name: "trailing-dot-only entry is skipped", + input: []string{".", "host.example.test."}, + want: "host.example.test", + }, + { + name: "strips trailing dot and trims whitespace", + input: []string{" mail.example.test. "}, + want: "mail.example.test", + }, + { + name: "case-insensitive arpa detection", + input: []string{"10.2.0.192.IN-ADDR.ARPA.", "mail.example.test."}, + want: "mail.example.test", + }, + { + name: "invalid records produce an empty result", + input: []string{".", " "}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeHostname(tt.input); got != tt.want { + t.Fatalf("NormalizeHostname() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/auth/hostnames/resolver.go b/apps/backend/internal/auth/hostnames/resolver.go new file mode 100644 index 0000000000..1aa12aada4 --- /dev/null +++ b/apps/backend/internal/auth/hostnames/resolver.go @@ -0,0 +1,488 @@ +package hostnames + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "go.uber.org/zap" +) + +const ( + lookupWorkers = 4 + lookupTimeout = 3 * time.Second + recentResolveWindow = 30 * time.Second + userIDKey = "user_id" +) + +type SettingsGate func(context.Context, string) (bool, error) +type SessionIPs func(context.Context, string) ([]string, error) +type LookupAddr func(context.Context, string) ([]string, error) + +type interest struct { + userID string + rawIP string +} + +type pendingJob struct { + ip string + observed CacheEntry + interests []interest + generation uint64 +} + +type pendingUser struct { + generation uint64 + running bool +} + +type interestGate struct { + generation uint64 + enabled map[string]bool +} + +func (g *interestGate) forGeneration(generation uint64) map[string]bool { + if g.enabled == nil || g.generation != generation { + g.generation = generation + g.enabled = make(map[string]bool) + } + return g.enabled +} + +// Resolver asynchronously reverse-resolves opted-in user session IPs. +type Resolver struct { + cache Cache + settingsOn SettingsGate + sessionIPs SessionIPs + lookupAddr LookupAddr + eventBus bus.EventBus + log *logger.Logger + + mu sync.Mutex + pending map[string]*pendingJob + inFlight map[string]*pendingJob + pendingUsers map[string]pendingUser + wake chan struct{} + triggerWake chan struct{} + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + started bool + stopping chan struct{} +} + +// NewResolver builds a resolver with independent lookup and trigger queues. +func NewResolver(cache Cache, settingsOn SettingsGate, sessionIPs SessionIPs, eventBus bus.EventBus, log *logger.Logger) *Resolver { + return &Resolver{ + cache: cache, settingsOn: settingsOn, sessionIPs: sessionIPs, eventBus: eventBus, log: log, + lookupAddr: net.DefaultResolver.LookupAddr, + pending: make(map[string]*pendingJob), inFlight: make(map[string]*pendingJob), + pendingUsers: make(map[string]pendingUser), wake: make(chan struct{}, lookupWorkers), triggerWake: make(chan struct{}, 1), + } +} + +// Start launches lookup workers unless an earlier lifecycle is still closing. +func (r *Resolver) Start(parent context.Context) { + for { + r.mu.Lock() + if r.started { + r.mu.Unlock() + return + } + if r.stopping != nil { + stopping := r.stopping + r.mu.Unlock() + <-stopping + continue + } + r.ctx, r.cancel = context.WithCancel(parent) + r.started = true + for range lookupWorkers { + r.wg.Add(1) + go r.worker() + } + r.wg.Add(1) + go r.triggerWorker() + r.mu.Unlock() + return + } +} + +// Close cancels workers and resets queue state for a future Start. +func (r *Resolver) Close() error { + r.mu.Lock() + if r.stopping != nil { + stopping := r.stopping + r.mu.Unlock() + <-stopping + return nil + } + if !r.started { + r.mu.Unlock() + return nil + } + r.cancel() + r.started = false + r.stopping = make(chan struct{}) + stopping := r.stopping + r.mu.Unlock() + r.wg.Wait() + + r.mu.Lock() + r.pending = make(map[string]*pendingJob) + r.inFlight = make(map[string]*pendingJob) + r.wake = make(chan struct{}, lookupWorkers) + r.triggerWake = make(chan struct{}, 1) + r.pendingUsers = make(map[string]pendingUser) + r.stopping = nil + close(stopping) + r.mu.Unlock() + return nil +} + +// HostnamesForSessionIPs returns raw-IP keyed cache entries and admits stale IPs for lookup. +func (r *Resolver) HostnamesForSessionIPs(ctx context.Context, userID string, ips []string) map[string]CacheEntry { + if r == nil || r.cache == nil || r.settingsOn == nil { + return map[string]CacheEntry{} + } + enabled, err := r.settingsOn(ctx, userID) + if err != nil || !enabled { + r.logError("hostname settings gate", err) + return map[string]CacheEntry{} + } + canonical, rawByCanonical := canonicalIPs(ips) + if len(canonical) == 0 { + return map[string]CacheEntry{} + } + cached, err := r.cache.GetMany(ctx, canonical) + if err != nil { + r.logError("read hostname cache", err) + return map[string]CacheEntry{} + } + result := make(map[string]CacheEntry, len(ips)) + for canonicalIP, rawIPs := range rawByCanonical { + entry, exists := cached[canonicalIP] + for _, raw := range rawIPs { + result[raw] = entry + } + if !exists || entry.ResolvedAt == nil || time.Since(*entry.ResolvedAt) >= recentResolveWindow { + r.enqueue(canonicalIP, entry, userID, rawIPs) + } + } + return result +} + +// TriggerUserSessionsResolved coalesces a background lookup pass after settings are persisted. +func (r *Resolver) TriggerUserSessionsResolved(userID string) { + if r == nil || r.sessionIPs == nil { + return + } + r.mu.Lock() + if !r.started || r.ctx == nil { + r.mu.Unlock() + return + } + pending := r.pendingUsers[userID] + pending.generation++ + r.pendingUsers[userID] = pending + triggerWake := r.triggerWake + r.mu.Unlock() + select { + case triggerWake <- struct{}{}: + default: + } +} + +// HandleUserSettingsUpdated starts a session pass after hostname resolution is enabled. +func (r *Resolver) HandleUserSettingsUpdated(_ context.Context, event *bus.Event) error { + if event == nil { + return nil + } + data, ok := event.Data.(map[string]any) + if !ok { + return nil + } + enabled, _ := data["resolve_session_hostnames"].(bool) + userID, _ := data[userIDKey].(string) + if enabled && userID != "" { + r.TriggerUserSessionsResolved(userID) + } + return nil +} + +// triggerWorker consumes coalesced user-session resolution requests. +func (r *Resolver) triggerWorker() { + defer r.wg.Done() + for { + userID, generation := r.nextPendingUser() + if userID != "" { + ips, err := r.sessionIPs(r.ctx, userID) + if err != nil { + r.logError("list session IPs", err) + } else { + r.HostnamesForSessionIPs(r.ctx, userID, ips) + } + r.finishPendingUser(userID, generation) + continue + } + select { + case <-r.ctx.Done(): + return + case <-r.triggerWake: + } + } +} + +// nextPendingUser claims one queued user-session request. +func (r *Resolver) nextPendingUser() (string, uint64) { + r.mu.Lock() + defer r.mu.Unlock() + for userID, pending := range r.pendingUsers { + if pending.running { + continue + } + pending.running = true + r.pendingUsers[userID] = pending + return userID, pending.generation + } + return "", 0 +} + +// finishPendingUser removes a completed request unless newer work arrived. +func (r *Resolver) finishPendingUser(userID string, generation uint64) { + r.mu.Lock() + defer r.mu.Unlock() + pending, exists := r.pendingUsers[userID] + if !exists { + return + } + if pending.generation != generation { + pending.running = false + r.pendingUsers[userID] = pending + return + } + delete(r.pendingUsers, userID) +} + +// enqueue coalesces an IP lookup and wakes an available worker. +func (r *Resolver) enqueue(ip string, observed CacheEntry, userID string, rawIPs []string) { + r.mu.Lock() + job := r.inFlight[ip] + queued := false + if job == nil { + job = r.pending[ip] + if job == nil { + job = &pendingJob{ip: ip, observed: observed} + r.pending[ip] = job + queued = true + } + } + job.generation++ + for _, rawIP := range rawIPs { + exists := false + for _, existing := range job.interests { + if existing.userID == userID && existing.rawIP == rawIP { + exists = true + break + } + } + if !exists { + job.interests = append(job.interests, interest{userID: userID, rawIP: rawIP}) + } + } + wake := r.wake + r.mu.Unlock() + if !queued { + return + } + select { + case wake <- struct{}{}: + default: + } +} + +// worker resolves queued IPs until cancellation. +func (r *Resolver) worker() { + defer r.wg.Done() + for { + job := r.nextPending() + if job == nil { + select { + case <-r.ctx.Done(): + return + case <-r.wake: + } + continue + } + r.resolve(job) + } +} + +// nextPending claims one queued IP lookup. +func (r *Resolver) nextPending() *pendingJob { + r.mu.Lock() + defer r.mu.Unlock() + for ip, job := range r.pending { + delete(r.pending, ip) + r.inFlight[ip] = job + return job + } + return nil +} + +// finish removes a completed lookup and returns its interested sessions. +func (r *Resolver) finish(job *pendingJob) []interest { + r.mu.Lock() + defer r.mu.Unlock() + if r.inFlight[job.ip] != job { + return nil + } + delete(r.inFlight, job.ip) + return append([]interest(nil), job.interests...) +} + +// finishIfInterestsUnchanged removes a lookup only when no newer interest arrived. +func (r *Resolver) finishIfInterestsUnchanged(job *pendingJob, generation uint64) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.inFlight[job.ip] != job { + return true + } + if job.generation != generation { + return false + } + delete(r.inFlight, job.ip) + return true +} + +// snapshotInterests copies the current interested sessions for a lookup. +func (r *Resolver) snapshotInterests(job *pendingJob) ([]interest, uint64) { + r.mu.Lock() + defer r.mu.Unlock() + if r.inFlight[job.ip] != job { + return nil, 0 + } + return append([]interest(nil), job.interests...), job.generation +} + +// resolve loads or performs one reverse-DNS lookup and publishes changes. +func (r *Resolver) resolve(job *pendingJob) { + current, err := r.cache.Get(r.ctx, job.ip) + if err != nil && !errors.Is(err, ErrNotFound) { + r.logError("read hostname cache", err) + r.finish(job) + return + } + gate := interestGate{} + for { + interests, generation := r.snapshotInterests(job) + if len(r.enabledInterests(interests, gate.forGeneration(generation))) != 0 { + break + } + if r.finishIfInterestsUnchanged(job, generation) { + return + } + } + missing := errors.Is(err, ErrNotFound) + if current.ResolvedAt != nil && time.Since(*current.ResolvedAt) < recentResolveWindow { + interests := r.finish(job) + if current.Hostname != job.observed.Hostname { + r.publish(r.enabledInterests(interests, make(map[string]bool)), current.Hostname, current.ResolvedAt) + } + return + } + lookupCtx, cancel := context.WithTimeout(r.ctx, lookupTimeout) + names, lookupErr := r.lookupAddr(lookupCtx, job.ip) + cancel() + if lookupErr != nil && !isNotFound(lookupErr) { + r.logError("reverse DNS lookup", lookupErr) + interests := r.finish(job) + if missing { + r.publish(r.enabledInterests(interests, make(map[string]bool)), "", nil) + } + return + } + hostname := NormalizeHostname(names) + resolvedAt := time.Now().UTC() + if err := r.cache.Set(r.ctx, job.ip, hostname, resolvedAt); err != nil { + r.logError("save hostname cache", err) + r.finish(job) + return + } + interests := r.finish(job) + if current.Hostname != hostname || missing { + r.publish(r.enabledInterests(interests, make(map[string]bool)), hostname, &resolvedAt) + } +} + +// enabledInterests retains only users still opted into hostname resolution. +func (r *Resolver) enabledInterests(interests []interest, gate map[string]bool) []interest { + out := make([]interest, 0, len(interests)) + for _, item := range interests { + enabled, cached := gate[item.userID] + if !cached { + resolved, err := r.settingsOn(r.ctx, item.userID) + if err != nil { + r.logError("hostname settings gate", err, zap.String(userIDKey, item.userID)) + } + enabled = err == nil && resolved + gate[item.userID] = enabled + } + if enabled { + out = append(out, item) + } + } + return out +} + +// publish emits hostname updates only to interested users. +func (r *Resolver) publish(interests []interest, hostname string, resolvedAt *time.Time) { + if r.eventBus == nil { + return + } + var timestamp any + if resolvedAt != nil { + timestamp = FormatTimestamp(*resolvedAt) + } + for _, item := range interests { + _ = r.eventBus.Publish(r.ctx, events.AuthSessionHostnameResolved, bus.NewEvent(events.AuthSessionHostnameResolved, "hostname-resolver", map[string]any{ + userIDKey: item.userID, "ip": item.rawIP, "hostname": hostname, "resolved_at": timestamp, + })) + } +} + +// canonicalIPs normalizes valid IP addresses while preserving their raw forms. +func canonicalIPs(ips []string) ([]string, map[string][]string) { + byCanonical := make(map[string][]string) + for _, raw := range ips { + parsed := net.ParseIP(raw) + if parsed == nil { + continue + } + canonical := parsed.String() + byCanonical[canonical] = append(byCanonical[canonical], raw) + } + out := make([]string, 0, len(byCanonical)) + for ip := range byCanonical { + out = append(out, ip) + } + return out, byCanonical +} + +// isNotFound reports whether a DNS error represents no PTR record. +func isNotFound(err error) bool { + var dnsErr *net.DNSError + return errors.As(err, &dnsErr) && dnsErr.IsNotFound +} + +// logError records resolver failures when a logger is configured. +func (r *Resolver) logError(message string, err error, fields ...zap.Field) { + if err != nil && r.log != nil { + r.log.Warn(message, append(fields, zap.Error(err))...) + } +} diff --git a/apps/backend/internal/auth/hostnames/resolver_test.go b/apps/backend/internal/auth/hostnames/resolver_test.go new file mode 100644 index 0000000000..5e80fe1c4d --- /dev/null +++ b/apps/backend/internal/auth/hostnames/resolver_test.go @@ -0,0 +1,651 @@ +package hostnames + +import ( + "context" + "errors" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +type memoryCache struct { + mu sync.Mutex + entries map[string]CacheEntry + set chan struct{} +} + +func (c *memoryCache) GetMany(_ context.Context, ips []string) (map[string]CacheEntry, error) { + c.mu.Lock() + defer c.mu.Unlock() + out := make(map[string]CacheEntry) + for _, ip := range ips { + if entry, ok := c.entries[ip]; ok { + out[ip] = entry + } + } + return out, nil +} + +func (c *memoryCache) Get(_ context.Context, ip string) (CacheEntry, error) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[ip] + if !ok { + return CacheEntry{}, ErrNotFound + } + return entry, nil +} + +func (c *memoryCache) Set(_ context.Context, ip, hostname string, resolvedAt time.Time) error { + c.mu.Lock() + c.entries[ip] = CacheEntry{Hostname: hostname, ResolvedAt: &resolvedAt} + c.mu.Unlock() + select { + case c.set <- struct{}{}: + default: + } + return nil +} + +type trackingCache struct { + *memoryCache + gets chan struct{} +} + +func (c *trackingCache) Get(ctx context.Context, ip string) (CacheEntry, error) { + select { + case c.gets <- struct{}{}: + default: + } + return c.memoryCache.Get(ctx, ip) +} + +func TestResolverDeduplicatesInterestsForTheSameRawIP(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, nil, nil, nil) + + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10", "192.0.2.10"}) + + resolver.mu.Lock() + defer resolver.mu.Unlock() + job := resolver.pending["192.0.2.10"] + if job == nil { + t.Fatal("missing queued job") + return + } + if len(job.interests) != 1 { + t.Fatalf("interests = %#v, want one user/IP pair", job.interests) + } +} + +func TestResolverDeduplicatesLookupsAlreadyInFlight(t *testing.T) { + cache := &trackingCache{ + memoryCache: &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)}, + gets: make(chan struct{}, 2), + } + lookupStarted := make(chan struct{}, 1) + releaseLookup := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseLookup) }) } + defer release() + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + lookupStarted <- struct{}{} + <-releaseLookup + return []string{"mail.example.test."}, nil + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("first lookup did not start") + } + select { + case <-cache.gets: + case <-time.After(time.Second): + t.Fatal("first job did not read the cache") + } + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + + select { + case <-cache.gets: + t.Fatal("second job read the cache while the first lookup was still in flight") + default: + } + release() + select { + case <-cache.set: + case <-time.After(time.Second): + t.Fatal("lookup did not persist") + } +} + +func TestResolverDoesNotPublishAfterOptOutDuringLookup(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("new logger: %v", err) + } + eventBus := bus.NewMemoryEventBus(log) + t.Cleanup(eventBus.Close) + received := make(chan *bus.Event, 1) + if _, err := eventBus.Subscribe(events.AuthSessionHostnameResolved, func(_ context.Context, event *bus.Event) error { + received <- event + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + var enabled atomic.Bool + enabled.Store(true) + resolver := NewResolver( + cache, + func(context.Context, string) (bool, error) { return enabled.Load(), nil }, + nil, + eventBus, + log, + ) + resolver.ctx = context.Background() + lookupStarted := make(chan struct{}) + releaseLookup := make(chan struct{}) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + close(lookupStarted) + <-releaseLookup + return []string{"mail.example.test."}, nil + } + job := &pendingJob{ + ip: "192.0.2.10", + interests: []interest{{userID: "user-1", rawIP: "192.0.2.10"}}, + } + resolver.inFlight[job.ip] = job + + done := make(chan struct{}) + go func() { + resolver.resolve(job) + close(done) + }() + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("lookup did not start") + } + enabled.Store(false) + close(releaseLookup) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("resolver did not finish") + } + + select { + case event := <-received: + t.Fatalf("received hostname event after opt-out: %#v", event.Data) + default: + } +} + +func TestResolverStartWaitsForCloseBeforeRestartingWorkers(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + firstLookupStarted := make(chan struct{}) + releaseFirstLookup := make(chan struct{}) + secondLookupStarted := make(chan struct{}, 1) + var calls int + var callsMu sync.Mutex + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + callsMu.Lock() + calls++ + call := calls + callsMu.Unlock() + if call == 1 { + close(firstLookupStarted) + <-releaseFirstLookup + return nil, context.Canceled + } + secondLookupStarted <- struct{}{} + return nil, nil + } + resolver.Start(context.Background()) + t.Cleanup(func() { + select { + case <-releaseFirstLookup: + default: + close(releaseFirstLookup) + } + _ = resolver.Close() + }) + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + select { + case <-firstLookupStarted: + case <-time.After(time.Second): + t.Fatal("first lookup did not start") + } + + closed := make(chan struct{}) + go func() { + _ = resolver.Close() + close(closed) + }() + for deadline := time.After(time.Second); ; { + resolver.mu.Lock() + stopping := resolver.stopping != nil + resolver.mu.Unlock() + if stopping { + break + } + select { + case <-deadline: + t.Fatal("Close did not enter its stopping state") + case <-time.After(time.Millisecond): + } + } + started := make(chan struct{}) + go func() { + resolver.Start(context.Background()) + close(started) + }() + select { + case <-started: + t.Fatal("Start returned before Close drained the old workers") + case <-time.After(100 * time.Millisecond): + } + close(releaseFirstLookup) + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("Close did not drain the first worker") + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("Start did not resume after Close") + } + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + select { + case <-secondLookupStarted: + case <-time.After(time.Second): + t.Fatal("replacement lookup did not start") + } +} + +func TestResolverCoalescesRepeatedSessionTriggers(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + sessionPasses := make(chan struct{}, 2) + releasePass := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releasePass) }) } + defer release() + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, func(context.Context, string) ([]string, error) { + sessionPasses <- struct{}{} + <-releasePass + return nil, nil + }, nil, nil) + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + resolver.TriggerUserSessionsResolved("user-1") + select { + case <-sessionPasses: + case <-time.After(time.Second): + t.Fatal("triggered session pass did not start") + } + resolver.TriggerUserSessionsResolved("user-1") + release() + select { + case <-sessionPasses: + case <-time.After(time.Second): + t.Fatal("repeated trigger did not start a follow-up session pass") + } +} + +func TestResolverSkipsQueuedLookupAfterOptOut(t *testing.T) { + cache := &trackingCache{ + memoryCache: &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)}, + gets: make(chan struct{}, 1), + } + enabled := true + lookupStarted := make(chan struct{}, 1) + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return enabled, nil }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + lookupStarted <- struct{}{} + return nil, nil + } + + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + enabled = false + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + select { + case <-cache.gets: + case <-time.After(time.Second): + t.Fatal("queued job did not re-read the cache") + } + select { + case <-lookupStarted: + t.Fatal("disabled user DNS lookup started") + case <-time.After(100 * time.Millisecond): + } +} + +func TestResolverRetainsLateEnabledInterestAfterDisabledGate(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + initialGate := make(chan struct{}) + releaseInitialGate := make(chan struct{}) + lookupStarted := make(chan struct{}, 1) + disabledChecks := 0 + var initialGateOnce sync.Once + resolver := NewResolver(cache, func(_ context.Context, userID string) (bool, error) { + if userID != "user-disabled" { + return userID == "user-enabled", nil + } + disabledChecks++ + if disabledChecks == 1 { + return true, nil + } + initialGateOnce.Do(func() { + close(initialGate) + <-releaseInitialGate + }) + return false, nil + }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + lookupStarted <- struct{}{} + return nil, nil + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + resolver.HostnamesForSessionIPs(context.Background(), "user-disabled", []string{"192.0.2.10"}) + select { + case <-initialGate: + case <-time.After(time.Second): + t.Fatal("initial disabled interest did not reach the settings gate") + } + resolver.HostnamesForSessionIPs(context.Background(), "user-enabled", []string{"192.0.2.10"}) + close(releaseInitialGate) + + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("late enabled interest did not trigger a lookup") + } +} + +func TestResolverRetainsRepeatedInterestAfterSettingsReenabled(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + settingsGate := make(chan struct{}) + releaseSettingsGate := make(chan struct{}) + lookupStarted := make(chan struct{}, 1) + var checks atomic.Int32 + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { + if checks.Add(1) == 2 { + close(settingsGate) + <-releaseSettingsGate + return false, nil + } + return true, nil + }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + lookupStarted <- struct{}{} + return nil, nil + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + select { + case <-settingsGate: + case <-time.After(time.Second): + t.Fatal("worker did not reach the disabled settings gate") + } + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + close(releaseSettingsGate) + + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("re-enabled repeated interest did not trigger a lookup") + } +} + +func TestResolverWakeQueueMatchesLookupConcurrency(t *testing.T) { + resolver := NewResolver(nil, nil, nil, nil, nil) + + if got := cap(resolver.wake); got != lookupWorkers { + t.Fatalf("initial wake queue capacity = %d, want %d workers", got, lookupWorkers) + } + resolver.Start(context.Background()) + if err := resolver.Close(); err != nil { + t.Fatalf("close resolver: %v", err) + } + if got := cap(resolver.wake); got != lookupWorkers { + t.Fatalf("restart wake queue capacity = %d, want %d workers", got, lookupWorkers) + } +} + +func TestResolverChecksEachUserOncePerResolve(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + var settingsReads atomic.Int32 + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { + settingsReads.Add(1) + return true, nil + }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + return []string{"mail.example.test."}, nil + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + resolver.HostnamesForSessionIPs( + context.Background(), + "user-1", + []string{"192.0.2.10", "::ffff:192.0.2.10"}, + ) + select { + case <-cache.set: + case <-time.After(time.Second): + t.Fatal("hostname lookup did not finish") + } + if got := settingsReads.Load(); got != 3 { + t.Fatalf("settings reads = %d, want admission, lookup, and publication reads", got) + } +} + +func TestResolverLogsSettingsGateErrorsWithUserID(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("new logger: %v", err) + } + resolver := NewResolver(nil, func(context.Context, string) (bool, error) { + return false, errors.New("settings unavailable") + }, nil, nil, log) + + if got := resolver.enabledInterests([]interest{{userID: "user-1"}}, make(map[string]bool)); len(got) != 0 { + t.Fatalf("enabled interests = %#v, want none after settings error", got) + } + entries := logs.FilterMessage("hostname settings gate").All() + if len(entries) != 1 { + t.Fatalf("settings gate log count = %d, want 1", len(entries)) + } + if got := entries[0].ContextMap()["user_id"]; got != "user-1" { + t.Fatalf("settings gate user_id = %#v, want user-1", got) + } +} + +func TestResolverHandlesEnabledUserSettingsEvent(t *testing.T) { + type settingsUpdateHandler interface { + HandleUserSettingsUpdated(context.Context, *bus.Event) error + } + requested := make(chan string, 1) + resolver := NewResolver( + &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)}, + func(context.Context, string) (bool, error) { return true, nil }, + func(_ context.Context, userID string) ([]string, error) { + requested <- userID + return nil, nil + }, + nil, + nil, + ) + handler, ok := any(resolver).(settingsUpdateHandler) + if !ok { + t.Fatal("resolver does not handle persisted user settings events") + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + if err := handler.HandleUserSettingsUpdated(context.Background(), bus.NewEvent( + events.UserSettingsUpdated, + "test", + map[string]any{"user_id": "user-1", "resolve_session_hostnames": false}, + )); err != nil { + t.Fatalf("handle disabled event: %v", err) + } + select { + case userID := <-requested: + t.Fatalf("disabled event requested sessions for %s", userID) + default: + } + + if err := handler.HandleUserSettingsUpdated(context.Background(), bus.NewEvent( + events.UserSettingsUpdated, + "test", + map[string]any{"user_id": "user-1", "resolve_session_hostnames": true}, + )); err != nil { + t.Fatalf("handle enabled event: %v", err) + } + select { + case userID := <-requested: + if userID != "user-1" { + t.Fatalf("requested user = %q, want user-1", userID) + } + case <-time.After(time.Second): + t.Fatal("enabled event did not request sessions") + } +} + +func TestResolverStreamsUncachedTransientLookupFailure(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + eventBus := bus.NewMemoryEventBus(log) + t.Cleanup(eventBus.Close) + received := make(chan *bus.Event, 1) + if _, err := eventBus.Subscribe(events.AuthSessionHostnameResolved, func(_ context.Context, event *bus.Event) error { + received <- event + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, nil, eventBus, log) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { + return nil, &net.DNSError{IsTemporary: true} + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + select { + case event := <-received: + data := event.Data.(map[string]any) + if data["hostname"] != "" || data["resolved_at"] != nil { + t.Fatalf("transient failure event = %#v, want empty unresolved hostname", data) + } + case <-time.After(time.Second): + t.Fatal("uncached transient lookup failure was not streamed") + } +} + +func TestResolverCloseWaitsForTriggeredSessionPass(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + triggerStarted := make(chan struct{}) + releaseTrigger := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseTrigger) }) } + defer release() + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, func(context.Context, string) ([]string, error) { + close(triggerStarted) + <-releaseTrigger + return nil, nil + }, nil, nil) + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + resolver.TriggerUserSessionsResolved("user-1") + select { + case <-triggerStarted: + case <-time.After(time.Second): + t.Fatal("triggered session pass did not start") + } + + closed := make(chan struct{}) + go func() { + _ = resolver.Close() + close(closed) + }() + select { + case <-closed: + t.Fatal("Close returned before the triggered session pass ended") + case <-time.After(100 * time.Millisecond): + } + release() + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("Close did not wait for the triggered session pass") + } +} + +func TestHostnamesForSessionIPsGatesAndResolves(t *testing.T) { + cache := &memoryCache{entries: map[string]CacheEntry{}, set: make(chan struct{}, 1)} + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return true, nil }, nil, nil, nil) + resolver.lookupAddr = func(_ context.Context, ip string) ([]string, error) { + if ip != "192.0.2.10" { + t.Fatalf("lookup IP = %q", ip) + } + return []string{"mail.example.test."}, nil + } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + initial := resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + if got := initial["192.0.2.10"]; got.Hostname != "" || got.ResolvedAt != nil { + t.Fatalf("initial cache entry = %#v, want unknown", got) + } + select { + case <-cache.set: + case <-time.After(time.Second): + t.Fatal("lookup did not persist") + } + resolved := resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}) + if got := resolved["192.0.2.10"].Hostname; got != "mail.example.test" { + t.Fatalf("hostname = %q, want mail.example.test", got) + } +} + +func TestHostnamesForSessionIPsDoesNotLeakOrLookupWhenDisabled(t *testing.T) { + now := time.Now().UTC() + cache := &memoryCache{entries: map[string]CacheEntry{"192.0.2.10": {Hostname: "cached.example.test", ResolvedAt: &now}}, set: make(chan struct{}, 1)} + resolver := NewResolver(cache, func(context.Context, string) (bool, error) { return false, nil }, nil, nil, nil) + resolver.lookupAddr = func(context.Context, string) ([]string, error) { t.Fatal("unexpected lookup"); return nil, nil } + resolver.Start(context.Background()) + t.Cleanup(func() { _ = resolver.Close() }) + + if got := resolver.HostnamesForSessionIPs(context.Background(), "user-1", []string{"192.0.2.10"}); len(got) != 0 { + t.Fatalf("disabled map = %#v, want empty", got) + } +} diff --git a/apps/backend/internal/auth/hostnames/store.go b/apps/backend/internal/auth/hostnames/store.go new file mode 100644 index 0000000000..3157520d0b --- /dev/null +++ b/apps/backend/internal/auth/hostnames/store.go @@ -0,0 +1,124 @@ +package hostnames + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +const timestampLayout = "2006-01-02T15:04:05.000000000Z07:00" + +const maxGetManyBatch = 900 + +// ErrNotFound reports that no cached hostname exists for an IP address. +var ErrNotFound = errors.New("hostname cache: not found") + +// CacheEntry stores a cached hostname and the time it was resolved. +type CacheEntry struct { + Hostname string + ResolvedAt *time.Time +} + +// Cache reads and writes persisted hostname-resolution entries. +type Cache interface { + GetMany(context.Context, []string) (map[string]CacheEntry, error) + Get(context.Context, string) (CacheEntry, error) + Set(context.Context, string, string, time.Time) error +} + +// Store persists hostname-resolution entries in SQLite. +type Store struct { + writer *sqlx.DB + reader *sqlx.DB +} + +// NewStore initializes hostname-cache storage over the supplied database handles. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + store := &Store{writer: writer, reader: reader} + if err := store.initSchema(); err != nil { + return nil, err + } + return store, nil +} + +// initSchema creates the hostname cache table when it is absent. +func (s *Store) initSchema() error { + _, err := s.writer.Exec(`CREATE TABLE IF NOT EXISTS auth_hostname_cache ( + ip TEXT PRIMARY KEY, + hostname TEXT NOT NULL DEFAULT '', + resolved_at TEXT NOT NULL + )`) + if err != nil { + return fmt.Errorf("create hostname cache: %w", err) + } + return nil +} + +// FormatTimestamp serializes a resolution time for SQLite storage. +func FormatTimestamp(value time.Time) string { return value.UTC().Format(timestampLayout) } + +// ParseTimestamp decodes a timestamp stored by FormatTimestamp. +func ParseTimestamp(value string) (time.Time, error) { return time.Parse(timestampLayout, value) } + +// Get returns the cached hostname entry for one IP. +func (s *Store) Get(ctx context.Context, ip string) (CacheEntry, error) { + entries, err := s.GetMany(ctx, []string{ip}) + if err != nil { + return CacheEntry{}, err + } + entry, ok := entries[ip] + if !ok { + return CacheEntry{}, ErrNotFound + } + return entry, nil +} + +// GetMany returns cached hostname entries for the requested IPs. +func (s *Store) GetMany(ctx context.Context, ips []string) (map[string]CacheEntry, error) { + entries := make(map[string]CacheEntry) + if len(ips) == 0 { + return entries, nil + } + for start := 0; start < len(ips); start += maxGetManyBatch { + end := min(start+maxGetManyBatch, len(ips)) + query, args, err := sqlx.In(`SELECT ip, hostname, resolved_at FROM auth_hostname_cache WHERE ip IN (?)`, ips[start:end]) + if err != nil { + return nil, err + } + rows, err := s.reader.QueryxContext(ctx, s.reader.Rebind(query), args...) + if err != nil { + return nil, err + } + for rows.Next() { + var ip, hostname, resolvedAt string + if err := rows.Scan(&ip, &hostname, &resolvedAt); err != nil { + _ = rows.Close() + return nil, err + } + parsed, err := ParseTimestamp(resolvedAt) + if err != nil { + _ = rows.Close() + return nil, err + } + entries[ip] = CacheEntry{Hostname: hostname, ResolvedAt: &parsed} + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + } + return entries, nil +} + +// Set inserts or replaces a cached hostname entry. +func (s *Store) Set(ctx context.Context, ip, hostname string, resolvedAt time.Time) error { + _, err := s.writer.ExecContext(ctx, s.writer.Rebind(`INSERT INTO auth_hostname_cache (ip, hostname, resolved_at) + VALUES (?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET hostname = excluded.hostname, resolved_at = excluded.resolved_at`), ip, hostname, FormatTimestamp(resolvedAt)) + return err +} diff --git a/apps/backend/internal/auth/hostnames/store_test.go b/apps/backend/internal/auth/hostnames/store_test.go new file mode 100644 index 0000000000..3127b3c8b0 --- /dev/null +++ b/apps/backend/internal/auth/hostnames/store_test.go @@ -0,0 +1,82 @@ +package hostnames + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + database := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + store, err := NewStore(database, database) + if err != nil { + t.Fatalf("new hostname store: %v", err) + } + return store +} + +// Reviewer-requested regression coverage for SQLite timestamp round trips. +func TestStoreSetAndGetRoundTrip(t *testing.T) { + store := newTestStore(t) + resolvedAt := time.Date(2026, 8, 16, 12, 0, 0, 123456789, time.UTC) + + if err := store.Set(context.Background(), "192.0.2.1", "mail.example.test", resolvedAt); err != nil { + t.Fatalf("set hostname: %v", err) + } + entry, err := store.Get(context.Background(), "192.0.2.1") + if err != nil { + t.Fatalf("get hostname: %v", err) + } + if entry.Hostname != "mail.example.test" { + t.Errorf("hostname = %q, want %q", entry.Hostname, "mail.example.test") + } + if entry.ResolvedAt == nil || !entry.ResolvedAt.Equal(resolvedAt) { + t.Errorf("resolved at = %v, want %v", entry.ResolvedAt, resolvedAt) + } +} + +func TestStoreGetManyChunksLargeCacheLookup(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + ips := make([]string, 0, 33_000) + for i := range cap(ips) { + ips = append(ips, fmt.Sprintf("192.0.2.%d", i)) + } + seeded := map[string]string{ + ips[0]: "first.example.test", + ips[maxGetManyBatch]: "second-batch.example.test", + ips[len(ips)-1]: "last.example.test", + } + resolvedAt := time.Date(2026, 8, 18, 12, 0, 0, 123456789, time.UTC) + for ip, hostname := range seeded { + if err := store.Set(ctx, ip, hostname, resolvedAt); err != nil { + t.Fatalf("seed %s: %v", ip, err) + } + } + + entries, err := store.GetMany(ctx, ips) + if err != nil { + t.Fatalf("get many: %v", err) + } + if len(entries) != len(seeded) { + t.Fatalf("entry count = %d, want %d", len(entries), len(seeded)) + } + for ip, hostname := range seeded { + entry, ok := entries[ip] + if !ok || entry.Hostname != hostname { + t.Errorf("entry %s = %#v, want hostname %q", ip, entry, hostname) + } + } +} diff --git a/apps/backend/internal/auth/httpapi/handlers.go b/apps/backend/internal/auth/httpapi/handlers.go index 5d4eaaa89d..e7b71291e9 100644 --- a/apps/backend/internal/auth/httpapi/handlers.go +++ b/apps/backend/internal/auth/httpapi/handlers.go @@ -31,18 +31,20 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/auth/hostnames" "github.com/kandev/kandev/internal/common/logger" ) // Handlers carries the auth HTTP handler dependencies. type Handlers struct { - svc *auth.Service - log *logger.Logger + svc *auth.Service + resolver *hostnames.Resolver + log *logger.Logger } // RegisterRoutes mounts the auth API on the main router. -func RegisterRoutes(router *gin.Engine, svc *auth.Service, log *logger.Logger) { - h := &Handlers{svc: svc, log: log} +func RegisterRoutes(router *gin.Engine, svc *auth.Service, resolver *hostnames.Resolver, log *logger.Logger) { + h := &Handlers{svc: svc, resolver: resolver, log: log} group := router.Group("/api/v1/auth") // Public bootstrap + credential-issuing endpoints (allowlisted in httpmw). group.POST("/setup", h.setup) @@ -175,14 +177,29 @@ func (h *Handlers) listSessions(c *gin.Context) { return } items := make([]gin.H, 0, len(sessions)) + ips := make([]string, 0, len(sessions)) for _, session := range sessions { + ips = append(ips, session.IP) + } + resolved := map[string]hostnames.CacheEntry{} + if h.resolver != nil { + resolved = h.resolver.HostnamesForSessionIPs(c.Request.Context(), identity.UserID, ips) + } + for _, session := range sessions { + entry := resolved[session.IP] + var resolvedAt any + if entry.ResolvedAt != nil { + resolvedAt = hostnames.FormatTimestamp(*entry.ResolvedAt) + } items = append(items, gin.H{ - "id": session.ID, - "created_at": session.CreatedAt, - "last_seen_at": session.LastSeenAt, - "user_agent": session.UserAgent, - "ip": session.IP, - "current": session.ID == identity.SessionID, + "id": session.ID, + "created_at": session.CreatedAt, + "last_seen_at": session.LastSeenAt, + "user_agent": session.UserAgent, + "ip": session.IP, + "hostname": entry.Hostname, + "hostname_resolved_at": resolvedAt, + "current": session.ID == identity.SessionID, }) } c.JSON(http.StatusOK, gin.H{"sessions": items}) diff --git a/apps/backend/internal/auth/httpapi/handlers_test.go b/apps/backend/internal/auth/httpapi/handlers_test.go index 44627db087..6b68850da0 100644 --- a/apps/backend/internal/auth/httpapi/handlers_test.go +++ b/apps/backend/internal/auth/httpapi/handlers_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" @@ -15,6 +16,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/auth" + "github.com/kandev/kandev/internal/auth/hostnames" authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" authstore "github.com/kandev/kandev/internal/auth/store" "github.com/kandev/kandev/internal/common/config" @@ -88,7 +90,33 @@ func newAPIFixtureWithCfg(t *testing.T, cfg *config.Config, trustedProxies ...st t.Fatalf("set trusted proxies: %v", err) } router.Use(authhttpmw.Middleware(svc)) - RegisterRoutes(router, svc, nil) + RegisterRoutes(router, svc, nil, nil) + return router, svc +} + +// newAPIFixtureWithResolver is newAPIFixture with an explicit hostname +// resolver wired into the registered routes. +func newAPIFixtureWithResolver( + t *testing.T, + authEnabled bool, + resolver *hostnames.Resolver, + trustedProxies ...string, +) (*gin.Engine, *auth.Service) { + t.Helper() + cfg := &config.Config{} + cfg.Features.Auth = authEnabled + cfg.Auth.SessionTTLHours = 720 + gin.SetMode(gin.TestMode) + svc := newAuthService(t, cfg) + router := gin.New() + if len(trustedProxies) == 0 { + trustedProxies = nil + } + if err := router.SetTrustedProxies(trustedProxies); err != nil { + t.Fatalf("set trusted proxies: %v", err) + } + router.Use(authhttpmw.Middleware(svc)) + RegisterRoutes(router, svc, resolver, nil) return router, svc } @@ -115,10 +143,32 @@ func newCORSGatedAPIFixture(t *testing.T, cfg *config.Config, trustedProxies ... c.Next() }) router.Use(authhttpmw.Middleware(svc)) - RegisterRoutes(router, svc, nil) + RegisterRoutes(router, svc, nil, nil) return router, svc } +type staticHostnameCache map[string]hostnames.CacheEntry + +func (c staticHostnameCache) GetMany(_ context.Context, ips []string) (map[string]hostnames.CacheEntry, error) { + entries := make(map[string]hostnames.CacheEntry) + for _, ip := range ips { + if entry, ok := c[ip]; ok { + entries[ip] = entry + } + } + return entries, nil +} + +func (c staticHostnameCache) Get(_ context.Context, ip string) (hostnames.CacheEntry, error) { + entry, ok := c[ip] + if !ok { + return hostnames.CacheEntry{}, hostnames.ErrNotFound + } + return entry, nil +} + +func (staticHostnameCache) Set(context.Context, string, string, time.Time) error { return nil } + type apiClient struct { t *testing.T router *gin.Engine @@ -320,6 +370,64 @@ func TestLoginSessionIPNoForwardedHeaderUsesPeer(t *testing.T) { } } +// Reviewer-requested contract coverage for cached and unresolved session hostnames. +func TestListSessionsIncludesHostnameFields(t *testing.T) { + resolvedAt := time.Date(2026, 8, 18, 12, 0, 0, 123456789, time.UTC) + resolver := hostnames.NewResolver( + staticHostnameCache{ + "192.0.2.1": {Hostname: "setup.example.test", ResolvedAt: &resolvedAt}, + }, + func(context.Context, string) (bool, error) { return true, nil }, + nil, + nil, + nil, + ) + router, _ := newAPIFixtureWithResolver(t, true, resolver) + client := &apiClient{t: t, router: router} + if rec := client.do(http.MethodPost, "/api/v1/auth/setup", map[string]any{ + "email": "admin@x.dev", "password": "adminpass123", "display_name": "Admin", + }); rec.Code != http.StatusOK { + t.Fatalf("setup: %d body=%s", rec.Code, rec.Body.String()) + } + if rec := client.do(http.MethodPost, "/api/v1/auth/login", map[string]any{ + "email": "admin@x.dev", "password": "adminpass123", + }, func(req *http.Request) { req.RemoteAddr = "198.51.100.7:1234" }); rec.Code != http.StatusOK { + t.Fatalf("login: %d body=%s", rec.Code, rec.Body.String()) + } + rec := client.do(http.MethodGet, "/api/v1/auth/sessions", nil) + if rec.Code != http.StatusOK { + t.Fatalf("sessions: %d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Sessions []struct { + IP string `json:"ip"` + Hostname string `json:"hostname"` + ResolvedAt *string `json:"hostname_resolved_at"` + } `json:"sessions"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode sessions: %v", err) + } + byIP := make(map[string]struct { + Hostname string + ResolvedAt *string + }, len(response.Sessions)) + for _, session := range response.Sessions { + byIP[session.IP] = struct { + Hostname string + ResolvedAt *string + }{session.Hostname, session.ResolvedAt} + } + resolved := byIP["192.0.2.1"] + if resolved.Hostname != "setup.example.test" || resolved.ResolvedAt == nil || *resolved.ResolvedAt != hostnames.FormatTimestamp(resolvedAt) { + t.Fatalf("resolved session = %#v, want cached hostname and timestamp", resolved) + } + unresolved := byIP["198.51.100.7"] + if unresolved.Hostname != "" || unresolved.ResolvedAt != nil { + t.Fatalf("unresolved session = %#v, want empty hostname and null timestamp", unresolved) + } +} + func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() out := map[string]any{} diff --git a/apps/backend/internal/auth/httpmw/middleware_test.go b/apps/backend/internal/auth/httpmw/middleware_test.go index db9fc2809f..2260f7dd9c 100644 --- a/apps/backend/internal/auth/httpmw/middleware_test.go +++ b/apps/backend/internal/auth/httpmw/middleware_test.go @@ -866,7 +866,7 @@ func TestSessionIPRefreshedThroughMiddleware(t *testing.T) { if err != nil { t.Fatal(err) } - authhttpapi.RegisterRoutes(router, svc, log) + authhttpapi.RegisterRoutes(router, svc, nil, log) rec = doRequest(router, http.MethodGet, "/api/v1/auth/sessions", withCookie(svc, token), func(r *http.Request) { r.RemoteAddr = "2.2.2.2:1234" }) diff --git a/apps/backend/internal/auth/service_credentials.go b/apps/backend/internal/auth/service_credentials.go index 32c18d3b26..a1d6bd1c29 100644 --- a/apps/backend/internal/auth/service_credentials.go +++ b/apps/backend/internal/auth/service_credentials.go @@ -156,6 +156,27 @@ func (s *Service) ListSessions(ctx context.Context, userID string) ([]*store.Ses return s.store.ListSessionsByUser(ctx, userID) } +// SessionIPs returns each distinct raw session IP belonging to the user. +func (s *Service) SessionIPs(ctx context.Context, userID string) ([]string, error) { + sessions, err := s.ListSessions(ctx, userID) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(sessions)) + ips := make([]string, 0, len(sessions)) + for _, session := range sessions { + if session.IP == "" { + continue + } + if _, exists := seen[session.IP]; exists { + continue + } + seen[session.IP] = struct{}{} + ips = append(ips, session.IP) + } + return ips, nil +} + // RevokeSession deletes one of the user's sessions. func (s *Service) RevokeSession(ctx context.Context, sessionID, userID string) error { return s.store.DeleteSession(ctx, sessionID, userID) diff --git a/apps/backend/internal/backendapp/app_status_bar_visibility_test.go b/apps/backend/internal/backendapp/app_status_bar_visibility_test.go index b2e75f6503..46ef14b206 100644 --- a/apps/backend/internal/backendapp/app_status_bar_visibility_test.go +++ b/apps/backend/internal/backendapp/app_status_bar_visibility_test.go @@ -22,3 +22,13 @@ func TestMapUserSettingsStateIncludesAppStatusBarVisibility(t *testing.T) { t.Fatalf("revision = %#v, want int64(42)", state["revision"]) } } +func TestMapUserSettingsStateIncludesResolveSessionHostnames(t *testing.T) { + var response userdto.UserSettingsResponse + if err := json.Unmarshal([]byte(`{"settings":{"resolve_session_hostnames":true}}`), &response); err != nil { + t.Fatalf("decode user settings response: %v", err) + } + state := mapUserSettingsState(response, "workspace-1") + if got, ok := state["resolveSessionHostnames"].(bool); !ok || !got { + t.Fatalf("resolveSessionHostnames = %#v, want true", state["resolveSessionHostnames"]) + } +} diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 666f8f1066..488d22a994 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -671,6 +671,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "simplified": settings.SystemMetricsDisplay.Simplified, }, "appStatusBarEnabled": settings.AppStatusBarEnabled, + "resolveSessionHostnames": settings.ResolveSessionHostnames, "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), "quickChatTabOrderByWorkspace": settings.QuickChatTabOrderByWorkspace, "hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs), diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index d23bf31012..a6279de7e3 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -862,7 +862,7 @@ func registerRoutes(p routeParams) { registerTaskRoutes(p, planService, handoffSvc) registerSecondaryRoutes(p, workflowCtrl, clarificationStore, clarificationCanceller, clarificationResolver, planService, handoffSvc) if p.authSvc != nil { - authhttpapi.RegisterRoutes(p.router, p.authSvc, p.log) + authhttpapi.RegisterRoutes(p.router, p.authSvc, p.services.SessionHostnameResolver, p.log) } // /health is a liveness probe: it answers 200 unconditionally, matching diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 22dee37b37..2aaf88d50c 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -21,6 +21,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/auth" + "github.com/kandev/kandev/internal/auth/hostnames" authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/entityrefs" @@ -416,6 +417,37 @@ func startServices( //nolint:cyclop return false } warnIfExposedWithoutAuth(cfg, services.Auth, log) + hostnameStore, err := hostnames.NewStore(dbPool.Writer(), dbPool.Reader()) + if err != nil { + log.Error("Failed to initialize session hostname cache", zap.Error(err)) + return false + } + hostnameResolver := hostnames.NewResolver( + hostnameStore, + func(settingsCtx context.Context, userID string) (bool, error) { + settings, err := repos.User.GetUserSettings(settingsCtx, userID) + if err != nil { + return false, err + } + return settings.ResolveSessionHostnames, nil + }, + services.Auth.SessionIPs, + eventBus, + log, + ) + hostnameResolver.Start(ctx) + addCleanup(hostnameResolver.Close) + hostnameSettingsSubscription, err := eventBus.QueueSubscribe( + events.UserSettingsUpdated, + "session-hostname-resolver", + hostnameResolver.HandleUserSettingsUpdated, + ) + if err != nil { + log.Error("Failed to subscribe session hostname resolver to user settings", zap.Error(err)) + return false + } + addCleanup(hostnameSettingsSubscription.Unsubscribe) + services.SessionHostnameResolver = hostnameResolver if err := runInitialAgentSetup(ctx, services.User, agentSettingsController, log); err != nil { // Agent registry seeding is a hard prerequisite for every diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index 0f684be8e3..f85530bd10 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -9,6 +9,7 @@ import ( settingsstore "github.com/kandev/kandev/internal/agent/settings/store" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" authservice "github.com/kandev/kandev/internal/auth" + "github.com/kandev/kandev/internal/auth/hostnames" authstore "github.com/kandev/kandev/internal/auth/store" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" @@ -120,7 +121,8 @@ type Services struct { // Auth is the opt-in authentication service (mode state machine, sessions, // PATs, invites). Always non-nil; in disabled mode it only answers // Mode() == ModeDisabled and the middleware injects the synthetic identity. - Auth *authservice.Service + Auth *authservice.Service + SessionHostnameResolver *hostnames.Resolver } type schedulerStopper interface { diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 66653fef9c..03bc52c3a7 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -182,6 +182,7 @@ const ( const ( UserSettingsUpdated = "user.settings.updated" UserAgentProfileRecentUseUpdated = "user.agent_profile_recent_use.updated" + AuthSessionHostnameResolved = "auth.session.hostname.resolved" ) // PluginUserStateUpdated fires after a successful write/delete on a diff --git a/apps/backend/internal/gateway/websocket/user_notifications.go b/apps/backend/internal/gateway/websocket/user_notifications.go index ca4f7131f4..57f245b060 100644 --- a/apps/backend/internal/gateway/websocket/user_notifications.go +++ b/apps/backend/internal/gateway/websocket/user_notifications.go @@ -28,6 +28,7 @@ func RegisterUserNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.UserSettingsUpdated, ws.ActionUserSettingsUpdated) b.subscribe(eventBus, events.UserAgentProfileRecentUseUpdated, ws.ActionUserAgentProfileRecentUseUpdated) + b.subscribe(eventBus, events.AuthSessionHostnameResolved, ws.ActionSessionHostnameResolved) b.subscribe(eventBus, events.PluginUserStateUpdated, ws.ActionPluginUserStateUpdated) go func() { diff --git a/apps/backend/internal/gateway/websocket/user_notifications_test.go b/apps/backend/internal/gateway/websocket/user_notifications_test.go index af41103c1d..fbcf0dcf7e 100644 --- a/apps/backend/internal/gateway/websocket/user_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/user_notifications_test.go @@ -218,3 +218,52 @@ func TestRegisterUserNotifications_AgentProfileRecentUseReachesOnlyOwner(t *test t.Fatal("expected other user's client to receive nothing") } } + +func TestRegisterUserNotifications_SessionHostnameResolvedRoutesAndStripsUser(t *testing.T) { + hub := newTestHub(t) + owner := newTestClient("owner") + other := newTestClient("other") + registerTestClient(hub, owner) + registerTestClient(hub, other) + hub.SubscribeToUser(owner, "user_1") + hub.SubscribeToUser(other, "user_2") + + eventBus := bus.NewMemoryEventBus(testLoggerForUserNotifications(t)) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + RegisterUserNotifications(ctx, eventBus, hub, testLoggerForUserNotifications(t)) + if err := eventBus.Publish(ctx, events.AuthSessionHostnameResolved, bus.NewEvent( + events.AuthSessionHostnameResolved, "test", map[string]any{ + "user_id": "user_1", "ip": "192.0.2.10", "hostname": "mail.example.test", + "resolved_at": "2026-08-16T12:00:00.123456789Z", + }, + )); err != nil { + t.Fatalf("publish: %v", err) + } + + select { + case raw := <-owner.send: + var message ws.Message + if err := json.Unmarshal(raw, &message); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if message.Action != ws.ActionSessionHostnameResolved { + t.Fatalf("Action = %q, want %q", message.Action, ws.ActionSessionHostnameResolved) + } + var payload map[string]any + if err := json.Unmarshal(message.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload["ip"] != "192.0.2.10" || payload["hostname"] != "mail.example.test" || payload["resolved_at"] != "2026-08-16T12:00:00.123456789Z" { + t.Fatalf("payload = %#v, want hostname resolution fields", payload) + } + if _, leaked := payload["user_id"]; leaked { + t.Fatalf("payload leaked user_id: %#v", payload) + } + default: + t.Fatal("owner did not receive hostname resolution") + } + if clientReceived(other) { + t.Fatal("non-owner received hostname resolution") + } +} diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 0b53cd2f34..da2dc3d597 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -141,6 +141,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS LastSeenDisplay: req.LastSeenDisplay, SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay), AppStatusBarEnabled: req.AppStatusBarEnabled, + ResolveSessionHostnames: req.ResolveSessionHostnames, AppStatusBarOrder: req.AppStatusBarOrder, QuickChatTabOrderByWorkspace: req.QuickChatTabOrderByWorkspace, KanbanHiddenStepIDs: req.KanbanHiddenStepIDs, diff --git a/apps/backend/internal/user/controller/controller_test.go b/apps/backend/internal/user/controller/controller_test.go index ce17496302..a161173ed1 100644 --- a/apps/backend/internal/user/controller/controller_test.go +++ b/apps/backend/internal/user/controller/controller_test.go @@ -4,11 +4,11 @@ import ( "context" "encoding/json" "errors" - "testing" - "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "go.uber.org/zap" + "reflect" + "testing" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/user/dto" @@ -253,3 +253,63 @@ func TestSystemMetricsDisplayPatch(t *testing.T) { } }) } + +func TestUpdateUserSettingsMapsResolveSessionHostnames(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + repo := &settingsRepository{settings: &models.UserSettings{}} + controller := NewController(service.NewService(repo, nil, log)) + + var enabled dto.UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"resolve_session_hostnames":true}`), &enabled); err != nil { + t.Fatalf("decode enabled patch: %v", err) + } + response, err := controller.UpdateUserSettings(context.Background(), enabled) + if err != nil { + t.Fatalf("update enabled setting: %v", err) + } + encoded, err := json.Marshal(response.Settings) + if err != nil { + t.Fatalf("encode settings response: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode settings response: %v", err) + } + if got, ok := payload["resolve_session_hostnames"].(bool); !ok || !got { + t.Fatalf("resolve_session_hostnames = %#v, want true", payload["resolve_session_hostnames"]) + } + + var omitted dto.UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatalf("decode omitted patch: %v", err) + } + if _, err := controller.UpdateUserSettings(context.Background(), omitted); err != nil { + t.Fatalf("apply omitted patch: %v", err) + } + if !resolveSessionHostnamesFromSettings(t, repo.settings) { + t.Fatal("omitted ResolveSessionHostnames did not preserve true") + } + + var disabled dto.UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"resolve_session_hostnames":false}`), &disabled); err != nil { + t.Fatalf("decode disabled patch: %v", err) + } + if _, err := controller.UpdateUserSettings(context.Background(), disabled); err != nil { + t.Fatalf("apply disabled patch: %v", err) + } + if resolveSessionHostnamesFromSettings(t, repo.settings) { + t.Fatal("explicit false did not persist") + } +} + +func resolveSessionHostnamesFromSettings(t *testing.T, settings *models.UserSettings) bool { + t.Helper() + field := reflect.ValueOf(settings).Elem().FieldByName("ResolveSessionHostnames") + if !field.IsValid() { + t.Fatal("ResolveSessionHostnames field is missing") + } + return field.Bool() +} diff --git a/apps/backend/internal/user/dto/app_status_bar_visibility_test.go b/apps/backend/internal/user/dto/app_status_bar_visibility_test.go index c9cbd06dd9..5c73580d52 100644 --- a/apps/backend/internal/user/dto/app_status_bar_visibility_test.go +++ b/apps/backend/internal/user/dto/app_status_bar_visibility_test.go @@ -50,3 +50,39 @@ func TestAppStatusBarVisibilityDTOResponseAndPatchSemantics(t *testing.T) { } }) } +func TestResolveSessionHostnamesDTOResponseAndPatchSemantics(t *testing.T) { + t.Run("response defaults to false", func(t *testing.T) { + encoded, err := json.Marshal(FromUserSettings(&models.UserSettings{})) + if err != nil { + t.Fatalf("encode response: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if got, ok := payload["resolve_session_hostnames"].(bool); !ok || got { + t.Fatalf("resolve_session_hostnames = %#v, want false", payload["resolve_session_hostnames"]) + } + }) + + t.Run("patch distinguishes omission from explicit false", func(t *testing.T) { + var omitted, disabled UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatalf("decode omitted patch: %v", err) + } + if err := json.Unmarshal([]byte(`{"resolve_session_hostnames":false}`), &disabled); err != nil { + t.Fatalf("decode explicit patch: %v", err) + } + omittedField := reflect.ValueOf(omitted).FieldByName("ResolveSessionHostnames") + disabledField := reflect.ValueOf(disabled).FieldByName("ResolveSessionHostnames") + if !omittedField.IsValid() || !disabledField.IsValid() { + t.Fatal("ResolveSessionHostnames patch field is missing") + } + if !omittedField.IsNil() { + t.Fatalf("omitted ResolveSessionHostnames = %#v, want nil", omittedField.Interface()) + } + if disabledField.IsNil() || disabledField.Elem().Bool() { + t.Fatalf("explicit ResolveSessionHostnames = %#v, want pointer to false", disabledField.Interface()) + } + }) +} diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index d69c53f423..53cc9bf5c9 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -73,6 +73,7 @@ type UserSettingsDTO struct { LastSeenDisplay string `json:"last_seen_display"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled bool `json:"app_status_bar_enabled"` + ResolveSessionHostnames bool `json:"resolve_session_hostnames"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` @@ -179,6 +180,7 @@ type UpdateUserSettingsRequest struct { LastSeenDisplay *string `json:"last_seen_display,omitempty"` SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"` AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` + ResolveSessionHostnames *bool `json:"resolve_session_hostnames,omitempty"` AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` QuickChatTabOrderByWorkspace *map[string][]string `json:"quick_chat_tab_order_by_workspace,omitempty"` KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"` @@ -365,6 +367,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { LastSeenDisplay: models.NormalizeLastSeenDisplay(settings.LastSeenDisplay), SystemMetricsDisplay: settings.SystemMetricsDisplay, AppStatusBarEnabled: settings.AppStatusBarEnabled, + ResolveSessionHostnames: settings.ResolveSessionHostnames, AppStatusBarOrder: settings.AppStatusBarOrder, QuickChatTabOrderByWorkspace: settings.QuickChatTabOrderByWorkspace, KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index a94e8505d6..f65c91e9cb 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -143,6 +143,7 @@ type UserSettings struct { LastSeenDisplay string `json:"last_seen_display"` // "absolute" | "relative" SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled bool `json:"app_status_bar_enabled"` + ResolveSessionHostnames bool `json:"resolve_session_hostnames"` AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` diff --git a/apps/backend/internal/user/service/app_status_bar_visibility_test.go b/apps/backend/internal/user/service/app_status_bar_visibility_test.go index 4d62bb37b1..be76c3070d 100644 --- a/apps/backend/internal/user/service/app_status_bar_visibility_test.go +++ b/apps/backend/internal/user/service/app_status_bar_visibility_test.go @@ -3,11 +3,11 @@ package service import ( "context" "encoding/json" - "testing" - "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/user/models" "go.uber.org/zap" + "reflect" + "testing" ) func TestApplyBasicSettingsAppStatusBarVisibility(t *testing.T) { @@ -81,3 +81,46 @@ func statusBarEnabledFromSettings(t *testing.T, settings *models.UserSettings) b } return got } +func TestResolveSessionHostnamesServiceRoundTrip(t *testing.T) { + enabled := true + settings := &models.UserSettings{} + req := &UpdateUserSettingsRequest{} + reqField := reflect.ValueOf(req).Elem().FieldByName("ResolveSessionHostnames") + if !reqField.IsValid() { + t.Fatal("ResolveSessionHostnames request field is missing") + } + reqField.Set(reflect.ValueOf(&enabled)) + if err := applyBasicSettings(settings, req); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + + encoded, err := json.Marshal(settings) + if err != nil { + t.Fatalf("encode settings: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode settings: %v", err) + } + if got, ok := payload["resolve_session_hostnames"].(bool); !ok || !got { + t.Fatalf("resolve_session_hostnames = %#v, want true", payload["resolve_session_hostnames"]) + } +} + +func TestPublishUserSettingsEventIncludesResolveSessionHostnames(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + eventBus := &recordingEventBus{} + svc := NewService(&recordingUserRepository{}, eventBus, log) + svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{ResolveSessionHostnames: true}) + + eventData, ok := eventBus.publishedEvents[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected event data map, got %T", eventBus.publishedEvents[0].Data) + } + if got, ok := eventData["resolve_session_hostnames"].(bool); !ok || !got { + t.Fatalf("resolve_session_hostnames = %#v, want true", eventData["resolve_session_hostnames"]) + } +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 687462b2b3..677731697c 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -101,6 +101,7 @@ type UpdateUserSettingsRequest struct { LastSeenDisplay *string SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch AppStatusBarEnabled *bool + ResolveSessionHostnames *bool AppStatusBarOrder *models.AppStatusBarOrder QuickChatTabOrderByWorkspace *map[string][]string KanbanHiddenStepIDs *map[string][]string @@ -194,7 +195,7 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.TaskCreateLastUsed != nil && !taskCreateLastUsedPatchEmpty(*req.TaskCreateLastUsed) { taskCreatePatch = req.TaskCreateLastUsed } - return s.updateUserSettingsCAS(ctx, func(settings *models.UserSettings) (bool, error) { + settings, err := s.updateUserSettingsCAS(ctx, func(settings *models.UserSettings) (bool, error) { // The shallow copy is safe only while every apply* helper replaces // reference fields (slices, maps, and json.RawMessage) instead of // mutating them in place. In-place mutation would alias before and make @@ -229,6 +230,10 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting } return !reflect.DeepEqual(*settings, before), nil }, taskCreatePatch) + if err != nil { + return nil, err + } + return settings, nil } // updateUserSettingsCAS applies a full-blob user-settings write under @@ -330,6 +335,9 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe if req.AppStatusBarEnabled != nil { settings.AppStatusBarEnabled = *req.AppStatusBarEnabled } + if req.ResolveSessionHostnames != nil { + settings.ResolveSessionHostnames = *req.ResolveSessionHostnames + } if req.AppStatusBarOrder != nil { settings.AppStatusBarOrder = *req.AppStatusBarOrder } @@ -983,6 +991,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay), "system_metrics_display": settings.SystemMetricsDisplay, "app_status_bar_enabled": settings.AppStatusBarEnabled, + "resolve_session_hostnames": settings.ResolveSessionHostnames, "app_status_bar_order": settings.AppStatusBarOrder, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, "workflow_ids_with_auto_hide_empty_steps": settings.WorkflowIDsWithAutoHideEmptySteps, diff --git a/apps/backend/internal/user/store/app_status_bar_visibility_test.go b/apps/backend/internal/user/store/app_status_bar_visibility_test.go index e55980deec..8b215c2789 100644 --- a/apps/backend/internal/user/store/app_status_bar_visibility_test.go +++ b/apps/backend/internal/user/store/app_status_bar_visibility_test.go @@ -38,3 +38,32 @@ func TestScanUserSettingsAppStatusBarVisibilityDefaultsAndRoundTrips(t *testing. }) } } +func TestScanUserSettingsResolveSessionHostnamesDefaultsAndRoundTrips(t *testing.T) { + tests := []struct { + name string + raw string + want bool + }{ + {name: "missing defaults to disabled", raw: `{}`, want: false}, + {name: "explicit true is preserved", raw: `{"resolve_session_hostnames":true}`, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings, err := scanUserSettings(settingsScanner{raw: tt.raw}, DefaultUserID) + if err != nil { + t.Fatalf("scan settings: %v", err) + } + encoded, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatalf("marshal settings payload: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode normalized settings: %v", err) + } + if got, ok := payload["resolve_session_hostnames"].(bool); !ok || got != tt.want { + t.Fatalf("resolve_session_hostnames = %#v, want %t", payload["resolve_session_hostnames"], tt.want) + } + }) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index f6a57a436d..156c00c89c 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -643,6 +643,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay), "system_metrics_display": settings.SystemMetricsDisplay, "app_status_bar_enabled": settings.AppStatusBarEnabled, + "resolve_session_hostnames": settings.ResolveSessionHostnames, "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), "quick_chat_tab_order_by_workspace": quickChatTabOrderByWorkspace, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, @@ -726,6 +727,7 @@ func defaultUserSettings(userID string) *models.UserSettings { ThreadActiveViewID: DefaultThreadViewID, SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), AppStatusBarEnabled: false, + ResolveSessionHostnames: false, AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), QuickChatTabOrderByWorkspace: map[string][]string{}, KanbanHiddenStepIDs: map[string][]string{}, @@ -815,6 +817,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin LastSeenDisplay json.RawMessage `json:"last_seen_display"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` + ResolveSessionHostnames *bool `json:"resolve_session_hostnames"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"` KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"` @@ -969,6 +972,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if payload.AppStatusBarEnabled != nil { settings.AppStatusBarEnabled = *payload.AppStatusBarEnabled } + if payload.ResolveSessionHostnames != nil { + settings.ResolveSessionHostnames = *payload.ResolveSessionHostnames + } settings.AppStatusBarOrder = normalizeAppStatusBarOrder(payload.AppStatusBarOrder) settings.QuickChatTabOrderByWorkspace = payload.QuickChatTabOrderByWorkspace if settings.QuickChatTabOrderByWorkspace == nil { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 200822142c..1fb337f5cc 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -367,6 +367,7 @@ const ( ActionUserSettingsUpdate = "user.settings.update" ActionUserSettingsUpdated = "user.settings.updated" ActionUserAgentProfileRecentUseUpdated = "user.agent_profile_recent_use.updated" + ActionSessionHostnameResolved = "auth.session.hostname.resolved" // ActionPluginUserStateUpdated notifies the writing user's other WS // connections that one of their per-user plugin storage keys changed diff --git a/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.test.tsx b/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.test.tsx index 609c27a335..b287b143b4 100644 --- a/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.test.tsx +++ b/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.test.tsx @@ -13,7 +13,6 @@ vi.mock("@/lib/api/domains/office-runs-api", () => api); const clients = vi.hoisted(() => ({ active: { subscribeRun: vi.fn() }, })); - vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => clients.active, useWebSocketClient: () => clients.active, @@ -21,14 +20,17 @@ vi.mock("@/lib/ws/connection", () => ({ const handlers = vi.hoisted(() => ({ listener: undefined as ((payload: RunEventAppendedPayload) => void) | undefined, + unsubscribers: [] as ReturnType[], })); vi.mock("@/lib/ws/handlers/run", () => ({ subscribeRunEvents: (_runId: string, listener: (payload: RunEventAppendedPayload) => void) => { handlers.listener = listener; - return () => { + const unsubscribe = vi.fn(() => { handlers.listener = undefined; - }; + }); + handlers.unsubscribers.push(unsubscribe); + return unsubscribe; }, })); @@ -60,6 +62,7 @@ const initialProps: Props = { runId: "run-1", initialEvents: [], initialStatus: describe("useRunLiveSync", () => { beforeEach(() => { clients.active = { subscribeRun: vi.fn(() => vi.fn()) }; + handlers.unsubscribers = []; handlers.listener = undefined; }); @@ -79,6 +82,26 @@ describe("useRunLiveSync", () => { expect(result.current.status).toBe("claimed"); }); + it("replaces claimed-run subscriptions when the WebSocket client changes", () => { + const firstClient = clients.active; + // Stable fixture: the hook re-syncs its events state on `initialEvents` + // identity, so a fresh literal on every render would feed an endless + // render->effect cycle (the snapshot is server-provided and reference-stable). + const initialEvents: RunEvent[] = []; + const { result, rerender } = renderHook(() => + useRunLiveSync("run-1", initialEvents, "claimed"), + ); + const secondClient = { subscribeRun: vi.fn(() => vi.fn()) }; + + clients.active = secondClient; + rerender(); + expect(handlers.unsubscribers[0]).toHaveBeenCalledOnce(); + expect(firstClient.subscribeRun.mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(secondClient.subscribeRun).toHaveBeenCalledWith("run-1"); + act(() => handlers.listener?.({ run_id: "run-1", event: runEvent(1, "finished") })); + expect(result.current.status).toBe("finished"); + }); + it("does not clobber a live event when a fresh but unchanged snapshot rerenders", () => { // A caller that supplies a fresh-but-unchanged `initialEvents` array (the // exact scenario the snapshot guard exists for) must not erase events that diff --git a/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.ts b/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.ts index 38274d9517..256e7e96e4 100644 --- a/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.ts +++ b/apps/web/app/office/agents/[id]/runs/[runId]/use-run-live-sync.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { useWebSocketClient } from "@/lib/ws/connection"; import { subscribeRunEvents } from "@/lib/ws/handlers/run"; import type { RunDetail, RunEvent } from "@/lib/api/domains/office-extended-api"; import { getRunDetail } from "@/lib/api/domains/office-runs-api"; @@ -64,6 +64,7 @@ export function useRunLiveSync( }); const outputSnapshotRef = useRef({ runId, value: initialOutputSummary }); const refreshedTerminalRunRef = useRef(null); + const client = useWebSocketClient(); useEffect(() => { const nextSeqs = new Set(initialEvents.map((e) => e.seq)); @@ -129,9 +130,7 @@ export function useRunLiveSync( if (status !== "claimed") return; if (TERMINAL_STATUSES.has(status)) return; - const client = getWebSocketClient(); if (!client) return; - const unsubscribeWs = client.subscribeRun(runId); const unsubscribeListener = subscribeRunEvents(runId, (payload) => { if (payload.run_id !== runId) return; @@ -147,7 +146,7 @@ export function useRunLiveSync( unsubscribeListener(); unsubscribeWs(); }; - }, [runId, status]); + }, [client, runId, status]); return { events, status, outputSummary }; } diff --git a/apps/web/app/office/tasks/[id]/use-session-live-sync.test.tsx b/apps/web/app/office/tasks/[id]/use-session-live-sync.test.tsx index cc580b059c..edb090a903 100644 --- a/apps/web/app/office/tasks/[id]/use-session-live-sync.test.tsx +++ b/apps/web/app/office/tasks/[id]/use-session-live-sync.test.tsx @@ -2,16 +2,22 @@ import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useSessionLiveSyncSubscriptions } from "./use-session-live-sync"; -const mockWebSocketClient = vi.hoisted(() => ({ - subscribeSession: vi.fn(), +const clients = vi.hoisted(() => ({ + active: { + subscribeSession: vi.fn(), + }, })); +const mockWebSocketClient = clients.active; + vi.mock("@/lib/ws/connection", () => ({ - getWebSocketClient: () => mockWebSocketClient, + getWebSocketClient: () => clients.active, + useWebSocketClient: () => clients.active, })); describe("Office session live-sync membership", () => { beforeEach(() => { + clients.active = mockWebSocketClient; mockWebSocketClient.subscribeSession.mockReset(); mockWebSocketClient.subscribeSession.mockImplementation(() => vi.fn()); }); @@ -89,4 +95,24 @@ describe("Office session live-sync membership", () => { unmount(); expect(unsubscribe).toHaveBeenCalledTimes(2); }); + + it("replaces mounted subscriptions when the WebSocket client changes", () => { + const firstUnsubscribe = vi.fn(); + const secondClient = { subscribeSession: vi.fn(() => vi.fn()) }; + mockWebSocketClient.subscribeSession.mockReturnValue(firstUnsubscribe); + + const { rerender } = renderHook(() => + useSessionLiveSyncSubscriptions({ + connectionStatus: "connected", + taskId: "task-1", + sessionIds: ["session-a"], + }), + ); + + clients.active = secondClient; + rerender(); + + expect(firstUnsubscribe).toHaveBeenCalledOnce(); + expect(secondClient.subscribeSession).toHaveBeenCalledWith("session-a"); + }); }); diff --git a/apps/web/app/office/tasks/[id]/use-session-live-sync.ts b/apps/web/app/office/tasks/[id]/use-session-live-sync.ts index d68f7b080f..2ec9b89bae 100644 --- a/apps/web/app/office/tasks/[id]/use-session-live-sync.ts +++ b/apps/web/app/office/tasks/[id]/use-session-live-sync.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef } from "react"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { useWebSocketClient } from "@/lib/ws/connection"; import type { ConnectionStatus } from "@/lib/types/connection"; export type SessionLiveSyncParams = { @@ -23,6 +23,8 @@ export function useSessionLiveSyncSubscriptions({ [membershipKey], ); const subscriptionsRef = useRef(new Map void>()); + const client = useWebSocketClient(); + const subscribedClientRef = useRef(null); useEffect(() => { const subscriptions = subscriptionsRef.current; @@ -30,13 +32,16 @@ export function useSessionLiveSyncSubscriptions({ for (const unsubscribe of subscriptions.values()) unsubscribe(); subscriptions.clear(); }; + if (subscribedClientRef.current !== client) { + clearSubscriptions(); + subscribedClientRef.current = client; + } if (connectionStatus !== "connected" || !taskId) { clearSubscriptions(); return; } - const client = getWebSocketClient(); if (!client) { clearSubscriptions(); return; @@ -52,7 +57,7 @@ export function useSessionLiveSyncSubscriptions({ if (subscriptions.has(sessionId)) continue; subscriptions.set(sessionId, client.subscribeSession(sessionId)); } - }, [connectionStatus, membershipKey, stableSessionIds, taskId]); + }, [client, connectionStatus, membershipKey, stableSessionIds, taskId]); useEffect( () => () => { diff --git a/apps/web/components/settings/account/security-settings.test.tsx b/apps/web/components/settings/account/security-settings.test.tsx index 5a6c33cc13..b425d27e7b 100644 --- a/apps/web/components/settings/account/security-settings.test.tsx +++ b/apps/web/components/settings/account/security-settings.test.tsx @@ -52,6 +52,8 @@ const SESSION: AuthSession = { user_agent: "Chrome on macOS", ip: "203.0.113.7", current: true, + hostname: "", + hostname_resolved_at: null, }; /** Returns default user settings merged with the given overrides. */ @@ -280,6 +282,66 @@ describe("Last seen display rendering", () => { }); }); +describe("Session hostname reconciliation", () => { + const older = "2026-08-18T12:00:00.000000001Z"; + const newer = "2026-08-18T12:00:00.000000002Z"; + + it.each([ + { + name: "keeps a newer HTTP result over an older event", + responseHostname: "http-new.example.test", + responseResolvedAt: newer, + streamedHostname: "event-old.example.test", + streamedResolvedAt: older, + expected: "http-new.example.test", + }, + { + name: "keeps a newer event over an older HTTP result", + responseHostname: "http-old.example.test", + responseResolvedAt: older, + streamedHostname: "event-new.example.test", + streamedResolvedAt: newer, + expected: "event-new.example.test", + }, + { + name: "keeps a durable HTTP result over a transient event", + responseHostname: "http-durable.example.test", + responseResolvedAt: newer, + streamedHostname: "", + streamedResolvedAt: null, + expected: "http-durable.example.test", + }, + { + name: "uses the non-empty result when timestamps match", + responseHostname: "", + responseResolvedAt: newer, + streamedHostname: "event-named.example.test", + streamedResolvedAt: newer, + expected: "event-named.example.test", + }, + ])("$name", async (scenario) => { + authApiMocks.listSessions.mockResolvedValue({ + sessions: [ + { + ...SESSION, + hostname: scenario.responseHostname, + hostname_resolved_at: scenario.responseResolvedAt, + }, + ], + }); + const holder: StoreHolder = { current: null }; + await renderLoaded({ resolveSessionHostnames: true }, holder); + + act(() => { + holder + .current!.getState() + .setSessionHostname(SESSION.ip, scenario.streamedHostname, scenario.streamedResolvedAt); + }); + + expect(screen.getByTestId("account-session-hostname").textContent).toBe(scenario.expected); + }); +}); + describe("Last seen display persistence", () => { it("keeps a selection local until the shared save action runs", async () => { await renderLoaded(); diff --git a/apps/web/components/settings/account/security-settings.tsx b/apps/web/components/settings/account/security-settings.tsx index 2f7dcd5bf5..865d876426 100644 --- a/apps/web/components/settings/account/security-settings.tsx +++ b/apps/web/components/settings/account/security-settings.tsx @@ -9,6 +9,7 @@ import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Spinner } from "@kandev/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { Switch } from "@kandev/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Drawer, @@ -27,18 +28,20 @@ import { revokeSession, type AuthSession, } from "@/lib/api/domains/auth-api"; +import { updateUserSettings } from "@/lib/api"; import { SettingsCard } from "@/components/settings/settings-card"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { useSettingsTargetRegistration } from "@/components/settings/settings-target-provider"; import { ACCOUNT_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/account"; import { formatDateTime, formatRelativeTime } from "@/lib/i18n/formats"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { isUserSettingsResponseCurrent } from "@/lib/settings/user-settings-revision"; import { SettingsCardHeader } from "@/components/settings/settings-card-header"; import { SettingsErrorText, SettingsFieldLabel } from "@/components/settings/settings-typography"; import { useNow } from "@/hooks/use-now"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync"; -import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { toast } from "@/lib/toast/sonner"; import type { LastSeenDisplay } from "@/lib/types/http"; @@ -136,26 +139,45 @@ function ChangePasswordCard() { } /** Loads the active sessions list and exposes reload/error state. */ -function useSessionsList() { +function useSessionsList(resolveHostnames: boolean) { const { t } = useTranslation(); const [sessions, setSessions] = useState([]); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); + const fetching = useRef(false); + const reloadPending = useRef(false); /** Fetches the latest sessions from the API and updates state. */ const reload = useCallback(async () => { - setError(null); + if (fetching.current) { + reloadPending.current = true; + return; + } + fetching.current = true; try { - const res = await listSessions({ cache: "no-store" }); - setSessions(res.sessions); - setLoaded(true); - } catch (err) { - setError(err instanceof ApiError ? err.message : t("account:failedToLoadSessions")); + do { + reloadPending.current = false; + setError(null); + try { + const res = await listSessions({ cache: "no-store" }); + setSessions(res.sessions); + setLoaded(true); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("account:failedToLoadSessions")); + } + } while (reloadPending.current); + } finally { + fetching.current = false; } }, [t]); useEffect(() => { void reload(); + }, [reload, resolveHostnames]); + useEffect(() => { + const onFocus = () => void reload(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); }, [reload]); return { sessions, loaded, error, reload, setError }; @@ -285,10 +307,14 @@ function LastSeenDisplaySelect({ function SessionsTable({ sessions, display, + resolveHostnames, + streamed, onRevoke, }: { sessions: AuthSession[]; display: LastSeenDisplay; + resolveHostnames: boolean; + streamed: Record; onRevoke: (id: string) => void; }) { const { t } = useTranslation(); @@ -298,6 +324,7 @@ function SessionsTable({ {t("account:device")} {t("account:ipAddress")} + {resolveHostnames && {t("account:hostname")}} {t("account:lastSeen")} {t("account:actions")} @@ -318,6 +345,12 @@ function SessionsTable({ )} {session.ip} + {resolveHostnames && ( + + {hostnameForSession(session, streamed[session.ip]) || + t("account:hostnameNotAvailable")} + + )} {!session.current && ( @@ -342,7 +375,9 @@ function SessionsTable({ /** Renders the active-sessions settings card with display preference and revoke. */ function SessionsCard() { const { t } = useTranslation(); - const { sessions, loaded, error, reload, setError } = useSessionsList(); + const resolveHostnames = useAppStore((state) => state.userSettings.resolveSessionHostnames); + const streamed = useAppStore((state) => state.sessionHostnames); + const { sessions, loaded, error, reload, setError } = useSessionsList(resolveHostnames); const savedDisplay = useAppStore((state) => state.userSettings.lastSeenDisplay); const store = useAppStoreApi(); const [draftDisplay, setDraftDisplay] = useState(savedDisplay); @@ -423,7 +458,86 @@ function SessionsCard() { )} {loaded && sessions.length > 0 && ( - + + )} + + + ); +} + +function hostnameForSession( + session: AuthSession, + streamed: { hostname: string; resolvedAt: string | null } | undefined, +) { + const response = { hostname: session.hostname, resolvedAt: session.hostname_resolved_at }; + if (!streamed) return response.hostname; + if (response.resolvedAt === null) + return streamed.resolvedAt === null + ? streamed.hostname || response.hostname + : streamed.hostname; + if (streamed.resolvedAt === null) return response.hostname; + if (streamed.resolvedAt > response.resolvedAt) return streamed.hostname; + if (streamed.resolvedAt < response.resolvedAt) return response.hostname; + return streamed.hostname || response.hostname; +} + +function ResolveHostnamesCard() { + const { t } = useTranslation(); + const settings = useAppStore((state) => state.userSettings); + const store = useAppStoreApi(); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const onChange = async (checked: boolean) => { + const settingsAtSubmit = store.getState().userSettings; + setError(null); + setSaving(true); + try { + const response = await updateUserSettings({ resolve_session_hostnames: checked }); + const state = store.getState(); + const responseIsCurrent = isUserSettingsResponseCurrent( + response.settings.revision, + state.userSettings.revision, + state.userSettings === settingsAtSubmit, + ); + if (responseIsCurrent) { + state.setUserSettings(mapUserSettingsResponse(response, state.userSettings)); + } + } catch { + setError(t("account:couldNotUpdateHostnameResolution")); + } finally { + setSaving(false); + } + }; + return ( + + +
+
+ + {t("account:resolveDeviceHostnames")} + +

+ {t("account:resolveDeviceHostnamesDescription")} +

+
+ void onChange(checked)} + /> +
+ {error && ( + + {error} + )}
@@ -435,6 +549,7 @@ export function SecuritySettings() { return (
+
); diff --git a/apps/web/e2e/tests/auth/account-security-hostnames.spec.ts b/apps/web/e2e/tests/auth/account-security-hostnames.spec.ts new file mode 100644 index 0000000000..7e1f0648ee --- /dev/null +++ b/apps/web/e2e/tests/auth/account-security-hostnames.spec.ts @@ -0,0 +1,121 @@ +import { expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { backendFixture as test } from "../../fixtures/backend"; +import { login, setupAdmin } from "../../helpers/auth"; + +const ADMIN = { + email: "hostnames@demo.dev", + password: "adminpass123", + displayName: "Hostname Admin", +}; + +test.describe.serial("account security hostnames", () => { + test.beforeEach(async ({ backend }) => { + const databasePath = path.join(backend.tmpDir, "kandev-hostnames-desktop.db"); + fs.rmSync(databasePath, { force: true }); + fs.rmSync(`${databasePath}-shm`, { force: true }); + fs.rmSync(`${databasePath}-wal`, { force: true }); + await backend.restart({ + KANDEV_FEATURES_AUTH: "true", + KANDEV_DATABASE_PATH: databasePath, + }); + }); + test.afterEach(async ({ backend }) => { + const databasePath = path.join(backend.tmpDir, "kandev-hostnames-desktop.db"); + await backend.restart(); + fs.rmSync(databasePath, { force: true }); + fs.rmSync(`${databasePath}-shm`, { force: true }); + fs.rmSync(`${databasePath}-wal`, { force: true }); + }); + + test("defaults off and restores the hostname column after re-enabling", async ({ + browser, + backend, + }) => { + const context = await browser.newContext({ baseURL: backend.frontendUrl }); + await setupAdmin(context, backend.baseUrl, ADMIN); + const page = await context.newPage(); + await page.goto("/settings/account/security"); + await expect(page.getByTestId("account-sessions-table")).toBeVisible(); + await expect(page.getByText("Hostname", { exact: true })).toHaveCount(0); + + const toggle = page.getByTestId("account-resolve-hostnames-switch"); + await toggle.click(); + await expect(page.getByText("Hostname", { exact: true })).toBeVisible(); + await expect(page.getByTestId("account-session-hostname")).toHaveCount(1); + await toggle.click(); + await expect(page.getByText("Hostname", { exact: true })).toHaveCount(0); + await toggle.click(); + await expect(page.getByText("Hostname", { exact: true })).toBeVisible(); + await page.reload(); + await expect(toggle).toHaveAttribute("data-state", "checked"); + await context.close(); + }); + + test("shows an error when the hostname preference cannot be saved", async ({ + browser, + backend, + }) => { + const context = await browser.newContext({ baseURL: backend.frontendUrl }); + await setupAdmin(context, backend.baseUrl, ADMIN); + const page = await context.newPage(); + await page.goto("/settings/account/security"); + await page.route("**/api/v1/user/settings", async (route) => { + if (route.request().method() === "PATCH") { + await route.fulfill({ status: 503, body: "unavailable" }); + return; + } + await route.continue(); + }); + + await page.getByTestId("account-resolve-hostnames-switch").click(); + + await expect(page.getByTestId("account-resolve-hostnames-error")).toHaveText( + "Could not update hostname resolution.", + ); + await context.close(); + }); + + test("refetches sessions after a new login while the first response is pending", async ({ + browser, + backend, + }) => { + const firstContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await setupAdmin(firstContext, backend.baseUrl, ADMIN); + const initialResponse = await firstContext.request.get( + `${backend.baseUrl}/api/v1/auth/sessions`, + ); + expect(initialResponse.ok(), await initialResponse.text()).toBeTruthy(); + const initialSessions = await initialResponse.json(); + const page = await firstContext.newPage(); + let releaseInitialResponse: (() => void) | undefined; + const initialResponseHeld = new Promise((resolve) => { + releaseInitialResponse = resolve; + }); + let sessionRequests = 0; + await page.route("**/api/v1/auth/sessions", async (route) => { + sessionRequests += 1; + if (sessionRequests === 1) { + await initialResponseHeld; + await route.fulfill({ json: initialSessions }); + return; + } + await route.continue(); + }); + await page.goto("/settings/account/security"); + await expect.poll(() => sessionRequests).toBe(1); + + const secondContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await login(secondContext, backend.baseUrl, ADMIN); + const secondPage = await secondContext.newPage(); + await secondPage.goto("/settings/account/security"); + await page.bringToFront(); + await page.evaluate(() => window.dispatchEvent(new FocusEvent("focus"))); + releaseInitialResponse?.(); + + await expect(page.getByTestId("account-sessions-row")).toHaveCount(2); + await firstContext.close(); + await secondContext.close(); + }); +}); diff --git a/apps/web/e2e/tests/auth/mobile-account-security-hostnames.spec.ts b/apps/web/e2e/tests/auth/mobile-account-security-hostnames.spec.ts new file mode 100644 index 0000000000..f2cd2fd170 --- /dev/null +++ b/apps/web/e2e/tests/auth/mobile-account-security-hostnames.spec.ts @@ -0,0 +1,45 @@ +import { devices, expect } from "@playwright/test"; +import path from "node:path"; +import { backendFixture as test } from "../../fixtures/backend"; +import { setupAdmin } from "../../helpers/auth"; + +const ADMIN = { + email: "hostnames-mobile@demo.dev", + password: "adminpass123", + displayName: "Hostname Mobile", +}; + +test.describe.serial("account security hostnames mobile", () => { + test.beforeAll(async ({ backend }) => { + await backend.restart({ + KANDEV_FEATURES_AUTH: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-hostnames-mobile.db"), + }); + }); + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("enables and hides hostnames without page overflow", async ({ browser, backend }) => { + const context = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: backend.frontendUrl, + }); + await setupAdmin(context, backend.baseUrl, ADMIN); + const page = await context.newPage(); + await page.goto("/settings/account/security"); + const toggle = page.getByTestId("account-resolve-hostnames-switch"); + await expect(toggle).toBeVisible(); + await toggle.tap(); + await expect(page.getByText("Hostname", { exact: true })).toBeVisible(); + await expect(page.getByTestId("account-session-hostname")).toHaveCount(1); + await expect( + page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).resolves.toBe(true); + await toggle.tap(); + await expect(page.getByText("Hostname", { exact: true })).toHaveCount(0); + await context.close(); + }); +}); diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 24000a635b..68e6b071b8 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -106,6 +106,7 @@ function makeUnloadedSettings(): UserSettingsState { lastSeenDisplay: "absolute", systemMetricsDisplay: { showInTopbar: false, simplified: false }, appStatusBarEnabled: false, + resolveSessionHostnames: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, quickChatTabOrderByWorkspace: {}, lspAutoStartLanguages: [], diff --git a/apps/web/hooks/use-system-metrics-subscription.test.ts b/apps/web/hooks/use-system-metrics-subscription.test.ts new file mode 100644 index 0000000000..96f8d8709d --- /dev/null +++ b/apps/web/hooks/use-system-metrics-subscription.test.ts @@ -0,0 +1,30 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useSystemMetricsSubscription } from "./use-system-metrics-subscription"; + +const clients = vi.hoisted(() => ({ + active: { subscribeSystemMetrics: vi.fn() }, +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => clients.active, + useWebSocketClient: () => clients.active, +})); + +describe("useSystemMetricsSubscription", () => { + beforeEach(() => { + clients.active = { subscribeSystemMetrics: vi.fn(() => vi.fn()) }; + }); + + it("replaces the subscription when the websocket client changes", () => { + const firstClient = clients.active; + const { rerender } = renderHook(() => useSystemMetricsSubscription(true)); + const secondClient = { subscribeSystemMetrics: vi.fn(() => vi.fn()) }; + + clients.active = secondClient; + rerender(); + + expect(firstClient.subscribeSystemMetrics.mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(secondClient.subscribeSystemMetrics).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/hooks/use-system-metrics-subscription.ts b/apps/web/hooks/use-system-metrics-subscription.ts index 7e3b50ea9c..0e820493ff 100644 --- a/apps/web/hooks/use-system-metrics-subscription.ts +++ b/apps/web/hooks/use-system-metrics-subscription.ts @@ -1,13 +1,13 @@ "use client"; import { useEffect } from "react"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { useWebSocketClient } from "@/lib/ws/connection"; export function useSystemMetricsSubscription(enabled: boolean) { + const client = useWebSocketClient(); + useEffect(() => { - if (!enabled) return; - const client = getWebSocketClient(); - if (!client) return; + if (!enabled || !client) return; return client.subscribeSystemMetrics(); - }, [enabled]); + }, [client, enabled]); } diff --git a/apps/web/lib/api/domains/auth-api.ts b/apps/web/lib/api/domains/auth-api.ts index dd5e01baba..bd040908d1 100644 --- a/apps/web/lib/api/domains/auth-api.ts +++ b/apps/web/lib/api/domains/auth-api.ts @@ -22,6 +22,8 @@ export type AuthSession = { last_seen_at: string; user_agent: string; ip: string; + hostname: string; + hostname_resolved_at: string | null; current: boolean; }; diff --git a/apps/web/lib/plugins/host.test.ts b/apps/web/lib/plugins/host.test.ts index b1d17b1576..ec79b28720 100644 --- a/apps/web/lib/plugins/host.test.ts +++ b/apps/web/lib/plugins/host.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import type { Window as HappyDOMWindow } from "happy-dom"; import { loadPlugins, unloadPlugin } from "./host"; import { pluginModalManager } from "./modal-manager"; import { pluginRegistry } from "./registry"; @@ -143,10 +142,6 @@ describe("loadPlugins — bundle lifecycle", () => { afterEach(cleanupBasePluginLoads); it("injects styleUrls as elements before importing the bundle", async () => { - // happy-dom eagerly loads real hrefs over the network; - // disable that for this test so it doesn't attempt (and 404-log) a real fetch. - const happyDOMWindow = window as unknown as HappyDOMWindow; - happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin("plugin-style-a", { @@ -168,7 +163,6 @@ describe("loadPlugins — bundle lifecycle", () => { const link = document.head.querySelector("link[href='/plugin-a.css']"); expect(link).not.toBeNull(); - happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false; }); it("isolates a throwing plugin: logs and does not stop other plugins from loading", async () => { @@ -253,8 +247,6 @@ describe("loadPlugins — asset URL prefixing", () => { it("prefixes a root-relative bundleUrl and style href with the backend apiBaseUrl when set (split-origin dev, Tauri)", async () => { mockApiBaseUrl = "http://localhost:38429"; - const happyDOMWindow = window as unknown as HappyDOMWindow; - happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; const importedUrls: string[] = []; const importer = async (url: string) => { importedUrls.push(url); @@ -279,7 +271,6 @@ describe("loadPlugins — asset URL prefixing", () => { "link[href='http://localhost:38429/api/plugins/plugin-prefix-a/ui/style.css']", ); expect(link).not.toBeNull(); - happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false; }); it("leaves a root-relative bundleUrl unprefixed when apiBaseUrl is empty (same-origin production)", async () => { @@ -368,8 +359,6 @@ describe("unloadPlugin", () => { }); it("removes the plugin's injected stylesheet tags so disable/enable cycles don't accumulate duplicates", async () => { - const happyDOMWindow = window as unknown as HappyDOMWindow; - happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_STYLE_A_ID, { @@ -392,7 +381,6 @@ describe("unloadPlugin", () => { unloadPlugin(PLUGIN_UNLOAD_STYLE_A_ID); expect(document.head.querySelector("link[href='/plugin-unload-style-a.css']")).toBeNull(); - happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false; }); }); diff --git a/apps/web/lib/settings/user-settings-revision.test.ts b/apps/web/lib/settings/user-settings-revision.test.ts new file mode 100644 index 0000000000..cb430bc155 --- /dev/null +++ b/apps/web/lib/settings/user-settings-revision.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isUserSettingsResponseCurrent } from "./user-settings-revision"; + +describe("isUserSettingsResponseCurrent", () => { + it("rejects a delayed response after a newer settings revision", () => { + expect(isUserSettingsResponseCurrent(4, 5, false)).toBe(false); + }); + + it("accepts equal or newer revisions and only accepts unversioned responses for their submitted snapshot", () => { + expect(isUserSettingsResponseCurrent(5, 5, false)).toBe(true); + expect(isUserSettingsResponseCurrent(6, 5, false)).toBe(true); + expect(isUserSettingsResponseCurrent(null, null, false)).toBe(false); + expect(isUserSettingsResponseCurrent(null, null, true)).toBe(true); + }); +}); diff --git a/apps/web/lib/settings/user-settings-revision.ts b/apps/web/lib/settings/user-settings-revision.ts index 91783b25c2..6d02d28764 100644 --- a/apps/web/lib/settings/user-settings-revision.ts +++ b/apps/web/lib/settings/user-settings-revision.ts @@ -15,3 +15,12 @@ export function compareUserSettingsRevisions( if (leftValue === rightValue) return 0; return leftValue > rightValue ? 1 : -1; } + +export function isUserSettingsResponseCurrent( + responseRevision: number | null | undefined, + currentRevision: number | null | undefined, + isSubmittedSnapshot: boolean, +): boolean { + const order = compareUserSettingsRevisions(responseRevision, currentRevision); + return order === null ? isSubmittedSnapshot : order >= 0; +} diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 5b2f894f09..1abf2e1656 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -195,6 +195,24 @@ describe("quick-chat tab order hydration", () => { }); }); +describe("session hostname resolution setting hydration", () => { + it("defaults to disabled, maps explicit values, and preserves omitted updates", () => { + const defaults = buildCoreFields({}) as Record; + const enabled = buildCoreFields({ resolve_session_hostnames: true } as Parameters< + typeof buildCoreFields + >[0]) as Record; + const current = { + ...mapUserSettingsResponse(null), + resolveSessionHostnames: true, + } as Parameters[1]; + const omitted = buildCoreFields({}, current) as Record; + + expect(defaults.resolveSessionHostnames).toBe(false); + expect(enabled.resolveSessionHostnames).toBe(true); + expect(omitted.resolveSessionHostnames).toBe(true); + }); +}); + describe("buildCoreFields", () => { it("normalizes the simplified metrics preference and defaults old rows to detailed", () => { expect(parseSystemMetricsDisplay({ show_in_topbar: true, simplified: true } as never)).toEqual({ diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 7ba77bfb76..fbbc0bab47 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -92,6 +92,7 @@ export function createDefaultUserSettings(): UserSettingsState { lastSeenDisplay: "absolute", systemMetricsDisplay: { showInTopbar: false, simplified: false }, appStatusBarEnabled: false, + resolveSessionHostnames: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, quickChatTabOrderByWorkspace: {}, hiddenWorkflowStepIds: {}, @@ -358,6 +359,7 @@ export function buildCoreFields( appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled, quickChatTabOrderByWorkspace: s.quick_chat_tab_order_by_workspace ?? current.quickChatTabOrderByWorkspace, + resolveSessionHostnames: s.resolve_session_hostnames ?? current.resolveSessionHostnames, hiddenWorkflowStepIds: s.kanban_hidden_step_ids ?? current.hiddenWorkflowStepIds, workflowIdsWithAutoHideEmptySteps: s.workflow_ids_with_auto_hide_empty_steps ?? current.workflowIdsWithAutoHideEmptySteps, diff --git a/apps/web/lib/state/app-state-types.ts b/apps/web/lib/state/app-state-types.ts index b549f53ed3..52767261b6 100644 --- a/apps/web/lib/state/app-state-types.ts +++ b/apps/web/lib/state/app-state-types.ts @@ -194,6 +194,8 @@ export type AppState = KanbanSlice & { // Auth slice (actions merged via AuthSliceActions intersection on AppState) auth: (typeof defaultAuthState)["auth"]; + sessionHostnames: (typeof defaultAuthState)["sessionHostnames"]; + sessionHostnamesEpoch: (typeof defaultAuthState)["sessionHostnamesEpoch"]; // Automations slice automations: (typeof defaultAutomationsState)["automations"]; diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index c1dc48c56e..f21027425c 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -111,6 +111,8 @@ export const defaultState = { office: defaultOfficeState.office, features: defaultFeaturesState.features, auth: defaultAuthState.auth, + sessionHostnames: defaultAuthState.sessionHostnames, + sessionHostnamesEpoch: defaultAuthState.sessionHostnamesEpoch, automations: defaultAutomationsState.automations, automationRuns: defaultAutomationsState.automationRuns, system: defaultSystemState.system, @@ -428,11 +430,7 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState { turns: mergeTurnsState(defaultState.turns, initialState.turns, initialState.taskSessions), ...mergeTaskSessionState(initialState), sessionAgentctl: { ...defaultState.sessionAgentctl, ...initialState.sessionAgentctl }, - worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, - sessionWorktreesBySessionId: { - ...defaultState.sessionWorktreesBySessionId, - ...initialState.sessionWorktreesBySessionId, - }, + ...mergeWorktreeState(initialState), pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, taskPlans: { ...defaultState.taskPlans, ...initialState.taskPlans }, @@ -480,6 +478,7 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState { office: { ...defaultState.office, ...initialState.office }, features: { ...defaultState.features, ...initialState.features }, auth: { ...defaultState.auth, ...initialState.auth }, + ...mergeSessionHostnamesState(initialState), automations: { ...defaultState.automations, ...initialState.automations }, automationRuns: { ...defaultState.automationRuns, ...initialState.automationRuns }, system: { ...defaultState.system, ...initialState.system }, @@ -487,6 +486,24 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState { }; } +/** Merge the worktree state fields (worktrees, session worktrees) from hydration state over defaults. */ +function mergeWorktreeState(initialState: HydrationState) { + return { + worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, + sessionWorktreesBySessionId: { + ...defaultState.sessionWorktreesBySessionId, + ...initialState.sessionWorktreesBySessionId, + }, + }; +} + +/** Merge the session-hostname resolution fields from hydration state over defaults. */ +function mergeSessionHostnamesState(initialState: HydrationState) { + return { + sessionHostnames: { ...defaultState.sessionHostnames, ...initialState.sessionHostnames }, + sessionHostnamesEpoch: initialState.sessionHostnamesEpoch ?? defaultState.sessionHostnamesEpoch, + }; +} function mergeWorkspaceContextGeneration(initialState: HydrationState) { return initialState.workspaceContextGeneration ?? defaultState.workspaceContextGeneration; } diff --git a/apps/web/lib/state/slices/auth/auth-slice.test.ts b/apps/web/lib/state/slices/auth/auth-slice.test.ts index 1c8e2f4f05..ebff930aef 100644 --- a/apps/web/lib/state/slices/auth/auth-slice.test.ts +++ b/apps/web/lib/state/slices/auth/auth-slice.test.ts @@ -60,4 +60,35 @@ describe("auth slice", () => { // mode is left untouched — clearAuthenticated only reflects session expiry. expect(store.getState().auth.mode).toBe("enabled"); }); + + it("keeps newer hostname entries and clears them across identities", () => { + const store = makeStore(); + store + .getState() + .setSessionHostname("192.0.2.10", "old.example.test", "2026-08-16T12:00:00.000000001Z"); + store + .getState() + .setSessionHostname("192.0.2.10", "new.example.test", "2026-08-16T12:00:00.000000002Z"); + store + .getState() + .setSessionHostname("192.0.2.10", "late.example.test", "2026-08-16T12:00:00.000000001Z"); + expect(store.getState().sessionHostnames["192.0.2.10"]).toEqual({ + hostname: "new.example.test", + resolvedAt: "2026-08-16T12:00:00.000000002Z", + }); + const epoch = store.getState().sessionHostnamesEpoch; + store.getState().setAuthState({ + mode: "enabled", + authenticated: true, + user: { + id: "user-2", + email: "user@example.test", + display_name: "User", + role: "member", + status: "active", + }, + }); + expect(store.getState().sessionHostnames).toEqual({}); + expect(store.getState().sessionHostnamesEpoch).toBe(epoch + 1); + }); }); diff --git a/apps/web/lib/state/slices/auth/auth-slice.ts b/apps/web/lib/state/slices/auth/auth-slice.ts index 51d75373f6..18783b2351 100644 --- a/apps/web/lib/state/slices/auth/auth-slice.ts +++ b/apps/web/lib/state/slices/auth/auth-slice.ts @@ -1,6 +1,7 @@ import type { StateCreator } from "zustand"; import type { AuthSlice, AuthSliceState } from "./types"; +import type { SessionHostnameEntry } from "./types"; export const defaultAuthState: AuthSliceState = { // IMPORTANT: authenticated defaults to true (with mode "disabled") so that // deployments/tests whose boot payload has no auth block at all (older @@ -12,6 +13,8 @@ export const defaultAuthState: AuthSliceState = { user: null, ssoProviders: [], }, + sessionHostnames: {}, + sessionHostnamesEpoch: 0, }; type ImmerSet = Parameters>[0]; @@ -19,15 +22,41 @@ type ImmerSet = Parameters current.resolvedAt; + } + return current.hostname === "" && incoming.hostname !== ""; +} + export const createAuthSlice = (set: ImmerSet): AuthSlice => ({ ...defaultAuthState, setAuthState: (state) => set((draft) => { + if (draft.auth.user?.id !== state.user?.id) { + draft.sessionHostnames = {}; + draft.sessionHostnamesEpoch += 1; + } draft.auth = state; }), clearAuthenticated: () => set((draft) => { draft.auth.authenticated = false; draft.auth.user = null; + draft.sessionHostnames = {}; + draft.sessionHostnamesEpoch += 1; + }), + setSessionHostname: (ip, hostname, resolvedAt) => + set((draft) => { + const incoming = { hostname, resolvedAt }; + const current = draft.sessionHostnames[ip]; + if (!current || incomingHostnameWins(current, incoming)) { + draft.sessionHostnames[ip] = incoming; + } }), }); diff --git a/apps/web/lib/state/slices/auth/types.ts b/apps/web/lib/state/slices/auth/types.ts index abe92d547d..6c2344b4d4 100644 --- a/apps/web/lib/state/slices/auth/types.ts +++ b/apps/web/lib/state/slices/auth/types.ts @@ -25,15 +25,21 @@ export type SsoProvider = { initiateUrl: string; }; +export type SessionHostnameEntry = { + hostname: string; + resolvedAt: string | null; +}; export type AuthSliceState = { auth: { mode: AuthMode; authenticated: boolean; user: AuthUser | null; // External-login options for the login screen. Optional: GET - // /api/v1/auth/me omits it, only the anonymous boot payload carries it. + // /api/auth/me omits it, only the anonymous boot payload carries it. ssoProviders?: SsoProvider[]; }; + sessionHostnames: Record; + sessionHostnamesEpoch: number; }; export type AuthSliceActions = { @@ -43,6 +49,7 @@ export type AuthSliceActions = { /** Mark the session unauthenticated without touching mode. * Used by the 401 handler when a session expires mid-use. */ clearAuthenticated: () => void; + setSessionHostname: (ip: string, hostname: string, resolvedAt: string | null) => void; }; export type AuthSlice = AuthSliceState & AuthSliceActions; diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index a953cb8f57..b32712a628 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -451,6 +451,7 @@ export type UserSettingsState = { lastSeenDisplay: LastSeenDisplay; systemMetricsDisplay: { showInTopbar: boolean; simplified: boolean }; appStatusBarEnabled: boolean; + resolveSessionHostnames: boolean; appStatusBarOrder: AppStatusBarOrderState; quickChatTabOrderByWorkspace: Record; hiddenWorkflowStepIds: Record; diff --git a/apps/web/lib/state/store-overrides.ts b/apps/web/lib/state/store-overrides.ts index 1be4e2db92..0b2f6f9b52 100644 --- a/apps/web/lib/state/store-overrides.ts +++ b/apps/web/lib/state/store-overrides.ts @@ -78,6 +78,8 @@ export function buildStateOverrides(m: DefaultState) { office: m.office, features: m.features, auth: m.auth, + sessionHostnames: m.sessionHostnames, + sessionHostnamesEpoch: m.sessionHostnamesEpoch, automations: m.automations, automationRuns: m.automationRuns, system: m.system, diff --git a/apps/web/lib/test-support/happy-dom-network.test.ts b/apps/web/lib/test-support/happy-dom-network.test.ts new file mode 100644 index 0000000000..735e841491 --- /dev/null +++ b/apps/web/lib/test-support/happy-dom-network.test.ts @@ -0,0 +1,9 @@ +import type { Window as HappyDOMWindow } from "happy-dom"; +import { expect, it } from "vitest"; + +it("disables external stylesheet loads without dispatching an error", () => { + const settings = (window as unknown as HappyDOMWindow).happyDOM.settings; + + expect(settings.disableCSSFileLoading).toBe(true); + expect(settings.handleDisabledFileLoadingAsSuccess).toBe(true); +}); diff --git a/apps/web/lib/test-support/noop-websocket.test.ts b/apps/web/lib/test-support/noop-websocket.test.ts new file mode 100644 index 0000000000..47092aa4e3 --- /dev/null +++ b/apps/web/lib/test-support/noop-websocket.test.ts @@ -0,0 +1,13 @@ +import { expect, it, vi } from "vitest"; + +import { NoopWebSocket } from "./noop-websocket"; + +it("restores the inert WebSocket after global stubs are cleared", () => { + expect(globalThis.WebSocket).toBe(NoopWebSocket); + + vi.stubGlobal("WebSocket", class {}); + vi.unstubAllGlobals(); + + expect(globalThis.WebSocket).toBe(NoopWebSocket); + expect(window.WebSocket).toBe(NoopWebSocket); +}); diff --git a/apps/web/lib/test-support/noop-websocket.ts b/apps/web/lib/test-support/noop-websocket.ts new file mode 100644 index 0000000000..48b467bdc9 --- /dev/null +++ b/apps/web/lib/test-support/noop-websocket.ts @@ -0,0 +1,12 @@ +export class NoopWebSocket { + onclose: ((event: CloseEvent) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onopen: (() => void) | null = null; + + close() { + this.onclose?.({ code: 1000 } as CloseEvent); + } + + send() {} +} diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index b10f0eb165..d97e51a8bd 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -369,6 +369,12 @@ export type UserSettingsUpdatedPayload = Omit< repository_ids: string[]; }; +export type SessionHostnameResolvedPayload = { + ip: string; + hostname: string; + resolved_at: string | null; +}; + // Session runtime payload types (extracted to reduce file size) export { type AuthMethodInfoPayload, @@ -488,6 +494,10 @@ export type BackendMessageMap = SessionBackendMessageMap & "user.agent_profile_recent_use.updated", AgentProfileRecentUseApiRecord >; + "auth.session.hostname.resolved": BackendMessage< + "auth.session.hostname.resolved", + SessionHostnameResolvedPayload + >; "secrets.created": BackendMessage<"secrets.created", SecretListItem>; "secrets.updated": BackendMessage<"secrets.updated", SecretListItem>; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index feaa59c744..f79886ade2 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -150,6 +150,7 @@ export type UserSettings = { last_seen_display?: LastSeenDisplay; system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; app_status_bar_enabled?: boolean; + resolve_session_hostnames?: boolean; app_status_bar_order?: AppStatusBarOrderApi; quick_chat_tab_order_by_workspace?: Record; kanban_hidden_step_ids?: Record; @@ -220,6 +221,7 @@ export type UserSettingsUpdatePayload = { last_seen_display?: LastSeenDisplay; system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; app_status_bar_enabled?: boolean; + resolve_session_hostnames?: boolean; app_status_bar_order?: AppStatusBarOrderApi; quick_chat_tab_order_by_workspace?: Record; kanban_hidden_step_ids?: Record; diff --git a/apps/web/lib/ws/connection.test.tsx b/apps/web/lib/ws/connection.test.tsx new file mode 100644 index 0000000000..c6ea9600a7 --- /dev/null +++ b/apps/web/lib/ws/connection.test.tsx @@ -0,0 +1,22 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { setWebSocketClient, useWebSocketClient } from "./connection"; +import type { WebSocketClient } from "./client"; + +describe("useWebSocketClient", () => { + afterEach(() => { + setWebSocketClient(null); + }); + + it("rerenders mounted consumers when the active client changes", () => { + const first = {} as WebSocketClient; + const second = {} as WebSocketClient; + const { result } = renderHook(() => useWebSocketClient()); + + act(() => setWebSocketClient(first)); + expect(result.current).toBe(first); + + act(() => setWebSocketClient(second)); + expect(result.current).toBe(second); + }); +}); diff --git a/apps/web/lib/ws/connection.ts b/apps/web/lib/ws/connection.ts index 533716b41a..c9493425d5 100644 --- a/apps/web/lib/ws/connection.ts +++ b/apps/web/lib/ws/connection.ts @@ -1,11 +1,23 @@ +import { useSyncExternalStore } from "react"; import type { WebSocketClient } from "@/lib/ws/client"; let activeClient: WebSocketClient | null = null; +const listeners = new Set<() => void>(); export function setWebSocketClient(client: WebSocketClient | null) { activeClient = client; + for (const listener of listeners) listener(); } export function getWebSocketClient() { return activeClient; } + +export function subscribeWebSocketClient(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useWebSocketClient() { + return useSyncExternalStore(subscribeWebSocketClient, getWebSocketClient, () => null); +} diff --git a/apps/web/lib/ws/handlers/session-hostnames.test.ts b/apps/web/lib/ws/handlers/session-hostnames.test.ts new file mode 100644 index 0000000000..0cf4bff2b3 --- /dev/null +++ b/apps/web/lib/ws/handlers/session-hostnames.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { createAppStore } from "@/lib/state/store"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import { registerSessionHostnamesHandlers } from "./session-hostnames"; + +function makeStore() { + return createAppStore(); +} + +function hostnameMessage(hostname: string): BackendMessageMap["auth.session.hostname.resolved"] { + return { + type: "notification", + action: "auth.session.hostname.resolved", + payload: { + ip: "192.0.2.10", + hostname, + resolved_at: "2026-08-18T12:00:00.000000001Z", + }, + }; +} + +describe("session hostname websocket handler", () => { + // Reviewer-requested contract coverage for the identity epoch fence. + it("rejects an old identity handler and accepts the replacement handler", () => { + const store = makeStore(); + const oldHandler = registerSessionHostnamesHandlers(store)["auth.session.hostname.resolved"]; + + store.getState().setAuthState({ + mode: "enabled", + authenticated: true, + user: { + id: "user-2", + email: "user@example.test", + display_name: "User", + role: "member", + status: "active", + }, + }); + oldHandler?.(hostnameMessage("old-user.example.test")); + expect(store.getState().sessionHostnames).toEqual({}); + + const currentHandler = + registerSessionHostnamesHandlers(store)["auth.session.hostname.resolved"]; + currentHandler?.(hostnameMessage("current-user.example.test")); + expect(store.getState().sessionHostnames["192.0.2.10"]).toEqual({ + hostname: "current-user.example.test", + resolvedAt: "2026-08-18T12:00:00.000000001Z", + }); + }); +}); diff --git a/apps/web/lib/ws/handlers/session-hostnames.ts b/apps/web/lib/ws/handlers/session-hostnames.ts new file mode 100644 index 0000000000..fcd8498624 --- /dev/null +++ b/apps/web/lib/ws/handlers/session-hostnames.ts @@ -0,0 +1,19 @@ +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { WsHandlers } from "@/lib/ws/handlers/types"; + +export function registerSessionHostnamesHandlers(store: StoreApi): WsHandlers { + const epoch = store.getState().sessionHostnamesEpoch; + return { + "auth.session.hostname.resolved": (message) => { + if (store.getState().sessionHostnamesEpoch !== epoch) return; + store + .getState() + .setSessionHostname( + message.payload.ip, + message.payload.hostname, + message.payload.resolved_at, + ); + }, + }; +} diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 8d02d282b2..1b9e375a87 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -163,6 +163,23 @@ describe("status bar visibility websocket sync", () => { }); }); +describe("session hostname resolution websocket sync", () => { + it("updates the setting and preserves it when omitted", () => { + const store = makeStore(); + const handler = registerUsersHandlers(store)["user.settings.updated"]; + + handler?.( + userSettingsMessage({ + resolve_session_hostnames: true, + }), + ); + expect(store.getState().userSettings.resolveSessionHostnames).toBe(true); + + handler?.(userSettingsMessage({})); + expect(store.getState().userSettings.resolveSessionHostnames).toBe(true); + }); +}); + describe("last seen display websocket sync", () => { it("applies a relative value and normalizes unknown values", () => { const store = makeStore(); diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts index 4ec1fe4389..6e14f2bf3d 100644 --- a/apps/web/lib/ws/router.ts +++ b/apps/web/lib/ws/router.ts @@ -31,6 +31,7 @@ import { registerTerminalsHandlers } from "@/lib/ws/handlers/terminals"; import { registerTurnsHandlers } from "@/lib/ws/handlers/turns"; import { registerSecretsHandlers } from "@/lib/ws/handlers/secrets"; import { registerUsersHandlers } from "@/lib/ws/handlers/users"; +import { registerSessionHostnamesHandlers } from "@/lib/ws/handlers/session-hostnames"; import { registerWorkspacesHandlers } from "@/lib/ws/handlers/workspaces"; import { registerRepositorySetsHandlers } from "@/lib/ws/handlers/repository-sets"; import { registerRepositoryBranchPoliciesHandlers } from "@/lib/ws/handlers/repository-branch-policies"; @@ -68,6 +69,7 @@ export function registerWsHandlers(store: StoreApi) { ...registerSessionTodosHandlers(store), ...registerPromptUsageHandlers(store), ...registerUsersHandlers(store), + ...registerSessionHostnamesHandlers(store), ...registerTerminalsHandlers(store), ...registerDiffsHandlers(store), ...messages.handlers, diff --git a/apps/web/lib/ws/use-websocket.test.tsx b/apps/web/lib/ws/use-websocket.test.tsx index eb63cc3cd0..3ee164f916 100644 --- a/apps/web/lib/ws/use-websocket.test.tsx +++ b/apps/web/lib/ws/use-websocket.test.tsx @@ -5,19 +5,24 @@ import { createAppStore } from "@/lib/state/store"; import { useWebSocket } from "./use-websocket"; const mocks = vi.hoisted(() => ({ + clients: [] as Array<{ + disconnect: ReturnType; + subscribeUser: ReturnType; + }>, onStatusChange: null as ((status: ConnectionStatus) => void) | null, })); vi.mock("@/lib/ws/client", () => ({ WebSocketClient: class { - constructor(_url: string, onStatusChange: (status: ConnectionStatus) => void) { - mocks.onStatusChange = onStatusChange; - } - - connect = vi.fn(); disconnect = vi.fn(); + connect = vi.fn(); subscribeUser = vi.fn(); on = vi.fn(() => vi.fn()); + + constructor(_url: string, onStatusChange: (status: ConnectionStatus) => void) { + mocks.onStatusChange = onStatusChange; + mocks.clients.push(this); + } }, })); @@ -29,6 +34,7 @@ vi.mock("@/lib/debug/log", () => ({ createDebugLogger: () => vi.fn() })); describe("useWebSocket", () => { afterEach(() => { + mocks.clients = []; mocks.onStatusChange = null; vi.useRealTimers(); }); @@ -70,4 +76,41 @@ describe("useWebSocket", () => { expect(store.getState().connection.issueSeverity).toBe("none"); unmount(); }); + + it("reconnects when the authenticated identity changes", () => { + const store = createAppStore(); + const { unmount } = renderHook(() => useWebSocket(store, "ws://kandev.test/ws")); + + act(() => { + store.getState().setAuthState({ + mode: "enabled", + authenticated: true, + user: { + id: "user-1", + email: "user@example.test", + display_name: "User", + role: "member", + status: "active", + }, + }); + }); + + expect(mocks.clients).toHaveLength(2); + expect(mocks.clients[0].disconnect).toHaveBeenCalledOnce(); + unmount(); + }); + + it("registers the user subscription once per client", () => { + const store = createAppStore(); + const { unmount } = renderHook(() => useWebSocket(store, "ws://kandev.test/ws")); + const client = mocks.clients[0]; + + act(() => { + mocks.onStatusChange?.("connected"); + mocks.onStatusChange?.("connected"); + }); + + expect(client.subscribeUser).toHaveBeenCalledOnce(); + unmount(); + }); }); diff --git a/apps/web/lib/ws/use-websocket.tsx b/apps/web/lib/ws/use-websocket.tsx index e5d88b4af8..6823962a96 100644 --- a/apps/web/lib/ws/use-websocket.tsx +++ b/apps/web/lib/ws/use-websocket.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import type { StoreApi } from "zustand"; import { WebSocketClient } from "@/lib/ws/client"; import { registerWsHandlers } from "@/lib/ws/router"; @@ -12,25 +12,27 @@ import { ConnectionIssueMonitor } from "@/lib/ws/connection-issue-monitor"; const debug = createDebugLogger("ws:connection"); export function useWebSocket(store: StoreApi, url: string) { + const userID = useSyncExternalStore( + store.subscribe, + () => store.getState().auth.user?.id ?? null, + () => null, + ); const clientRef = useRef(null); useEffect(() => { - debug("WS hook mounting", { url }); + let active = true; const connectionIssueMonitor = new ConnectionIssueMonitor((severity) => { store.getState().setConnectionIssueSeverity(severity); }); const client = new WebSocketClient( url, (status) => { + if (!active) return; connectionIssueMonitor.onStatusChange(status); const setConnectionStatus = store.getState().setConnectionStatus; debug("status transition", { status, timestamp: new Date().toISOString() }); // WS client and ConnectionState share one ConnectionStatus vocabulary, - // so this is a 1:1 forward with an `error` message attached and a - // `subscribeUser()` side-effect on first connect. - if (status === "connected") { - client.subscribeUser(); - } + // so this is a 1:1 forward with an `error` message attached. // i18n-exempt: transport diagnostic interpolated into the translated // `sidebar:connectionErrorDetail` frame; see connection-status-item.tsx. setConnectionStatus(status, status === "error" ? "WebSocket connection failed" : null); @@ -43,23 +45,30 @@ export function useWebSocket(store: StoreApi, url: string) { backoffMultiplier: 1.5, }, ); + client.subscribeUser(); clientRef.current = client; client.connect(); setWebSocketClient(client); const registration = registerWsHandlers(store); const unsubscribers = Object.entries(registration.handlers).map(([type, handler]) => - client.on(type as keyof typeof registration.handlers, handler as never), + client.on( + type as keyof typeof registration.handlers, + ((payload: never) => { + if (active) (handler as (value: never) => void)(payload); + }) as never, + ), ); return () => { + active = false; unsubscribers.forEach((unsubscribe) => unsubscribe()); registration.dispose(); connectionIssueMonitor.dispose(); client.disconnect(); setWebSocketClient(null); }; - }, [store, url]); + }, [store, url, userID]); return clientRef; } diff --git a/apps/web/src/locales/en/account.json b/apps/web/src/locales/en/account.json index 70650bc2ec..cb3a84ffe2 100644 --- a/apps/web/src/locales/en/account.json +++ b/apps/web/src/locales/en/account.json @@ -7,6 +7,7 @@ "cancel": "Cancel", "changePassword": "Change password", "couldNotChangePassword": "Could not change password.", + "couldNotUpdateHostnameResolution": "Could not update hostname resolution.", "couldNotCreateToken": "Could not create token.", "createApiToken": "Create API token", "createApiTokenDescription": "Grants the same access as your account to any script or CLI that uses it. Give it a name so you can recognize it later.", @@ -21,6 +22,8 @@ "failedToSaveLastSeenDisplay": "Failed to save the last seen display.", "failedToRevokeSession": "Failed to revoke the session.", "ipAddress": "IP", + "hostname": "Hostname", + "hostnameNotAvailable": "N/A", "lastSeen": "Last seen", "lastSeenDisplay": "Last seen display", "lastSeenDisplayDescription": "Choose how session activity times are shown.", @@ -39,6 +42,8 @@ "saving": "Saving...", "securityPageDescription": "Change your password and review devices signed in to your account.", "securityPageTitle": "Profile & password", + "resolveDeviceHostnames": "Resolve device hostnames", + "resolveDeviceHostnamesDescription": "Uses the system DNS resolver for signed-in device IP addresses.", "signOut": "Sign out", "thisDevice": "This device", "tokenCreated": "Token created", diff --git a/apps/web/src/locales/pseudo/account.json b/apps/web/src/locales/pseudo/account.json index a134a8f8d2..e04df2ac9f 100644 --- a/apps/web/src/locales/pseudo/account.json +++ b/apps/web/src/locales/pseudo/account.json @@ -7,6 +7,7 @@ "cancel": "Ćàńćēĺ", "changePassword": "Ćĥàńĝē ƥàśśŵōŕď", "couldNotChangePassword": "Ćōũĺď ńōţ ćĥàńĝē ƥàśśŵōŕď.", + "couldNotUpdateHostnameResolution": "Ćōũĺď ńōţ ũƥďàţē ĥōśţńàḿē ŕēśōĺũţĩōń.", "couldNotCreateToken": "Ćōũĺď ńōţ ćŕēàţē ţōķēń.", "createApiToken": "Ćŕēàţē ÀƤĨ ţōķēń", "createApiTokenDescription": "Ĝŕàńţś ţĥē śàḿē àććēśś àś ŷōũŕ àććōũńţ ţō àńŷ śćŕĩƥţ ōŕ ĆĹĨ ţĥàţ ũśēś ĩţ. Ĝĩvē ĩţ à ńàḿē śō ŷōũ ćàń ŕēćōĝńĩźē ĩţ ĺàţēŕ.", @@ -21,6 +22,8 @@ "failedToSaveLastSeenDisplay": "Ƒàĩĺēď ţō śàvē ţĥē ĺàśţ śēēń ďĩśƥĺàŷ.", "failedToRevokeSession": "Ƒàĩĺēď ţō ŕēvōķē ţĥē śēśśĩōń.", "ipAddress": "ĨƤ", + "hostname": "Ĥōśţńàḿē", + "hostnameNotAvailable": "Ń/À", "lastSeen": "Ĺàśţ śēēń", "lastSeenDisplay": "Ĺàśţ śēēń ďĩśƥĺàŷ", "lastSeenDisplayDescription": "Ćĥōōśē ĥōŵ śēśśĩōń àćţĩvĩţŷ ţĩḿēś àŕē śĥōŵń.", @@ -39,6 +42,8 @@ "saving": "Śàvĩńĝ...", "securityPageDescription": "Ćĥàńĝē ŷōũŕ ƥàśśŵōŕď àńď ŕēvĩēŵ ďēvĩćēś śĩĝńēď ĩń ţō ŷōũŕ àććōũńţ.", "securityPageTitle": "Ƥŕōƒĩĺē & ƥàśśŵōŕď", + "resolveDeviceHostnames": "Ŕēśōĺvē ďēvĩćē ĥōśţńàḿēś", + "resolveDeviceHostnamesDescription": "Ũśēś ţĥē śŷśţēḿ ĎŃŚ ŕēśōĺvēŕ ƒōŕ śĩĝńēď-ĩń ďēvĩćē ĨƤ àďďŕēśśēś.", "signOut": "Śĩĝń ōũţ", "thisDevice": "Ţĥĩś ďēvĩćē", "tokenCreated": "Ţōķēń ćŕēàţēď", diff --git a/apps/web/src/locales/pt-pt/account.json b/apps/web/src/locales/pt-pt/account.json index 9bf00977fe..91a912cadb 100644 --- a/apps/web/src/locales/pt-pt/account.json +++ b/apps/web/src/locales/pt-pt/account.json @@ -7,6 +7,7 @@ "cancel": "Cancelar", "changePassword": "Alterar palavra-passe", "couldNotChangePassword": "Não foi possível alterar a palavra-passe.", + "couldNotUpdateHostnameResolution": "Não foi possível atualizar a resolução de nomes de anfitrião.", "couldNotCreateToken": "Não foi possível criar o token.", "createApiToken": "Criar token de API", "createApiTokenDescription": "Concede a qualquer script ou CLI que o utilize o mesmo acesso da sua conta. Atribua-lhe um nome para o reconhecer mais tarde.", @@ -21,6 +22,8 @@ "failedToSaveLastSeenDisplay": "Não foi possível guardar a forma de apresentação da última atividade.", "failedToRevokeSession": "Não foi possível revogar a sessão.", "ipAddress": "IP", + "hostname": "Nome do anfitrião", + "hostnameNotAvailable": "N/D", "lastSeen": "Visto pela última vez", "lastSeenDisplay": "Apresentação da última atividade", "lastSeenDisplayDescription": "Escolha como as horas de atividade das sessões são apresentadas.", @@ -39,6 +42,8 @@ "saving": "A guardar...", "securityPageDescription": "Altere a sua palavra-passe e reveja os dispositivos com sessão iniciada na sua conta.", "securityPageTitle": "Perfil e palavra-passe", + "resolveDeviceHostnames": "Resolver nomes de anfitrião dos dispositivos", + "resolveDeviceHostnamesDescription": "Utiliza o resolvedor DNS do sistema para os endereços IP dos dispositivos com sessão iniciada.", "signOut": "Terminar sessão", "thisDevice": "Este dispositivo", "tokenCreated": "Token criado", diff --git a/apps/web/src/locales/zh-cn/account.json b/apps/web/src/locales/zh-cn/account.json index 67bc46540a..8c5091b572 100644 --- a/apps/web/src/locales/zh-cn/account.json +++ b/apps/web/src/locales/zh-cn/account.json @@ -7,6 +7,7 @@ "cancel": "取消", "changePassword": "修改密码", "couldNotChangePassword": "无法修改密码。", + "couldNotUpdateHostnameResolution": "无法更新主机名解析。", "couldNotCreateToken": "无法创建令牌。", "createApiToken": "创建 API 令牌", "createApiTokenDescription": "授予与你的账户相同的访问权限,供脚本或 CLI 使用。请为其命名,便于日后识别。", @@ -21,6 +22,8 @@ "failedToSaveLastSeenDisplay": "无法保存上次活动的显示方式。", "failedToRevokeSession": "无法吊销会话。", "ipAddress": "IP", + "hostname": "主机名", + "hostnameNotAvailable": "不适用", "lastSeen": "上次活动", "lastSeenDisplay": "上次活动显示", "lastSeenDisplayDescription": "选择会话活动时间的显示方式。", @@ -39,6 +42,8 @@ "saving": "正在保存…", "securityPageDescription": "修改密码并查看已登录你账户的设备。", "securityPageTitle": "个人资料与密码", + "resolveDeviceHostnames": "解析设备主机名", + "resolveDeviceHostnamesDescription": "使用系统 DNS 解析器解析已登录设备的 IP 地址。", "signOut": "退出登录", "thisDevice": "此设备", "tokenCreated": "令牌已创建", diff --git a/apps/web/src/locales/zh-hk/account.json b/apps/web/src/locales/zh-hk/account.json index 8f73830775..e8ca3578c5 100644 --- a/apps/web/src/locales/zh-hk/account.json +++ b/apps/web/src/locales/zh-hk/account.json @@ -7,6 +7,7 @@ "cancel": "取消", "changePassword": "修改密碼", "couldNotChangePassword": "無法修改密碼。", + "couldNotUpdateHostnameResolution": "無法更新主機名稱解析。", "couldNotCreateToken": "無法建立權杖。", "createApiToken": "建立 API 權杖", "createApiTokenDescription": "授予與你的帳戶相同的存取權限,供腳本或 CLI 使用。請為其命名,便於日後識別。", @@ -21,6 +22,8 @@ "failedToSaveLastSeenDisplay": "無法儲存上次活動的顯示方式。", "failedToRevokeSession": "無法吊銷工作階段。", "ipAddress": "IP", + "hostname": "主機名稱", + "hostnameNotAvailable": "不適用", "lastSeen": "上次活動", "lastSeenDisplay": "上次活動顯示", "lastSeenDisplayDescription": "選擇工作階段活動時間的顯示方式。", @@ -39,6 +42,8 @@ "saving": "正在儲存…", "securityPageDescription": "修改密碼並查看已登入你帳戶的設備。", "securityPageTitle": "個人數據與密碼", + "resolveDeviceHostnames": "解析裝置主機名稱", + "resolveDeviceHostnamesDescription": "使用系統 DNS 解析器解析已登入裝置的 IP 位址。", "signOut": "登出", "thisDevice": "此設備", "tokenCreated": "權杖已建立", diff --git a/apps/web/src/locales/zh-tw/account.json b/apps/web/src/locales/zh-tw/account.json index 4e4b25aadf..e199c0446f 100644 --- a/apps/web/src/locales/zh-tw/account.json +++ b/apps/web/src/locales/zh-tw/account.json @@ -7,6 +7,7 @@ "cancel": "取消", "changePassword": "修改密碼", "couldNotChangePassword": "無法修改密碼。", + "couldNotUpdateHostnameResolution": "無法更新主機名稱解析。", "couldNotCreateToken": "無法建立權杖。", "createApiToken": "建立 API 權杖", "createApiTokenDescription": "授予與你的帳戶相同的存取權限,供指令碼或 CLI 使用。請為其命名,便於日後識別。", @@ -21,6 +22,8 @@ "failedToSaveLastSeenDisplay": "無法儲存上次活動的顯示方式。", "failedToRevokeSession": "無法吊銷工作階段。", "ipAddress": "IP", + "hostname": "主機名稱", + "hostnameNotAvailable": "不適用", "lastSeen": "上次活動", "lastSeenDisplay": "上次活動顯示", "lastSeenDisplayDescription": "選擇工作階段活動時間的顯示方式。", @@ -39,6 +42,8 @@ "saving": "正在儲存…", "securityPageDescription": "修改密碼並檢視已登入你帳戶的裝置。", "securityPageTitle": "個人資料與密碼", + "resolveDeviceHostnames": "解析裝置主機名稱", + "resolveDeviceHostnamesDescription": "使用系統 DNS 解析器解析已登入裝置的 IP 位址。", "signOut": "登出", "thisDevice": "此裝置", "tokenCreated": "權杖已建立", diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts index c422b36084..74537ad62d 100644 --- a/apps/web/vitest.setup.ts +++ b/apps/web/vitest.setup.ts @@ -1,7 +1,9 @@ import type { Window as HappyDOMWindow } from "happy-dom"; import * as React from "react"; +import { afterEach, beforeEach } from "vitest"; import { initI18nForTests, loadAllLocalesForTests } from "./lib/i18n"; +import { NoopWebSocket } from "./lib/test-support/noop-websocket"; // Guards against `react` production build or duplicate copy — both make `act` unavailable; // vitest.config.ts pins NODE_ENV=test for the common case. Namespace import is deliberate. @@ -37,6 +39,27 @@ if (typeof React.act !== "function") { initI18nForTests(); await loadAllLocalesForTests(); +const noNetworkWebSocket = NoopWebSocket as unknown as typeof WebSocket; + +// The fallback is installed as the pre-stub global rather than through +// `vi.stubGlobal()`. Many suites call `vi.unstubAllGlobals()` during teardown; +// restoring their WebSocket fake must return to this inert transport, not +// happy-dom's network-backed implementation. +Object.defineProperty(globalThis, "WebSocket", { + configurable: true, + writable: true, + value: noNetworkWebSocket, +}); +beforeEach(() => { + globalThis.WebSocket = noNetworkWebSocket; + if (typeof window !== "undefined") window.WebSocket = noNetworkWebSocket; +}); + +afterEach(() => { + globalThis.WebSocket = noNetworkWebSocket; + if (typeof window !== "undefined") window.WebSocket = noNetworkWebSocket; +}); + function createLocalStorageMock(): Storage { const store = new Map(); @@ -85,6 +108,8 @@ if (typeof window !== "undefined") { "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", }); + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; + happyDOMWindow.happyDOM.settings.handleDisabledFileLoadingAsSuccess = true; happyDOMWindow.happyDOM.settings.fetch.interceptor = { beforeAsyncRequest: ({ window: requestWindow }) => Promise.resolve(new requestWindow.Response(null, { status: 404 })), diff --git a/docs/decisions/2026-08-28-viewport-activation-owns-thread-streams.md b/docs/decisions/2026-08-28-viewport-activation-owns-thread-streams.md index 5cadc5cda1..82017e93e7 100644 --- a/docs/decisions/2026-08-28-viewport-activation-owns-thread-streams.md +++ b/docs/decisions/2026-08-28-viewport-activation-owns-thread-streams.md @@ -71,4 +71,3 @@ preload-entry list hydration repair missed compact events. - **Fully virtualize task shells now.** Rejected for this delivery because the expensive work is transcript and subscription fan-out. Stable shells are a smaller and simpler scroll contract. - diff --git a/docs/plans/session-hostname-resolution/plan.md b/docs/plans/session-hostname-resolution/plan.md new file mode 100644 index 0000000000..89e11146d8 --- /dev/null +++ b/docs/plans/session-hostname-resolution/plan.md @@ -0,0 +1,421 @@ +--- +spec: docs/specs/session-hostname-resolution/spec.md +created: 2026-08-16 +status: 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 state + `userSettings.resolveSessionHostnames`. Missing stored values and initial + payloads default to `false`; 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 `hostname` per session. +- New cache table `auth_hostname_cache`, owned by the hostnames cache store + (`internal/auth/hostnames/store.go`) whose `initSchema` creates it (new + table, so `CREATE TABLE IF NOT EXISTS` suffices; no `ADD COLUMN` + migration). +- Cache is global by IP; `hostname == ""` means no answer and is cached. + `recentResolveWindow = 30s` skips 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.resolved` + with payload `{user_id, ip, hostname, resolved_at}` (fixed 9-digit + RFC3339 UTC or null) — the `UserEventBroadcaster` routes by `user_id` and + strips it — and the WS action `auth.session.hostname.resolved` carries the + reduced `{ip, hostname, resolved_at}` to the client; published only when + the hostname value changed + versus the cache. Every successful lookup (valid or `IsNotFound`) + persists and refreshes `resolved_at` via `Set` even when the hostname is + unchanged, so `recentResolveWindow` engages for stable and no-answer rows. + Cache/job keys are canonical IPs; the event `ip` is 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 carries `hostname` and `hostname_resolved_at` + per 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 `sessionHostnames` sibling of `auth`** (never nested, never + addressed as `auth.sessionHostnames`; also wired through + `default-state.ts` and `store-overrides.ts` so 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 compares `resolved_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 in `setSessionHostname` (not a blind upsert) so a + delayed older event can never overwrite a newer map entry. There is + **no pending spinner**: empty values always render `N/A`. A first + absent → no-answer resolution IS a change and publishes (an `IsNotFound` + negative with a persisted `resolved_at`; a transient DNS error over an + absent cache with `resolved_at: null`); a subsequent same-value cached + no-answer only refreshes `resolved_at` via `Set` and publishes nothing, + so an unchanged settled no-answer row emits no further events and renders + `N/A` **until its freshness expires (`recentResolveWindow`) and a retry + replaces the empty with a valid answer, which publishes the changed + hostname**. A + `false -> true` setting 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.DNSError` with `IsNotFound` (NXDOMAIN / + no PTR, the common no-answer case) is a successful negative answer + persisted as `hostname = ""`; 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, `ErrNotFound` treated + 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, registers `Close` as + cleanup, queue-subscribes the resolver to the persisted + `UserSettingsUpdated` event, and threads the resolver to `registerRoutes` + via the `Services` struct (`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 and `Close` joins 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 `Cache` interface + (`GetMany`/`Get`/`Set` over `CacheEntry{Hostname string, ResolvedAt + *time.Time}`, absent rows absent from results, `ErrNotFound` treated as a + successful absent baseline) for deterministic error injection and + `recentResolveWindow`; timestamps are fixed 9-digit fractional-second + RFC3339 UTC text (lexicographically comparable, so same-millisecond + changes still order). A `Set` error publishes + **nothing** (changed-only semantics; the next trigger retries the persist, + and a later successful `Set` that changes the cache publishes); + `resolved_at: null` in 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 within + `recentResolveWindow` still 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 assert `resolved_at` (fixed-width + RFC3339 and null) survives both memory and NATS transports; a frontend + router integration test pins `registerSessionHostnamesHandlers` in + `registerWsHandlers`. + +## Backend + +### Task 01 — portable setting contract + +- `AppStatusBarEnabled`-style field `ResolveSessionHostnames bool` in + `internal/user/models/models.go`. +- Response + PATCH `*bool` in `internal/user/dto/dto.go`, controller mapping, + `applyBasicSettings` in `internal/user/service/service.go`, event payload + key `resolve_session_hostnames` in `publishUserSettingsEvent`. +- Store: default false, JSON marshal key, `*bool` scan decode, overwrite only + when present (`internal/user/store/sqlite.go`). +- Boot mapping `resolveSessionHostnames` in + `internal/backendapp/boot_state_routes.go`. +- Frontend: HTTP types (`http-user-settings.ts`), `UserSettingsState` field, + shared mapper in `lib/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.arpa` + suffixes); + - `resolver.go`: `Resolver` with `Start`/`Close` worker pool (goleak), + in-flight + interested-user dedup, `recentResolveWindow` guard, + `HostnamesForSessionIPs(ctx, userID, ips)` (enabled gate, cached values, + schedules lookups) and fire-and-forget + `TriggerUserSessionsResolved(userID)` (owned trigger queue, PATCH never + blocks); changed-only event publishing with `IsNotFound` persisting 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). +- `events` constant `AuthSessionHostnameResolved`. +- `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)`; `listSessions` + returns per-session `hostname` and schedules resolution. +- `user/service`: publish the complete persisted setting through the existing + `UserSettingsUpdated` event. +- `backendapp`: construct the resolver (store over `dbPool`, session-IPs = + `authSvc`, settings gate over `repos.User`, event bus, log), subscribe it to + setting updates, and pass it to `authhttpapi.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 + +- `AuthSession` type gains `hostname: string` and + `hostname_resolved_at: string | null` (fixed 9-digit timestamp or null). +- `BackendMessageMap` entry + `SessionHostnameResolvedPayload` (`{ip, + hostname, resolved_at}`); WS handler + `lib/ws/handlers/session-hostnames.ts` registered in `router.ts` updating + top-level `sessionHostnames`; router integration test pins the + registration. +- Auth slice: `sessionHostnames: Record` with + `SessionHostnameEntry = { hostname: string; resolvedAt: string | null }` + and `setSessionHostname(ip, hostname, resolvedAt)` (default empty). +- `security-settings.tsx`: toggle card (Switch, direct save via + `updateUserSettings`, `data-testid`), SessionsCard Hostname column gated on + the setting, cell resolution by `resolved_at` (newer wins, durable beats + transient, ties to non-empty, both empty → `N/A`; lexicographic string + comparison, never `Date.parse`). +- i18n: `account.json` keys (`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 the + `auth` project (backend restarted with `KANDEV_FEATURES_AUTH: true`). + **Setup is `setupAdmin(...)` only for the main flow** (`/auth/setup` + creates the admin session and sets the cookie, so the baseline is exactly + one row — do NOT call `login` before navigation, which would create a + second baseline row); the second `login(...)` is reserved for the + focus-refetch step. Reset the setting to `false` in `afterEach`: + 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, `bringToFront` page 1, row count grows by one). +- Mobile: `tests/auth/mobile-account-security-hostnames.spec.ts` (runs in the + `mobile-chrome` project; the auth project's `testIgnore` excludes + `mobile-*`) 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](task-01-portable-setting-contract.md) | pending | `resolve_session_hostnames` round trips backend→boot→frontend with default false. | +| 2 | [02 backend reverse-DNS service](task-02-backend-reverse-dns-service.md) | 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](task-03-frontend-streaming-and-ui.md) | pending | Toggle + Hostname column (empty always N/A, no spinner) + streamed updates + focus-refetch, desktop and mobile. | +| 4 | [04 E2E and final verification](task-04-e2e-and-final-verification.md) | 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: + +```sh +(cd apps && pnpm install --frozen-lockfile) +``` + +Each task runs its focused commands. Final verification: + +```sh +(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 `true` would 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 `hostname` field 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/A` + cells, 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 `recentResolveWindow` guard + 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 `sessionHostnames` map must be a top-level sibling of + `auth` (never nested, never `auth.sessionHostnames`), or `setAuthState` + (login/me refresh) erases it; `store.ts` must be updated with the slice + shape. +- A cache interface that cannot distinguish absent from cached-empty or + expose `ResolvedAt` makes no-answer caching and `recentResolveWindow` + unimplementable; the resolver's `Cache` returns `CacheEntry` and absent + rows are absent from `GetMany` results. +- Raw session-IP spellings must survive to the event payload (per user, per + spelling), or hook-triggered resolutions publish canonical `ip` values 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/A` forever (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 to `default-state.ts` and `store-overrides.ts` (slices + spread after the default state). +- E2E focus-refetch needs a second page in the same context that actually + navigates (the `login` helper never focuses a page), then page 1 + `bringToFront`; it must not be skippable. +- The common no-PTR case surfaces as `*net.DNSError` with `IsNotFound`, not + as records; failing to classify it means no-answer IPs are never cached + and re-queried on every trigger. Persist `IsNotFound` as 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 + by `resolved_at` (newer wins, durable beats transient), or a stale-cache + + transient-error + re-enable cycle pins rows to `N/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_at` and cells must compare it. +- `ErrNotFound` must 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 `Set` never refreshes + `resolved_at`, so `recentResolveWindow` never 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 `UserSettingsUpdated` subscription 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 `sessionHostnames` on identity change is not enough: a delayed + WS frame from the prior user (the gateway strips `user_id`) can + repopulate the cleared map. Fence the stream with a + `sessionHostnamesEpoch` handler 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` + / `useWebSocket` deps — a `store.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 (`onclose` + AND `onerror`) must be fenced (detached or generation-guarded) so a + delayed callback cannot overwrite the replacement's `connected` status. + Hook-owned subscriptions obtained via `getWebSocketClient()` (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 on `Set` error + 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 `hostnames` API + (`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_hostnames` while DTO/service/store tests pass; pin the + pointer mapping in `controller_test.go`. +- A forgotten production `Start(ctx)` passes every unit test (they call + Start themselves) and every E2E (which tolerates `N/A`); the backendapp + wiring smoke test is the only guard. +- A missing router registration (`registerSessionHostnamesHandlers`) passes + direct-handler tests and `N/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`/`RFC3339Nano` cannot 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_PATH` like the + mobile spec; the auth worker is serial/shared and `backend.restart` + preserves the DB, so leaked sessions/cache/admin state makes row-count + and setup assertions order-dependent. +- A synchronous hook or a naive goroutine in `UpdateUserSettings` blocks 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 `login` again + 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, then `bringToFront` page 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 the `auth` project; auth-mode + setup (`setupAdmin`/`login`) and per-test cleanup are required, and the + mobile spec must be a separate `mobile-*.spec.ts` file for the + `mobile-chrome` project. diff --git a/docs/plans/session-hostname-resolution/task-01-portable-setting-contract.md b/docs/plans/session-hostname-resolution/task-01-portable-setting-contract.md new file mode 100644 index 0000000000..dc75674461 --- /dev/null +++ b/docs/plans/session-hostname-resolution/task-01-portable-setting-contract.md @@ -0,0 +1,123 @@ +--- +id: session-hostname-resolution-01 +title: Portable resolve-session-hostnames setting contract +status: pending +wave: 1 +depends_on: [] +plan: docs/plans/session-hostname-resolution/plan.md +spec: docs/specs/session-hostname-resolution/spec.md +--- + +# Portable resolve-session-hostnames setting contract + +## Inputs + +The [session-hostname-resolution spec](../../specs/session-hostname-resolution/spec.md) +(User setting + Contracts sections) and the existing +`app_status_bar_enabled` user-settings round trip as the pattern. + +## TDD sequence + +1. Add failing backend tests: missing stored value and initial payload + default to `false`; explicit `true` survives store read → DTO response → + boot mapping → event payload; PATCH omission preserves the current value; + PATCH explicit `false` is durable. +2. Add failing frontend mapper/WS-handler tests: default `false`, explicit + `true`, omission preserving current state, revision-ordered snapshots. +3. Add the smallest model, DTO, controller, service, store, boot, HTTP type, + state, and mapper changes that make those tests pass. +4. Refactor only repeated mapping code exposed by the tests. No UI or lookup + behavior in this task. + +## Implementation + +- Add `ResolveSessionHostnames bool` to + `apps/backend/internal/user/models/models.go` (json + `resolve_session_hostnames`). +- Add the response field and pointer PATCH field to + `apps/backend/internal/user/dto/dto.go` (including `FromUserSettings`). +- Thread the PATCH pointer through + `apps/backend/internal/user/controller/controller.go` and + `apps/backend/internal/user/service/service.go` + (`UpdateUserSettingsRequest` + `applyBasicSettings`). +- Apply an explicit value only when the pointer is non-nil. Publish + `resolve_session_hostnames` in the complete `user.settings.updated` event + (`publishUserSettingsEvent`). +- In `apps/backend/internal/user/store/sqlite.go`: + - default `false` in `defaultUserSettings` (zero value; no entry needed); + - include `"resolve_session_hostnames"` in `marshalUserSettingsPayload`; + - decode it as `*bool` in the `scanUserSettings` payload struct and + overwrite the default only when present. +- Add `"resolveSessionHostnames": settings.ResolveSessionHostnames` to + `apps/backend/internal/backendapp/boot_state_routes.go` + (`mapUserSettingsState`). +- Add `resolve_session_hostnames?: boolean` to response and PATCH shapes in + `apps/web/lib/types/http-user-settings.ts`. +- Add `resolveSessionHostnames: boolean` to + `apps/web/lib/state/slices/settings/types.ts` (`UserSettingsState`). +- Initialize it to `false` in `createDefaultUserSettings()` and map it + through the shared path in `apps/web/lib/ssr/user-settings.ts`; the common + mapper must preserve the current value when the field is absent. +- **Controller mapping regression test (required):** the controller maps a + long request literal field-by-field; an omitted `ResolveSessionHostnames` + entry silently drops `{resolve_session_hostnames: true}` before the + service/store while DTO/service/store tests all pass. Add + `apps/backend/internal/user/controller/controller_test.go` coverage: + a PATCH request with the pointer field set reaches the service request + (and the response setting) as `true`, an omitted field stays nil (no + change), and an explicit `false` is preserved. +- The existing `user.settings.updated` WS handler already flows through the + shared mapper; do not add a second field-specific handler. + +## Files likely touched + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/dto/dto.go` +- `apps/backend/internal/user/controller/controller.go` +- `apps/backend/internal/user/controller/controller_test.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/service/service_test.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/user/store/sqlite_test.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/backendapp/boot_state_user_settings_test.go` +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/ssr/user-settings.test.ts` +- `apps/web/lib/ws/handlers/users.test.ts` + +## Acceptance + +1. A new user, an old settings JSON blob without the field, and an initial + payload that omits it all resolve to `false`. +2. Stored and PATCHed `true` remains true through controller → repository + read, DTO response, boot state, frontend mapping, and event mapping. +3. An omitted PATCH field leaves the existing setting unchanged (pinned at + the controller layer too). +4. The complete `user.settings.updated` event payload includes + `resolve_session_hostnames` (pin the key in a service test). +5. No UI, sessions payload, or lookup behavior changes yet. + +## Verification + +```sh +(cd apps/backend && go test ./internal/user/... ./internal/backendapp/...) +(cd apps/web && pnpm exec vitest run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts) +git diff --check +``` + +The backend run must include the controller mapping regression test +(`internal/user/controller/controller_test.go`). + +## Dependencies + +None. This task creates the portable setting consumed by Task 02. + +## Risks + +- A default that remains `true` in either mapper enables resolution for old + rows with no preference. Pin default-false at store, boot, and frontend + mapper boundaries. +- The complete event map can silently omit the new field even when HTTP + works. Pin the event key in service tests. diff --git a/docs/plans/session-hostname-resolution/task-02-backend-reverse-dns-service.md b/docs/plans/session-hostname-resolution/task-02-backend-reverse-dns-service.md new file mode 100644 index 0000000000..74bb8c3d60 --- /dev/null +++ b/docs/plans/session-hostname-resolution/task-02-backend-reverse-dns-service.md @@ -0,0 +1,522 @@ +--- +id: session-hostname-resolution-02 +title: Backend reverse-DNS lookup, cache, and streaming +status: pending +wave: 2 +depends_on: [session-hostname-resolution-01] +plan: docs/plans/session-hostname-resolution/plan.md +spec: docs/specs/session-hostname-resolution/spec.md +--- + +# Backend reverse-DNS lookup, cache, and streaming + +## Inputs + +The [spec](../../specs/session-hostname-resolution/spec.md) (Lookup pipeline, +Streaming, Persistence, Failure modes sections) and Task 01's +`resolve_session_hostnames` setting. Existing patterns: +`internal/auth/store` schema init, `internal/gateway/websocket/user_notifications.go` +broadcaster, `internal/integrations/healthpoll` Start/Stop goroutine shape, +backend AGENTS.md schema-replay rule (ADR 0027). + +## TDD sequence + +1. Add failing tests for `NormalizeHostname` (trailing dot, empty, + `.in-addr.arpa`/`.ip6.arpa` suffix rejection, first-valid-record + selection: a generic first record followed by a valid second record + yields the valid one). +2. Add failing tests for the cache store (`CacheEntry` round trip via + `GetMany`, absent-vs-cached-empty distinction, `resolved_at` fixed 9-digit + RFC3339 UTC text + round trip, and a schema replay test: fresh-DB `initSchema` plus + re-running `initSchema` on the same DB is a no-op; include Postgres-gated + fresh + replay coverage because the store is wired over the production + `dbPool`, mirroring the auth store test harness). +3. Add failing tests for the resolver: + - disabled gate: setting off returns an **empty map** (no cached values) + and schedules nothing; + - **gate read error:** the settings provider returning an error behaves + exactly like disabled — empty map, no lookups scheduled, no DNS + queries, logged (fail closed, opt-in unknown means no lookups); + - cached values returned immediately when enabled (via one `GetMany` call, + not per-IP reads); + - **absent vs cached-empty:** an IP with no cache row schedules a lookup; + an IP cached as no-answer (`hostname == ""` with a `ResolvedAt` within + `recentResolveWindow`) does not; + - **stale/fresh:** an IP cached within `recentResolveWindow` is not + re-queried; an older one is; + - in-flight dedup registers an interested user instead of a second + lookup; + - **raw-IP key contract:** triggering with `::ffff:192.0.2.10` and + `192.0.2.10` runs ONE job (canonical dedup) and publishes two events, + each carrying the triggering user's own raw spelling — including **two + sessions of the SAME user** with different raw spellings of the same + canonical IP (both rows must receive events); + - **interested-set lifecycle:** snapshot+clear each IP's interested + pairs exactly once on every terminal path (publish, no-change, + dequeue-gate skip, cache-fresh equal, cache-read error, `Set` error, + nil bus), with a churn/cleanup test asserting no completed interest + survives and in-flight merges are retained; + - a changed result persists + publishes once to all interested users; an + unchanged result publishes nothing; + - **DNS error classification:** a `*net.DNSError` with `IsNotFound` + (NXDOMAIN/no PTR — the common no-answer case) is a successful negative + result: persisted as `hostname = ""` and published when changed, then + skipped by `recentResolveWindow` on the next trigger; a timeout / + temporary / server error yields `""` **not persisted**, and is + published **only when the IP has no cache entry** — never over an + existing cached value (regression: stale cache `old.example` + + transient error publishes nothing, and re-enable/list still returns + `old.example`); + - **Set-on-success:** a same-value successful lookup (valid or + `IsNotFound`) still `Set`s and refreshes `resolved_at` (publishing + nothing when the hostname is unchanged), so the very next trigger is + skipped by `recentResolveWindow` — a stale timestamp that never + refreshes would re-query on every trigger; + - **cached-empty requery:** a cached no-answer row whose freshness has + expired (`ResolvedAt` older than `recentResolveWindow`) is re-queried + on the next trigger; when the retry finds a valid answer the change + persists and publishes (regression: cached-empty → later valid + answer after the window); + - **fire-and-forget trigger:** `TriggerUserSessionsResolved(userID)` + returns immediately (does not block on settings/session/cache reads), + the work runs on the resolver-owned context, and `Close()` joins the + trigger goroutine (goleak); + - **admission never drops**: more distinct IPs than workers all resolve + eventually (deterministic `N > lookupWorkers` pending-set drain test + with blocked/fake lookups proving the complete-then-recheck invariant); + IPs admitted while all workers are busy are not stranded; + - **stale-read-after-completion race:** user B's snapshot read sees the + old cache value, a concurrent job persists a new value (now fresh + within `recentResolveWindow`), and B then enqueues from its stale read + — the worker must skip the DNS query but publish the current cache + value to B (their observed value differs), never silently drop the + update; + - **context ownership**: canceling the request context does not cancel the + in-flight lookup, and `Close()` joins workers (goleak); + - **IP canonicalization**: invalid IPs are skipped; equivalent IPv6 + spellings produce one cache row and one job; + - **cache-error semantics via the `Cache` interface**: a `GetMany` error + on the API path returns empty entries for the affected IPs and + schedules nothing; on completion **`ErrNotFound` is a successful absent + baseline (absent + transient error publishes `""` without persisting; + absent + valid result persists and publishes) while **non-`ErrNotFound` + cache errors** suppress both persist and publish; a `Set` error + publishes **nothing** (changed-only semantics; the next trigger + retries the persist, and a later successful `Set` that changes the + cache publishes) and + does not panic; a nil event bus still returns cached values; + - **entry semantics:** `CacheEntry` covers unknown (absent from map / + `ResolvedAt` nil), cached-no-answer (`ResolvedAt` non-nil, `Hostname` + `""`), and durable (`ResolvedAt` non-nil, `Hostname` non-empty); the + handler emits `hostname_resolved_at` JSON `null` when `ResolvedAt` is + nil; +4. Add failing httpapi test: `GET /api/v1/auth/sessions` includes per-session + `hostname` keyed by raw IP; with the toggle off every `hostname` is `""` + even when the cache has values (no leak); a cache/settings failure still + returns 200 with `hostname: ""` (the resolver method never errors); + user-service test: PATCH to `true` fires the resolver hook, and a + non-canonical stored IP triggered through the hook (before any sessions + GET) publishes an event with the raw stored spelling. +5. Add failing gateway tests in `user_notifications_test.go`: memory and + NATS-shaped delivery of `auth.session.hostname.resolved` routes to the + `user_id` owner only, strips `user_id` from the payload, and leaves + `ip`, `hostname`, **and `resolved_at`** intact — for both a fixed + 9-digit RFC3339 UTC timestamp and a JSON `null` value (both must survive + both transports; + a dropped `resolved_at` breaks the frontend newer-wins reconciliation). + Also assert the **internal event map the broadcaster receives contains + `user_id`** (routing depends on it; an implementer publishing only + `{ip, hostname, resolved_at}` would dead-end the stream). +6. Add a failing resolver event-handler test: a persisted + `UserSettingsUpdated` event with `resolve_session_hostnames: true` queues a + session pass for its user. A false or malformed event does not queue work. +7. Implement the smallest production code that makes the tests pass. + +## Implementation + +- New package `apps/backend/internal/auth/hostnames`: + - `store.go`: `CacheEntry{Hostname string; ResolvedAt *time.Time}` — + `ResolvedAt` is **`*time.Time`** (nil = no row/unknown; a cached + no-answer has a non-nil `ResolvedAt` with `Hostname == ""`); `Store` + over writer/reader `*sqlx.DB` with `initSchema` creating + `auth_hostname_cache (ip TEXT PRIMARY KEY, hostname TEXT NOT NULL + DEFAULT '', resolved_at TEXT NOT NULL)`; implements a narrow `Cache` + interface: `GetMany(ctx, ips []string) (map[string]CacheEntry, error)` + (one dialect-safe `IN` query, chunked to stay under parameter limits; + **absent IPs are simply absent from the map**, so cached-no-answer + (`hostname == ""` present) is distinguishable from never-resolved), + `Get(ctx, ip) (CacheEntry, error)` (sentinel `ErrNotFound`), and + `Set(ctx, ip, hostname, resolvedAt time.Time)`. **Time representation:** + define ONE shared timestamp formatter as an **exported API of the + `hostnames` package** — `hostnames.FormatTimestamp(time.Time) string` + and `hostnames.ParseTimestamp(string) (time.Time, error)` — backed by a + single `timestampLayout` constant + (`"2006-01-02T15:04:05.000000000Z07:00"`). The helpers must be exported + because the `httpapi` handler (a different package) emits the + `hostname_resolved_at` API field, and the resolver emits the WS event + `resolved_at`; unexported helpers would force duplication of the layout + and risk drifting from the exact fixed-width UTC ordering contract. Use + them **everywhere**: store reads and writes, the sessions API + `hostname_resolved_at` field, and WS event `resolved_at`. + `FormatTimestamp` writes + `resolvedAt.UTC().Format(timestampLayout)` (UTC, so the zone is always + `Z`, exactly 9 fractional digits). The fixed width makes the + stored/emitted strings **lexicographically comparable in chronological + order** in both Go and JavaScript (unlike `time.RFC3339`, which drops + fractions, and `time.RFC3339Nano`, which trims trailing zeros — either + would break string ordering for same-second or same-millisecond + changes). Add sub-millisecond ordering tests at the store, handler, and + gateway layers asserting the exact 9-digit output, **plus a + cross-package test proving the handler emits the same helper output as + the store (one formatter, one contract)**. This is + dialect-portable + across the SQLite/Postgres `db.Pool` — scanning a raw TEXT column + directly into `time.Time` is not — and matches the explicit-parse + pattern used elsewhere in the codebase. New table, so `CREATE TABLE IF + NOT EXISTS` in `initSchema` is the whole migration; it must be + replay-safe (see TDD step 2), with **required** Postgres fresh+replay + coverage because the resolver is wired over the production `dbPool` + (not conditional). + - `normalize.go`: `NormalizeHostname(names []string) string` — examine + records in order and return the **first one that normalizes to a valid + hostname**: trim trailing dot; reject empty names and names ending in + `.in-addr.arpa` / `.ip6.arpa` (case-insensitive); if none is valid + return `""`. + - `resolver.go`: `Resolver` with constants `lookupWorkers = 4`, + `lookupTimeout = 3s`, `recentResolveWindow = 30s`; injectable + `LookupAddr` defaulting to `net.DefaultResolver.LookupAddr`; depends on + the `Cache` interface (not the concrete store) so tests can inject read + and write failures. + - **Admission (no drops, no missed updates):** pending entries carry + the **observed cache value** the trigger read (`pending + map[canonicalIP]observedCacheEntry`) plus the interested users; the + worker loop selects on the wakeup channel / stop channel, then drains + pending. + - **Dequeue-time privacy gate:** immediately before calling + `LookupAddr`, the worker re-reads the settings gate for every + interested user (fail closed on error) and **drops users who disabled + the toggle since admission**; if no interested user remains enabled, + the job is skipped entirely — **no DNS query runs while the toggle is + off**, even for work admitted while it was on. Jobs whose DNS already + started are "in flight" and may complete (their events go only to the + still-interested users). Regression test: enable → admit an IP → + disable before dequeue → `LookupAddr` is never called and no event is + published. For each admitted IP: if already in flight, merge the + interested users into the running job; if the cache is now within + `recentResolveWindow` (fresh), skip the DNS query but **compare the + current cached hostname against the observed hostname and publish the + current value to the job's interested users when they differ** — a + user who + scheduled from a stale snapshot must still learn of the change that + happened since their read; if the observed hostname equals the current + cached hostname, publish nothing (the diff compares the **hostname**, + never the timestamp, which refreshes on every successful lookup). + Otherwise run the lookup. **Wakeup invariant:** after completing a + lookup, each worker must re-check and drain `pending` again before + blocking on the wakeup channel — the wakeup channel only wakes *idle* + workers; progress while all workers are busy comes from the + complete-then-recheck loop. This is the **complete-then-recheck** + invariant's companion: IPs are never discarded because the queue is + full, never stranded because every completion re-drains, and never + silently skipped past a value the requester already saw. + - **Context ownership:** workers and the trigger goroutine run on a + resolver-owned context derived at `Start` and canceled by `Close()`. + Request/service contexts are used only for the synchronous cache and + settings reads inside `HostnamesForSessionIPs`; they never reach + `net.LookupAddr` and never outlive their request. + - In-flight set + interested-user set under one mutex. The interested + set is keyed by canonical IP and maps + `userID -> set[rawIP]` (every triggering session's stored spelling, + so one user with two spellings of the same IP keeps both): + `Start(ctx)` spawning workers + `Close()` joining them (idempotent, + goleak-verified). + - **Interested-set lifecycle (bounded, tested):** each job **snapshots + and clears** its IP's interested pairs exactly once when it resolves + OR is skipped, on every terminal path — publish, no-change + (observed hostname equals current), dequeue-gate skip (no enabled + users), cache-fresh observed-diff equal, non-`ErrNotFound` cache-read + error, `Set` error, and nil event bus. Only interests merged into an + already-running job (in-flight dedup) survive until that job's own + terminal path. A user who triggers again after completion re-registers + fresh. This prevents unbounded stale routing state under + session/IP/user churn; add a churn/cleanup test that triggers, + completes, re-triggers from many users/IPs and asserts the set never + retains completed interests. + - **Fire-and-forget trigger:** the resolver owns a buffered trigger + queue plus a pending-user set (same no-drop pattern as the IP pending + set); `TriggerUserSessionsResolved(userID string)` enqueues the user + and returns immediately. A trigger goroutine (spawned by `Start`, + joined by `Close`) drains the queue on the resolver-owned context: + read the settings gate (fail closed on error), fetch the user's raw + session IPs, and schedule lookups. The PATCH hook path never blocks on + settings/session/cache reads and never inherits the request context. + - `HostnamesForSessionIPs(ctx, userID string, ips []string) + map[string]CacheEntry` — **exact contract:** keyed by the raw input + spelling (the handler maps `m[session.IP]` directly with no + canonicalization of its own), one entry per valid input IP + (`Hostname` `""` and `ResolvedAt` nil when no hostname is known), + invalid/empty input IPs absent from the map. + The method **never returns an error and never fails the caller**: + every degradation is logged internally — settings gate read error → + empty map, no lookups (fail closed); cache `GetMany` error → empty + entries for the affected IPs, no lookups scheduled for them (unknown + baseline); per-IP invalid entries skipped. When the setting is **off + or unreadable, the map is empty** (cached values must not leak and + DNS must never run while opt-in is unknown). When on: validates each + IP with `net.ParseIP` (skipping invalid), keeps the **raw spelling** + for interested registration, canonicalizes (`ip.String()`) for + cache/job keys, reads the cache with **one `GetMany` call**, returns + cached hostnames with their `ResolvedAt`, and schedules lookups for + IPs that are absent or stale. The synchronous portion is one settings + read + one cache query (fast); scheduling is async. + - On completion: canonical IP key; compare with the cache. Every + **successful** lookup — valid records and `IsNotFound` negatives — + **persists via `Set` (refreshing `resolved_at`) even when the hostname + is unchanged**, so `recentResolveWindow` actually engages for + no-answer and stable rows; a same-value success that skipped `Set` + would leave a stale timestamp and re-query on every trigger. Publish + `events.AuthSessionHostnameResolved` `{user_id, ip, hostname, + resolved_at}` **only when the hostname value changed** (first + resolution counts as changed), where the published `ip` is **each + interested (user, raw spelling) pair** (one event per pair) and + `resolved_at` is the persisted row's + fixed 9-digit RFC3339 UTC time, formatted with the shared exported + `hostnames.FormatTimestamp` helper (or `null` for a non-persisted + transient result). + **`ErrNotFound` is a successful absent baseline, not an error**: with + an absent row a transient failure may publish `""` (nothing to mask) + and a valid/`IsNotFound` result persists normally. Only + **non-`ErrNotFound` cache errors** on completion skip persist + + publish (baseline unknown). A `Set` error logs and publishes + **nothing**: the value was not persisted, so changed-only semantics + do not admit an event (publishing a transient value would outlive its + own retry and make the frontend ordering inconsistent); the next + trigger retries the persist, and a later successful `Set` that + changes the cache publishes normally. `resolved_at: null` stays + reserved for transient DNS errors streamed over an absent cache + (empty-string events), never for failed writes. + Neither ever panics or fails the caller. + Result classification: + - valid record(s): hostname from `NormalizeHostname` (may be `""` for + generic answers); + - `*net.DNSError` with `IsNotFound`: successful negative answer, + `hostname = ""`, **persisted**; + - other errors (timeout/temporary/server): `""` **not persisted**, + and published **only when the IP has no cache entry** — a transient + failure must never stream `""` over an existing cached value, + because the client treats the streamed map as authoritative and a + masking empty would pin the row to `N/A` until identity reset. + A nil event bus is guarded (skip publish, keep persisting). +- `apps/backend/internal/auth/service.go`: add + `SessionIPs(ctx, userID) ([]string, error)` — the user's session IPs as + **raw stored spellings**: skip empty values, validate with `net.ParseIP` + (skip invalid), dedupe exact string duplicates only. Canonicalization is + the resolver's job (job/cache keys); raw spellings must survive for event + payloads. +- `apps/backend/internal/events/types.go`: `AuthSessionHostnameResolved = + "auth.session.hostname.resolved"`. +- `apps/backend/pkg/websocket/actions.go`: `ActionSessionHostnameResolved = + "auth.session.hostname.resolved"`. +- `apps/backend/internal/gateway/websocket/user_notifications.go`: add + `b.subscribe(eventBus, events.AuthSessionHostnameResolved, + ws.ActionSessionHostnameResolved)`. The resolver publishes a + `map[string]interface{}` with literal keys **`user_id`, `ip`, `hostname`, + and `resolved_at`** (matching the map-shaped convention of + `publishUserSettingsEvent`) so both memory and NATS transports route by + `user_id`, the broadcaster strips it correctly, and the frontend receives + the timestamp it needs for newer-wins reconciliation. +- `apps/backend/internal/auth/httpapi/handlers.go`: + `RegisterRoutes(router, svc, resolver, log)`; `listSessions` builds items + from `resolver.HostnamesForSessionIPs(ctx, userID, ips)` (raw-keyed, so + no canonicalization in the handler; the method never returns an error): + `"hostname": entry.Hostname` (or `""`) and + `"hostname_resolved_at": ` when the entry + has a `ResolvedAt`; resolver degradations are already logged inside the + resolver and never fail the sessions response. Handler test: a + cache/settings failure still returns HTTP 200 with `hostname: ""` and + `hostname_resolved_at: null` per session, and a durable entry emits the + exact 9-digit fixed-width string via the exported helper. +- `apps/backend/internal/user/service/service.go`: publish the complete + persisted settings snapshot through the existing `UserSettingsUpdated` + event. The service has no hostname-resolver dependency. +- `apps/backend/internal/backendapp`: + - Construct the resolver in `startServices` right after + `services.Auth` is set (all of `dbPool`, `repos`, `eventBus`, `log`, + `ctx`, `addCleanup` are in scope): store over `dbPool` writer/reader, + `SessionIPs` = `services.Auth`, settings gate = adapter over + `repos.User.GetUserSettings(ctx, userID)`. + - **Call `resolver.Start(ctx)` explicitly** after construction; on error, + log and abort startup. Register `addCleanup(resolver.Close)` only after + a successful `Start`. + - Queue-subscribe `resolver.HandleUserSettingsUpdated` to + `events.UserSettingsUpdated`. The shared queue prevents duplicate DNS work + across backend replicas. Register the subscription cleanup after the + resolver cleanup so shutdown unsubscribes before it closes the resolver. + - Keep the event handler small and idempotent. It reads the persisted user + ID and enabled flag, then calls `TriggerUserSessionsResolved`. + - Thread the resolver to the routes: attach it to `Services` + (`SessionHostnameResolver *hostnames.Resolver`; the `Services` struct is + declared in **`apps/backend/internal/backendapp/types.go`**, not + `services.go`), which is already passed through + `startAgentInfrastructure` → `startGatewayAndServe` → + `buildHTTPServer` → `registerRoutes`; `routeParams` reads it from + `p.services.SessionHostnameResolver` (or add an explicit parameter to + every signature in that chain — pick one, do not leave the plumbing + implicit). Extend the `authhttpapi.RegisterRoutes` call in + `registerRoutes` and update all call sites/tests. + +## Files likely touched + +- `apps/backend/internal/auth/hostnames/store.go` +- `apps/backend/internal/auth/hostnames/timestamp.go` (exported + `FormatTimestamp`/`ParseTimestamp` + `timestampLayout`) +- `apps/backend/internal/auth/hostnames/store_test.go` +- `apps/backend/internal/auth/hostnames/normalize.go` +- `apps/backend/internal/auth/hostnames/normalize_test.go` +- `apps/backend/internal/auth/hostnames/resolver.go` +- `apps/backend/internal/auth/hostnames/resolver_test.go` +- `apps/backend/internal/auth/hostnames/goleak_test.go` +- `apps/backend/internal/auth/service.go` +- `apps/backend/internal/auth/service_test.go` +- `apps/backend/internal/auth/httpapi/handlers.go` +- `apps/backend/internal/auth/httpapi/handlers_test.go` +- `apps/backend/internal/gateway/websocket/user_notifications.go` +- `apps/backend/internal/gateway/websocket/user_notifications_test.go` +- `apps/backend/internal/events/types.go` +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/service/service_test.go` +- `apps/backend/internal/backendapp/main.go` (or `auth.go`) +- `apps/backend/internal/backendapp/types.go` (Services struct field) +- `apps/backend/internal/backendapp/services.go` (provider wiring) +- `apps/backend/internal/backendapp/helpers.go` +- `apps/backend/internal/backendapp/` (Start/cleanup/routing smoke test) + +## Acceptance + +1. `HostnamesForSessionIPs` returns cached values immediately (one `GetMany` + call) and schedules lookups **only when the setting is confirmed on**; + with the setting off **or unreadable** it returns an empty map, schedules + nothing, and no cached hostname reaches the sessions response. +2. A changed resolution persists to `auth_hostname_cache` and publishes + exactly one event per interested + (user, raw-spelling) + pair — including two sessions of one user with different spellings of the + same IP; unchanged hostname resolutions publish nothing. **The internal + event-bus map is `{user_id, ip, hostname, resolved_at}`** (the + `UserEventBroadcaster` routes by `user_id` and strips it); the WS payload + delivered to the client is `{ip, hostname, resolved_at}`. Gateway/wiring + tests must assert the exact internal map (with `user_id`) is what the + broadcaster receives. Every successful + lookup (including same-value and `IsNotFound`) refreshes `resolved_at` + via `Set`, so `recentResolveWindow` engages for stable and no-answer + rows. +3. DNS result classification: `IsNotFound` (NXDOMAIN/no PTR) persists as a + cached no-answer (`""` with `resolved_at`) and is skipped by + `recentResolveWindow`; timeout/temporary/server errors are never + persisted and are published `""` only for IPs with no cache entry — a + stale cache plus transient error publishes nothing, so the durable value + survives re-enable and list refreshes. +4. `TriggerUserSessionsResolved(userID)` returns immediately (PATCH latency + unaffected by settings/session/cache reads), runs on the resolver-owned + context, and `Close()` joins the trigger goroutine and workers (goleak). +5. Admission never drops an IP: `N` distinct IPs with `N > lookupWorkers` + all resolve; rapid re-triggers dedupe via in-flight + + `recentResolveWindow` (driven by `CacheEntry.ResolvedAt`); equivalent IP + spellings share one cache row and one job; absent and cached-empty IPs + are treated distinctly (absent schedules, fresh cached-empty does not). +6. Request-context cancellation does not cancel lookups; `Close()` joins + workers with no goroutine leaks (goleak). +7. Invalid IPs are skipped; cache read/write errors and a nil event bus + follow the spec Failure modes (degrade, never fail the sessions API or + panic). A `Set` error publishes nothing (changed-only semantics; the next + trigger retries the persist, and a later successful `Set` that changes + the cache publishes). `CacheEntry.ResolvedAt` + is `*time.Time`: nil = unknown (handler emits JSON `null`), non-nil + covers both cached-no-answer (`Hostname == ""`) and durable values. + `resolved_at: null` in events is reserved for transient DNS errors over + an absent cache (empty strings), never for failed writes. +8. The resolver is started in production wiring: `Start(ctx)` is called + after construction (abort on error), `Close` is registered as cleanup, + and the resolver reaches `registerRoutes`/`RegisterRoutes` through the + startup chain; a backendapp wiring smoke test asserts Start/cleanup/ + route-threading (unit tests that call `Start` themselves are not + sufficient evidence). +9. `GET /api/v1/auth/sessions` includes `hostname` and + `hostname_resolved_at` (fixed 9-digit RFC3339 UTC or `null`) for every + session, and both + are empty/null while the toggle is off; non-canonical stored spellings + still resolve (canonical lookup key, raw display key), including when the + toggle hook fires before any sessions GET. +10. PATCH `resolve_session_hostnames: true` triggers + `TriggerUserSessionsResolved` for the user; the PATCH response is + unaffected by lookup results. +11. The WS gateway routes `auth.session.hostname.resolved` (`{ip, hostname, + resolved_at}`) to the user in `user_id`, stripped from the payload, for + both memory and NATS-shaped deliveries (gateway tests added). +12. The `auth_hostname_cache` schema is replay-safe: fresh DB and + same-DB re-run of `initSchema` both succeed, including Postgres-gated + fresh + replay coverage (the store is wired over the production + `dbPool`); `resolved_at` fixed 9-digit RFC3339 UTC text round trips through both + dialects. + +## Verification + +```sh +(cd apps/backend && go test -race ./internal/auth/... ./internal/user/... ./internal/gateway/websocket/... ./internal/backendapp/... ./pkg/websocket/...) +make -C apps/backend lint +git diff --check +``` + +## Dependencies + +Task 01 (the resolver reads `resolve_session_hostnames`). + +## Risks + +- Publishing an event per interested user for a shared IP is required; + publishing only to the triggering user drops updates for concurrent users, + and the event `ip` must be each (user, raw-spelling) pair or the frontend + row match breaks. A `map[userID]rawIP` interested set silently drops a + user's second spelling of the same IP — use a per-user set of raw + spellings. +- A resolver that drops jobs when busy strands IPs with no later trigger: + use a pending set drained by workers, never a drop-on-full queue — and + workers must re-drain `pending` after every completion (the wakeup channel + only wakes idle workers), or excess IPs strand while all workers are busy. +- Storing `resolved_at` in a dialect-typed column and scanning it directly + into `time.Time` is not portable across the SQLite/Postgres `db.Pool`; + store fixed 9-digit RFC3339 UTC text (one shared formatter) and parse + explicitly, with required Postgres fresh+replay coverage. +- Lookups on a request context die with the HTTP response: all DNS work runs + on the resolver-owned context canceled by `Close`. +- Uncached `hostname` values can leak while the toggle is off: gate the + returned map, not just scheduling. +- Publishing transient `""` over an existing cache entry masks the durable + value in the client (the streamed map is authoritative there); transient + errors publish `""` only for IPs with no cache entry. +- An explicit `Start(ctx)` is easy to omit in production wiring; unit tests + that call Start themselves will pass while the real server never drains + pending lookups. Pin the production Start + cleanup registration in the + wiring change. +- A per-IP cache read per session row turns the sessions list into an N+1 + path: use `GetMany`. +- A cache interface that cannot distinguish absent from cached-empty, or + lacks `ResolvedAt`, makes `recentResolveWindow` and no-answer caching + unimplementable: reads must return `CacheEntry{Hostname, ResolvedAt}` and + absent rows must be absent from the result map. +- Canonicalizing in `SessionIPs` (the toggle-hook provider) loses the raw + stored spelling, so a hook-triggered completion publishes a canonical `ip` + the frontend cannot match: the provider returns raw validated spellings; + canonicalization stays inside the resolver. +- Resolver tests cannot inject cache failures against a concrete store: the + resolver must depend on the narrow `Cache` interface. +- A struct-typed event without a literal `user_id` key is unroutable through + NATS: publish a `map[string]interface{}` with literal keys and pin memory + + NATS-shaped gateway tests. +- `goleak` will fail if `Start`/`Close` are not idempotent and joined; + follow the healthpoll Start/Stop shape. +- A new table that only works on a fresh DB fails the repo's replay rule: + cover fresh + same-DB `initSchema` replay in the store tests. diff --git a/docs/plans/session-hostname-resolution/task-03-frontend-streaming-and-ui.md b/docs/plans/session-hostname-resolution/task-03-frontend-streaming-and-ui.md new file mode 100644 index 0000000000..b2d3b454f0 --- /dev/null +++ b/docs/plans/session-hostname-resolution/task-03-frontend-streaming-and-ui.md @@ -0,0 +1,377 @@ +--- +id: session-hostname-resolution-03 +title: Frontend hostname streaming and security-page UI +status: pending +wave: 3 +depends_on: [session-hostname-resolution-01, session-hostname-resolution-02] +plan: docs/plans/session-hostname-resolution/plan.md +spec: docs/specs/session-hostname-resolution/spec.md +--- + +# Frontend hostname streaming and security-page UI + +## Inputs + +The [spec](../../specs/session-hostname-resolution/spec.md) (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 + +1. Add failing tests: auth slice `setSessionHostname` upsert 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; and + `setAuthState` semantics — **same identity preserves `sessionHostnames`, + a different identity (or null/logout) clears it AND increments + `sessionHostnamesEpoch`**, and + **`clearAuthenticated` (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 handler + `auth.session.hostname.resolved` updates the store map; SessionsCard + component tests (column hidden while off, shown while on, `N/A` for empty + values — including the cached-no-answer case that renders `N/A` with no + spinner while fresh, and updates when a post-`recentResolveWindow` retry + yields a valid answer, streamed value overrides response value, **newer + `resolved_at` wins: 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); `useSessionsList` refetches on + window focus and on the `false -> true` setting transition (same-tab + toggle AND cross-tab `user.settings.updated` push 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 a `store.getState()` read); store + composition: `createAppStore({ + sessionHostnames })` keeps the supplied map (default-state + overrides + paths). **Router integration:** obtain the handlers from + `registerWsHandlers(store)` and apply a synthetic + `auth.session.hostname.resolved` notification, asserting the store map + updates — a missing `...registerSessionHostnamesHandlers(store)` entry + in `router.ts` otherwise passes every direct-handler test and the + `N/A`-tolerant E2E while the streaming path is dead. +2. Implement the store/handler/type changes, then the UI, keeping tests + green. +3. Run `pnpm run i18n:pseudo` after adding locale keys and re-run + `i18n:ratchet`. + +## Implementation + +- `apps/web/lib/api/domains/auth-api.ts`: `AuthSession` gains + `hostname: string` and `hostname_resolved_at: string | null`. +- `apps/web/lib/types/backend.ts`: add + `"auth.session.hostname.resolved": BackendMessage<"auth.session.hostname.resolved", SessionHostnameResolvedPayload>` + with + `SessionHostnameResolvedPayload = { ip: string; hostname: string; resolved_at: string | null }`. +- `apps/web/lib/state/slices/auth/types.ts` + `auth-slice.ts`: + `sessionHostnames: Record` (default `{}`) + with `SessionHostnameEntry = { hostname: string; resolvedAt: string | + null }`, `sessionHostnamesEpoch: number` (default `0`), and + `setSessionHostname(ip, hostname, resolvedAt)`. + **Shape rule (exact path, no nesting):** `AuthSliceState` gains + `sessionHostnames` and `sessionHostnamesEpoch` as **sibling keys of + `auth`** — the slice creator spreads `defaultAuthState` into the ROOT + store, so these keys become top-level `AppState` fields; they must NEVER + be placed inside the `auth` object (nesting under `auth` is erased by + `setAuthState`, which replaces `draft.auth` wholesale). Declare both + explicitly in `apps/web/lib/state/store.ts`'s `AppState` type (alongside + the existing `auth: (typeof defaultAuthState)["auth"]`), in + `apps/web/lib/state/default-state.ts` (`defaultState`), and in + `apps/web/lib/state/store-overrides.ts` (`buildStateOverrides`). The auth + actions mutate these root fields directly through the immer draft: + `setAuthState` clears the map + bumps the epoch on identity change (and + preserves both on same-identity refresh), `clearAuthenticated` clears + + bumps, and `setSessionHostname` applies the entry comparator (below). + Selectors and WS handlers read/write + `state.sessionHostnames` / `state.sessionHostnamesEpoch` / + `state.setSessionHostname(...)` — never `auth.sessionHostnames`. + **Entry comparator (single source of truth):** `setSessionHostname` is + NOT a blind upsert — it applies one shared `compareEntries(a, b)` + (newer `resolvedAt` wins; a durable entry (non-null `resolvedAt`) 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. **Compare `resolvedAt` strings lexicographically** (the fixed-width + UTC + format is chronologically ordered as a string); do NOT use + `Date.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):** `sessionHostnames` is a **top-level sibling + of `auth`** in the auth slice state and in `AppState` + (`{ auth: {...}, sessionHostnames: {...} }`) — never nested under `auth` + and never addressed as `auth.sessionHostnames`. `setAuthState` replaces + `draft.auth` wholesale, so a nested map would be erased by every + login/`me` refresh. Update `apps/web/lib/state/store.ts` (`AppState` + declares `auth: (typeof defaultAuthState)["auth"]` explicitly) with the + new slice state shape; `defaultAuthState` must include + `sessionHostnames: {}`. **State composition:** also add the field to + `apps/web/lib/state/default-state.ts` (`defaultState`) and + `apps/web/lib/state/store-overrides.ts` (`buildStateOverrides`) — store + construction spreads `defaultState`, then slices, then overrides, so a + caller-supplied `sessionHostnames` in `createAppStore({...})` would + otherwise be overwritten by the slice default. Selectors and WS handlers + read/write `state.sessionHostnames` / `state.setSessionHostname(...)`. + **Identity policy (decided):** the map survives disable/enable and + same-identity refreshes; `setAuthState` **clears it when the authenticated + identity changes** (the new `auth.user` id differs, or the user becomes + null on logout), and `clearAuthenticated` (invoked on every 401 by + `apps/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 strips + `user_id` from `auth.session.hostname.resolved`, so a frame queued from + the prior user is indistinguishable from a fresh one once delivered. + Add a `sessionHostnamesEpoch: number` to the auth slice, **incremented + whenever the map is cleared on an identity transition**; the + `registerSessionHostnamesHandlers(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 (see `useWebSocket`/`ws-connector` below), + dropping transport-queued frames from the old connection. Regression + test: populate the map, change identity via `setAuthState` (map cleared, + epoch bumped), then invoke the OLD handler registration with a synthetic + `auth.session.hostname.resolved` event — 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 in `apps/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):** reading `store.getState()` inside + the existing `[store, url]`-keyed effect does NOT trigger a rerender, so + the effect never re-runs on `setAuthState`/`clearAuthenticated`. + `WebSocketConnector` must select the identity reactively — `useAppStore((s) + => s.auth.user?.id ?? null)` plus the `authenticated` flag — and pass it + (or a memoized identity string) as an explicit identity key into + `useWebSocket`, which includes it in the effect dependencies. The auth + gate does NOT unmount the connector on a user-id-only change + (`auth-gate.tsx` keys on mode+authenticated), so the identity key is the + only trigger — the lifecycle test MUST cover a **user-id-only + transition** (authenticated stays `true`, 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 new `WebSocketClient` + starts with empty subscription maps, so re-establish every + component-owned subscription the old client held (`subscribeUser` re-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-only `setAuthState` disposes 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` (unconditional `disconnected`) **and `onerror` + (unconditional `error`)** — must not be able to run + after the replacement client has connected; an unguarded shared + `connectionStatus` write from the disposed client can overwrite + `connected` with `disconnected`/`error` (or clear subscriptions) after the + new + client is live, leaving app state stale while the transport is up. + `useWebSocket` must 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's `onclose` **and `onerror`** until after the + replacement's + `onopen`/connected — assert `connectionStatus` stays `connected` and 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-subscription` with `[enabled]`, + `office run live sync` with `[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. Audit + `getWebSocketClient()` 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 a `Switch` bound to `userSettings.resolveSessionHostnames`, + immediate `updateUserSettings({ 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** `` + 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`): **newer `resolved_at` wins; 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 renders `N/A`; a streamed event replaces it when + the lookup completes. Read `state.sessionHostnames` (top-level) via + `useAppStore`. + - `useSessionsList`: refetch on window `focus` (in addition to mount) and + on the `false -> true` setting 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 `pendingTrigger` + boolean, and (c) a monotonically increasing generation counter. A + trigger (focus or enable-transition) that arrives while a fetch is in + flight sets `pendingTrigger` instead of starting a second fetch; when + the in-flight fetch settles, one more reload runs if `pendingTrigger` + was 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 watching + `userSettings.resolveSessionHostnames` then observes the `false -> true` + transition only after persistence, and its refetch hits a backend whose + settings gate is already `true` (an un-sequenced optimistic update + races: the refetch GET can run while the durable setting is still + `false`, 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-tab `user.settings.updated` behave 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. +- **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.json` keys to every catalog + (`en`, `pseudo`, `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`) under + **`apps/web/src/locales/`** (the actual catalog directory): + `resolveDeviceHostnames`, `resolveDeviceHostnamesDescription`, `hostname`, + `hostnameNotAvailable` (English value `N/A`). Regenerate pseudo with + `pnpm run i18n:pseudo`; never hand-edit pseudo output. + +## Files likely touched + +- `apps/web/lib/api/domains/auth-api.ts` +- `apps/web/lib/types/backend.ts` +- `apps/web/lib/state/slices/auth/types.ts` +- `apps/web/lib/state/slices/auth/auth-slice.ts` +- `apps/web/lib/state/slices/auth/auth-slice.test.ts` +- `apps/web/lib/state/store.ts` +- `apps/web/lib/state/default-state.ts` +- `apps/web/lib/state/store-overrides.ts` +- `apps/web/lib/ws/handlers/session-hostnames.ts` +- `apps/web/lib/ws/handlers/session-hostnames.test.ts` +- `apps/web/lib/ws/router.test.ts` (or existing router test file) +- `apps/web/lib/ws/router.ts` +- `apps/web/lib/ws/use-websocket.tsx` (identity-dependent client lifecycle) +- `apps/web/components/ws-connector.tsx` +- `apps/web/components/settings/account/security-settings.tsx` +- `apps/web/components/settings/account/security-settings.test.tsx` +- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/account.json` + +## Acceptance + +1. Toggle off by default: no Hostname column renders. +2. Toggling on PATCHes the setting and shows the column immediately with + cached values or `N/A`; every empty value renders `N/A` with no pending + spinner, and streamed events replace it. +3. A streamed `auth.session.hostname.resolved` event updates matching rows + without a reload; cell resolution is by `resolved_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 renders `N/A` (its first + absent → no-answer resolution DID emit an event with a persisted + `resolved_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). +4. Toggling off hides the column; toggling on again restores it with the + in-memory cached map values before new streams arrive. +5. `setAuthState` preserves `sessionHostnames` for 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 with `store.ts`'s explicit `auth` AppState type; every + reference uses the top-level `sessionHostnames` path, never + `auth.sessionHostnames`. +6. The `false -> true` setting 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. +7. The sessions list refetches on window focus (same coalescing protocol); a + session created elsewhere appears without a full reload. +8. All new copy passes `i18n:ratchet` and the pseudo locale renders; catalog + files live under `apps/web/src/locales/`. +9. `AuthSession` and the WS payload types match the backend wire shapes. +10. Mobile: toggle and table remain reachable/touchable at Pixel 5 width + with no document horizontal overflow. + +## Verification + +```sh +(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 `sessionHostnames` under `auth` lets `setAuthState` erase it on + every login/`me` refresh; keep it a top-level sibling of `auth` in the + slice and `AppState`, address it only as `sessionHostnames`, 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.ts` in the state shape + change makes `createAppStore({ 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 `recentResolveWindow` a retry may + replace the empty with a valid answer and publish it; never render a + pending state; empty always means `N/A` until 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.updated` push 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 `resolvedAt` + and cells must compare `resolved_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.ts` in the slice-shape change breaks + typecheck (AppState pins the `auth` field shape). diff --git a/docs/plans/session-hostname-resolution/task-04-e2e-and-final-verification.md b/docs/plans/session-hostname-resolution/task-04-e2e-and-final-verification.md new file mode 100644 index 0000000000..b311ac523c --- /dev/null +++ b/docs/plans/session-hostname-resolution/task-04-e2e-and-final-verification.md @@ -0,0 +1,216 @@ +--- +id: session-hostname-resolution-04 +title: E2E and final verification +status: pending +wave: 4 +depends_on: [session-hostname-resolution-03] +plan: docs/plans/session-hostname-resolution/plan.md +spec: docs/specs/session-hostname-resolution/spec.md +--- + +# E2E and final verification + +## Inputs + +Tasks 01-03 and the [spec](../../specs/session-hostname-resolution/spec.md) +acceptance criteria. Existing E2E patterns: +`apps/web/e2e/tests/auth/auth-screenshots.spec.ts` (auth-mode restart + +account security page), `apps/web/e2e/helpers/auth.ts` (`setupAdmin`, `login`, +`mintPAT`), `apps/web/e2e/helpers/causal-waits.ts` (causal wait primitives), +and `apps/web/e2e/README.md` for projects/commands. + +## Project placement (verified against `apps/web/e2e/playwright.config.ts`) + +- `tests/auth/*.spec.ts` runs in the **`auth` project**: the backend is + restarted with auth enabled and the whole API is locked behind a login; + `mobile-*.spec.ts` files under `tests/auth/` run in the **`mobile-chrome`** + project instead (the auth project's `testIgnore` excludes them). The new + spec **must not** claim to run on the default project. + +## Implementation + +### Desktop spec — `apps/web/e2e/tests/auth/account-security-hostnames.spec.ts` + +Setup (mirror `auth-screenshots.spec.ts` for the restart shape, plus +`mobile-users-self-actions.spec.ts` for DB isolation): + +- `beforeAll`: `backend.restart({ KANDEV_FEATURES_AUTH: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, + "kandev-hostnames-desktop.db") })` — **an isolated DB path is required**, + exactly as in the mobile spec. The auth project's worker is serial/shared + and `backend.restart` preserves the existing SQLite DB, so without an + isolated path this spec's second login (focus-refetch step), hostname + cache rows, and admin state leak into later auth-project tests, and + `setupAdmin` may find the instance already past setup mode. The isolated + DB also makes this spec's own row-count assertions deterministic across + runs. +- **Lifecycle (pinned):** call **`setupAdmin(...)` only** to create the + admin and authenticate the page context — `/auth/setup` already creates + the admin's session and sets the `kandev_session` cookie on the context, + so the baseline sessions table has exactly **one** row. Do NOT call + `login(...)` for the main flow (it would create a second baseline row and + skew the isolation proof). `login(...)` is used only in the focus-refetch + step to create the second session row. The page context must carry the + authenticated session cookie for both the settings page and the + `afterEach` cleanup PATCH. +- `afterAll`: `backend.restart()` to return to the baseline env (this + reverts the DB path too). +- `afterEach`: PATCH `resolve_session_hostnames` back to `false` using the + authenticated API client (the E2E worker does not reset user settings + between tests). +- **Isolation model (single test, no false claims):** the spec is **one + `test(...)`** containing the whole flow below — a second test cannot + assert a fresh one-row baseline because the isolated DB persists across + tests within the worker (setupAdmin runs once in `beforeAll`, and the + focus-refetch `login` adds a session that no `afterEach` removes). + Cross-spec isolation comes from the isolated `KANDEV_DATABASE_PATH` + itself (other auth-project specs use their own/`backend`'s DB), not from + an in-spec two-test proof. Within the single test, the row-count + assertions are: exactly **one** row after `setupAdmin` (fresh DB), and + exactly **two** after the focus-refetch `login` — these are + deterministic because the DB is dedicated to this spec. + +Flow (assertions are env-robust; DNS answers vary — never assert an exact +hostname). The E2E **never depends on `auth.session.hostname.resolved` +events**: the worker-scoped backend keeps its DB (and therefore the hostname +cache) across tests, and every e2e login comes from the same loopback IP, so +a first-resolution or changed-result event is not guaranteed for any toggle. +Streaming behavior is covered by the resolver/WS-handler/component unit +tests; the E2E validates the UI contract (column, hostname-or-`N/A` cells, +cached rendering on re-enable, persistence, focus-refetch): + +0. Before navigation: `const watch = watchWs(page);` then `page.goto(...)` + (per `causal-waits.ts`, `watchWs` must precede the first `goto`). +1. Navigate to `/settings/account/security`; assert the sessions table has no + Hostname column (toggle is off by default). +2. Toggle the switch on; assert the column appears and every row's cell is + either `N/A` or a non-empty hostname immediately (empty always renders + `N/A`; there is no pending state). +3. Toggle off; assert the column is hidden. +4. Toggle on again; assert the column reappears with values (cached from the + in-memory map and/or the enable-transition refetch's `hostname` values — + `N/A` or a non-empty hostname). Do NOT wait for a WS event here: an + unchanged or fresh-cache result intentionally emits none. +5. Reload the page; assert the toggle is still on and the column is shown + with hostname-or-`N/A` cells (persistence through user settings). +6. New-session discovery (required, not skippable): with the page still + open, call `login(...)` **again through the same BrowserContext's request + client** — that is what creates a second `auth_sessions` row (navigating + alone only reuses the cookie and adds nothing; the baseline is the single + `setupAdmin` session, so the table must grow from one row to two). Then: + a. `const page2 = await ctx.newPage();` + b. **Explicitly activate page 2 first:** `await page2.bringToFront();` + — `goto` alone is navigation, not activation, and the focus handler + on page 1 must deterministically fire from a real blur/focus + transition. + c. `await page2.goto("/")` (shared cookies authenticate it); wait for + the app shell to render on page 2. + d. Arm a causal wait for the sessions refetch (`watch.waitForHttp` or + `page.waitForResponse` on `GET /api/v1/auth/sessions`), then + `await page.bringToFront()` on the original page; await the sessions + response — this proves the focus handler fired and refetched. + e. Assert the sessions table now has **two** rows (the `setupAdmin` + session plus the new login) and the new row's hostname cell is + hostname-or-`N/A`. If the auth fixture cannot create a second session + for the same admin, fix the fixture rather than dropping the + assertion. + +### Mobile spec — `apps/web/e2e/tests/auth/mobile-account-security-hostnames.spec.ts` + +Runs in the **`mobile-chrome`** project (routed away from the desktop `auth` +project via its `testIgnore`), which does NOT inherit the auth project's +backend restart — the spec must set it up itself, mirroring +`mobile-users-self-actions.spec.ts`: + +- `beforeAll`: `backend.restart({ KANDEV_FEATURES_AUTH: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, + "kandev-hostnames-mobile.db") })` (an isolated DB so the hostname cache + does not leak between the desktop and mobile workers); `afterAll`: + `backend.restart()` to the baseline env. +- Create a manual context: `browser.newContext({ ...devices["Pixel 5"], + baseURL: backend.frontendUrl })` (manual contexts do not inherit project + device options), then `setupAdmin` + `login` before navigating. +- `afterEach`: PATCH `resolve_session_hostnames` back to `false` with the + authenticated client. + +Flow at the Pixel 5 viewport: enable → column appears → cells are +hostname-or-`N/A` (no WS waits, per the desktop flow), disable → hidden, +re-enable → restored. Assert the toggle switch row is reachable/tappable and +the page has no document-level horizontal overflow with the Hostname column +rendered. + +## Final verification + +```sh +(cd apps/backend && go test -race ./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/users.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 +``` + +Update the plan's wave table and this task's status to `done` when green. + +## Acceptance + +1. The desktop spec (auth project) proves default-off, enable → column + + hostname-or-`N/A` cells (no WS-event waits), disable → hidden, re-enable → + restored with cached values, reload persistence, and focus-refetch + discovery of a second login, with authenticated setup/cleanup. +2. The mobile spec (mobile-chrome project) proves the same flow at Pixel 5 + width with no horizontal overflow, using its own auth-enabled backend + restart (isolated DB), `setupAdmin`/`login`, and authenticated cleanup. +3. All focused backend and frontend checks pass; `git diff --check` is clean. +4. The spec/plan/task statuses are updated to match reality. + +## Verification + +The commands in "Final verification" are the acceptance gate. + +## Dependencies + +Tasks 01-03. + +## Risks + +- Running the spec without the auth project's env/helpers: `GET + /api/v1/auth/sessions` is behind `RequireRealIdentity`, so an unauthenticated + page cannot load sessions. Always restart with `KANDEV_FEATURES_AUTH: + "true"` and log in first. +- Exact-hostname assertions flake across environments (no PTR, `localhost`, + synthesized `.in-addr.arpa`). Assert column presence/absence and + hostname-or-`N/A` cell text only; because `N/A` renders immediately there + is no pending state to wait for. +- Never await `auth.session.hostname.resolved` in E2E: the worker backend + keeps its cache across tests and e2e logins share one loopback IP, so a + first-resolution or changed-result event is not guaranteed (fresh cache + and unchanged results intentionally emit nothing). Streaming is proven by + unit/component tests. +- Focus-refetch coverage must **call `login` again** through the same + context's request client (only setup/login create `auth_sessions` rows; + navigation reuses the cookie), then navigate a second same-context page so + page 1 really loses focus, then `bringToFront` page 1 and assert the row + count grew by exactly one. It must not be skippable; fixture limitations + are fixture bugs. +- The desktop spec must restart with an isolated `KANDEV_DATABASE_PATH` + (the auth worker is serial/shared and `backend.restart` preserves the DB); + without it, the focus-refetch second login, hostname cache, and admin + state leak into later auth-project tests and `setupAdmin` may not be in + setup mode. Keep the spec a single test with deterministic 1-row → 2-row + assertions; cross-spec isolation comes from the dedicated DB path, not an + in-spec two-test proof (the DB persists across tests, so a second test + cannot claim a fresh baseline). +- The mobile spec runs in the `mobile-chrome` project, which does not + inherit the auth project's backend restart; it must restart auth-enabled + with an isolated DB itself (per `mobile-users-self-actions.spec.ts`) or + the page hits setup/unauthenticated mode and sessions never load. +- A leftover enabled setting leaks into later specs; always reset in + `afterEach` with the authenticated client. +- The managed E2E runner must rebuild production assets on the first run; + re-run with `--no-build` only after a successful prior build from the same + tree. diff --git a/docs/specs/platform/requirements/viewport-bounded-session-delivery.md b/docs/specs/platform/requirements/viewport-bounded-session-delivery.md index 1e2376498a..781675de04 100644 --- a/docs/specs/platform/requirements/viewport-bounded-session-delivery.md +++ b/docs/specs/platform/requirements/viewport-bounded-session-delivery.md @@ -95,4 +95,3 @@ subscribing to its transcript. - Limiting full detail streams in a layout where the user explicitly makes several session details visible at the same time. - Persisting viewport or selected-tab state on the backend. - diff --git a/docs/specs/session-hostname-resolution/spec.md b/docs/specs/session-hostname-resolution/spec.md new file mode 100644 index 0000000000..f1a585bf5d --- /dev/null +++ b/docs/specs/session-hostname-resolution/spec.md @@ -0,0 +1,412 @@ +--- +slug: session-hostname-resolution +title: Optional reverse-DNS hostnames for account sessions +status: draft +--- + +# Optional reverse-DNS hostnames for account sessions + +## Problem + +The account security page (`/settings/account/security`) lists signed-in +devices with their IP address. IPs are opaque; users often want to know which +hostname an IP belongs to (their own machines, a corporate proxy, a cloud +host) without leaving the page. Resolving every session IP unconditionally +would leak per-IP DNS traffic and column width for users who do not need it, +so the feature is an opt-in toggle, off by default. + +## Goal + +- A per-user toggle **Resolve device hostnames** on the account security page, + disabled by default. +- When enabled, the sessions table gains a **Hostname** column. The backend + reverse-resolves each session IP through the OS-configured DNS resolver + (never an external API or hardcoded resolver) in the background and streams + results to the page as they complete. +- IPs with no PTR answer, or with only a synthesized generic + `.in-addr.arpa` / `.ip6.arpa` name, display **N/A**. +- Disabling hides the column. Re-enabling restores it immediately with cached + results, then the backend re-runs the lookups and streams only values that + changed since the cache. + +## Non-goals + +- No DNS lookups happen while the toggle is off. Hostname data is rendered + **only** on the account security page (no other UI surface reads or shows + it). Transport note: streamed notifications are delivered to the + authenticated user's own WebSocket connections (all their tabs), because + the gateway fan-outs per user; other tabs retain the in-memory map but + never render it, which is what makes re-enable and cross-tab navigation + show cached results instantly. A page-scoped subscription that delivers + only to the security page is a possible future narrowing, not part of + this change. +- No external DNS-over-HTTPS, geolocation, or enrichment APIs. Only the OS + resolver (`net.LookupAddr`) is used. +- No change to how sessions are created, stored, or revoked. +- No per-session hostname persistence: the cache is keyed by IP (reverse DNS + is an IP property, identical for every session that shares it). + +## User stories + +1. Alice signs in from `192.0.2.10`. On the security page she enables the + toggle. The Hostname column appears; the row shows `N/A` (the empty + value) until the lookup completes, then the hostname replaces it without + a reload. +2. Bob enables the toggle while one of his devices is behind a NAT that has + no PTR record. The row shows `N/A`. +3. Alice disables the toggle. The column disappears. She enables it again + five minutes later: the column reappears with the previously resolved + hostnames immediately, then re-resolves in the background; only rows whose + hostname changed update. +4. Carol signs in from a new IP while the toggle is already on. The next + time the security page's session list refreshes (window focus returns or + the page reloads) the new session's row appears and its hostname resolves + in the background and streams into the row. Live discovery of sessions + created elsewhere while the page stays focused is out of scope (no + session-created push event). + +## Contracts + +### User setting + +- Wire field: `resolve_session_hostnames: boolean` (top-level user settings, + snake_case on the wire). +- Frontend state: `userSettings.resolveSessionHostnames: boolean`. +- Default: `false` for missing stored values and initial payloads. +- PATCH semantics: omitted field preserves the current value; explicit + `true`/`false` is durable. Changing it to `true` triggers background + resolution for the user's session IPs (fire-and-forget; the PATCH response + is unaffected by lookup results). +- The setting is broadcast in the complete `user.settings.updated` WebSocket + event so other tabs and the boot hydration path stay in sync through the + existing shared mapper. + +### Sessions API + +`GET /api/v1/auth/sessions` (unchanged auth/permission requirements) adds two +fields per session: + +```json +{ "id": "...", "ip": "192.0.2.10", "hostname": "mail.example.com", "hostname_resolved_at": "2026-08-16T12:00:00.123456789Z", ... } +``` + +- `hostname` is always present: the cached value when known, `""` when + unknown or when the toggle is off. When the toggle is off the resolution + path is gated end to end: no lookups are scheduled **and** the returned + map is empty, so no cached hostname ever leaks into the response. +- `hostname_resolved_at` is the cache row's `resolved_at` when a cache row + exists, `null` otherwise. It is formatted with the **same fixed 9-digit + fractional-second RFC3339 UTC layout used everywhere** + (`2006-01-02T15:04:05.000000000Z07:00`, e.g. + `"2026-08-16T12:00:00.123456789Z"`), so the field is lexicographically + comparable in chronological order and lets the frontend order list + snapshots against streamed events (see Frontend). +- A background resolution pass is scheduled for any of the user's session IPs + that are not cached, not in flight, and not resolved in the last + `recentResolveWindow`, but only when the toggle is on. + +### Reverse-DNS cache (backend) + +- New SQLite table `auth_hostname_cache`, owned by the hostnames cache store + (`apps/backend/internal/auth/hostnames/store.go`), which runs its own + `CREATE TABLE IF NOT EXISTS` in its `initSchema` (a new table needs no + `ADD COLUMN` migration); it is the sole consumer of the table: + `ip TEXT PRIMARY KEY, hostname TEXT NOT NULL DEFAULT '', resolved_at TEXT NOT NULL`. +- Global by IP (PTR answers do not vary per user). `hostname == ""` means + "no answer" and is itself cached so re-enables do not re-spin a spinner; + **cache presence** (a row exists) is what distinguishes "resolved to no + answer" from "never resolved", and `resolved_at` drives the + `recentResolveWindow` re-query guard. +- Cache rows never expire for display. Re-resolution happens when the toggle + is turned on and when new IPs appear on the sessions list. + +### Lookup pipeline + +- Session IPs are validated with `net.ParseIP` and canonicalized + (`ip.String()`: v4-mapped IPv6 becomes dotted-quad, IPv6 is lowercased) + before they become cache keys, job keys, or `net.LookupAddr` inputs. + Invalid or empty IPs are skipped entirely. +- `net.LookupAddr` (OS resolver) per IP, using a **resolver-owned context** + canceled by `Close()`, never the triggering request's context (which ends + when the HTTP response completes). Request/service contexts are used only + for synchronous cache and settings reads. +- Records are examined in resolver order; the **first record that normalizes + to a valid hostname** is used (see normalization below). If none does, the + IP has no answer. +- Normalization: trim trailing dot; a name that is empty or ends in + `.in-addr.arpa` / `.ip6.arpa` (case-insensitive) is not a valid hostname. +- Bounded background workers (constant `lookupWorkers = 4`) process a + **pending set** that workers drain; admission never drops an IP. Pending + entries carry the **observed cache value** the trigger read. One job per + IP; in-flight IPs are deduplicated; a second trigger for the same IP + records the requesting user as interested instead of starting a second + lookup. Immediately before calling `LookupAddr`, the worker re-reads the + settings gate for every interested user (fail closed on error) and drops + users who disabled the toggle since admission; if no interested user + remains enabled, the job is skipped — **DNS never runs while the toggle is + off**, even for work admitted while it was on. When a worker finds an IP + already fresh within `recentResolveWindow`, it skips the DNS query but + **compares the current cache value against the observed value and + publishes the current value to the job's interested users when they + differs** — a user who scheduled from a stale snapshot must still learn of + the change since their read (a read → concurrent completion → enqueue + interleaving must never silently drop the update). The diff compares the + cached **hostname** (not the timestamp, which now refreshes on every + successful lookup). +- Per-lookup timeout (`lookupTimeout = 3s`). Every **successful** lookup — + valid records, and `*net.DNSError` with `IsNotFound` (NXDOMAIN, no PTR + records) as a successful negative answer — **persists its result and + refreshes `resolved_at` even when the hostname is unchanged**, so the + `recentResolveWindow` re-query guard actually engages (a stale row that + never refreshes its timestamp would be re-queried on every trigger). + Publishing stays changed-only (hostname value differs). DNS errors are + classified: + - `IsNotFound` → `hostname = ""`, persisted as a cached no-answer; + - timeout, temporary, and server errors are treated as no answer for the + UI but are **not persisted**. They are streamed as `""` **only when the + IP has no cache entry at all**; when a durable cache entry exists + (hostname or no-answer), a transient failure is logged and **nothing is + published**, so a transient blip can never mask an existing cached + hostname in the client (the durable value stands until a real change or + the next successful lookup). +- `recentResolveWindow = 30s`: an IP resolved within this window is not + re-queried on a new trigger (anti-thrash for rapid toggle cycles; the OS + resolver's own cache makes repeated lookups cheap but unnecessary). + +### Streaming + +- New event `auth.session.hostname.resolved` on the event bus, payload + `{user_id, ip, hostname, resolved_at}` (fixed 9-digit RFC3339 UTC string + for a persisted result, `null` for a non-persisted transient result), + published **only when the resolved hostname value differs + from the cache** (a first resolution with an empty cache counts as a + change; a failed lookup that yields `""` against an absent cache also + counts as a change). `resolved_at` travels with the event so the gateway + and frontend can preserve newer-wins ordering; dropping it breaks + reconciliation. +- **Key contract:** cache keys and job keys are the canonical IP + (`net.ParseIP(...).String()`); the event's `ip` is the **raw stored session + IP of the triggering user's session** (the same string the frontend already + holds as `AuthSession.ip`), so the frontend matches events to rows without + any IP parsing. When several users — or one user with several sessions — + trigger the same canonical IP with different raw spellings, each + (user, raw spelling) pair receives an event carrying that raw spelling. + The resolver canonicalizes internally for job/cache keys but returns + **raw-input-keyed** results: the sessions handler looks up + `m[session.IP]` directly with no canonicalization of its own, so a + non-canonical stored spelling (e.g. `::ffff:192.0.2.10`) still finds its + cached hostname. +- New WebSocket notification action `auth.session.hostname.resolved`, payload + `{ip, hostname, resolved_at}` (the gateway strips `user_id`), routed to + every user that triggered a lookup for that IP (a user-triggered lookup + registers the user as interested with their raw IP spelling; all interested + users receive the update). `resolved_at` is the RFC3339 UTC time the + result was persisted with a **fixed 9-digit fractional-second field** + (e.g. `2026-08-16T12:00:00.123456789Z`, lexicographically comparable in + chronological order), or `null` **only for a transient DNS + error/timeout streamed over an IP with no cache entry** (an empty-string + event with nothing durable to mask). A failed cache write publishes + nothing, so `null` never denotes a failed-write hostname. +- The frontend merges streamed values into a per-IP map keyed by the raw + session IP and re-renders matching rows. + +### Frontend + +- Toggle card on `/settings/account/security`: label **Resolve device + hostnames**, description stating that lookups use the system DNS resolver + and apply to the IPs of signed-in devices. Saves immediately through + `PATCH /api/v1/user/settings` (page-local save, consistent with the rest of + the account page). +- Sessions table: the **Hostname** column renders only while the toggle is + on. Cell value resolution compares the streamed map entry + (`{hostname, resolvedAt}` — camelCase frontend state field; the wire + field `resolved_at` is reserved for the WS payload/API) with the + session's response entry (`hostname`, `hostname_resolved_at`): **newer + `resolvedAt` wins; a + durable entry (non-null `resolvedAt`) beats a transient/unknown one + (null); ties break to the non-empty value; both empty → `N/A`**. This is + what makes list snapshots authoritative over stale streamed entries after + a WebSocket gap (the cache's `resolved_at` is newer than the retained map + value) while still letting a fresh streamed event beat an in-flight stale + response. There is **no pending spinner state**: an empty value is + indistinguishable from a cached no-answer, so it always renders `N/A`, and + a streamed event replaces it as soon as the lookup completes (an + unchanged cached no-answer row renders `N/A` until the row's freshness + expires and a retry finds a valid answer — see `recentResolveWindow`; it + does not stay `N/A` forever by construction). The streamed map survives + disable/enable cycles in the client store, which is what makes re-enable + show cached results before the backend re-query streams deltas. +- The sessions list refreshes when the window regains focus (and on mount / + manual reload), so sessions created elsewhere appear without a full reload; + their IPs are then resolved by the existing list-fetch trigger. No + session-created push event is added. +- When the toggle transitions to on — same tab or another tab (the + `user.settings.updated` push updates the store) — the sessions list is + refetched once. This is what renders backend-cached hostnames when the + list was last loaded while the toggle was off (those responses carried + `hostname: ""`), and it also discovers sessions created meanwhile. +- Identity policy for the in-memory streamed map: it survives disable/enable + cycles and same-identity refreshes, and is **cleared when the + authenticated identity changes** — logout, a different user logging in, or + the 401/session-expiry path (`clearAuthenticated`) — so one user's session + IPs never seed another user's rows. Because the gateway strips `user_id` + from streamed events, the stream is fenced on identity transitions: a + handler-generation epoch guard ignores events from a prior identity, and + the WS client is recreated on identity change so transport-queued frames + are dropped — a delayed old-user event can never repopulate the cleared + map. +- `N/A` is localized copy (`account:hostnameNotAvailable`); all new copy goes + through `t()` and every locale catalog including pseudo. + +### Mobile + +- The toggle row is a standard 44 px+ touch target; the sessions table remains + the sole internal scroll owner with no document-level horizontal overflow + when the Hostname column is added. +- The account security page is reachable on the Pixel 5 path; a + `mobile-*.spec.ts` in the `mobile-chrome` project covers enable/disable, + column presence, and N/A/cached rendering (same env-robust assertions as + the desktop spec). + +## Permissions + +- The toggle lives in per-user settings; `PATCH /api/v1/user/settings` is + already identity-scoped. +- The sessions endpoint and the new streaming action are user-scoped: the + gateway routes `auth.session.hostname.resolved` with `Hub.BroadcastToUser` + to the interested user's own connections only; the payload never contains a + `user_id`. +- The cache is populated only from the user's own session IPs; no endpoint + exposes the cache directly. + +## State machine + +- Toggle off: no lookups, no column, sessions response carries `hostname: + ""`. +- Toggle on: sessions list triggers a resolution pass; per-IP jobs run once + (dedup by in-flight + `recentResolveWindow`); each changed result is + streamed; the column renders cached + streamed values. +- Toggle off: lookups already in flight complete and update the cache, but + their events are only sent to interested users; the column hides. +- Toggle on again: cached values render immediately from the client store and + the sessions response; a new resolution pass streams only deltas. + +## Failure modes + +- Settings-gate read error: the resolver cannot confirm the toggle is on, so + it **fails closed** — returns an empty hostname map, schedules no lookups, + and logs. DNS queries must never run while opt-in is unknown. +- DNS timeout/temporary/server error: cell shows `N/A` only when nothing + durable is known; nothing persisted; streamed `""` only when the IP has no + cache entry, never over an existing cached value; the next trigger (new + session list load or toggle) retries. A `*net.DNSError` with `IsNotFound` + is NOT an error: it is a successful negative answer persisted as + `hostname = ""` (cached no-answer). +- Cache read error on the sessions API path: the affected IPs return `""` + (no hostname), no lookups are scheduled for them (baseline unknown), the + request is not failed, and the error is logged. +- Cache read error on lookup completion: the baseline is unknown, so the + result is neither persisted nor published (a change cannot be verified); + the error is logged and the next trigger retries. +- Cache write error on lookup completion: logged; **nothing is published** + — the value was not persisted, so changed-only semantics do not admit an + event (a transient publish could outlive its own retry and the frontend + ordering would treat it inconsistently). The next trigger retries the + persist; a later successful `Set` that changes the cache publishes + normally. `resolved_at: null` remains reserved for transient DNS + errors streamed over an absent cache (empty-string events), never for + failed writes. +- Event bus unavailable (nil): no stream; cached values still flow through + the sessions response. +- Invalid/empty session IP (e.g. sessions recorded before IP capture): + skipped, cell shows `N/A`. +- Multi-user instance: user A's lookup for an IP is shared with user B only + when B also triggered a lookup for that same IP (B is registered as + interested, with B's raw IP spelling); the cache itself is shared by IP, + which is safe because PTR answers are not user-specific. + +## Persistence guarantees + +- The toggle is durable in the user settings JSON blob (default false). +- The hostname cache is durable in `auth_hostname_cache` and survives backend + restarts, which is what makes "re-enable shows cached results" hold even + after a restart. +- The frontend streamed map is in-memory only; a page reload re-hydrates from + the sessions response (backend cache) and the next lookup pass. + +## Data model + +```sql +CREATE TABLE IF NOT EXISTS auth_hostname_cache ( + ip TEXT PRIMARY KEY, -- canonical IP literal + hostname TEXT NOT NULL DEFAULT '', -- "" = no answer (N/A) + resolved_at TEXT NOT NULL -- RFC3339 UTC text; drives recentResolveWindow +); +``` + +`resolved_at` is stored as RFC3339 UTC **text with a fixed 9-digit +fractional-second field** (dialect-portable across the SQLite/Postgres +`db.Pool`) and parsed explicitly on read; fixed width keeps the strings +lexicographically comparable, so changes within the same second — or the +same millisecond — still order correctly. + +## API surface + +- `PATCH /api/v1/user/settings` accepts `resolve_session_hostnames: boolean`. +- `GET /api/v1/auth/sessions` per-session response gains `hostname: string` + and `hostname_resolved_at: string | null`. +- WS notification `auth.session.hostname.resolved`: `{ip, hostname, + resolved_at}`. +- No new HTTP endpoints. + +## Acceptance criteria + +1. Toggle defaults to off for new users, existing settings blobs, and + initial boot payloads; toggling persists and survives reload. +2. Enabling shows the Hostname column immediately (cached values or `N/A`), + and streamed results fill in rows without a reload; there is no pending + spinner state (empty always renders `N/A`). Enabling also refetches the + sessions list so backend-cached hostnames render even when the list was + last loaded while the toggle was off (same tab or another tab). +3. Disabling hides the column; re-enabling within the same session shows + cached values immediately and streams only changed values. +4. Sessions created elsewhere appear on the next list refresh (window + focus, the enable-transition refetch, or reload) and their hostnames then + resolve and stream into the new rows. +5. The in-memory hostname map survives disable/enable and same-identity + refreshes and is cleared when the authenticated identity changes + (logout, different user, or the 401/`clearAuthenticated` path). +6. No-answer IPs and generic `.in-addr.arpa` / `.ip6.arpa` answers render + `N/A`. +7. Lookups use the OS resolver only: the lookup function is injectable and + defaults to `net.DefaultResolver.LookupAddr`; no HTTP/API calls exist in + the lookup path. +8. `GET /api/v1/auth/sessions` includes `hostname` (cached value or `""`) + without auth-mode changes. +9. Streaming events are routed to the triggering user's own connections only + and never include `user_id`. +10. Streamed event `ip` values match the raw session IP spelling the + frontend already holds, so rows update without client-side IP parsing; + the sessions handler looks up cached hostnames by the raw stored + spelling (the resolver canonicalizes internally), so non-canonical + spellings still resolve, and equivalent IP spellings still share one + cache row and one lookup job. +11. Cache read/write failures degrade per the Failure modes section without + failing the sessions API or panicking (nil event bus included). +12. A transient DNS failure never masks a durable cached hostname: the + backend publishes transient `""` only for IPs with no cache entry, and + the frontend compares `resolved_at` (durable beats transient; newer + wins), so a stale-cache + transient-error + re-enable cycle still + restores the cached value. +13. After a WebSocket gap, a newer sessions response reconciles the retained + streamed map: an entry whose cache `resolved_at` is newer than the + retained map value replaces it (unit regression: event → disconnect → + list refresh), and a fresh streamed event still beats an in-flight stale + response. `resolved_at` uses fractional-second precision so two changes + within one second still order correctly. +14. A user who scheduled resolution from a stale snapshot still receives the + current cache value when it changed since their read (admission + observed-value diff), so a read → concurrent completion → enqueue + interleaving never silently drops the streamed update. +15. New frontend copy is localized (all catalogs + pseudo) and passes + `i18n:ratchet`.