diff --git a/AGENTS.md b/AGENTS.md index ac7547a..1e325ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,21 @@ numbers, which is exactly what "works in my headless run" cannot tell you. Extend it when the next geometry puzzle appears; the endpoint takes whatever fields `SnapReport` declares. +## Direction of travel + +Two live planning documents, both written to be refactored toward rather than +implemented in one go: + +- `docs/async_refactor.md` — nothing blocks the event loop. P0 and P1 are done; + `/api/status` reports `loop_lag_ms`, and a rise there is the signal. +- `docs/multi_user.md` — separate people with their own drawings and alerts, + authenticated by OIDC. Read it before adding state to `Runtime`: new state is + either genuinely shared (market data) or belongs to a user, and knowing which + now is much cheaper than untangling it later. + +Do not build local user accounts. The destination is OIDC, so password storage +would be written and then deleted. + ## Running tests ``` diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e677ef8..2ced185 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1188,6 +1188,7 @@ enforces for data sources. | Repainting pivots | Lines that "were always there" | `w`-bar confirmation lag, enforced by test | | Alert fatigue | Product becomes unusable | Cluster-level alerts, cooldown + separation re-arm | | Multiple uvicorn workers | Duplicate Schwab connections | `workers=1`; streamer in `lifespan`; warned in `Procfile` | +| JWT signing key derived from the password | A leaked cookie brute-forces the password offline; blocks multi-user outright | Server-side random secret — `docs/multi_user.md` | | Threadpool routes touching `asyncio.Queue` | Dropped socket wakeups, rare corruption | Post via `call_soon_threadsafe` — `docs/async_refactor.md` P0 | | Level rebuilds run CPU-bound on the event loop | 82s startup; a stall every closed bar | Bulk seed, incremental MAs — `docs/async_refactor.md` P1 | | Alert disarm writes to disk on the loop | Stream stalls when an alert fires | Offload the write — `docs/async_refactor.md` P2 | diff --git a/docs/multi_user.md b/docs/multi_user.md new file mode 100644 index 0000000..ddcf3cb --- /dev/null +++ b/docs/multi_user.md @@ -0,0 +1,119 @@ +# 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. + +### 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. + +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`.