Bilan #5 — Price-fetching premium via maximus-api #143

Closed
opened 2026-04-25 16:08:56 +00:00 by maximus · 3 comments
Owner

Refs: spec-decisions-bilan.md + spec-plan-bilan.md (v2 + overnight-2026-04-26)

Depends on #142

Reste dans spec-bilan (PAS dans overnight-2026-04-26-bilan) : autopilot ne peut pas exécuter une issue blockée externe. Reprise manuelle quand maximus-api Phase 2 sera en prod.

Pré-requis externe (BLOQUANT)

  • maximus-api Phase 2 en production (ref. issues maximus-api : #49 license server core, #136 Stripe webhooks)
  • Créer une issue dédiée dans le repo maximus-api pour limplémentation de lendpoint GET /v1/prices?symbol=&date= avec spec serveur :
    • Auth : Authorization: Bearer activation_token UNIQUEMENT (jamais en query string)
    • Validation tier premium server-side : 403 sur licences non-premium AVANT cache/provider
    • Strip de tous headers entrants (X-Forwarded-For, User-Agent client, Accept-Language, etc.) avant proxy vers Yahoo/CoinGecko
    • Pas de log conjoint (symbol, license_id) — séparer les logs ou hash le license_id
    • Cache local SQLite (symbol, date) → price (TTL infini sur dates passées, 5 min sur today)

Tâches client (déclenchées une fois maximus-api prêt)

Backend

  • Commande Tauri fetch_price(symbol, date) dans balance_commands.rs :
    • Lit activation_token via activation_path existant (PAS user_preferences) — réutiliser les accessors existants du module license
    • reqwest::Client::builder().user_agent("simpl-resultat").build() — UA fixe
    • Headers envoyés UNIQUEMENT : Authorization: Bearer token + Accept: application/json
    • PAS de Accept-Language, PAS dautres headers identifiants

Service (avec rate-limit)

  • Étendre balance.service.ts avec section prices :
    • Rate-limit client : max 1 fetch / 2s, dedup in-flight par (symbol, date)
    • Backoff exponentiel sur 5xx / network (2s, 4s, 8s — max 3 retries)
    • Plafond hard : 100 fetches par session snapshot (anti-loop)

Tests

  • Tests vitest mock du proxy : succès, 401 license expirée, 403 non-premium, 404 symbole, 429 rate limit, network error, retry behavior, dedup
  • Test "privacy headers" : intercepter la requête fil et vérifier quaucun header autre que Authorization + Accept + UA fixe simpl-resultat nest envoyé

UI

  • Composant PriceFetchControl.tsx (fusion bouton ↻ + modal de consentement) :
    • Premier clic affiche modal de consentement
    • Stockage per-profile dans user_preferences clé price_fetching_consent = {consented_at: ISO, version: 1}
    • NE PAS seeder la clé (absence = jamais demandé)
    • Pas de re-consent automatique : permanence jusquà révocation explicite via Settings
    • Spinner pendant fetch, attribution [via Maximus le YYYY-MM-DD HH:mm] après succès
  • Toggle de révocation dans SettingsPage.tsx (supprime la clé price_fetching_consent)
  • Vérification du tier premium côté UI avant affichage du bouton (tooltip "Disponible avec abonnement" si non-premium) — ne remplace PAS la vérif server-side
  • En cas déchec serveur (toutes erreurs) : toast informatif + champ texte de saisie manuelle reste actif. Jamais bloquer la saisie dun snapshot
  • Entrée CHANGELOG mentionnant explicitement premium-only + privacy preserved (proxy + header stripping)

Décisions prises ce soir

  • Consentement permanent : pas de re-consent automatique. Révocation uniquement via toggle Settings.

Critères dacceptation

  • Le bouton de price-fetching apparaît uniquement pour utilisateurs premium
  • Le premier clic affiche le modal de consentement (per-profile, persistance correcte)
  • Test privacy headers vérifie que la requête fil ne contient que Authorization + Accept + UA fixe simpl-resultat
  • En cas déchec serveur (401, 403, 404, 429, network), fallback gracieux vers saisie manuelle (jamais de blocage)
  • Lutilisateur peut révoquer son consentement depuis Settings
  • Rate-limit client empêche les loops (vérifié dans les tests)
Refs: spec-decisions-bilan.md + spec-plan-bilan.md (v2 + overnight-2026-04-26) Depends on #142 **Reste dans spec-bilan (PAS dans overnight-2026-04-26-bilan)** : autopilot ne peut pas exécuter une issue blockée externe. Reprise manuelle quand maximus-api Phase 2 sera en prod. ## Pré-requis externe (BLOQUANT) - maximus-api Phase 2 en production (ref. issues maximus-api : #49 license server core, #136 Stripe webhooks) - **Créer une issue dédiée dans le repo maximus-api** pour limplémentation de lendpoint GET /v1/prices?symbol=&date= avec spec serveur : - Auth : Authorization: Bearer activation_token UNIQUEMENT (jamais en query string) - Validation tier premium **server-side** : 403 sur licences non-premium AVANT cache/provider - Strip de tous headers entrants (X-Forwarded-For, User-Agent client, Accept-Language, etc.) avant proxy vers Yahoo/CoinGecko - **Pas de log conjoint (symbol, license_id)** — séparer les logs ou hash le license_id - Cache local SQLite (symbol, date) → price (TTL infini sur dates passées, 5 min sur today) ## Tâches client (déclenchées une fois maximus-api prêt) ### Backend - [ ] Commande Tauri fetch_price(symbol, date) dans balance_commands.rs : - Lit activation_token via activation_path existant (PAS user_preferences) — réutiliser les accessors existants du module license - reqwest::Client::builder().user_agent("simpl-resultat").build() — UA fixe - Headers envoyés UNIQUEMENT : Authorization: Bearer token + Accept: application/json - PAS de Accept-Language, PAS dautres headers identifiants ### Service (avec rate-limit) - [ ] Étendre balance.service.ts avec section prices : - Rate-limit client : max 1 fetch / 2s, dedup in-flight par (symbol, date) - Backoff exponentiel sur 5xx / network (2s, 4s, 8s — max 3 retries) - Plafond hard : 100 fetches par session snapshot (anti-loop) ### Tests - [ ] Tests vitest mock du proxy : succès, 401 license expirée, 403 non-premium, 404 symbole, 429 rate limit, network error, retry behavior, dedup - [ ] **Test "privacy headers"** : intercepter la requête fil et vérifier quaucun header autre que Authorization + Accept + UA fixe simpl-resultat nest envoyé ### UI - [ ] Composant PriceFetchControl.tsx (fusion bouton ↻ + modal de consentement) : - Premier clic affiche modal de consentement - Stockage **per-profile** dans user_preferences clé price_fetching_consent = {consented_at: ISO, version: 1} - **NE PAS seeder** la clé (absence = jamais demandé) - **Pas de re-consent automatique** : permanence jusquà révocation explicite via Settings - Spinner pendant fetch, attribution [via Maximus le YYYY-MM-DD HH:mm] après succès - [ ] Toggle de révocation dans SettingsPage.tsx (supprime la clé price_fetching_consent) - [ ] Vérification du tier premium côté UI avant affichage du bouton (tooltip "Disponible avec abonnement" si non-premium) — **ne remplace PAS** la vérif server-side - [ ] En cas déchec serveur (toutes erreurs) : toast informatif + champ texte de saisie manuelle reste actif. **Jamais bloquer** la saisie dun snapshot - [ ] Entrée CHANGELOG mentionnant explicitement premium-only + privacy preserved (proxy + header stripping) ## Décisions prises ce soir - Consentement permanent : pas de re-consent automatique. Révocation uniquement via toggle Settings. ## Critères dacceptation - Le bouton de price-fetching apparaît uniquement pour utilisateurs premium - Le premier clic affiche le modal de consentement (per-profile, persistance correcte) - Test privacy headers vérifie que la requête fil ne contient que Authorization + Accept + UA fixe simpl-resultat - En cas déchec serveur (401, 403, 404, 429, network), fallback gracieux vers saisie manuelle (jamais de blocage) - Lutilisateur peut révoquer son consentement depuis Settings - Rate-limit client empêche les loops (vérifié dans les tests)
maximus added this to the spec-price-fetching milestone 2026-04-25 16:08:56 +00:00
maximus added the
status:blocked
type:feature
source:human
labels 2026-04-25 16:08:56 +00:00
Author
Owner

/review-spec — findings sécurité bloquants pour le price-fetching

3 critiques, 2 améliorations, 2 suggestions remontés par l'agent Sécurité. Voir spec-plan-bilan.md section "Issue 5" pour les annotations inline complètes.

🔴 Critiques (à trancher avant implémentation)

1. license_id mal stocké
Le spec dit que license_id vit dans user_preferences, mais le projet stocke license_key + activation_token dans des fichiers via license_path / activation_path (pas dans la table SQL). Implémenter tel qu'écrit = duplication du secret en plaintext SQLite OU casse l'auth.
Fix : Réutiliser les accessors existants. Envoyer activation_token (pas license_key) en Bearer pour /v1/prices.
Ref : CWE-836

2. license_id en query string (token-in-URL)
Le sketch maximus-api montre ?license=<license_id> comme query param. Token en URL = leak dans logs Traefik, accès VPS, history navigateur, headers Referer.
Fix : Authorization header uniquement. Aucune license/token en query. Critère d'acceptation : test que la requête envoyée ne contient aucun segment license/token dans l'URL.
Ref : OWASP API2:2023 / CWE-598

3. Privacy claim repose sur hygiène headers stricte
"IP jamais exposée à Yahoo/CoinGecko" suppose que le proxy ne forwarde NI client IP, NI User-Agent, NI Accept-Language. reqwest::Client::new() envoie par défaut User-Agent: reqwest/0.12 — corrélable avec license_id sur un petit VPS.
Fix : Dans l'ADR proxy-price-fetching-via-maximus-api.md :

  • maximus-api strip TOUS les headers entrants et ne logge JAMAIS symbol+license ensemble
  • Client envoie uniquement Authorization + minimal Accept + explicit User-Agent: simpl-resultat
  • Test privacy dans Issue 6 mockant la requête fil pour vérifier les headers envoyés
    Ref : OWASP A09:2021

🟡 Améliorations

4. Aucun rate-limit / circuit breaker côté client
Le spec gère les erreurs serveur (429, 401, 404) mais pas le rate-flood côté client. Bug ou retry loop = spam maximus-api + déanonymisation potentielle via timing/volume même sur le cache.
Fix : Rate-limit client (max 1 fetch / 2s, dedup in-flight par symbole), backoff exponentiel sur 5xx/network, plafond hard par session snapshot. Tests dans Issue 6.

5. Consent storage trop grossier
Boolean unique price_fetching_consent dans user_preferences : une fois accepté, tous les fetches futurs sont auto. Pas explicite que la clé est par-profil. Pas de re-consent après pause.
Fix : Préciser que la clé vit dans user_preferences per-profile. Considérer re-consent visible si > 30j sans usage.
Sub-fix : Default non spécifié — NE PAS seeder la clé (absence = jamais demandé). Premier clic écrit {consented_at: ISO, version: 1}.

6. 3 services pour 1 domaine sur-découpe
priceFetcher.service.ts + returnCalculator.service.ts séparés alors que pattern projet = 1 service par domaine. Voir aussi commentaire sur Issue #141.
Fix : Tout intégrer dans balance.service.ts (CRUD + prices + returns).

🟢 Suggestions

7. Premium check server-side
Le check "non-premium → tooltip 'Disponible avec abonnement'" est UI-only. Un client modifié peut appeler fetch_price directement et brûler le quota.
Fix : Dans l'issue maximus-api : /v1/prices rejette les licences non-premium avec 403 avant cache/provider. Test correspondant dans Issue 6.
Ref : OWASP A01:2021 / CWE-602

## /review-spec — findings sécurité bloquants pour le price-fetching 3 critiques, 2 améliorations, 2 suggestions remontés par l'agent Sécurité. Voir spec-plan-bilan.md section "Issue 5" pour les annotations inline complètes. ### 🔴 Critiques (à trancher avant implémentation) **1. license_id mal stocké** Le spec dit que `license_id` vit dans `user_preferences`, mais le projet stocke license_key + activation_token dans des **fichiers** via `license_path` / `activation_path` (pas dans la table SQL). Implémenter tel qu'écrit = duplication du secret en plaintext SQLite OU casse l'auth. **Fix :** Réutiliser les accessors existants. Envoyer `activation_token` (pas `license_key`) en Bearer pour `/v1/prices`. *Ref : CWE-836* **2. license_id en query string (token-in-URL)** Le sketch maximus-api montre `?license=<license_id>` comme query param. Token en URL = leak dans logs Traefik, accès VPS, history navigateur, headers Referer. **Fix :** Authorization header uniquement. Aucune license/token en query. Critère d'acceptation : test que la requête envoyée ne contient aucun segment license/token dans l'URL. *Ref : OWASP API2:2023 / CWE-598* **3. Privacy claim repose sur hygiène headers stricte** "IP jamais exposée à Yahoo/CoinGecko" suppose que le proxy ne forwarde NI client IP, NI User-Agent, NI Accept-Language. `reqwest::Client::new()` envoie par défaut `User-Agent: reqwest/0.12` — corrélable avec license_id sur un petit VPS. **Fix :** Dans l'ADR `proxy-price-fetching-via-maximus-api.md` : - maximus-api strip TOUS les headers entrants et ne logge JAMAIS symbol+license ensemble - Client envoie uniquement `Authorization` + minimal `Accept` + explicit `User-Agent: simpl-resultat` - Test privacy dans Issue 6 mockant la requête fil pour vérifier les headers envoyés *Ref : OWASP A09:2021* ### 🟡 Améliorations **4. Aucun rate-limit / circuit breaker côté client** Le spec gère les erreurs serveur (429, 401, 404) mais pas le rate-flood côté client. Bug ou retry loop = spam maximus-api + déanonymisation potentielle via timing/volume même sur le cache. **Fix :** Rate-limit client (max 1 fetch / 2s, dedup in-flight par symbole), backoff exponentiel sur 5xx/network, plafond hard par session snapshot. Tests dans Issue 6. **5. Consent storage trop grossier** Boolean unique `price_fetching_consent` dans `user_preferences` : une fois accepté, tous les fetches futurs sont auto. Pas explicite que la clé est par-profil. Pas de re-consent après pause. **Fix :** Préciser que la clé vit dans `user_preferences` per-profile. Considérer re-consent visible si > 30j sans usage. **Sub-fix :** Default non spécifié — NE PAS seeder la clé (absence = jamais demandé). Premier clic écrit `{consented_at: ISO, version: 1}`. **6. 3 services pour 1 domaine sur-découpe** `priceFetcher.service.ts` + `returnCalculator.service.ts` séparés alors que pattern projet = 1 service par domaine. Voir aussi commentaire sur Issue #141. **Fix :** Tout intégrer dans `balance.service.ts` (CRUD + prices + returns). ### 🟢 Suggestions **7. Premium check server-side** Le check "non-premium → tooltip 'Disponible avec abonnement'" est UI-only. Un client modifié peut appeler `fetch_price` directement et brûler le quota. **Fix :** Dans l'issue maximus-api : `/v1/prices` rejette les licences non-premium avec 403 avant cache/provider. Test correspondant dans Issue 6. *Ref : OWASP A01:2021 / CWE-602*
Author
Owner

Revue de spec — docs/api-contract-prices.md

Revue multi-expert (Sécurité, Architecture, Technique) du contrat API /v1/prices ajoutée dans docs/api-contract-prices.md (commit à venir).

Verdict : 🔴 CRITIQUES À CORRIGER avant gel — 8 critiques + 11 améliorations + 3 suggestions.

Critiques bloquantes (résumé)

  1. Provider de prix illégal — Yahoo Finance n'a pas d'API publique stable, CoinGecko free interdit le proxying commercial. Risque légal + kill-switch IP global pour tous les premium. Souscrire à Polygon/Alpha Vantage/Twelve Data/CoinGecko Pro avant ship.
  2. Rate-limit in-memoryrateLimit.ts actuel keye par IP avec un Map global ; le quota par-licence promis (30/min, 2000/j) requiert Redis ou token-bucket Postgres atomique.
  3. Enveloppe d'erreur incohérente/licenses/* retourne {error: "string"} plat, le contrat propose {error: {code, message}} nesté. Deux shapes dans la même app Hono.
  4. Versioning asymétrique/v1/prices introduit /v1/ mais /licenses/* reste non versionné.
  5. Binding product manquant — JWT activation porte une claim product, jamais validée par le contrat. Un futur 2e produit pourrait hitter prices.
  6. mockito-rs inexistant — le crate Rust s'appelle mockito (sans suffixe), et le client est en TS de toute façon.
  7. Quota CoinGecko 2000/j incompatible avec free tier 30/min — promesse non adossée à la capacité provider.
  8. edition déjà exposé — la spec référence à tort une issue maximus-api à créer pour ce champ.

Améliorations clés

  • activation_token : ajouter jti + revocation list, OU TTL court + refresh
  • Cache serveur : cap LRU + TTL court sur 404s + idéalement allowlist symboles
  • Logs : stripper querystring /v1/prices dans Traefik (sinon corrélation timestamp casse §9.1)
  • kid dans header JWT pour préparer la prochaine rotation Ed25519
  • Middleware partagée licenseAuth.ts
  • Cache → table Drizzle Postgres (pas SQLite, pas en mémoire)
  • Logger structuré (pino) pour tester « jamais log conjoint »

Doc annotée inline avec tous les détails et résolutions concrètes.


Revue automatique via /review-spec

## Revue de spec — `docs/api-contract-prices.md` Revue multi-expert (Sécurité, Architecture, Technique) du contrat API `/v1/prices` ajoutée dans `docs/api-contract-prices.md` (commit à venir). **Verdict : 🔴 CRITIQUES À CORRIGER avant gel** — 8 critiques + 11 améliorations + 3 suggestions. ### Critiques bloquantes (résumé) 1. **Provider de prix illégal** — Yahoo Finance n'a pas d'API publique stable, CoinGecko free interdit le proxying commercial. Risque légal + kill-switch IP global pour tous les premium. Souscrire à Polygon/Alpha Vantage/Twelve Data/CoinGecko Pro avant ship. 2. **Rate-limit in-memory** — `rateLimit.ts` actuel keye par IP avec un Map global ; le quota par-licence promis (30/min, 2000/j) requiert Redis ou token-bucket Postgres atomique. 3. **Enveloppe d'erreur incohérente** — `/licenses/*` retourne `{error: "string"}` plat, le contrat propose `{error: {code, message}}` nesté. Deux shapes dans la même app Hono. 4. **Versioning asymétrique** — `/v1/prices` introduit `/v1/` mais `/licenses/*` reste non versionné. 5. **Binding `product` manquant** — JWT activation porte une claim `product`, jamais validée par le contrat. Un futur 2e produit pourrait hitter prices. 6. **`mockito-rs` inexistant** — le crate Rust s'appelle `mockito` (sans suffixe), et le client est en TS de toute façon. 7. **Quota CoinGecko 2000/j incompatible avec free tier 30/min** — promesse non adossée à la capacité provider. 8. **`edition` déjà exposé** — la spec référence à tort une issue maximus-api à créer pour ce champ. ### Améliorations clés - `activation_token` : ajouter `jti` + revocation list, OU TTL court + refresh - Cache serveur : cap LRU + TTL court sur 404s + idéalement allowlist symboles - Logs : stripper querystring `/v1/prices` dans Traefik (sinon corrélation timestamp casse §9.1) - `kid` dans header JWT pour préparer la prochaine rotation Ed25519 - Middleware partagée `licenseAuth.ts` - Cache → table Drizzle Postgres (pas SQLite, pas en mémoire) - Logger structuré (pino) pour tester « jamais log conjoint » Doc annotée inline avec tous les détails et résolutions concrètes. --- *Revue automatique via `/review-spec`*
Author
Owner

Closing in favor of decomposed sub-issues in milestone spec-price-fetching (this same milestone, renamed from spec-bilan). Decomposition based on the multi-expert review of docs/api-contract-prices.md (see prior comment).

Sub-issues being created: contract commit, Tauri fetch_price command, service prices section, premium tier detection, PriceFetchControl + consent modal, Settings revocation toggle, i18n + CHANGELOG, production wiring. Server-side work tracked in maximus-api repo under milestone prices-proxy.

The original BLOCKED status was overcautious — most client work is mock-driven and decoupled from server availability via the now-frozen contract.

Closing in favor of decomposed sub-issues in milestone `spec-price-fetching` (this same milestone, renamed from `spec-bilan`). Decomposition based on the multi-expert review of `docs/api-contract-prices.md` (see prior comment). Sub-issues being created: contract commit, Tauri fetch_price command, service prices section, premium tier detection, PriceFetchControl + consent modal, Settings revocation toggle, i18n + CHANGELOG, production wiring. Server-side work tracked in `maximus-api` repo under milestone `prices-proxy`. The original BLOCKED status was overcautious — most client work is mock-driven and decoupled from server availability via the now-frozen contract.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#143
No description provided.