feat(auth): slide session expiry so active members stay signed in - #30
Merged
Conversation
Sessions had a fixed 7-day lifetime with no renewal, so even a member who used their Space every day was pushed back through an emailed login code every week. Make expires_at an idle window instead of a fixed lifetime: a request landing in the second half of the window pushes expiry out to a full TTL, and the window itself widens to 30 days. Renewing only past the halfway mark keeps authentication read-only in the common case — at most one write per session per half-window rather than one per request, which is what the fixed lifetime was buying. A copied token still dies 30 days after its last use, and logout, leaving a Space, and the 10-session cap are all unchanged. No backfill is needed: every live session sits below the halfway mark of the new window, so each slides out to the full 30 days on its owner's next request. Terms §5 and Privacy §16 restated accordingly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLS7Awz5uAiN7RKog2YYCu
Review of the sliding-session change caught a defect it introduced. Node does not clamp a setTimeout delay above 2^31-1 ms (~24.8 days): it warns and fires after 1ms. addClient schedules the session-expiry disconnect at the session's full remaining lifetime, which was always safe under the old 7-day TTL and never is under a 30-day one. Every stream opened with more than ~24.8 days left — i.e. every session straight after sign-in or renewal — was therefore closed within milliseconds. The web client resets its backoff on a successful open, so this degraded into a ~1Hz reconnect loop that also fanned two presence broadcasts per cycle to every other member of the Space. Clamp the delay. A stream that stays open past the ceiling is disconnected a little early and simply reconnects. Also from review: - Prune sessions over the per-member cap by expires_at rather than created_at. With sliding expiry the two diverged, so the cap retired a member's daily-driver device and kept a phone used once. - Terms §5 and Privacy §16 stated the idle window as a floor. Renewal only fires past the halfway mark, so a session last used early in its window lapses sooner than a full TTL later; restate both as the ceiling they are, and correct the comment on MEMBER_SESSION_TTL_MS likewise. - Terms §5 said an API token "follows the same rule": API tokens have a fixed 1-year lifetime that use does not extend. Say so. - Add the server-side session record to the Privacy §7 retention list. - Note the shifted meaning of the analytics "open sessions" figure, and refresh comments that still described a fixed 7-day lifetime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLS7Awz5uAiN7RKog2YYCu
Sliding expiry left no upper bound at all: a token someone keeps using never expires, and there is no "sign out everywhere" control to fall back on. That mattered less when the lifetime was a fixed 7 days — the old scheme locked out a copied token on a fixed schedule whether or not the member ever noticed the compromise. Restore that guarantee at a horizon that doesn't reintroduce the original complaint: 90 days from issuance, four emailed codes a year for a daily user against the previous 52. Enforced on read via created_at, not merely by capping the renewal write. A ceiling that holds only because no older code path ever wrote the row is not a bound worth relying on for revocation. The write is capped as well so expires_at stays honest for the retention sweep and the live-session count, and the new `next > expiresAt` guard stops a session pinned at its ceiling from rewriting the same timestamp on every request — it sits permanently inside the halfway window, so without the guard the cap would undo the read-only-authentication property the threshold exists to protect. No mass logout on deploy: every currently-live session was issued within the last 7 days, so none is near the ceiling. Terms §5 and Privacy §7/§16 now state both bounds. This does not close the missing revoke-all-sessions path, which remains the right fix for a compromise the member *does* notice, nor the proof-token lateral movement it would only partly contain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLS7Awz5uAiN7RKog2YYCu
CI's `npm audit --audit-level=high` step went red on two advisories that went public after main was last built — the failure predates this branch and would hit main equally. Every other job was already green. postcss is a straight `npm audit fix` (8.5.16 -> patched, lockfile only). brace-expansion is the awkward one. The advisory covers every published version up to 5.0.7 across all three major lines in the tree (1.1.16, 2.1.2, 5.0.7) and only 5.0.8 is patched, so nothing short of forcing 5.0.8 everywhere clears it. Doing that alone breaks eslint: @eslint/config-array 0.22.x pins minimatch 3.x, which throws constructing a Minimatch against 5.x. Pairing the override with @eslint/config-array ^0.23.5 — the first release off that minimatch — removes the incompatible path, so the whole dependent cascade (minimatch, eslintrc, eslint, workbox, vite-plugin-pwa) clears at once. `npm audit fix --force` was the alternative and is worse: it downgrades vite-plugin-pwa to 1.2.0, a breaking change to the PWA build. Note the gate was never protecting shipped code here — `npm audit --omit=dev` reports zero high advisories. All of this is lint and build tooling. Relaxing the gate to production deps would also have made CI green, but that trades away real supply-chain coverage of the dev toolchain, so it isn't done here. Verified end to end: check (typecheck/lint/format), web 247, server 242, e2e 203, production build including the workbox chain the override touches, and drizzle migrations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLS7Awz5uAiN7RKog2YYCu
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.
Sessions had a fixed 7-day lifetime with no renewal, so even a member who
used their Space every day was pushed back through an emailed login code
every week.
Make expires_at an idle window instead of a fixed lifetime: a request
landing in the second half of the window pushes expiry out to a full TTL,
and the window itself widens to 30 days. Renewing only past the halfway
mark keeps authentication read-only in the common case — at most one write
per session per half-window rather than one per request, which is what the
fixed lifetime was buying.
A copied token still dies 30 days after its last use, and logout, leaving a
Space, and the 10-session cap are all unchanged. No backfill is needed:
every live session sits below the halfway mark of the new window, so each
slides out to the full 30 days on its owner's next request.
Terms §5 and Privacy §16 restated accordingly.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01QLS7Awz5uAiN7RKog2YYCu