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:
parent
89632e9937
commit
f326b41a08
23 changed files with 1637 additions and 95 deletions
|
|
@ -21,7 +21,11 @@ config = context.config
|
|||
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
# disable_existing_loggers=False is essential: the app applies
|
||||
# migrations in-process at startup (see app.main lifespan), so the
|
||||
# default True would disable uvicorn's already-configured loggers —
|
||||
# silencing access logs and 500 tracebacks for the whole process.
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
|
|
|||
43
alembic/versions/0015_portfolio_sync.py
Normal file
43
alembic/versions/0015_portfolio_sync.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""portfolio_sync: opt-in encrypted backup of a user's pie.
|
||||
|
||||
The plaintext pie is encrypted client-side with a PIN-derived AES-GCM
|
||||
key; the server wraps the ciphertext again with a key derived from
|
||||
PORTFOLIO_SYNC_PEPPER + user_id. We only store the outer-wrapped bytes
|
||||
plus a small rate-limit window pair for GET throttling.
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
Create Date: 2026-05-23
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0015"
|
||||
down_revision: Union[str, None] = "0014"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"portfolio_sync",
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True, nullable=False,
|
||||
),
|
||||
sa.Column("outer_ciphertext", sa.LargeBinary(), nullable=False),
|
||||
sa.Column("outer_nonce", sa.LargeBinary(), nullable=False),
|
||||
sa.Column("version", sa.SmallInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fetch_window_start", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("fetch_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("portfolio_sync")
|
||||
Loading…
Add table
Add a link
Reference in a new issue