sync: encrypted cloud backup for portfolios + settings UX rework

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

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

Settings + import rework:

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

Collateral fixes:

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

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

View file

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