Skip to content

Isolate demo chat history per visitor#5

Merged
Ho1yShif merged 1 commit into
mainfrom
fix/demo-per-visitor-session-isolation
Jul 14, 2026
Merged

Isolate demo chat history per visitor#5
Ho1yShif merged 1 commit into
mainfrom
fix/demo-per-visitor-session-isolation

Conversation

@Ho1yShif

Copy link
Copy Markdown
Collaborator

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 sessions table has no owner column, and GET /api/v1/sessions lists sessions with no filter. In DEMO_MODE the SQLite session store is a single process-wide in-memory instance shared by every visitor, and all anonymous visitors resolve to the same local_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-id ContextVar (mirrors multi_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 unique db_path, so get_turn_runtime_manager() (cached by db_path) stays isolated per visitor automatically — including the WS turn path.
  • Binding 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 WS upgrades can't set custom headers). No-op unless DEMO_MODE.
  • FrontendgetDemoVisitorId() persists a random id in localStorage; apiFetch attaches the header and the unified WS appends the query param. An incognito window has its own storage → distinct id → distinct history.
  • Also fixes a latent mypy error in SQLiteSessionStore.__init__ surfaced by touching the file (db_path is str | Path across 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: the bind_demo_visitor dependency 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, and ws_require_auth binds 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; frontend test:node (155) + eslint green.

Scope & trade-offs

  • Isolates the SQLite-backed history — the reported leak: /api/v1/sessions, dashboard recent, and unified WS turns.
  • Not covered: the legacy JSON-manager /chat path and per-visitor memory persistence (the modern UI uses /api/v1/ws + /api/v1/sessions).
  • Per-visitor in-memory stores accumulate until restart; acceptable for a demo given per-IP rate limiting and deploy restarts. A TTL/LRU is a possible follow-up.

🤖 Generated with Claude Code

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>
@Ho1yShif Ho1yShif merged commit fed8250 into main Jul 14, 2026
11 checks passed
@Ho1yShif Ho1yShif deleted the fix/demo-per-visitor-session-isolation branch July 14, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant