Files
IdeA/.ideai/tickets/77/carnet.md
Blomios 1be5f8cd18 chore(ideai): #77 en QA, ouverture de #78 et #79
Enregistre l'état du registre de tickets : #77 entre en QA, #78 ouvre la
dette UX de langue de l'écran Settings et #79 le test flaky relevé en
cours de route.

État runtime uniquement : aucun code de feature n'est touché.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:27:06 +02:00

11 KiB

issueRef, version, updatedBy, updatedAt
issueRef version updatedBy updatedAt
#77 4
kind agent_id
agent a6ced819-b893-4213-b003-9e9dc79b9641
1784287453378

Carnet #77 — cadrage UX + Architecture (2026-07-17)

Le design produit est dans la description du ticket. Ce carnet porte le cadrage, validé UX puis Architect. UX est passée avant Architect (la forme conditionnait les DTO).


Arbitrages Main

Affichage du code — groupement visuel uniquement. UX proposait AB12-CD34. Refusé. Le code réel n'a pas de séparateur, et la normalisation frontend de #75 supprime les espaces mais pas les tirets : l'utilisateur recopierait le tiret et l'appairage échouerait. Groupement en deux blocs de 4 par espacement typographique/CSS, valeur copiée = AB12CD34.

#76 est absorbé dans ce lot (Architect, confirmé Main) : normalisation serveur (espaces, tirets, puis uppercase) avant comparaison. La sécurité ne doit pas dépendre de la normalisation frontend.

already_used n'est pas exposé (tranché par Architect, périmètre sécurité). L'API répond invalid_or_expired pour code absent, expiré, déjà consommé, remplacé ou incorrect — pas d'oracle pour un attaquant. Les logs internes peuvent distinguer ; le DTO public jamais.

TTL du code : 10 minutes (UX, confirmé Architect).


Spec UX — surface « Appareils »

Écran unique, mobile-first, monté dans l'UI web ET l'app desktop. Navigation : Paramètres → Appareils. Même écran, même vocabulaire, mêmes actions des deux côtés.

Liste — par ligne : nom (niveau principal), badge Cet appareil pour la session courante, dernière activité (Actif à l'instant, Aujourd'hui à 14:32, Hier, puis date courte), date d'appairage en secondaire discret. Jamais de User-Agent brut, jamais d'IP.

Appairer — action primaire en haut. Panneau : code grand et lisible, bouton Copier, Saisissez ce code sur le nouvel appareil., Expire dans 10 min. À expiration : Ce code a expiré. + bouton Générer un nouveau code, sans alarmisme. Après succès : Nouvel appareil appairé, liste rafraîchie, highlight temporaire 2-3 s.

Nom d'appareil — saisi sur le nouvel appareil au moment de l'appairage, prérempli par dérivation lisible (iPhone, Chrome sur Windows), obligatoire, 1-40 caractères, renommable ensuite via le menu .

Révocation — menu Révoquer. Confirmation : Révoquer cet appareil ? / Il devra être appairé à nouveau pour accéder à IdeA. Si c'est l'appareil courant : Vous serez déconnecté immédiatement. puis coupure et redirection vers l'écran d'appairage. Révoquer tous les appareils en bas, confirmation forte, n'exige pas de lire la liste, inclut l'appareil courant.

ErreursCode invalide ou expiré. (incorrect/expiré/utilisé, message unique) ; Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.


Cadrage Architecture

Persistance — sous-domaine d'accès mono-utilisateur

  • domain : PairedDevice, DeviceId, SessionTokenHash, DeviceName, PairingCode.
  • application : PairDevice, ListDevices, RenameDevice, RevokeDevice, RevokeAllDevices, AuthenticateSession, TouchDevice.
  • port : DeviceSessionStoreadapter : FsDeviceSessionStore.

Store dans {app_data_dir}/security/devices.json, pas dans .ideai/ projet : ce sont des accès à l'instance, pas des données du repo.

