sync: encrypted cloud backup for portfolios + settings UX rework

Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-23 16:15:54 +02:00
parent 89632e9937
commit f326b41a08
23 changed files with 1637 additions and 95 deletions

View file

@ -55,20 +55,31 @@ def normalise_code(raw: str | None) -> str | None:
async def assign_code_if_missing(session: AsyncSession, user: User) -> User:
"""Generate + persist a referral code on `user` if they don't have
one yet. Retries on the (very rare) collision."""
one yet. Retries on the (very rare) collision.
The `user` argument is the User attached to the auth-dependency
session, which has since been closed so it is detached from our
`session`. We re-fetch it here before mutating so SQLAlchemy doesn't
refuse with 'not persistent within this Session'.
"""
if user.referral_code:
return user
db_user = await session.get(User, user.id)
if db_user is None:
raise RuntimeError(f"referral_service: user {user.id} vanished mid-request")
if db_user.referral_code:
# Raced with another request — accept their code.
return db_user
for _ in range(8):
code = generate_code()
existing = (await session.execute(
select(User.id).where(User.referral_code == code)
)).scalar_one_or_none()
if existing is None:
user.referral_code = code
db_user.referral_code = code
await session.commit()
await session.refresh(user)
log.info("referral.code_assigned", user_id=user.id, code=code)
return user
log.info("referral.code_assigned", user_id=db_user.id, code=code)
return db_user
# 8 collisions in a row would be a statistical event we'd want to
# know about.
raise RuntimeError("referral_service: exhausted code-collision retries")