diff --git a/alembic/env.py b/alembic/env.py index a652b05..9918b4f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -21,11 +21,7 @@ config = context.config config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL) if config.config_file_name is not None: - # 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) + fileConfig(config.config_file_name) target_metadata = Base.metadata diff --git a/alembic/versions/0015_portfolio_sync.py b/alembic/versions/0015_portfolio_sync.py deleted file mode 100644 index f6e9521..0000000 --- a/alembic/versions/0015_portfolio_sync.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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") diff --git a/app/config.py b/app/config.py index 70c0f1c..a035f60 100644 --- a/app/config.py +++ b/app/config.py @@ -63,13 +63,6 @@ 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 diff --git a/app/db.py b/app/db.py index a3affe1..646959d 100644 --- a/app/db.py +++ b/app/db.py @@ -31,27 +31,12 @@ def get_engine(): global _engine if _engine is None: s = get_settings() - # 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) + _engine = create_async_engine( + s.DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, + future=True, + ) return _engine diff --git a/app/main.py b/app/main.py index 20ed7f0..241aa7e 100644 --- a/app/main.py +++ b/app/main.py @@ -20,7 +20,6 @@ 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 @@ -42,12 +41,6 @@ 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. @@ -84,5 +77,4 @@ 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"]) diff --git a/app/models.py b/app/models.py index bdc884a..efa5a03 100644 --- a/app/models.py +++ b/app/models.py @@ -17,8 +17,6 @@ from sqlalchemy import ( ForeignKey, Index, Integer, - LargeBinary, - SmallInteger, String, Text, UniqueConstraint, @@ -181,31 +179,6 @@ 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=`. The conversion diff --git a/app/routers/pages.py b/app/routers/pages.py index 46f58e0..1214586 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -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, RedirectResponse +from fastapi.responses import HTMLResponse 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") +@router.get("/upload", response_class=HTMLResponse) async def upload_page(request: Request): - """Legacy bookmark — the import widget now lives in /settings.""" - return RedirectResponse(url="/settings#import", status_code=302) + """Drag-drop CSV import. Posts to /api/portfolios/upload.""" + return templates.TemplateResponse(request, "upload.html", {}) async def _resolve_log_date(session: AsyncSession, day: str | None) -> date: diff --git a/app/routers/sync.py b/app/routers/sync.py deleted file mode 100644 index e6496e0..0000000 --- a/app/routers/sync.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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} diff --git a/app/services/csv_import.py b/app/services/csv_import.py index 770ff1e..97f4bde 100644 --- a/app/services/csv_import.py +++ b/app/services/csv_import.py @@ -130,7 +130,6 @@ 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): @@ -168,8 +167,6 @@ 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( @@ -185,16 +182,6 @@ 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." diff --git a/app/services/openrouter.py b/app/services/openrouter.py index f1402f4..b3bc2c7 100644 --- a/app/services/openrouter.py +++ b/app/services/openrouter.py @@ -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 = 8 +PROMPT_VERSION = 7 # --- Core: invariant across tone/analysis settings ---------------------------- @@ -85,25 +85,16 @@ 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 (MANDATORY in every paragraph) +# Rational vs irrational framing The reader's primary goal is to disconnect rational decisions from market \ -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. +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. # Discipline - No emojis, no marketing language, no "concerning" or "unprecedented" \ @@ -311,13 +302,6 @@ 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", \ @@ -366,13 +350,6 @@ 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", \ diff --git a/app/services/portfolio_analysis.py b/app/services/portfolio_analysis.py index eb8a349..9d2ec7f 100644 --- a/app/services/portfolio_analysis.py +++ b/app/services/portfolio_analysis.py @@ -250,19 +250,6 @@ 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. """ diff --git a/app/services/portfolio_sync.py b/app/services/portfolio_sync.py deleted file mode 100644 index 15c9c41..0000000 --- a/app/services/portfolio_sync.py +++ /dev/null @@ -1,178 +0,0 @@ -"""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 diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 5f663e6..91e7b7c 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -55,31 +55,20 @@ 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. - - 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'. - """ + one yet. Retries on the (very rare) collision.""" 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: - db_user.referral_code = code + user.referral_code = code await session.commit() - log.info("referral.code_assigned", user_id=db_user.id, code=code) - return db_user + await session.refresh(user) + log.info("referral.code_assigned", user_id=user.id, code=code) + return 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") diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 9bb7ffc..391be28 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -82,9 +82,7 @@ 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; @@ -140,9 +138,8 @@ a:hover { text-decoration: underline; } padding: 14px; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - grid-template-rows: auto auto auto auto; + grid-template-rows: auto auto auto; grid-template-areas: - "header header" "indicators log" "portfolio log" "news news"; @@ -151,11 +148,10 @@ a:hover { text-decoration: underline; } @media (max-width: 1100px) { .app-main { grid-template-columns: 1fr; - grid-template-areas: "header" "indicators" "portfolio" "log" "news"; + grid-template-areas: "indicators" "portfolio" "log" "news"; } } -#dash-header-container { grid-area: header; } #indicators-panel { grid-area: indicators; } #portfolio-panel { grid-area: portfolio; } #log-panel { @@ -1038,55 +1034,19 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } border-color: var(--accent) !important; } -/* 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 in header */ .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: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 { + color: var(--muted); + border-bottom: 1px dotted var(--muted); } -.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); } +.user-chip a:hover { color: var(--accent); border-color: var(--accent); } /* --- Upload page (drag-drop CSV) ------------------------------------- */ diff --git a/app/static/js/portfolio-sync.js b/app/static/js/portfolio-sync.js deleted file mode 100644 index 6772dd9..0000000 --- a/app/static/js/portfolio-sync.js +++ /dev/null @@ -1,280 +0,0 @@ -/* 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, - }; -})(); diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index d40dcde..a6c8bbe 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -168,58 +168,10 @@ mount.innerHTML = '
' + 'No portfolio loaded in this browser. ' + - 'Import a T212 CSV →' + + 'Import a T212 CSV →' + '
'; } - function renderRestoreFromCloud(mount, status) { - const lastSynced = status.updated_at - ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' - : '—'; - mount.innerHTML = - '
' + - '
▸ Restore from cloud
' + - '
' + - 'A synced portfolio is available for this account (last synced ' + - esc(lastSynced) + '). Enter your PIN to load it on this browser.' + - '
' + - '
' + - '' + - '' + - '' + - 'or import a new CSV →' + - '
' + - '' + - '
'; - - 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]) @@ -379,15 +331,7 @@ }); const data = await r.json(); if (!r.ok) { - // 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 = '
' + esc(msg) + '
'; + out.innerHTML = '
' + esc(data.detail || ('HTTP ' + r.status)) + '
'; return; } // Persist before rendering so auto-refresh can re-hydrate. @@ -407,20 +351,7 @@ if (!mount) return; const pie = loadPie(); if (!pie || !pie.positions || !pie.positions.length) { - // 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); - } + renderEmpty(mount); return; } try { @@ -438,42 +369,72 @@ renderPanel(mount, pie, enriched, agg); } - // --- 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) { + // --- Upload page helper ------------------------------------------------ + + async function handleUpload(form, file, statusEl) { + statusEl.className = 'result'; + statusEl.hidden = true; + const fd = new FormData(); fd.append('file', file); - 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; + + 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 = + '
✕ Import failed
' + + '
' + esc(data.detail || ('HTTP ' + r.status)) + '
'; + statusEl.hidden = false; + return false; + } + savePie(data); + + const warnings = (data.warnings || []).map(w => + '
' + esc(w) + '
').join(''); + + statusEl.className = 'result result--ok'; + statusEl.innerHTML = + '
' + + '▸ Parsed ' + esc(data.pie_name || 'pie') + ' · ' + + 'stored locally' + + '
' + + '
' + + '
Positions
' + data.positions.length + '
' + + '
Invested
' + fmt(data.totals && data.totals.invested) + '
' + + '
Value
' + fmt(data.totals && data.totals.value) + '
' + + '
Result
' + + signed(data.totals && data.totals.result) + '
' + + '
' + + warnings + + '
' + + 'Open dashboard →' + + '
'; + statusEl.hidden = false; + return true; + } catch (err) { + statusEl.className = 'result result--err'; + statusEl.innerHTML = + '
✕ Import failed
' + + '
' + esc(err.message) + '
'; + statusEl.hidden = false; + return false; } - return data; } - // Formatting helpers exposed so inline UI scripts (like the import - // preview in settings.html) don't have to re-implement them. + // Public surface — usable from inline scripts on upload.html. window.CassandraPortfolio = { mountAndRender, - parseCsv, + handleUpload, loadPie, savePie, clearPie, - fmt, - signed, - pct, - cls, - esc, }; // Auto-mount on dashboard load and refresh every minute. diff --git a/app/templates/base.html b/app/templates/base.html index 9f08c7e..4a2f402 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -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', '#log-content'].forEach(function (sel) { + '#indicators-body'].forEach(function (sel) { var el = document.querySelector(sel); if (el && window.htmx) window.htmx.trigger(el, 'tone-changed'); }); @@ -137,11 +137,13 @@
- {{ BRAND_NAME }} +
{{ BRAND_NAME }}
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %} - {% if cu and (cu.user or cu.is_admin) %} -
- - -
+ {% if cu and cu.user %} + {{ cu.user.email }} · logout + {% elif cu and cu.is_admin %} + admin · logout {% endif %} v0.1 · UTC
- -
{% block main %}{% endblock %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 8e778d1..6c1efcc 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -55,7 +55,6 @@
-
diff --git a/app/templates/log.html b/app/templates/log.html index 3e56727..34c37f9 100644 --- a/app/templates/log.html +++ b/app/templates/log.html @@ -25,7 +25,7 @@
loading log…
diff --git a/app/templates/partials/indicators.html b/app/templates/partials/indicators.html index 0ae1e1f..3639e8d 100644 --- a/app/templates/partials/indicators.html +++ b/app/templates/partials/indicators.html @@ -30,12 +30,7 @@ {% for q in quotes %} {% set is_stale = stale_symbols and q.symbol in stale_symbols %} - {# 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 %} - + {% 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. @@ -44,7 +39,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 %} - {{ short_sym }} + {{ short_sym }}{% if is_stale %} stale{% endif %} {{ q.label or "" }} {{ q.price | price }} @@ -63,7 +58,6 @@ {% endif %} {{ q.as_of or "" }} - {% endif %} {% endfor %} diff --git a/app/templates/settings.html b/app/templates/settings.html index ecdd25a..2679c59 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -39,28 +39,6 @@ - {# --- Import portfolio --------------------------------------------- #} -
-
Import portfolio (Trading 212 CSV)
-

- Export your pie from T212 - (Investing → Your Pie → ··· → Export) - and drop the CSV here. We’ll parse it and show a preview before - importing anywhere. -

- -
- -
-
Drop a T212 pie CSV here
-
or browse · max 1 MB
-
-
- - - -
- {# --- Referral block ---------------------------------------------- #}
Invite a friend
@@ -96,31 +74,6 @@
- {# --- Cloud sync block --------------------------------------------- #} -
-
Cloud sync (encrypted)
-

- Manage the encrypted server-side copy of your portfolio. Sync is - opted-in per import (see the Import section above). -

- - {% if paid and paid.active %} -
-
Status
-
- checking… -
-
-
-
- {% else %} -

- Available on the paid tier. Upgrade or apply an invite credit - above to enable cloud sync. -

- {% endif %} -
- {# Future: Paddle subscription block, AI-spend ledger summary, etc. #} {% endif %} @@ -128,187 +81,6 @@
-{% if user and paid and paid.active %} - - - - -{% endif %} - - -{% if user %} -{# Import widget wiring — auto-parse on drop, preview, then commit. #} - - -{% endif %} {% endblock %} diff --git a/app/templates/upload.html b/app/templates/upload.html index 0c397e8..4233d3e 100644 --- a/app/templates/upload.html +++ b/app/templates/upload.html @@ -5,7 +5,7 @@
Import portfolio (Trading 212 CSV) - held locally · optional encrypted cloud sync (paid) + stays in your browser · never persists server-side
@@ -13,11 +13,9 @@ Export your pie from the T212 web app (Trading 212 → Investing → Your Pie → ⋯ → Export) and drop the CSV here. Each Slice is resolved to its Yahoo ticker; - the parsed pie is kept in this browser's localStorage. - The server learns only which tickers exist (anonymously) so it can - fetch their prices. If you have cloud sync - enabled, an encrypted copy is also pushed to the - server — only your PIN can decrypt it. + the parsed pie is kept in this browser's localStorage only. + The server learns just which tickers exist (anonymously) so it can + fetch their prices.

@@ -36,7 +34,6 @@
-