{ "version": 1, "devices": [ { "deviceId": "uuid", "name": "…", "pairedAt": "…", "lastSeenAt": "…", "sessionTokenHash": "sha256:…" } ] }

Token : 32 octets CSPRNG. Hash disque SHA-256("idea-session-v1\0" || token_bytes), hex préfixé par l'algo, comparaison constant-time.

Pas d'Argon2id — décision Architect, et elle est juste : ce n'est pas un mot de passe faible mais un secret aléatoire 256-bit. Un KDF lent n'ajoute rien contre la préimage et coûte du CPU serveur à chaque requête. Le point dur reste : jamais de token en clair sur disque.

DTO UI : { deviceId, name, pairedAt, lastSeenAt, isCurrentDevice }. Pas d'IP, pas de User-Agent.

Code éphémère

Ne va pas dans le store persistant — reste en mémoire process, mais sort du pairing_code: String statique de ServerState. État : { code_hash, expires_at, generation_id, used }. Consommation atomique consume(code, now) -> Valid | InvalidOrExpired. Générer invalide toujours le précédent. Sans --new-code, aucun code n'existe au boot.

Desktop Tauri : commande appelant le même use case via le composition root, pas via HTTP.

À supprimer : EmbeddedServerHandle.pairing_code / EmbeddedServerStatusDto.pairing_code comme source permanente.

Révocation → WebSockets (durcissement n°4)

Le domaine publie DeviceRevoked { device_id } / AllDevicesRevoked. Le web-server (adapter transport) tient un ActiveConnectionRegistry: device_id -> Vec<ConnectionHandle>. AuthenticateSession retourne AuthenticatedDevice { device_id, token_hash }pas un booléen — pour que run_ws_connection s'enregistre sous le bon device_id.

Séquence : le use case supprime du store → publie l'événement → l'adapter observe → ferme les WS concernés via un canal shutdown que la boucle sélectionne en parallèle de read_ws_frame, puis unregister ws_pty_bridge comme aujourd'hui. OutputBridge reste un outil de flux PTY, jamais le mécanisme d'autorisation.

Rate-limit

Port PairAttemptLimiter dans l'application, appelé par PairDevice avant validation du code. Adapter prod InMemoryPairAttemptLimiter, adapter test à horloge fixe. Clé RateLimitKey { origin, route }pas de headers HTTP dans le port, la résolution proxy reste dans l'adapter. Repères : 5 échecs/min par origine, 30 échecs/min global.

HttpOnly, SameSite=Strict, Secure selon config existante, Path=/, Max-Age≈34560000 (400 j). Renouvellement glissant sur toute requête authentifiée réussie. Seule la révocation invalide. lastSeenAt throttlé (≤ 1 écriture / 5 min / appareil).

--new-code

Bool dans ServerArgs. Après ServerState créé et listener bindé : génération + impression uniquement dans ce cas. Ne touche pas le store, ne crée pas d'appareil, ne change aucune config.


Lots

