chart/docs/multi_user.md
2026-08-14 01:12:20 -05:00

7.2 KiB
Raw Blame History

Multi-user — the target, and how to get there without a big bang

Status: tracked, not started. A direction to refactor toward, not a project with a date. Each phase below is worth doing on its own merits while the app is still single-user; none of it is speculative scaffolding.

Target: separate people, each with their own drawings, alerts and notifications, authenticated through OIDC against a self-hosted Authentik that can federate Google.

Decide this first: whose market data?

This fork determines the architecture, and it is not an engineering question.

A — one shared feed (this account). Everyone sees bars streamed from one Schwab connection. Simplest to build, and the bar store stays shared. But Schwab's agreement, and CME's beneath it, generally prohibit redistributing exchange data to third parties. One account feeding you on five devices is ordinary use; feeding other people is redistribution.

B — each user brings their own brokerage account. Every user runs the OAuth flow against their own Schwab login, and receives data under their own entitlement. No redistribution question. The cost is real: N streams, N tokens, N weekly re-auths, and the "one shared bar store" assumption disappears — MarketRuntime becomes one per connected account rather than one per process.

A is a private tool for people you trust. B is a product. Everything below works for either, except the last phase. Worth answering before that phase, not before starting.

Do not build local accounts

Going to OIDC means the app never stores a password, never hashes one, never implements reset or lockout. Building local accounts first means writing all of that and then deleting it. The path is: shared password → OIDC subject.

The one thing to fix in the current auth regardless is deps.session_secret, which derives the JWT signing key from sha256(password). With one shared password that is merely weak — anyone holding a session cookie can brute-force the password offline. With several users it is unworkable: either everyone shares a signing key, or the key varies by user and you cannot verify a token without already knowing who sent it. A server-side random secret fixes both, and is worth doing on its own.

Phases

Each is independently useful today.

Phase 1 — Split Runtime (valuable now: clarity and testability)

Runtime currently conflates market data with one person's analysis. Split it:

  • MarketRuntime — the stream, the bar store, and levels derived only from bars: daily MAs, session VWAP, prior-day H/L/C. Shared, one per process.
  • UserView — drawings, confluence clusters, the alert engine, layer prefs, and the ntfy topic. One per user.

The seam already half exists: ws.py computes connection_clusters(runtime, prefs) per connection, because layer visibility is per-browser. That is the per-user compute shape, just not keyed to an identity yet.

The consequence to plan for: clusters mix shared levels with your lines, so per-user drawings make clustering and alerting per-user too. Alerts move from one evaluation per closed bar to N. At small N that is nothing, but it lands on the event loop — see docs/async_refactor.md, and watch loop_lag_ms.

Phase 2 — Persistence with a user column (valuable now: cold restarts)

This is M7, which is already wanted for its own reasons: restarts currently re-seed everything and drawings live in one JSON file. Do it as SQLite, and give every drawing and every alert cooldown a user_id from the start — populated with a single constant while there is one user.

Doing per-user state on flat files and migrating later is doing it twice.

Preferences follow the same rule. Browser-only preferences may remain in localStorage until cross-device sync is worth building, but the first server-synced preference must not go into a global JSON file or acquire a dedicated database column. Add a user-keyed preference store at that point, initially using the same single constant as drawings.

Use an extensible shape such as:

CREATE TABLE user_preferences (
    user_id    TEXT NOT NULL,
    namespace  TEXT NOT NULL,
    value_json TEXT NOT NULL,
    updated_at INTEGER NOT NULL,
    PRIMARY KEY (user_id, namespace)
);

Each namespace owns a validated, versioned JSON object — for example drawing_palette can hold row annotations. Adding another preference or field then changes application validation, not the database schema. Do not turn this into an unvalidated miscellaneous bag: loaders supply defaults, ignore unknown fields for forward compatibility, and migrate a namespace's JSON version when its meaning changes. Whole-object last-write-wins is sufficient initially; introduce revisions or optimistic concurrency only when simultaneous edits from multiple devices become a demonstrated problem.

This store belongs to UserView persistence, never MarketRuntime. Palette labels, layer visibility, notification presentation and similar settings are owned by a person; bars, market-derived levels and feed health remain shared.

Phase 3 — Identity as a first-class concept, still one user

Thread user_id through every query and every WebSocket subscription while the value is still hardcoded. Nothing changes behaviourally; the difference is that afterwards, "more than one user" is data rather than a refactor.

The same identity must key user_preferences. Replacing the hardcoded value with an OIDC subject should require no preference-table migration and no JSON shape change — only the source of user_id changes.

This is the phase that makes the rest cheap, and it is invisible from outside — which is exactly why it is worth doing before it is needed.

Phase 4 — OIDC

Replace the password with an OIDC code flow against Authentik. The session JWT carries the provider's sub instead of "shared". Authentik federates Google, so the app never sees a credential of any kind.

Notes for when this lands:

  • The session cookie mechanics already exist and are correct — HttpOnly, SameSite=Strict, Secure derived from X-Forwarded-Proto. Keep them.
  • /api/version and /api/health stay unauthenticated for bin/wait-deploy.
  • /api/qt must stay reachable unauthenticated: Schwab redirects a browser there and cannot carry a session.
  • Keep a bypass for API clients — an opaque token header — or scripts and bin/ tooling all need a browser.

Phase 5 — Actually let other people in

Per-user ntfy topics, per-user alert engines, per-user drawing sets. Mechanical once phases 1–3 are done. Gated on the market-data question above.

What stays shared, forever

One Schwab streaming session per account — a per-account limit, not a per-server one. Under option A that is the whole app's feed. Under option B it is one per user account, which is the main reason B is more than a configuration change.

Where the cost shows up

The per-connection cluster recompute in ws.py is already the only O(N) path. Multi-user multiplies it by users rather than by tabs, and adds a per-user alert evaluation each closed bar. loop_lag_ms on /api/status is the number to watch; if it climbs past a few hundred milliseconds, the answer is incremental moving averages and fingerprint-based level diffs, both already described in docs/async_refactor.md.