Simpl-Resultat/docs/api-contract-prices.md
le king fu 7f5e5a8c71
All checks were successful
PR Check / rust (pull_request) Successful in 22m12s
PR Check / frontend (pull_request) Successful in 2m29s
docs: replace JWT-like Bearer placeholder with <license-token> (#181)
Defenseur secrets-scanner false positive: the truncated example token
in api-contract-prices.md:471 passed the entropy threshold for the
"Bearer Token" pattern. Swap for an explicit <license-token>
placeholder so the next defenseur run no longer flags it.

No user-visible behavior change — doc placeholder only, no CHANGELOG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:05:20 -04:00

41 KiB
Raw Permalink Blame History

Contrat API — GET /v1/prices

Statut : Draft v2 (2026-04-26) — décisions de revue intégrées, prêt pour gel après création des issues Producteurs : maximus-api (serveur Hono/Node.js sur VPS OVH) Consommateurs : simpl-resultat (client desktop Tauri) Fichiers de référence (mirror) :

  • simpl-resultat/docs/api-contract-prices.md (ce fichier — source de vérité)
  • maximus-api/docs/api-contract-prices.md (copie identique, à synchroniser à chaque modification)

Ce document fige la surface d'API entre le client desktop premium et le proxy de récupération de prix de maximus-api. Le but : permettre au client et au serveur d'être développés et testés en parallèle, sans dépendance temporelle, contre des mocks conformes.

0. Décisions de revue (2026-04-26)

Synthèse des décisions issues de la revue multi-expert (cf. issue #143 et ce fichier annoté). Ces décisions sont gelées et impactent toutes les sections ci-dessous.

Décision Choix Section impactée
Providers de prix Crypto via exchanges directs (Kraken, Coinbase via CCXT, OSS-légal, gratuit) + Stocks via Yahoo Finance en best-effort assumé (gratuit, ToS risque acté dans ADR 0011) §8
Coût mensuel 0 $ initial. Migration vers Tiingo (~10 $/mo) ou Polygon (~29 $/mo) si Yahoo devient inutilisable (cf. ADR 0011) §8, §11
Rate-limit infra Postgres token-bucket atomique (INSERT ... ON CONFLICT DO UPDATE + pg_advisory_xact_lock). Pas de Redis. §6.1
Versioning + enveloppe Migration globale vers /v1/ + enveloppe nestée {error: {code, message, retry_after}}. /licenses/* deviennent aliases deprecated 30 jours pointant sur /v1/licenses/*. §2, §5, §11
Quota par licence 30/min, 200/jour (revu à la baisse depuis 2000/jour : Yahoo est gratuit mais fragile, et 200 suffit pour ~50 actifs × snapshot mensuel) §6.1
Justification du gating premium Le client premium paie le proxy d'anonymisation et l'infrastructure, pas la donnée (qui est gratuite/best-effort). Cohérent avec privacy-first. §1, ADR 0011
product claim binding Middleware valide claims.product === 'simpl-resultat'. §7.1
Lib mocks Client TS : vi.fn() sur fetch. Serveur outbound : nock. Serveur inbound : app.request() natif Hono. §12

1. Objectif fonctionnel

Permettre au client desktop d'obtenir le prix d'un actif coté (action ou crypto) à une date donnée, sans exposer l'identité ni l'IP de l'utilisateur aux fournisseurs de données. Le proxy maximus-api fait écran et mutualise les appels.

Le client premium paie pour l'infrastructure d'anonymisation et de proxy, pas pour la donnée elle-même. La donnée est gratuite (crypto via exchanges publics, stocks via Yahoo en best-effort). Cette distinction est centrale au modèle économique et préserve la cohérence privacy-first.

UX explicite — feature stocks en best-effort : pour les catégories de bilan en stocks (actions cotées), le bouton de fetch affiche un label « best-effort » + warning au premier usage : « Source non garantie, peut être indisponible. La saisie manuelle reste prioritaire et toujours active. » Pas de warning pour crypto (provider stable).

Ce qui n'est PAS dans cet endpoint :

  • Recherche / autocomplete de symboles (hors scope MVP)
  • Historique sur intervalle (un seul couple (symbol, date) par requête)
  • Conversion de devise

2. Endpoint

GET /v1/prices?symbol=<symbol>&date=<YYYY-MM-DD>
  • Méthode : GET (idempotent, cacheable au sens HTTP — mais voir §10 pour les contraintes côté client)
  • Base URL prod : https://api.lacompagniemaximus.com
  • Base URL dev : configurable côté client (MAXIMUS_API_URL ou équivalent)
  • Versioning : préfixe /v1/. Toute modification non rétrocompatible passe par /v2/. Au sein de /v1/, seuls les ajouts de champs sont autorisés.

🔴 ARCHITECTURE — Asymétrie de versioning /v1/prices vs /licenses/*. Introduire /v1/ sur prices alors que /licenses/* reste non-versionné crée une surface mixte. La politique §11 ne s'applique alors qu'à un endpoint. Resolution : Trancher avant gel : soit renommer en /v1/licenses/* (avec alias durant deprecation window), soit retirer le préfixe /v1 sur prices. À acter dans l'ADR 0009.

2.1 Paramètres de requête

Param Type Format Validation Obligatoire
symbol string alphanum + . + -, 1-20 chars, case-insensitive (normalisé en MAJUSCULES côté serveur) regex ^[A-Za-z0-9.\-]{1,20}$ oui
date string ISO 8601 YYYY-MM-DD doit être ≥ 1970-01-01 et ≤ aujourd'hui (UTC) oui

Toute autre query string est ignorée silencieusement (le serveur ne se base que sur ces deux paramètres).

🔴 ARCHITECTURE — Binding manquant à la claim product du JWT. Tous les endpoints existants requièrent product (schéma multi-produit) et le JWT activation porte une claim product. /v1/prices ne la valide pas — un futur 2e produit pourrait hitter prices avec son propre token. Resolution : Valider claims.product === 'simpl-resultat' dans le middleware d'auth (extraction JWT, pas en query). Documenter en §7.1 (étape 5.5).

3. Headers de requête

3.1 Headers requis (client → maximus-api)

Header Valeur Notes
Authorization Bearer <activation_token> Token opaque côté client. Le serveur valide la signature Ed25519 + l'état de la licence en DB.
Accept application/json
User-Agent simpl-resultat FIXE, sans version, sans OS, sans architecture.

🟢 SECURITE+TECHNIQUE — User-Agent fixe empêche la dépréciation d'urgence des clients. Aucun moyen de refuser un build vulnérable connu (ex. version qui leak l'activation_token dans les logs). Pas de gate de version minimale possible. Resolution : Envoyer un header séparé X-Client-Major: 0.x (major+minor uniquement, pas patch/OS/arch) — préserve la k-anonymity et active les gates de dépréciation. Documenter le tradeoff de privacy.

3.2 Headers interdits (client → maximus-api)

Le client ne doit jamais envoyer :

  • Accept-Language
  • X-Forwarded-For, X-Real-IP, ou tout header X-Forwarded-*
  • Cookies
  • Aucun header personnalisé identifiant la machine

Cette règle est testée par un test unitaire côté client (« privacy headers test »). Le serveur tolère leur présence (ne les rejette pas par 400) mais les supprime avant tout traitement.

3.3 Comportement du serveur sur les headers entrants

Avant tout appel sortant vers Yahoo / CoinGecko, maximus-api strippe tous les headers entrants à l'exception de ceux qu'il génère lui-même. Garanti par contrat — vérifié par tests d'intégration côté serveur.

🟡 TECHNIQUE — Headers injectés par l'infra (CF-*, Coolify, Traefik) non couverts par le test §12.2. Le proxy Traefik / Coolify ajoute des headers (X-Forwarded-*, X-Real-IP, etc.) entre le client et l'app — si l'app les propage par accident vers Yahoo/CoinGecko, la promesse §3.3 est cassée. Resolution : Utiliser un client HTTP nu (fetch natif Node, sans propagation d'headers) pour les appels sortants. Test : asserter que la liste de headers reçue par le mock provider == exactement ['user-agent', 'accept', 'host'].

4. Réponse de succès (200 OK)

{
  "symbol": "AAPL",
  "date": "2026-04-25",
  "actual_date": "2026-04-24",
  "price": 173.45,
  "currency": "USD",
  "source": "yahoo",
  "fetched_at": "2026-04-25T14:32:11Z",
  "cached": true
}

4.1 Sémantique des champs

Champ Type Description
symbol string Symbole tel que normalisé par le serveur (MAJUSCULES)
date string Date demandée (echo du paramètre)
actual_date string | null Si la date demandée n'a pas de cotation (week-end, jour férié pour les actions), date effective de la cotation retournée. null si actual_date == date.
price number Prix de clôture pour les actions, prix instantané pour les crypto. Précision : 4 décimales pour les actions, 8 pour les crypto.
currency string ISO 4217 (3 lettres). Exemples : USD, CAD, EUR.
source string yahoo ou coingecko. Indicatif uniquement — le client ne doit pas faire de logique conditionnelle dessus.
fetched_at string ISO 8601 UTC du moment où le prix a été récupéré du provider (différent de now si servi depuis cache).
cached boolean true si servi depuis le cache serveur. Indicatif uniquement — n'affecte pas la fraîcheur garantie (cf. §10).

4.2 Headers de réponse (succès)

Header Toujours présent Description
Content-Type oui application/json; charset=utf-8
X-RateLimit-Limit oui Quota total sur la fenêtre courante (entier)
X-RateLimit-Remaining oui Quota restant (entier)
X-RateLimit-Reset oui Unix timestamp (secondes) du reset de quota
Cache-Control oui private, max-age=0 — le client ne doit pas mettre en cache HTTP

5. Réponses d'erreur

5.1 Format d'enveloppe (toutes les 4xx/5xx)

{
  "error": {
    "code": "premium_required",
    "message": "Premium license required for price fetching",
    "retry_after": 30
  }
}
Champ Type Description
error.code string Code stable, lisible-machine. Snake_case. Liste fermée (cf. §5.2).
error.message string Message lisible-humain en anglais. Ne pas afficher tel quel à l'utilisateur — le client doit traduire en FR/EN via i18n à partir du code.
error.retry_after number | absent Présent uniquement sur 429 et 503. Secondes à attendre avant retry.

🔴 ARCHITECTURE — Enveloppe d'erreur incohérente avec /licenses/*. Les routes existantes /licenses/* retournent un format plat { error: "string" } (parfois avec details ou machines). Cette spec propose { error: { code, message, retry_after } } — deux shapes dans la même app Hono force les clients à brancher par route. Resolution : Trancher : soit migrer /licenses/* vers la nouvelle enveloppe (versionner en /v1/licenses/*), soit aligner /v1/prices sur { error, code, retry_after } plat. Documenter le choix dans le README maximus-api.

5.2 Codes d'erreur par status HTTP

Status error.code Cause Retry possible ?
400 Bad Request invalid_symbol symbol ne matche pas la regex non
400 invalid_date date mal formée, future, ou pré-1970 non
400 missing_param symbol ou date absent non
401 Unauthorized missing_token Header Authorization absent non
401 invalid_token Signature Ed25519 invalide non
401 expired_token Token expiré non — re-activation requise
403 Forbidden premium_required Licence valide mais edition != 'premium' non — abonnement requis
403 license_revoked Licence révoquée non — contact support
404 Not Found symbol_not_found Le symbole est inconnu de tous les providers consultés non
429 Too Many Requests rate_limit_exceeded Quota dépassé pour cette licence oui — après retry_after secondes
502 Bad Gateway provider_unavailable Yahoo / CoinGecko a échoué (timeout, 5xx) oui — backoff exponentiel
503 Service Unavailable service_degraded Maintenance ou panne interne oui — après retry_after secondes
500 Internal Server Error internal_error Bug serveur. Loggé côté server. oui — backoff

Le serveur ne doit jamais retourner un code HTTP avec un body qui ne respecte pas l'enveloppe {error: {code, message}}. Aucune fuite de stack trace, aucun message d'erreur de provider non sanitisé.

6. Rate-limiting

6.1 Côté serveur

Le quota est appliqué par licence (clé = hash(license_id)).

Tier Fenêtre Quota
premium 1 minute glissante 30 requêtes
premium 1 jour glissant 200 requêtes

Le quota est partagé entre toutes les machines activées sur la même licence.

Implémentation : table Postgres rate_limit_buckets(license_id PK, window_start TIMESTAMPTZ, count INT) avec atomicité via INSERT ... ON CONFLICT (license_id) DO UPDATE SET count = count + 1, window_start = CASE WHEN now() - window_start > '1 day' THEN now() ELSE window_start END RETURNING count. Pour la fenêtre minute, idem en table séparée. Pas de Redis (cf. décision §0).

🔴 SECURITE+ARCHITECTURE — Rate-limit in-memory ne peut pas garantir un quota par-licence. maximus-api/src/middleware/rateLimit.ts keye par IP via Map global (5 req/min, hardcoded). Le quota promis (30/min, 2000/jour, partagé entre machines) requiert un store atomique partagé : sur restart Coolify les compteurs se réinitialisent (refill gratuit), et toute scaling horizontale crée une race TOCTOU exploitable. Resolution : Avant que §6 ne quitte le draft : ajouter Redis OU token-bucket Postgres avec row-level lock. Généraliser rateLimit.ts en { keyFn, windows: [{ms,max}] } pour réutiliser une même middleware avec deux configs (IP pour licenses, license-id pour prices). Documenter le backend. Ref : OWASP API4:2023 — Unrestricted Resource Consumption

🔴 TECHNIQUE — Quota 2000/jour × N licences incompatible avec free tier CoinGecko (30/min). Le free tier CoinGecko Demo plafonne à 30 calls/min ; promettre 2000 req/j × N premium dépasse ce plafond dès quelques utilisateurs simultanés. Le quota promis n'est pas adossé à une capacité provider. Resolution : Soit prévoir CoinGecko Analyst payant (~$129/mois, 500 calls/min) et le mentionner dans la section coûts, soit baisser le quota par licence (ex. 200/j) et le justifier. Test interne que le quota provider est respecté en interne.

6.2 Côté client

Le client implémente en plus un rate-limit local pour éviter de gaspiller le quota serveur :

  • Max 1 requête sortante toutes les 2 secondes
  • Déduplication des requêtes en vol identiques (mêmes symbol + date → une seule requête réseau, plusieurs awaiters)
  • Plafond hard : 100 requêtes par session de saisie de snapshot (anti-loop)

Ces limites sont des défenses en profondeur — le contrat ne dépend pas de leur valeur exacte.

🟡 TECHNIQUE — Test "1 req / 2s" requiert fake timers + emplacement du rate-limiter non figé. Le test it("respecte le rate-limit local 1 req / 2s") est temporel et flaky sans vi.useFakeTimers(). Le contrat ne dit pas où implémenter le rate-limiter (hook ? service ?), ce qui laisse une décision d'archi ouverte. Resolution : Préciser §6.2 : « rate-limit implémenté dans src/services/priceService.ts (ou dans balance.service.ts section prices). Tests vitest avec vi.useFakeTimers(). » Ajouter le service au CLAUDE.md avant gel.

7. Authentification et autorisation — détail

7.1 Validation côté serveur (ordre)

Implémentée comme middleware partagée src/middleware/licenseAuth.ts (analogue à adminAuth.ts), réutilisable pour /v1/quota et autres endpoints premium futurs.

  1. Header Authorization présent → sinon 401 missing_token
  2. Format Bearer <token> correct → sinon 401 invalid_token
  3. Signature Ed25519 valide (clé publique embarquée côté serveur) → sinon 401 invalid_token
  4. Token non expiré (exp claim) → sinon 401 expired_token
  5. Claim product du JWT = 'simpl-resultat' → sinon 401 invalid_token (un futur 2e produit aura sa propre clé ou son propre claim ; pas de cross-product)
  6. Licence en DB existe et is_revoked = false → sinon 403 license_revoked
  7. Licence edition = 'premium' → sinon 403 premium_required
  8. Aucun appel provider tant que ces 7 étapes n'ont pas réussi. Cette règle est testée côté serveur.

La middleware populate c.set('license', { id, edition, product }) pour les handlers downstream.

🟡 SECURITEactivation_token sans jti, durée 2 ans → fenêtre de replay non bornée. Tokens signés Ed25519 avec exp ~2 ans, sans jti (vérifié dans licenseService.ts). Un token leaké (compromission machine, slip de log, exfil malware) donne un accès premium pendant ~2 ans sans path de révocation hors révocation de la licence entière. /v1/prices envoie ce token à chaque appel — multiplie la surface d'exfil. Resolution : (a) ajouter jti + revocation list Redis-backed checkée à chaque /v1/prices, OU (b) raccourcir le TTL à 7-14 jours avec refresh-token silencieux, OU (c) bind du token au canal TLS via DPoP. Choix à acter en §7. Ref : CWE-294 (Authentication Bypass by Capture-Replay), OWASP API2:2023

🟡 ARCHITECTURE — Auth + premium check doit être une middleware partagée. Étapes 1-6 = concern auth/authz autonome. Inliner dans le handler prices = duplication quand /v1/quota ou autres endpoints premium arriveront. Resolution : Ajouter src/middleware/licenseAuth.ts (analogue à adminAuth.ts) qui populate c.set('license', ...). Référencer cette middleware par nom dans §7.1.

7.2 Le champ edition côté licence

Le serveur expose déjà edition dans la réponse de POST /v1/licenses/verify (depuis maximus-api Phase 1, cf. licenseService.ts:216). Valeurs possibles : "base" | "premium". Aucun travail backend additionnel sur ce champ. Le client lit ce champ pour afficher / cacher conditionnellement le bouton de price-fetching dans l'UI — mais cette vérif n'est qu'ergonomique, jamais un substitut au check serveur.

🟡 ARCHITECTUREedition est déjà exposé par /licenses/verify (depuis Phase 1). La référence à une « issue maximus-api dédiée à ajouter » est obsolète : licenseService.ts:216 retourne déjà edition dans la réponse verify. Resolution : Mettre à jour §7.2 : « edition est déjà exposé par /licenses/verify depuis maximus-api Phase 1. Aucun travail backend nécessaire pour ce champ. »

8. Comportement de proxying (côté serveur)

8.1 Routage par type de symbole

maximus-api détermine le provider en interne :

  • Crypto : symbole matche le catalogue crypto connu (BTC, ETH, SOL, ADA, DOT, etc. ou suffixe -USD/-USDT) → exchanges directs via la lib ccxt. Tente Kraken d'abord, fallback Coinbase si Kraken 404. Données de marché publiques, ToS-clean, gratuit.
  • Stocks : tout le reste → Yahoo Finance en best-effort assumé (cf. ADR 0011). Endpoint query1.finance.yahoo.com/v7/finance/quote ou v8/finance/chart. Best-effort : peut échouer / bouger sans préavis.

Si crypto 404 sur Kraken ET Coinbase, retourne 404 symbol_not_found. Si Yahoo 404 ou indisponible, retourne 404 symbol_not_found ou 503 service_degraded (cf. circuit breaker §8.4). Le client doit utiliser la saisie manuelle dans ces cas.

Pas de fallback cross-asset : un symbole inconnu de la liste crypto n'est pas réessayé sur Yahoo (et vice-versa). Le client est explicite.

🔴 SECURITE+TECHNIQUE — Yahoo Finance n'a pas d'API publique stable et son ToS interdit la redistribution. Endpoints query1/query2.finance.yahoo.com non documentés, sujets à blocage IP/CAPTCHA. ToS interdit l'usage commercial et la redistribution. Proxying tous les premium via une IP VPS = kill-switch unique pour le feature payant + risque légal. Resolution : Soit (a) souscrire à un fournisseur licencié payant (Polygon, Alpha Vantage, Twelve Data, Finnhub) avec droit contractuel de proxy, soit (b) flagger explicitement source: 'yahoo' comme best-effort + circuit breaker + fallback provider documenté. Acter dans un ADR avant ship en payant. Ref : Yahoo Finance ToS sec. 7-8 (no commercial reuse)

🔴 SECURITE — Free tier CoinGecko interdit l'usage commercial / proxying. CoinGecko Demo gratuit interdit explicitement le commercial use et le proxy/redistribution ; seul le plan Demo/Pro payant avec API key le permet. Feature premium-payant sur free tier = breach ToS + risque de cutoff soudain. Resolution : Souscrire à CoinGecko Demo/Pro (API key en env), documenter le tier contractuel en §8, ajouter le header API-key serveur. Refléter le coût dans le pricing model. Ref : CoinGecko ToS — Free Plan restrictions

8.2 Headers sortants vers le provider

Vers les exchanges crypto (Kraken, Coinbase via CCXT) :

  • User-Agent: maximus-api/<version> (version interne, jamais transmise au client)
  • Accept: application/json

Vers Yahoo Finance (stocks, best-effort) : Yahoo bloque les requêtes sans User-Agent navigateur. Le serveur envoie un UA browser-like (Mozilla/5.0 (X11; Linux x86_64) ... Chrome/...) — c'est une exception explicite à la règle « UA fixe maximus-api ». Documenté dans ADR 0011.

Garanties communes : aucun header issu de la requête entrante n'est répercuté vers les providers. Implémentation via un client HTTP nu (fetch natif Node sans propagation), validée par le test §12.2.

8.3 IP source du provider

L'IP source vue par les providers est celle du VPS Maximus, mutualisée pour tous les utilisateurs premium. Cette propriété est garantie par la topologie réseau (pas de NAT transparent, pas de proxy SSL inverse vers les providers).

8.4 Circuit breaker (Yahoo best-effort)

Yahoo Finance étant un provider best-effort sans API officielle, un circuit breaker est obligatoire :

  • Compteur d'erreurs : sur les 60 dernières secondes, si count(5xx | 403 | timeout) >= 5, le breaker s'ouvre.
  • État ouvert : pendant 15 minutes, toutes les requêtes stocks retournent immédiatement 503 service_degraded avec retry_after: <secondes restantes>. Aucun appel sortant Yahoo.
  • Half-open : après 15 min, une seule requête tentée. Si succès, breaker fermé ; si échec, ouvert pour 15 min de plus.
  • Notification : à l'ouverture du breaker, log structuré level=warn + (optionnel) webhook Telegram / email à maxime2tremblay@protonmail.com.

Crypto via CCXT n'a pas de circuit breaker dédié — les exchanges sont stables, leurs erreurs sont rares et déterministes.

🔴 SECURITE — Single point of failure : un IP block VPS coupe le feature pour TOUS les premium. Mutualiser l'IP VPS est la promesse privacy, mais une seule sanction de Yahoo (qui voit du trafic commercial) bloque l'IP — kill-switch global qui touche 100% des utilisateurs payants en même temps. Resolution : Documenter dans un ADR le risque + budget pour un fallback provider rotatif. Ajouter un circuit breaker côté maximus-api : sur 5xx ou 403 répétés du provider, marquer automatiquement le service degraded et notifier (Telegram/email).

9. Garanties de logging et de privacy

Le serveur garantit par contrat :

  1. Pas de log conjoint (IP utilisateur, symbol). Les logs d'accès Traefik conservent les IP, mais le log applicatif des prix utilise hash(license_id, salt_serveur) à la place de toute info utilisateur.
  2. Pas de log de l'activation_token complet. Seulement le license_id extrait du payload après validation de signature.
  3. Le cache prix ne stocke aucune référence utilisateur — clé = (symbol, date), valeur = (price, currency, source, fetched_at).
  4. Aucun analytics, aucune télémétrie sur /v1/prices. Seuls les logs minimaux d'observabilité.

🟡 SECURITE — Logs Traefik (IP) + log applicatif (license_hash, symbol) → corrélation par timestamp casse §9.1. §9.1 promet « pas de log conjoint (IP, symbol) », mais Traefik enregistre (IP, ts, path?querystring) et l'app enregistre (license_hash, symbol, ts). Quiconque a accès aux deux logs (ou un backup co-localisé) corrèle par timestamp et reconstitue (IP, symbol). La garantie privacy est structurellement plus faible qu'annoncée. Resolution : (a) configurer Traefik pour stripper la querystring sur /v1/prices, OU (b) ne pas logger /v1/prices du tout côté Traefik (rely sur log app + stats privacy-preserving). Mettre à jour §9.1 pour refléter la garantie réelle. Ref : CWE-532 (Insertion of Sensitive Info into Log)

10. Sémantique de cache

10.1 Cache serveur

Type de date TTL
Date passée (< aujourd'hui UTC) 90 jours (les prix passés sont immuables, mais TTL fini = défense LRU)
Aujourd'hui (UTC) 5 minutes
Réponse 404 symbol_not_found 1 heure (TTL court séparé pour éviter pollution)

Implémentation : table Drizzle pricesCache dans la même DB Postgres que les licenses (cf. décision §0).

// src/db/schema.ts
export const pricesCache = pgTable("prices_cache", {
  symbol: text("symbol").notNull(),
  date: text("date").notNull(),  // YYYY-MM-DD
  price: numeric("price", { precision: 20, scale: 8 }),  // null si 404
  currency: text("currency"),
  source: text("source").notNull(),  // 'yahoo' | 'kraken' | 'coinbase'
  fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
}, (t) => [primaryKey({ columns: [t.symbol, t.date] })]);

Pas de FK vers licenses (privacy). Job nightly cleanup DELETE FROM prices_cache WHERE expires_at < now().

L'invalidation manuelle du cache n'est pas exposée par l'API.

Défenses anti-pollution :

  • Cap LRU implicite via TTL fini (90 jours max sur les passées)
  • 404s en TTL court (1h) sur table séparée pour éviter qu'un attaquant fill l'espace clé avec des symboles inexistants
  • Idéalement (optimisation v1.1) : allowlist de symboles connus refresh quotidiennement (catalogue Yahoo + crypto exchanges) — reject les inconnus en 404 avant tout appel provider

🟡 ARCHITECTURE+TECHNIQUE — Emplacement du cache non spécifié + maximus-api utilise Postgres (pas SQLite). §10 dit « cache local SQLite » dans l'ADR 0009 mais maximus-api est sur Postgres+Drizzle, pas SQLite. Pas de Redis non plus. Sans précision, soit on stocke en mémoire (perdu au restart Coolify), soit on persiste sans plan. Resolution : Ajouter §10.3 : « Cache implémenté via table Drizzle prices_cache(symbol, date PK, price, currency, source, fetched_at, expires_at) dans la même DB Postgres. Pas de FK à licenses (privacy). » Migration Drizzle nouvelle. Test d'intégration it('persiste à travers un restart').

🟡 SECURITE — Cache illimité avec TTL infini + regex symbole permissive → pollution de l'espace clé. TTL « infini » sur dates passées + regex ^[A-Za-z0-9.\-]{1,20}$ sans allowlist. Une licence premium compromise (ou plusieurs en parallèle) peut itérer ~36^20 symboles pour saturer le cache OU brûler le quota provider. Plusieurs machines par licence partagent le quota — abus coordonné dans le quota légal possible. Resolution : Cap LRU sur la taille du cache. 404s cachés en TTL court (1h max) avec LRU séparé. Idéalement : allowlist de symboles connus (catalogue Yahoo + CoinGecko refresh quotidien) — reject les inconnus en 404 avant tout appel provider. Ref : CWE-770 (Allocation of Resources Without Limits)

10.2 Cache client

Le client ne doit pas mettre en cache les réponses au-delà de la session courante :

  • Pas de stockage SQLite des prix retournés
  • Pas de localStorage ni IndexedDB
  • Le value calculé (quantity × unit_price) est stocké dans balance_snapshot_lines — c'est une valeur dérivée denormalisée, pas un cache de l'API
  • En mémoire de la session : OK pour la déduplication in-flight (cf. §6.2)

Cette contrainte protège contre l'extraction d'historique de consultation en cas de compromission de la machine cliente.

11. Versioning et évolutions

11.1 Changements rétrocompatibles autorisés en /v1/

  • Ajout d'un champ optionnel dans la réponse de succès
  • Ajout d'un nouveau code d'erreur (le client doit avoir un fallback sur les codes inconnus)
  • Ajout d'un header optionnel
  • Élargissement du quota

11.2 Changements non rétrocompatibles → /v2/

  • Renommage / suppression d'un champ
  • Changement du format d'enveloppe d'erreur
  • Changement du format d'auth
  • Restriction des paramètres acceptés

11.3 Coexistence

maximus-api peut servir simultanément /v1/ et /v2/ pendant une période de transition. Le client signale sa version par le path, pas par un header.

11.5 Migration /licenses/*/v1/licenses/* (en parallèle de cette spec)

Pour cohérence avec /v1/prices, les endpoints existants /licenses/* migrent vers /v1/licenses/* :

  • Phase 1 (avec ce milestone) : nouveau préfixe /v1/licenses/* exposé. Anciens /licenses/* deviennent aliases qui renvoient 308 Permanent Redirect vers /v1/licenses/*. Le client desktop continue à fonctionner sans modification.
  • Phase 2 (release simpl-resultat suivante, +30 jours) : le client desktop migre ses appels vers /v1/licenses/* directement. Header de réponse Deprecation (RFC 8594) sur les anciens paths.
  • Phase 3 (+60 jours) : les anciens paths retournent 410 Gone. Suppression du code legacy.

L'enveloppe d'erreur de /v1/licenses/* est aussi migrée vers {error: {code, message}} nesté pour cohérence. Mapping des erreurs existantes vers les codes nouveaux est documenté dans le README maximus-api.

11.4 Rotation de clé Ed25519 (cross-cutting)

🟡 SECURITE — Pas de header kid → la prochaine rotation Ed25519 force un redeploy complet. Le client Rust embed un PEM unique en constante (license_commands.rs:28) et le serveur signe sans kid dans le header JWT (crypto/ed25519.ts setProtectedHeader). La rotation 2026-04-25 (#49) a marché parce qu'aucune licence active n'existait ; la prochaine cassera toutes les licences actives jusqu'à update client. Pas de fenêtre d'overlap. Resolution : Ajouter kid au header JWT protégé. Ship le client Rust avec une map kid → PEM. Documenter une procédure de rotation avec fenêtre overlap 30 jours dans un nouvel ADR. Ref : RFC 7515 §4.1.4 (kid header)

12. Tests de conformité (références)

12.1 Côté client (simpl-resultat)

Tests unitaires obligatoires (vitest + vi.fn() sur fetch natif — pas de lib de mock externe ajoutée) :

  • it("envoie uniquement Authorization, Accept, User-Agent fixe") — privacy headers
  • it("retourne le price sur 200")
  • it("traduit chaque error.code en clé i18n")
  • it("respecte Retry-After sur 429 et 503")
  • it("ne réessaye pas sur 401, 403, 404, 400")
  • it("dédoublonne les requêtes in-flight identiques")
  • it("respecte le rate-limit local 1 req / 2s") — utilise vi.useFakeTimers()
  • it("respecte le plafond hard 100 req / session")

Le rate-limiter client + la dedup vivent dans src/services/balance.service.ts (section prices), conformément à la convention « 1 service par domaine » du projet.

🔴 TECHNIQUEmockito-rs n'existe pas comme crate. (a) Le crate Rust s'appelle mockito (sans suffixe -rs). (b) Le client de prix sera en TypeScript (fetch via Tauri vers maximus-api), donc Rust n'est pas le bon endroit pour ces tests. Cargo.toml de simpl-resultat ne contient aucun mock HTTP actuellement. Resolution : Préciser : tests en vitest côté TS avec msw ou vi.fn() sur fetch. Si volet Rust nécessaire (ex. tests Tauri command), utiliser mockito (sans -rs) ou wiremock ajouté à [dev-dependencies] du Cargo.toml.

12.2 Côté serveur (maximus-api)

Tests d'intégration obligatoires (vitest + nock pour mock outbound + app.request() natif Hono pour inbound) :

  • it("rejette toute requête sans Authorization → 401 missing_token")
  • it("rejette une signature invalide → 401 invalid_token")
  • it("rejette claims.product !== 'simpl-resultat' → 401 invalid_token")
  • it("rejette une licence non-premium → 403 premium_required AVANT tout appel provider")
  • it("appel sortant Yahoo n'inclut que User-Agent browser-like + Accept + Host") — assertion sur nock interceptor
  • it("appel sortant exchanges (Kraken/Coinbase) n'inclut que User-Agent maximus-api + Accept + Host")
  • it("logger.spy n'a jamais été appelé avec un payload contenant license_id ET symbol simultanément") — via wrapper src/logger.ts (pino) + vi.spyOn(logger, 'info')
  • it("retourne 404 symbol_not_found sans fallback cross-asset") — crypto inconnu ≠ tenter Yahoo
  • it("sert depuis le cache si disponible — vérifié par 0 appel sortant nock")
  • it("circuit breaker : ouvre après 5 erreurs Yahoo / minute → 503 service_degraded en réponse")
  • it("token-bucket Postgres : 31e requête en 1 minute → 429 rate_limit_exceeded")
  • it("retourne le bon shape d'erreur pour chaque status code")

🟡 TECHNIQUE — Test « jamais log (license_id, symbol) » non testable simplement. Requiert une infra d'inspection de logs (capture stdout, parsing fichier) que maximus-api n'a pas — pas de logger structuré (pino, winston) dans package.json. Resolution : Reformuler en assertion sur logger injectable : expect(loggerSpy).not.toHaveBeenCalledWith(stringContaining(licenseId) && stringContaining(symbol)). Introduire src/logger.ts (pino) avant l'implémentation et tester contre lui.

🟢 TECHNIQUE — Aucune lib HTTP mock prévue côté serveur. Les tests d'intégration mentionnent « supertest ou équivalent » mais aucune lib n'est dans devDependencies actuellement. Pour mocker Yahoo/CoinGecko il faut aussi nock ou msw/node. Resolution : Ajouter à maximus-api/package.json : nock (mock HTTP outbound) + @hono/testing ou supertest (test inbound). Préciser §12.2 : « nock pour les fournisseurs externes, app.request() de Hono pour l'API maximus. »

13. Décisions ouvertes (à trancher avant gel)

Cette section disparaît une fois le contrat marqué Statut: Stable.

  1. Crypto pricing : prix instantané ou close UTC ? Pour les snapshots de bilan datés, il faut un prix « représentatif » de la date. Décision proposée : close UTC 00:00 via les endpoints OHLC des exchanges (Kraken OHLC interval=1440, Coinbase candles granularity=86400) ; pour date == today, prix instantané toléré.
  2. actual_date est-il utile ou source de bugs ? Décision : garder car plus explicite que is_approximation: bool.
  3. Quota nuit / WE plus permissif ? Décision : non, garder les seuils plats. Simple à expliquer.
  4. Endpoint d'introspection du quota (GET /v1/quota) ? Décision : out du MVP. Ajouter en v1.x si demande utilisateur.

Décisions tranchées en §0 (ne sont plus ouvertes) : provider, infra rate-limit, versioning, enveloppe d'erreur, lib mocks, product claim binding.

Annexe A — Exemples complets

A.1 Succès

Requête :

GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1
Host: api.lacompagniemaximus.com
Authorization: Bearer <license-token>
Accept: application/json
User-Agent: simpl-resultat

Réponse :

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 28
X-RateLimit-Reset: 1714061520
Cache-Control: private, max-age=0

{
  "symbol": "AAPL",
  "date": "2026-04-25",
  "actual_date": null,
  "price": 173.45,
  "currency": "USD",
  "source": "yahoo",
  "fetched_at": "2026-04-25T14:32:11Z",
  "cached": false
}

A.2 Licence non-premium

Réponse :

HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8

{
  "error": {
    "code": "premium_required",
    "message": "Premium license required for price fetching"
  }
}

A.3 Rate-limit

Réponse :

HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714061580
Retry-After: 42

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded for this license",
    "retry_after": 42
  }
}

Annexe B — Mapping des codes d'erreur vers les clés i18n du client

À implémenter dans src/i18n/locales/{fr,en}.json sous balance.priceFetching.errors.* :

error.code Clé i18n FR EN
invalid_symbol errors.invalidSymbol « Symbole invalide » "Invalid symbol"
invalid_date errors.invalidDate « Date invalide » "Invalid date"
missing_param errors.missingParam « Paramètre manquant » "Missing parameter"
missing_token / invalid_token / expired_token errors.authFailed « Activation requise » "Activation required"
premium_required errors.premiumRequired « Abonnement premium requis » "Premium subscription required"
license_revoked errors.licenseRevoked « Licence révoquée — contactez le support » "License revoked — contact support"
symbol_not_found errors.symbolNotFound « Symbole inconnu — saisissez le prix manuellement » "Symbol not found — enter price manually"
rate_limit_exceeded errors.rateLimit « Trop de requêtes — réessayez dans {{seconds}}s » "Too many requests — retry in {{seconds}}s"
provider_unavailable / service_degraded / internal_error errors.serverUnavailable « Service indisponible — saisissez le prix manuellement » "Service unavailable — enter price manually"
service_degraded (circuit breaker Yahoo) errors.bestEffortDegraded « Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » "Price source temporarily unavailable — retry in {{minutes}} min or enter manually"

Règle générale côté UI : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.


Revision — Synthese

Date : 2026-04-26 | Experts : Securite, Architecture, Technique

Verdict

🔴 CRITIQUES A CORRIGER — La privacy posture et la défense en profondeur sont saines, mais le contrat repose sur des prémisses provider (Yahoo, CoinGecko free) en violation de ToS et sur une infra rate-limit/cache non encore en place. À débloquer avant le gel.

Resume

Expert 🔴 🟡 🟢 Points cles
Securite 3 4 1 ToS providers, rate-limit infra, replay token, log correlation, kid rotation
Architecture 3 4 1 Asymétrie versioning, enveloppe d'erreur, claim product, middleware partagée, cache storage
Technique 3 3 1 mockito-rs inexistant, quota CoinGecko vs free tier, log inspection infra, headers infra

Actions requises

🔴 Critiques — bloquantes pour le ship

  1. Provider de prix légitime — souscrire à un fournisseur licencié (Polygon / Alpha Vantage / Twelve Data / CoinGecko Pro) avec droit contractuel de proxy, OU acter un fallback documenté. Couvre Yahoo §8.1+§8.3 et CoinGecko §8.1+§6.1.
  2. Rate-limit partagé persistant — Redis ou token-bucket Postgres (atomique) avant que §6 ne quitte le draft. Généraliser rateLimit.ts pour accepter { keyFn, windows }.
  3. Enveloppe d'erreur cohérente — trancher entre flat (/licenses/* actuel) ou nesté (proposé). Migrer un côté avant de figer.
  4. Versioning cohérent — décider du préfixe /v1/ global (renommer /licenses/* en /v1/licenses/*) ou local au prices uniquement.
  5. Binding product — middleware vérifie claims.product === 'simpl-resultat'. Documenter en §7.1.
  6. Lib de tests réelle — remplacer mockito-rs (inexistant) par vitest + msw côté TS, ou mockito/wiremock côté Rust si nécessaire.
  7. Quota client réaliste — réduire 2000/j ou souscrire au tier provider qui le supporte. Cohérence quota promis ↔ capacité provider.

🟡 Améliorations recommandées

  1. activation_token : ajouter jti + revocation list, OU réduire TTL à 7-14j avec refresh-token.
  2. Cache serveur borné (LRU + cap), 404 TTL court, idéalement allowlist symboles.
  3. Stripper la querystring de /v1/prices dans Traefik (ou ne pas logger), pour tenir §9.1.
  4. kid dans le header JWT + map kid→PEM côté client (préparer la prochaine rotation Ed25519).
  5. Mettre à jour §7.2 — edition est déjà exposé par /licenses/verify.
  6. Middleware partagée licenseAuth.ts (étapes 1-6) au lieu d'inline dans le handler.
  7. Cache : table Drizzle prices_cache dans le Postgres existant (pas SQLite, pas en mémoire).
  8. Logger structuré (pino) injectable pour rendre testable « jamais log conjoint ».
  9. Préciser fake-timers + emplacement du rate-limiter client (priceService.ts).
  10. Client HTTP nu côté serveur pour les appels providers (éviter propagation des headers infra).

🟢 Suggestions

  1. X-Client-Major: 0.x pour permettre la dépréciation d'urgence sans casser k-anonymity.
  2. Architecture globale saine — convergence essentiellement sur l'alignement avec le code existant.
  3. Ajouter nock + @hono/testing aux devDependencies de maximus-api.