Lot Contenu Dépend de
B1 DeviceSessionStore, hash tokens, PairDevice {code, name}, cookie Max-Age + renouvellement, normalisation serveur (#76) — (bloquant)
B2 Code éphémère, POST /api/pairing-code, --new-code, suppression impression au boot, TTL/usage unique/invalidation, invalid_or_expired B1
B3 Endpoints list/rename/revoke/revoke-all/logout, event DeviceRevoked, registre WS par device_id, fermeture immédiate + cleanup OutputBridge B1
B4 Port PairAttemptLimiter, adapter mémoire + tests horloge, réponse rate_limited parallèle à B2
F1 POST /api/pair {code, name}, normalisation frontend espaces et tirets, erreurs contrat B1
F2 Écran Appareils partagé web/desktop DTO figés

Écart UX ↔ Architecture : aucun sur la forme des DTO.


État d'avancement (2026-07-17)

  • B1 — vert, vérifié par Main (le rapport de DevBackend a été perdu par un timeout de rendez-vous ; les tests ont été rejoués indépendamment). cargo test -p domain -p application -p infrastructure -p web-server intégralement vert. Couverture réelle constatée : store (round-trip, fichier absent, JSON corrompu), session_token_hash_is_prefixed_sha256_and_verifies_constant_time, authenticated_invoke_renews_session_cookie_max_age, pairing_code_normalization_removes_spaces_hyphens_and_uppercases, touch_device_is_throttled_to_five_minutes, validation du nom.
  • F1 + F2 — verts sur mock, 91 fichiers / 841 tests. tsc --noEmit à 0. Garde-fous no-direct-invoke et desktop-only verts. test:bundle-transport confirme dist = tauri, dist-web = http.
  • B2 + B4 — en cours.
  • B3 — à lancer.

Contrats figés par Main après F1/F2

F1/F2 ont été livrés avant B3. DevFrontend a dû déduire des contrats que le cadrage ne nommait pas ; je les fige tels quels, ils sont cohérents avec les routes déjà nommées par Architect. B3 s'y conforme — si divergence, c'est le backend qui s'aligne, pas le frontend.

Routes REST

Route Usage
GET /api/devices liste
POST /api/devices/{id}/rename renommage
POST /api/devices/{id}/revoke révocation d'un appareil
POST /api/devices/revoke-all révocation totale
POST /api/pairing-code génération d'un code
POST /api/pair {code, name}

Raisonnement retenu (DevFrontend, validé Main) : la surface d'authentification ne peut pas vivre sur le RPC générique /api/invoke, qui est lui-même auth-gated. Elle vit donc sur des routes dédiées, comme /api/pair et /api/logout aujourd'hui.

Commandes Tauri

list_devices, create_pairing_code, rename_device, revoke_device, revoke_all_devices.

Notification d'appairage — polling, pas d'event

UX demande « nouvel appareil appairé → liste rafraîchie + highlight ». Décision Main : pas d'event DevicePaired côté B3. L'UI poll listDevices() toutes les 3 s uniquement pendant que le panneau de code est ouvert (≤ 10 min, jamais en régime permanent), pour un acte rare. Un event domaine routé jusqu'au WS live serait plus propre en théorie, mais ajoute une surface et du code client pour un gain invisible. Option notée, non retenue.

Choix de sécurité frontend à préserver

Le message d'erreur du serveur n'est jamais réaffiché : l'UI mappe sur le code (invalid_or_expired, rate_limited) vers les libellés figés. Si un backend écrivait un jour « code déjà utilisé » dans message, un pont qui forwarde réintroduirait l'oracle que le ticket interdit. Le mapping rend la fuite structurellement impossible plutôt que d'en faire une affaire de discipline. Un test verrouille ce point — ne pas le contourner.

Écart B1 → corrigé en B2

new_session_token() concaténait deux UUID v4 au lieu d'un CSPRNG (aucune crate rand dans web-server). Pas une faille — UUID v4 tire de getrandom, 244 bits effectifs — mais écart au cadrage sur un chemin de sécurité, et construction que le prochain lecteur devrait re-vérifier pour se rassurer. Correction demandée dans B2.

Hors périmètre, consigné ailleurs

  • Langue mélangée des sections Settings desktop (« Appareils » vs « AI Profiles », « Deployment ») → #78, décision UX de fond (règle de langue de l'UI, éventuel i18n).
  • ConfirmDialog laissée locale à la feature plutôt qu'ajoutée au design system : décision UX/Architect, pas un effet de bord de ce ticket.

Dette de topologie à traiter par Git

B1, F1 et F2 ont été implémentés directement sur develop, sans branche de feature — erreur de cadrage de Main, qui a lancé les devs sans passer par Git. Le travail est sain et non commité. Git doit ranger ça avant tout commit, sachant que develop porte déjà 37 commits non poussés.