# ADR 0009 — Price-fetching premium via proxy maximus-api - Status: Accepted (architecture documentée — implémentation reportée à l'Issue #143, BLOCKED par maximus-api Phase 2) - Date: 2026-04-25 - Milestone: `overnight-2026-04-26-bilan` (architecture spec) ## Context La feature Bilan supporte des comptes "priced" (actions, crypto) où chaque ligne de snapshot stocke `(quantity, unit_price, value)`. La saisie manuelle de `unit_price` reste toujours possible mais devient pénible dès qu'on a plusieurs titres ou qu'on rétro-saisit un historique. L'objectif est de proposer un bouton "récupérer le prix au [date]" qui interroge un fournisseur de données (Yahoo Finance, CoinGecko, etc.) sans **trahir le principe privacy-first NON NÉGOCIABLE** du projet : > Zéro donnée envoyée vers un serveur tiers. Tout le traitement CSV et toutes les données financières restent en local. Aucune télémétrie, aucun analytics cloud. Or interroger Yahoo ou CoinGecko, c'est par définition envoyer une requête sortante depuis l'IP de l'utilisateur. Quelles informations fuiteraient ? - **L'IP de l'utilisateur** : géolocalisation grossière, profilage de session - **L'User-Agent par défaut** de `reqwest` : `reqwest/0.12 ...`, identifie le client comme une app Tauri (silhouette technique reconnaissable) - **Le symbole + date** : "AAPL au 2026-03-15" n'est pas identifiant en soi mais corrélé à l'IP, le provider peut reconstruire le portefeuille - **Headers résiduels** : `Accept-Language` peut révéler la locale système Trois architectures candidates : | Option | Privacy | Complexité serveur | Coût d'API | |--------|---------|--------------------|------------| | Appel direct client → provider | ❌ IP exposée, fingerprint headers | aucune | par user (rate limits triggered fast) | | Appel direct + Tor / VPN intégré | ⚠ partiel, latence dégradée | aucune | par user | | **Proxy via maximus-api auto-hébergé** | ✅ IP cachée, headers strippés, cache mutualisé | Endpoint `/v1/prices` à maintenir | mutualisé (cache mutualisé entre users premium) | ## Decision **Implémenter le price-fetching comme fonctionnalité premium-only servie par `maximus-api` agissant comme proxy**, avec consentement explicite et hygiène de headers stricte des deux côtés du fil. ### Architecture ``` [App Tauri] │ GET /v1/prices?symbol=AAPL&date=2026-03-15 │ Headers: Authorization: Bearer │ Accept: application/json │ User-Agent: simpl-resultat ▼ [maximus-api] ← VPS Max (Coolify) │ 1. Strip TOUS headers entrants identifiants │ 2. Validation tier premium (403 si non-premium) │ 3. Cache SQLite (symbol, date) → price (TTL infini sur dates passées) │ 4. Cache miss → adapter (Yahoo / CoinGecko) ▼ [Provider tiers] ← voit l'IP du VPS, pas du client ``` ### Choix de providers : abstraction adapter Côté maximus-api, un module `price-fetcher` expose une interface unique et délègue à des adapters : | Provider | Stocks | Crypto | Coût | Adapter | |----------|--------|--------|------|---------| | **Yahoo Finance** (unofficial) | ✅ | ⚠ | gratuit | `YahooAdapter` (HTTP direct) | | **CoinGecko** | ❌ | ✅ excellent | gratuit (free tier 30 req/min) | `CoinGeckoAdapter` | | Alpha Vantage (fallback) | ✅ | ⚠ | freemium | optionnel si Yahoo casse | **Stocks → Yahoo** ; **Crypto → CoinGecko**. L'abstraction permet de swap si un provider casse, sans changer le contrat client. ### Stratégie d'authentification - **`Authorization: Bearer ` uniquement.** Le token est lu côté client depuis `activation_path` (le fichier déjà utilisé par `license_commands.rs` pour persister le token d'activation). **Jamais stocké dans `user_preferences`** (la table SQL de l'app n'a pas vocation à versionner les credentials). - **Jamais en query string.** Un token-in-URL leakerait dans : - Les logs Traefik / nginx du VPS (URL complète loguée par défaut) - Le header `Referer` si maximus-api redirige - Les écrans de partage (le header `Authorization` est masqué par les outils de capture, pas l'URL) ### Hygiène des headers — privacy en profondeur **Côté client (Rust / `reqwest`)** : - `reqwest::Client::builder().user_agent("simpl-resultat").build()` — UA fixe, pas le default `reqwest/0.12 ...` - Headers envoyés UNIQUEMENT : `Authorization: Bearer ` + `Accept: application/json` - **Pas** de `Accept-Language` (révèle la locale) - **Pas** d'autres headers identifiants **Côté serveur (maximus-api)** : - Strip TOUS les headers entrants avant de proxyer vers le provider tiers (`X-Forwarded-For`, `User-Agent` client, `Accept-Language`, etc.) - **Ne JAMAIS logger `(symbol, license_id)` ensemble.** Soit séparer les logs (un journal pour la facturation/quota par licence sans symbole, un journal pour les hits cache/provider sans license), soit hasher le `license_id` côté serveur avec un sel rotatif court avant log - Validation premium **AVANT** cache et provider — un client non-premium reçoit 403 sans qu'aucun appel sortant ne soit déclenché ### Rate limiting **Côté client** : - Max 1 fetch / 2 secondes (timer simple) - Dedup in-flight par `(symbol, date)` (deux clics rapides = 1 seule requête réseau) - Backoff exponentiel sur 5xx / network : 2s, 4s, 8s — max 3 retries - Plafond hard : 100 fetches par session snapshot (anti-loop) **Côté serveur** : - Quota par licence (proposition initiale : 1000 req/jour, le cache absorbe l'essentiel) - Le cache `(symbol, date)` est immuable pour les dates passées (TTL infini), 5 min pour `today` (le marché peut bouger) ### Premium gating — défense en profondeur - **UI client** : si `entitlements.check_entitlement("price-fetching")` retourne `false`, le bouton ↻ affiche un tooltip "Disponible avec abonnement" et est désactivé. Pas de tentative de fetch. - **Server-side** : `maximus-api /v1/prices` valide le tier premium AVANT cache/provider. Un client modifié qui bypass la UI reçoit 403. La double vérification est délibérée : le client est compromettable (l'app Tauri est ouverte au reverse-engineering), seul le serveur peut faire foi. ### Consentement explicite (per-profile) - Stockage : `user_preferences.price_fetching_consent = {consented_at: , version: 1}` - **NE PAS seeder la clé.** Absence = jamais demandé. Le default doit être "non-décidé", pas "false". - Premier clic sur le bouton ↻ → modal de consentement → écriture de la clé après acceptation - **Permanence** : pas de re-consent automatique. Révocation explicite via toggle Settings (supprime la clé) - Stockage **per-profile** (table `user_preferences` est par-profil), pas global au système ### Mode offline / fallback L'app **ne doit jamais bloquer la saisie d'un snapshot** parce que le price-fetching a échoué. La saisie manuelle de `unit_price` reste TOUJOURS disponible : | Erreur serveur | Comportement | |----------------|--------------| | 401 license expirée | Toast "Renouvelez votre abonnement" + champ manuel dispo | | 403 non-premium | Toast "Disponible avec abonnement Premium" + champ manuel dispo | | 404 symbole | Toast "Symbole introuvable — vérifiez l'orthographe" + champ manuel | | 429 rate limit | Toast "Limite atteinte — réessayez plus tard" + champ manuel | | Network error / 5xx | Toast "Service temporairement indisponible" + champ manuel | ## Consequences ### Positive - **L'IP de l'utilisateur n'est JAMAIS exposée à Yahoo / CoinGecko.** Le provider voit l'IP du VPS de Max — privacy-first préservée. - **Aucun symbole ne révèle de données personnelles.** "AAPL" ou "BTC" ne sont pas identifiants en soi ; corrélés à une license_id ils le redeviennent, c'est pourquoi le serveur ne logue jamais les deux ensemble. - **Cache mutualisé.** Si 500 utilisateurs premium demandent AAPL au 2026-03-15, c'est UN seul appel sortant côté maximus-api. Économise les rate limits ET réduit la surface d'exposition. - **Mode offline préservé.** L'app continue de fonctionner sans price-fetching — la saisie manuelle reste le chemin de secours. - **Justification commerciale.** Le price-fetching premium aligne le coût d'API tiers sur la révenue récurrente, sans dégrader l'expérience free-tier (qui reste 100 % local). - **Adapter pattern.** Si Yahoo casse (API non officielle), swap pour Alpha Vantage côté serveur sans changer le contrat client. ### Negative / trade-offs - **Dépendance opérationnelle au VPS.** Si maximus-api est down, le price-fetching ne fonctionne pas — atténué par le fallback manuel toujours dispo. - **Surface serveur à maintenir.** Endpoint `/v1/prices` + cache + adapters + auth + rate limiting + observabilité (sans corrélation log). - **Charge financière sur Max.** Les tier free n'ont pas accès, donc les coûts d'API tiers sont absorbés par les abonnements premium ; le cache aide significativement. - **Implémentation BLOQUÉE.** L'Issue #143 ne peut shipper tant que `maximus-api` Phase 2 n'expose pas `/v1/prices` (dépendance externe : issues maximus-api `#49` license server core et `#136` Stripe webhooks). ## Alternatives considered - **Appel direct client → provider.** Rejeté : viole le principe privacy-first (IP exposée + fingerprint headers). - **Tor / I2P intégré.** Rejeté : latence prohibitive (5-10 secondes par fetch), maintenance d'un client Tor embarqué dans Tauri, et certains providers bloquent les exits Tor. - **VPN tiers (Mullvad, etc.) configuré par l'utilisateur.** Rejeté : ne supprime pas le fingerprint headers, et "exiger l'utilisateur à configurer un VPN" est une régression UX inacceptable. - **Cache local sans serveur (chaque user a son propre cache).** Rejeté : pas de mutualisation, chaque user paie son propre rate limit, et le client doit toujours faire l'appel sortant initial (donc IP exposée). - **Saisie manuelle uniquement, pas de price-fetching du tout.** C'est le mode free-tier — fonctionnel mais friction élevée pour les utilisateurs avec un portefeuille actions/crypto significatif. Le proxy premium est le compromis qui justifie l'abonnement sans dégrader le free-tier. - **Endpoint `/v1/symbols/search` côté maximus-api** pour autocomplete. Reporté à v2 : l'autocomplete double la surface d'API et n'est pas critique. La saisie texte simple suffit au MVP. ## References - Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md) (Issue #5 — Phase 5) - Spike : `~/claude-code/.spikes/bilan/code/price-fetching.md` (architecture, choix providers, consent flow) - Issue client (BLOCKED) : maximus/simpl-resultat #143 - Issues maximus-api (externes, prerequisites) : `maximus-api#49` (license server core), `maximus-api#136` (Stripe webhooks) - Pattern auth : `src-tauri/src/commands/license_commands.rs` (`activation_path` + `activate_machine` — le token Bearer existe déjà) - Privacy frame : ce que `maximus-api` voit jamais ensemble = `(IP, license_id, symbol)`. Le proxy garantit que (IP) est cachée du provider et que (license_id, symbol) ne se retrouvent pas dans le même log.