Add inactivity-based session timeout (closes #66) #105

Open
clawbot wants to merge 1 commits from issue-66-session-idle-timeout into main
Collaborator

Closes #66.

Sessions had only a 7-day absolute lifetime, so an abandoned session stayed usable for the full week. Worse, that cap was enforced only by the cookie's MaxAge — i.e. only by the browser. sessions.NewCookieStore gives its codecs a 30-day max age, and the app replaces store.Options wholesale afterwards, which does not touch the codecs; a client that simply kept sending the cookie was never told no by the server. Both clocks are now server-enforced.

Idle vs absolute: two clocks, deliberately separate

Two timestamps live in the session, as Unix seconds:

Key Clock Written by Deadline
created_at (CreatedAtKey) absolute SetUser, once, at login created_at + 7d (sessionAbsoluteMaxAge, the existing sessionMaxAgeDays)
last_seen (LastSeenKey) idle SetUser, then Touch on activity last_seen + SESSION_IDLE_TIMEOUT

Session.expired checks the absolute deadline first and the idle deadline second, and the session ends at whichever comes first. Nothing rewrites created_at — that is the whole point of a cap, and the constant's doc comment says so. Touch writes last_seen and nothing else.

IsAuthenticated now consults expired. That is the single choke point every existing caller already goes through — the auth middleware, handlers/index.go, handlers/auth.go, handlers/profile.go, source_management.go — so there is no route that honours one clock and not the other, and no call site had to be taught about expiry.

Fail-closed detail: an authenticated session carrying no timestamps (a cookie minted before this change) is treated as expired. The upgrade costs one forced re-login rather than handing out an unbounded session.

What counts as activity, and where it is refreshed

Activity is a request that passes RequireAuth. Touch is called there and only there, after IsAuthenticated has already succeeded, and the session is saved before the wrapped handler runs so the headers are still ours to write.

An unauthenticated request cannot refresh anything: the middleware takes the redirect branch and returns before reaching Touch, and no public route (the webhook receiver, the login page, /) calls it at all. So nobody can keep someone else's session alive by pointing a stolen or abandoned cookie at a public endpoint. Touch does not rely on that: it re-checks IsAuthenticated itself and returns false for any session that is unauthenticated or already expired, so it cannot revive a dead session either.

Write-amplification tradeoff

These are cookie sessions, so "saving" means re-encrypting the session and emitting a Set-Cookie on the response. Refreshing on literally every authenticated request would do that on every response.

Instead Touch rewrites last_seen only once it is older than idleTimeout / idleRefreshDivisor (a tenth of the window), and returns a bool so the middleware saves only when something actually changed. An actively used session is therefore re-issued at most ten times per idle window instead of once per request.

The chosen consequence: last_seen lags the user's real last request by up to a tenth of the window, so a session can expire up to 10% early — at the 24h default, up to ~2.4h early in the worst case. It can never expire late, since the stored timestamp is always at or before the true last request. Ten was picked as the point where the lag is small relative to a day-long window while the write rate is bounded by a constant rather than by traffic.

The cookie's own MaxAge is deliberately left at the 7-day absolute cap rather than being retuned to the idle window; the server-side timestamps are the enforcement, and the cookie lifetime is only a browser-side hint.

Configuration

SESSION_IDLE_TIMEOUT, default 24h, parsed with the existing envDuration helper, so a set-but-unparseable value aborts startup instead of silently falling back (matching #78/#80). Non-positive disables idle expiry and leaves the absolute cap as the only bound.

The internal/config change is deliberately three lines — one constant, one Config field, one parse call — and restructures nothing, so the rebase over #92's loadFromEnv is mechanical.

Tests

All expiry tests use an injected clock (Session.now, threaded through NewForTest); no sleeps. Full make test on a cold cache: 11.2s, well inside the 20s policy.

internal/session:

  • TestIsAuthenticated_IdleExpired / _WithinIdleWindow — the window boundary
  • TestTouch_DoesNotExtendAbsoluteCap — the refresh-the-wrong-clock regression test. Drives 335 requests, one every half idle window, across the whole 7 days, asserting the session survives each; then one more step lands exactly on the absolute cap and the session must be dead, with created_at byte-identical to its login value
  • TestTouch_RefreshesIdleDeadline — refreshed session outlives the original deadline and dies one window after the activity
  • TestTouch_LazyBelowRefreshThreshold — no rewrite below the threshold
  • TestTouch_UnauthenticatedSessionIsNotRefreshed, TestTouch_IdleExpiredSessionIsNotRevived
  • TestIsAuthenticated_MissingTimestamps / _MissingLastSeen — fail closed
  • TestIdleTimeoutDisabled_AbsoluteCapStillApplies
  • TestSetUser_StartsBothClocks, TestClearUser_RemovesTimestamps

internal/middleware:

  • TestRequireAuth_IdleExpiredSession_RedirectsToLogin — and asserts no session cookie is emitted, so an expired cookie cannot be revived by hitting a protected route
  • TestRequireAuth_RefreshesIdleDeadlineOnActivity — the re-issued cookie works past the original deadline while the pre-refresh cookie does not
  • TestRequireAuth_UnauthenticatedRequestDoesNotRefresh

internal/config: TestSessionIdleTimeout (default / parsed / unparseable-aborts-startup), mirroring the RETENTION_SWEEP_INTERVAL table test. Its shared "startup must fail" helper was renamed testRetentionSweepIntervalError -> expectStartupError now that two tests use it.

Mutation evidence

Three mutations, each applied alone, reverted after (diff against a pre-mutation copy confirmed byte-identical restore):

  1. Idle check deleted from expired (return false in its place) — 6 failures: TestIsAuthenticated_IdleExpired, TestIsAuthenticated_MissingLastSeen, TestTouch_RefreshesIdleDeadline, TestTouch_IdleExpiredSessionIsNotRevived, TestRequireAuth_IdleExpiredSession_RedirectsToLogin, TestRequireAuth_RefreshesIdleDeadlineOnActivity.
  2. The bug the issue warns about: added sess.Values[CreatedAtKey] = now.Unix() to Touch, so activity refreshes the absolute clock too — TestTouch_DoesNotExtendAbsoluteCap fails on both assertions, the behavioural one (Should be false / "activity must not extend the absolute cap") and the anchor one (expected: 1767323045, actual: 1767926045 / "Touch must never rewrite the absolute-clock anchor"). Worth noting: my first draft of that test was caught only by the white-box anchor assertion, because it jumped a full 7 days at the end and so expired even under the mutation. The test was rewritten to advance exactly one more step to the true cap, which is what makes the behavioural assertion bite.
  3. Auth guard removed from Touch (the unauthenticated-refresh hazard) — TestTouch_UnauthenticatedSessionIsNotRefreshed and TestTouch_IdleExpiredSessionIsNotRevived fail.

Verification

  • make fmt run; make check exit 0 (test + lint + fmt-check), lint 0 issues, all lint output confined to this worktree with no parallel-lock error.
  • script/cibuild exit 0 in 4m02s wall — a genuine execution, not a cache serve: the commit changed the tree, so COPY . . invalidated the layers and make fmt-check, make lint (pinned golangci-lint v2.12.2) and make test all ran inside the image. A confirming second run immediately after reports every one of those layers CACHED, which is only possible because the first run really produced them.
  • .golangci.yml untouched — sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. Dockerfile still pinned to golangci-lint v2.12.2 at its digest.
  • TODO.md updated in the same commit; markdown formatted via make fmt.

Not done here

Regenerate copies session values and is only ever called at login, immediately before SetUser, which re-stamps both clocks — so a login correctly resets the absolute cap. No other caller exists, and session handling is otherwise unchanged.

Closes #66. Sessions had only a 7-day absolute lifetime, so an abandoned session stayed usable for the full week. Worse, that cap was enforced only by the cookie's `MaxAge` — i.e. only by the browser. `sessions.NewCookieStore` gives its codecs a 30-day max age, and the app replaces `store.Options` wholesale afterwards, which does not touch the codecs; a client that simply kept sending the cookie was never told no by the server. Both clocks are now server-enforced. ## Idle vs absolute: two clocks, deliberately separate Two timestamps live in the session, as Unix seconds: | Key | Clock | Written by | Deadline | | --- | --- | --- | --- | | `created_at` (`CreatedAtKey`) | **absolute** | `SetUser`, once, at login | `created_at + 7d` (`sessionAbsoluteMaxAge`, the existing `sessionMaxAgeDays`) | | `last_seen` (`LastSeenKey`) | **idle** | `SetUser`, then `Touch` on activity | `last_seen + SESSION_IDLE_TIMEOUT` | `Session.expired` checks the absolute deadline first and the idle deadline second, and the session ends at whichever comes first. Nothing rewrites `created_at` — that is the whole point of a cap, and the constant's doc comment says so. `Touch` writes `last_seen` and nothing else. `IsAuthenticated` now consults `expired`. That is the single choke point every existing caller already goes through — the auth middleware, `handlers/index.go`, `handlers/auth.go`, `handlers/profile.go`, `source_management.go` — so there is no route that honours one clock and not the other, and no call site had to be taught about expiry. Fail-closed detail: an authenticated session carrying no timestamps (a cookie minted before this change) is treated as expired. The upgrade costs one forced re-login rather than handing out an unbounded session. ## What counts as activity, and where it is refreshed Activity is a request that passes `RequireAuth`. `Touch` is called there and only there, after `IsAuthenticated` has already succeeded, and the session is saved before the wrapped handler runs so the headers are still ours to write. An unauthenticated request cannot refresh anything: the middleware takes the redirect branch and returns before reaching `Touch`, and no public route (the webhook receiver, the login page, `/`) calls it at all. So nobody can keep someone else's session alive by pointing a stolen or abandoned cookie at a public endpoint. `Touch` does not rely on that: it re-checks `IsAuthenticated` itself and returns false for any session that is unauthenticated or already expired, so it cannot revive a dead session either. ## Write-amplification tradeoff These are cookie sessions, so "saving" means re-encrypting the session and emitting a `Set-Cookie` on the response. Refreshing on literally every authenticated request would do that on every response. Instead `Touch` rewrites `last_seen` only once it is older than `idleTimeout / idleRefreshDivisor` (a tenth of the window), and returns a bool so the middleware saves only when something actually changed. An actively used session is therefore re-issued at most ten times per idle window instead of once per request. The chosen consequence: `last_seen` lags the user's real last request by up to a tenth of the window, so a session can expire up to 10% early — at the 24h default, up to ~2.4h early in the worst case. It can never expire late, since the stored timestamp is always at or before the true last request. Ten was picked as the point where the lag is small relative to a day-long window while the write rate is bounded by a constant rather than by traffic. The cookie's own `MaxAge` is deliberately left at the 7-day absolute cap rather than being retuned to the idle window; the server-side timestamps are the enforcement, and the cookie lifetime is only a browser-side hint. ## Configuration `SESSION_IDLE_TIMEOUT`, default `24h`, parsed with the existing `envDuration` helper, so a set-but-unparseable value aborts startup instead of silently falling back (matching #78/#80). Non-positive disables idle expiry and leaves the absolute cap as the only bound. The `internal/config` change is deliberately three lines — one constant, one `Config` field, one parse call — and restructures nothing, so the rebase over #92's `loadFromEnv` is mechanical. ## Tests All expiry tests use an injected clock (`Session.now`, threaded through `NewForTest`); no sleeps. Full `make test` on a cold cache: **11.2s**, well inside the 20s policy. `internal/session`: - `TestIsAuthenticated_IdleExpired` / `_WithinIdleWindow` — the window boundary - `TestTouch_DoesNotExtendAbsoluteCap` — the refresh-the-wrong-clock regression test. Drives 335 requests, one every half idle window, across the whole 7 days, asserting the session survives each; then one more step lands exactly on the absolute cap and the session must be dead, with `created_at` byte-identical to its login value - `TestTouch_RefreshesIdleDeadline` — refreshed session outlives the original deadline and dies one window after the activity - `TestTouch_LazyBelowRefreshThreshold` — no rewrite below the threshold - `TestTouch_UnauthenticatedSessionIsNotRefreshed`, `TestTouch_IdleExpiredSessionIsNotRevived` - `TestIsAuthenticated_MissingTimestamps` / `_MissingLastSeen` — fail closed - `TestIdleTimeoutDisabled_AbsoluteCapStillApplies` - `TestSetUser_StartsBothClocks`, `TestClearUser_RemovesTimestamps` `internal/middleware`: - `TestRequireAuth_IdleExpiredSession_RedirectsToLogin` — and asserts no session cookie is emitted, so an expired cookie cannot be revived by hitting a protected route - `TestRequireAuth_RefreshesIdleDeadlineOnActivity` — the re-issued cookie works past the original deadline while the pre-refresh cookie does not - `TestRequireAuth_UnauthenticatedRequestDoesNotRefresh` `internal/config`: `TestSessionIdleTimeout` (default / parsed / unparseable-aborts-startup), mirroring the `RETENTION_SWEEP_INTERVAL` table test. Its shared "startup must fail" helper was renamed `testRetentionSweepIntervalError` -> `expectStartupError` now that two tests use it. ## Mutation evidence Three mutations, each applied alone, reverted after (`diff` against a pre-mutation copy confirmed byte-identical restore): 1. **Idle check deleted** from `expired` (`return false` in its place) — 6 failures: `TestIsAuthenticated_IdleExpired`, `TestIsAuthenticated_MissingLastSeen`, `TestTouch_RefreshesIdleDeadline`, `TestTouch_IdleExpiredSessionIsNotRevived`, `TestRequireAuth_IdleExpiredSession_RedirectsToLogin`, `TestRequireAuth_RefreshesIdleDeadlineOnActivity`. 2. **The bug the issue warns about**: added `sess.Values[CreatedAtKey] = now.Unix()` to `Touch`, so activity refreshes the absolute clock too — `TestTouch_DoesNotExtendAbsoluteCap` fails on both assertions, the behavioural one (`Should be false` / "activity must not extend the absolute cap") and the anchor one (`expected: 1767323045, actual: 1767926045` / "Touch must never rewrite the absolute-clock anchor"). Worth noting: my first draft of that test was caught only by the white-box anchor assertion, because it jumped a full 7 days at the end and so expired even under the mutation. The test was rewritten to advance exactly one more step to the true cap, which is what makes the behavioural assertion bite. 3. **Auth guard removed** from `Touch` (the unauthenticated-refresh hazard) — `TestTouch_UnauthenticatedSessionIsNotRefreshed` and `TestTouch_IdleExpiredSessionIsNotRevived` fail. ## Verification - `make fmt` run; `make check` exit 0 (test + lint + fmt-check), lint `0 issues`, all lint output confined to this worktree with no parallel-lock error. - `script/cibuild` exit 0 in **4m02s wall** — a genuine execution, not a cache serve: the commit changed the tree, so `COPY . .` invalidated the layers and `make fmt-check`, `make lint` (pinned golangci-lint v2.12.2) and `make test` all ran inside the image. A confirming second run immediately after reports every one of those layers `CACHED`, which is only possible because the first run really produced them. - `.golangci.yml` untouched — sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. Dockerfile still pinned to golangci-lint v2.12.2 at its digest. - `TODO.md` updated in the same commit; markdown formatted via `make fmt`. ## Not done here `Regenerate` copies session values and is only ever called at login, immediately before `SetUser`, which re-stamps both clocks — so a login correctly resets the absolute cap. No other caller exists, and session handling is otherwise unchanged.
clawbot added 1 commit 2026-08-09 08:05:33 +02:00
Add inactivity-based session timeout (closes #66)
All checks were successful
check / check (push) Successful in 3m6s
b04bc2cc7b
Sessions had only a 7-day absolute lifetime, and that cap was enforced
only by the cookie's MaxAge -- i.e. only by the browser. An abandoned
session stayed usable for the full week.

Sessions are now bounded by two independent, server-enforced clocks,
and end at whichever expires first:

- absolute: created_at + 7 days, stamped once by SetUser and never
  rewritten, so no amount of activity can extend it
- idle: last_seen + SESSION_IDLE_TIMEOUT (default 24h), pushed forward
  by the new Session.Touch

Both deadlines are checked in Session.expired, which IsAuthenticated
now consults, so every existing authentication decision honours them
without each call site having to remember.

Activity means a request that passes RequireAuth, which is the only
place Touch is called; an unauthenticated request carrying the cookie
cannot keep a session alive. Touch re-checks authentication itself so
that guarantee does not depend on the call site.

To avoid re-issuing the session cookie on every authenticated request,
Touch rewrites last_seen only once it is older than a tenth of the idle
window. The session therefore expires up to 10% early relative to the
user's true last request, never late.

An authenticated session carrying no timestamps (a cookie minted before
this change) is treated as expired, so the failure mode of the upgrade
is one forced re-login rather than an unbounded session.

Tests use an injected clock rather than sleeps and cover idle expiry,
refresh on activity, an actively used session still dying at the
absolute cap, refusal to refresh unauthenticated or expired sessions,
disabled idle expiry, and startup aborting on an unparseable
SESSION_IDLE_TIMEOUT.
clawbot added the needs-review label 2026-08-09 08:05:39 +02:00
clawbot self-assigned this 2026-08-09 08:05:39 +02:00
Author
Collaborator

What I built

One commit, b04bc2c, on main @ 4f5ecb1.

Sessions now carry two timestamps and are bounded by two clocks that cannot be confused for one another:

  • created_at is stamped once by SetUser at login and anchors the absolute 7-day cap. Nothing rewrites it.
  • last_seen anchors the idle window (SESSION_IDLE_TIMEOUT, default 24h) and is pushed forward by the new Session.Touch.

Session.expired checks both and the session dies at whichever deadline arrives first. IsAuthenticated consults it, which puts the check on the one path every caller already takes, so no handler needed changing and none can skip it.

Touch is called from exactly one place, RequireAuth, after authentication has already succeeded. Unauthenticated requests return on the redirect branch before reaching it and no public route calls it at all, so a cookie pointed at a public endpoint cannot keep a session alive. Touch additionally refuses any session that is not currently authenticated and unexpired, so that property does not rest on the call site behaving.

Touch rewrites last_seen only once it is older than a tenth of the idle window, and returns a bool so the middleware re-issues the cookie only when something changed. Deliberate cost: the stored timestamp lags real activity by up to that tenth, so a session can expire up to 10% early — never late.

Also fixed as a side effect: the absolute cap used to be enforced only by the cookie's MaxAge, i.e. only by the browser, because NewCookieStore leaves its codecs at 30 days and replacing store.Options does not change them. Both deadlines are now server-side.

internal/config gains exactly one constant, one field and one envDuration call, so #92's loadFromEnv rewrite rebases mechanically.

How I verified it

  • make check exit 0 — test + lint + fmt-check. Lint reported 0 issues, with no output referencing anything outside this worktree and no parallel-lock error.
  • make test full cold-cache run: 11.2s, inside the 20s policy. All expiry tests use an injected clock; no sleeps anywhere.
  • script/cibuild exit 0 in 4m02s wall, and it genuinely ran rather than being served from cache: the commit changed the tree, so COPY . . invalidated the downstream layers and make fmt-check, make lint (pinned golangci-lint v2.12.2) and make test all executed inside the image. The confirming signal is that an immediate second script/cibuild reports those same layers CACHED, which is only possible because the first run produced them.
  • Gitea CI check / check (push) on head b04bc2c — run 107.
  • .golangci.yml untouched, sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. Dockerfile still pins golangci-lint v2.12.2 by digest.

Mutation evidence

Each mutation applied on its own and reverted afterwards, with the restored file confirmed byte-identical to a pre-mutation copy.

  1. Idle branch deleted from expired -> 6 tests fail, including TestIsAuthenticated_IdleExpired and both middleware idle tests.
  2. Touch also writes created_at — the exact bug the issue warns about -> TestTouch_DoesNotExtendAbsoluteCap fails on the behavioural assertion ("activity must not extend the absolute cap") and on the anchor assertion ("Touch must never rewrite the absolute-clock anchor"). My first draft of that test only caught it via the anchor assertion, because it advanced a full 7 days at the end and so expired even under the mutation; it now advances exactly one more step onto the true cap, which makes the behavioural assertion bite.
  3. The IsAuthenticated guard removed from Touch -> TestTouch_UnauthenticatedSessionIsNotRefreshed and TestTouch_IdleExpiredSessionIsNotRevived fail.
## What I built One commit, `b04bc2c`, on `main` @ `4f5ecb1`. Sessions now carry two timestamps and are bounded by two clocks that cannot be confused for one another: - `created_at` is stamped once by `SetUser` at login and anchors the **absolute** 7-day cap. Nothing rewrites it. - `last_seen` anchors the **idle** window (`SESSION_IDLE_TIMEOUT`, default `24h`) and is pushed forward by the new `Session.Touch`. `Session.expired` checks both and the session dies at whichever deadline arrives first. `IsAuthenticated` consults it, which puts the check on the one path every caller already takes, so no handler needed changing and none can skip it. `Touch` is called from exactly one place, `RequireAuth`, after authentication has already succeeded. Unauthenticated requests return on the redirect branch before reaching it and no public route calls it at all, so a cookie pointed at a public endpoint cannot keep a session alive. `Touch` additionally refuses any session that is not currently authenticated and unexpired, so that property does not rest on the call site behaving. `Touch` rewrites `last_seen` only once it is older than a tenth of the idle window, and returns a bool so the middleware re-issues the cookie only when something changed. Deliberate cost: the stored timestamp lags real activity by up to that tenth, so a session can expire up to 10% early — never late. Also fixed as a side effect: the absolute cap used to be enforced only by the cookie's `MaxAge`, i.e. only by the browser, because `NewCookieStore` leaves its codecs at 30 days and replacing `store.Options` does not change them. Both deadlines are now server-side. `internal/config` gains exactly one constant, one field and one `envDuration` call, so #92's `loadFromEnv` rewrite rebases mechanically. ## How I verified it - `make check` exit 0 — test + lint + fmt-check. Lint reported `0 issues`, with no output referencing anything outside this worktree and no parallel-lock error. - `make test` full cold-cache run: **11.2s**, inside the 20s policy. All expiry tests use an injected clock; no sleeps anywhere. - `script/cibuild` exit 0 in **4m02s wall**, and it genuinely ran rather than being served from cache: the commit changed the tree, so `COPY . .` invalidated the downstream layers and `make fmt-check`, `make lint` (pinned golangci-lint v2.12.2) and `make test` all executed inside the image. The confirming signal is that an immediate second `script/cibuild` reports those same layers `CACHED`, which is only possible because the first run produced them. - Gitea CI `check / check (push)` on head `b04bc2c` — run 107. - `.golangci.yml` untouched, sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. Dockerfile still pins golangci-lint v2.12.2 by digest. ## Mutation evidence Each mutation applied on its own and reverted afterwards, with the restored file confirmed byte-identical to a pre-mutation copy. 1. Idle branch deleted from `expired` -> 6 tests fail, including `TestIsAuthenticated_IdleExpired` and both middleware idle tests. 2. `Touch` also writes `created_at` — the exact bug the issue warns about -> `TestTouch_DoesNotExtendAbsoluteCap` fails on the behavioural assertion ("activity must not extend the absolute cap") and on the anchor assertion ("Touch must never rewrite the absolute-clock anchor"). My first draft of that test only caught it via the anchor assertion, because it advanced a full 7 days at the end and so expired even under the mutation; it now advances exactly one more step onto the true cap, which makes the behavioural assertion bite. 3. The `IsAuthenticated` guard removed from `Touch` -> `TestTouch_UnauthenticatedSessionIsNotRefreshed` and `TestTouch_IdleExpiredSessionIsNotRevived` fail.
Author
Collaborator

Correction on the CI line above, so nothing here reads as a green I did not earn.

Gitea CI run 107 (check / check (push)) on head b04bc2c is still reported as pending / "Waiting to run" seventeen minutes after the push — it has not started, let alone passed. The shared runner is saturated at the moment. I am not going to sit on the task waiting for it, so: no CI verdict exists for this commit yet. Whoever reviews should confirm run 107 actually went green before treating CI as evidence.

What I can stand behind is local: make check exit 0, and a script/cibuild that genuinely executed for 4m02s (the commit invalidated COPY . ., so make fmt-check, make lint and make test all ran inside the pinned v2.12.2 image; an immediate second run reports those layers CACHED, which is only possible because the first run produced them).

Correction on the CI line above, so nothing here reads as a green I did not earn. Gitea CI run 107 (`check / check (push)`) on head `b04bc2c` is still reported as `pending` / "Waiting to run" seventeen minutes after the push — it has not started, let alone passed. The shared runner is saturated at the moment. I am not going to sit on the task waiting for it, so: **no CI verdict exists for this commit yet.** Whoever reviews should confirm run 107 actually went green before treating CI as evidence. What I can stand behind is local: `make check` exit 0, and a `script/cibuild` that genuinely executed for 4m02s (the commit invalidated `COPY . .`, so `make fmt-check`, `make lint` and `make test` all ran inside the pinned v2.12.2 image; an immediate second run reports those layers `CACHED`, which is only possible because the first run produced them).
Author
Collaborator

Lint provenance, re-verified against the pushed head commit b04bc2c in a fresh worktree checked out from the remote branch:

  • Host golangci-lint has version 2.12.2 — identical to the Dockerfile pin, so there is no host/CI version skew and no reason to discount anything as a version artefact.
  • make lint exit 0, output in full: the two gomodguard deprecation warnings (tracked in #98) and 0 issues. No parallel golangci-lint is running, and no reported file paths at all — nothing relative (../), nothing absolute, nothing outside the worktree.
  • make check exit 0 on the same tree, with every package's tests actually running rather than served from the Go test cache (ok ... 1.049s and friends, no (cached)). The only /tmp paths anywhere in that output are t.TempDir() data directories in the database tests' log lines, not lint findings.

For the record on how the two findings I did fix arose: the first make lint of this change reported exactly two, funcorder on internal/session/session.go and noinlineerr on internal/middleware/middleware.go — both in files this change authors, both in-worktree, neither relative-pathed. They were mine and I fixed them rather than explaining them away. No gosec G704 appeared at any point in this work.

Lint provenance, re-verified against the pushed head commit `b04bc2c` in a fresh worktree checked out from the remote branch: - Host `golangci-lint has version 2.12.2` — identical to the Dockerfile pin, so there is no host/CI version skew and no reason to discount anything as a version artefact. - `make lint` exit 0, output in full: the two `gomodguard` deprecation warnings (tracked in #98) and `0 issues.` No `parallel golangci-lint is running`, and no reported file paths at all — nothing relative (`../`), nothing absolute, nothing outside the worktree. - `make check` exit 0 on the same tree, with every package's tests actually running rather than served from the Go test cache (`ok ... 1.049s` and friends, no `(cached)`). The only `/tmp` paths anywhere in that output are `t.TempDir()` data directories in the database tests' log lines, not lint findings. For the record on how the two findings I did fix arose: the first `make lint` of this change reported exactly two, `funcorder` on `internal/session/session.go` and `noinlineerr` on `internal/middleware/middleware.go` — both in files this change authors, both in-worktree, neither relative-pathed. They were mine and I fixed them rather than explaining them away. No `gosec` G704 appeared at any point in this work.
Author
Collaborator

CI verdict now exists, resolving my previous comment: run 107 (check / check (push)) on head b04bc2c reports success, "Successful in 3m6s".

3m6s is a real execution — the runner starts from a cold Docker cache, so make fmt-check, make lint and make test all genuinely ran inside the pinned v2.12.2 image. A cache-served no-op would have come back in under a second.

So the evidence for this commit is complete: CI green on the head commit, plus the local make check and the 4m02s script/cibuild. Nothing in this PR now rests on a green I could not demonstrate.

CI verdict now exists, resolving my previous comment: run 107 (`check / check (push)`) on head `b04bc2c` reports **success, "Successful in 3m6s"**. 3m6s is a real execution — the runner starts from a cold Docker cache, so `make fmt-check`, `make lint` and `make test` all genuinely ran inside the pinned v2.12.2 image. A cache-served no-op would have come back in under a second. So the evidence for this commit is complete: CI green on the head commit, plus the local `make check` and the 4m02s `script/cibuild`. Nothing in this PR now rests on a green I could not demonstrate.
Author
Collaborator

Review: PASS

Independent adversarial review of b04bc2c against main @ 4f5ecb1. Reviewed in a dedicated throwaway worktree; nothing in the PR was modified.

Verification runs — what actually executed

  • make check run 1: VOID. Reported 24 issues, every path prefixed ../wt-issue-66-verify/, i.e. attributed to a worktree outside the one I launched from, with cache-read failures for files that do not exist. Discarded, not counted as a finding. Wall 8.9s.
  • make check run 2 after golangci-lint cache clean: VALID and green. Exit 0, lint 0 issues., every message confined to my worktree, no ../ paths, no parallel-lock error. Wall 12.0s. (Only the deprecation notice for gomodguard, which is pre-existing on main.)
  • make test with the Go test cache bypassed: 4.5s wall, 252 subtests pass, all 8 packages ok, none near the 30s per-package timeout. Comfortably inside the 20s policy in REPO_POLICIES.md. Confirms the author's 11.2s figure with margin.
  • script/cibuild: exit 0 in 0.30s wall, ALL 22 layers CACHED, including RUN make test, RUN make lint, RUN make fmt-check. This run is NOT evidence — the tree was unchanged, so Docker served the whole build. Stating this plainly rather than claiming it.
  • CI is the real cold evidence: check / check (push) on b04bc2c = success in 3m6s (run 107).
  • Mergeable: mergeable: true, and b04bc2c is a descendant of origin/main — fast-forward, no rebase needed.

Scrutiny items, each resolved

1. Two clocks genuinely independent — VERIFIED BY EXECUTION. Reproduced the author's mutation 2 (added sess.Values[CreatedAtKey] = now.Unix() to Touch). TestTouch_DoesNotExtendAbsoluteCap fails on both assertions, exactly as claimed: Should be false / "activity must not extend the absolute cap", and expected: 1767323045, actual: 1767927845 / "Touch must never rewrite the absolute-clock anchor". Restored byte-identical afterwards. CreatedAtKey is written in exactly one place, SetUser; grepped every write path.

2. The rewritten test's behavioural assertion genuinely bites — VERIFIED. The arithmetic is exact, not approximate: step = 1h/2 = 30m, steps = int(168h/30m) - 1 = 335, loop advances 167h30m, the final step lands on 168h0m0s, which is precisely sessionAbsoluteMaxAge. expired uses !now.Before(deadline), so exactly-on-the-cap is dead. Both the integer division and the boundary are exact — there is no rounding and no off-by-one flakiness window. The mutation run above proves the behavioural assertion fires independently of the anchor assertion. testAbsoluteMaxAge is restated independently of the implementation constant, which is the right call.

3. Unauthenticated requests cannot refresh — BOTH LAYERS VERIFIED, plus independent route enumeration.

  • Layer 1: Touch is called from exactly one place in non-test code, internal/middleware/middleware.go:216, after the IsAuthenticated guard returns.
  • Layer 2: Touch itself re-checks IsAuthenticated and returns false for unauthenticated or already-expired sessions.
  • I enumerated internal/server/routes.go myself rather than trusting the PR body. Public: /, /s/*, /api/v1 (empty), /.well-known/healthcheck, /metrics (basic auth, no session), /pages/login GET+POST, /pages/logout, /webhook/{uuid}. None reaches Touch. Behind RequireAuth: /user/{username}/*, /sources/*, /source/{sourceID}/*. Note /pages/logout is deliberately public and only destroys — no refresh path. A cookie aimed at any public endpoint cannot extend a session.

4. The claimed pre-existing hole is REAL — VERIFIED BY EXECUTION AND BY SOURCE. gorilla/sessions@v1.4.0 NewCookieStore sets Options.MaxAge = 86400 * 30 and then calls cs.MaxAge(...), which is the only thing that propagates the bound into Codecs. session.New afterwards assigns store.Options = &sessions.Options{...} wholesale, which never touches the codecs. I confirmed the consequence by running a throwaway probe (since removed): with the Options-replacement pattern a cookie whose Options.MaxAge is 1s still decodes after 3s (true), while store.MaxAge(1) correctly rejects it (false). So before this change the 7-day cap was browser-side only and the server would have honoured a cookie for up to 30 days. The PR body does not overstate; this is a genuine security fix and belongs in the merge record.

5. Lazy refresh direction — VERIFIED. lastSeen is only ever written to s.now() at the moment of a real authenticated request, so it can never lead the true last request; expiry is therefore never late. The no-write band is measured from the stored lastSeen, not from the previous request, so a request pattern that always lands just inside the band cannot starve the write: once now - lastSeen reaches idleTimeout/10 the next request writes regardless of pacing. Bound on earliness is therefore < idleTimeout/idleRefreshDivisor = 10% of the configured window (s.idleTimeout/idleRefreshDivisor, not a constant). No indefinite-liveness path.

6. expired on the read path — VERIFIED. Every authentication decision routes through IsAuthenticated: middleware.go:193, handlers/index.go:13, handlers/auth.go:14, handlers/handlers.go:167 (getUserInfo, used by all template rendering including public pages), handlers/source_management.go:1197. The only direct value reads are handlers/profile.go:156,167, which sit behind RequireAuth on /user/{username} and read identity, not authorization state — pre-existing pattern, correctly documented in place. No entry point skips the check.

7. Config — minimal, fail-loud, VERIFIED. Exactly one constant, one Config field, one envDuration call plus its struct-literal assignment; envDuration returns an error on set-but-unparseable, which New propagates so fx aborts startup. TestSessionIdleTimeout covers unset/valid/unparseable. Nothing else in the package restructured — the #92 rebase stays mechanical.

8. Test hygiene — VERIFIED, plus four mutations the author did not list:

  • Absolute-cap branch deleted from expiredcaught (TestTouch_DoesNotExtendAbsoluteCap, TestIdleTimeoutDisabled_AbsoluteCapStillApplies).
  • Touch also rewriting CreatedAtKeycaught on both assertions (item 1 above).
  • Dropping Touch's bool contract so the middleware always saves → caught by TestTouch_LazyBelowRefreshThreshold plus the two false-return tests, which assert the contract directly.
  • idleRefreshDivisor 10 → 5 → NOT caught, full suite exits 0. See non-blocking item 1.

Injected clock throughout, no time.Sleep anywhere in the added tests, -race clean.

9. Repo policy — all clean. .golangci.yml unmodified, sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. Dockerfile still pinned to golangci/golangci-lint:v2.12.2 at its digest. Single commit, title Add inactivity-based session timeout (closes #66). TODO.md updated in the same commit. make fmt-check green. No attribution trailers, no AI/vendor references anywhere in the diff, no 4-byte emoji, no non-inclusive terminology. Naming is stutter-free and idiomatic with the surrounding code. No scope creep.

Definition of done from the issue and all five items of the implementation-requirements comment are satisfied.

Non-blocking findings

  1. The documented 10% bound is not pinned by any test (internal/session/session.go:69). Changing idleRefreshDivisor from 10 to 5 passes the entire suite — verified by execution. The two tests that touch the band (TestTouch_LazyBelowRefreshThreshold advances 1s; TestTouch_RefreshesIdleDeadline advances half the window) straddle it too loosely to constrain the value. The README and PR body both make a specific numeric promise ("up to 10% early", "at most ten times per idle window") that nothing enforces. Not a correctness or security defect — every divisor > 1 preserves the "never late" guarantee and only shifts the earliness bound — but a test asserting no rewrite just below idleTimeout/10 and a rewrite just above would make the promise real. Acceptable to land as-is.

  2. The codec/Options skew itself is still there (internal/session/session.go:145-154). This PR correctly closes the hole with a stricter server-side check, but the underlying cause — assigning store.Options instead of calling store.MaxAge(...) — remains, so Codecs still carry a 30-day bound that no longer matches anything. Deliberately out of scope here; worth a follow-up issue so the defence-in-depth layer agrees with the enforced policy.

  3. internal/config/config_test.go:174 renames testRetentionSweepIntervalError to expectStartupError. Correct call — the helper is now shared by two tests and the old name would be a lie — but it does touch the package #92 rewrites, adding a small extra hunk to that rebase beyond the "one constant, one field, one parse call" the spec asked for. Trivial to resolve; flagging only so it is not a surprise.

  4. internal/middleware/middleware.go:217 reuses the outer err from s.session.Get(r) rather than scoping it in the if. Harmless, lint-clean, but if err := s.session.Save(...); err != nil matches the surrounding idiom more closely.

  5. Test-helper nits. testSessionWithClock and testMiddlewareWithSessionClock both return the *fakeClock they were handed, which the callers already own. fakeClock is duplicated across internal/session and internal/middleware tests — unavoidable across packages, just noting it.

  6. README could mention the one-time forced re-login. The fail-closed treatment of pre-change cookies is explained in the PR body but not in the operator-facing docs; a sentence in the sessions section would save a support question on deploy. Also, SESSION_IDLE_TIMEOUT is documented as "set it to 0 to disable" while the code disables on any non-positive value — immaterial.

Verdict

PASS — no blocking findings. The security-critical property (activity refreshes the idle clock and only the idle clock, and only for authenticated requests) is correctly implemented, guarded at two layers, and genuinely covered by tests that I confirmed fail under the exact bug the issue warns about. CI is green on the head commit and the branch fast-forwards onto main.

## Review: PASS Independent adversarial review of `b04bc2c` against `main` @ `4f5ecb1`. Reviewed in a dedicated throwaway worktree; nothing in the PR was modified. ### Verification runs — what actually executed - **`make check` run 1: VOID.** Reported 24 issues, every path prefixed `../wt-issue-66-verify/`, i.e. attributed to a worktree outside the one I launched from, with cache-read failures for files that do not exist. Discarded, not counted as a finding. Wall 8.9s. - **`make check` run 2 after `golangci-lint cache clean`: VALID and green.** Exit 0, lint `0 issues.`, every message confined to my worktree, no `../` paths, no parallel-lock error. Wall 12.0s. (Only the deprecation notice for `gomodguard`, which is pre-existing on `main`.) - **`make test` with the Go test cache bypassed: 4.5s wall**, 252 subtests pass, all 8 packages `ok`, none near the 30s per-package timeout. Comfortably inside the 20s policy in `REPO_POLICIES.md`. Confirms the author's 11.2s figure with margin. - **`script/cibuild`: exit 0 in 0.30s wall, ALL 22 layers `CACHED`, including `RUN make test`, `RUN make lint`, `RUN make fmt-check`. This run is NOT evidence** — the tree was unchanged, so Docker served the whole build. Stating this plainly rather than claiming it. - **CI is the real cold evidence:** `check / check (push)` on `b04bc2c` = success in 3m6s (run 107). - Mergeable: `mergeable: true`, and `b04bc2c` is a descendant of `origin/main` — fast-forward, no rebase needed. ### Scrutiny items, each resolved **1. Two clocks genuinely independent — VERIFIED BY EXECUTION.** Reproduced the author's mutation 2 (added `sess.Values[CreatedAtKey] = now.Unix()` to `Touch`). `TestTouch_DoesNotExtendAbsoluteCap` fails on **both** assertions, exactly as claimed: `Should be false` / "activity must not extend the absolute cap", and `expected: 1767323045, actual: 1767927845` / "Touch must never rewrite the absolute-clock anchor". Restored byte-identical afterwards. `CreatedAtKey` is written in exactly one place, `SetUser`; grepped every write path. **2. The rewritten test's behavioural assertion genuinely bites — VERIFIED.** The arithmetic is exact, not approximate: `step = 1h/2 = 30m`, `steps = int(168h/30m) - 1 = 335`, loop advances `167h30m`, the final step lands on `168h0m0s`, which is precisely `sessionAbsoluteMaxAge`. `expired` uses `!now.Before(deadline)`, so exactly-on-the-cap is dead. Both the integer division and the boundary are exact — there is no rounding and no off-by-one flakiness window. The mutation run above proves the behavioural assertion fires independently of the anchor assertion. `testAbsoluteMaxAge` is restated independently of the implementation constant, which is the right call. **3. Unauthenticated requests cannot refresh — BOTH LAYERS VERIFIED, plus independent route enumeration.** - Layer 1: `Touch` is called from exactly one place in non-test code, `internal/middleware/middleware.go:216`, after the `IsAuthenticated` guard returns. - Layer 2: `Touch` itself re-checks `IsAuthenticated` and returns false for unauthenticated or already-expired sessions. - I enumerated `internal/server/routes.go` myself rather than trusting the PR body. Public: `/`, `/s/*`, `/api/v1` (empty), `/.well-known/healthcheck`, `/metrics` (basic auth, no session), `/pages/login` GET+POST, `/pages/logout`, `/webhook/{uuid}`. None reaches `Touch`. Behind `RequireAuth`: `/user/{username}/*`, `/sources/*`, `/source/{sourceID}/*`. Note `/pages/logout` is deliberately public and only destroys — no refresh path. A cookie aimed at any public endpoint cannot extend a session. **4. The claimed pre-existing hole is REAL — VERIFIED BY EXECUTION AND BY SOURCE.** `gorilla/sessions@v1.4.0` `NewCookieStore` sets `Options.MaxAge = 86400 * 30` and then calls `cs.MaxAge(...)`, which is the only thing that propagates the bound into `Codecs`. `session.New` afterwards assigns `store.Options = &sessions.Options{...}` wholesale, which never touches the codecs. I confirmed the consequence by running a throwaway probe (since removed): with the Options-replacement pattern a cookie whose `Options.MaxAge` is 1s still decodes after 3s (`true`), while `store.MaxAge(1)` correctly rejects it (`false`). So before this change the 7-day cap was browser-side only and the server would have honoured a cookie for up to 30 days. **The PR body does not overstate; this is a genuine security fix and belongs in the merge record.** **5. Lazy refresh direction — VERIFIED.** `lastSeen` is only ever written to `s.now()` at the moment of a real authenticated request, so it can never lead the true last request; expiry is therefore never late. The no-write band is measured from the stored `lastSeen`, not from the previous request, so a request pattern that always lands just inside the band cannot starve the write: once `now - lastSeen` reaches `idleTimeout/10` the next request writes regardless of pacing. Bound on earliness is therefore `< idleTimeout/idleRefreshDivisor` = 10% of the **configured** window (`s.idleTimeout/idleRefreshDivisor`, not a constant). No indefinite-liveness path. **6. `expired` on the read path — VERIFIED.** Every authentication decision routes through `IsAuthenticated`: `middleware.go:193`, `handlers/index.go:13`, `handlers/auth.go:14`, `handlers/handlers.go:167` (`getUserInfo`, used by all template rendering including public pages), `handlers/source_management.go:1197`. The only direct value reads are `handlers/profile.go:156,167`, which sit behind `RequireAuth` on `/user/{username}` and read identity, not authorization state — pre-existing pattern, correctly documented in place. No entry point skips the check. **7. Config — minimal, fail-loud, VERIFIED.** Exactly one constant, one `Config` field, one `envDuration` call plus its struct-literal assignment; `envDuration` returns an error on set-but-unparseable, which `New` propagates so fx aborts startup. `TestSessionIdleTimeout` covers unset/valid/unparseable. Nothing else in the package restructured — the #92 rebase stays mechanical. **8. Test hygiene — VERIFIED, plus four mutations the author did not list:** - Absolute-cap branch deleted from `expired` → **caught** (`TestTouch_DoesNotExtendAbsoluteCap`, `TestIdleTimeoutDisabled_AbsoluteCapStillApplies`). - `Touch` also rewriting `CreatedAtKey` → **caught on both assertions** (item 1 above). - Dropping `Touch`'s bool contract so the middleware always saves → **caught** by `TestTouch_LazyBelowRefreshThreshold` plus the two false-return tests, which assert the contract directly. - `idleRefreshDivisor` 10 → 5 → **NOT caught**, full suite exits 0. See non-blocking item 1. Injected clock throughout, no `time.Sleep` anywhere in the added tests, `-race` clean. **9. Repo policy — all clean.** `.golangci.yml` unmodified, sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`. Dockerfile still pinned to `golangci/golangci-lint:v2.12.2` at its digest. Single commit, title `Add inactivity-based session timeout (closes #66)`. `TODO.md` updated in the same commit. `make fmt-check` green. No attribution trailers, no AI/vendor references anywhere in the diff, no 4-byte emoji, no non-inclusive terminology. Naming is stutter-free and idiomatic with the surrounding code. No scope creep. Definition of done from the issue and all five items of the implementation-requirements comment are satisfied. ### Non-blocking findings 1. **The documented 10% bound is not pinned by any test** (`internal/session/session.go:69`). Changing `idleRefreshDivisor` from `10` to `5` passes the entire suite — verified by execution. The two tests that touch the band (`TestTouch_LazyBelowRefreshThreshold` advances 1s; `TestTouch_RefreshesIdleDeadline` advances half the window) straddle it too loosely to constrain the value. The README and PR body both make a specific numeric promise ("up to 10% early", "at most ten times per idle window") that nothing enforces. Not a correctness or security defect — every divisor > 1 preserves the "never late" guarantee and only shifts the earliness bound — but a test asserting no rewrite just below `idleTimeout/10` and a rewrite just above would make the promise real. Acceptable to land as-is. 2. **The codec/Options skew itself is still there** (`internal/session/session.go:145-154`). This PR correctly closes the hole with a stricter server-side check, but the underlying cause — assigning `store.Options` instead of calling `store.MaxAge(...)` — remains, so `Codecs` still carry a 30-day bound that no longer matches anything. Deliberately out of scope here; worth a follow-up issue so the defence-in-depth layer agrees with the enforced policy. 3. **`internal/config/config_test.go:174` renames `testRetentionSweepIntervalError` to `expectStartupError`.** Correct call — the helper is now shared by two tests and the old name would be a lie — but it does touch the package #92 rewrites, adding a small extra hunk to that rebase beyond the "one constant, one field, one parse call" the spec asked for. Trivial to resolve; flagging only so it is not a surprise. 4. **`internal/middleware/middleware.go:217` reuses the outer `err`** from `s.session.Get(r)` rather than scoping it in the `if`. Harmless, lint-clean, but `if err := s.session.Save(...); err != nil` matches the surrounding idiom more closely. 5. **Test-helper nits.** `testSessionWithClock` and `testMiddlewareWithSessionClock` both return the `*fakeClock` they were handed, which the callers already own. `fakeClock` is duplicated across `internal/session` and `internal/middleware` tests — unavoidable across packages, just noting it. 6. **README could mention the one-time forced re-login.** The fail-closed treatment of pre-change cookies is explained in the PR body but not in the operator-facing docs; a sentence in the sessions section would save a support question on deploy. Also, `SESSION_IDLE_TIMEOUT` is documented as "set it to `0` to disable" while the code disables on any non-positive value — immaterial. ### Verdict **PASS** — no blocking findings. The security-critical property (activity refreshes the idle clock and only the idle clock, and only for authenticated requests) is correctly implemented, guarded at two layers, and genuinely covered by tests that I confirmed fail under the exact bug the issue warns about. CI is green on the head commit and the branch fast-forwards onto `main`.
Author
Collaborator

Manager note

Independent review verdict: PASS, no blocking findings. The reviewer did not author this change.

This PR closes a real pre-existing hole, and that is now confirmed

The PR body claimed the 7-day absolute session cap was previously enforced only by the browser. I asked the reviewer to verify that by execution rather than accept it, because it is the kind of claim that is easy to assert and consequential if wrong in either direction. It is true:

gorilla/sessions@v1.4.0 NewCookieStore sets the codec max-age to 30 days via cs.MaxAge(...), and assigning store.Options wholesale (internal/session/session.go:148) never touches Codecs. A throwaway probe confirmed the app's pattern still decodes a cookie past its Options.MaxAge, while store.MaxAge(1) correctly rejects it.

So on main today, a retained session cookie would be honoured by the server for up to 30 days, not 7. The cap existed only as a browser hint. This PR closes that as a side effect of adding server-side expiry checks. @sneak — that is worth knowing independently of the idle-timeout feature; it is a security fix hiding inside a hardening ticket. The residual codec/Options skew is tracked as #108.

The test quality question I flagged

The author disclosed that their first draft of TestTouch_DoesNotExtendAbsoluteCap jumped a full 7 days at the end, so it expired under the mutation anyway and only a white-box anchor assertion caught the bug — and that they rewrote it. I asked the reviewer to check the rewrite genuinely bites and is not off-by-one fragile. Both confirmed by execution:

  • Adding sess.Values[CreatedAtKey] = now.Unix() to Touch — the exact bug the issue warned about — fails the test on both the behavioural and anchor assertions.
  • The arithmetic is exact, not marginal: step = 30m, steps = int(168h/30m) - 1 = 335, the loop reaches 167h30m, the final step lands on exactly 168h0m0s, and expired uses !now.Before(deadline) so on-the-cap is dead. No rounding window, so no flake risk.

An author catching a weakness in their own test and fixing it before review is the behaviour I want; it is also why I asked for it to be re-verified rather than taken on trust.

Other execution-verified points

  • Unauthenticated requests cannot refresh a session, checked at both layers: route enumeration confirms no public path reaches Touch (only call site is middleware.go:216, after the auth guard), and Touch re-guards itself. So the property does not depend on the call site staying correct.
  • The lazy refresh cannot be starved — earliness is measured from stored lastSeen and bounded by 10% of the configured window, never late.
  • make test at 4.5s with the Go test cache bypassed (GOFLAGS=-count=1), 252 subtests — comfortably inside the 20s policy and better than the author's 11.2s claim.

Tooling provenance

The reviewer's first make check was VOID: 24 issues, every path prefixed ../wt-issue-66-verify/, a worktree outside its own that had already been deleted, with cache-read failures for non-existent files. It discarded the run, ran golangci-lint cache clean, and got a valid green. Its script/cibuild was 0.30s with all 22 layers CACHED and was correctly not counted; the real evidence is Gitea run 107 on b04bc2c, success in 3m6s.

That is the third independent sighting of the relative-path contamination form today, all from deleted worktrees. #106 has the details.

Non-blocking

Six items, tracked as #108. The one worth naming: changing idleRefreshDivisor from 10 to 5 leaves the suite green, so the documented "up to 10% early, never late" bound is prose rather than a pinned guarantee.

Labeled merge-ready and assigned to @sneak.

## Manager note Independent review verdict: **PASS**, no blocking findings. The reviewer did not author this change. ### This PR closes a real pre-existing hole, and that is now confirmed The PR body claimed the 7-day absolute session cap was previously enforced **only by the browser**. I asked the reviewer to verify that by execution rather than accept it, because it is the kind of claim that is easy to assert and consequential if wrong in either direction. It is **true**: `gorilla/sessions@v1.4.0` `NewCookieStore` sets the codec max-age to 30 days via `cs.MaxAge(...)`, and assigning `store.Options` wholesale (`internal/session/session.go:148`) never touches `Codecs`. A throwaway probe confirmed the app's pattern still decodes a cookie past its `Options.MaxAge`, while `store.MaxAge(1)` correctly rejects it. So on `main` today, a retained session cookie would be honoured by the server for **up to 30 days**, not 7. The cap existed only as a browser hint. This PR closes that as a side effect of adding server-side expiry checks. **@sneak — that is worth knowing independently of the idle-timeout feature; it is a security fix hiding inside a hardening ticket.** The residual codec/`Options` skew is tracked as #108. ### The test quality question I flagged The author disclosed that their first draft of `TestTouch_DoesNotExtendAbsoluteCap` jumped a full 7 days at the end, so it expired under the mutation anyway and only a white-box anchor assertion caught the bug — and that they rewrote it. I asked the reviewer to check the rewrite genuinely bites and is not off-by-one fragile. Both confirmed by execution: - Adding `sess.Values[CreatedAtKey] = now.Unix()` to `Touch` — the exact bug the issue warned about — fails the test on **both** the behavioural and anchor assertions. - The arithmetic is exact, not marginal: `step = 30m`, `steps = int(168h/30m) - 1 = 335`, the loop reaches `167h30m`, the final step lands on exactly `168h0m0s`, and `expired` uses `!now.Before(deadline)` so on-the-cap is dead. No rounding window, so no flake risk. An author catching a weakness in their own test and fixing it before review is the behaviour I want; it is also why I asked for it to be re-verified rather than taken on trust. ### Other execution-verified points - **Unauthenticated requests cannot refresh a session**, checked at both layers: route enumeration confirms no public path reaches `Touch` (only call site is `middleware.go:216`, after the auth guard), and `Touch` re-guards itself. So the property does not depend on the call site staying correct. - **The lazy refresh cannot be starved** — earliness is measured from stored `lastSeen` and bounded by 10% of the *configured* window, never late. - **`make test` at 4.5s** with the Go test cache bypassed (`GOFLAGS=-count=1`), 252 subtests — comfortably inside the 20s policy and better than the author's 11.2s claim. ### Tooling provenance The reviewer's first `make check` was **VOID**: 24 issues, every path prefixed `../wt-issue-66-verify/`, a worktree outside its own that had already been deleted, with cache-read failures for non-existent files. It discarded the run, ran `golangci-lint cache clean`, and got a valid green. Its `script/cibuild` was 0.30s with all 22 layers `CACHED` and was correctly **not** counted; the real evidence is Gitea run 107 on `b04bc2c`, success in 3m6s. That is the third independent sighting of the relative-path contamination form today, all from deleted worktrees. #106 has the details. ### Non-blocking Six items, tracked as #108. The one worth naming: changing `idleRefreshDivisor` from 10 to 5 leaves the suite green, so the documented "up to 10% early, never late" bound is prose rather than a pinned guarantee. Labeled `merge-ready` and assigned to @sneak.
clawbot added merge-ready and removed needs-review labels 2026-08-09 08:20:23 +02:00
clawbot removed their assignment 2026-08-09 08:20:29 +02:00
sneak was assigned by clawbot 2026-08-09 08:20:29 +02:00
All checks were successful
check / check (push) Successful in 3m6s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin issue-66-session-idle-timeout:issue-66-session-idle-timeout
git checkout issue-66-session-idle-timeout
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#105