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

@ -131,6 +131,13 @@ app.include_router(setup_routes.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"
# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be
# heuristically cached — otherwise, after a deploy, a browser keeps serving the old index.html
# (pointing at the previous bundle) and runs stale code until a hard refresh. `no-cache` lets it
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
# hashed bundles under /assets can cache forever (a new build changes their filename).
INDEX_HTML = STATIC_DIR / "index.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"}
app.mount(
"/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@ -140,7 +147,7 @@ app.mount(
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
@app.get("/{full_path:path}")
@ -155,4 +162,4 @@ async def spa_fallback(full_path: str) -> FileResponse:
candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(STATIC_DIR / "index.html")
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)