Track multi-user as a direction, not a project

Separate people with their own drawings, alerts and notifications, behind OIDC
against a self-hosted Authentik that can federate Google. Written as phases that
each pay for themselves while the app is still single-user, so none of it is
scaffolding waiting on a decision.

The ordering conclusion worth stating plainly: do not build local accounts.
Going to OIDC means the app never stores or hashes a password, so building that
first means deleting it later. Shared password to OIDC subject, with nothing in
between.

One thing to fix regardless: the JWT signing key is sha256 of the password.
Today that is merely weak, since anyone holding a cookie can brute-force the
password offline. With several users it cannot work at all — either everyone
shares a signing key, or the key varies per user and a token cannot be verified
without already knowing who sent it. Added to the risk register.

The fork that decides the architecture is not an engineering one: whose market
data. One shared feed is redistribution, which Schwab's agreement and CME's
beneath it generally prohibit; each user bringing their own brokerage account
avoids the question entirely but means a stream, a token and a weekly re-auth
each, and the shared bar store stops being shared. That answer is only needed
before the last phase, which is why it is not a blocker on starting.

AGENTS.md points at both planning documents, because the cheapest moment to know
whether new state is shared or per-user is while it is being written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-11 16:21:25 -05:00
parent c653e65d5b
commit cbb26b19b9
3 changed files with 135 additions and 0 deletions

View file

@ -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
```

View file

@ -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 |

119
docs/multi_user.md Normal file
View file

@ -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`.