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>
119 lines
5.6 KiB
Markdown
119 lines
5.6 KiB
Markdown
# 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`.
|