- Add frozen v2 /v1/prices API contract (docs/api-contract-prices.md) - Add ADR 0011: providers best-effort Yahoo (docs/adr/0011-providers-best-effort-yahoo.md) - Add dedicated ADR table section to docs/architecture.md (rows 0001-0011) Closes #154
41 KiB
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_URLou é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/pricesvs/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/v1sur 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
productdu JWT. Tous les endpoints existants requièrentproduct(schéma multi-produit) et le JWT activation porte une claimproduct./v1/pricesne la valide pas — un futur 2e produit pourrait hitter prices avec son propre token. Resolution : Validerclaims.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_tokendans 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-LanguageX-Forwarded-For,X-Real-IP, ou tout headerX-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 (fetchnatif Node, sans propagation d'headers) pour les appels sortants. Test : asserter que la liste de headers reçue par le mock provider == exactement['user-agent', 'accept', 'host'].
4. Réponse de succès (200 OK)
{
"symbol": "AAPL",
"date": "2026-04-25",
"actual_date": "2026-04-24",
"price": 173.45,
"currency": "USD",
"source": "yahoo",
"fetched_at": "2026-04-25T14:32:11Z",
"cached": true
}
4.1 Sémantique des champs
| Champ | Type | Description |
|---|---|---|
symbol |
string | Symbole tel que normalisé par le serveur (MAJUSCULES) |
date |
string | Date demandée (echo du paramètre) |
actual_date |
string | null | Si la date demandée n'a pas de cotation (week-end, jour férié pour les actions), date effective de la cotation retournée. null si actual_date == date. |
price |
number | Prix de clôture pour les actions, prix instantané pour les crypto. Précision : 4 décimales pour les actions, 8 pour les crypto. |
currency |
string | ISO 4217 (3 lettres). Exemples : USD, CAD, EUR. |
source |
string | yahoo ou coingecko. Indicatif uniquement — le client ne doit pas faire de logique conditionnelle dessus. |
fetched_at |
string | ISO 8601 UTC du moment où le prix a été récupéré du provider (différent de now si servi depuis cache). |
cached |
boolean | true si servi depuis le cache serveur. Indicatif uniquement — n'affecte pas la fraîcheur garantie (cf. §10). |
4.2 Headers de réponse (succès)
| Header | Toujours présent | Description |
|---|---|---|
Content-Type |
oui | application/json; charset=utf-8 |
X-RateLimit-Limit |
oui | Quota total sur la fenêtre courante (entier) |
X-RateLimit-Remaining |
oui | Quota restant (entier) |
X-RateLimit-Reset |
oui | Unix timestamp (secondes) du reset de quota |
Cache-Control |
oui | private, max-age=0 — le client ne doit pas mettre en cache HTTP |
5. Réponses d'erreur
5.1 Format d'enveloppe (toutes les 4xx/5xx)
{
"error": {
"code": "premium_required",
"message": "Premium license required for price fetching",
"retry_after": 30
}
}
| Champ | Type | Description |
|---|---|---|
error.code |
string | Code stable, lisible-machine. Snake_case. Liste fermée (cf. §5.2). |
error.message |
string | Message lisible-humain en anglais. Ne pas afficher tel quel à l'utilisateur — le client doit traduire en FR/EN via i18n à partir du code. |
error.retry_after |
number | absent | Présent uniquement sur 429 et 503. Secondes à attendre avant retry. |
🔴 ARCHITECTURE — Enveloppe d'erreur incohérente avec
/licenses/*. Les routes existantes/licenses/*retournent un format plat{ error: "string" }(parfois avecdetailsoumachines). 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/pricessur{ 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.tskeye par IP viaMapglobal (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éraliserrateLimit.tsen{ 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 sansvi.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é danssrc/services/priceService.ts(ou dansbalance.service.tssection prices). Tests vitest avecvi.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.
- Header
Authorizationprésent → sinon 401missing_token - Format
Bearer <token>correct → sinon 401invalid_token - Signature Ed25519 valide (clé publique embarquée côté serveur) → sinon 401
invalid_token - Token non expiré (
expclaim) → sinon 401expired_token - Claim
productdu JWT ='simpl-resultat'→ sinon 401invalid_token(un futur 2e produit aura sa propre clé ou son propre claim ; pas de cross-product) - Licence en DB existe et
is_revoked = false→ sinon 403license_revoked - Licence
edition = 'premium'→ sinon 403premium_required - 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_tokensansjti, durée 2 ans → fenêtre de replay non bornée. Tokens signés Ed25519 avecexp~2 ans, sansjti(vérifié danslicenseService.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/pricesenvoie ce token à chaque appel — multiplie la surface d'exfil. Resolution : (a) ajouterjti+ 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/quotaou autres endpoints premium arriveront. Resolution : Ajoutersrc/middleware/licenseAuth.ts(analogue àadminAuth.ts) qui populatec.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 —
editionest 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:216retourne déjàeditiondans la réponse verify. Resolution : Mettre à jour §7.2 : «editionest déjà exposé par/licenses/verifydepuis 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 libccxt. 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/quoteouv8/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.comnon 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 explicitementsource: '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_degradedavecretry_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 :
- Pas de log conjoint
(IP utilisateur, symbol). Les logs d'accès Traefik conservent les IP, mais le log applicatif des prix utilisehash(license_id, salt_serveur)à la place de toute info utilisateur. - Pas de log de l'
activation_tokencomplet. Seulement lelicense_idextrait du payload après validation de signature. - Le cache prix ne stocke aucune référence utilisateur — clé =
(symbol, date), valeur =(price, currency, source, fetched_at). - 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/pricesdu tout côté Traefik (rely sur log app + stats privacy-preserving). Mettre à jour §9.1 pour refléter la garantie réelle. Ref : CWE-532 (Insertion of Sensitive Info into Log)
10. Sémantique de cache
10.1 Cache serveur
| Type de date | TTL |
|---|---|
| Date passée (< aujourd'hui UTC) | 90 jours (les prix passés sont immuables, mais TTL fini = défense LRU) |
| Aujourd'hui (UTC) | 5 minutes |
Réponse 404 symbol_not_found |
1 heure (TTL court séparé pour éviter pollution) |
Implémentation : table Drizzle pricesCache dans la même DB Postgres que les licenses (cf. décision §0).
// src/db/schema.ts
export const pricesCache = pgTable("prices_cache", {
symbol: text("symbol").notNull(),
date: text("date").notNull(), // YYYY-MM-DD
price: numeric("price", { precision: 20, scale: 8 }), // null si 404
currency: text("currency"),
source: text("source").notNull(), // 'yahoo' | 'kraken' | 'coinbase'
fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
}, (t) => [primaryKey({ columns: [t.symbol, t.date] })]);
Pas de FK vers licenses (privacy). Job nightly cleanup DELETE FROM prices_cache WHERE expires_at < now().
L'invalidation manuelle du cache n'est pas exposée par l'API.
Défenses anti-pollution :
- Cap LRU implicite via TTL fini (90 jours max sur les passées)
- 404s en TTL court (1h) sur table séparée pour éviter qu'un attaquant fill l'espace clé avec des symboles inexistants
- Idéalement (optimisation v1.1) : allowlist de symboles connus refresh quotidiennement (catalogue Yahoo + crypto exchanges) — reject les inconnus en 404 avant tout appel provider
🟡 ARCHITECTURE+TECHNIQUE — Emplacement du cache non spécifié + maximus-api utilise Postgres (pas SQLite). §10 dit « cache local SQLite » dans l'ADR 0009 mais maximus-api est sur Postgres+Drizzle, pas SQLite. Pas de Redis non plus. Sans précision, soit on stocke en mémoire (perdu au restart Coolify), soit on persiste sans plan. Resolution : Ajouter §10.3 : « Cache implémenté via table Drizzle
prices_cache(symbol, date PK, price, currency, source, fetched_at, expires_at)dans la même DB Postgres. Pas de FK à licenses (privacy). » Migration Drizzle nouvelle. Test d'intégrationit('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
localStorageniIndexedDB - Le
valuecalculé (quantity × unit_price) est stocké dansbalance_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 renvoient308 Permanent Redirectvers/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éponseDeprecation(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 sanskiddans 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 : Ajouterkidau header JWT protégé. Ship le client Rust avec une mapkid → 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 headersit("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")— utilisevi.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-rsn'existe pas comme crate. (a) Le crate Rust s'appellemockito(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.tomlde simpl-resultat ne contient aucun mock HTTP actuellement. Resolution : Préciser : tests envitestcôté TS avecmswouvi.fn()surfetch. Si volet Rust nécessaire (ex. tests Tauri command), utilisermockito(sans-rs) ouwiremockajouté à[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("rejetteclaims.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 surnockinterceptorit("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 wrappersrc/logger.ts(pino) +vi.spyOn(logger, 'info')it("retourne 404 symbol_not_found sans fallback cross-asset")— crypto inconnu ≠ tenter Yahooit("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) danspackage.json. Resolution : Reformuler en assertion sur logger injectable :expect(loggerSpy).not.toHaveBeenCalledWith(stringContaining(licenseId) && stringContaining(symbol)). Introduiresrc/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
devDependenciesactuellement. Pour mocker Yahoo/CoinGecko il faut aussinockoumsw/node. Resolution : Ajouter àmaximus-api/package.json:nock(mock HTTP outbound) +@hono/testingousupertest(test inbound). Préciser §12.2 : «nockpour 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.
- 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
OHLCinterval=1440, Coinbasecandlesgranularity=86400) ; pourdate == today, prix instantané toléré. actual_dateest-il utile ou source de bugs ? Décision : garder car plus explicite queis_approximation: bool.- Quota nuit / WE plus permissif ? Décision : non, garder les seuils plats. Simple à expliquer.
- Endpoint d'introspection du quota (
GET /v1/quota) ? Décision : out du MVP. Ajouter en v1.x si demande utilisateur.
Décisions tranchées en §0 (ne sont plus ouvertes) : provider, infra rate-limit, versioning, enveloppe d'erreur, lib mocks, product claim binding.
Annexe A — Exemples complets
A.1 Succès
Requête :
GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1
Host: api.lacompagniemaximus.com
Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
Accept: application/json
User-Agent: simpl-resultat
Réponse :
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 28
X-RateLimit-Reset: 1714061520
Cache-Control: private, max-age=0
{
"symbol": "AAPL",
"date": "2026-04-25",
"actual_date": null,
"price": 173.45,
"currency": "USD",
"source": "yahoo",
"fetched_at": "2026-04-25T14:32:11Z",
"cached": false
}
A.2 Licence non-premium
Réponse :
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"error": {
"code": "premium_required",
"message": "Premium license required for price fetching"
}
}
A.3 Rate-limit
Réponse :
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714061580
Retry-After: 42
{
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded for this license",
"retry_after": 42
}
}
Annexe B — Mapping des codes d'erreur vers les clés i18n du client
À implémenter dans src/i18n/locales/{fr,en}.json sous balance.priceFetching.errors.* :
error.code |
Clé i18n | FR | EN |
|---|---|---|---|
invalid_symbol |
errors.invalidSymbol |
« Symbole invalide » | "Invalid symbol" |
invalid_date |
errors.invalidDate |
« Date invalide » | "Invalid date" |
missing_param |
errors.missingParam |
« Paramètre manquant » | "Missing parameter" |
missing_token / invalid_token / expired_token |
errors.authFailed |
« Activation requise » | "Activation required" |
premium_required |
errors.premiumRequired |
« Abonnement premium requis » | "Premium subscription required" |
license_revoked |
errors.licenseRevoked |
« Licence révoquée — contactez le support » | "License revoked — contact support" |
symbol_not_found |
errors.symbolNotFound |
« Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
rate_limit_exceeded |
errors.rateLimit |
« Trop de requêtes — réessayez dans {{seconds}}s » | "Too many requests — retry in {{seconds}}s" |
provider_unavailable / service_degraded / internal_error |
errors.serverUnavailable |
« Service indisponible — saisissez le prix manuellement » | "Service unavailable — enter price manually" |
service_degraded (circuit breaker Yahoo) |
errors.bestEffortDegraded |
« Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » | "Price source temporarily unavailable — retry in {{minutes}} min or enter manually" |
Règle générale côté UI : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.
Revision — Synthese
Date : 2026-04-26 | Experts : Securite, Architecture, Technique
Verdict
🔴 CRITIQUES A CORRIGER — La privacy posture et la défense en profondeur sont saines, mais le contrat repose sur des prémisses provider (Yahoo, CoinGecko free) en violation de ToS et sur une infra rate-limit/cache non encore en place. À débloquer avant le gel.
Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|---|---|---|---|---|
| Securite | 3 | 4 | 1 | ToS providers, rate-limit infra, replay token, log correlation, kid rotation |
| Architecture | 3 | 4 | 1 | Asymétrie versioning, enveloppe d'erreur, claim product, middleware partagée, cache storage |
| Technique | 3 | 3 | 1 | mockito-rs inexistant, quota CoinGecko vs free tier, log inspection infra, headers infra |
Actions requises
🔴 Critiques — bloquantes pour le ship
- 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.
- Rate-limit partagé persistant — Redis ou token-bucket Postgres (atomique) avant que §6 ne quitte le draft. Généraliser
rateLimit.tspour accepter{ keyFn, windows }. - Enveloppe d'erreur cohérente — trancher entre flat (
/licenses/*actuel) ou nesté (proposé). Migrer un côté avant de figer. - Versioning cohérent — décider du préfixe
/v1/global (renommer/licenses/*en/v1/licenses/*) ou local au prices uniquement. - Binding
product— middleware vérifieclaims.product === 'simpl-resultat'. Documenter en §7.1. - Lib de tests réelle — remplacer
mockito-rs(inexistant) parvitest + mswcôté TS, oumockito/wiremockcôté Rust si nécessaire. - 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
activation_token: ajouterjti+ revocation list, OU réduire TTL à 7-14j avec refresh-token.- Cache serveur borné (LRU + cap), 404 TTL court, idéalement allowlist symboles.
- Stripper la querystring de
/v1/pricesdans Traefik (ou ne pas logger), pour tenir §9.1. kiddans le header JWT + map kid→PEM côté client (préparer la prochaine rotation Ed25519).- Mettre à jour §7.2 —
editionest déjà exposé par/licenses/verify. - Middleware partagée
licenseAuth.ts(étapes 1-6) au lieu d'inline dans le handler. - Cache : table Drizzle
prices_cachedans le Postgres existant (pas SQLite, pas en mémoire). - Logger structuré (pino) injectable pour rendre testable « jamais log conjoint ».
- Préciser fake-timers + emplacement du rate-limiter client (
priceService.ts). - Client HTTP nu côté serveur pour les appels providers (éviter propagation des headers infra).
🟢 Suggestions
X-Client-Major: 0.xpour permettre la dépréciation d'urgence sans casser k-anonymity.- Architecture globale saine — convergence essentiellement sur l'alignement avec le code existant.
- Ajouter
nock+@hono/testingaux devDependencies de maximus-api.