Repository navigation
fix(api,web): X-TCDI-Poll marker stops background polls sliding the portal idle window (FIX-IDLE) - #133
Open
vanlongme wants to merge 8 commits into
Open
fix(api,web): X-TCDI-Poll marker stops background polls sliding the portal idle window (FIX-IDLE)#133vanlongme wants to merge 8 commits into
vanlongme wants to merge 8 commits into
Conversation
…X-IDLE) Interval polls on RequireAuth routes (workspaces list/detail/events, data list/detail, the session page's "starting" poll, /v1/me backoff retries) used to keep a visible-but-unattended portal tab's session alive forever. The SPA now marks timer-driven reads with X-TCDI-Poll: background and requireAuth reads marked requests with Peek instead of Get; navigation, manual refresh, return-to-visible reloads and mutations still slide. The marker is honoured on every authenticated route and can only withhold an idle slide, never earn one. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Literal "X-TCDI-Poll: background" instead of the new constants keeps the regression test compiling on v0.5.0, where it then fails at the "marked poll past idle" step (204, want 401) — proving the pre-fix slide. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ow (FIX-IDLE) Advisor-steered redesign (supersedes the client marker at c5745e6): the server decides what is activity — every cookie-authenticated GET mounts RequireAuthPassive, so the portal's interval polls can no longer hold a visible-but-unattended session open; only mutations and server-measured desktop input slide the idle window. The SPA needs no marker. SR-1-F2: the lease layer now consults the same liveness rule as RequireAuth — WithSessionIdle plumbs cfg.SessionIdle into the broker and the shared portalSessionLiveSQL predicate (epoch + absolute expiry + last_seen_at inside the window) is applied by loadLease's CASE and RedeemTicket's session re-check, so redeem, renew and rehydrate all fail closed on an idled-out session and the lease is revoked on the spot. Renew deliberately only consults, never slides: a sliding renew would let a connected-but-idle stream pin the session open — the same bug through the desktop path. SR-1-F3: desktop input now credits exactly the session the stream's lease was minted under — loadLease selects the bound portal_session_digest and the input hook carries it to the new digest-scoped store method TouchSessionDigest, instead of sliding every session of the principal. A NULL-digest legacy lease keeps the principal-wide fallback so pre-upgrade streams survive a rolling deploy. Tests: idlepoll_test.go pins that polled GETs never slide (fails on the old sliding mounts) while mutations still do; lease_session_test gains idle-dead renew/redeem/rehydrate cases on real Postgres; me_test pins digest-scoped input vs a same-principal sibling; session_touch_test pins the pkey index and the scoping/no-revival behaviour. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
With every cookie-authenticated GET now passive, real user interaction needed a way to slide the idle window — read-only portal use is activity. POST /v1/session:touch is a bodiless mutation: RequireAuth's sliding read extends the deadline, RequireCSRF guards it like any other write, the login-family limiter keys it on the session digest, and the audited wrapper emits session.touch. The SPA sends it on pointerdown, keydown and route changes only — throttled to one call per minute in useActivityTouch (web/src/session/activity.ts), never from timer polls. Server tests pin the semantics: polls alone let the session expire, one touch slides it, CSRF/session are both required (403/401). Portal tests pin that pointer/key/route events beat while useResource's timer ticks never do. OpenAPI + generated types updated. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Reviewer finding: the beat's RequireAuth ran its sliding read before RequireCSRF, so a cookie-only POST that failed the token check still extended the idle window — a slide the caller never earned. The chain is now RequireAuthPassive + RequireCSRF and the handler slides explicitly with Get only after both gates pass. Denied requests change nothing: the test proves a 403'd touch leaves the window to lapse on schedule. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Portal sessions carry a sliding idle timeout, but the SPA polls
GET /v1/workspaces,GET /v1/workspaces/{id},GET /v1/workspaces/{id}/events(and the data lists) every 8–10 s on a visible tab — and those routes were mounted behind slidingRequireAuth, so an open but unattended tab kept the session alive forever.Design (server-side rule — no client marker)
The server decides what counts as activity; the client never does:
GETmountsRequireAuthPassive(Peek, no slide): workspaces list/detail/events, templates, data list/detail,/v1/me,/v1/quota, and the admin reads./v1/sessionand/v1/workspaces/{id}/connectionwere already passive. Interval polls can therefore never hold a session open.POST /v1/session:touchactivity beat (passive auth + CSRF; the handler slides explicitly withGetonly after the token check, so a cookie-only POST never earns a slide; bodiless204; auditedsession.touch; wrapped in the login-family limiter keyed on the session digest). The SPA fires it fromuseActivityTouchonpointerdown,keydownand route changes — throttled to one beat per 60 s, never from timer polls or background fetches. Forging it needs cookie + CSRF — the same bar as any mutation.inputevents only (existingInputHook→ session store touch, 1/min throttle). Lease renew deliberately does not slide — a sliding renew would let a connected-but-idle stream pin the session open, reintroducing the same bug through the desktop path. Net idle semantics: no user interaction AND no desktop input for the whole window.SR-1-F2 (lease layer honours the idle window)
WithSessionIdle(cfg.SessionIdle)gives the broker the portal idle window, and the sharedportalSessionLiveSQLpredicate (epoch + absolute expiry +last_seen_atinside the window) now backs bothloadLease's CASE andRedeemTicket's session re-check. Redeem, renew and rehydrate all fail closed on an idle-dead session — the lease is revoked on the spot (reason=invalid), so the stream dies within one renew cycle instead of outliving its session. NULL-digest legacy leases stay exempt.SR-1-F3 (input touch scoped to the bound session)
Desktop input used to slide every session of the principal.
loadLeasenow selects the boundportal_session_digestand the input hook carries it to the newTouchSessionDigeststore method (single-row, pkey-keyed, same never-revive guards). NULL-digest legacy leases keep the principal-wide fallback so pre-upgrade streams survive a rolling deploy.Tests
go test -race ./internal/api/ -count=1go test ./internal/broker/ -count=1TestLeaseSession_IdleDeadSessionRevokesOnRenew,RedeemRejectsIdleDeadSession,IdleCheckDisabledWithoutOptiongo test -tags integration ./tests/integration/ -run TestTouchTestTouchSessionDigest_ScopesToBoundSession, pkey-index EXPLAIN pinsgo test ./internal/...internal/operatortests fail locally for missingbin/k8sbinaries — pre-existing env gap; CI has them)npm run test:unitactivity.test.tsx: polls never touch; pointer/key/route beats once per 60 snpm run typecheck/npm run build/lint:stringsnpm run generatecommitted (schema.d.ts)origin/mainTestPolledReads_DoNotSlideIdleWindow+TestMeRead_DoesNotSlideIdleWindowFAIL on the unfixed mounts, PASS hereAlso pinned:
TestSessionTouch_SlidesIdleWindow(touch slides; 403 without CSRF; 401 anonymous),TestMutation_StillSlidesIdleWindow,TestInputActivity_SlidesOnlyBoundSession,TestInputActivity_NullDigestFallsBackToPrincipal. Docs updated:openapi.yamlidle-timeout section,docs/lifecycle-reasons.mdportal-idle note,docs/security/threat-model.md.git diff --stat origin/main...HEAD: