Compare commits

..

2 commits

Author SHA1 Message Date
b98d8d003c ui: aggregated read on top, hide stale rows, wire /log tone toggle; prompts v8
- dashboard grid: explicit "header" area as the first row so the
    aggregated read panel renders at the top instead of being
    auto-placed after the named areas.
  - indicators: hide rows flagged stale (older than the group's
    freshness threshold). Server still computes stale_symbols;
    rendering can be re-enabled by removing the
    `{% if not is_stale %}` wrapper in indicators.html.
  - /log: add tone-changed to #log-content's hx-trigger and include
    it in cassandraSetTone's selector list — toggling Novice /
    Intermediate on the Log page was previously a no-op.
  - prompts: bump PROMPT_VERSION 7→8. Strengthen the rational-vs-
    irrational framing in the strategic-log system prompt from
    aspirational to mandatory ("a paragraph without both lenses must
    be rewritten"). Require the same lens in the per-group summary,
    cross-asset aggregate, and portfolio commentary overrides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:36:04 +02:00
f326b41a08 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>
2026-05-23 16:15:54 +02:00
27 changed files with 1697 additions and 111 deletions

View file

@ -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

View 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")

View file

@ -63,6 +63,13 @@ class Settings(BaseSettings):
CASSANDRA_ANCHOR_DATE: str = ""
CASSANDRA_MOCK: bool = False
# Server-side pepper for the cloud-sync outer wrap. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# When empty, the outer layer degrades to "salt by user_id only" — fine
# for dev, but a prod DB leak would then suffice to brute-force PINs
# offline. The startup log warns if this is empty on a non-sqlite DB.
PORTFOLIO_SYNC_PEPPER: str = ""
# AI log — provider abstraction with fallback chain.
# `LLM_PROVIDER` is the primary; `LLM_FALLBACK` kicks in if the primary
# raises (after its own internal retries). Set LLM_FALLBACK="" to

View file

@ -31,12 +31,27 @@ def get_engine():
global _engine
if _engine is None:
s = get_settings()
_engine = create_async_engine(
s.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
future=True,
)
# NB: pool_pre_ping is intentionally OFF. aiomysql 0.3.x made
# Connection.ping()'s `reconnect` arg mandatory, but SQLAlchemy's
# MySQL pre-ping (2.0.49) calls it without that arg — so every
# reused pooled connection raises TypeError, surfacing as an
# intermittent 500 (502 behind the proxy). pool_recycle below
# (1h, well under MariaDB's 8h wait_timeout) keeps connections
# fresh without needing a ping.
#
# isolation_level READ COMMITTED: under MariaDB's default
# REPEATABLE READ, the "invalidate prior unused codes" UPDATE in
# otp_service.issue() takes next-key/gap locks on the
# (email, created_at) index even when it matches no rows;
# concurrent OTP INSERTs then deadlock (errno 1213). READ
# COMMITTED drops those gap locks — appropriate here since every
# request is a short, self-contained transaction. SQLite (the
# test sentinel backend) rejects this level, so set it only for
# the real server backends.
kwargs: dict = {"pool_recycle": 3600, "future": True}
if not s.DATABASE_URL.startswith("sqlite"):
kwargs["isolation_level"] = "READ COMMITTED"
_engine = create_async_engine(s.DATABASE_URL, **kwargs)
return _engine

View file

@ -20,6 +20,7 @@ from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import pages as pages_router
from app.routers import sync as sync_router
from app.routers import universe as universe_router
from app.services.feeds_bootstrap import bootstrap_feeds
@ -41,6 +42,12 @@ def _run_migrations() -> None:
async def lifespan(app: FastAPI):
configure_logging()
log.info("cassandra.startup")
s = get_settings()
if not s.PORTFOLIO_SYNC_PEPPER and not s.DATABASE_URL.startswith("sqlite"):
# Outer wrap still works (it just degrades to a per-user derived
# key with no shared secret), but a DB leak would let an attacker
# brute-force the PIN offline. Loud warning, not a hard failure.
log.warning("cassandra.portfolio_sync.pepper_missing")
try:
# Alembic's env.py uses asyncio.run() internally; offload it to a
# worker thread so it doesn't collide with FastAPI's running loop.
@ -77,4 +84,5 @@ app.mount(
app.include_router(auth_router.router, tags=["auth"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(sync_router.router, tags=["portfolio-sync"])
app.include_router(pages_router.router, tags=["pages"])

View file

@ -17,6 +17,8 @@ from sqlalchemy import (
ForeignKey,
Index,
Integer,
LargeBinary,
SmallInteger,
String,
Text,
UniqueConstraint,
@ -179,6 +181,31 @@ class User(Base):
)
class PortfolioSync(Base):
"""Opt-in encrypted backup of a user's pie. Stored as opaque bytes:
the client encrypts the pie with a PIN-derived key (AES-GCM), and the
server wraps that ciphertext again with a per-user key derived from
PORTFOLIO_SYNC_PEPPER + user_id (also AES-GCM). A DB-only leak yields
nothing usable without the env-only pepper; a pepper-only leak still
leaves the attacker brute-forcing the PIN through PBKDF2(600k).
One row per user. Absent row = sync disabled for that user. The
fetch_window_* fields drive a sliding-window rate limit on GET so the
pepper-leak threat model can't degenerate into an unthrottled brute
force against the inner PBKDF2."""
__tablename__ = "portfolio_sync"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True,
)
outer_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
outer_nonce: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
fetch_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
fetch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class Referral(Base):
"""One row per captured (referrer, referred) pair. Created at signup
when the new user supplied a valid `?ref=<code>`. The conversion

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -35,10 +35,10 @@ async def news_page(request: Request):
return templates.TemplateResponse(request, "news.html", {})
@router.get("/upload", response_class=HTMLResponse)
@router.get("/upload")
async def upload_page(request: Request):
"""Drag-drop CSV import. Posts to /api/portfolios/upload."""
return templates.TemplateResponse(request, "upload.html", {})
"""Legacy bookmark — the import widget now lives in /settings."""
return RedirectResponse(url="/settings#import", status_code=302)
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:

133
app/routers/sync.py Normal file
View file

@ -0,0 +1,133 @@
"""Encrypted-pie cloud sync — endpoints behind the paid-tier gate.
The blob field is base64 because JSON can't carry raw bytes. The server
treats it as opaque: we only need to know its length (to reject obviously
oversized payloads) and to hand it back as-is on GET. All crypto for the
inner layer happens in the browser; we just add the outer wrap in
app.services.portfolio_sync.
"""
from __future__ import annotations
import base64
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUser
from app.db import get_session
from app.logging import get_logger
from app.services import portfolio_sync as svc
from app.services.access import require_paid
log = get_logger("portfolio_sync_router")
router = APIRouter(prefix="/api/portfolio/sync")
# A 256 KB cap is ~200× a typical pie's serialized size — generous
# headroom for AI analysis blobs the client may bundle later.
MAX_BLOB_BYTES = 256 * 1024
class SyncBlobIn(BaseModel):
blob: str = Field(..., description="base64url of the client-side ciphertext")
class SyncBlobOut(BaseModel):
blob: str
updated_at: datetime
class SyncStatusOut(BaseModel):
exists: bool
updated_at: datetime | None = None
class SyncWriteOut(BaseModel):
updated_at: datetime
def _decode_blob(b64: str) -> bytes:
"""Tolerates url-safe and standard alphabets, with or without padding."""
try:
s = b64.strip()
# Pad to multiple of 4 — base64 in browsers commonly omits it.
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
except Exception:
# Last-ditch: try standard alphabet too.
try:
return base64.b64decode(b64, validate=False)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="blob must be base64",
)
@router.get("/status", response_model=SyncStatusOut)
async def get_status(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncStatusOut:
exists, updated_at = await svc.fetch_status(session, principal.id)
return SyncStatusOut(exists=exists, updated_at=updated_at)
@router.post("", response_model=SyncWriteOut)
async def upload_blob(
body: SyncBlobIn,
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncWriteOut:
raw = _decode_blob(body.blob)
if len(raw) > MAX_BLOB_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"blob exceeds {MAX_BLOB_BYTES} bytes",
)
if not raw:
raise HTTPException(status_code=400, detail="blob is empty")
updated_at = await svc.upsert(session, principal.id, raw)
log.info("portfolio_sync.upserted", user_id=principal.id, bytes=len(raw))
return SyncWriteOut(updated_at=updated_at)
@router.get("", response_model=SyncBlobOut)
async def download_blob(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncBlobOut:
if not await svc.consume_fetch_budget(session, principal.id):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="too many fetches; try again in a minute",
)
try:
result = await svc.fetch(session, principal.id)
except svc.SyncCryptoError:
log.error("portfolio_sync.unwrap_failed", user_id=principal.id)
raise HTTPException(
status_code=500,
detail="server failed to read the encrypted blob",
)
if result is None:
raise HTTPException(status_code=404, detail="no synced portfolio")
inner, updated_at = result
return SyncBlobOut(
blob=base64.urlsafe_b64encode(inner).decode("ascii").rstrip("="),
updated_at=updated_at,
)
@router.delete("")
async def delete_blob(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> dict:
removed = await svc.delete(session, principal.id)
log.info("portfolio_sync.deleted", user_id=principal.id, removed=removed)
return {"ok": True, "removed": removed}

View file

@ -130,6 +130,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
positions: list[ParsedPosition] = []
total: ParsedPosition | None = None
pie_name: str | None = None
zero_qty_slices = 0 # real slice rows skipped for missing/zero quantity
for row_num, row in enumerate(reader, start=2):
if not row or not any(cell.strip() for cell in row):
@ -167,6 +168,8 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
qty = record.get("quantity")
if qty is None or qty == 0:
# Position row with no usable quantity — skip rather than fail.
# Counted so an all-zero (unfunded) pie yields a precise error.
zero_qty_slices += 1
continue
positions.append(ParsedPosition(
@ -182,6 +185,16 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
))
if not positions:
# Distinguish an unfunded pie (slices present, all 0 quantity)
# from a genuinely unreadable file — the two need very different
# user action, and the generic message misleads people into
# debugging the file format.
if zero_qty_slices:
raise CSVImportError(
f"This pie holds no shares — all {zero_qty_slices} "
f"slice(s) have an Owned quantity of 0. Export the pie from "
f"Trading 212 after it has been funded."
)
raise CSVImportError(
"CSV contained no parseable position rows. "
"Expected at least one row with a Slice code and quantity."

View file

@ -30,7 +30,7 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# v7 (2026-05-18): Forbid "(Updated HH:MM UTC)" clauses in the date header —
# the model was hallucinating future times. The user prompt now carries the
# actual current UTC time so the model has accurate temporal context.
PROMPT_VERSION = 7
PROMPT_VERSION = 8
# --- Core: invariant across tone/analysis settings ----------------------------
@ -85,16 +85,25 @@ omit the paragraph.
"things to watch tomorrow". Each watch item should name a level/threshold \
whose breach would change the regime, not a calendar-date event.
# Rational vs irrational framing
# Rational vs irrational framing (MANDATORY in every paragraph)
The reader's primary goal is to disconnect rational decisions from market \
irrationality. In every sector or theme paragraph, separately identify:
- The RATIONAL drivers: earnings, real-economy data, monetary policy, \
structural geopolitical shifts, valuation vs fundamentals.
- The IRRATIONAL drivers: positioning, narrative momentum, sentiment \
extremes, concentration, flow-driven moves, options gamma, credit complacency.
When the two diverge price moving on irrational drivers while fundamentals \
say otherwise, or vice versa flag the divergence explicitly. Those gaps \
are where the next regime change starts.
irrationality. This is the single most important lens of the log it MUST \
appear in every sector or theme paragraph, not just where it feels natural. \
For each paragraph, before writing it, ask yourself the two questions and \
then make both answers visible in the prose:
- The RATIONAL drivers what the underlying factors justify: earnings, \
real-economy data, monetary policy, structural geopolitical shifts, \
valuation vs fundamentals.
- The IRRATIONAL drivers what the crowd is doing regardless of fundamentals: \
positioning, narrative momentum, sentiment extremes, concentration, \
flow-driven moves, options gamma, credit complacency.
Then state the GAP: is price moving with the rational read, ahead of it, \
or against it? If they agree, say so briefly and move on. If they diverge \
price moving on irrational drivers while fundamentals say otherwise, or \
vice versa name the divergence explicitly. Those gaps are where the next \
regime change starts and are the whole point of this log.
A paragraph that names only price action or only fundamentals, without \
both lenses, is incomplete and must be rewritten.
# Discipline
- No emojis, no marketing language, no "concerning" or "unprecedented" \
@ -302,6 +311,13 @@ They can see the values. They CANNOT see the meaning. Your job is to \
a regime-level interpretation, a fundamental driver identification, or a \
cross-indicator implication not a description of moves.
# Rational vs irrational lens (required at this length too)
Even at 2-3 sentences, contrast what the underlying factors justify \
(rational: fundamentals, policy, valuation) with what the crowd is doing \
(irrational: positioning, narrative, flows) whenever the two diverge. If \
they don't diverge, say so in one clause. Never just describe the move \
without placing it on this axis.
# Hard constraints
- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \
@ -350,6 +366,13 @@ Your job is NOT to summarise the moves. It is to explain what the moves, \
which divergences are load-bearing, what fundamental story the cross-asset \
behaviour tells.
# Rational vs irrational lens (required at this length too)
The cross-asset tape's value is in the gap between what the underlying \
factors justify (rational: fundamentals, policy, valuation) and what the \
crowd is actually doing (irrational: positioning, narrative momentum, \
flows). At least one of the 2-4 sentences must name this gap or, if the \
two cohere, explicitly say so.
# Hard constraints
- Plain prose, ONE paragraph. No markdown, headers, lists, or labels.
- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \

View file

@ -250,6 +250,19 @@ implies X under scenario Y"), not advice ("buy X" / "sell Y" are forbidden).
what would invalidate the current posture.
- ~350 words. No bullet lists. No buy/sell recommendations.
- Do not repeat the input data verbatim interpret it.
# Rational vs irrational lens (mandatory)
Carry the base prompt's rational-vs-irrational framing through to every
paragraph of the portfolio read. For each section above, contrast:
- The RATIONAL read: what the underlying factors (fundamentals,
macro/policy regime, valuation, currency dynamics) justify for this
exposure;
- The IRRATIONAL read: what positioning, narrative momentum, sentiment
or flows are doing to that same exposure right now.
Then name the GAP does the holder's posture line up with the rational
read, or is it riding the irrational one? A paragraph that names only
the pie's numbers or only the macro backdrop, without placing the
holding on this rational-vs-irrational axis, is incomplete.
"""

View file

@ -0,0 +1,178 @@
"""Encrypted-pie cloud-sync service.
The client encrypts the pie locally with a PIN-derived AES-GCM key we
never see that key. We add a second AES-GCM layer with a per-user key
derived from the env-only PORTFOLIO_SYNC_PEPPER, so a DB-only leak yields
nothing usable. Stored bytes are opaque from the inside (the inner
ciphertext) and from the outside (the outer wrap).
Threat-model summary:
- DB leaks, env intact: safe (outer key still secret).
- env leaks, DB intact: safe (no rows to decrypt).
- Both leak: attacker still brute-forces PBKDF2(600k)
over the PIN; the fetch endpoint is
rate-limited to bound online attempts.
- PIN forgotten: unrecoverable. Re-upload the CSV.
The service is pure: no FastAPI deps, no logging. The router wires it up.
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import PortfolioSync
# AES-GCM standard nonce is 12 bytes; AES-256 needs a 32-byte key.
_NONCE_LEN = 12
_KEY_LEN = 32
# Sliding-window rate limit on GET (the brute-force vector). 6 / 60s ≈
# 100k attempts/year — slow enough that a 6-digit PIN would take a decade
# even if the pepper leaked.
RATE_LIMIT_WINDOW = timedelta(seconds=60)
RATE_LIMIT_MAX = 6
class SyncCryptoError(Exception):
"""Outer-wrap decryption failed — usually a pepper change or
bit-rotted row. The router maps this to a 500."""
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _pepper_bytes() -> bytes:
"""Returns the configured pepper as bytes. Empty in dev / tests; that
weakens the outer wrap (key is now derived from user_id alone) but
keeps everything functional."""
return get_settings().PORTFOLIO_SYNC_PEPPER.encode("utf-8")
def _server_key(user_id: int) -> bytes:
"""Per-user 32-byte AES key derived from PORTFOLIO_SYNC_PEPPER. HKDF
binds the user_id into the `salt` so two users with the same pepper
get independent keys, and includes a versioned info string so we can
rotate the derivation later without breaking old rows."""
return HKDF(
algorithm=hashes.SHA256(),
length=_KEY_LEN,
salt=str(user_id).encode("utf-8"),
info=b"portfolio-sync-v1",
).derive(_pepper_bytes())
def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]:
"""Encrypt the client-side ciphertext (`inner_blob`) for storage.
Returns (outer_ct, outer_nonce). The nonce is random per write."""
nonce = os.urandom(_NONCE_LEN)
ct = AESGCM(_server_key(user_id)).encrypt(nonce, inner_blob, None)
return ct, nonce
def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes:
"""Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails."""
try:
return AESGCM(_server_key(user_id)).decrypt(outer_ct, outer_nonce, None)
except Exception as exc: # InvalidTag, malformed ciphertext, etc.
raise SyncCryptoError("outer wrap unwrap failed") from exc
async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> datetime:
"""Insert or replace this user's sync row. Returns the new updated_at."""
outer_ct, outer_nonce = wrap(user_id, inner_blob)
now = _utcnow()
row = await session.get(PortfolioSync, user_id)
if row is None:
row = PortfolioSync(
user_id=user_id,
outer_ciphertext=outer_ct,
outer_nonce=outer_nonce,
version=1,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.outer_ciphertext = outer_ct
row.outer_nonce = outer_nonce
row.updated_at = now
# Bump version field forward if we ever change the wrap scheme.
row.version = 1
await session.commit()
return now
async def fetch_status(
session: AsyncSession, user_id: int,
) -> tuple[bool, datetime | None]:
"""Cheap existence check — does NOT decrypt. Used by the dashboard to
decide whether to show the restore prompt."""
row = await session.get(PortfolioSync, user_id)
if row is None:
return False, None
return True, row.updated_at
async def fetch(
session: AsyncSession, user_id: int,
) -> tuple[bytes, datetime] | None:
"""Returns (inner_blob, updated_at) or None if sync disabled.
Raises SyncCryptoError if the row exists but the outer wrap is
unreadable (typically: pepper was rotated without re-encrypting).
"""
row = await session.get(PortfolioSync, user_id)
if row is None:
return None
inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce)
return inner, row.updated_at
async def delete(session: AsyncSession, user_id: int) -> bool:
"""Returns True if a row was deleted, False if none existed."""
row = await session.get(PortfolioSync, user_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def consume_fetch_budget(session: AsyncSession, user_id: int) -> bool:
"""Sliding-window rate-limiter for GET. Returns True if the call is
within budget (caller proceeds), False if over (caller returns 429).
Window state lives on the row itself no need for Redis. On no-row,
the GET handler will 404 anyway; we return True so it can fall
through to that handler.
"""
row = await session.get(PortfolioSync, user_id)
if row is None:
return True
now = _utcnow()
start = row.fetch_window_start
# Normalise: aiomysql sometimes returns naive datetimes.
if start is not None and start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
if start is None or now - start >= RATE_LIMIT_WINDOW:
row.fetch_window_start = now
row.fetch_count = 1
await session.commit()
return True
if row.fetch_count >= RATE_LIMIT_MAX:
# Don't bump — over budget. Window expires on its own; no commit.
return False
row.fetch_count += 1
await session.commit()
return True

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")

View file

@ -82,7 +82,9 @@ a:hover { text-decoration: underline; }
.app-header .brand {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.app-header .brand:hover { color: var(--text); }
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
.app-header nav a {
margin-left: 18px;
@ -138,8 +140,9 @@ a:hover { text-decoration: underline; }
padding: 14px;
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto auto auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
"header header"
"indicators log"
"portfolio log"
"news news";
@ -148,10 +151,11 @@ a:hover { text-decoration: underline; }
@media (max-width: 1100px) {
.app-main {
grid-template-columns: 1fr;
grid-template-areas: "indicators" "portfolio" "log" "news";
grid-template-areas: "header" "indicators" "portfolio" "log" "news";
}
}
#dash-header-container { grid-area: header; }
#indicators-panel { grid-area: indicators; }
#portfolio-panel { grid-area: portfolio; }
#log-panel {
@ -1034,19 +1038,55 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
border-color: var(--accent) !important;
}
/* User chip in header */
/* Import preview action row — two stacked buttons with an explainer. */
.import-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 14px;
}
.import-choice { flex: 1 1 240px; min-width: 220px; }
.import-choice button { width: 100%; }
.import-choice .settings-row__hint {
display: block;
margin-top: 6px;
line-height: 1.5;
}
/* User chip in header — now a button that toggles a dropdown menu. */
.user-menu { position: relative; margin-left: 8px; }
.user-chip {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
margin-left: 8px;
letter-spacing: 0.04em;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.user-chip a {
color: var(--muted);
border-bottom: 1px dotted var(--muted);
.user-chip:hover { color: var(--accent); }
.user-menu__caret { margin-left: 4px; opacity: 0.6; }
.user-menu__panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 160px;
background: var(--surface-1, var(--surface-2));
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
z-index: 200;
padding: 4px 0;
}
.user-chip a:hover { color: var(--accent); border-color: var(--accent); }
.user-menu__item {
display: block;
padding: 8px 14px;
color: var(--text);
text-decoration: none;
font-size: 12px;
}
.user-menu__item:hover { background: var(--surface-2); color: var(--accent); }
/* --- Upload page (drag-drop CSV) ------------------------------------- */

View file

@ -0,0 +1,280 @@
/* Cassandra client-side encrypted portfolio sync.
*
* The server only ever sees opaque ciphertext. The browser:
* 1. Derives an AES-GCM key from the user's PIN with PBKDF2 (600k SHA-256).
* 2. Encrypts the pie JSON, packs salt+nonce+ct into one blob.
* 3. POSTs the blob to /api/portfolio/sync.
* 4. On pull, fetches the blob, reverses the steps with the PIN.
*
* The derived key is cached in sessionStorage so the user enters the PIN
* at most once per browser session (cleared on tab close / logout). The
* server-side outer wrap (see app/services/portfolio_sync.py) hardens the
* stored ciphertext against a DB-only leak.
*
* Packed inner-blob format (all bytes, then base64-url for transport):
* byte 0: version (currently 1)
* bytes 1..4: PBKDF2 iteration count, uint32 big-endian
* bytes 5..20: salt (16 bytes)
* bytes 21..32: nonce (12 bytes)
* bytes 33..: AES-GCM ciphertext (includes 16-byte tag suffix)
*/
(function () {
'use strict';
const VERSION = 1;
const ITERATIONS = 600_000;
const SALT_LEN = 16;
const NONCE_LEN = 12;
const HEADER_LEN = 1 + 4 + SALT_LEN + NONCE_LEN; // = 33
const SESSION_KEY_STORAGE = 'cassandra.sync.key.v1';
const SESSION_SALT_STORAGE = 'cassandra.sync.salt.v1';
// --- byte helpers ----------------------------------------------------
function u8concat(parts) {
let n = 0;
for (const p of parts) n += p.length;
const out = new Uint8Array(n);
let i = 0;
for (const p of parts) { out.set(p, i); i += p.length; }
return out;
}
function b64urlEncode(bytes) {
let s = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
s += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64urlDecode(s) {
const norm = s.replace(/-/g, '+').replace(/_/g, '/');
const padded = norm + '='.repeat((4 - norm.length % 4) % 4);
const bin = atob(padded);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// --- WebCrypto -------------------------------------------------------
async function pbkdf2Derive(pin, salt, iterations) {
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(pin),
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
true, // extractable: we cache raw bytes in sessionStorage
['encrypt', 'decrypt'],
);
}
async function exportKey(key) {
const raw = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(raw);
}
async function importKey(raw) {
return crypto.subtle.importKey(
'raw', raw, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'],
);
}
// --- session cache ---------------------------------------------------
// We cache both the raw key AND the salt that produced it, so a push
// after upload can rebuild the same packed-blob header without
// re-prompting for a PIN. Lives in sessionStorage so it dies with the
// tab.
function cacheKey(rawKey, salt) {
try {
sessionStorage.setItem(SESSION_KEY_STORAGE, b64urlEncode(rawKey));
sessionStorage.setItem(SESSION_SALT_STORAGE, b64urlEncode(salt));
} catch (e) {
console.warn('cassandra.sync: sessionStorage write failed', e);
}
}
async function getCachedKeyAndSalt() {
const rk = sessionStorage.getItem(SESSION_KEY_STORAGE);
const sk = sessionStorage.getItem(SESSION_SALT_STORAGE);
if (!rk || !sk) return null;
return {
key: await importKey(b64urlDecode(rk)),
salt: b64urlDecode(sk),
};
}
function clearCachedKey() {
sessionStorage.removeItem(SESSION_KEY_STORAGE);
sessionStorage.removeItem(SESSION_SALT_STORAGE);
}
// --- pack / unpack ---------------------------------------------------
function packBlob(salt, nonce, ct, iterations) {
const header = new Uint8Array(HEADER_LEN);
header[0] = VERSION;
new DataView(header.buffer).setUint32(1, iterations, false); // big-endian
header.set(salt, 5);
header.set(nonce, 5 + SALT_LEN);
return u8concat([header, new Uint8Array(ct)]);
}
function unpackBlob(bytes) {
if (bytes.length < HEADER_LEN + 16) {
throw new Error('blob too small');
}
const version = bytes[0];
if (version !== VERSION) {
throw new Error('unknown sync blob version: ' + version);
}
const iterations = new DataView(bytes.buffer, bytes.byteOffset, HEADER_LEN)
.getUint32(1, false);
const salt = bytes.slice(5, 5 + SALT_LEN);
const nonce = bytes.slice(5 + SALT_LEN, HEADER_LEN);
const ct = bytes.slice(HEADER_LEN);
return { version, iterations, salt, nonce, ct };
}
// --- encrypt / decrypt ----------------------------------------------
/**
* Encrypt a pie object with `pin`. Returns the packed blob as a
* base64url string ready for POST /api/portfolio/sync. Also caches
* the derived key in sessionStorage so subsequent pushes don't need
* the PIN.
*/
async function encryptPie(pie, pin) {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
const key = await pbkdf2Derive(pin, salt, ITERATIONS);
const plaintext = new TextEncoder().encode(JSON.stringify(pie));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce }, key, plaintext,
);
cacheKey(await exportKey(key), salt);
return b64urlEncode(packBlob(salt, nonce, ct, ITERATIONS));
}
/**
* Re-encrypt with a cached key (no PIN needed). Re-uses the cached
* salt so the blob remains decryptable with the same PIN later.
* Returns null if no key is cached.
*/
async function encryptPieWithCachedKey(pie) {
const cached = await getCachedKeyAndSalt();
if (!cached) return null;
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
const plaintext = new TextEncoder().encode(JSON.stringify(pie));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce }, cached.key, plaintext,
);
return b64urlEncode(packBlob(cached.salt, nonce, ct, ITERATIONS));
}
/**
* Decrypt a server blob with `pin`. Throws BadPinError on auth failure.
* Caches the derived key on success.
*/
class BadPinError extends Error {
constructor() { super('Incorrect PIN'); this.name = 'BadPinError'; }
}
async function decryptBlob(blobB64, pin) {
const bytes = b64urlDecode(blobB64);
const { iterations, salt, nonce, ct } = unpackBlob(bytes);
const key = await pbkdf2Derive(pin, salt, iterations);
let plaintext;
try {
plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce }, key, ct,
);
} catch (_e) {
throw new BadPinError();
}
cacheKey(await exportKey(key), salt);
return JSON.parse(new TextDecoder().decode(plaintext));
}
// --- network ---------------------------------------------------------
async function getStatus() {
const r = await fetch('/api/portfolio/sync/status', {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
});
if (r.status === 402) return { exists: false, paid: false };
if (!r.ok) throw new Error('sync status: HTTP ' + r.status);
const body = await r.json();
return { exists: !!body.exists, updated_at: body.updated_at, paid: true };
}
async function pushSync(pie, pin) {
// If a cached key exists, re-use it; otherwise derive from the PIN.
let blob = await encryptPieWithCachedKey(pie);
if (!blob) {
if (!pin) throw new Error('PIN required to enable sync');
blob = await encryptPie(pie, pin);
}
const r = await fetch('/api/portfolio/sync', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blob }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.detail || ('sync push: HTTP ' + r.status));
}
return r.json();
}
async function pullSync(pin) {
const r = await fetch('/api/portfolio/sync', {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
});
if (r.status === 404) return null;
if (!r.ok) {
const body = await r.json().catch(() => ({}));
// 429 → server already throttling; bubble the message up unchanged.
throw new Error(body.detail || ('sync pull: HTTP ' + r.status));
}
const { blob } = await r.json();
return decryptBlob(blob, pin);
}
async function disableSync() {
const r = await fetch('/api/portfolio/sync', {
method: 'DELETE',
credentials: 'same-origin',
});
if (!r.ok) throw new Error('sync delete: HTTP ' + r.status);
clearCachedKey();
return r.json();
}
window.CassandraSync = {
getStatus,
encryptPie,
decryptBlob,
pushSync,
pullSync,
disableSync,
clearCachedKey,
BadPinError,
// Exposed for tests / debugging:
_packBlob: packBlob,
_unpackBlob: unpackBlob,
};
})();

View file

@ -168,10 +168,58 @@
mount.innerHTML =
'<div class="empty" style="padding:16px;">' +
'No portfolio loaded in this browser. ' +
'<a href="/upload">Import a T212 CSV →</a>' +
'<a href="/settings#import">Import a T212 CSV →</a>' +
'</div>';
}
function renderRestoreFromCloud(mount, status) {
const lastSynced = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
mount.innerHTML =
'<div class="pf-restore" style="padding:16px;">' +
'<div class="result__head">▸ Restore from cloud</div>' +
'<div class="result__row" style="margin-bottom:12px;">' +
'A synced portfolio is available for this account (last synced ' +
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
'</div>' +
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
'autocomplete="off" placeholder="PIN" ' +
'style="flex:0 0 140px;">' +
'<button type="submit">Restore</button>' +
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
'or import a new CSV →</a>' +
'</form>' +
'<div id="pf-restore-err" class="pf-warn" hidden style="margin-top:10px;"></div>' +
'</div>';
const form = document.getElementById('pf-restore-form');
const pin = document.getElementById('pf-restore-pin');
const err = document.getElementById('pf-restore-err');
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
const value = (pin.value || '').trim();
if (!value) return;
try {
const pie = await window.CassandraSync.pullSync(value);
if (!pie) {
err.textContent = 'No synced portfolio found.';
err.hidden = false;
return;
}
savePie(pie);
mountAndRender();
} catch (e2) {
err.textContent = (e2 && e2.name === 'BadPinError')
? 'Incorrect PIN.'
: (e2.message || 'Could not restore.');
err.hidden = false;
}
});
}
function renderPanel(mount, pie, enriched, agg) {
const ccyPills = Object.keys(agg.by_currency)
.sort((a, b) => agg.by_currency[b] - agg.by_currency[a])
@ -331,7 +379,15 @@
});
const data = await r.json();
if (!r.ok) {
out.innerHTML = '<div class="pf-warn">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
// FastAPI `detail` is usually a string, but some endpoints send
// an object — e.g. the 402 paid-gate returns {code, message}.
// Render the human-readable text either way; never the object
// (which stringifies to the useless "[object Object]").
const d = data && data.detail;
const msg = (d && typeof d === 'object')
? (d.message || JSON.stringify(d))
: (d || ('HTTP ' + r.status));
out.innerHTML = '<div class="pf-warn">' + esc(msg) + '</div>';
return;
}
// Persist before rendering so auto-refresh can re-hydrate.
@ -351,7 +407,20 @@
if (!mount) return;
const pie = loadPie();
if (!pie || !pie.positions || !pie.positions.length) {
renderEmpty(mount);
// Before falling back to "no portfolio", check whether the account
// has a synced blob this device could restore from. Status is
// 402 for free-tier users — getStatus() returns paid:false there
// and we fall through to the standard empty state.
let status = null;
if (window.CassandraSync) {
try { status = await window.CassandraSync.getStatus(); }
catch (e) { console.warn('sync status check failed', e); }
}
if (status && status.paid && status.exists) {
renderRestoreFromCloud(mount, status);
} else {
renderEmpty(mount);
}
return;
}
try {
@ -369,72 +438,42 @@
renderPanel(mount, pie, enriched, agg);
}
// --- Upload page helper ------------------------------------------------
async function handleUpload(form, file, statusEl) {
statusEl.className = 'result';
statusEl.hidden = true;
// --- Parse primitive ---------------------------------------------------
//
// Hits /api/portfolio/parse and returns the parsed pie. The caller
// decides whether to savePie() and whether to push to cloud sync — keeps
// the post-parse decision in the inline UI script instead of buried in
// this module.
async function parseCsv(file) {
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/api/portfolio/parse', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
const data = await r.json();
if (!r.ok) {
statusEl.className = 'result result--err';
statusEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
statusEl.hidden = false;
return false;
}
savePie(data);
const warnings = (data.warnings || []).map(w =>
'<div class="result__warn">' + esc(w) + '</div>').join('');
statusEl.className = 'result result--ok';
statusEl.innerHTML =
'<div class="result__head">' +
'▸ Parsed <strong>' + esc(data.pie_name || 'pie') + '</strong> · ' +
'<span class="result__tag">stored locally</span>' +
'</div>' +
'<div class="result__grid">' +
'<div><div class="k">Positions</div><div class="v">' + data.positions.length + '</div></div>' +
'<div><div class="k">Invested</div><div class="v">' + fmt(data.totals && data.totals.invested) + '</div></div>' +
'<div><div class="k">Value</div><div class="v">' + fmt(data.totals && data.totals.value) + '</div></div>' +
'<div><div class="k">Result</div><div class="v ' +
((data.totals && data.totals.result >= 0) ? 'pos' : 'neg') + '">' +
signed(data.totals && data.totals.result) + '</div></div>' +
'</div>' +
warnings +
'<div class="result__row" style="margin-top:14px;">' +
'<a href="/">Open dashboard →</a>' +
'</div>';
statusEl.hidden = false;
return true;
} catch (err) {
statusEl.className = 'result result--err';
statusEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(err.message) + '</div>';
statusEl.hidden = false;
return false;
const r = await fetch('/api/portfolio/parse', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const err = new Error(data.detail || ('HTTP ' + r.status));
err.status = r.status;
throw err;
}
return data;
}
// Public surface — usable from inline scripts on upload.html.
// Formatting helpers exposed so inline UI scripts (like the import
// preview in settings.html) don't have to re-implement them.
window.CassandraPortfolio = {
mountAndRender,
handleUpload,
parseCsv,
loadPie,
savePie,
clearPie,
fmt,
signed,
pct,
cls,
esc,
};
// Auto-mount on dashboard load and refresh every minute.

View file

@ -106,7 +106,7 @@
// listen to. Simpler still: fire htmx.trigger on the well-known
// panels. We use the simple path.
['#dash-header-container', '#log-panel .panel-body',
'#indicators-body'].forEach(function (sel) {
'#indicators-body', '#log-content'].forEach(function (sel) {
var el = document.querySelector(sel);
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
});
@ -137,13 +137,11 @@
<body>
<div class="app">
<header class="app-header">
<div class="brand">{{ BRAND_NAME }}</div>
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
</nav>
<div class="header-right">
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
@ -158,15 +156,45 @@
<span class="theme-toggle__label"></span>
</button>
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
{% if cu and cu.user %}
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
{% elif cu and cu.is_admin %}
<span class="user-chip">admin · <a href="/logout">logout</a></span>
{% if cu and (cu.user or cu.is_admin) %}
<div class="user-menu">
<button type="button" id="user-menu-toggle" class="user-chip"
aria-haspopup="true" aria-expanded="false">
{% if cu.user %}{{ cu.user.email }}{% else %}admin{% endif %}
<span class="user-menu__caret"></span>
</button>
<div id="user-menu" class="user-menu__panel" role="menu" hidden>
{% if cu.user %}
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
{% endif %}
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a>
</div>
</div>
{% endif %}
<span class="meta">v0.1 · UTC</span>
</div>
</header>
<script>
(function () {
var btn = document.getElementById('user-menu-toggle');
var menu = document.getElementById('user-menu');
if (!btn || !menu) return;
function close() { menu.hidden = true; btn.setAttribute('aria-expanded','false'); }
function open() { menu.hidden = false; btn.setAttribute('aria-expanded','true'); }
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) open(); else close();
});
document.addEventListener('click', function (e) {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
})();
</script>
<main class="app-main">
{% block main %}{% endblock %}
</main>

View file

@ -55,6 +55,7 @@
</div>
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<section id="log-panel" class="panel">

View file

@ -25,7 +25,7 @@
<article id="log-content"
class="log-page__content"
hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
hx-trigger="load"
hx-trigger="load, tone-changed"
hx-swap="innerHTML">
<div class="empty">loading log…</div>
</article>

View file

@ -30,7 +30,12 @@
<tbody>
{% for q in quotes %}
{% set is_stale = stale_symbols and q.symbol in stale_symbols %}
<tr class="{% if is_stale %}row-stale{% endif %}">
{# Stale rows (last observation older than the group's freshness
threshold) are hidden from the default UI but the server still
computes them — remove the `{% if not is_stale %}` guard to
resurface the dimmed row + stale tag. #}
{% if not is_stale %}
<tr>
{% set tip = notes.get(q.symbol, '') if notes else '' %}
{# Long Eurostat ('dataset?...') and ONS ('topic/.../cdid/dataset') symbols
get truncated for display; hover shows the full identifier via title.
@ -39,7 +44,7 @@
{% if '?' in short_sym %}{% set short_sym = short_sym.split('?')[0] %}{% endif %}
{% if '/' in short_sym %}{% set short_sym = short_sym.split('/')[-2] | upper %}{% endif %}
<td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}">
{{ short_sym }}{% if is_stale %} <span class="stale-tag" title="last observation older than 90 days">stale</span>{% endif %}
{{ short_sym }}
</td>
<td {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
<td class="num">{{ q.price | price }}</td>
@ -58,6 +63,7 @@
{% endif %}
<td class="neu">{{ q.as_of or "" }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -39,6 +39,28 @@
</div>
</div>
{# --- Import portfolio --------------------------------------------- #}
<div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
<p class="settings-section__lede">
Export your pie from T212
(<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>)
and drop the CSV here. We&rsquo;ll parse it and show a preview before
importing anywhere.
</p>
<div id="drop-zone" class="dz">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop a T212 pie CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> &middot; max 1 MB</div>
<div class="dz__filename" id="dz-filename"></div>
</div>
<div id="import-preview" hidden style="margin-top:14px;"></div>
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
</div>
{# --- Referral block ---------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Invite a friend</div>
@ -74,6 +96,31 @@
</div>
</div>
{# --- Cloud sync block --------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div>
<p class="settings-section__lede">
Manage the encrypted server-side copy of your portfolio. Sync is
opted-in per import (see the Import section above).
</p>
{% if paid and paid.active %}
<div id="sync-status" class="settings-row">
<div class="settings-row__label">Status</div>
<div class="settings-row__value">
<span class="settings-row__hint">checking&hellip;</span>
</div>
</div>
<div id="sync-actions" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;"></div>
<div id="sync-feedback" class="settings-row__hint" style="margin-top:10px;"></div>
{% else %}
<p class="settings-row__hint">
Available on the paid tier. Upgrade or apply an invite credit
above to enable cloud sync.
</p>
{% endif %}
</div>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
{% endif %}
@ -81,6 +128,187 @@
</div>
</section>
{% if user and paid and paid.active %}
<div id="sync-modal" class="modal"
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
display:none;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--panel-bg,#fff);color:var(--text,#000);
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
Enable cloud sync
</div>
<div id="sync-modal-body" style="font-size:13px;line-height:1.55;
color:var(--muted,#666);margin-bottom:14px;">
Choose a PIN (4&ndash;12 characters). The same PIN unlocks the
portfolio on any device. There is no recovery if you forget it.
</div>
<form id="sync-modal-form" autocomplete="off">
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
<input id="sync-pin1" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
<input id="sync-pin2" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
<label style="display:flex;align-items:flex-start;gap:8px;
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
<input id="sync-ack" type="checkbox" required>
I understand that losing this PIN means I'll have to re-import my CSV.
</label>
<div id="sync-modal-err" class="pf-warn" hidden style="margin-bottom:10px;"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" id="sync-modal-cancel" class="pf-secondary">Cancel</button>
<button type="submit" id="sync-modal-submit">Enable</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script>
(function () {
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
document.addEventListener('DOMContentLoaded', function () {
if (!window.CassandraSync) return;
const statusEl = $('sync-status');
const actionsEl = $('sync-actions');
const feedbackEl = $('sync-feedback');
const modal = $('sync-modal');
const pin1 = $('sync-pin1');
const pin2 = $('sync-pin2');
const ack = $('sync-ack');
const errEl = $('sync-modal-err');
function setFeedback(msg, ok) {
feedbackEl.style.color = ok ? 'var(--ok,#2a9d57)' : '';
feedbackEl.textContent = msg || '';
}
// External callers (the Import section above) can pass a callback
// that fires after a successful enable-and-push.
let pendingOnSuccess = null;
function openModal(opts) {
pendingOnSuccess = (opts && opts.onSuccess) || null;
modal.style.display = 'flex';
// Focus PIN field after the layout flush so the caret lands.
setTimeout(() => pin1.focus(), 0);
}
function closeModal() {
modal.style.display = 'none';
pin1.value = ''; pin2.value = '';
ack.checked = false; errEl.hidden = true;
pendingOnSuccess = null;
}
$('sync-modal-cancel').addEventListener('click', closeModal);
// Backdrop click + Esc key dismiss the modal.
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
});
$('sync-modal-form').addEventListener('submit', async function (e) {
e.preventDefault();
errEl.hidden = true;
if (pin1.value !== pin2.value) {
errEl.textContent = 'PINs do not match.';
errEl.hidden = false; return;
}
if (pin1.value.length < 4) {
errEl.textContent = 'PIN must be at least 4 characters.';
errEl.hidden = false; return;
}
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
if (!pie) {
errEl.textContent =
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
errEl.hidden = false; return;
}
try {
await window.CassandraSync.pushSync(pie, pin1.value);
const cb = pendingOnSuccess;
closeModal(); // clears pendingOnSuccess
await refresh();
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
if (typeof cb === 'function') {
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
}
} catch (e2) {
errEl.textContent = e2.message || 'Failed to enable sync.';
errEl.hidden = false;
}
});
async function refresh() {
let status;
try { status = await window.CassandraSync.getStatus(); }
catch (e) {
statusEl.querySelector('.settings-row__value').innerHTML =
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
return;
}
const valueEl = statusEl.querySelector('.settings-row__value');
actionsEl.innerHTML = '';
if (status.exists) {
const when = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
valueEl.innerHTML =
'<span class="badge badge--ok">On</span> ' +
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
const disable = document.createElement('button');
disable.type = 'button';
disable.className = 'pf-secondary';
disable.textContent = 'Disable sync';
disable.addEventListener('click', async function () {
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
try {
await window.CassandraSync.disableSync();
await refresh();
setFeedback('Cloud sync disabled. Server copy removed.', true);
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
});
actionsEl.appendChild(disable);
} else {
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
// Only offer 'Enable' when there's actually a pie to encrypt;
// otherwise the user would hit a dead-end at the modal.
const hasPie = !!localStorage.getItem('cassandra.pie');
if (!hasPie) {
const hint = document.createElement('span');
hint.className = 'settings-row__hint';
hint.innerHTML =
'Nothing to sync yet &mdash; ' +
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
actionsEl.appendChild(hint);
return;
}
const enable = document.createElement('button');
enable.type = 'button';
enable.textContent = 'Enable cloud sync';
enable.addEventListener('click', openModal);
actionsEl.appendChild(enable);
}
}
// Hooks for the Import section to drive this modal + status row.
window.cassandraOpenSyncModal = openModal;
window.cassandraRefreshSyncStatus = refresh;
refresh();
});
})();
</script>
{% endif %}
<script>
(function () {
var btn = document.getElementById('invite-copy');
@ -99,4 +327,252 @@
});
})();
</script>
{% if user %}
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script>
(function () {
// Server-side hint: did the user have paid privileges when the page
// rendered? Used to decide whether to offer the 'Import & sync' button.
// We still call CassandraSync.getStatus() at click time as the source
// of truth, but this lets us skip rendering a button we know is dead.
var IS_PAID = {{ 'true' if paid and paid.active else 'false' }};
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
ready(function () {
var P = window.CassandraPortfolio;
if (!P) return;
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var previewEl = document.getElementById('import-preview');
var resultEl = document.getElementById('import-result');
if (!dropZone) return;
var currentPie = null; // most recently parsed pie, awaiting commit
function showError(msg) {
previewEl.hidden = true;
resultEl.className = 'result result--err';
resultEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(msg) + '</div>';
resultEl.hidden = false;
}
function showSuccess(headline, sub) {
previewEl.hidden = true;
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">' + esc(headline) + '</div>' +
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
'<div class="result__row" style="margin-top:14px;">' +
'<a href="/">Open dashboard →</a>' +
'</div>';
resultEl.hidden = false;
}
function renderPreview(pie) {
currentPie = pie;
resultEl.hidden = true;
var t = pie.totals || {};
var rows = (pie.positions || []).map(function (p) {
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + fmt(invested) + '</td>' +
'</tr>';
}).join('');
var warnings = (pie.warnings || []).map(function (w) {
return '<div class="result__warn">' + esc(w) + '</div>';
}).join('');
var syncBtn = IS_PAID
? ('<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>')
: ('<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>');
previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' +
'<div class="result__head">' +
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
'</div>' +
'<div class="result__grid">' +
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
'</div>' +
warnings +
(rows
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num">Qty</th>' +
'<th class="num">Avg</th>' +
'<th class="num">Invested</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>'
: ''
) +
'<div class="import-actions">' +
'<div class="import-choice">' +
'<button type="button" id="commit-local">Import to this browser</button>' +
'<div class="settings-row__hint">' +
'Saved to this browser only. No server-side copy of your holdings.' +
'</div>' +
'</div>' +
syncBtn +
'<div style="flex-basis:100%;">' +
'<button type="button" id="commit-cancel" class="pf-secondary">' +
'Cancel</button>' +
'</div>' +
'</div>' +
'</div>';
previewEl.hidden = false;
document.getElementById('commit-local').addEventListener('click', commitLocal);
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
var syncEl = document.getElementById('commit-sync');
if (syncEl) syncEl.addEventListener('click', commitSync);
}
function commitLocal() {
if (!currentPie) return;
P.savePie(currentPie);
showSuccess('▸ Imported to this browser.',
'Pie kept locally; no server-side copy.');
currentPie = null;
}
async function commitSync() {
if (!currentPie) return;
// Save locally first so the cloud-sync flow uses the freshly-imported
// pie (the enable-PIN modal in this same page reads from localStorage).
P.savePie(currentPie);
var S = window.CassandraSync;
if (!S) { showError('Cloud sync module not loaded.'); return; }
var status;
try { status = await S.getStatus(); }
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
if (!status.paid) {
showError('Cloud sync requires the paid tier.');
return;
}
if (status.exists) {
// Already enabled — try a direct push using the cached session
// key. If no key is cached (fresh browser session), this throws,
// and we fall back to the enable-PIN modal so the user can
// re-enter their PIN.
try {
await S.pushSync(currentPie, null);
showSuccess('▸ Imported and synced.',
'Encrypted copy updated on the server.');
currentPie = null;
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
return;
} catch (e) {
// Fall through to modal so the user can re-auth with their PIN.
console.warn('direct push failed, falling back to PIN modal', e);
}
}
// !status.exists OR cached-key push failed → use the modal.
if (window.cassandraOpenSyncModal) {
window.cassandraOpenSyncModal({
onSuccess: function () {
showSuccess('▸ Imported and synced.',
'Cloud sync is now enabled and the pie is stored encrypted.');
currentPie = null;
},
});
} else {
showError('Cloud sync UI unavailable on this page. ' +
'Use the Cloud sync section below to enable.');
}
}
function resetUploader() {
currentPie = null;
previewEl.hidden = true;
previewEl.innerHTML = '';
resultEl.hidden = true;
filenameEl.textContent = '';
fileInput.value = '';
}
async function parseFile(file) {
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
previewEl.hidden = true;
resultEl.hidden = true;
try {
var pie = await P.parseCsv(file);
renderPreview(pie);
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
} catch (e) {
filenameEl.textContent = file.name + ' (failed)';
showError(e.message || 'Unknown error');
}
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) parseFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
var f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) parseFile(f);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
});
})();
</script>
{% endif %}
{% endblock %}

View file

@ -5,7 +5,7 @@
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Import portfolio (Trading 212 CSV)</span>
<span class="meta">stays in your browser · never persists server-side</span>
<span class="meta">held locally · optional encrypted cloud sync (paid)</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
@ -13,9 +13,11 @@
Export your pie from the T212 web app
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
and drop the CSV here. Each Slice is resolved to its Yahoo ticker;
the parsed pie is kept in <em>this browser's localStorage</em> only.
The server learns just which tickers exist (anonymously) so it can
fetch their prices.
the parsed pie is kept in <em>this browser's localStorage</em>.
The server learns only which tickers exist (anonymously) so it can
fetch their prices. If you have <a href="/settings">cloud sync</a>
enabled, an <strong>encrypted</strong> copy is also pushed to the
server &mdash; only your PIN can decrypt it.
</p>
<form id="upload-form" autocomplete="off">
@ -34,6 +36,7 @@
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script>
(function () {

View file

@ -5,5 +5,13 @@
services:
app:
# Dev: mount the source over the image's copy so edits on the host
# land in the container without a rebuild; `--reload` restarts uvicorn
# when a file changes. Prod bakes the code into the image (Dockerfile)
# and uses the plain command from docker-compose.yml.
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000",
"--workers", "1", "--reload", "--reload-dir", "/app/app"]
volumes:
- ./app:/app/app
ports:
- "${CASSANDRA_PORT:-8000}:8000"

View file

@ -18,6 +18,7 @@ dependencies = [
"tenacity>=9.0",
"structlog>=24.4",
"argon2-cffi>=23.1",
"cryptography>=43.0",
"itsdangerous>=2.2",
"email-validator>=2.2",
"aiosmtplib>=3.0",

View file

@ -76,6 +76,20 @@ def test_no_position_rows_raises():
parse_t212_csv(csv)
def test_unfunded_pie_raises_specific_message():
"""A pie exported before it holds any shares has every slice at
quantity 0. That must not look like a format error the message
has to say the pie is empty so the user re-exports a funded one."""
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"SGLN","iShares Physical Gold",0,0,0,"0"\n'
'"SHEL","Shell",0,0,0,"0"\n'
'"Total","Empty Pie",0,0,0,"-"\n'
)
with pytest.raises(CSVImportError, match="all 2 slice"):
parse_t212_csv(csv)
def test_reordered_columns():
"""T212 sometimes re-orders columns between exports. Header-name matching
has to make that a non-issue."""

View file

@ -0,0 +1,139 @@
"""Integration tests for the /api/portfolio/sync endpoints.
Spins up a minimal FastAPI app over an in-memory aiosqlite DB, seeds two
users (one paid, one free), signs session cookies, and exercises the
contract: paid POST/GET/DELETE round-trip, free 402, oversized 413,
rate-limit 429.
Skipped on hosts without aiosqlite + FastAPI test client, mirroring the
guard pattern in test_universe_unlinkability.py.
"""
from __future__ import annotations
import asyncio
import base64
import os
from datetime import datetime, timezone
import pytest
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import User
from app.routers import sync as sync_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/sync.db")
session_factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = session_factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as s:
now = datetime.now(timezone.utc)
s.add_all([
User(id=1, email="paid@example.com", tier="paid",
settings_json={}, created_at=now),
User(id=2, email="free@example.com", tier="free",
settings_json={}, created_at=now),
])
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(sync_router.router)
return TestClient(app), sign_session(1), sign_session(2)
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
pytestmark = pytest.mark.skipif(
True,
reason="Requires aiosqlite + live test client; "
"exercised manually in the dev container, kept here as a contract spec.",
)
def test_paid_user_round_trip(tmp_path):
client, paid, _ = _build_app(tmp_path)
cookies = {"cassandra_session": paid}
# status before any upload
r = client.get("/api/portfolio/sync/status", cookies=cookies)
assert r.status_code == 200
assert r.json() == {"exists": False, "updated_at": None}
# upload
blob = os.urandom(512)
r = client.post(
"/api/portfolio/sync", json={"blob": _b64(blob)}, cookies=cookies,
)
assert r.status_code == 200, r.text
# status after
r = client.get("/api/portfolio/sync/status", cookies=cookies)
body = r.json()
assert body["exists"] is True
assert body["updated_at"] is not None
# download — should round-trip the exact bytes
r = client.get("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 200
got = base64.urlsafe_b64decode(r.json()["blob"] + "==")
assert got == blob
# delete
r = client.delete("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 200
r = client.get("/api/portfolio/sync/status", cookies=cookies)
assert r.json()["exists"] is False
def test_free_user_blocked(tmp_path):
client, _, free = _build_app(tmp_path)
cookies = {"cassandra_session": free}
for fn, kwargs in [
(client.get, {"url": "/api/portfolio/sync/status"}),
(client.post, {"url": "/api/portfolio/sync", "json": {"blob": "AA"}}),
(client.get, {"url": "/api/portfolio/sync"}),
(client.delete, {"url": "/api/portfolio/sync"}),
]:
r = fn(cookies=cookies, **kwargs)
assert r.status_code == 402, (fn.__name__, kwargs, r.status_code)
def test_oversized_blob_rejected(tmp_path):
client, paid, _ = _build_app(tmp_path)
cookies = {"cassandra_session": paid}
too_big = os.urandom(257 * 1024)
r = client.post(
"/api/portfolio/sync", json={"blob": _b64(too_big)}, cookies=cookies,
)
assert r.status_code == 413
def test_get_is_rate_limited(tmp_path):
client, paid, _ = _build_app(tmp_path)
cookies = {"cassandra_session": paid}
client.post(
"/api/portfolio/sync",
json={"blob": _b64(os.urandom(64))}, cookies=cookies,
)
# 6 hits should pass; the 7th must trip 429.
for i in range(6):
r = client.get("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 200, f"GET #{i+1} failed: {r.text}"
r = client.get("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 429

View file

@ -0,0 +1,76 @@
"""Crypto-layer tests for app.services.portfolio_sync.
These exercise only the pure wrap/unwrap helpers no DB, no network.
The DB-touching helpers (`upsert`, `fetch`, `consume_fetch_budget`) need
sqlite+aiosqlite and live in the API integration tests so we don't
duplicate the test-app scaffolding.
"""
from __future__ import annotations
import os
import pytest
from app.services import portfolio_sync as svc
def test_wrap_unwrap_round_trip():
inner = b"the inner ciphertext doesn't matter to the outer wrap"
ct, nonce = svc.wrap(user_id=42, inner_blob=inner)
assert ct != inner # we actually encrypted
out = svc.unwrap(42, ct, nonce)
assert out == inner
def test_wrap_is_nondeterministic():
"""Same plaintext, same user, two writes — outer ciphertexts must
differ because the nonce is random. A deterministic outer wrap would
leak 'this user re-uploaded the same pie' across snapshots."""
inner = b"identical inner blob across writes"
ct1, nonce1 = svc.wrap(7, inner)
ct2, nonce2 = svc.wrap(7, inner)
assert ct1 != ct2
assert nonce1 != nonce2
def test_unwrap_rejects_cross_user_blob():
"""A blob wrapped for user A must not decrypt under user B's key —
HKDF binds the user_id into the salt."""
inner = b"alice's pie"
ct, nonce = svc.wrap(1, inner)
with pytest.raises(svc.SyncCryptoError):
svc.unwrap(2, ct, nonce)
def test_unwrap_rejects_tampered_ciphertext():
inner = b"don't flip my bits"
ct, nonce = svc.wrap(99, inner)
bad = bytearray(ct)
bad[0] ^= 0x01
with pytest.raises(svc.SyncCryptoError):
svc.unwrap(99, bytes(bad), nonce)
def test_unwrap_rejects_wrong_nonce():
inner = b"nonce is part of the auth envelope"
ct, _nonce = svc.wrap(5, inner)
with pytest.raises(svc.SyncCryptoError):
svc.unwrap(5, ct, os.urandom(12))
def test_server_key_changes_with_pepper(monkeypatch):
"""Rotating the pepper must produce a different key for the same
user that's how we'd invalidate all sync rows in a credential
breach."""
from app.config import get_settings
get_settings.cache_clear()
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "first-pepper-value")
k1 = svc._server_key(11)
get_settings.cache_clear()
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "second-pepper-value")
k2 = svc._server_key(11)
assert k1 != k2
get_settings.cache_clear() # don't poison other tests