Documentation API
Tout ce qu'un logiciel client peut faire avec Smart-Evolution : enregistrer une machine, gérer ses licences, charger sa configuration et son code, récupérer des clés de chiffrement, un coffre-fort de secrets zéro-connaissance, et le self-service du portail. Sans mot de passe : chaque machine s'authentifie avec sa propre clé.
https://app.smart-evolution.com/apiIntroduction
L'API permet à une application cliente (le logiciel installé chez votre client) d'interagir avec la plateforme : licences, configuration d'équipement, code source, plugins et chiffrement. Le modèle est l'enrôlement machine : chaque poste est inscrit une fois et reçoit sa propre clé.
À gauche, les fonctions sont rangées par thème. Cliquez sur une section pour dérouler ses fonctions.
Configurer un poste
Comment un poste s'authentifie et se provisionne de façon transparente — aucun jeton manuel côté utilisateur.
- Connexion utilisateur (OIDC). App enregistrée comme client public (
is_public: true, sans secret). Flow authorization code + PKCE (S256), Authorityhttps://app.smart-evolution.com(auto-découverte/.well-known). Redirect loopback à port dynamique : enregistrer une foishttp://127.0.0.1/callback/(port ignoré). Scopesopenid profile email license. - Au login, l'
id_tokenporte déjà l'identité (email,name), l'organisation (org_id,org_name, tableauorgsavec rôle) et la licence (plan,entitlements,valid_until,licensed) — exploitable hors-ligne, sans appel supplémentaire. - Enrôlement automatique du poste. Si aucune clé machine locale :
POST /api/portal/machines/enrollavecAuthorization: Beareret un corps{ machine_uid, name }. Stocker lamachine_keyrenvoyée (keystore).status: pending→ afficher « en attente d'approbation ». - À l'exécution — deux secrets.
X-Machine-Key: dvk_…pour tout ce qui est machine (licences, config, manifeste de plugins, chiffrement) ;Authorization: Bearerpour les appels au nom de l'utilisateur. - Bon à savoir. Le client peut épingler la version de plugin installée (portail → Téléchargements) ; le loader installe exactement la version du manifeste (downgrade inclus). Le logiciel peut joindre une capture d'écran à un incident (
screenshot_base64).
Authentification
Trois façons de s'authentifier selon le contexte :
| Secret | En-tête / mécanisme | Usage |
|---|---|---|
enr_… jeton d'enrôlement | X-Enroll-Token | Une fois, pour inscrire une machine |
dvk_… clé machine | X-Machine-Key | Tous les appels d'un poste (licences, équipement, chiffrement) |
| Utilisateur du portail | Cookie de session (code courriel) | Le self-service du client (portail) |
Le jeton d'enrôlement est fourni au client depuis son portail. La clé machine est renvoyée une seule fois, à l'enrôlement. Les endpoints admin (staff) utilisent une session 2FA.
Enregistrement machine
Inscrire un poste et obtenir sa clé. À faire une seule fois, à l'installation.
Enrôler une machine
/api/license/enrollX-Enroll-TokenInscrit un poste et renvoie sa clé propre. Ré-enrôler avec le même machine_uid réémet la clé sur la même fiche (pas de doublon).
{
"machine_uid": "<empreinte matérielle stable>",
"name": "PC-USINE-07",
"location": "Usine Saint-Jean",
"asset_tag": "INV-1042"
}{
"machine_id": "uuid",
"machine_key": "dvk_…", // à stocker — affiché une seule fois
"status": "active" // ou "pending" si approbation requise
}Vérifier un jeton d'enrôlement
/api/license/enroll-token/checkX-Enroll-TokenVérifie sans effet de bord qu'un jeton est valide (n'enrôle rien). Utile pour confirmer qu'un jeton régénéré est bien inactif.
{ "valid": true, "client": "Acme inc." } // sinon { "valid": false, "client": null }Licences
Consommer un siège dans le pool du client. Mode flottant (concurrent, libération auto) ou nominatif (siège fixe par utilisateur).
Prendre une licence
/api/license/checkoutX-Machine-KeyPrend un siège dans le pool du produit. Idempotent : relancé sur la même machine, renvoie le même siège. Le serveur DÉCLARE le genre : kind="floating" (bail concurrent, expires_at présent) ou kind="fixed" (installée sur le poste — hors réseau OK, AUCUN expires_at dans la réponse). Perpétuité EXPLICITE : perpetual:true OU valid_until, jamais les deux, jamais une absence qui accorde un droit. Toute erreur de cette API a la forme { "error": "<code>" }. Champ OPTIONNEL key_verdict (own_machine | copied_from_another | unknown) : le constat du poste sur SA clé — absent, rien ne change ; mot inconnu, 422 qui le nomme. Il déclenche une vérification humaine, JAMAIS une révocation automatique.
{
"product_code": "mon-produit",
"user": "[email protected]" // optionnel (attribution assignée par usager)
}// bail (flottant)
{ "seat_id": "uuid", "lease_token": "…", "expires_at": "2026-06-24T16:00:00Z",
"product": "Mon produit", "mode": "floating", "kind": "floating",
"perpetual": false, "valid_until": "2027-01-01" }
// installée (fixe machine) — pas d'expires_at
{ "seat_id": "uuid", "lease_token": "…", "product": "Mon produit",
"mode": "named", "kind": "fixed", "perpetual": true }Renouveler le bail / sonder le siège
/api/license/heartbeatX-Machine-KeyGenre BAIL : prolonge le bail — à envoyer périodiquement (1–4 h) ; sans heartbeat pendant reset_hours (24 h), le siège est auto-libéré. Genre FIXE : aucun bail à prolonger et aucune auto-libération — servez-vous-en comme SONDE (ex. toutes les 30 min) pour apprendre une révocation : siège retiré → 409 seat_deprovisioned, stable et idempotent. Une panne réseau ne dit RIEN sur la révocation — seul le 409 positif fait foi.
{ "seat_id": "uuid", "lease_token": "…" }// bail
{ "expires_at": "2026-06-24T20:00:00Z", "kind": "floating", "perpetual": false, "valid_until": "2027-01-01" }
// fixe — pas d'expires_at
{ "kind": "fixed", "perpetual": true }Libérer la licence (bail)
/api/license/releaseX-Machine-KeyRend le siège au pool immédiatement. À appeler à la fermeture du logiciel — GENRE BAIL SEULEMENT : sur un siège fixe, release est REFUSÉ (le retrait d'une installation est un acte délibéré, voir deprovision).
{ "seat_id": "uuid", "lease_token": "…" }204 No Content
Déprovisionner (fixe)
/api/license/deprovisionX-Machine-Key + Bearer (jeton de geste)Retrait DÉLIBÉRÉ d'une installation fixe — en ligne obligatoire, jamais déclenché par une fermeture/mise à jour/redémarrage. Après, le poste reste SANS licence (re-checkout refusé 409 seat_deprovisioned tant que le siège n'est pas restauré au portail). EXIGE UNE AUTORISATION HUMAINE : le poste démarre un device flow (POST /api/oidc/device_authorization, scope "gesture:deprovision:<seat_id>") et affiche le code ; le DÉLÉGUÉ de l'organisation (ou le support SE) l'approuve depuis son navigateur ; le jeton obtenu (5 min, sans refresh, usage unique) part en Authorization: Bearer. La clé machine seule ne suffit plus — la lire sur le disque ne permet pas de déprovisionner.
Authorization: Bearer <jeton de geste>
{ "seat_id": "uuid", "lease_token": "…" }204 No Content
Se signaler sans licence
/api/license/unlicensed-reportX-Machine-KeyUn poste fixe révoqué NE SE BLOQUE PAS : il continue et se signale — le délégué de l'organisation (portail) et Smart-Evolution voient « roule sans licence depuis <date> ». since = instant ISO du PREMIER refus 409 confirmé (ancré côté poste) ; activity_count = mesure de travail réel produit depuis (entier opaque, remplacé à chaque déclaration, omis si indisponible). L'état s'efface dès qu'un checkout/heartbeat répond normalement.
{ "product_code": "mon-produit", "since": "2026-08-04T13:22:11Z", "activity_count": 340 }204 No Content
État de licence
/api/license/status?product_code=mon-produitX-Machine-KeyÉtat pour ce produit. 200 même sans licence (has_license:false) ; 404 uniquement si le product_code est inconnu.
{ "product": "Mon produit", "has_license": true, "seats_total": 5, "seats_used": 3,
"seats_available": 2, "valid_until": "2027-01-01", "is_active": true }
// sans licence → 200 { "has_license": false, "is_active": false, "reason": "no_entitlement" }
// produit inconnu → 404 { "error": "product_not_found" }Licences de l'organisation
/api/org/licensesJeton OIDC utilisateurLes licences actives de l'organisation de l'utilisateur connecté + leur occupation. S'authentifie avec le jeton OIDC utilisateur (Authorization: Bearer). Un délégué (admin d'org) voit la validité et les détenteurs de chaque siège ; tout autre utilisateur voit l'usage seul (nombres), sans identités ni validité. Utilisez le champ delegate de la réponse pour adapter l'affichage.
// délégué
{ "delegate": true, "licenses": [ {
"product_code": "evocore", "product_name": "EvoCore Inventory", "mode": "floating",
"seats": 5, "seats_used": 1, "seats_available": 4,
"valid_from": null, "valid_until": "2027-01-01",
"holders": [ { "machine_name": "Poste-1", "asset_tag": "A1", "user_label": "Alice", "last_seen_at": "…" } ] } ] }
// non-délégué → { "delegate": false, "licenses": [ { …, "holders": [], "valid_until": null } ] }Boutique — catalogue vitrine
/api/store/catalogJeton OIDC utilisateurTous les produits visibles pour l'org — possédés ET non possédés (actifs publics + ceux que l'org détient) — avec le contenu marketing pour la page « Découvrir ». Auth jeton usager, portée org auto (comme /api/org/licenses). owned = l'org a une licence ; trialable = un essai est offert pour l'utilisateur courant. product_code identique au plugin_key.
{ "products": [ {
"product_code": "evocore.admincrypt", "name": "EvoCore.AdminCrypt", "publisher": "Smart-Evolution",
"category_key": "securite", "category_label": "Sécurité", "category_label_en": "Security",
"description": "…HTML léger…", "description_en": "…", "tags": ["chiffrement","escrow"],
"pricing": { "model": "subscription", "price": 5.0, "currency": "CAD", "period": "month" },
"icon_url": "<url signée ou lucide:nom>", "screenshots": ["<url signée>"],
"product_url": "https://app.smart-evolution.com/produits/admincrypt",
"owned": true, "trialable": false, "trial_days": 0,
"latest_version": "1.4.0", "install_count": 128 } ] }model ∈ free|paid|subscription ; period ∈ month|year|once. owned est au niveau org ; trialable est calculé pour l'utilisateur courant (essai par utilisateur).
Boutique — démarrer un essai
/api/store/trialsJeton OIDC utilisateurAccorde à l'utilisateur son essai d'un produit (automatique quand trial_days > 0). L'essai est PAR UTILISATEUR : chaque usager de l'org a droit au sien. L'accès reste org-wide — une fois l'essai lancé, le produit apparaît dans GET /api/device/plugins jusqu'à valid_until, puis disparaît (filtre de validité). Miroir : POST /api/store/trials/release { product_code } pour annuler.
{ "product_code": "evocore.admincrypt" }200 → { "product_code": "…", "trial": true, "valid_until": "2026-08-15", "seats": 1 }
// erreurs (shape { "error": … }) :
409 { "error": "already_owned" } // l'org possède déjà une licence
409 { "error": "trial_already_used" } // cet utilisateur a déjà utilisé son essai
403 { "error": "not_trialable" } // produit sans essai (trial_days = 0)
404 { "error": "unknown_product" }Durée = trial_days du produit. À l'expiration, auto-libéré par la date (pas d'action client). Un 2ᵉ utilisateur peut lancer son propre essai même si un 1ᵉʳ l'a fait.
Équipement & code
Ce que le poste charge au démarrage : sa configuration, son code source assigné et le manifeste des plugins sous licence.
Charger la configuration
/api/device/configX-Machine-KeyConfiguration courante de l'équipement (valeurs + schéma typé), telle que réglée dans la plateforme.
{ "label": "prod", "values": { "luminosite": 80 },
"fields": [ { "key": "luminosite", "type": "int" } ] }Bootstrap d'un objet intelligent
/api/device/smart/{guid}GUID de l'objetEvoMatrix, EvoCam… : l'objet s'identifie par le GUID gravé dans sa mémoire à la fabrication (imprimé sur son étiquette) et lit d'un coup sa configuration + le contenu de son code source de démarrage (la version courante du code relié à son TYPE). configuration est un texte libre : une variable d'entrée pour un EvoMatrix (« Poste C », « 58 »), un document JSON pour un EvoCam (résolution, ISO, crops, ZMQPort…). reboot est ONE-SHOT : true est livré une seule fois puis remis à false — l'objet qui le voit redémarre.
{ "guid": "…", "family": "evomatrix", "type": "Afficheur heure",
"name": "Afficheur scie 1", "configuration": "Poste C", "location": "Usine 2",
"reboot": false,
"source": { "id": "…", "title": "…", "language": "python", "version": "1.0", "content": "…" },
"secrets": { "kpi_tenant_id": "…", "kpi_user": "…", "kpi_password": "…" } }secrets = les secrets applicatifs du CLIENT (ex. identifiants KPI), résolus et déchiffrés côté serveur depuis un coffre par client — ABSENTS de la source servie, du bundle et du portail client. Gardez-les EN MÉMOIRE, ne les écrivez jamais sur le volume de la carte. {} si le client n'en a aucun. Rotation : le staff modifie la valeur en console puis coche reboot sur l'objet — la nouvelle valeur arrive au bootstrap suivant, sans intervention physique. Un GUID inconnu, désactivé ou NON AFFECTÉ à un client répond le même 404 générique. Chaque bootstrap horodate last_seen_at (visible dans la console). Rapportez ce que l'objet EMBARQUE via les query params ?fw=<version CIRCUITPYTHON>&sw=<version du bundle logiciel>&platform=<texte> (ex. fw=8.0.4&sw=2.0.0&platform=CircuitPython pour une matrix ; fw=1.4&platform=Raspberry Pi Docker pour une caméra). CONVENTION : fw = l'INTERPRÉTEUR (CircuitPython — c'est lui qui gouverne le code source servi et la compat des bundles), sw = votre bundle applicatif — enregistrés à chaque bootstrap, visibles en console et imprimés sur l'étiquette (une 1.0 et une 2.0 n'ont pas les mêmes fonctionnalités). Le CODE SERVI peut d'ailleurs dépendre de la version rapportée : un type peut relier un code différent par préfixe de version (fw=2.1.3 → la règle « 2.1 » bat « 2 », sinon le code par défaut du type). Plafond : 120 requêtes / 15 min par IP. Appelez ce endpoint au DÉMARRAGE, puis faites votre poll (ex. aux 60 s) sur GET /api/device/smart/{guid}/status — LÉGER, sans le contenu du code : { reboot, source_version, type_name } (plafond 1200/15 min par IP, reboot one-shot consommé là aussi) ; re-téléchargez le bootstrap seulement si reboot est true ou si source_version change. GET /api/device/smart/time?tz=America/Toronto donne l'heure locale du serveur pour régler le RTC — clés PascalCase { Year, Month, Day, Hour, Minute, Second, DayOfWeek (dimanche=0), DayOfYear, IsDaylightSavingTime }.
Catalogue de firmwares & logiciels
/api/device/smart-provision/firmwares · /softwareX-Machine-KeyLa source de vérité des firmwares d'objets intelligents (CircuitPython .uf2), hébergés sur notre CDN. Filtres family et board_model. Chaque entrée porte Sha256 + Signature — la MÊME mécanique d'attestation que le manifeste de plugins (JWS RS256 vérifiable contre /.well-known/jwks.json, purpose firmware-attestation, claims board/ver/file/sha256).
{ "Firmwares": [ { "Family": "evomatrix", "BoardModel": "matrixportal_m4",
"Version": "8.0.4", "FileName": "adafruit-…-8.0.4.uf2", "Sha256": "3a7f…",
"Signature": "eyJhbGciOiJSUzI1NiJ9…", "SizeBytes": 1048576, "IsCurrent": true,
"Url": "https://cdn…?token=…" } ] }ATTESTATION OBLIGATOIRE, sans mode « non attesté » : un firmware part sur du matériel (une erreur brique une carte, pas un processus) — SE ne publie JAMAIS d'entrée sans Sha256+Signature, et le module de flash doit refuser net si la vérification échoue (recalculer le SHA-256 des octets téléchargés == Sha256, puis valider la Signature et ses claims). Url est signée 1 h — re-listez plutôt que de la stocker. IsCurrent = la version proposée au flash pour ce modèle de carte. MÊME MÉCANIQUE pour les BUNDLES APPLICATIFS (.zip, le code EvoMatrix lui-même) : GET …/software → { "Software": [ … ] }, purpose software-attestation, courant indépendant par type — et le même BoardModel (un bundle M4/ESP32-SPI n'est pas un bundle S3/Wi-Fi natif). INGESTION AUTOMATIQUE : les champs d'une entrée software (BoardModel, Version, FwPrefix) sont lus du MANIFEST.json DU BUNDLE à l'envoi (clés board_model, software_version, fw_prefix) — une saisie contradictoire est refusée : la valeur vient de l'artefact signé. COMPATIBILITÉ CIRCUITPYTHON : chaque entrée software porte FwPrefix ("8", "10", null = tout — les .mpy changent de format entre majeures) et le courant est indépendant PAR compat ; passez ?fw=<version flashée> pour ne recevoir que les bundles compatibles (match par segments : "1" ne couvre jamais 10.x). Séquence sûre : flashez le firmware courant, puis demandez le software avec ce fw.
Profil Wi-Fi de l'objet en pose
/api/device/smart-provision/{guid}/wifiX-Machine-KeyLe profil Wi-Fi de L'OBJET EN COURS DE POSE — un appel SÉPARÉ et délibéré, fait au moment d'écrire secrets.py, jamais dans une liste. Résolution : le SITE de l'objet (un client a plusieurs sites), sinon le profil PAR DÉFAUT de l'organisation (une ligne explicite, pas un repli sur « le premier »), sinon 404 nommé — et le module doit REFUSER la pose.
{ "Ssid": "SCIERIE-PROD", "Password": "…", "Extra": "{"static_ip": "10.0.0.50"}", "Site": "Usine 2" }C'est un COFFRE : mots de passe chiffrés au repos, JAMAIS relus — la console SE remplace, et le DÉLÉGUÉ du client remplace lui-même celui de ses sites depuis son portail (onglet Objets) quand son mot de passe change. Chaque livraison à un poste est JOURNALISÉE (quel poste, quel objet, quand — visible en console). Cloisonné : un poste ne tire que le profil d'un objet de SA propre organisation. Ne journalisez jamais la réponse côté module. Plafond : 60 requêtes / 15 min par IP.
Provisionner les objets intelligents
/api/device/smart-provision · POST /{guid}/installedX-Machine-KeyPour le module EvoCore d'initialisation des EvoMatrix. GET liste les objets de l'ORGANISATION du poste (filtres family, installed — installed=false = créés en console mais pas encore posés : l'opérateur CHOISIT dans la liste au lieu de recopier un GUID). POST /{guid}/installed marque la pose une fois le bootstrap vérifié, en joignant le n° de série SILICIUM de la carte (traçabilité matériel — la créance reste le GUID).
POST { "hardware_serial": "ECCABCAE4854375320…", "workstation": "Poste scie" }[ { "guid": "…", "family": "evomatrix", "name": "…", "type_name": "…",
"location": "…", "installed": false, "hardware_serial": null } ]Le n° de série est normalisé en MAJUSCULES et RÉASSIGNABLE : remplacer une carte morte re-poste installed avec le nouveau numéro sur le MÊME objet — l'historique et la date de mise en service d'origine survivent (chaque pose est journalisée, visible en console). La date de mise en service se fixe au PREMIER marquage. CLOISONNEMENT : un poste voit sa propre organisation — sauf le BANC Smart-Evolution (machines de l'org interne), qui provisionne toutes les orgs (?client_id= pour filtrer). SÉQUENCE D'ESSAI : GET /{guid}/presence (léger, fait pour le poll — 2400/15 min/IP) rend { LastSeenAt, FirmwareVersion (CircuitPython, ?fw=), SoftwareVersion (bundle, ?sw=), Platform, LastSeenIp } = le dernier bootstrap RÉEL de la carte (rapporté par elle-même ; l'IP distingue « démarré sur le banc » d'« ailleurs ») ; GET /bench-wifi rend le profil Wi-Fi par défaut de l'org DU POSTE (l'essai se fait sur NOTRE réseau avant d'écrire le Wi-Fi client — même coffre, même journal). ⚠ Comme {guid}/wifi, la réponse contient le MOT DE PASSE en clair : n'imprimez jamais ce corps brut dans un terminal ou un journal — même une sonde de diagnostic le divulguerait.
Charger le code source
/api/device/sourceX-Machine-KeyCode source assigné à l'équipement (ou partagé au client), version courante incluse.
[ { "id": "uuid", "title": "Boot", "language": "python", "version": "1.0", "content": "…" } ]Manifeste de plugins
/api/device/plugins?host=evocoreX-Machine-KeyPortée par logiciel hôte via ?host= (evocore | smartframework) : chaque logiciel ne reçoit QUE ses propres plugins — EvoCore ne liste jamais ceux de SmartFramework et vice-versa. Le paramètre est optionnel et vaut evocore par défaut (les appels EvoCore existants, sans host, sont donc inchangés). Un host inconnu renvoie 400 (pas une liste vide silencieuse). Les plugins-produits sous licence active du client PLUS la clôture transitive de leurs dépendances (les librairies partagées, Type=Library, non-licenciées, arrivent aussi, même si leur propre host diffère). Chaque entrée porte PluginKey (= code produit, identifiant stable) et Dependencies. IconBaseUrl + PluginIconUrl et PluginBaseUrl + PluginFile. Chaque version porte aussi Sha256 (intégrité) et Signature (authenticité). Champs boutique additifs (ignorez l'inconnu) : CategoryKey + CategoryLabel/_en (taxonomie stable), Tags[], Publisher, Description/_en (peut contenir du HTML), Pricing { Model: free|paid|subscription, Price, Currency, Period }, InstallCount, Screenshots[] (URLs signées), ReleaseNotes (notes de la version servie).
{
"IconBaseUrl": "…/icon/", "PluginBaseUrl": "…/plugins/",
"Plugins": [
{ "PluginName": "EvoCore Inventory", "PluginKey": "evocore-inventory",
"PluginFile": "evocore-inventory.dll", "Sha256": "3a7f9c…", "Signature": "eyJhbGciOiJSUzI1NiJ9…",
"MajorVersion": 2, "MinorVersion": 1, "BuildVersion": 0,
"TypePlugin": "Plugin", "MinVersion": 5,
"Dependencies": [ { "Name": "MBA Data", "Key": "mbadata", "MinVersion": 10203 } ] },
{ "PluginName": "MBA Data", "PluginKey": "mbadata", "PluginFile": "mbadata.dll",
"MajorVersion": 1, "MinorVersion": 2, "BuildVersion": 3,
"TypePlugin": "Library", "MinVersion": 5, "Dependencies": [],
"CategoryKey": "data", "CategoryLabel": "Données", "Tags": ["etl"],
"Publisher": "Smart-Evolution", "Pricing": { "Model": "subscription", "Price": 15.0, "Currency": "cad", "Period": "month" },
"InstallCount": 42, "Screenshots": ["https://cdn…"], "ReleaseNotes": "Nouveautés v1.2.3" }
]
}Dependencies[] = les modules requis : { Name (affichage), Key (= PluginKey du module cible, joignez là-dessus), MinVersion }. MinVersion est un entier compact major*10000 + minor*100 + build (ex. 10203 = 1.2.3 ; 0 = toute version). La clôture étant calculée côté serveur, le poste n'a qu'à trier les arêtes en ordre topologique puis télécharger. Une librairie-dépendance se télécharge sans siège de licence — être dans le manifeste = être autorisé. La version servie par plugin est la courante, sauf si le client en a épinglé une (POST /api/portal/plugins/{product_id}/version). Vérification : recalcule le SHA-256 du binaire téléchargé (doit == Sha256, intégrité), puis vérifie Signature — un JWS RS256 liant code+version+fichier+hash — contre /.well-known/jwks.json comme un token OIDC (claims purpose=plugin-attestation, sha256, code, ver ; authenticité + anti-substitution).
Maître du bois (référentiel CSA O86)
/api/device/lumber-masterX-Machine-KeyLe référentiel normatif du bois, par GÉNÉRATIONS. Le poste envoie ?since=N (la génération qu'il détient) et reçoit up_to_date, un delta, ou le complet (génération inconnue → complet, jamais deviner). Il ne voit QUE des instantanés publiés, jamais le brouillon admin. AUCUN prix ni fournisseur ne traverse cette API — ils n'existent pas dans les tables ; le magasinage viendra un jour comme une ressource d'OFFRES distincte. Et jamais de k_zc : il dépend de la longueur du membre et se calcule au design — servi ici, il serait faux pour toute autre portée, en silence.
{
"generation": 3, "mode": "delta",
"standards": [ { "standard_id": "o86-19", "label": "CSA O86-19" } ],
"sections": [ { "section_id": "2x4", "nominal_code": "2x4", "thickness_mm": "38.000", "width_mm": "89.000" } ],
"classes": [ { "class_id": "spf-msr-2100fb-1.8e", "species_group": "SPF", "grading_method": "mechanical",
"designation": "MSR 2100Fb-1.8E", "finger_jointed": false, "source": "master" } ],
"strengths": [ { "standard_id": "o86-19", "class_id": "spf-msr-2100fb-1.8e", "property": "f_b",
"value_mpa": "14.500000", "is_approximate": false } ],
"size_factors": [ { "standard_id": "o86-19", "thickness_mm": "38.000", "width_mm": "89.000",
"factor": "k_zb", "value": "1.7000" } ],
"removed": { "strengths": [ { "standard_id": "o86-14", "class_id": "spf-msr-2100fb-1.8e", "property": "e05" } ] }
}SÉRIALISATION : les décimaux sont des CHAÎNES — un nombre JSON EST un float64 par spécification, donc le mauvais porteur pour un DECIMAL(18,6) ; la chaîne est le seul moyen que la valeur arrive telle qu'elle est partie (⚠ lisez-la en INVARIANT, jamais dans la culture de l'usager : « 1,5 » lu en fr-CA a déjà enregistré 15). Un décimal sort TOUJOURS en chaîne, sans exception — une valeur ronde sort « 1234.000000 », jamais le nombre 1234 : le champ n'a donc jamais deux types JSON selon la donnée. L'ÉCHELLE EST FIXE, imposée par la colonne et non par la valeur (résistances 6 décimales, facteurs 4, dimensions 3), donc vos tests peuvent comparer les chaînes telles quelles. En ENTRÉE, une valeur ambiguë est REFUSÉE, jamais devinée : ni virgule, ni espace (même fine), ni apostrophe, ni soulignement (« 1_234.5 » vaut 1234.5 en Python — piège réel), ni exposant, ni nombre JSON. Les booléens sont de VRAIS booléens JSON, jamais 0/1 : un entier admettrait « 2 » ou « -1 », qui ne veulent rien dire. PROPRIÉTÉS (ensemble fermé) : f_b flexion · f_v cisaillement · f_t traction ∥ · f_c compression ∥ · f_cp compression ⊥ · e module moyen — les SIX de la substitution ; plus e05 (module au 5e centile), portée à part et JAMAIS rangée sous « e ». FACTEURS : k_zb, k_zv, k_zt, k_zcp — clés sur les DIMENSIONS RÉELLES, jamais sur un section_id (deux sections désignent la même planche ; c'est la taille physique qui décide). ABSENTE = AUCUNE LIGNE : jamais zéro, jamais héritée de l'édition voisine — un matériau présent en O86-19 et absent en O86-14 n'a simplement pas de ligne, et la lecture doit refuser en nommant ce qui manque. is_approximate porte le « ~ » du tableau source : c'est un fait, pas une typographie. `removed` (mode delta seulement) nomme ce qui a DISPARU — un poste qui ne verrait que les ajouts garderait une valeur morte. ÉDITION ≠ GÉNÉRATION : l'édition dit à quelle norme une valeur appartient, la génération dit quand nous l'avons publiée.
Provisionnement des postes
Distribuer des secrets de configuration (mot de passe de base, jetons) à des dizaines de postes sans les saisir un par un. Le principe : le site admet, la plateforme enregistre. L'EvoServer du client est l'autorité locale — il enveloppe et distribue en réseau local, donc hors ligne ; nous ne voyons jamais un secret en clair et ne détenons aucune clé privée.
Un secret logique = N enveloppes : le mot de passe « commun » est chiffré séparément pour chaque poste, sous SA clé publique. Un poste cloné ne peut pas ouvrir l'enveloppe du voisin.
Qui suis-je, et chez qui ?
/api/device/whoamiX-Machine-KeyL'identité du poste ET de son organisation. À appeler en premier dans tout diagnostic : sans elle, un poste authentifié par clé machine ne peut pas NOMMER l'organisation à laquelle il appartient — on mesure, on conclut, et on ne sait pas de qui on parlait. Donne aussi les faits qui répondent aux questions les plus fréquentes : suis-je approuvé, ai-je publié ma clé, combien de secrets m'attendent.
{
"organisation": { "id": "uuid", "name": "Scierie du Nord",
"culture": "fr-CA", "currency": "CAD" },
"machine": { "id": "uuid", "name": "Poste scie 2", "status": "active",
"enrolled_at": "…", "public_key_id": "2f2f886f…",
"secrets_waiting": 3 }
}Publier la clé publique du poste
/api/device/public-keyX-Machine-KeyLe poste publie SA clé publique — c'est ce qui permet de chiffrer POUR LUI et pour lui seul. La privée ne doit JAMAIS quitter son matériel (TPM/DPAPI) : c'est elle qui fait qu'un disque cloné ne suffit pas. RSA uniquement, 2048 | 3072 | 4096 (EC refusé : les enveloppes utilisent RSA-OAEP). Idempotent ; une nouvelle clé remplace la courante et horodate — un PC réinstallé a une nouvelle paire, et ça doit se voir.
{ "public_key": "<SPKI DER encodé en base64>" }{ "key_id": "2f2f886f8bff31ce", "published_at": "2026-08-21T18:22:00Z" }key_id = hex(SHA256(SPKI)[0..8]) — vérifié par deux implémentations indépendantes. Refus : 422 { "error": "invalid_public_key", "detail": "taille de clé refusée (1024 bits) — attendu 2048, 3072, 4096" } — le motif est NOMMÉ, un installateur ne doit pas deviner.
Lire les clés publiques du site
/api/device/site-public-keysX-Machine-KeyLes clés publiques des postes ACTIFS de la même organisation — lues par l'EvoServer pour envelopper à destination de chacun. Ne rend que du public. Un poste révoqué au portail DISPARAÎT de cette liste : c'est par là que passe la révocation, sans annonce séparée.
{ "machines": [ { "machine_id": "uuid", "name": "Poste scie 2",
"key_id": "2f2f886f…", "public_key": "<base64>", "published_at": "…" } ] }Déposer des enveloppes de secrets
/api/device/config-secretsX-Machine-KeyL'EvoServer dépose les enveloppes de son site. Idempotent : même valeur = 0 écriture, valeur changée = version + 1. Les refus sont NOMMÉS et JAMAIS PARTIELS — un dépôt à moitié accepté ferait croire le site provisionné.
{ "envelopes": [ { "machine_id": "uuid",
"scope": "company|workstation|user",
"key": "secret.evocore.database.password",
"enc_value": "<base64 de l'enveloppe SEC1 complète>",
"key_id": "2f2f886f8bff31ce" } ] }{ "written": 1, "received": 1 }FORMAT D'ENVELOPPE « SEC1 », figé : base64( "SEC1"[4o] | uint16BE L | RSA-OAEP-SHA256(clé publique du poste, aesKey 32o) | nonce[12o] | AES-256-GCM(secret, AAD="SEC1", tag inclus) ). L = longueur de la clé emballée (256 o sous RSA-2048), donc le nonce commence à 4+2+L. ⚠ HYBRIDE OBLIGATOIRE : RSA direct ne porte que ~190 octets, une chaîne de connexion SQL en fait 250+. ⚠ « SEC1 » EST AUTHENTIFIÉ (données associées du GCM), pas seulement préfixé — les deux bords doivent passer exactement ces 4 octets. ⚠ TOUT NOM DE SECRET DOIT COMMENCER PAR « secret. » (422 missing_secret_prefix) : c'est le NOM qui dit où la valeur vit, et c'est ce qui rend impossible qu'une même clé existe aussi en clair dans les réglages. ORDRE DE VALIDATION : format → portée → NOM PRÉFIXÉ → poste → fraîcheur du key_id ; donc stale_key_id ne se déclenche que sur une enveloppe BIEN FORMÉE. Refus : 422 unknown_envelope_version · 422 unknown_scope · 404 unknown_machine · 409 stale_key_id (avec received et current — le poste a régénéré sa paire : relisez /site-public-keys et re-enveloppez). ENVOYEZ TOUJOURS key_id, même s'il est optionnel : sans lui la garde ne peut pas jouer et l'erreur ne se verrait qu'à l'ouverture, sur le plancher.
Lire ses secrets
/api/device/config-secretsX-Machine-KeyLe poste lit LES SIENNES, jamais celles d'un voisin — même pour la même clé logique, même dans la même organisation. Il les déchiffre avec sa privée locale.
{ "machine_id": "uuid", "secrets": [ { "scope": "company",
"key": "secret.evocore.database.password", "enc_value": "<base64 SEC1>",
"key_id": "2f2f886f…", "version": 2 } ] }SANS CASCADE : company/x et workstation/x sont DEUX secrets distincts, jamais l'un le défaut de l'autre. Une valeur fausse doit avoir une seule origine possible.
Lire les réglages (en clair)
/api/device/settings?scope=company|workstation|user&subject=<uuid>X-Machine-KeyLes réglages NON SECRETS d'une portée : langue, unités, disposition, préférences d'un opérateur. C'est le pendant EN CLAIR de config-secrets — même vocabulaire de portées, stockage tout autre. Ne confondez pas les deux : ce qui est ici est lisible par la plateforme, donc rien de sensible n'y a sa place. Versionné et jamais perdu : sans le paramètre version vous recevez la DERNIÈRE, avec lui une version précise.
{ "scope": "user", "version": 3,
"payload": { "langue": "fr-CA", "unites": "metrique" },
"created_at": "2026-08-23T14:02:00Z" }Portée jamais réglée = 200 avec version 0 et payload vide — ce n'est PAS une erreur, c'est « rien n'a encore été écrit ». scope=user EXIGE subject (l'identifiant de l'usager), sinon 422 subject_required — et cet usager DOIT appartenir à l'organisation du poste, sinon 404 unknown_user (un identifiant n'est pas un secret : il circule dans les jetons et les adresses du portail). scope=workstation exige que le poste soit rattaché à un poste de travail, sinon 409 no_workstation. Portée inconnue : 422 unknown_scope.
Écrire des réglages
/api/device/settingsX-Machine-KeyÉcrit une NOUVELLE VERSION — append-only. C'est le versionnage qui rend « le dernier qui écrit gagne » acceptable : rien n'est écrasé, l'historique reste lisible. La portée company est REFUSÉE ici (403 company_scope_read_only) : elle fait foi et s'édite au portail, par le délégué — un poste ne redéfinit pas la règle commune.
{ "scope": "workstation|user", "subject": "<uuid si scope=user>",
"payload": { "langue": "fr-CA" } }{ "scope": "user", "version": 4 }⚠ UN NOM COMMENÇANT PAR « secret. » EST REFUSÉ ICI (422 reserved_secret_prefix, avec le CHEMIN complet de chaque nom fautif, sous-objets et tableaux compris) : ce préfixe est réservé aux enveloppes chiffrées, et c'est cette symétrie qui garantit qu'une clé ne peut jamais exister des deux côtés. ⚠ AUCUN SECRET dans payload, même sous un nom anodin. Un secret déposé ici serait lisible par la plateforme, et surtout : restauré sur un AUTRE poste il resterait valide, alors qu'une enveloppe SEC1 ne s'ouvre que chez son destinataire. Pour un mot de passe, utilisez config-secrets. 409 version_conflict si deux postes écrivent la même portée exactement en même temps — réessayez, le numéro de version est reconstruit.
Sauvegarder les secrets du site
/api/device/site-secret-backupX-Machine-KeyLe filet : l'EvoServer dépose son jeu de secrets chiffré sous la clé d'ORGANISATION usage=secrets (déposée par le délégué, dont nous n'avons pas la privée). Si le serveur meurt, on restaure au lieu de retaper cent configurations. APPEND-ONLY : une nouvelle sauvegarde n'écrase jamais la précédente — une corrompue ne doit pas emporter la dernière bonne.
{ "enc_blob": "<jeu de secrets chiffré>", "org_key_id": "…", "note": "après ajout scie 3" }{ "version": 2, "org_key_id": "…", "current_org_key_id": "…" }⚠ 409 no_org_secrets_key si le client n'a PAS déposé sa clé d'org « secrets » : accepter un blob que personne ne pourra jamais ouvrir donnerait un faux sentiment de sécurité. Comparez current_org_key_id au vôtre : s'ils diffèrent, la clé a tourné et vos anciennes sauvegardes ne s'ouvriront qu'avec l'ancienne privée.
Signaler une grâce de module
/api/device/module-graceX-Machine-KeyUne licence de MODULE révoquée laisse 21 jours de grâce au poste, puis le module ne se lance plus. Le poste signale ses CONSTATS — kind=started (entrée en grâce), expired (échéance), cleared (licence rétablie pendant la grâce) — pour que le staff appelle le client AVANT la coupure. L'épisode est ancré sur started_at, LA DATE DU POSTE (son premier constat), jamais celle du serveur.
{ "product_code": "evocore-crypt", "kind": "started",
"started_at": "<premier constat du poste>", "ends_at": "<started + 21 j>" }{ "status": "in_grace", "episodeId": "…" }IDEMPOTENT par (machine, product_code, started_at) : la file durable du poste peut renvoyer le même constat sans effet. Les statuts ne reculent JAMAIS : in_grace → expired → cleared, ou in_grace → cleared. Un expired/cleared reçu sans épisode connu CRÉE l'épisode dans cet état (un rapport tardif reste un fait). Refus nommés : 422 grace_kind_unknown (nomme le mot reçu et le vocabulaire) · 422 product_code_invalide. Chaque entrée en grâce et chaque échéance notifient le staff.
Déclarer les sources de production
/api/device/production-sourcesX-Machine-KeyDes logiciels TIERS du client déclarent leur production à l'EvoServer. L'admission est un geste d'ATELIER : elle se fait au site, sans Internet. Cet appel n'autorise rien après coup — il ENREGISTRE ce que le site a fait, rend le quota et redescend les révocations. Il n'est JAMAIS sur le chemin critique : s'il ne passe pas, l'usine tourne quand même.
{ "sources": [ { "source_uid": "scie-1", "name": "Scie 2",
"admitted_at": "<quand LE SITE l'a admise>" } ] }{ "quota": 3, "used": 2,
"accepted": ["scie-1"], "rejected": ["robot-1"], "revoked": ["presse-9"] }LE CONTRÔLE EST UNE LICENCE : le quota est la SOMME des licences evocore-api valides du client — « 3 licences » = « 3 solutions externes autorisées ». QUATRE LISTES, QUATRE GESTES, à ne pas fondre : accepted (autorisées) · rejected (au-delà du quota : enregistrées pour être VISIBLES, jamais autorisées — votre site doit les REFUSER) · revoked (coupées ici : votre site doit les COUPER au prochain contact) · quota/used (le compte). Une source hors quota redevient autorisée SEULE si le client achète une licence ; une révoquée ne se réveille JAMAIS seule. Mémorisez le quota pour l'appliquer HORS LIGNE.
Dessins de fermes (géométrie)
evocore.drawing/2 : enveloppe, membres polygones, plaques avec calibre). Le document est IMMUABLE par empreinte (source_hash, calculée par EvoCore) et conservé OCTET POUR OCTET — la lecture rend exactement ce qui est entré, ce qui garde l'empreinte vérifiable chez le consommateur (ShopCQ). Une organisation ne voit jamais les dessins d'une autre.Publier un dessin
/api/device/drawingsX-Machine-KeyCorps = le document lui-même (Content-Type application/json, ≤ 256 Ko). L'organisation est celle de la machine. Seuls trois champs de RACINE sont lus pour indexer : order_reference, position_label, source_hash (plus le schéma) ; le reste n'est jamais interprété ni réécrit. IDEMPOTENT par (organisation, source_hash).
{ "schema": "evocore.drawing/2",
"order_reference": "O260082A", "position_label": "F1",
"source_hash": "…", "units": "inch", "envelope": { … }, "members": [ … ], "plates": [ … ] }201 { "outcome": "written", "source_hash": "…" }
200 { "outcome": "unchanged", "source_hash": "…" }
409 { "outcome": "refused", "error": "hash_taken_by_different_content" }
422 { "outcome": "refused", "error": "invalid_document", "detail": "…" }LISEZ `outcome`, JAMAIS le code HTTP seul. written = rangé · unchanged = même empreinte ET même corps, déjà là (rejouer est sans effet) · refused = DÉFINITIF, n'insistez pas : hash_taken_by_different_content (même empreinte, corps différent — un dessin ne s'écrase jamais, changez d'empreinte) ou invalid_document (pas un objet JSON, schéma qui ne commence pas par evocore.drawing/, champ de racine manquant ou vide, plus de 256 Ko — `detail` nomme la cause). Le mot « refused » n'apparaît QUE sur 409 et 422 : tout autre code (401 machine, 404 route absente, 5xx) est TRANSITOIRE — gardez le document dans votre file et rejouez-le.
Lister les dessins d'une commande
/api/agent/drawings/{order_reference}Bearer (jeton OIDC d'usager) ou sessionToutes les empreintes reçues pour la commande, dans l'organisation de l'usager : par position, la plus récente d'abord. 404 { error: not_found } si la commande n'a aucun dessin.
{ "order_reference": "O260082A",
"drawings": [ { "position_label": "F1", "source_hash": "…", "schema": "evocore.drawing/2",
"size_bytes": 4120, "received_utc": "2026-08-29T15:04:00+00:00" } ] }L'usager doit appartenir à l'organisation qui a publié (un compte client de cette organisation ; un compte staff SE lit l'organisation interne). Le droit fin par usager (« drawings:read » délégable) n'existe pas encore : aujourd'hui tout membre de l'organisation lit.
Lire un dessin
/api/agent/drawings/{order_reference}/{position_label}?hash=Bearer (jeton OIDC d'usager) ou sessionLe DERNIER document reçu pour la position (ou, avec ?hash=<source_hash>, cette version précise — même ancienne ; 404 si cette empreinte n'existe pas pour la position), rendu tel quel — octet pour octet, Content-Type application/json. En-têtes X-Source-Hash, X-Content-SHA256, X-Received-At (ISO 8601, décalage +00:00) et X-Versions-Count (nombre d'empreintes reçues pour cette position — > 1 signifie qu'une géométrie a été REPUBLIÉE ; un rapport doit épingler son source_hash, sinon une relecture montrera un autre dessin en silence). 404 { error: not_found } si absent.
DEUX EMPREINTES, DEUX QUESTIONS : source_hash est l'IDENTITÉ du dessin amont (calculée par EvoCore à partir de la géométrie) — elle dit QUEL dessin, pas si le transport l'a abîmé ; X-Content-SHA256 est le sha256 des octets réellement servis (= ceux reçus) — c'est LUI qui vérifie le transport. Les confondre ferait conclure à une corruption inexistante. Joignez TOUJOURS sur (order_reference, position_label) : F1 et F1P existent dans deux commandes différentes. Une non-conformité se rattache à (source_hash, plates[].name) : quand le dessin change, l'empreinte change et le rattachement devient détectablement périmé au lieu de pointer en silence vers un autre joint.
Chiffrement lié à l'usager
Ouvrir un fichier sans taper de mot de passe. Le poste emballe la clé du fichier pour la clé publique de l'usager ; pour ouvrir, il nous renvoie l'enveloppe et nous rendons la clé de ce fichier-là. La privée ne quitte jamais notre serveur — c'est ce qui fait mordre la révocation à chaque ouverture, et non une fois par session.
⚠ C'est le chemin d'ouverture du quotidien, pas un raccourci : le mot de passe par fichier est retiré, pas doublé. Une indisponibilité de cette route — ou une simple coupure réseau — ferme donc le fichier à son auteur, jusqu'à ce qu'un délégué sorte la clé d'organisation. Pas de réseau, pas de clé : aucune mise en cache hors ligne, c'est une décision, pas une limite technique. Celle-ci reste obligatoire à l'écriture : c'est le recours, et il faut savoir exactement ce qu'il est. ⚠ « La clé d'organisation ouvre hors ligne » est vrai d'une récupération, faite par qui détient le fichier de clé privée — le délégué, avec AdminCrypt. C'est faux d'un opérateur à son poste : aucun poste ne détient cette privée, et rien dans le logiciel du plancher ne sait s'en servir. Le recours existe donc pour l'entreprise, pas pour la personne devant l'écran.
Ma clé publique
/api/crypt/user-key/meBearer usagerLa clé publique de l'usager authentifié — CRÉÉE À LA DEMANDE si elle n'existe pas. Appelée à l'ÉCRITURE. L'usager ne la voit jamais et n'a rien à configurer : aucun écran, aucun assistant, aucun fichier à déposer.
{ "userId": "uuid", "keyId": "2f2f886f8bff31ce",
"publicKeySpki": "<base64 SPKI DER>",
"algorithm": "RSA-3072-OAEP-SHA256" }POURQUOI « me » ET PAS UN IDENTIFIANT D'USAGER : un module ne peut pas nommer honnêtement quelqu'un — le jeton dont il dispose peut dégrader en silence vers une créance machine, et son identifiant d'usager recouvre plusieurs natures sans discriminant. C'est donc au serveur de trancher qui parle, à partir du jeton. Bénéfice : il n'y a aucune route à sonder pour savoir si un usager existe. `keyId` = hex(SHA256(SPKI)[0..8]), la MÊME convention que les clés d'organisation et de machine. 503 crypt_desactive si le serveur n'a pas de clé maîtresse.
Déballer la clé d'un fichier
/api/crypt/unwrapBearer usagerRend la clé de CE fichier-là, jamais la privée. Appelée à la LECTURE, une fois par fichier ouvert.
{ "keyId": "2f2f886f8bff31ce", "wrapped": "<base64 de la DEK emballée>" }{ "dek": "<base64>", "keyId": "2f2f886f8bff31ce" }⚠ BEARER SEULEMENT — un témoin de session ne déballe RIEN, même valide : un témoin est une créance AMBIANTE que le navigateur envoie tout seul, donc une page malveillante pourrait déclencher des déballages au nom de l'usager. ⚠ CETTE ROUTE VÉRIFIE L'AUDIENCE du jeton, contrairement au reste de la plateforme : une décision cryptographique ne se prend pas sur une créance émise pour un autre service. ⚠ PAR FICHIER, PAS PAR SESSION : gardez la clé du fichier ouvert, jamais plus. ⚠ UNE CLÉ RETIRÉE DÉBALLE ENCORE — une rotation ne ferme jamais le passé. Refus NOMMÉS : 401 non_authentifie (reconnectez-vous) · 403 creance_machine (créance d'usager exigée) · 403 audience_refusee (jeton émis pour un autre service, rend l'audience reçue) · 404 cle_inconnue (le MÊME 404 pour une clé qui n'existe pas et pour la clé de quelqu'un d'autre) · 410 porteur_supprime (la personne qui a chiffré ce fichier n'est plus au dossier — ouverture par la clé d'organisation, par un délégué ; 410 et non 404 parce que la clé A EXISTÉ, et que le remède n'est pas le même) · 422 enveloppe_invalide · 429 débit dépassé · 503 crypt_desactive. AUCUNE VÉRIFICATION DE LICENCE : ce qui porte la révocation, c'est l'authentification — un compte désactivé est refusé à l'appel SUIVANT, écart zéro. Chaque appel, succès COMME refus, est journalisé.
Qui a ouvert quoi (délégué)
/api/portal/crypt/journal?days=7|30|90Session portail · déléguéLe journal des déballages de l'organisation, succès ET refus — jamais une clé, jamais un contenu : il dit qu'une porte s'est ouverte, pas ce qu'il y avait derrière (nous ne le savons pas nous-mêmes, ces fichiers ne sont pas chez nous). Une rafale de refus est le premier signe d'un accès qu'on essaie d'utiliser sans y avoir droit.
{ "days": 7, "total": 124, "refus": 2, "tronque": false,
"entrees": [ { "at": "…", "usager": "…", "ip": "…", "resultat": "ok" } ] }Chiffrement
Un fichier est chiffré par un DEK déposé dans plusieurs slots (logique OU) : mot de passe, org assisté (récupération à distance sur approbation) et, en option, org hardware (privée sur YubiKey). Le serveur ne détient que du chiffré qu'il ne peut pas ouvrir.
Clés actives de l'organisation
/api/org/keys/active?usage=files|secretsX-Machine-KeyLes clés courantes de CET USAGE — un slot par clé (organisation + secours Smart-Evolution s'il y en a une). ⚠ LE PARAMÈTRE usage EST FILTRANT ET SON DÉFAUT EST « files » : sans lui, vous n'obtiendrez JAMAIS la clé des secrets de poste, même si le client l'a déposée. Pour sceller une sauvegarde de secrets, appelez explicitement ?usage=secrets.
[ { "keyId": "…", "usage": "secrets", "role": "assisted", "custody": "assisted",
"holder": "organisation", "recoverableBySe": false, "recoverableWithDelegate": true,
"algorithm": "RSA-3072-OAEP-SHA256", "publicKeySpki": "…" } ]POURQUOI CE DÉFAUT, et pourquoi il ne changera pas : les postes DÉJÀ DÉPLOYÉS prennent toute entrée portant un publicKeySpki et posent un slot org par clé sur CHAQUE fichier chiffré — ils ignorent les champs qu'ils ne connaissent pas. Servir la clé usage=secrets par défaut ferait donc ouvrir les documents des employés par la clé des secrets, en silence, sur toute la flotte, sans qu'aucun poste soit mis à jour. Un binaire déjà livré ne lira jamais un champ neuf : le seul remède est de NE PAS la lui envoyer. La garantie vient de l'ABSENCE, pas de la discipline. ⚠ DEUX FAITS DE RÉCUPÉRABILITÉ depuis 2026-08-25 : recoverableBySe = Smart-Evolution SEUL peut ouvrir (vrai UNIQUEMENT pour custody=offline, la clé de secours). Il valait vrai pour assisted — une FAUSSE PROMESSE : le blob s'ouvre avec la privée admin du délégué, que nous n'avons pas. recoverableWithDelegate = le blob ET le fichier admin sont chez nous, la seule PHRASE du délégué suffit. Ne jamais inférer ces faits d'un couple (custody, blob) : servez-les tels quels.
Demander une récupération
/api/org/recover/requestsX-Machine-KeyDemande de récupération assistée. Le poste envoie une clé publique éphémère (canal de retour).
{ "keyId": "…", "wrappedDek": "…", "clientPubKey": "…", "reason": "…", "fileRef": "…" }{ "requestId": "…", "status": "pending" }Récupérer la clé approuvée
/api/org/recover/requests/{id}X-Machine-KeyRécupère le DEK une fois approuvé (chiffré pour le poste). Disponible 1 h puis effacé.
{ "status": "fulfilled", "dekEncryptedForClient": "…" }Enregistrer la clé admin
/api/org/{orgId}/admin-keyAdmin · session (2FA)Enregistre la clé publique admin (une fois) : elle emballe les clés privées d'org au repos.
{ "adminPublicKeySpki": "…" }Créer une clé d'organisation
/api/org/{orgId}/keysAdmin · session (2FA)'assisted' sans publicKeySpki → le serveur génère + emballe sous la clé publique admin, puis détruit le clair. 'hardware' → publicKeySpki fourni (privée sur YubiKey).
{ "role": "assisted", "makeCurrent": true }{ "keyId": "…", "role": "assisted", "publicKeySpki": "…", "status": "current" }Demandes en attente (admin)
/api/org/{orgId}/recover/requests?status_filter=pendingAdmin · session (2FA)Les demandes en attente (avec wrappedDek + clientPubKey) pour la console d'approbation.
[ { "id": "…", "keyId": "…", "wrappedDek": "…", "clientPubKey": "…", "reason": "…" } ]Approuver une récupération (admin)
/api/org/{orgId}/recover/requests/{id}/fulfillAdmin · session (2FA)Poste le DEK rechiffré pour le poste (sous clientPubKey). Déballage/déchiffrement faits hors-ligne sur la console.
{ "dekEncryptedForClient": "…" }Voir aussi …/reject et POST /api/org/{orgId}/keys/{keyId}/revoke.
Clés de l'organisation (délégué)
/api/portal/org-keysSession portail · déléguéToutes les clés de l'organisation — actives ET historiques (la rotation est additive, rien n'est jamais effacé). Porte les deux faits de récupérabilité, calculés au serveur.
[ { "keyId": "…", "usage": "files", "custody": "assisted", "holder": "organisation",
"recoverableBySe": false, "recoverableWithDelegate": true,
"status": "current", "hasBlob": true, "depositedBy": "…", "createdAt": "…" } ]Déposer une clé d'organisation (délégué)
/api/portal/org-keysSession portail · déléguéMultipart : pub (fichier .pub.spki, requis, RSA-3072 strict — PEM refusé), blob (fichier .blob, optionnel, refusé si c'est une privée en clair), usage=files|secrets, custody=assisted|hardware. AdminCrypt peut appeler cette route avec un Bearer OIDC du délégué — c'est le chemin du dépôt automatique.
{ "keyId": "…", "usage": "files", "custody": "assisted", "status": "current", "hasBlob": true }⚠ NON-IDEMPOTENT, et c'est voulu : MÊME fichier + même usage → 409 sans écriture (un déposant automatique y voit « déjà déposé », un succès silencieux). Fichier DIFFÉRENT + même usage → 201 et ROTATION : l'ancienne clé courante passe en historique. Un automate ne doit JAMAIS pousser une paire différente sans geste humain explicite — sinon il fait tourner la clé de l'org en silence. custody=offline est réservé aux clés de secours SE (côté staff) ; la même paire peut servir aux deux usages (déposez-la deux fois).
Reprendre le blob (délégué)
/api/portal/org-keys/{keyId}/blobSession portail · déléguéRestitue le fichier .blob (privée d'org emballée sous la publique admin — opaque pour nous). Chaque téléchargement est journalisé.
{ "keyId": "…", "blob": "<base64>" }Sauvegarder le fichier admin (délégué)
/api/portal/org-keys/admin-backupSession portail · déléguéDépose le fichier admin (*.admin.key.enc) : la privée ADMIN sous la PHRASE du délégué, opaque pour nous. Avec le blob déjà déposé, la seule phrase suffit alors à récupérer — plus aucun fichier à conserver côté client. Multipart : file. APPEND-ONLY : chaque dépôt est une version de plus, jamais un écrasement.
{ "version": 1, "sha256": "…" }409 si le fichier est IDENTIQUE à la dernière version (même sha256) — un déposant automatique le traite comme un succès. Contrairement au dépôt de clé, une nouvelle version ne tourne AUCUNE clé : redéposer un fichier différent (changement de phrase) est sans danger. 400 si le fichier est une privée en clair — nous ne détenons jamais une privée que nous pourrions ouvrir. 422 si vide.
Reprendre le fichier admin (délégué)
/api/portal/org-keys/admin-backup?version=NSession portail · déléguéRestitue la dernière version (ou ?version=N). 404 nommé si aucune sauvegarde. Chaque téléchargement est journalisé. L'état SANS journaliser : GET /api/portal/org-keys/admin-backup/status → { present, version, at, sha256 }.
{ "version": 2, "sha256": "…", "file": "<base64>" }EvoVault (coffre-fort)
Un coffre-fort de secrets zéro-connaissance : le serveur stocke et synchronise des octets opaques (contenu et clés emballées) qu'il ne peut pas ouvrir. Chaque secret est chiffré par une DEK déposée dans un slot par destinataire (emballée sous sa clé publique RSA). Partage, révocation et recherche des titres se font côté client. Authentifié par la session portail ; tout est cloisonné à l'organisation.
Publier ma clé
/api/vault/keys/meCookie sessionPremière configuration (ou rotation de passphrase) : publie la clé publique de l'utilisateur et sa clé privée EMBALLÉE sous sa passphrase maître (jamais connue du serveur).
{ "public_key_spki": "…", "wrapped_private_key": "…" }204 No Content
Ma clé
/api/vault/keys/meCookie sessionRécupérée sur chaque appareil ; le client déballe la clé privée localement avec la passphrase. 404 si pas encore configurée.
{ "public_key_spki": "…", "wrapped_private_key": "…", "updated_at": "…" }Annuaire des membres
/api/vault/membersCookie sessionLes utilisateurs de l'organisation et leur clé PUBLIQUE, pour choisir un destinataire de partage. Aucune clé privée ici.
[ { "user_id": "…", "email": "…", "name": "…", "public_key_spki": "…", "has_key": true } ]Mes coffres
/api/vault/vaultsCookie sessionCoffres visibles : personnels (propriétaire) + partagés de l'org où l'utilisateur détient un slot de coffre. POST crée {enc_name, kind:'personal'|'shared'} ; PATCH /{id} renomme ; DELETE /{id} supprime (propriétaire ou délégué). enc_name (nom) est opaque, chiffré sous la DEK de coffre. Chaque coffre renvoie ses slots (le membre y retrouve le sien pour déballer la DEK).
[ { "id": "…", "enc_name": "…", "kind": "shared", "owner_user_id": "…", "created_at": "…",
"slots": [ { "key_id": "…", "kind": "user", "recipient_user_id": "…", "wrapped_vault_key": "…" } ] } ]Synchroniser les items
/api/vault/vaults/{id}/items?since={seq}&include_deleted=trueCookie sessionDelta de synchronisation : les items dont seq > since (tombstones inclus si demandé). Le client mémorise max_seq par coffre. seq est un compteur monotone par coffre, réassigné à chaque écriture — il sert de curseur de sync ET de jeton de concurrence.
{ "items": [ { "id": "…", "type": "password", "enc_title": "…", "seq": 42, "is_deleted": false,
"enc_blob": "…", "slots": [ { "key_id": "…", "kind": "user", "recipient_user_id": "…", "wrapped_dek": "…" } ] } ],
"max_seq": 42 }Créer un item
/api/vault/itemsCookie sessionCrée un secret. ≥ 1 slot obligatoire. Le serveur ne valide PAS le contenu chiffré ; il vérifie l'accès au coffre et l'intégrité structurelle (base64, key_id = 16 hex). enc_title et enc_blob sont opaques.
{ "vault_id": "…", "type": "password", "enc_title": "…", "enc_blob": "…",
"slots": [ { "key_id": "…", "kind": "user", "recipient_user_id": "…", "wrapped_dek": "…" } ] }{ "id": "…", "seq": 42 }Modifier un item
/api/vault/items/{id}Cookie sessionRemplace enc_blob + la liste complète des slots ; archive l'ancien contenu dans l'historique. Concurrence optimiste : base_seq = le seq sur lequel le client s'est basé.
{ "enc_title": "…", "enc_blob": "…", "slots": [ … ], "base_seq": 42 }{ "seq": 43 }Partager (ajouter un slot)
/api/vault/items/{id}/slotsCookie sessionAccorde l'accès à un destinataire : le client déballe la DEK avec SA clé privée puis la ré-emballe sous la clé publique du destinataire. Réservé au créateur de l'item, au propriétaire du coffre ou à un délégué.
{ "key_id": "…", "kind": "user", "recipient_user_id": "…", "wrapped_dek": "…" }{ "key_id": "…", "seq": 44 }Révoquer un accès
/api/vault/items/{id}/slots/{key_id}Cookie sessionRetire le slot (révocation immédiate côté serveur). La sécurité descendante réelle vient d'une rotation de DEK côté client : régénérer la DEK, re-chiffrer enc_blob, ré-emballer les slots restants, puis PUT de l'item.
204 No Content
Aussi : dossiers (/api/vault/vaults/{id}/folders), tags (/api/vault/vaults/{id}/tags), favoris (POST /api/vault/items/{id}/favorite), historique (GET /api/vault/items/{id}/history · POST …/restore). Récupération d'urgence : un slot kind='org' emballé sous la clé publique d'org, débloqué via /api/org/recover/*. Pièces jointes : hors API (stockage S3 configuré par l'organisation).
Carte d'affaires
Une page publique par employé — /carte/<slug> — bâtie sur le profil qui alimente déjà les claims OIDC. Rien de neuf à saisir : prénom, nom, titre, téléphone, photo, bio et LinkedIn viennent de /api/me/profile.
⚠ Une carte non publiée rend 404, exactement comme une carte inexistante — et le même corps. Distinguer les deux dirait à un inconnu qui travaille ici.
Lire une carte
/api/public/carte/{slug}PublicCe qu'une carte de carton dirait, et rien de plus. Aucun champ du COMPTE ne sort d'ici : ni rôle, ni identifiant, ni dernière connexion, ni état de la double authentification.
{ "slug": "jean-tremblay", "name": "Jean Tremblay",
"title": "Directeur", "bio": "…", "email": "…", "phone": "…",
"linkedin": "…", "photo_url": "<URL signée, 6 h>",
"company": "Smart-Evolution", "vcard_url": "…", "card_url": "…" }404 si la carte n'existe pas, n'est pas publiée, ou si le compte est désactivé — le même 404 dans les trois cas.
Télécharger le vCard
/api/public/carte/{slug}/vcardPublicLe fichier .vcf — « ajouter à mes contacts » en un geste sur téléphone.
vCard 3.0, PAS 4.0 : c'est la version que les applications Contacts d'iOS et Android importent sans broncher ; la 4.0 est plus propre sur le papier et moins bien reçue sur un téléphone. Les séparateurs RFC 6350 (virgule, point-virgule, barre oblique inverse) sont échappés — « Tremblay, fils » non échappé casserait la fiche à l'import. La PHOTO n'est pas embarquée : son URL est signée et expire, donc un contact enregistré aujourd'hui afficherait une image morte dans six heures.
Écrire au propriétaire de la carte
/api/public/carte/{slug}/contactPublicLa demande part à CET EMPLOYÉ, pas à la boîte générale — c'est ce qui distingue une carte d'affaires d'un formulaire de site. Elle est aussi enregistrée comme demande (source carte:<slug>) : le courriel peut échouer, la trace ne doit pas.
{ "name": "…", "email": "…", "company_name": "…",
"phone": "…", "message": "…", "website": "" }`website` est un POT DE MIEL : rempli, la réponse est 200 et RIEN n'est écrit. Débit limité à 10 par heure et par adresse IP. Refus 404 si la carte n'est pas publiée.
Ma carte (lien, QR, signature)
/api/me/profile/carteCookie sessionTout ce qu'il faut pour partager : l'adresse, le code QR en SVG, le lien du vCard et la signature Outlook prête à coller. Le slug est créé au premier appel s'il n'existe pas.
{ "slug": "…", "published": false, "url": "https://www.smart-evolution.com/carte/…",
"qr_svg": "data:image/svg+xml,…", "vcard_url": "…", "signature_html": "<table…>" }LE SLUG NE BOUGE JAMAIS une fois créé : ce lien finit imprimé sur du carton, collé dans une signature et encodé en QR. Corriger l'orthographe de son nom ne doit pas le casser. La signature est du HTML de TABLEAU avec styles en ligne — Outlook (Windows) rend le HTML avec le moteur de Word : aucune feuille de style, aucune classe, aucun flexbox ne survit au collage.
Publier / retirer sa carte
/api/me/profile/carteCookie sessionL'employé décide seul : c'est son nom, sa photo et son téléphone direct qui sont en jeu.
{ "published": true }DÉFAUT NON PUBLIÉ, y compris pour les employés déjà en place au moment de la migration. Publier le téléphone direct et la photo de quelqu'un est un geste qui se pose ; ce n'est jamais un effet de bord de déploiement.
Portail client (self-service)
Ce qu'une personne du client fait après connexion (code à usage unique par courriel → cookie de session). Un délégué gère l'équipe et les permissions par module (aucun / voir / gérer) et par portée (ses propres / tous).
Demander un code de connexion
/api/portal/auth/request-codePublicEnvoie un code à 6 chiffres (10 min) par courriel ou SMS selon le canal du contact. RÉSERVÉ AUX COMPTES CLIENTS : un compte employé (owner/staff) ne reçoit jamais de code — son premier facteur est le mot de passe. Réponse toujours générique (200), qu'un compte existe ou non.
{ "email": "[email protected]" }{ "ok": true, "channel": "email" }Plafonds (2026-09-09) : 5 envois / 15 min PAR ADRESSE demandée (429 Retry-After au-delà — compte existant ou non, même en changeant d'IP) et 30 requêtes / 15 min par IP. Chaque nouveau code invalide le précédent ; un code admet 5 essais. /api/auth/otp/request existe aussi (même contrat, même plafonds, bucket d'envoi PARTAGÉ — alterner les deux ne double rien).
Vérifier le code (ouvre la session)
/api/portal/auth/verify-codePublicÉchange courriel + code contre le cookie de session (7 jours). Si le compte a un 2e facteur (TOTP ou passkey), répond { twofa_required: true } et l'étape 2 passe par /api/auth/login/2fa ou /api/auth/login/webauthn.
{ "email": "[email protected]", "code": "123456" }Plafond : 120 requêtes / 15 min par IP (429). 5 échecs sur un compte en 15 min verrouillent le compte (429). Réservé aux comptes clients — un code valide n'ouvre jamais un compte employé (401).
Lien d'invitation (magique)
/api/portal/auth/magic/consumePublicLe lien reçu par courriel (GET /api/portal/auth/magic/{token}) ne consomme RIEN : il valide et redirige vers la page /invitation — les scanners antipourriel qui préchargent les GET ne peuvent pas griller le lien. C'est ce POST (le clic) qui confirme le courriel et ouvre la session.
{ "token": "…" }Durci 2026-09-09 : USAGE UNIQUE (une 2e consommation → 401 « Lien déjà utilisé ») et validité 7 jours (avant : rejouable 14 jours). Si le compte a un 2e facteur, répond { twofa_required: true } — l'étape 2 passe par /api/auth/login/2fa, le lien saute plus jamais le 2FA. Plafond : 30 requêtes / 15 min par IP.
2FA TOTP (activer / désactiver)
/api/auth/2fa/setup · /enable · /disableCookie sessionsetup génère secret + QR sans RIEN écrire côté serveur (le secret en attente voyage dans un cookie signé, 10 min) ; enable confirme le code et écrit tout d'un coup (secret, activation, codes de secours — renvoyés une seule fois) ; disable exige le code courant OU le mot de passe.
Durci 2026-09-09 : setup et enable répondent 409 si un 2FA est déjà actif — le remplacer exige de le désactiver d'abord (donc de prouver code ou mot de passe). Avant, un simple POST /setup avec un cookie volé désactivait le 2FA d'un compte protégé. Plafonds : 5 essais / 15 min par compte sur enable et disable (429). Chaque activation/désactivation est journalisée. ANTI-REJEU (2026-09-11) : un code TOTP déjà consommé est refusé même dans sa fenêtre de ±30 s — ne soumettez jamais deux fois le même code. Passkeys : l'enrôlement (POST /api/auth/webauthn/register/verify) exige désormais le mot de passe OU un code 2FA courant (champs password / code du corps) quand le compte en a un — 403 sinon ; 5 essais / 15 min.
Objets intelligents (self-service)
/api/portal/smart-devices · /types · PATCH /{id}Cookie session · module objetsLe client voit SES EvoMatrix/EvoCam et change leur UTILITÉ lui-même : GET liste ses objets (type, paramètres, localisation, version embarquée, dernier contact) ; GET /types donne les types offerts AVEC leur config_schema — la liste de champs ({key, label, type, required, options…}) dont l'interface génère le formulaire ; PATCH /{id} change device_type_id (même famille seulement) et configuration.
{ "device_type_id": "…", "configuration": "{"poste": "C", "mode": "scie"}" }La configuration est VALIDÉE contre le config_schema du type : champs requis manquants ou mauvais types → 422 avec le détail nommé — l'usager sait quoi entrer et ne peut pas l'omettre. Un type sans schéma = paramètre texte libre. PATCH exige le niveau « gérer » du module objets ; le client ne touche jamais l'affectation, le GUID, le reboot ni l'activation. L'objet applique le changement à son prochain bootstrap.
Demander un accès
/api/portal/auth/request-accessPublicAuto-demande d'accès (le domaine du courriel doit correspondre à un client connu). Réponse générique.
{ "email": "[email protected]", "name": "Jane Doe" }Mon profil
/api/portal/meCookie sessionL'utilisateur portail connecté (client, nom).
Mes permissions
/api/portal/access/meCookie sessionModules + grants effectifs (niveau + portée).
Mes projets
/api/portal/projectsCookie sessionProjets du client (restreints aux projets autorisés).
Mes communications
/api/portal/communicationsCookie sessionJournal des échanges. Portée ses propres / tous selon la permission.
Ouvrir un incident
/api/portal/incidentsCookie sessionOuvre un ticket. La liste GET /incidents applique la portée ses propres / tous. Champ optionnel screenshot_base64 : le logiciel client peut joindre une capture d'écran (base64 ou data: URI, ≤ ~6,5 Mo) — visible par le support.
{ "title": "…", "severity": "medium", "description": "…", "screenshot_base64": "data:image/png;base64,iVBORw0K…" }Approuver une soumission
/api/portal/soumissions/{id}/approveCookie sessionApprouve une soumission (fige le devis → lead Vendu).
Mes documents
/api/portal/documentsCookie sessionDocuments du client. Téléchargement via /documents/{id}/download (URL présignée).
Mes postes / licences
/api/portal/machinesCookie sessionPostes enrôlés. PATCH pour approuver/révoquer, DELETE pour retirer un poste révoqué.
Enrôler ce poste (sans jeton)
/api/portal/machines/enrollCookie session / Bearer OIDCAuto-enrôlement du poste avec l'identité de l'utilisateur connecté — AUCUN jeton d'enrôlement requis. Le poste est rattaché au client de l'utilisateur ; dédup par machine_uid (ré-enrôler réémet la clé sur la même fiche). Renvoie la clé machine UNE fois. Si le client exige l'approbation, statut 'pending' jusqu'à validation admin. Idéal pour une 1re expérience transparente : l'utilisateur se connecte, et si le poste n'a pas de clé, le logiciel appelle ceci une fois avec un machine_uid stable + un nom (ex. le hostname).
{ "machine_uid": "<empreinte matérielle stable>", "name": "PC-USINE-07" }{ "machine_id": "…", "machine_key": "dvk_…", "status": "active" } // ou "pending" si approbation requiseMes téléchargements & versions
/api/portal/downloadsCookie sessionProduits sous licence : standalone → lien + version ; saas → lien ; plugin → livré aux postes. Pour les plugins, renvoie aussi les versions disponibles et la version épinglée par le client (null = version courante).
[ { "product_id": "…", "name": "…", "distribution": "plugin", "download_url": null,
"versions": [ { "id": "…", "label": "2.1.0", "is_current": true } ], "pinned_version_id": null } ]Choisir la version d'un plugin
/api/portal/plugins/{product_id}/versionCookie session · licences (gérer)Épingle la version que les postes du client installent (null = revient à la version courante). S'applique à toutes les machines du client ; le manifeste /api/device/plugins sert alors la version épinglée. Réservé au module licences niveau gérer (délégués inclus).
{ "version_id": "…" } // ou { "version_id": null } pour retirer l'épingleJeton d'enrôlement
/api/portal/enrollment-tokenCookie session · déléguéRégénère le jeton d'enrôlement (enr_…) — renvoyé une seule fois.
Mon code source
/api/portal/source-codesCookie sessionCode source hébergé du client. POST /{id}/versions pour sauvegarder si accès lecture/écriture.
Approuver une étude de cas
/api/portal/case-studies/{id}/approveCookie session · déléguéLe délégué approuve la publication d'une étude de cas le concernant.
Gérer mon équipe
/api/portal/teamCookie session · déléguéLe délégué crée un employé (courriel du domaine de l'entreprise) puis règle ses permissions.
{ "email": "[email protected]", "first_name": "Bob" }Demandes agent (soumettre + suivre)
/api/portal/agent-requestsCookie session · module agentsSoumet une demande exécutée par les agents Smart-Evolution. client_ref = l'identifiant du ticket dans VOTRE CRM : re-soumettre le même client_ref met à jour (upsert idempotent, retries sûrs). GET liste vos demandes avec ?client_ref= (recherche) et ?updated_since= (synchronisation delta). Statuts à mapper dans votre CRM : queued → assigned → in_progress → blocked → done | failed.
{ "title": "Analyser nos données", "description": "…", "client_ref": "CRM-42", "priority": 0 }{ "id": "…", "client_ref": "CRM-42", "status": "queued", "deliverable": null, "updated_at": "…", "cost": 0 }Aussi : GET /agent-requests/{id} (détail + livrable + coût), GET /agent-requests/{id}/events (chronologie), GET /agent-initiatives (budgets), GET /agent-incidents + POST /{id}/comment (blocages sur vos demandes). Accès machine : votre CRM s'authentifie en OIDC (jeton d'organisation) — mêmes endpoints. Isolation stricte par organisation (hors org = 404).
Authentification (OIDC / SSO)
Smart-Evolution est un fournisseur d'identité OpenID Connect : tes outils (web, .NET, Python, IoT) authentifient les utilisateurs contre app.smart-evolution.com — une seule identité partout. Flow authorization code + PKCE, jetons signés RS256, clé publique exposée via JWKS. Chaque outil lit l'URL de découverte et se configure seul.
Point d'entrée : Authority = https://app.smart-evolution.com. La découverte et le JWKS sont à la racine du domaine — /.well-known/openid-configuration et /.well-known/jwks.json — pas sous /api (seuls /authorize, /token, /userinfo le sont). Pointez votre librairie sur l'Authority et laissez l'auto-découverte câbler les endpoints.
L'access_token (et l'id_token) portent les claims de licence — plan, entitlements, valid_until, licensed — et, pour un utilisateur client, son organisation : org_id + org_name, plus le tableau orgs — [ { id, name, role } ] avec role = admin (délégué) ou member — relu à chaque rotation de token, pour provisionner/lier l'organisation côté consommateur (ex. NOVUS) — et licenses : l'ensemble effectif des services plateforme licenciés à l'organisation, [ { code, limit? } ] (codes produits du catalogue ; limit = total des sièges de l'org, absent = illimité/booléen) — et, si l'organisation en a un, llm_budget : son plafond LLM total, { amount, currency: "CAD", window: "month" }, sous-réparti côté consommateur. Chaque service sait ainsi à quoi l'utilisateur a droit et de quelle organisation il relève.
Accès API : passez l'access_token en Authorization: Bearer sur n'importe quel endpoint /api/… — l'API agit au nom de l'utilisateur, avec ses permissions exactes (staff → selon le rôle ; client → cloisonné à son entreprise + droits délégué/module), en lecture comme en écriture. Seul l'access_token (qui porte scope) est accepté, pas l'id_token.
Audience vérifiée (2026-09-09) : l'API contrôle désormais l'aud du jeton. Sont acceptés le jeton propre de votre application (émis sans paramètre resource : aud = votre client_id) et les audiences de la plateforme (se-api, l'issuer, plus novus-api en tolérance de transition). Un jeton re-ciblé vers un autre serveur de ressources (ex. resource=novus-mcp, jeton de geste) est refusé 401 « Jeton émis pour un autre service » — demandez un jeton sans resource pour appeler l'API SE.
iss, aud, exp) — aucun secret partagé.Découverte OIDC
/.well-known/openid-configurationPublicDocument de découverte, à la RACINE du domaine (pas sous /api). Chaque librairie lit cette URL et se configure seule (endpoints, algos, scopes).
{ "issuer": "https://app.smart-evolution.com", "authorization_endpoint": ".../api/oidc/authorize", "token_endpoint": ".../api/oidc/token", "userinfo_endpoint": ".../api/oidc/userinfo", "device_authorization_endpoint": ".../api/oidc/device_authorization", "jwks_uri": ".../.well-known/jwks.json", "grant_types_supported": ["authorization_code","refresh_token","client_credentials","machine_key","urn:ietf:params:oauth:grant-type:device_code"], "code_challenge_methods_supported": ["S256"] }Clés publiques (JWKS)
/.well-known/jwks.jsonPublicClés publiques de signature (RS256), à la RACINE du domaine (pas sous /api). Les services vérifient les jetons hors-ligne avec ces clés (par kid).
{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "…", "n": "…", "e": "AQAB" } ] }Remonter l'usage LLM (NOVUS→SE)
/api/llm/usageBearer client_credentials · scope llm.usageService-à-service : NOVUS remonte le coût LLM par organisation pour réconciliation/facturation (SE reste l'autorité de financement, NOVUS le gate temps réel). Idempotent par request_id ; accepte une entrée OU un lot. Alimente un registre durable + le miroir de dépense de l'org (CAD).
[ { "request_id": "…", "org_id": "<client id>", "model": "claude-…", "cost_cad": 0.42, "tokens_in": 1200, "tokens_out": 800, "sector_id": "…?", "player_id": "…?" } ]{ "received": 3, "recorded": 2, "duplicates": 1 }Échanger le code / rafraîchir
/api/oidc/tokenClient (secret ou PKCE)Échange le code contre les jetons (confidentiel : client_secret ; public : code_verifier). L'access_token porte les claims de licence (plan, entitlements, valid_until, licensed).
grant_type=authorization_code&code=…&redirect_uri=…&client_id=cli_…&client_secret=…&code_verifier=…
{ "access_token": "eyJ…", "id_token": "eyJ…", "refresh_token": "…", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile email license" }Aussi acceptés : grant_type=refresh_token (rotatif — l'ancien est invalidé à chaque usage) et client_credentials (service-à-service : le client confidentiel obtient un JWT ; s'il est lié à un agent, le jeton porte agent_id/secteur/rôle/initiatives), ainsi que grant_type=machine_key (hôtes EvoCore, sans navigateur : la clé machine enrôlée s'échange contre un Bearer 1 h — sub=machine:<uuid>, aud=novus-api, claims orgs + licenses ; pas de refresh token, l'hôte ré-échange à l'expiration ; révoquer la machine coupe l'émission).
Brancher un poste (device flow, RFC 8628)
/api/oidc/device_authorizationClient public (client_id, sans secret)Enrôler un poste sans clavier/navigateur par QR. Le poste appelle device_authorization, affiche user_code + un QR de verification_uri_complete, puis interroge /token. L'usager (déjà connecté sur son téléphone) ouvre le QR, approuve sur /device → le poste reçoit les MÊMES jetons que l'auth-code (access + refresh, claims org), à usage unique, et s'auto-enrôle via POST /api/portal/machines/enroll.
POST /api/oidc/device_authorization client_id=cli_…&scope=openid
{ "device_code": "…", "user_code": "WDJB-MDN7",
"verification_uri": "https://app.smart-evolution.com/device",
"verification_uri_complete": "https://app.smart-evolution.com/device?code=WDJB-MDN7",
"expires_in": 600, "interval": 5 }
// Polling — POST /api/oidc/token :
// grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=cli_…
// en attente → 400 { "error": "authorization_pending" } | { "error": "slow_down" }
// approuvé → 200 { "access_token": "eyJ…", "refresh_token": "…", "id_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600 }Client public : aucun secret ; le device_code est le secret pour le polling. Respectez interval (5 s) et slow_down. Le QR encode verification_uri_complete (déjà pré-rempli avec le user_code, alphabet sans voyelles ni 0/O/1/I). Le jeton obtenu est identique au flow navigateur → accepté tel quel par /api/portal/machines/enroll. Découverte : device_authorization_endpoint + grant urn:ietf:params:oauth:grant-type:device_code annoncés.
Profil (userinfo)
/api/oidc/userinfoBearer access_tokenIdentité de base à partir de l'access_token.
{ "sub": "…", "email": "…", "name": "…", "org_id": "…", "org_name": "…",
"orgs": [ { "id": "…", "name": "…", "role": "admin|member" } ],
"licenses": [ { "code": "vm", "limit": 5 }, { "code": "storage" } ] } // org_* / orgs / licenses présents pour les utilisateurs client (portail)Révoquer un refresh token
/api/oidc/revokeclient_id + client_secret (form)RFC 7009 (2026-09-11). Révoque un REFRESH token du client appelant — annoncé dans la découverte (revocation_endpoint). Un access token passé ici répond 200 sans effet : c'est un JWT validé hors-ligne (≤ 1 h), non révocable. Répond 200 même pour un jeton inconnu ou déjà révoqué, conformément à la RFC.
token=<refresh_token>&client_id=cli_…&client_secret=…
À savoir : une réinitialisation de mot de passe révoque désormais TOUTES les créances de l'usager — sessions, refresh tokens OIDC et clés API (désactivées) ; un changement volontaire de mot de passe révoque les autres sessions et les refresh tokens. Prévoyez le retour au flux de connexion quand un refresh échoue en invalid_grant.
Déconnexion (SSO)
/api/oidc/logoutPublic · navigateurFerme la session IdP (cookie + révocation) puis redirige vers un post_logout_redirect_uri enregistré. Query : post_logout_redirect_uri, id_token_hint (ou client_id), state.
Le post_logout_redirect_uri doit être enregistré sur le client, sinon 400.
Applications enregistrées
/api/oidc/clientsAdmin · sessionListe des clients OIDC (outils enregistrés). Gérable dans Administration → Applications (OIDC).
Enregistrer une application
/api/oidc/clientsAdmin · sessionEnregistre un outil. Le client_secret est renvoyé une seule fois (clients confidentiels).
{ "name": "Portail EvoKPI", "redirect_uris": ["https://…/signin-oidc"], "is_public": false, "product_code": "evokpi" }{ "client_id": "cli_…", "client_secret": "…", "client": { "id": "…", "is_active": true } }Modifier / secret / supprimer
/api/oidc/clients/{id}Admin · sessionÉdite (redirect_uris, scopes, product_code, rôles, actif). POST /{id}/secret régénère le secret ; DELETE /{id} supprime.
product_code relie l'outil à un produit : valid_until et licensed sont alors calculés pour ce produit.
Agents (orchestration)
app.smart-evolution.com est le poste de commandement d'un système multi-agents (l'exécution vit dans NOVUS). Un agent = un employé avec une clé API, cadré à son secteur et ses initiatives : il tire ses tâches, remonte leur état, délègue à son sous-arbre et peut lancer des agents d'appoint.
Mon profil d'agent
/api/agent/meX-API-Key (agent)L'agent courant : secteur, rôle, manager, initiatives.
{ "id": "…", "role": "worker", "sector_id": "…", "initiatives": ["…"] }Fiches produit (éditorial agent)
/api/agent/products/{id}X-API-Key (agent) · capacité productsSurface éditoriale des produits pour agents, gouvernée par le connecteur products (read | write | media | docs — baseline secteur ou grant agent). GET /api/agent/products liste les produits actifs ; PATCH ne touche QUE les champs éditoriaux (description, public_description, public_description_en, app_type, app_type_en) ; POST /{id}/media/image téléverse un screenshot ; POST /{id}/documents/upload téléverse un manuel/PDF (multipart : title, title_en, kind, version, visibility) ; GET /{id}/media et /{id}/documents listent. Stockage Bunny partagé avec le staff (une seule liste) ; pas de suppression côté agent. Le reste du catalogue (prix, distribution, versions…) reste réservé owner/staff.
{ "public_description": "<p>…FR…</p>", "public_description_en": "<p>…EN…</p>" }Support client (helpdesk agent)
/api/agent/support/incidents/{id}/replyX-API-Key (agent) · capacité supportTraitement des incidents CLIENTS (CRM/SLA) par agents, connecteur support (read | respond | status). Lecture org-wide : GET /api/agent/support/incidents (?status_id=, ?client_id=, ?assigned=me) et GET /{id} avec le fil complet (communications, notes internes incluses). Agir exige d'être ASSIGNÉ (assigned_user_id) : POST /{id}/claim PREND EN CHARGE un incident NON assigné (traitement automatique sans clic humain — jamais de vol : déjà assigné à un autre → 409, un humain reprend la main en réassignant ; idempotent, et la prise en charge laisse une note interne dans le fil) ; POST /{id}/reply crée une PROPOSITION de réponse (note interne « [Proposition d'agent] » — un humain approuve et envoie le courriel depuis l'écran de l'incident, bouton « Approuver et envoyer au client » ; rien ne part directement au client) ; PATCH /{id}/status change le statut (statuts configurables, resolved_on synchronisé pour le SLA) — la table se découvre par GET /api/agent/support/statuses (id → nom ; résolvez par NOM, ne figez jamais un UUID : la table est éditable et change d'un environnement à l'autre). Tout est journalisé dans l'audit agent.
{ "body": "Bonjour, voici la marche à suivre…", "subject": "Re: …" }Passerelle courriel (envoi agent)
/api/agent/email/sendX-API-Key (agent) · capacité email:sendEnvoi d'un courriel via Microsoft Graph depuis une boîte de service configurée — pensé pour les envois automatisés non-interactifs (rapports planifiés avec PDF en pièce jointe) : aucun OAuth interactif, la clé API de l'agent suffit. Corps : `html` envoyé tel quel, sinon `text` habillé du gabarit transactionnel SE (l'un des deux est requis). Destinataires : 1 à 10, domaines externes permis. Pièces jointes : max 5, base64 (content_b64), total décodé ≤ 3 Mo (413 au-delà — limite Graph inline). `from_mailbox` optionnel, doit être une boîte de service configurée (défaut : la première). Erreurs : 403 sans la capacité email:send, 422 corps/base64/boîte invalide, 502 refus Graph, 503 Graph non configuré. Chaque envoi est journalisé dans l'audit agent (destinataires, sujet, taille).
{ "to": ["[email protected]"], "subject": "Rapport quotidien",
"text": "Bonjour, voir le rapport en pièce jointe.",
"attachments": [ { "name": "rapport.pdf", "content_type": "application/pdf", "content_b64": "…" } ] }{ "status": "sent", "from": "[email protected]", "to": ["[email protected]"], "attachments": 1 }Annuaire mesh (NOVUS→SE)
/api/agent/directoryBearer client_credentials · scope agents.directoryL'annuaire des agents actifs pour le mesh NovusBus (server-à-server : NOVUS le proxifie via ses outils MCP mesh.directory / mesh.resolve avec un cache court, et superpose la présence live). mesh_address est la clé de routage canonique : l'UUID immuable de l'agent (== claim agent_id des JWT agents). name / description / novus_ref sont éditables par le staff — ne jamais router dessus.
[ { "mesh_address": "…uuid…", "name": "…", "description": "…", "novus_ref": "…",
"sector": "…", "role": "worker", "status": "active",
"initiatives": [ { "id": "…", "name": "…", "sector_role": "…" } ] } ]Ma file de tâches
/api/agent/tasksX-API-Key (agent)Les tâches qui me sont assignées ou dans mes initiatives. Filtre optionnel ?status_filter=.
Remonter l'état
/api/agent/tasks/{id}X-API-Key (agent)Change le statut d'une tâche de mon périmètre (queued|assigned|in_progress|blocked|done|failed). Si une lease est active, présenter son lease_id (fencing → 409 si périmée). Répercuté sur la tâche CRM liée.
{ "status": "in_progress", "lease_id": "ls_…", "note": "…" }Réserver une tâche (lease)
/api/agent/tasks/{id}/claimX-API-Key (agent)Prend une réservation exclusive et limitée dans le temps (visibility timeout, 120 s). N'importe quel membre de l'initiative peut réserver → permet le failover d'une tâche orpheline. 409 si déjà réservée ou si l'agent est en drain.
{ "lease_id": "ls_…", "lease_expires_at": "…" }Prolonger la réservation
/api/agent/tasks/{id}/heartbeatX-API-Key (agent)À envoyer ~toutes les 60 s pour garder la lease vivante. 409 si la lease est périmée ou ne correspond pas (fencing).
{ "lease_id": "ls_…" }{ "lease_expires_at": "…" }Drain (arrêt gracieux)
/api/agent/drainX-API-Key (agent)L'agent finit sa réservation en cours mais ne réserve plus rien (failover / redémarrage). Renvoyer {"draining": false} pour reprendre.
{ "draining": true }Déléguer une tâche
/api/agent/tasksX-API-Key (agent)Crée une tâche dans une de mes initiatives, assignée à moi ou à un subordonné de mon sous-arbre.
{ "initiative_id": "…", "title": "…", "assigned_agent_id": "…" }Mon équipe (sous-arbre)
/api/agent/teamX-API-Key (agent)Mes subordonnés, directs et indirects.
Lancer un agent d'appoint
/api/agent/agentsX-API-Key (agent)Enregistre un agent sous moi (même secteur) et renvoie sa clé API une seule fois. Quota (max_children) + profondeur max. NOVUS démarre ensuite la VM avec cette clé.
{ "name": "Helper", "role": "worker" }{ "id": "…", "name": "Helper", "api_key": "sek_…" }Un jeton client_credentials (JWT porteur de agent_id/secteur/rôle/initiatives) est aussi disponible via POST /api/oidc/token.
Contrats d'interface (lire)
/api/agent/contracts/{initiative}/{name}?version=X-API-Key (agent)Contrat d'interface versionné entre agents (« s'entendre sur les méthodes avant de coder ») — SE est la maison autoritaire : document, versions, signatures, coordinateur, machine à états. Sans version : la version gelée courante (repli : dernier brouillon ; vérifiez status avant de construire). GET /api/agent/contracts?initiative_id= liste. Le corps document est opaque (votre schéma).
{ "initiative_id": "…", "name": "task-exchange", "version": 2, "status": "frozen",
"document": { …opaque… }, "frozen_at": "…",
"signoffs": [ { "agent_id": "…", "kind": "build", "signed_at": "…" } ] }Brouillon + signature
/api/agent/contracts/{initiative}/{name}X-API-Key (agent)Créer/mettre à jour le brouillon (tout membre de l'initiative). Une version gelée en tête → 409 (utilisez supersede). Signer une partie : POST .../signoff { version, kind } avec kind=build (« je construis contre cette version, réveillez-moi ») ou conform (« mon côté a passé la vérif NOVUS »).
POST …/{name} { "document": { …opaque… } }
POST …/{name}/signoff { "version": 1, "kind": "build" }Membre = coordinateur OU membre de l'initiative. build = abonnement au réveil ; conform après intégration. « Toutes parties conform » = contrat vivant (déduit des signatures, pas de statut stocké).
Geler / superséder (coordinateur)
/api/agent/contracts/{initiative}/{name}/freezeX-API-Key (coordinateur)Réservé au coordinateur de l'initiative (Initiative.coordinator_agent_id). freeze { version } : le brouillon → gelé (l'ancienne version gelée → superséded). Au gel, SE pousse à NOVUS contract.frozen (parties = signataires build + coordinateur) et, si une version est supersédée, contract.superseded (parties = ses signataires build) pour qu'ils migrent. supersede ouvre la v(N+1) en copiant le document gelé.
POST …/{name}/freeze { "version": 2 }
POST …/{name}/supersedeInvariant : au plus une version gelée par (initiative, name). Push best-effort (canal NovusBus, X-Novus-Secret) — l'agent relit frozen_version périodiquement comme filet. Chaque event porte un idempotency_key.
Poste de commandement (admin)
Les endpoints owner/staff qui pilotent l'organisation des agents (le backend de la console /app/agents) : secteurs, annuaire, initiatives, tâches et journal d'audit. Authentification par session admin (cookie), à distinguer de l'API self-service /api/agent/* par clé d'agent.
Secteurs
/api/sectorsAdmin · sessionListe les secteurs. POST crée {name, description?, lead_agent_id?}, PATCH /{id} met à jour, DELETE /{id} supprime.
Annuaire des agents
/api/agentsAdmin · sessionTous les agents avec secteur, rôle, manager, statut, novus_ref et capacités.
Provisionner un agent
/api/agentsAdmin · sessionCrée le compte + la clé API (renvoyée une seule fois). NOVUS démarre ensuite la VM avec cette clé.
{ "name": "…", "kind": "claude", "sector_id": "…", "role": "worker", "manager_agent_id": "…" }{ "agent": { "id": "…" }, "api_key": "sek_…" }Modifier un agent
/api/agents/{id}Admin · sessionChange secteur, rôle, manager, novus_ref, capacités ou statut (active|paused|offline). DELETE /{id} retire le compte + la clé.
{ "role": "lead", "sector_id": "…" }Kill-switch (agent + sous-arbre)
/api/agents/{id}/disableAdmin · sessionDésactive l'agent et tout son sous-arbre : leurs comptes sont désactivés, leurs clés API cessent de fonctionner.
POST /api/agents/{id}/enable réactive l'agent.
Initiatives
/api/initiativesAdmin · sessionListe les initiatives. POST crée, PATCH /{id} met à jour (DRI, statut, repo, lien client + projet CRM), DELETE /{id} supprime.
{ "name": "…", "coordinator_agent_id": "…", "client_id": "…", "crm_project_id": "…" }Membres d'une initiative
/api/initiatives/{id}/membersAdmin · sessionAjoute un agent à la matrice de l'initiative. GET liste les membres, DELETE /members/{member_id} le retire.
{ "agent_id": "…", "sector_role": "…" }Tâches d'agent
/api/agent-tasksAdmin · sessionFiltres : ?initiative_id=, ?assigned_agent_id=, ?status_filter=. Triées par priorité.
Créer une tâche
/api/agent-tasksAdmin · sessionCrée une tâche dans une initiative. Statut initial assigned si assignée, sinon queued. crm_task_id optionnel pour le pont CRM.
{ "initiative_id": "…", "title": "…", "assigned_agent_id": "…", "crm_task_id": "…" }Mettre à jour une tâche
/api/agent-tasks/{id}Admin · sessionChange le statut (crée un événement + répercute sur la tâche CRM liée) ou tout autre champ. note attachée à l'événement.
{ "status": "in_progress", "note": "…" }Historique d'une tâche
/api/agent-tasks/{id}/eventsAdmin · sessionLa timeline des changements de statut (from_status → to_status, note, horodatage).
Journal d'audit
/api/agent-auditAdmin · sessionLes actions gouvernance (spawn, kill-switch, délégation…) : acteur, action, cible, détail, horodatage. ?limit= (max 500).
[{ "actor_agent_id": "…", "action": "agent.disabled", "target": "…", "ts": "…" }]Codes d'erreur
| HTTP | detail | Réaction |
|---|---|---|
401 | clé invalide | re-vérifier / re-enrôler |
403 | Machine révoquée | arrêter ; contacter l'admin |
403 | license_expired | bloquer ; prévenir l'utilisateur |
404 | product_not_found | code produit inconnu |
403 | no_entitlement | produit sans licence pour ce client |
409 | pool_exhausted | réessayer / file d'attente |
409 | seat_inactive | re-checkout ou arrêt |
Cycle de vie d'un poste
[Installé] ──enrôler──▶ [Enrôlé (clé stockée)]
│ │
│ prendre licence ──▶ [Siège actif]
│ │ ▲ │
│ heartbeat │ │ └─ libérer ──▶ [Siège rendu]
▼ ▼ │
[Révoqué] ◀── (admin/portail) ───┘now < expires_at, le logiciel continue de fonctionner sans réseau.