154 lines
7.2 KiB
Markdown
154 lines
7.2 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.
|
||
|
||
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:
|
||
|
||
```sql
|
||
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`.
|