feat(prices): commit /v1/prices contract + ADR 0011 (#154) #162
3 changed files with 705 additions and 0 deletions
89
docs/adr/0011-providers-best-effort-yahoo.md
Normal file
89
docs/adr/0011-providers-best-effort-yahoo.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# ADR 0011 — Providers de prix : exchanges directs (crypto) + Yahoo Finance best-effort (stocks)
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-04-26
|
||||||
|
- Successor of: ADR 0009 (architecture proxy) — précise les providers concrets
|
||||||
|
- Milestone: `spec-price-fetching` + `prices-proxy` (maximus-api)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0009 a établi qu'un proxy `maximus-api` mutualisé sert le price-fetching premium pour préserver la privacy (IP cachée, headers strippés). La revue spec du contrat `/v1/prices` (2026-04-26) a soulevé deux risques critiques :
|
||||||
|
|
||||||
|
1. **Yahoo Finance n'a pas d'API publique officielle.** Les endpoints `query1/query2.finance.yahoo.com` sont non documentés, leur ToS interdit l'usage commercial et la redistribution. Un IP-ban du VPS coupe le feature pour 100% des premium en même temps.
|
||||||
|
2. **CoinGecko free tier interdit le proxy commercial.** Seul le plan Demo/Pro payant (~129 $/mo Analyst) le permet contractuellement.
|
||||||
|
|
||||||
|
Quatre options ont été considérées (cf. revue inline `docs/api-contract-prices.md` §0) :
|
||||||
|
|
||||||
|
| Option | Coût/mois | Légalité commercial | Stabilité | Couverture |
|
||||||
|
|--------|-----------|---------------------|-----------|------------|
|
||||||
|
| Polygon.io Starter | 29 $ | ✅ contractuelle | ✅ haute | Stocks NYSE/NASDAQ + crypto |
|
||||||
|
| Tiingo Power + exchanges directs (crypto) | 10 $ | ✅ Tiingo, ✅ exchanges (public market data OSS-légal) | ✅ haute | Stocks + crypto |
|
||||||
|
| **Exchanges directs (crypto) + Yahoo best-effort (stocks)** | **0 $** | ⚠️ Yahoo ToS risqué (data publique mais redistribution interdite) ; ✅ exchanges | ⚠️ Yahoo fragile, exchanges stables | Stocks + crypto |
|
||||||
|
| Polygon Stocks + CoinGecko Pro | 158 $ | ✅ | ✅ | Best-of-both |
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Adopter l'option « tout-OSS / best-effort »** pour le MVP :
|
||||||
|
|
||||||
|
- **Crypto** : interrogation directe des exchanges majeurs via la lib `ccxt` (MIT). Les données de marché publiques (ticker, OHLC) sont gratuites et explicitement autorisées en commercial par les ToS de Kraken, Coinbase, Binance, etc. Implémentation initiale : Kraken d'abord, Coinbase en fallback si Kraken 404.
|
||||||
|
- **Stocks** : interrogation de Yahoo Finance via `query1.finance.yahoo.com/v7/finance/quote` (et v8 chart pour historique) avec un User-Agent navigateur. **Best-effort assumé** : peut échouer ou changer sans préavis.
|
||||||
|
|
||||||
|
Le client paie pour **l'infrastructure d'anonymisation**, pas pour la donnée. Cette distinction est centrale au modèle économique : la valeur premium = privacy (proxy mutualisé) + commodité (auto-fill), pas la donnée elle-même.
|
||||||
|
|
||||||
|
### Garde-fous obligatoires
|
||||||
|
|
||||||
|
1. **Label UX explicite** : sur les catégories de bilan en stocks, le bouton fetch affiche un badge « best-effort » + warning au premier usage. Sur crypto : pas de warning.
|
||||||
|
2. **Circuit breaker côté maximus-api** : seuil `5 erreurs Yahoo / 60 sec → breaker ouvert pour 15 min`. Notification automatique Telegram/email à `maxime2tremblay@protonmail.com`.
|
||||||
|
3. **Quota baissé** : 200 req/jour/licence (vs 2000 initialement). Suffit pour ~50 actifs × snapshot mensuel. Réduit l'incitation à abuser.
|
||||||
|
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
|
||||||
|
5. **Headers stripping rigoureux** : tous les headers entrants supprimés avant call sortant. Vers Yahoo : UA browser-like (`Mozilla/5.0 ...`). Vers exchanges : UA `maximus-api/<version>`.
|
||||||
|
6. **Logs séparés** : pas de log conjoint `(license_id, symbol)`. Implémentation via wrapper logger injectable (`src/logger.ts` pino).
|
||||||
|
|
||||||
|
### Plan de migration si Yahoo devient inutilisable
|
||||||
|
|
||||||
|
Triggers de migration vers un provider payant :
|
||||||
|
- Plus de 1 incident IP-ban / mois pendant 2 mois consécutifs, OU
|
||||||
|
- Plus de 30% des requêtes stocks tombent en circuit-breaker `service_degraded` sur 7 jours, OU
|
||||||
|
- Plainte légale formelle de Yahoo / Verizon Media.
|
||||||
|
|
||||||
|
Provider de bascule prioritaire : **Tiingo plan Power** (~10 $/mo, 1000 req/jour, ToS-clean).
|
||||||
|
- Implémentation : ajouter un module `providers/tiingo.ts` parallèle à `providers/yahoo.ts`. Switch via env var `STOCKS_PROVIDER=yahoo|tiingo`.
|
||||||
|
- Délai de bascule : ≤ 30 jours après déclenchement d'un trigger.
|
||||||
|
- Communication : entrée CHANGELOG explicite + email aux licences premium actives.
|
||||||
|
|
||||||
|
Si l'audience grandit (>500 licences premium actives), bascule vers Polygon Starter (~29 $/mo) considérée.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **0 $ de coût récurrent au MVP** — pas de cash burn avant que le produit ait validé son marché.
|
||||||
|
- **Crypto 100% OSS-légal** — voie pérenne, ne nécessitera jamais de migration.
|
||||||
|
- **Justification premium cohérente** — privacy comme valeur, pas la donnée. Aligne avec les principes du projet.
|
||||||
|
- **Plan de bascule pré-engagé** — pas pris au dépourvu si Yahoo devient hostile.
|
||||||
|
|
||||||
|
### Négatives / Risques actés
|
||||||
|
|
||||||
|
- **ToS Yahoo en zone grise** — le proxy commercial de leur data publique n'est pas formellement autorisé. Yahoo a déjà émis des cease-and-desist contre yfinance (lib Python). Risque légal théorique mais peu probable à petite échelle.
|
||||||
|
- **IP-ban probable à un moment ou l'autre** — Yahoo bloque les UA non browser et les patterns de requête trop réguliers. Le circuit breaker absorbe l'événement, mais le feature devient temporairement HS pour tous les premium.
|
||||||
|
- **Pas de garantie de stabilité de schéma** — Yahoo peut renommer un champ JSON sans préavis. Tests d'intégration `nock` ne capturent pas ça (mock = donnée figée).
|
||||||
|
- **Charge ops accrue** — il faudra surveiller le taux d'erreur Yahoo et réagir vite si dégradation.
|
||||||
|
|
||||||
|
### Neutre
|
||||||
|
|
||||||
|
- Première implémentation un peu plus complexe côté serveur (deux providers + circuit breaker), mais le code reste contained dans `src/providers/` et est testable.
|
||||||
|
|
||||||
|
## Suivi
|
||||||
|
|
||||||
|
- ADR à reviewer dans 6 mois (2026-10-26) ou plus tôt si trigger de migration déclenché.
|
||||||
|
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`.
|
||||||
|
- Issue de suivi : créer une issue `ops` dans `maximus-api` pour le monitoring continu une fois deployé.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Yahoo Finance ToS](https://legal.yahoo.com/us/en/yahoo/terms/index.html) — sec. 7-8 sur l'usage commercial
|
||||||
|
- [CoinGecko API ToS](https://www.coingecko.com/en/api/terms) — restrictions free tier
|
||||||
|
- [Kraken API public market data](https://docs.kraken.com/rest/) — explicite : free public tier, commercial OK pour data publique
|
||||||
|
- [CCXT (MIT)](https://github.com/ccxt/ccxt) — abstraction multi-exchange, lib OSS
|
||||||
|
- ADR 0009 — Architecture du proxy
|
||||||
|
- `docs/api-contract-prices.md` — Contrat figé `/v1/prices`
|
||||||
598
docs/api-contract-prices.md
Normal file
598
docs/api-contract-prices.md
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
# 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 eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
|
||||||
|
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
|
||||||
|
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
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
|
||||||
|
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**
|
||||||
|
|
||||||
|
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 kid→PEM 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.
|
||||||
|
|
@ -383,3 +383,21 @@ Fonctionnalités :
|
||||||
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
||||||
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
|
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
|
||||||
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
|
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
|
||||||
|
|
||||||
|
## Architecture Decision Records (ADRs)
|
||||||
|
|
||||||
|
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.
|
||||||
|
|
||||||
|
| # | Titre | Date | Statut |
|
||||||
|
|---|-------|------|--------|
|
||||||
|
| [0001](adr/0001-tauri-v2.md) | Choix de Tauri v2 comme framework desktop | 2024-01-01 | Accepted |
|
||||||
|
| [0002](adr/0002-useReducer-vs-redux.md) | useReducer plutôt que Redux | 2024-01-01 | Accepted |
|
||||||
|
| [0003](adr/0003-sqlx-migrations.md) | Migrations SQL inline via tauri-plugin-sql | 2024-01-01 | Accepted |
|
||||||
|
| [0004](adr/0004-aes-256-gcm-encryption.md) | Chiffrement AES-256-GCM pour l'export | 2024-01-01 | Accepted |
|
||||||
|
| [0005](adr/0005-multi-profile-db.md) | Multi-profils avec bases SQLite séparées | 2024-01-01 | Accepted |
|
||||||
|
| [0006](adr/0006-oauth-tokens-keychain.md) | Stockage des tokens OAuth via keychain | 2024-01-01 | Accepted |
|
||||||
|
| [0007](adr/0007-reports-hub-refactor.md) | Refactorisation du hub de rapports | 2024-01-01 | Accepted |
|
||||||
|
| [0008](adr/0008-modified-dietz-pour-rendement.md) | Modified Dietz pour le calcul de rendement | 2025-01-01 | Accepted |
|
||||||
|
| [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted |
|
||||||
|
| [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted |
|
||||||
|
| [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue