Skip to content

fix(guests): the middleware that keeps an active reader alive had never run - #604

Merged
mrviduus merged 1 commit into
mainfrom
fix/guest-activity
Sep 11, 2026
Merged

fix(guests): the middleware that keeps an active reader alive had never run#604
mrviduus merged 1 commit into
mainfrom
fix/guest-activity

Conversation

@mrviduus

Copy link
Copy Markdown
Owner

GuestActivityMiddleware exists so GuestCleanupWorker does not delete a guest who is still reading. It decided whether a request belonged to a guest by reading context.User.FindFirst("is_guest").

This API registers no ASP.NET authentication middleware at all — by design; every endpoint resolves identity by hand through GetUserId. So HttpContext.User is permanently empty, the check was always false, and the method returned on its first line for every request it ever served. LastActiveAt was written exactly once per guest, at creation.

The claim was never missing. AuthService has minted is_guest into every guest's access token since guest sessions shipped; ValidateAccessToken threw it away and returned only the id. It now has a sibling — ValidateAccessTokenIdentity — returning both, with the claim name as a single constant shared by the one place that mints it and the one that reads it.

What the damage actually was

Narrower than it looks, and the docs now say so rather than implying a data-loss emergency: GuestCleanupWorker's preservation filter also spares any guest holding a ReadingProgress row, so a guest who genuinely read a book survived regardless. The real exposure was a guest who opened books and never produced a progress row — plus a pipeline carrying a slice that did nothing, beneath a paragraph in Program.cs reasoning carefully about where to place it.

Two changes beyond the identity fix

  • The debounce read the row it was trying to avoid writing. Every guest request did a SELECT on users to decide whether to skip the UPDATE. It is now an IMemoryCache entry, so the common path touches no database at all.
  • The write is a targeted ExecuteUpdateAsync rather than loading an entity and saving it, so nothing else on the row can be written by accident.

Verification — the side effect, not the 200

That is the lesson this same middleware taught the first time, so it was checked the same way. Against a live local stack:

last_active_at
one guest request moves
five more inside the debounce window does not move
an account's request never touched (stays NULL)

The 200 is identical in all three cases — it was identical for the whole time the middleware did nothing.

The decision itself is now a pure function (ShouldWrite) with unit tests, including the inclusive boundary where the debounce and the cache expiry meet. The previous version had no testable seam, which is part of why the defect survived review.

  • dotnet test tests/TextStack.UnitTests — 1240 pass (5 new)
  • dotnet test tests/TextStack.IntegrationTests — 171 pass, 48 skipped
  • dotnet format textstack.sln --verify-no-changes — clean

🤖 Generated with Claude Code

https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E

…er run

GuestActivityMiddleware decided whether a request belonged to a guest by reading
context.User.FindFirst("is_guest"). This API registers no ASP.NET authentication
middleware at all — every endpoint resolves identity by hand via GetUserId — so
HttpContext.User is permanently empty, the check was always false, and the method
returned on its first line for every request it ever served. LastActiveAt was
written once per guest, at creation.

The claim was never missing: AuthService has minted is_guest into every guest
token since guest sessions shipped, and ValidateAccessToken threw it away. It now
has a sibling that returns both, with the claim name as one constant.

Damage was narrower than it looks and the doc now says so: GuestCleanupWorker also
spares any guest holding a ReadingProgress row, so anyone who actually read a book
survived regardless.

Two things beyond the identity fix: the old debounce read the user row on every
guest request to decide whether to skip the write — the read it was avoiding — and
is now an IMemoryCache entry, so the common path touches no database; and the write
is a targeted ExecuteUpdateAsync instead of loading an entity.

Verified as a side effect, not a 200 — the lesson this same middleware taught the
first time. Against a live stack: one request moves last_active_at, five more in
the debounce window move nothing, an account's row is untouched. The decision is
now a pure function with tests; the previous version had no seam at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rgEMvYYi4Egj99dVtvm6E
@mrviduus
mrviduus merged commit 40aadbb into main Sep 11, 2026
10 checks passed
@mrviduus
mrviduus deleted the fix/guest-activity branch September 11, 2026 04:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant