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

598 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
```json
{
"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)
```json
{
"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.
> **🟡 SECURITE** — `activation_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.
> **🟡 ARCHITECTURE** — `edition` 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).
```typescript
// 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.
> **🔴 TECHNIQUE** — `mockito-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** :
```http
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
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
```
### A.2 Licence non-premium
**Réponse** :
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
```
### A.3 Rate-limit
**Réponse** :
```http
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
```
## 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.18.3 et CoinGecko §8.16.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**
8. `activation_token` : ajouter `jti` + revocation list, OU réduire TTL à 7-14j avec refresh-token.
9. Cache serveur borné (LRU + cap), 404 TTL court, idéalement allowlist symboles.
10. Stripper la querystring de `/v1/prices` dans Traefik (ou ne pas logger), pour tenir §9.1.
11. `kid` dans le header JWT + map kidPEM côté client (préparer la prochaine rotation Ed25519).
12. Mettre à jour §7.2 `edition` est déjà exposé par `/licenses/verify`.
13. Middleware partagée `licenseAuth.ts` (étapes 1-6) au lieu d'inline dans le handler.
14. Cache : table Drizzle `prices_cache` dans le Postgres existant (pas SQLite, pas en mémoire).
15. Logger structuré (pino) injectable pour rendre testable « jamais log conjoint ».
16. Préciser fake-timers + emplacement du rate-limiter client (`priceService.ts`).
17. Client HTTP nu côté serveur pour les appels providers (éviter propagation des headers infra).
**🟢 Suggestions**
18. `X-Client-Major: 0.x` pour permettre la dépréciation d'urgence sans casser k-anonymity.
19. Architecture globale saine convergence essentiellement sur l'alignement avec le code existant.
20. Ajouter `nock` + `@hono/testing` aux devDependencies de maximus-api.