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:
parent
c653e65d5b
commit
cbb26b19b9
3 changed files with 135 additions and 0 deletions
15
AGENTS.md
15
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
|
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||||
fields `SnapReport` declares.
|
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
|
## Running tests
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1188,6 +1188,7 @@ enforces for data sources.
|
||||||
| Repainting pivots | Lines that "were always there" | `w`-bar confirmation lag, enforced by test |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
119
docs/multi_user.md
Normal 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`.
|
||||||
Loading…
Reference in a new issue