Isolate demo chat history per visitor#5
Merged
Conversation
In DEMO_MODE the SQLite session store was one process-wide in-memory instance shared by every anonymous visitor, and GET /api/v1/sessions lists sessions with no owner filter (DeepTutor is single-user by design; all demo visitors resolve to the same local_admin_user). So any visitor saw every other visitor's chat history — reproducible by opening the demo in a second browser or incognito window. The earlier ephemeral- history fix was orthogonal: it stopped disk writes but did not isolate concurrent visitors. Carry a per-visitor id (minted client-side, stored in localStorage) and key the demo in-memory store by it, so each visitor gets a physically separate ephemeral DB: - deeptutor/services/demo/visitor.py: request-local visitor-id ContextVar (mirrors multi_user.context) with input sanitization. Isolation, not auth — an opaque token that only partitions the demo's ephemeral store. - get_sqlite_session_store(): demo branch keyed per visitor. Each in-memory store already mints a unique db_path, so get_turn_runtime_manager() (cached by db_path) stays isolated per visitor automatically. - Bind the id at the two existing choke points that set the current user: a new async bind_demo_visitor dependency in the _auth list (HTTP, reads X-Demo-Visitor header) and ws_require_auth (WS, reads ?visitor= query param, since upgrades can't set custom headers). No-op unless DEMO_MODE. - Frontend: getDemoVisitorId() persists a random id in localStorage; apiFetch attaches the header and the unified WS appends the query param. Incognito has its own storage, so it gets a distinct id and history. Also fixes a latent mypy error in SQLiteSessionStore.__init__ surfaced by touching the file (db_path is str | Path across the two branches). Scope: isolates the SQLite-backed history (the reported leak: /sessions, dashboard recent, unified WS turns). The legacy JSON-manager /chat path and per-visitor memory persistence are not covered here. Per-visitor in-memory stores accumulate until restart; acceptable for the demo given per-IP rate limiting and deploy restarts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Problem
Opening the public demo in a second browser (or incognito) showed another visitor's chat history. This is a session-isolation bug, distinct from the earlier ephemeral-history fix (#4).
Root cause: DeepTutor is single-user by design — the
sessionstable has no owner column, andGET /api/v1/sessionslists sessions with no filter. InDEMO_MODEthe SQLite session store is a single process-wide in-memory instance shared by every visitor, and all anonymous visitors resolve to the samelocal_admin_user. So the history endpoint returned everyone's chats to everyone. The ephemeral-history PR stopped disk writes but did nothing to isolate concurrent visitors.Fix
Carry a per-visitor id (minted client-side, stored in
localStorage) and key the demo in-memory store by it, so each visitor gets a physically separate ephemeral DB.deeptutor/services/demo/visitor.py— request-local visitor-idContextVar(mirrorsmulti_user.context) with input sanitization. This is isolation, not authentication: an opaque token that only partitions the demo's ephemeral store.get_sqlite_session_store()— demo branch keyed per visitor. Each in-memory store already mints a uniquedb_path, soget_turn_runtime_manager()(cached bydb_path) stays isolated per visitor automatically — including the WS turn path.bind_demo_visitordependency in the_authlist (HTTP, readsX-Demo-Visitorheader) andws_require_auth(WS, reads?visitor=query param, since WS upgrades can't set custom headers). No-op unlessDEMO_MODE.getDemoVisitorId()persists a random id inlocalStorage;apiFetchattaches the header and the unified WS appends the query param. An incognito window has its own storage → distinct id → distinct history.SQLiteSessionStore.__init__surfaced by touching the file (db_pathisstr | Pathacross the in-memory / on-disk branches).Tests
tests/services/demo/test_demo_visitor_isolation.py— store-level isolation (A can't see B; same id reuses one store; non-demo ignores the id).tests/api/test_demo_visitor_isolation.py— end-to-end through the ASGI stack: thebind_demo_visitordependency sets the ContextVar visibly to the endpoint (guards the #481 sync-dependency regression class), a no-header request does not inherit a prior visitor's bucket, andws_require_authbinds the query-param id.Full suite: 2433 passed; the only failures are pre-existing missing-optional-dependency gaps (
telegram/slack/croniter) unrelated to this change. ruff / ruff-format / mypy / bandit green on changed files; frontendtest:node(155) + eslint green.Scope & trade-offs
/api/v1/sessions, dashboard recent, and unified WS turns./chatpath and per-visitor memory persistence (the modern UI uses/api/v1/ws+/api/v1/sessions).🤖 Generated with Claude Code