feat(auth): per-tab account — different account per browser tab

Two tabs in one browser can now run two different signed-in accounts at once.

- The signed session cookie stays the browser's account WALLET (account_ids). Which account a
  given tab acts as is a per-tab choice held in sessionStorage and sent per-request via the
  X-Siftlode-Account header; current_user honours it only for an account already in the wallet,
  without mutating the cookie's default account. Switching accounts sets the header + reloads
  THIS tab only, instead of the old cookie-wide switch that changed every tab.
- WebSocket can't send headers, so the per-tab account rides in the ?account= query param
  (validated against the wallet).
- Logout is per-tab aware: it signs the requesting tab's active account out of the wallet
  (promoting a new default only if the removed one was the default), and the tab drops its
  override. A stale per-tab header account 401s just that tab instead of clearing the session.
- Serve index.html with Cache-Control: no-cache so a deploy's new hashed bundle is picked up
  immediately instead of the browser running a heuristically-cached stale index.html.
This commit is contained in:
npeter83 2026-07-01 23:43:35 +02:00
parent c0487101fe
commit 5cd807ec51
6 changed files with 146 additions and 36 deletions

View file

@ -363,7 +363,19 @@ async def messages_ws(ws: WebSocket) -> None:
"""Live push channel: authenticated by the session cookie, registers the connection so
sent messages reach this user's open tabs instantly. We don't consume clientserver frames
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
uid = ws.session.get("user_id") if "session" in ws.scope else None
# A browser WebSocket can't send custom headers, so the per-tab account (see
# X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for
# an account already in this browser's wallet. Falls back to the session default.
sess = ws.session if "session" in ws.scope else {}
uid = sess.get("user_id")
q = ws.query_params.get("account")
if q:
try:
qid = int(q)
except ValueError:
qid = None
if qid is not None and qid in (sess.get("account_ids") or []):
uid = qid
if not uid:
await ws.close(code=1008)
return