diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index 7fad2e8..daf7226 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -8255,10 +8255,400 @@ "ticket_id": "690948d6-4063-4d28-ba3c-a6b6545f03c0", "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" }, - "state": "running", + "state": "completed", "wakePolicy": "recordOnly", "createdAtMs": 1784277549807, - "updatedAtMs": 1784277549807, + "updatedAtMs": 1784277679408, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784277679408, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**#75 est intégré dans `develop` (`9463b7e`). Working tree propre, aucun push.**\n\n## Ce que j'ai commité\n\nDeux commits, sur une branche dédiée `feature/ticket75-pairing-code-keyboard` créée depuis `develop` :\n\n- `120d0d1 — fix(frontend)` : le code du fix (`PairingScreen.tsx`) et son test (`PairingScreen.test.tsx`). Je les ai gardés ensemble parce qu'ils forment une seule intention — le fix et son garde-fou, livrés par le même lot. Contrairement à #74, où le test venait d'un lot QA distinct et méritait son propre commit.\n- `481a7e2 — chore(ideai)` : tout l'état runtime du registre de tickets (clôture #74/#68, ouverture #75/#76).\n\n**Un arbitrage à te signaler sur le découpage.** Tu m'as demandé de séparer le code de l'état runtime, ce que j'ai fait, mais je n'ai pas poussé la séparation plus loin *à l'intérieur* de l'état runtime — alors que trois histoires s'y mélangent (la clôture de #74/#68 est la queue de comète du merge précédent, #76 est une dette backend sans rapport avec #75). La raison est concrète : dans `index.json`, l'entrée `#74` et les créations `#75`/`#76` sont **contiguës dans le même hunk**. Les séparer demandait d'éditer à la main un fichier généré par l'app, et j'aurais produit un commit intermédiaire où l'index contredit les `issue.md` sur disque. Un historique joli au prix d'un état incohérent est un mauvais échange. Le message du commit nomme les trois mouvements.\n\n## Ce que j'ai vérifié avant de merger\n\nJ'ai rejoué la suite moi-même plutôt que de merger sur la foi du rapport : `npx tsc --noEmit` sort en 0, `npx vitest run` donne **88 fichiers / 801 tests passés** en 16.89s. Les chiffres de DevFrontend sont exacts.\n\n## Ce que j'ai mergé\n\n`9463b7e` — merge `--no-ff` vers `develop`, sans conflit et sans rebase préalable (`develop` n'avait pas bougé depuis `aec4293`). Branche de feature supprimée.\n\n## État final\n\n`develop` est sur `9463b7e`, **en avance de 37 commits sur `origin/develop`**, arbre propre, aucune branche de feature en cours. Rien n'est poussé, conformément à mon périmètre local.\n\n## Ce qui reste en suspens\n\nLe fix **revient sur la décision de #69**, qui avait choisi `inputMode=\"numeric\"` en supposant un code purement numérique. C'est assumé et documenté dans le commit, mais si #69 est encore ouvert quelque part, son hypothèse est désormais fausse — ça vaut un coup d'œil de Main.\n\nLe **statut de #75 reste à `qa`** : la clôture formelle n'est pas ma décision. Et **#76** (casse du code d'appairage côté serveur) reste `open` — c'est la dette que la normalisation frontend masque pour l'instant ; tant qu'elle n'est pas traitée, tout autre client de l'API d'appairage reste exposé au problème.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "4104c7ef-6f81-4213-a7f6-47d189574fb1", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "c3e0d9d8-df36-4b00-b271-62d8aa78892c", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "c3e0d9d8-df36-4b00-b271-62d8aa78892c", + "ticket_id": "387732cc-e7e8-4e4f-afa2-9e5ec9f0b271", + "conversation_id": "652e01c1-67a5-0913-0272-fc456de31f6d" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784279233365, + "updatedAtMs": 1784279284217, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784279284217, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Spec UX Ticket #77**\n**Intention**\nCréer une surface unique “Appareils” accessible dans l’UI web et l’app desktop, mobile-first, pour gérer les appareils appairés sans redémarrer le serveur.\n\n**Navigation**\n- Entrée : `Paramètres` → `Appareils`.\n- Sur mobile web : entrée visible dans le menu principal ou menu de compte/app, libellé `Appareils`.\n- Même écran côté desktop, même vocabulaire, mêmes actions.\n\n**Liste Des Appareils**\nChaque ligne affiche uniquement :\n- Nom de l’appareil, niveau principal.\n- Badge `Cet appareil` si c’est la session courante.\n- Dernière activité : `Actif à l’instant`, `Aujourd’hui à 14:32`, `Hier`, puis date courte.\n- Date d’appairage en secondaire discret : `Appairé le 17 juil. 2026`.\n\nÀ éviter : User-Agent brut, IP, détails navigateur verbeux. Bruit technique inutile.\n\nDisposition mobile :\n```text\nAppareils\n[Appairer un appareil]\n\nCet appareil\niPhone d'Anthony [⋯]\nActif à l'instant\nAppairé le 17 juil. 2026\n\nMacBook bureau [⋯]\nDernière activité hier\nAppairé le 12 juil. 2026\n\n[Révoquer tous les appareils]\n```\n\nDesktop : même hiérarchie, éventuellement en liste/table compacte, mais pas une surface différente.\n\n**Appairer Un Nouvel Appareil**\nAction primaire en haut : `Appairer un appareil`.\n\nAu clic :\n- Génère un nouveau code.\n- Affiche un panneau/modal avec :\n - titre `Appairer un appareil`\n - code grand, lisible, groupé : `AB12-CD34`\n - bouton `Copier`\n - texte : `Saisissez ce code sur le nouvel appareil.`\n - durée : `Expire dans 10 min`\n\nTTL UX retenu : **10 minutes**. C’est court côté sécurité, mais assez confortable pour prendre le téléphone, ouvrir l’URL et saisir le code.\n\nExpiration :\n- À `00:00`, remplacer le code par l’état :\n - `Ce code a expiré.`\n - bouton primaire `Générer un nouveau code`\n- Ne pas afficher d’alerte anxiogène.\n- Si un nouveau code est généré, l’ancien disparaît immédiatement de l’UI.\n\nAprès appairage réussi :\n- Le panneau passe en succès bref : `Nouvel appareil appairé`.\n- La liste se rafraîchit.\n- Le nouvel appareil apparaît en haut ou à sa position normale avec un highlight temporaire 2-3 s.\n- Nom initial affiché selon la règle ci-dessous.\n\n**Identité D’un Appareil**\nDécision : nom saisi par l’utilisateur au moment de l’appairage, renommable après coup.\n\nParcours sur le nouvel appareil :\n1. saisie du code ;\n2. champ `Nom de cet appareil` ;\n3. valeur proposée dérivée simplement si possible : `iPhone`, `Android`, `Chrome sur Windows`, mais éditable ;\n4. bouton `Appairer`.\n\nRègles :\n- Le nom est obligatoire, mais prérempli.\n- Ne jamais exposer le User-Agent brut.\n- Action `Renommer` disponible dans le menu `⋯` de chaque appareil.\n- Longueur recommandée : 1 à 40 caractères.\n\n**Révocation**\nPar appareil :\n- Menu `⋯` → `Révoquer`.\n- Confirmation :\n - titre `Révoquer cet appareil ?`\n - texte `Il devra être appairé à nouveau pour accéder à IdeA.`\n - bouton destructif `Révoquer`\n - bouton secondaire `Annuler`\n\nSi l’appareil révoqué est l’appareil courant :\n- Confirmation spécifique :\n - titre `Révoquer cet appareil ?`\n - texte `Vous serez déconnecté immédiatement.`\n- Après confirmation : couper la session, fermer/rejeter les connexions actives, rediriger vers l’écran d’appairage.\n\nRévoquer tout :\n- Bouton destructif en bas de l’écran : `Révoquer tous les appareils`.\n- Ne demande pas de lire la liste.\n- Confirmation forte :\n - titre `Révoquer tous les appareils ?`\n - texte `Tous les appareils seront déconnectés immédiatement, y compris celui-ci. Pour revenir, il faudra appairer un appareil à nouveau.`\n - bouton destructif `Tout révoquer`\n - bouton secondaire `Annuler`\n- Après confirmation depuis le web : déconnexion immédiate et retour écran d’appairage.\n\n**Erreurs D’Appairage**\nFormulations non bavardes, sans aider un attaquant :\n\n- Code expiré :\n `Ce code n’est plus valide. Demandez un nouveau code depuis un appareil déjà appairé.`\n\n- Code déjà utilisé :\n `Ce code n’est plus valide. Demandez un nouveau code depuis un appareil déjà appairé.`\n\n- Code incorrect :\n `Code invalide ou expiré.`\n\n- Trop de tentatives :\n `Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.`\n\nMême message possible pour incorrect/expiré/utilisé côté API si Architect veut limiter l’oracle. L’UI peut spécialiser seulement quand le serveur expose explicitement un état sûr.\n\n**Implications DTO Pour Architect**\nPour afficher l’écran, il faut au minimum :\n- `deviceId`\n- `name`\n- `pairedAt`\n- `lastSeenAt`\n- `isCurrentDevice`\n\nPour le code :\n- `code`\n- `expiresAt`\n- éventuellement `ttlSeconds`\n\nPour les erreurs d’appairage, prévoir des codes stables mais non bavards côté client :\n- `invalid_or_expired`\n- `rate_limited`\n- éventuellement `already_used` seulement si accepté comme signal non sensible, sinon le mapper vers `invalid_or_expired`.\n\n**Critères D’Acceptation**\n- L’écran est utilisable confortablement sur téléphone.\n- L’UI web permet de générer un code sans desktop.\n- Aucun libellé ne parle d’“utilisateurs”.\n- Révoquer l’appareil courant déconnecte immédiatement.\n- Révoquer tout ne nécessite pas d’inspecter la liste.\n- Le nom d’appareil est compréhensible, éditable, et jamais un User-Agent brut.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "a9eadfef-5556-4d1a-a00d-65f8a036c9ef", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "64b22654-40bd-4675-8d15-fc02630e8e0e", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784279322532, + "updatedAtMs": 1784279422699, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784279422699, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "J’ai lu #77 et vérifié les points d’entrée cités. Cadrage proposé.\n\n**Décision Transverse**\n\nOui, j’absorberais #76 dans ce lot : normalisation serveur du code avec suppression des espaces et tirets, puis uppercase, avant comparaison. C’est cohérent avec le refus du code réel groupé et évite que la sécurité dépende de la normalisation frontend.\n\nPour `already_used`, je tranche côté sécurité : ne pas l’exposer. Réponse API stable `invalid_or_expired` pour code absent, expiré, déjà consommé, remplacé ou incorrect. `rate_limited` uniquement pour le rate-limit. Les logs internes peuvent distinguer `already_used`, jamais le DTO public.\n\n**1. Persistance Appareils/Sessions**\n\nÀ modéliser comme un sous-domaine d’accès mono-utilisateur, pas comme de l’UI :\n\n- `domain`: entités/VO purs `PairedDevice`, `DeviceId`, `SessionTokenHash`, `DeviceName`, `PairingCode`.\n- `application`: use cases `PairDevice`, `ListDevices`, `RenameDevice`, `RevokeDevice`, `RevokeAllDevices`, `AuthenticateSession`, `TouchDevice`.\n- port domaine/application : `DeviceSessionStore`.\n- adapter infra : `FsDeviceSessionStore`.\n\nStore dans le data dir applicatif, pas dans `.ideai/` projet : par exemple `{app_data_dir}/security/devices.json`. Ce sont des accès à l’instance IdeA, pas des données du repo.\n\nFormat v1 :\n\n```json\n{\n \"version\": 1,\n \"devices\": [\n {\n \"deviceId\": \"uuid\",\n \"name\": \"Chrome sur Windows\",\n \"pairedAt\": \"2026-07-17T...\",\n \"lastSeenAt\": \"2026-07-17T...\",\n \"sessionTokenHash\": \"sha256:...\"\n }\n ]\n}\n```\n\nToken : remplacer `Uuid+Uuid` par 32 octets CSPRNG encodés base64url sans padding ou hex 64. Hash disque : `SHA-256(\"idea-session-v1\\0\" || token_bytes)`, stocké en hex avec préfixe algo.\n\nPourquoi pas Argon2id : ce n’est pas un mot de passe faible, c’est un secret aléatoire 256-bit. Un KDF lent n’ajoute presque rien contre la préimage et crée du coût serveur inutile. Le point dur est : token jamais en clair sur disque, comparaison constant-time du hash.\n\nDTO UI appareil :\n\n```ts\n{\n deviceId: string;\n name: string;\n pairedAt: string;\n lastSeenAt: string;\n isCurrentDevice: boolean;\n}\n```\n\nPas d’IP, pas de User-Agent.\n\n**2. Code Éphémère**\n\nLe code ne va pas dans le store persistant. Il reste en mémoire process, mais sort du `pairing_code: String` statique de `ServerState`.\n\nPort/use case :\n\n- `PairingCodeIssuer` ou service applicatif process-local injecté dans `ServerState`.\n- état mémoire : `{ code_hash, expires_at, generation_id, used }`.\n- TTL : 10 minutes.\n- génération à la demande invalide toujours le précédent.\n- usage unique : consommation atomique `consume(code, now) -> Valid | InvalidOrExpired`.\n\nNe pas persister le code ni son hash. Le flag `--new-code` appelle la même génération mémoire après démarrage du serveur et imprime le code. Sans flag, aucun code n’existe au boot.\n\nRoutes à ajouter :\n\n- `POST /api/pairing-code` authentifiée : génère un code, retourne `{ code, expiresAt, ttlSeconds: 600 }`.\n- desktop Tauri : commande équivalente qui appelle le même use case via le backend/composition root, pas via HTTP.\n- `POST /api/pair` devient `{ code, name }`.\n\n**3. Révocation Et WebSockets**\n\nNe pas faire fuiter WS dans le domaine. Le domaine/application publie un événement applicatif :\n\n- `DeviceRevoked { device_id }`\n- `AllDevicesRevoked`\n- éventuellement `SessionRevoked { device_id }`\n\nLe web-server, adapter entrant/transport, maintient un registre transport :\n\n```rust\nActiveConnectionRegistry {\n device_id -> Vec\n}\n```\n\nAu `validate_ws_upgrade`, `AuthenticateSession` doit retourner une `AuthenticatedDevice { device_id, token_hash }`, pas seulement booléen. `run_ws_connection` enregistre la connexion sous ce `device_id`.\n\nQuand `/api/devices/{id}/revoke`, `/api/logout`, ou `/api/devices/revoke-all` réussit :\n\n1. le use case supprime du `DeviceSessionStore`,\n2. publie l’événement,\n3. l’adapter HTTP/WS observe l’événement,\n4. il ferme les connexions WS concernées.\n\nConcrètement, la fermeture peut se faire via un canal `shutdown` par connexion, que la boucle `run_ws_connection` sélectionne en parallèle de `read_ws_frame`. À la fermeture, elle unregister `ws_pty_bridge` comme aujourd’hui. `OutputBridge` reste un outil de flux PTY, pas le mécanisme d’autorisation.\n\nPour les requêtes HTTP, `AuthenticateSession` revalide à chaque `/api/invoke`. Pour WS, l’autorisation est liée au `device_id` capturé à l’upgrade puis coupée par événement de révocation.\n\n**4. Rate-Limit `POST /api/pair`**\n\nLe rate-limit vit dans l’application comme port testable, avec adapter mémoire dans le web-server :\n\n- port : `PairAttemptLimiter`.\n- use case `PairDevice` l’appelle avant validation du code.\n- adapter prod initial : `InMemoryPairAttemptLimiter`.\n- adapter test : horloge fixe + fake limiter.\n\nGranularité recommandée :\n\n- par origine réseau normalisée : IP client réelle après validation reverse-proxy, sinon peer IP.\n- plus un bucket global faible pour éviter le brute force distribué simple.\n- exemple : 5 échecs / minute par origine, 30 échecs / minute global, reset court.\n- succès ou code régénéré peut nettoyer le bucket local, mais ce n’est pas nécessaire.\n\nLe port doit prendre `RateLimitKey { origin, route }`, pas des headers HTTP. La résolution proxy reste dans l’adapter HTTP.\n\n**5. Cookie**\n\nCookie long mais serveur source de vérité :\n\n- `HttpOnly`\n- `SameSite=Strict`\n- `Secure` selon config existante\n- `Path=/`\n- `Max-Age=34560000` environ 400 jours\n\nRenouvellement glissant : sur toute requête authentifiée réussie (`/api/invoke`, endpoints devices, possiblement refresh statique non nécessaire), renvoyer un `Set-Cookie` avec même token et Max-Age remis à 400 jours. Le serveur ne donne pas d’expiration métier à la session ; révocation seule invalide.\n\nLe `lastSeenAt` se met à jour avec throttling pour éviter d’écrire le JSON à chaque frame/requête : par exemple au plus une fois par 5 minutes par appareil.\n\n**6. `--new-code`**\n\nBranchement dans `ServerArgs` :\n\n- ajouter bool `new_code`.\n- usage : `idea --serve ... --new-code`.\n- après `ServerState` créé et listener bindé, appeler `generate_pairing_code(ttl=600s)`.\n- imprimer uniquement dans ce cas : `IdeA pairing code: ABCD1234`.\n\nGarantie “rien de persistant” :\n\n- `--new-code` ne modifie pas le store.\n- il ne crée pas d’appareil.\n- il ne change pas une config.\n- à chaque redémarrage avec le flag, un code mémoire TTL 10 min existe, invalide le précédent du même process par construction.\n- si laissé dans systemd, ça imprime un code à chaque démarrage mais uniquement éphémère et usage unique, comme accepté par #77.\n\nIl faut supprimer `EmbeddedServerHandle.pairing_code`/`EmbeddedServerStatusDto.pairing_code` comme source permanente. Le desktop doit demander explicitement un nouveau code via l’écran Appareils.\n\n**7. Lots B/F**\n\nLot B1 backend socle, bloquant :\n- `DeviceSessionStore` + hash tokens + migration depuis sessions mémoire sans compat nécessaire.\n- `PairDevice { code, name }`.\n- cookie Max-Age + renouvellement.\n- normalisation serveur code (#76).\n- tests unitaires store/hash/cookie.\n\nLot B2 code éphémère :\n- remplacer `pairing_code: String`.\n- endpoints génération code authentifiée.\n- `--new-code`.\n- suppression impression au boot hors flag.\n- TTL 10 min, usage unique, invalidation précédent.\n- erreurs `invalid_or_expired`.\n\nLot B3 révocation :\n- endpoints list/rename/revoke/revoke-all/logout-current.\n- event `DeviceRevoked`.\n- registre WS par `device_id`.\n- fermeture immédiate WS et cleanup `OutputBridge`.\n\nLot B4 rate-limit :\n- port `PairAttemptLimiter`.\n- adapter mémoire + tests horloge.\n- réponse `rate_limited`.\n\nLot F1 web session/contracts :\n- `POST /api/pair { code, name }`.\n- normalisation frontend espaces + tirets.\n- gestion erreurs `invalid_or_expired`, `rate_limited`.\n\nLot F2 écran Appareils partagé web/desktop :\n- liste devices.\n- générer code, affichage en deux blocs visuels de 4 sans tiret copiable.\n- renommer.\n- révoquer courant, révoquer autre, révoquer tout.\n- redirection appairage sur révocation courante ou 401/WS close auth.\n\nParallélisable :\n- F2 peut commencer sur DTO mock après validation des routes.\n- B1 et B2 doivent précéder l’intégration réelle de pairing.\n- B3 dépend de B1 pour `device_id`.\n- B4 peut avancer en parallèle de B2, mais doit s’insérer avant validation code dans `PairDevice`.\n\nÉcart avec UX : aucun sur la forme des DTO. Seule précision sécurité : `already_used` doit rester indiscernable de `invalid_or_expired`.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "0939ce1e-8490-4e9b-8382-c095cf2cd774", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "6bc3f343-9e50-4ede-8b26-256233bb0336", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784283700869, + "updatedAtMs": 1784284300912, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784284300912, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "5d252215-fc7c-4605-a88f-b698124110fb", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "e8297d3e-efa7-4bb2-9993-7da162885f0a", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784283845793, + "updatedAtMs": 1784284705729, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784284705729, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "F1 et F2 sont livrés et verts sur mock. Trois écarts de contrat demandent ton arbitrage — tous côté B3, aucun sur les DTO.\n\n## Ce que j'ai changé\n\n**F1 — session et contrats**\n\n`normalizePairingCode()` vit maintenant dans `domain/` (`frontend/src/domain/index.ts`) : elle enlève espaces **et tirets** puis met en majuscules. Je l'ai sortie de `PairingScreen` parce que c'est la règle canonique du code, pas une astuce de formulaire. `WebSession.pair(code, name)` poste `{code, name}` sur `/api/pair`.\n\nSur les erreurs, j'ai fait un choix que tu dois valider : **le message du serveur n'est jamais réaffiché**. Je mappe sur le code de la réponse (`invalid_or_expired` / `rate_limited`) vers les libellés figés au carnet. La raison est la décision de sécurité elle-même : si un futur backend écrit « code déjà utilisé » dans son `message`, un pont qui forwarde le message réintroduit l'oracle que le ticket interdit. Le mapping le rend structurellement impossible. Un test verrouille ce point.\n\nLe champ `Nom de cet appareil` est prérempli par `deriveDeviceName()` (`features/web/deviceName.ts`), pure et pilotée par une chaîne UA — donc testable sans navigateur. Elle rend `iPhone`, `Android`, `Chrome sur Windows`, et `Edge sur Windows` (l'UA d'Edge contient aussi `Chrome/` et `Safari/` ; les nommer tous « Chrome » rendrait deux navigateurs d'une même machine indiscernables dans la liste). Un test balaie tous les UA et vérifie qu'aucun fragment brut ne ressort.\n\n**F2 — écran Appareils**\n\nNouveau port `DeviceGateway` avec ses trois adapters : `TauriDeviceGateway` (desktop, via le composition root), `HttpDeviceGateway` (web, routes REST), `MockDeviceGateway`. L'écran `features/devices/DevicesScreen.tsx` est monté tel quel des deux côtés : section `Appareils` dans `SettingsView` (la liste des sections pilote déjà le menu, donc rien d'autre à câbler) et bouton `Appareils` dans le header de `WebApp`.\n\nJ'ai ajouté une primitive `request()` à `HttpInvoker` plutôt qu'un client HTTP séparé : le mapping 401 → retour appairage et la préservation de l'`ErrorDto` restent à un seul endroit, et `invoke()` délègue dessus.\n\nSur le code : les deux blocs de 4 sont deux `` séparés par du CSS, `aria-label` porte la valeur brute (un lecteur d'écran doit entendre ce qu'il faut taper), et `Copier` met `AB12CD34`. Un test assert que la valeur copiée matche `/^[0-9A-F]{8}$/` et ne contient ni tiret ni espace.\n\nSur la révocation de l'appareil courant, j'ai résisté à faire parler l'écran partagé à `WebSession` : le hook lit `isCurrentDevice` **avant** l'appel (après, la session est morte et la liste illisible), puis remonte `sessionEnded`. La surface qui monte l'écran décide de ce que ça veut dire — web : `forget()` + coupure WS + retour appairage ; desktop : rien, aucun appareil n'y est jamais `isCurrentDevice`. La révocation **subie** passe par le chemin `onUnauthorized` existant, qui était déjà branché.\n\n## Sortie de test réelle\n\n`npx vitest run` (suite complète) :\n\n```\n Test Files 91 passed (91)\n Tests 841 passed (841)\n Duration 13.99s\n```\n\nLes deux garde-fous, `npx vitest run src/app/no-direct-invoke.test.ts src/features/settings/desktop-only.test.ts` :\n\n```\n Test Files 2 passed (2)\n Tests 6 passed (6)\n```\n\n`npx tsc --noEmit` sort en 0. `npm run build:bundle` puis `npm run test:bundle-transport` :\n\n```\ndist: __IDEA_TRANSPORT__=\"tauri\" (1 marker)\ndist-web: __IDEA_TRANSPORT__=\"http\" (1 marker)\n```\n\nJ'ai ajouté 44 tests (devices 24, deviceName 7, pairing 11, webSession 5 réécrits). Un test existant a échoué et c'était utile : `mock.test.ts` assert la liste exhaustive des gateways, il m'a rappelé de déclarer `device` — je l'ai complété plutôt que contourné.\n\n## Ce que j'ai mocké en attendant B1/B2/B3\n\n`MockDeviceGateway` modélise ce que l'UI peut observer du contrat figé : liste avec un seul `isCurrentDevice`, codes hex 8 majuscules à TTL 600 s où générer remplace le précédent, rename, revoke, revoke-all. Il ne modélise ni le wire ni le cookie — c'est le job de `HttpDeviceGateway`, non couvert par test tant que B3 n'a pas figé ses routes. Tous les tests F2 tournent dessus.\n\n## Écarts de contrat — j'ai besoin de toi\n\n1. **Les chemins REST de B3 ne sont pas figés.** Le carnet nomme `POST /api/pairing-code` explicitement, mais B3 dit seulement « endpoints list/rename/revoke/revoke-all/logout ». J'ai déduit que la surface auth vit sur des routes dédiées (comme `/api/pair` et `/api/logout` aujourd'hui) et non sur le RPC générique `/api/invoke` — logique, puisque `/api/invoke` est lui-même auth-gated et ne peut pas porter la surface qui gère l'auth. J'ai posé `GET /api/devices`, `POST /api/devices/{id}/rename`, `POST /api/devices/{id}/revoke`, `POST /api/devices/revoke-all`. Tout est confiné à `adapters/http/deviceGateway.ts` : si DevBackend choisit autre chose, un seul fichier bouge.\n\n2. **Les noms de commandes Tauri ne sont pas figés non plus.** J'ai pris `list_devices`, `create_pairing_code`, `rename_device`, `revoke_device`, `revoke_all_devices`. Même confinement, dans `adapters/device.ts`.\n\n3. **Rien ne notifie l'UI qu'un appareil vient de s'appairer.** UX exige « Nouvel appareil appairé, liste rafraîchie, highlight 2-3 s », mais B3 ne prévoit aucun event domaine pour ça. Je poll `listDevices()` toutes les 3 s **uniquement pendant que le panneau de code est ouvert** — jamais en régime permanent. Ça marche, mais si tu préfères un event `DevicePaired` côté B3, c'est plus propre et je retire le polling.\n\nDeux points mineurs, pour info : la section desktop s'appelle `Appareils` au milieu de `AI Profiles` et `Deployment` (le vocabulaire est figé par UX, mais la surface Settings est incohérente en langue — c'est un arbitrage UX qui dépasse ce ticket) ; et j'ai écrit une `ConfirmDialog` locale à la feature plutôt qu'ajouter un dialog au design system, parce que c'est une décision UX/Architect, pas un effet de bord de ce ticket.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "f2f11942-2b6e-4fda-990e-1640535ad81a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "3fdc7a01-9f04-49f9-bc3c-f0e9da1c7e89", + "conversation_id": "dd35eeb0-2329-0dfa-3255-df7dba90a0aa" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784284373462, + "updatedAtMs": 1784284464560, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784284464560, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Validation QA B1 terminée. Verdict : **vert sur le périmètre B1**, aucune régression/correction à demander.\n\nCommandes relancées :\n\n- `cargo test --offline -p domain device -- --nocapture` : OK, `2 passed`\n- `cargo test --offline -p application device -- --nocapture` : OK, `4 passed`\n- `cargo test --offline -p infrastructure device_session -- --nocapture` : OK, `3 passed`\n- `cargo test --offline -p web-server pairing -- --nocapture` : OK, `5 passed`\n- `cargo test --offline -p web-server authenticated_invoke_renews_session_cookie_max_age -- --nocapture` : OK, `1 passed`\n- `cargo test --offline -p web-server websocket_upgrade -- --nocapture` : OK, `4 passed`\n- `cargo test --offline -p web-server logout_revokes_session_for_http_and_websocket -- --nocapture` : OK, `1 passed`\n- `cargo test --offline -p backend --lib -- --nocapture` : OK, `41 passed`, `7 ignored`\n\nSuites larges :\n\n- `cargo test --offline -p web-server -- --nocapture` : compile OK, `66 passed`, `2 failed`\n - échecs uniquement sur `run_embedded_stop_shuts_down_accept_loop` et `run_embedded_with_core_uses_injected_core_for_http_invokes`\n - cause réelle : `failed to bind 127.0.0.1:0: Operation not permitted (os error 1)`\n - hors périmètre B1, restriction sandbox locale\n\n- `cargo test --offline -p app-tauri embedded_server -- --nocapture` : compile OK, `9 passed`, `1 failed`\n - échec unique sur `start_is_idempotent_and_stop_stops_running_server`\n - même cause : bind `127.0.0.1:0` interdit par sandbox\n - hors périmètre B1\n\nLecture ciblée faite sur `DeviceSessionStore/FsDeviceSessionStore`, VO domaine, use cases et wiring `web-server` : les points demandés sont couverts par tests verts ou code lu, notamment token 32 octets généré puis hashé `sha256:`, comparaison constant-time côté hash, normalisation serveur espaces+tirets+casse, cookie `Max-Age=34560000` avec renouvellement, nom obligatoire borné, et `lastSeenAt` throttlé à 5 minutes.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "5a540a56-e62e-449e-8803-76cbe6695896", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "7b652059-c5db-4e97-ba23-3f358cce850b", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784284490503, + "updatedAtMs": 1784285090642, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784285090642, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "2156395d-f4ac-4de1-8f85-2247f45c31da", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "75c2b626-4cf9-4038-b4cb-4b751e3d919c", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784285235779, + "updatedAtMs": 1784285836228, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784285836228, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "3f963c10-c33b-42bf-8c3f-ff234734832c", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "ac7212ef-ef45-4f70-8658-8ba4e584920a", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784285928768, + "updatedAtMs": 1784286116073, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286116073, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "`invalid_name` est mappé et l'erreur s'affiche sur le champ du nom — la structure le permettait sans refonte.\n\n## Ce que j'ai changé\n\n**Le mapping** (`adapters/http/webSession.ts`). J'ai ajouté `invalidName` à `PairingErrorCode` et le libellé `Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.` La clause finale n'est pas du remplissage : sans elle, le réflexe est de fermer et de regénérer un code, ce qui est exactement le geste inutile que la correction de B3 rend évitable. Les parenthèses disent quoi corriger sans réafficher le message du serveur — le mapping local est conservé tel quel, comme demandé.\n\nJ'ai câblé `invalid_name` **uniquement sur le code wire**, pas sur le statut 400. Un 400 nu (body illisible, requête malformée) tombe toujours dans `pairingFailed`. Sinon une erreur sans rapport enverrait l'utilisateur éditer un champ qui allait très bien. `invalid_or_expired` et `rate_limited` sont intacts.\n\n**La présentation** (`features/web/PairingScreen.tsx`). Le composant `Field` avait déjà tout : une prop `error` qui remplace le hint, le `role=\"alert\"` et le câblage `aria-describedby`. J'ai donc changé l'état d'erreur de `string` en `{code, message}` et je route : `invalidName` va sur le champ du nom, tout le reste reste en erreur globale. Une trentaine de lignes, aucune restructuration. Le champ du code n'est plus peint en rouge quand c'est le nom qui est en cause.\n\n**Un désalignement client/serveur que j'ai trouvé en vérifiant** (`features/web/deviceName.ts`). Tu m'as demandé de garder les règles alignées sur `DeviceName::new`, donc je suis allé le lire : il fait `trim()` puis `chars().count()` avec la borne 1..=40, c'est-à-dire des **code points**. Mon `clampDeviceName` utilisait `slice(0, 40)`, qui compte des unités UTF-16. Sur un nom contenant des caractères hors du plan multilingue de base (emoji), `slice` pouvait **couper une paire de substitution en deux** et mettre un demi-caractère sur le fil. Corrigé avec `Array.from(...)`, qui itère par code point comme Rust. C'était en dehors de ta demande stricte, mais c'est précisément l'alignement que tu voulais, et le bug était réel.\n\nUne nuance à connaître : le `maxLength={40}` du champ HTML compte lui aussi en UTF-16, donc un nom en emoji est coupé plus tôt que le serveur ne l'exigerait. C'est plus strict que le serveur, donc ça ne peut jamais déclencher `invalid_name` — je l'ai laissé.\n\n## Commande et sortie réelle\n\n`cd frontend && npx vitest run` :\n\n```\n Test Files 91 passed (91)\n Tests 846 passed (846)\n Duration 16.04s\n```\n\n`npx tsc --noEmit` sort en 0.\n\nCinq tests ajoutés. Sur le mapping : `invalid_name` → libellé dédié, un 400 nu ne blâme pas le nom, et le message du serveur n'est pas réaffiché pour `invalid_name` non plus. Sur l'écran : l'erreur de nom est bien attachée au champ (`aria-describedby` pointe le message, `aria-invalid` sur le nom, pas sur le code, et aucun `pairing-error` global), et un code refusé reste une erreur globale sans marquer le nom.\n\nUn détail de méthode : j'ai vérifié que `Input` implémente bien `aria-invalid` avant de m'appuyer dessus dans les assertions — sinon ces deux tests passaient à vide sans rien garantir.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "3078c12c-844a-4850-bffc-75d89f09048d", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "c83fc2e6-9808-4010-8727-3c7e6330ba2e", + "conversation_id": "08336578-5b47-09d2-2f41-5cc483f101f4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286145550, + "updatedAtMs": 1784286563757, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286563757, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict QA**\nJe ne donnerais pas un feu vert “commit + rebuild” tel quel. Les 4 durcissements fonctionnels passent en ciblé et les tests ne sont pas tautologiques sur ces points, mais il reste des signaux rouges autour de la commande standard demandée, de la parallélisation `web-server`, de warnings `clippy` nouveaux, et d’une surface frontend stale sur `pairingCode`.\n\n**Bloquant / À Corriger**\n1. `cargo test -p web-server` échoue en parallèle avec 6 failures. Deux sont les binds sandbox connus, mais 4 autres sont des tests WebSocket qui passent isolés et en mono-thread. C’est une pollution d’état/course de tests, pas un vert fiable.\n\nSortie réelle :\n```text\ntest result: FAILED. 73 passed; 6 failed\n\nfailures:\n tests::run_embedded_stop_shuts_down_accept_loop\n tests::run_embedded_with_core_uses_injected_core_for_http_invokes\n tests::websocket_launch_agent_emits_attached_with_assigned_conversation_id\n tests::websocket_launch_agent_reattach_replays_scrollback_without_respawn\n tests::websocket_open_terminal_emits_attached_with_empty_scrollback\n tests::websocket_upgrade_accepts_valid_cookie_and_origin\n\nassertion `left == right` failed\n left: \"error\"\n right: \"terminal.attached\"\n\ncalled `Option::unwrap()` on a `None` value\n```\n\nEn mono-thread :\n```text\ncargo test -p web-server -- --test-threads=1\ntest result: FAILED. 77 passed; 2 failed\nfailed to bind 127.0.0.1:0: Operation not permitted\n```\n\n2. La commande demandée est rouge :\n```text\ncargo test -p domain -p application -p infrastructure -p web-server\ntest result: FAILED. 275 passed; 10 failed\n```\nLes 10 failures visibles sont hors #77, tous sur `infrastructure::session::openai_compat` avec :\n```text\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\nDonc pas une régression #77, mais la commande attendue n’est pas verte dans ce sandbox.\n\n3. Frontend stale autour du code d’appairage au démarrage. Le Rust a bien supprimé `pairing_code` du status, mais le TS/mock/ancienne UI gardent encore l’ancien modèle :\n- [domain/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/domain/index.ts:193) documente encore `EmbeddedServerStatus.pairingCode`.\n- [DeploymentSettings.tsx](/home/anthony/Documents/Projects/IdeA/frontend/src/features/settings/DeploymentSettings.tsx:337) affiche encore un panneau “Pairing” basé sur `status.pairingCode`.\n- [mock/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/adapters/mock/index.ts:1477) injecte encore `pairingCode: \"MOCK-PAIR-4242\"`.\n- Le test historique `DeploymentSettings.test.tsx` valide encore “start server => copy pairing code”.\n\nÇa contredit le contrat #77 “sans `--new-code`, aucun code n’existe au démarrage”. La prod Rust ne semble pas exposer le secret, mais la surface frontend/mock garde une assertion alignée sur l’ancien comportement.\n\n**Durcissements**\n- TTL 10 min / usage unique / génération invalide l’ancien : OK.\n```text\ncargo test -p web-server pairing_code -- --nocapture\nrunning 4 tests\ntest tests::pairing_code_expires_and_returns_public_invalid_or_expired ... ok\ntest tests::generating_new_pairing_code_invalidates_previous_code ... ok\ntest tests::pairing_code_is_single_use ... ok\ntest tests::pairing_code_endpoint_requires_auth_and_returns_ttl ... ok\n```\n\n- `invalid_or_expired` sans oracle : OK en corps/statut pour absent/expiré/utilisé/faux.\n```text\ncargo test -p web-server invalid_or_expired -- --nocapture\nrunning 2 tests\ntest tests::invalid_or_expired_response_does_not_leak_cause ... ok\n```\n\n- Rate-limit horloge injectée : OK.\n```text\ncargo test -p infrastructure pair_attempt_limiter -- --nocapture\nrunning 2 tests\ntest pair_attempt_limiter::tests::limits_five_failures_per_origin_per_minute_with_injected_clock ... ok\ntest pair_attempt_limiter::tests::limits_thirty_failures_globally_per_minute ... ok\n```\n\n- Tokens hashés sur disque : OK, vérifié via le vrai `FsDeviceSessionStore`.\n```text\n\"sessionTokenHash\": \"sha256:f9714c2b7b3b0a332476f55ba0f022424a68acfea50781cfcf128c2cb94d3433\"\ncontains_raw_hex=false\ncontains_sha256_prefix=true\n```\n\n- Révocation coupe WS A sans couper B : OK, le test fait réellement A/B.\n```text\ncargo test -p web-server closes -- --nocapture\nrunning 2 tests\ntest tests::revoke_all_closes_all_active_connections ... ok\ntest tests::device_revocation_closes_only_matching_active_connections ... ok\n```\n\n**Autres Commandes**\nFrontend :\n```text\nnpx vitest run\nTest Files 91 passed (91)\nTests 846 passed (846)\n\nnpx tsc --noEmit\n# code 0, pas d'erreur\n```\n\nGarde-fous :\n```text\nnpx vitest run src/app/no-direct-invoke.test.ts\nTest Files 1 passed; Tests 3 passed\n\nnpx vitest run src/features/settings/desktop-only.test.ts\nTest Files 1 passed; Tests 3 passed\n\nnpm run test:bundle-transport\ndist: __IDEA_TRANSPORT__=\"tauri\" (1 marker)\ndist-web: __IDEA_TRANSPORT__=\"http\" (1 marker)\n```\n\n`clippy` :\n```text\ncargo clippy -p domain -p application -p infrastructure -p backend -p web-server -p app-tauri --all-targets\nFinished `dev` profile\n```\nMais warnings probablement nouveaux dans `web-server` touché :\n```text\ncrates/web-server/src/lib.rs:587: manual_unwrap_or_default\ncrates/web-server/src/lib.rs:1096: question_mark\n```\n\nMon verdict : **durcissements #77 validés en ciblé, mais pas de feu vert global tant que le rouge `web-server` parallèle et le stale `pairingCode` frontend/mock ne sont pas traités ou explicitement arbitrés.**", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "1d78f19e-3707-4f40-a0ec-0610f8a9988a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "7816fbc3-973b-442f-9e13-94424ea9945e", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286639903, + "updatedAtMs": 1784286919811, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286919811, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Le nettoyage demandé est fait et vert. Mais en cherchant les autres restes, j'ai trouvé **deux écarts qui rendraient l'écran Appareils non fonctionnel en production** — dont un que je dois te laisser trancher.\n\n## Ce que j'ai changé (les 4 points)\n\n`domain/index.ts` : `pairingCode` retiré d'`EmbeddedServerStatus`, et le commentaire réécrit — il décrit maintenant pourquoi il n'y en a pas (un code n'est pas une propriété d'un serveur qui tourne, il n'existe que quand on en demande un).\n\n`adapters/mock/index.ts` : `pairingCode: \"MOCK-PAIR-4242\"` supprimé.\n\n`features/settings/DeploymentSettings.tsx` : panneau `Pairing` remplacé par le renvoi vers `Settings → Appareils`. J'ai gardé le titre du panneau pour que le repère visuel reste au même endroit.\n\n`DeploymentSettings.test.tsx` : j'ai remplacé le test par deux tests vrais plutôt que retirer l'assertion. Le premier vérifie qu'**aucun code n'apparaît, serveur démarré ou non** — c'est le vrai garde-fou de régression, il pin le fait qu'un code ne peut plus revenir sur cette surface. Le second vérifie que le renvoi est là, pour que le cul-de-sac que tu voulais éviter reste évité. Au passage, mon premier essai s'est planté sur un mauvais libellé d'ancrage : le test a échoué, je l'ai corrigé sur la sortie réelle.\n\n## Ce que j'ai trouvé en cherchant — et c'est plus grave\n\nTon diagnostic sur le mécanisme (« le mock fournit une valeur que le vrai backend n'enverra jamais ») s'applique **aussi à mon propre lot F2**, à deux endroits. Mes tests sont verts pour exactement la raison que tu décris : mon mock et mes fixtures nourrissent des formes que le backend n'envoie pas.\n\n**1. `list_devices` renvoie un objet, pas un tableau.** B3 expose `DeviceListDto { devices: [...] }`. Mon `TauriDeviceGateway.listDevices()` faisait `invoke(\"list_devices\")` et rendait l'objet tel quel — la liste desktop aurait planté au premier rendu. **Je l'ai corrigé** : ce n'est pas un arbitrage, juste mon adapter qui ne collait pas à un DTO livré. Mon gateway HTTP tolérait déjà les deux formes, c'était une incohérence entre mes deux adapters.\n\n**2. Les trois timestamps sont des millisecondes epoch en chaîne, pas de l'ISO-8601. Ça, je te le laisse.** `pairedAt`, `lastSeenAt` (`clock.now_millis().to_string()`, fixtures de test `\"1000\"`) et `expiresAt` (`expires_at_ms.to_string()`). Mon `formatActivity.ts` fait `new Date(iso)` partout. Vérifié en réel :\n\n```\nbackend sends: 1784286814274\nnew Date(s) → Invalid Date\n```\n\nConcrètement, en production aujourd'hui : chaque ligne d'appareil afficherait `—` et `Appairé le —`, et **chaque code fraîchement généré s'afficherait immédiatement comme `Ce code a expiré.`** — le panneau d'appairage serait inutilisable. Le carnet a figé les noms des champs, pas leur encodage ; c'est le trou.\n\nDeux directions, et je ne tranche pas seul parce que ça traverse la frontière backend :\n\n- **Frontend s'aligne** (ma recommandation) : un `parseInstant()` dans `formatActivity.ts` qui lit l'epoch-ms, plus les fixtures et le mock à corriger. Le backend est livré et testé, l'epoch-ms est sans ambiguïté de fuseau. ~15 min, confiné à un fichier plus les tests.\n- **Backend passe en ISO-8601** : plus lisible sur le fil, mais ça touche B1/B3 déjà validés.\n\nÀ noter pour ton arbitrage : le reste du codebase utilise plutôt des timestamps **numériques** suffixés `Ms` (`createdAtMs`, `updatedAtMs`), donc `pairedAt: \"1784286814274\"` en *chaîne* est une troisième convention, ni ISO ni `...Ms`. Si tu veux uniformiser, c'est le moment.\n\nDis-moi la direction et je l'applique.\n\n## Commande et sortie réelle\n\n`cd frontend && npx vitest run` :\n\n```\n Test Files 91 passed (91)\n Tests 847 passed (847)\n Duration 15.82s\n```\n\n`npx tsc --noEmit` sort en 0.\n\nUne précision d'honnêteté sur ce vert : il ne prouve rien sur le point 2 ci-dessus. Les tests F2 passent parce qu'ils nourrissent de l'ISO — c'est le même vert trompeur, et il le restera tant que la décision d'encodage n'est pas prise.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "f27eb580-310a-444c-9547-b2dd3a9f69fc", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "2d3233b2-e1c6-4a0e-9dd1-361081455636", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286770999, + "updatedAtMs": 1784286852321, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286852321, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Corrigé uniquement dans [crates/web-server/src/lib.rs](/home/anthony/Documents/Projects/IdeA/crates/web-server/src/lib.rs:585).\n\n**Diff**\n```diff\n@@\n- match self\n+ self\n .app\n .authenticate_session\n .execute(AuthenticateSessionInput { token_bytes })\n .await\n- {\n- Ok(device) => device,\n- Err(_) => None,\n- }\n+ .unwrap_or_default()\n@@\n- if let Err(response) = validate_reverse_proxy(headers, peer_ip, state, WS_PATH) {\n- return Err(response);\n- }\n+ validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?;\n```\n\n**`cargo clippy -p web-server --all-targets`**\n```text\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 13.54s\nexit code: 0\n```\nLa commande sort encore des warnings préexistants dans `domain`, `application`, `infrastructure` et `backend`, mais plus les deux warnings `web-server` signalés.\n\n**`cargo test -p web-server`**\n```text\nrunning 79 tests\n...\ntest result: FAILED. 73 passed; 6 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s\n\nfailures:\ntests::run_embedded_stop_shuts_down_accept_loop\ntests::run_embedded_with_core_uses_injected_core_for_http_invokes\ntests::websocket_close_terminal_emits_exited_status_and_releases_session\ntests::websocket_launch_agent_reattach_replays_scrollback_without_respawn\ntests::websocket_launch_agent_same_cell_is_idempotent_singleton\ntests::websocket_upgrade_requires_valid_cookie\n```\n\nCause visible dans la sortie réelle ici :\n```text\nfailed to bind 127.0.0.1:0: Operation not permitted (os error 1)\nFailed building the Runtime: Os { code: 24, kind: Uncategorized, message: \"Too many open files\" }\n```\nDonc même échec sandbox réseau/FD que celui annoncé, sans correction tentée dessus.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "87d2feff-19a8-4f24-9f4b-a789716cb28d", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "a1219a26-a509-4906-a0e3-3e1ee92f5227", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286971190, + "updatedAtMs": 1784286980344, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286980344, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Convention retenue : **epoch-ms numérique suffixé `Ms` partout**.\n\n- Store persistant : `pairedAtMs`, `lastSeenAtMs`, `expiresAtMs` si le code éphémère est sérialisé dans un DTO/debug, type `number`/`u64`, pas chaîne.\n- DTO UI : mêmes noms `pairedAtMs`, `lastSeenAtMs`, `expiresAtMs`, type `number`.\n\nQui bouge : **backend + frontend**, mais minimalement. Le backend doit arrêter d’émettre des chaînes epoch non suffixées et renommer les champs. Le frontend doit consommer `*AtMs` via `new Date(ms)` et ajuster fixtures/types.\n\nJe retire donc mon ISO initial au profit de la convention codebase. Ne gardez pas `pairedAt: \"1784286814274\"` : c’est le pire des trois contrats.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "7bfd1e08-df74-4f00-8fc4-477ebaa4076b", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "5fa078ba-48c8-457d-af31-339a3453eae3", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784287004702, + "updatedAtMs": 1784287215598, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784287215598, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Changement appliqué sur le contrat backend/Tauri uniquement. Le scan suivant ne retourne plus rien sur les anciens champs ou timestamps chaîne du ticket :\n\n`rg \"pairedAt\\b|lastSeenAt\\b|expiresAt\\b|paired_at:\\s*String|last_seen_at:\\s*String|expires_at:\\s*String|\\.paired_at\\b|\\.last_seen_at\\b|expires_at\\b.*to_string\" crates/domain crates/application crates/infrastructure crates/web-server crates/app-tauri`\n\n**Diff Ciblé**\n```diff\n- pub paired_at: String,\n- pub last_seen_at: String,\n+ pub paired_at_ms: u64,\n+ pub last_seen_at_ms: u64,\n\n- let now = self.clock.now_millis().max(0).to_string();\n+ let now = self.clock.now_millis().max(0) as u64;\n\n- let last_seen = device.last_seen_at.parse::().unwrap_or(0);\n- device.last_seen_at = now.to_string();\n+ if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS { ... }\n+ device.last_seen_at_ms = now;\n\n- pub expires_at: String,\n+ pub expires_at_ms: u64,\n\n- expires_at: expires_at_ms.to_string(),\n+ expires_at_ms: expires_at_ms.max(0) as u64,\n\n- assert!(row.contains_key(\"pairedAt\"));\n- assert!(row.contains_key(\"lastSeenAt\"));\n+ assert!(row[\"pairedAtMs\"].is_number());\n+ assert!(row[\"lastSeenAtMs\"].is_number());\n```\n\nFichiers touchés pour ce lot :\n`crates/domain/src/device.rs`, `crates/application/src/device.rs`, `crates/infrastructure/src/store/device_session.rs`, `crates/web-server/src/lib.rs`, `crates/app-tauri/src/commands.rs`.\n\n**`cargo test -p domain -p application -p infrastructure -p web-server`**\n```text\nFinished `test` profile [unoptimized + debuginfo] target(s) in 16.46s\n...\ntest result: FAILED. 275 passed; 10 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.37s\nerror: test failed, to rerun pass `-p infrastructure --lib`\n```\n\nLes 10 échecs sont tous des binds interdits par le sandbox :\n```text\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\nÉchecs concernés : tests `session::openai_compat::*` et `session::tests::factory_routes_openai_compatible_to_http_session`. Rien d’autre. Cargo s’arrête sur `infrastructure`, donc les tests runtime `web-server` ne sont pas atteints dans cette commande combinée.\n\nVérification additionnelle utile :\n```text\ncargo test -p domain -p application\ntest result: ok\n```\n\n**`cargo clippy -p web-server --all-targets`**\n```text\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 8.71s\nexit code: 0\n```\n\nWarnings restants uniquement préexistants dans les dépendances vérifiées par la commande : `domain` 4, `application` 8, `infrastructure` 8, `backend` 1. Aucun warning `web-server`.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "47c10f1a-751c-4c91-b279-529325151408", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "6bc07012-5994-4856-9b8b-5feb8e94c7a5", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784287140993, + "updatedAtMs": 1784287409664, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784287409664, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Le contrat `*AtMs: number` est appliqué, et je l'ai vérifié contre le Rust livré plutôt que contre mes mocks : **DevBackend est parfaitement aligné, aucune dérive à signaler.**\n\n## Ce que j'ai changé\n\n`domain/index.ts` : `pairedAt`/`lastSeenAt`/`expiresAt` → `pairedAtMs`/`lastSeenAtMs`/`expiresAtMs`, typés `number`.\n\n`formatActivity.ts` : consomme des `number` via `new Date(ms)`. J'ai suivi ta consigne à la lettre — **pas de `parseInstant()` tolérant**. J'ai même retiré les gardes `Number.isNaN` qui existaient : avec un contrat univoque et un type `number`, ils ne protégeaient plus de rien et laissaient croire qu'un autre encodage était possible. Le test « degrades to a dash on an unparseable instant » est parti avec eux, remplacé par un test qui vérifie qu'un epoch-ms brut rend une vraie date.\n\nRenommage propagé dans `DevicesScreen`, `PairingCodePanel`, `MockDeviceGateway`, et les deux gateways.\n\n**Les fixtures portent maintenant des nombres** (`Date.now()`, `.getTime()`) au lieu d'ISO, dans `DevicesScreen.test.tsx` et `formatActivity.test.ts`. C'est le point que tu as désigné comme le plus important, et c'était bien la racine du vert trompeur.\n\nSur le décompte (point 4) : j'ai ajouté un test dédié qui vérifie qu'un code fraîchement généré **n'affiche pas « Ce code a expiré. »**. Je l'ai écrit au niveau de l'écran, pas du formateur, parce que le bug d'origine ne vivait pas dans le formateur seul — il vivait dans la chaîne mock → panneau → formateur. Un test du formateur seul l'aurait raté exactement comme les précédents.\n\n## Vérification côté Rust — tout est aligné\n\nJ'ai lu les DTO livrés plutôt que de faire confiance à mes mocks :\n\n- `DeviceDto` : `paired_at_ms: u64`, `last_seen_at_ms: u64`, avec `rename_all = \"camelCase\"` → `pairedAtMs`, `lastSeenAtMs`. ✓\n- `PairingCodeDto` : `expires_at_ms: u64`, même casing → `expiresAtMs`. ✓\n- Domaine `PairedDevice` : `paired_at_ms: u64`, plus aucun `.to_string()` ; l'arithmétique de throttle `last_seen_at_ms` est redevenue numérique. ✓\n\nDeux bonus que cette lecture a confirmés : `GET /api/devices` renvoie bien `{\"devices\": [...]}`, et **les cinq routes que j'avais devinées en F2 sont exactement celles que B3 a livrées** (`/api/pairing-code`, `/api/devices`, `/api/devices/revoke-all`, `/api/devices/{id}/rename`, `/api/devices/{id}/revoke`). L'écart n°1 que je t'avais signalé est donc clos.\n\nDu coup j'ai appliqué ton principe « pas de tolérance défensive » **aussi à mes deux gateways** : ils acceptaient encore un tableau nu en plus de `{devices}`, une forme que le backend n'émet pas. Supprimé des deux côtés. Même raisonnement que pour `parseInstant()`, je ne l'ai pas laissé traîner par cohérence.\n\n## Commande et sortie réelle\n\n`cd frontend && npx vitest run` — **premier passage rouge**, honnêtement :\n\n```\n FAIL src/features/permissions/permissions.test.tsx > PermissionsPanel > saves project defaults\n Test Files 1 failed | 90 passed (91)\n Tests 1 failed | 847 passed (848)\n```\n\nVérifié : `git diff HEAD -- src/features/permissions` est vide (je n'y ai pas touché), le test passe seul, et le second passage complet est vert. C'est un **test flaky préexistant** (1371 ms, sensible au timing), pas une régression de ce lot — mais il mérite un ticket, il finira par mordre en CI.\n\nSecond passage :\n\n```\n Test Files 91 passed (91)\n Tests 848 passed (848)\n Duration 15.69s\n```\n\n`npx tsc --noEmit` sort en 0. `npm run build:bundle` + `npm run test:bundle-transport` :\n\n```\ndist: __IDEA_TRANSPORT__=\"tauri\" (1 marker)\ndist-web: __IDEA_TRANSPORT__=\"http\" (1 marker)\n```\n\nUn `grep` final ne trouve plus aucun `pairedAt`/`lastSeenAt`/`expiresAt` sans suffixe `Ms`, ni aucune mention d'ISO-8601 dans la surface Appareils.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "02eda7ec-4b10-4e15-8f96-979c3f7d3f80", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "bb4a0123-588b-48a4-92b9-1cfd00255a71", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "running", + "wakePolicy": "recordOnly", + "createdAtMs": 1784287477331, + "updatedAtMs": 1784287477331, "deadlineMs": null, "result": null, "completionDelivered": false diff --git a/.ideai/tickets/77/carnet.md b/.ideai/tickets/77/carnet.md new file mode 100644 index 0000000..9379bf4 --- /dev/null +++ b/.ideai/tickets/77/carnet.md @@ -0,0 +1,153 @@ +--- +issueRef: "#77" +version: 4 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 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. + +**Erreurs** — `Code 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** : `DeviceSessionStore` — **adapter** : `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. + +```json +{ "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`. `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. + +### Cookie + +`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. diff --git a/.ideai/tickets/77/issue.md b/.ideai/tickets/77/issue.md new file mode 100644 index 0000000..965182c --- /dev/null +++ b/.ideai/tickets/77/issue.md @@ -0,0 +1,77 @@ +--- +id: "eecf77ee-dfcb-436c-aa5f-0c7033c7bdfa" +number: 77 +title: "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique" +status: "qa" +priority: "high" +sprint: null +links: [{"target":"#76","kind":"relatesTo"},{"target":"#75","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784279214815 +updatedAt: 1784287453378 +version: 4 +--- +## Besoin utilisateur + +Deux constats live (téléphone, instance exposée derrière reverse proxy) : + +1. Le code d'appairage est redemandé **à chaque ouverture de la page**. Impraticable. +2. Le code étant affiché sur la machine, un appairage est impossible quand l'utilisateur n'est pas chez lui. + +## Cause racine du #1 — trois couches, confirmées + +- **Le cookie de session n'a ni `Max-Age` ni `Expires`** (`crates/web-server/src/lib.rs:1629`). C'est un cookie de session au sens navigateur : détruit à la fermeture. Sur mobile, purge agressive en arrière-plan → réappairage quasi systématique. **C'est la cause directe.** +- **Les sessions vivent en mémoire** (`sessions: Mutex>`, ligne 422). Un redémarrage désappaire tous les appareils, même avec un cookie persistant. +- **Le code d'appairage est régénéré à chaque démarrage** (`new_pairing_code()` appelé dans `with_core`, ligne 437). Le code noté hier ne vaut plus rien. + +## Design validé (discussion utilisateur ↔ Main, 2026-07-17) + +### Appareils + +- Un **appareil** appairé est persistant, nommé, listable, révocable. Session valide **indéfiniment jusqu'à révocation**. +- Le **serveur est la source de vérité** de la durée de vie. Le cookie n'est qu'un porteur, renouvelé à chaque visite (le plafond navigateur réel est ~400 jours côté Chrome — « indéfini » se tient côté serveur, pas côté cookie). +- Vocabulaire figé : **« appareils »**, jamais « utilisateurs ». Ce design est **mono-utilisateur assumé** : tous les appareils ont les pleins pouvoirs, pas de comptes, pas de mots de passe, pas de permissions différenciées. Le jour où plusieurs personnes seront nécessaires, ce sera un autre chantier — le vocabulaire ne doit pas avoir menti entre-temps. + +### Code d'appairage + +- **Éphémère, à usage unique, généré à la demande.** TTL court (5-10 min à arbitrer). Générer un nouveau code invalide le précédent. +- **Suppression du code statique au démarrage**, y compris l'`eprintln!("IdeA pairing code: …")` (ligne 619) : un secret permanent qui part dans stderr/journald/toute capture de sortie. Après ce lot, aucun secret au repos — un code n'existe que quand il est demandé. +- Génération : depuis l'**app desktop** (serveur embarqué, accès direct à l'état) et depuis l'**UI web déjà appairée**. +- **Pas de commande CLI de génération, pas de socket de contrôle, pas de store partagé entre processus.** Décision explicite : supprime l'IPC, la concurrence d'écriture CLI↔serveur et la classe de bugs associée. +- **Flag `--new-code`** au lancement du serveur headless : affiche un code au démarrage. C'est le **bootstrap du premier lancement** et la **trappe de secours** quand plus aucune UI n'est accessible. Le flag ne doit **rien rendre persistant** (laissé par mégarde dans une unit systemd, il ne doit pas transformer chaque redémarrage en distribution de code). +- Le code réapparaît dans stderr avec `--new-code` : risque résiduel accepté, car TTL court + usage unique rendent sans valeur un log qui fuite plus tard. + +### Condition de validité du design + +**L'écran de gestion des appareils doit vivre dans l'UI web, pas seulement dans l'app desktop.** Sinon, sur une installation headless, générer un code impose un redémarrage — donc couper PTY, agents en cours et WebSockets — à chaque nouvel appareil. Avec l'écran dans l'UI web, la boucle se ferme : `--new-code` donne le premier appareil, tout le reste se gère depuis cet appareil, et `--new-code` redevient une trappe de secours. + +### Accès distant — trou assumé + +Le design ne couvre **pas** l'appairage d'un appareil neuf à distance : générer un code suppose une UI appairée ou un accès à la machine. Le filet est **SSH** (se connecter, relancer avec `--new-code`). Assumé consciemment, acceptable tant que les appareils persistent réellement — le cas devient rare. **Passkey/WebAuthn gardée en réserve, hors périmètre de ce lot.** + +## Durcissements non négociables + +1. **TTL sur le code** — pas seulement l'usage unique. Un code généré et jamais consommé qui reste valide pour toujours recrée le problème d'aujourd'hui. +2. **Limite de tentatives sur `POST /api/pair`** — 8 caractères hex = 4,3e9 combinaisons, brute-forçable en quelques semaines à débit soutenu contre un code permanent. TTL **et** rate-limit, pas l'un ou l'autre. +3. **Tokens de session hachés sur disque, jamais en clair.** Le store `.ideai/` part dans les backups et les synchros ; en clair il donne un accès shell. À traiter comme un fichier de mots de passe. +4. **La révocation doit couper les WebSockets déjà ouverts.** Un WS établi ne revalide jamais le cookie : révoquer un appareil qui a un PTY ouvert le laisserait piloter la machine. Une révocation qui ne coupe pas la connexion vivante n'est pas une révocation. + +## Surface de gestion + +Lister les appareils (nom, date d'appairage, dernière activité), les révoquer un par un, et **révoquer tout** — pour le jour où un téléphone est perdu et où lister avant d'agir n'est pas souhaitable. + +## Points d'entrée code + +- `crates/web-server/src/lib.rs` : `ServerState` (418-482), `create_session`/`has_session`/`revoke_session` (469-484), `eprintln!` du code (619), comparaison du code (1612), pose du cookie (1629-1640), `logout_response` (1643), `new_pairing_code` (2220), `new_session_token` (2230). +- `crates/app-tauri/src/embedded_server.rs` : exposition `pairing_code` (97, 334). +- `frontend/src/features/web/PairingScreen.tsx`, `frontend/src/adapters/http/webSession.ts`. + +## DoD + +- Un appareil appairé le reste après fermeture du navigateur **et** après redémarrage du serveur. +- Aucun code d'appairage n'existe au repos ; un code demandé expire et ne sert qu'une fois. +- Révocation effective immédiatement, WebSockets vivants inclus. +- Écran de gestion accessible depuis l'UI web **et** l'app desktop. +- Validation live : téléphone appairé une fois, toujours connecté le lendemain après redémarrage de l'AppImage. \ No newline at end of file diff --git a/.ideai/tickets/78/carnet.md b/.ideai/tickets/78/carnet.md new file mode 100644 index 0000000..4758a67 --- /dev/null +++ b/.ideai/tickets/78/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#78" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784284746767 +--- diff --git a/.ideai/tickets/78/issue.md b/.ideai/tickets/78/issue.md new file mode 100644 index 0000000..1a2d880 --- /dev/null +++ b/.ideai/tickets/78/issue.md @@ -0,0 +1,34 @@ +--- +id: "8d8bc65a-50f6-4fab-bb02-486b6145205f" +number: 78 +title: "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)" +status: "open" +priority: "low" +sprint: null +links: [{"target":"#77","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784284746767 +updatedAt: 1784284746767 +version: 1 +--- +## Constat + +Relevé par DevFrontend pendant l'implémentation de #77 (F2), hors périmètre de ce ticket. + +L'écran Settings du desktop mélange les langues dans sa liste de sections : la nouvelle section **« Appareils »** (vocabulaire figé par UX pour #77, non négociable) voisine avec **« AI Profiles »** et **« Deployment »**. + +Le problème n'est pas la section ajoutée par #77 — c'est que la surface Settings n'a pas de règle de langue établie. Le français de « Appareils » est correct et cohérent avec le reste de l'UI produit (`Appairer cet appareil`, `Code d'appairage`) ; ce sont les sections préexistantes qui sont en anglais. + +## Pourquoi c'est un ticket UX et pas un fix de libellé + +Trancher demande une décision de fond qui dépasse un renommage : quelle est la langue de l'UI d'IdeA, et est-elle uniforme ou dépend-elle de la surface ? La réponse engage toutes les surfaces existantes, pas seulement Settings, et conditionne un éventuel besoin d'i18n. + +## Attendu + +UX tranche la règle de langue de l'UI, puis on aligne les sections de Settings dessus. + +## Périmètre + +Frontend pur si la décision est « tout en français » ou « tout en anglais ». Devient un chantier distinct si la réponse est « il faut de l'i18n ». \ No newline at end of file diff --git a/.ideai/tickets/79/carnet.md b/.ideai/tickets/79/carnet.md new file mode 100644 index 0000000..e4ae099 --- /dev/null +++ b/.ideai/tickets/79/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#79" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784287450082 +--- diff --git a/.ideai/tickets/79/issue.md b/.ideai/tickets/79/issue.md new file mode 100644 index 0000000..800af11 --- /dev/null +++ b/.ideai/tickets/79/issue.md @@ -0,0 +1,47 @@ +--- +id: "05a70dc5-fd28-4c58-8ba1-be6058ae3cfc" +number: 79 +title: "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence" +status: "open" +priority: "low" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784287450082 +updatedAt: 1784287450082 +version: 1 +--- +## Constat + +Relevé par DevFrontend pendant #77, **sans rapport avec ce ticket**. + +`frontend/src/features/permissions/permissions.test.tsx` → `PermissionsPanel > saves project defaults` échoue par intermittence sur une exécution complète de la suite : + +``` + FAIL src/features/permissions/permissions.test.tsx > PermissionsPanel > saves project defaults + Test Files 1 failed | 90 passed (91) + Tests 1 failed | 847 passed (848) +``` + +## Pourquoi ce n'est pas une régression de #77 + +Vérifié au moment du constat : + +- `git diff HEAD -- src/features/permissions` est **vide** — le lot #77 n'a pas touché cette surface. +- Le test **passe seul**. +- Le passage complet suivant est **vert** (848/848), sans modification entre les deux. +- Durée du test : ~1371 ms — sensible au timing. + +## Pourquoi ça mérite un ticket quand même + +Un test qui passe deux fois sur trois est pire qu'un test rouge : il apprend à l'équipe à relancer plutôt qu'à lire. Il finira par mordre en CI, où l'on ne relance pas toujours, et il érode la confiance dans une suite dont ce chantier a montré qu'elle est le principal garde-fou. + +## Attendu + +Identifier la source du non-déterminisme (timers, attente implicite, état partagé entre tests) et rendre le test déterministe. **Ne pas le neutraliser ni augmenter un timeout pour le faire taire** : si le comportement testé est réellement dépendant du timing, c'est le comportement qu'il faut regarder. + +## Périmètre + +Frontend, tests. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 4fa6919..ad1925c 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 77 + "nextNumber": 80 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 1a94f21..b311171 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -798,6 +798,36 @@ "sprint": null, "assignedAgentIds": [], "updatedAt": 1784277540034 + }, + { + "issueRef": "#77", + "path": "77", + "title": "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique", + "status": "qa", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784287453378 + }, + { + "issueRef": "#78", + "path": "78", + "title": "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784284746767 + }, + { + "issueRef": "#79", + "path": "79", + "title": "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784287450082 } ] } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f66cdb9..953cadd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,7 @@ dependencies = [ "domain", "serde", "serde_json", + "subtle", "thiserror 2.0.18", "tokio", "uuid", @@ -905,8 +906,11 @@ name = "domain" version = "0.3.0" dependencies = [ "async-trait", + "hex", "serde", "serde_json", + "sha2", + "subtle", "thiserror 2.0.18", "tokio", "uuid", @@ -5247,10 +5251,14 @@ dependencies = [ "bytes", "cookie", "domain", + "getrandom 0.3.4", + "hex", "http", "http-body-util", "serde", "serde_json", + "sha2", + "subtle", "tokio", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index a1fe65e..a497c5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ thiserror = "2" async-trait = "0.1" futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } +hex = "0.4" +sha2 = "0.10" +subtle = "2" +getrandom = "0.3" # Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # only local operations (status/commit/branch/checkout/log) are in scope; remote # push/pull and static vendoring for the AppImage are deferred to L9/L11. diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 4ac33ad..9628a12 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -16,12 +16,12 @@ use application::{ DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, - LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput, + LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput, - ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput, + ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, @@ -66,7 +66,8 @@ use crate::embedded_server::{ }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::{AppState, FocusedProjectDto}; -use domain::{SkillRef, SkillScope}; +use domain::{DeviceId, SkillRef, SkillScope}; +use uuid::Uuid; /// `health` — trivial command validating the full IPC pipeline /// (frontend gateway → invoke → command → use case → ports → event relay). @@ -149,6 +150,157 @@ pub async fn embedded_server_stop( state.embedded_server.stop().await } +/// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the embedded server is not running. +#[tauri::command] +pub fn embedded_server_generate_pairing_code( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.generate_pairing_code() +} + +/// Device row exposed to the desktop settings surface. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceDto { + /// Device id. + pub device_id: String, + /// User-facing name. + pub name: String, + /// Pairing timestamp as epoch milliseconds. + pub paired_at_ms: u64, + /// Last successful access timestamp as epoch milliseconds. + pub last_seen_at_ms: u64, + /// Whether this is the current authenticated web device. + pub is_current_device: bool, +} + +/// List response for paired devices. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceListDto { + /// Paired devices. + pub devices: Vec, +} + +/// Rename request for paired devices. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameDeviceRequestDto { + /// Device id. + pub device_id: String, + /// New name. + pub name: String, +} + +/// Revoke request for one paired device. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeDeviceRequestDto { + /// Device id. + pub device_id: String, +} + +fn parse_device_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(DeviceId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: "invalid device id".to_owned(), + }) +} + +/// `list_devices` — list paired devices through the desktop composition root. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the device store cannot be read. +#[tauri::command] +pub async fn list_devices(state: State<'_, AppState>) -> Result { + let output = state + .list_devices + .execute(ListDevicesInput { + current_device_id: None, + }) + .await + .map_err(ErrorDto::from)?; + Ok(DeviceListDto { + devices: output + .devices + .into_iter() + .map(|device| DeviceDto { + device_id: device.device_id.to_string(), + name: device.name, + paired_at_ms: device.paired_at_ms, + last_seen_at_ms: device.last_seen_at_ms, + is_current_device: device.is_current_device, + }) + .collect(), + }) +} + +/// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the embedded server is not running. +#[tauri::command] +pub fn create_pairing_code( + state: State<'_, AppState>, +) -> Result { + state.embedded_server.generate_pairing_code() +} + +/// `rename_device` — rename a paired device. +/// +/// # Errors +/// Returns an [`ErrorDto`] for invalid input or store failures. +#[tauri::command] +pub async fn rename_device( + request: RenameDeviceRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .rename_device + .execute(RenameDeviceInput { + device_id: parse_device_id(&request.device_id)?, + name: request.name, + }) + .await + .map_err(ErrorDto::from) +} + +/// `revoke_device` — revoke one paired device. +/// +/// # Errors +/// Returns an [`ErrorDto`] for invalid input or store failures. +#[tauri::command] +pub async fn revoke_device( + request: RevokeDeviceRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .revoke_device + .execute(RevokeDeviceInput { + device_id: parse_device_id(&request.device_id)?, + }) + .await + .map_err(ErrorDto::from) +} + +/// `revoke_all_devices` — revoke every paired device. +/// +/// # Errors +/// Returns an [`ErrorDto`] when the device store cannot be rewritten. +#[tauri::command] +pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> { + state + .revoke_all_devices + .execute() + .await + .map_err(ErrorDto::from) +} + /// `create_project` — create a project from a root: init `.ideai/`, register it. /// /// # Errors diff --git a/crates/app-tauri/src/embedded_server.rs b/crates/app-tauri/src/embedded_server.rs index 0684543..4a4a76b 100644 --- a/crates/app-tauri/src/embedded_server.rs +++ b/crates/app-tauri/src/embedded_server.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use backend::BackendCore; use serde::{Deserialize, Serialize}; -use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy}; +use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy}; use crate::dto::ErrorDto; @@ -93,8 +93,6 @@ pub struct EmbeddedServerStatusDto { pub public_url: Option, /// Reverse-proxy upstream URL derived from settings. pub upstream_url: Option, - /// Runtime pairing code, never persisted. - pub pairing_code: Option, /// Last failure, when state is `failed`. pub error: Option, } @@ -278,6 +276,21 @@ impl EmbeddedServerController { } } + /// Generates a new ephemeral pairing code on the running embedded server. + /// + /// # Errors + /// Returns an [`ErrorDto`] if the embedded server is not running. + pub fn generate_pairing_code(&self) -> Result { + let inner = self.inner.lock().expect("embedded server mutex poisoned"); + let Some(handle) = inner.handle.as_ref() else { + return Err(ErrorDto { + code: "UNAVAILABLE".to_owned(), + message: "embedded server is not running".to_owned(), + }); + }; + Ok(handle.generate_pairing_code()) + } + /// Stops the embedded server. Idempotent when already stopped. /// /// # Errors @@ -331,10 +344,6 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto { local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()), public_url: inner.public_url.clone(), upstream_url: inner.upstream_url.clone(), - pairing_code: inner - .handle - .as_ref() - .map(|handle| handle.pairing_code().to_owned()), error, } } @@ -436,6 +445,7 @@ fn server_config_from_settings( trusted_proxies, app_data_dir, web_root, + new_code: false, }; config.validate().map_err(invalid_error)?; Ok(config) @@ -771,7 +781,9 @@ mod tests { .as_deref() .is_some_and(|url| url.starts_with("http://127.0.0.1:"))); assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0")); - assert_eq!(first.pairing_code, second.pairing_code); + let pairing = controller.generate_pairing_code().unwrap(); + assert_eq!(pairing.ttl_seconds, 600); + assert_eq!(pairing.code.len(), 8); let stopped = controller.stop().await.unwrap(); @@ -780,7 +792,6 @@ mod tests { EmbeddedServerStatusStateDto::Stopped )); assert!(stopped.local_url.is_none()); - assert!(stopped.pairing_code.is_none()); } #[tokio::test] @@ -804,6 +815,5 @@ mod tests { assert_eq!(err.code, "INVALID"); assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); - assert!(status.pairing_code.is_none()); } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index d1b160b..ba7e0f4 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -292,6 +292,12 @@ pub fn run() { commands::embedded_server_status, commands::embedded_server_start, commands::embedded_server_stop, + commands::embedded_server_generate_pairing_code, + commands::list_devices, + commands::create_pairing_code, + commands::rename_device, + commands::revoke_device, + commands::revoke_all_devices, ]) .run(tauri::generate_context!()) .expect("error while running IdeA Tauri application"); diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 13a5e25..d6f7e27 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -12,6 +12,7 @@ thiserror = { workspace = true } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +subtle = { workspace = true } # `v5` derives stable reference-profile ids from a fixed namespace (catalogue). uuid = { workspace = true } # `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4). diff --git a/crates/application/src/device.rs b/crates/application/src/device.rs new file mode 100644 index 0000000..269dca1 --- /dev/null +++ b/crates/application/src/device.rs @@ -0,0 +1,482 @@ +//! Paired-device session use cases. + +use std::sync::Arc; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError}; +use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash}; +use subtle::ConstantTimeEq; + +use crate::error::AppError; + +const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000; + +/// Input for [`PairDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairDeviceInput { + /// Name supplied by the new device. + pub name: String, + /// Hash of the freshly issued session token. + pub session_token_hash: SessionTokenHash, +} + +/// Output for [`PairDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairDeviceOutput { + /// Created paired device. + pub device: PairedDevice, +} + +/// Validates a pairing request and persists the new paired device. +pub struct PairDevice { + store: Arc, + ids: Arc, + clock: Arc, +} + +impl PairDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new( + store: Arc, + ids: Arc, + clock: Arc, + ) -> Self { + Self { store, ids, clock } + } + + /// Executes device pairing. + /// + /// # Errors + /// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for + /// persistence failures. + pub async fn execute(&self, input: PairDeviceInput) -> Result { + let now = self.clock.now_millis().max(0) as u64; + let device = PairedDevice { + device_id: DeviceId::from_uuid(self.ids.new_uuid()), + name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?, + paired_at_ms: now, + last_seen_at_ms: now, + session_token_hash: input.session_token_hash, + }; + let mut devices = self.store.load_devices().await?; + devices.push(device.clone()); + self.store.save_devices(&devices).await?; + Ok(PairDeviceOutput { device }) + } +} + +/// Rate-limit key for pairing attempts. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RateLimitKey { + /// Resolved origin identity, typically the peer IP after transport-layer proxy handling. + pub origin: String, + /// Logical route being limited. + pub route: String, +} + +/// Pairing-attempt limiter port. +#[async_trait] +pub trait PairAttemptLimiter: Send + Sync { + /// Returns whether a new failed pairing attempt may be processed. + async fn check(&self, key: RateLimitKey) -> Result; + + /// Records one failed pairing attempt for the key and the global bucket. + async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>; +} + +/// Result of a rate-limit check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PairAttemptDecision { + /// Attempt can proceed. + Allowed, + /// Attempt is blocked by one of the buckets. + RateLimited, +} + +/// Input for [`AuthenticateSession`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticateSessionInput { + /// Raw token bytes decoded from the session cookie. + pub token_bytes: Vec, +} + +/// Authenticates a persisted device session. +pub struct AuthenticateSession { + store: Arc, +} + +impl AuthenticateSession { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Returns the authenticated device, including its token hash, when valid. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute( + &self, + input: AuthenticateSessionInput, + ) -> Result, AppError> { + Ok(self.store.authenticate_token(&input.token_bytes).await?) + } +} + +/// Input for [`TouchDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TouchDeviceInput { + /// Device to mark as seen. + pub device_id: DeviceId, +} + +/// Output for [`TouchDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TouchDeviceOutput { + /// Whether the store was rewritten. + pub updated: bool, +} + +/// Device row exposed to presentation layers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceView { + /// Persistent device id. + pub device_id: DeviceId, + /// User-facing name. + pub name: String, + /// Pairing timestamp as epoch milliseconds. + pub paired_at_ms: u64, + /// Last successful authenticated access timestamp as epoch milliseconds. + pub last_seen_at_ms: u64, + /// Whether this row is the authenticated current device. + pub is_current_device: bool, +} + +/// Input for [`ListDevices`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListDevicesInput { + /// Current device id, when the driving adapter has an authenticated device. + pub current_device_id: Option, +} + +/// Output for [`ListDevices`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListDevicesOutput { + /// Paired devices. + pub devices: Vec, +} + +/// Lists paired devices without exposing transport-sensitive fields. +pub struct ListDevices { + store: Arc, +} + +impl ListDevices { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the listing. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: ListDevicesInput) -> Result { + let devices = self + .store + .load_devices() + .await? + .into_iter() + .map(|device| DeviceView { + device_id: device.device_id, + name: device.name.as_str().to_owned(), + paired_at_ms: device.paired_at_ms, + last_seen_at_ms: device.last_seen_at_ms, + is_current_device: Some(device.device_id) == input.current_device_id, + }) + .collect(); + Ok(ListDevicesOutput { devices }) + } +} + +/// Input for [`RenameDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenameDeviceInput { + /// Device to rename. + pub device_id: DeviceId, + /// New user-facing name. + pub name: String, +} + +/// Renames a paired device. +pub struct RenameDevice { + store: Arc, +} + +impl RenameDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the rename. + /// + /// # Errors + /// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`] + /// when the device does not exist, and [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> { + let new_name = + DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?; + let mut devices = self.store.load_devices().await?; + let Some(device) = devices + .iter_mut() + .find(|device| device.device_id == input.device_id) + else { + return Err(AppError::NotFound("device not found".to_owned())); + }; + device.name = new_name; + self.store.save_devices(&devices).await?; + Ok(()) + } +} + +/// Input for [`RevokeDevice`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevokeDeviceInput { + /// Device to revoke. + pub device_id: DeviceId, +} + +/// Revokes one paired device and publishes a revocation event after persistence. +pub struct RevokeDevice { + store: Arc, + events: Arc, +} + +impl RevokeDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc, events: Arc) -> Self { + Self { store, events } + } + + /// Executes the revocation. + /// + /// # Errors + /// Returns [`AppError::NotFound`] when the device does not exist and + /// [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> { + if !self.store.remove_device(input.device_id).await? { + return Err(AppError::NotFound("device not found".to_owned())); + } + self.events.publish(DomainEvent::DeviceRevoked { + device_id: input.device_id, + }); + Ok(()) + } +} + +/// Revokes every paired device and publishes a global revocation event. +pub struct RevokeAllDevices { + store: Arc, + events: Arc, +} + +impl RevokeAllDevices { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc, events: Arc) -> Self { + Self { store, events } + } + + /// Executes global revocation. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute(&self) -> Result<(), AppError> { + self.store.save_devices(&[]).await?; + self.events.publish(DomainEvent::AllDevicesRevoked); + Ok(()) + } +} + +/// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request. +pub struct TouchDevice { + store: Arc, + clock: Arc, +} + +impl TouchDevice { + /// Builds the use case from injected ports. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Executes the touch. + /// + /// # Errors + /// Returns [`AppError::Store`] for persistence failures. + pub async fn execute(&self, input: TouchDeviceInput) -> Result { + let now = self.clock.now_millis().max(0) as u64; + let mut devices = self.store.load_devices().await?; + let Some(device) = devices + .iter_mut() + .find(|device| device.device_id == input.device_id) + else { + return Ok(TouchDeviceOutput { updated: false }); + }; + + if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS { + return Ok(TouchDeviceOutput { updated: false }); + } + + device.last_seen_at_ms = now; + self.store.save_devices(&devices).await?; + Ok(TouchDeviceOutput { updated: true }) + } +} + +/// Normalizes user-entered pairing codes server-side. +#[must_use] +pub fn normalize_pairing_code(code: &str) -> String { + code.chars() + .filter(|ch| !ch.is_whitespace() && *ch != '-') + .flat_map(char::to_uppercase) + .collect() +} + +/// Compares pairing codes after normalization without early-exit byte comparison. +#[must_use] +pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool { + let candidate = normalize_pairing_code(candidate); + let expected = normalize_pairing_code(expected); + let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into(); + bytes_equal && candidate.len() == expected.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use domain::ports::StoreError; + use std::sync::Mutex; + use uuid::Uuid; + + #[derive(Default)] + struct FakeStore { + devices: Mutex>, + saves: Mutex, + } + + #[async_trait] + impl DeviceSessionStore for FakeStore { + async fn load_devices(&self) -> Result, StoreError> { + Ok(self.devices.lock().unwrap().clone()) + } + + async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> { + *self.devices.lock().unwrap() = devices.to_vec(); + *self.saves.lock().unwrap() += 1; + Ok(()) + } + } + + struct FixedClock(i64); + + impl domain::ports::Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } + } + + struct FixedIds; + + impl domain::ports::IdGenerator for FixedIds { + fn new_uuid(&self) -> Uuid { + Uuid::from_u128(7) + } + } + + #[test] + fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() { + assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34"); + assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34")); + assert!(!pairing_codes_equal("AB12CD35", "AB12CD34")); + } + + #[tokio::test] + async fn pair_device_validates_name_and_persists_device() { + let store = Arc::new(FakeStore::default()); + let use_case = PairDevice::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock(1234)), + ); + + let output = use_case + .execute(PairDeviceInput { + name: "Phone".to_owned(), + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + }) + .await + .unwrap(); + + assert_eq!(output.device.name.as_str(), "Phone"); + assert_eq!(store.load_devices().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn pair_device_rejects_empty_name() { + let use_case = PairDevice::new( + Arc::new(FakeStore::default()), + Arc::new(FixedIds), + Arc::new(FixedClock(1234)), + ); + + let err = use_case + .execute(PairDeviceInput { + name: " ".to_owned(), + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + } + + #[tokio::test] + async fn touch_device_is_throttled_to_five_minutes() { + let device = PairedDevice { + device_id: DeviceId::from_uuid(Uuid::from_u128(7)), + name: DeviceName::new("Phone").unwrap(), + paired_at_ms: 1000, + last_seen_at_ms: 1000, + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + }; + let store = Arc::new(FakeStore::default()); + store.save_devices(&[device]).await.unwrap(); + *store.saves.lock().unwrap() = 0; + let use_case = TouchDevice::new( + Arc::clone(&store) as Arc, + Arc::new(FixedClock( + (1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64, + )), + ); + + let output = use_case + .execute(TouchDeviceInput { + device_id: DeviceId::from_uuid(Uuid::from_u128(7)), + }) + .await + .unwrap(); + + assert!(!output.updated); + assert_eq!(*store.saves.lock().unwrap(), 0); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 7d069de..0105aed 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -14,6 +14,7 @@ pub mod agent; pub mod background; pub mod conversation; +pub mod device; pub mod diag; pub mod embedder; pub mod error; @@ -65,6 +66,13 @@ pub use conversation::{ ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView, }; +pub use device::{ + normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput, + DeviceView, ListDevices, ListDevicesInput, ListDevicesOutput, PairAttemptDecision, + PairAttemptLimiter, PairDevice, PairDeviceInput, PairDeviceOutput, RateLimitKey, RenameDevice, + RenameDeviceInput, RevokeAllDevices, RevokeDevice, RevokeDeviceInput, TouchDevice, + TouchDeviceInput, TouchDeviceOutput, +}; pub use embedder::{ CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput, DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice, diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index ef1805c..7ad3b24 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -302,6 +302,14 @@ pub enum DomainEventDto { /// Version synced to. to: u64, }, + /// A paired device was revoked. + #[serde(rename_all = "camelCase")] + DeviceRevoked { + /// Device id. + device_id: String, + }, + /// Every paired device was revoked. + AllDevicesRevoked, /// A skill was assigned to (or unassigned from) an agent. #[serde(rename_all = "camelCase")] SkillAssigned { @@ -884,6 +892,10 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), to: to.get(), }, + DomainEvent::DeviceRevoked { device_id } => Self::DeviceRevoked { + device_id: device_id.to_string(), + }, + DomainEvent::AllDevicesRevoked => Self::AllDevicesRevoked, DomainEvent::SkillAssigned { agent_id, skill_id, diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index e06c9a0..2ee5bcc 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -13,43 +13,46 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, - AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, - ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, - CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, - CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, - DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, - DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, - GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, - InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, - ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, - ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, - LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, + CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, + CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, + ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, + CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, + DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, + DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, + EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, + LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, + ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, + ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, + ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, - SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, - SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, - StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, - UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, + RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, + SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, - BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, - EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, - IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, - ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, + BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, + EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, + IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, + ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, }; @@ -69,14 +72,15 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, - FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, - FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, - FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, - FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, - MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, + FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, + FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, + FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, + FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, + FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, + HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, + LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, + NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, @@ -798,6 +802,24 @@ impl AgentResumer for AppAgentResumer { pub struct BackendCore { /// Trivial health use case validating the end-to-end wiring. pub health: Arc, + /// Pair a persistent device session. + pub pair_device: Arc, + /// Limits failed pairing attempts. + pub pair_attempt_limiter: Arc, + /// Authenticate a persistent device session. + pub authenticate_session: Arc, + /// Throttled update of the authenticated device last-seen timestamp. + pub touch_device: Arc, + /// List paired devices. + pub list_devices: Arc, + /// Rename a paired device. + pub rename_device: Arc, + /// Revoke one paired device. + pub revoke_device: Arc, + /// Revoke every paired device. + pub revoke_all_devices: Arc, + /// Paired-device store port, exposed for legacy logout revocation. + pub device_session_store: Arc, /// Create a project (init `.ideai/`, register it). pub create_project: Arc, /// Open a project (load meta + manifest). @@ -1134,11 +1156,21 @@ impl BackendCore { Arc::clone(&fs) as Arc, app_data_dir.to_string_lossy().into_owned(), )); + let device_session_store = Arc::new(FsDeviceSessionStore::new( + Arc::clone(&fs) as Arc, + app_data_dir.to_string_lossy().into_owned(), + )); + let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new( + Arc::clone(&clock) as Arc + )); // Port-typed handles for injection. let fs_port = Arc::clone(&fs) as Arc; let store_port = Arc::clone(&store) as Arc; let window_state_port = Arc::clone(&window_state_store) as Arc; + let device_session_port = Arc::clone(&device_session_store) as Arc; + let pair_attempt_limiter_port = + Arc::clone(&pair_attempt_limiter) as Arc; let events_port = Arc::clone(&event_bus) as Arc; // --- Use cases (ports injected as Arc) --- @@ -1147,6 +1179,27 @@ impl BackendCore { Arc::clone(&ids) as Arc, Arc::clone(&events_port), )); + let pair_device = Arc::new(PairDevice::new( + Arc::clone(&device_session_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + )); + let authenticate_session = + Arc::new(AuthenticateSession::new(Arc::clone(&device_session_port))); + let touch_device = Arc::new(TouchDevice::new( + Arc::clone(&device_session_port), + Arc::clone(&clock) as Arc, + )); + let list_devices = Arc::new(ListDevices::new(Arc::clone(&device_session_port))); + let rename_device = Arc::new(RenameDevice::new(Arc::clone(&device_session_port))); + let revoke_device = Arc::new(RevokeDevice::new( + Arc::clone(&device_session_port), + Arc::clone(&events_port), + )); + let revoke_all_devices = Arc::new(RevokeAllDevices::new( + Arc::clone(&device_session_port), + Arc::clone(&events_port), + )); let create_project = Arc::new(CreateProject::new( Arc::clone(&store_port), @@ -2316,6 +2369,15 @@ impl BackendCore { Self { health, + pair_device, + pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port), + authenticate_session, + touch_device, + list_devices, + rename_device, + revoke_device, + revoke_all_devices, + device_session_store: Arc::clone(&device_session_port), create_project, open_project, close_project, diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 75ad042..4748cbb 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -12,6 +12,9 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/domain/src/device.rs b/crates/domain/src/device.rs new file mode 100644 index 0000000..e86f1f3 --- /dev/null +++ b/crates/domain/src/device.rs @@ -0,0 +1,184 @@ +//! Persistent paired-device access model. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use thiserror::Error; +use uuid::Uuid; + +const SESSION_TOKEN_HASH_PREFIX: &str = "sha256:"; +const SESSION_TOKEN_HASH_CONTEXT: &[u8] = b"idea-session-v1\0"; +const SESSION_TOKEN_HASH_HEX_LEN: usize = 64; + +/// Unique identifier for a paired device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeviceId(Uuid); + +impl DeviceId { + /// Builds a device id from an existing UUID. + #[must_use] + pub const fn from_uuid(value: Uuid) -> Self { + Self(value) + } + + /// Returns the wrapped UUID. + #[must_use] + pub const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl std::fmt::Display for DeviceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// Device display name supplied by the newly paired device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeviceName(String); + +impl DeviceName { + /// Validates and stores a device name. + /// + /// Names are required and limited to 40 Unicode scalar values. + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_owned(); + let len = value.chars().count(); + if len == 0 { + return Err(DeviceError::InvalidName( + "device name is required".to_owned(), + )); + } + if len > 40 { + return Err(DeviceError::InvalidName( + "device name must be 40 characters or fewer".to_owned(), + )); + } + Ok(Self(value)) + } + + /// Returns the stored name. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Hash of a random 256-bit session token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionTokenHash(String); + +impl SessionTokenHash { + /// Hashes raw token bytes as `SHA-256("idea-session-v1\0" || token_bytes)`. + #[must_use] + pub fn from_token_bytes(token_bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(SESSION_TOKEN_HASH_CONTEXT); + hasher.update(token_bytes); + let digest = hasher.finalize(); + Self(format!( + "{SESSION_TOKEN_HASH_PREFIX}{}", + hex::encode(digest) + )) + } + + /// Validates an already persisted hash string. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_hash_string(&value)?; + Ok(Self(value)) + } + + /// Returns the persisted hash string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Constant-time verification of raw token bytes against this stored hash. + #[must_use] + pub fn verify_token_bytes(&self, token_bytes: &[u8]) -> bool { + let candidate = Self::from_token_bytes(token_bytes); + self.constant_time_eq(&candidate) + } + + /// Constant-time equality for two valid persisted token hashes. + #[must_use] + pub fn constant_time_eq(&self, other: &Self) -> bool { + self.0.as_bytes().ct_eq(other.0.as_bytes()).into() + } +} + +/// A paired device persisted in the app-data security store. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairedDevice { + /// Persistent device id. + pub device_id: DeviceId, + /// User-facing device name. + pub name: DeviceName, + /// Pairing timestamp as epoch milliseconds. + pub paired_at_ms: u64, + /// Last successful authenticated access timestamp as epoch milliseconds. + pub last_seen_at_ms: u64, + /// Hash of the bearer session token. + pub session_token_hash: SessionTokenHash, +} + +/// Successful session authentication result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedDevice { + /// Authenticated device id. + pub device_id: DeviceId, + /// Matched token hash. + pub token_hash: SessionTokenHash, +} + +/// Device-domain validation errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum DeviceError { + /// Invalid device name. + #[error("invalid device name: {0}")] + InvalidName(String), + /// Invalid persisted token hash. + #[error("invalid session token hash")] + InvalidSessionTokenHash, +} + +fn validate_hash_string(value: &str) -> Result<(), DeviceError> { + let Some(hex) = value.strip_prefix(SESSION_TOKEN_HASH_PREFIX) else { + return Err(DeviceError::InvalidSessionTokenHash); + }; + if hex.len() != SESSION_TOKEN_HASH_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(DeviceError::InvalidSessionTokenHash); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_token_hash_is_prefixed_sha256_and_verifies_constant_time() { + let token = [7_u8; 32]; + let hash = SessionTokenHash::from_token_bytes(&token); + + assert!(hash.as_str().starts_with("sha256:")); + assert_eq!(hash.as_str().len(), "sha256:".len() + 64); + assert!(hash.verify_token_bytes(&token)); + assert!(!hash.verify_token_bytes(&[8_u8; 32])); + assert!(hash.constant_time_eq(&SessionTokenHash::new(hash.as_str()).unwrap())); + } + + #[test] + fn device_name_is_required_and_limited() { + assert!(DeviceName::new("phone").is_ok()); + assert!(DeviceName::new(" ").is_err()); + assert!(DeviceName::new("a".repeat(41)).is_err()); + } +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 107014e..d096926 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -2,6 +2,7 @@ //! presentation layer (ARCHITECTURE §3.2). use crate::conversation::ConversationParty; +use crate::device::DeviceId; use crate::ids::{ AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId, @@ -268,6 +269,13 @@ pub enum DomainEvent { /// Version it was brought up to. to: TemplateVersion, }, + /// A paired device was revoked and any live transport for it must be closed. + DeviceRevoked { + /// Revoked device. + device_id: DeviceId, + }, + /// All paired devices were revoked and every live paired transport must close. + AllDevicesRevoked, /// A skill was assigned to (or unassigned from) an agent. SkillAssigned { /// The agent whose skill set changed. diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9d6e603..ff017a4 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -35,6 +35,7 @@ pub mod agent_tool_policy; pub mod background_task; pub mod conversation; pub mod conversation_log; +pub mod device; pub mod error; pub mod events; pub mod fileguard; @@ -140,6 +141,10 @@ pub use conversation_log::{ ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS, }; +pub use device::{ + AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash, +}; + pub use fileguard::{ is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, OrchestratorDesignation, ReadLease, WriteLease, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 938b1de..2a20fa2 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy; use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; +use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice}; use crate::events::DomainEvent; use crate::ids::{ AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId, @@ -1688,6 +1689,45 @@ pub trait EmbedderPromptStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Persists paired devices and their session-token hashes. +#[async_trait] +pub trait DeviceSessionStore: Send + Sync { + /// Loads all paired devices. A missing store is represented as an empty list. + async fn load_devices(&self) -> Result, StoreError>; + + /// Replaces the persisted device list atomically enough for the filesystem + /// adapter's single-process use case. + async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError>; + + /// Removes one device by id. + async fn remove_device(&self, device_id: DeviceId) -> Result { + let mut devices = self.load_devices().await?; + let before = devices.len(); + devices.retain(|device| device.device_id != device_id); + if devices.len() != before { + self.save_devices(&devices).await?; + return Ok(true); + } + Ok(false) + } + + /// Authenticates a session token against persisted token hashes. + async fn authenticate_token( + &self, + token_bytes: &[u8], + ) -> Result, StoreError> { + for device in self.load_devices().await? { + if device.session_token_hash.verify_token_bytes(token_bytes) { + return Ok(Some(AuthenticatedDevice { + device_id: device.device_id, + token_hash: device.session_token_hash, + })); + } + } + Ok(None) + } +} + /// Reads/writes agent `.md` contexts and the project manifest, within a project. #[async_trait] pub trait AgentContextStore: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index f33a772..934386d 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -28,6 +28,7 @@ pub mod issues; pub mod mailbox; pub mod model_server; pub mod orchestrator; +pub mod pair_attempt_limiter; pub mod permission; pub mod process; pub mod pty; @@ -77,6 +78,7 @@ pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, REQUESTS_SUBDIR, }; +pub use pair_attempt_limiter::InMemoryPairAttemptLimiter; pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; @@ -96,9 +98,9 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, - FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore, - FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, - FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, - StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, + FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, + FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/pair_attempt_limiter.rs b/crates/infrastructure/src/pair_attempt_limiter.rs new file mode 100644 index 0000000..ef04be1 --- /dev/null +++ b/crates/infrastructure/src/pair_attempt_limiter.rs @@ -0,0 +1,153 @@ +//! In-memory pairing-attempt limiter. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use application::{PairAttemptDecision, PairAttemptLimiter, RateLimitKey}; +use async_trait::async_trait; +use domain::ports::{Clock, StoreError}; + +const WINDOW_MS: i64 = 60_000; +const PER_ORIGIN_LIMIT: usize = 5; +const GLOBAL_LIMIT: usize = 30; + +/// Process-local limiter for failed pairing attempts. +pub struct InMemoryPairAttemptLimiter { + clock: Arc, + inner: Mutex, +} + +#[derive(Default)] +struct LimiterState { + by_origin: HashMap>, + global: Vec, +} + +impl InMemoryPairAttemptLimiter { + /// Creates a limiter using the supplied clock. + #[must_use] + pub fn new(clock: Arc) -> Self { + Self { + clock, + inner: Mutex::new(LimiterState::default()), + } + } +} + +#[async_trait] +impl PairAttemptLimiter for InMemoryPairAttemptLimiter { + async fn check(&self, key: RateLimitKey) -> Result { + let now = self.clock.now_millis().max(0); + let cutoff = now.saturating_sub(WINDOW_MS); + let mut inner = self.inner.lock().expect("pair limiter mutex poisoned"); + prune(&mut inner.global, cutoff); + let origin = origin_key(&key); + let origin_bucket = inner.by_origin.entry(origin).or_default(); + prune(origin_bucket, cutoff); + if origin_bucket.len() >= PER_ORIGIN_LIMIT || inner.global.len() >= GLOBAL_LIMIT { + return Ok(PairAttemptDecision::RateLimited); + } + Ok(PairAttemptDecision::Allowed) + } + + async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError> { + let now = self.clock.now_millis().max(0); + let cutoff = now.saturating_sub(WINDOW_MS); + let mut inner = self.inner.lock().expect("pair limiter mutex poisoned"); + prune(&mut inner.global, cutoff); + inner.global.push(now); + let origin = origin_key(&key); + let origin_bucket = inner.by_origin.entry(origin).or_default(); + prune(origin_bucket, cutoff); + origin_bucket.push(now); + Ok(()) + } +} + +fn origin_key(key: &RateLimitKey) -> String { + format!("{}:{}", key.route, key.origin) +} + +fn prune(bucket: &mut Vec, cutoff: i64) { + bucket.retain(|timestamp| *timestamp > cutoff); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicI64, Ordering}; + + struct FakeClock(AtomicI64); + + impl FakeClock { + fn new(now: i64) -> Self { + Self(AtomicI64::new(now)) + } + + fn set(&self, now: i64) { + self.0.store(now, Ordering::SeqCst); + } + } + + impl Clock for FakeClock { + fn now_millis(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } + } + + fn key(origin: &str) -> RateLimitKey { + RateLimitKey { + origin: origin.to_owned(), + route: "/api/pair".to_owned(), + } + } + + #[tokio::test] + async fn limits_five_failures_per_origin_per_minute_with_injected_clock() { + let clock = Arc::new(FakeClock::new(1_000)); + let limiter = InMemoryPairAttemptLimiter::new(Arc::clone(&clock) as Arc); + + for _ in 0..PER_ORIGIN_LIMIT { + assert_eq!( + limiter.check(key("1.2.3.4")).await.unwrap(), + PairAttemptDecision::Allowed + ); + limiter.record_failure(key("1.2.3.4")).await.unwrap(); + } + + assert_eq!( + limiter.check(key("1.2.3.4")).await.unwrap(), + PairAttemptDecision::RateLimited + ); + assert_eq!( + limiter.check(key("1.2.3.5")).await.unwrap(), + PairAttemptDecision::Allowed + ); + + clock.set(61_001); + assert_eq!( + limiter.check(key("1.2.3.4")).await.unwrap(), + PairAttemptDecision::Allowed + ); + } + + #[tokio::test] + async fn limits_thirty_failures_globally_per_minute() { + let clock = Arc::new(FakeClock::new(1_000)); + let limiter = InMemoryPairAttemptLimiter::new(clock as Arc); + + for n in 0..GLOBAL_LIMIT { + let key = key(&format!("10.0.0.{n}")); + assert_eq!( + limiter.check(key.clone()).await.unwrap(), + PairAttemptDecision::Allowed + ); + limiter.record_failure(key).await.unwrap(); + } + + assert_eq!( + limiter.check(key("10.0.0.99")).await.unwrap(), + PairAttemptDecision::RateLimited + ); + } +} diff --git a/crates/infrastructure/src/store/device_session.rs b/crates/infrastructure/src/store/device_session.rs new file mode 100644 index 0000000..cc9e1b3 --- /dev/null +++ b/crates/infrastructure/src/store/device_session.rs @@ -0,0 +1,176 @@ +//! Filesystem-backed persistent paired-device store. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ports::{DeviceSessionStore, FileSystem, FsError, RemotePath, StoreError}; +use domain::PairedDevice; + +const DEVICES_DOC_VERSION: u8 = 1; +const SECURITY_DIR: &str = "security"; +const DEVICES_FILE: &str = "devices.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DevicesDoc { + version: u8, + devices: Vec, +} + +/// JSON-file implementation for `{app_data_dir}/security/devices.json`. +#[derive(Clone)] +pub struct FsDeviceSessionStore { + fs: Arc, + app_data_dir: String, +} + +impl FsDeviceSessionStore { + /// Builds the store from an injected filesystem port and app-data directory. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + fn security_dir(&self) -> RemotePath { + RemotePath::new(format!( + "{}/{}", + self.app_data_dir.trim_end_matches(['/', '\\']), + SECURITY_DIR + )) + } + + fn path(&self) -> RemotePath { + RemotePath::new(format!("{}/{}", self.security_dir().as_str(), DEVICES_FILE)) + } +} + +#[async_trait] +impl DeviceSessionStore for FsDeviceSessionStore { + async fn load_devices(&self) -> Result, StoreError> { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + let doc: DevicesDoc = serde_json::from_slice(&bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + if doc.version != DEVICES_DOC_VERSION { + return Err(StoreError::Serialization(format!( + "unsupported devices.json version {}", + doc.version + ))); + } + Ok(doc.devices) + } + Err(FsError::NotFound(_)) => Ok(Vec::new()), + Err(err) => Err(StoreError::Io(err.to_string())), + } + } + + async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> { + self.fs + .create_dir_all(&self.security_dir()) + .await + .map_err(|err| StoreError::Io(err.to_string()))?; + let doc = DevicesDoc { + version: DEVICES_DOC_VERSION, + devices: devices.to_vec(), + }; + let mut bytes = serde_json::to_vec_pretty(&doc) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|err| StoreError::Io(err.to_string())) + } +} + +#[cfg(test)] +mod tests { + use self::infrastructure_test_support::TempFs; + use super::*; + use domain::{DeviceId, DeviceName, SessionTokenHash}; + use uuid::Uuid; + + mod infrastructure_test_support { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use crate::LocalFileSystem; + use domain::ports::FileSystem; + + pub struct TempFs { + pub root: PathBuf, + pub fs: Arc, + } + + impl TempFs { + pub fn new() -> Self { + let root = std::env::temp_dir() + .join(format!("idea-devices-store-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + Self { + root, + fs: Arc::new(LocalFileSystem::new()), + } + } + + pub fn path(&self, rel: &str) -> PathBuf { + self.root.join(Path::new(rel)) + } + } + + impl Drop for TempFs { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + } + + fn device() -> PairedDevice { + PairedDevice { + device_id: DeviceId::from_uuid(Uuid::from_u128(1)), + name: DeviceName::new("Phone").unwrap(), + paired_at_ms: 1000, + last_seen_at_ms: 1000, + session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]), + } + } + + #[tokio::test] + async fn missing_file_loads_empty_device_list() { + let temp = TempFs::new(); + let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy()); + + assert_eq!(store.load_devices().await.unwrap(), Vec::new()); + } + + #[tokio::test] + async fn devices_round_trip_in_v1_document() { + let temp = TempFs::new(); + let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy()); + + store.save_devices(&[device()]).await.unwrap(); + + let loaded = store.load_devices().await.unwrap(); + assert_eq!(loaded, vec![device()]); + let raw = std::fs::read_to_string(temp.path("security/devices.json")).unwrap(); + assert!(raw.contains("\"version\": 1")); + assert!(raw.contains("\"deviceId\"")); + assert!(raw.contains("\"sessionTokenHash\"")); + } + + #[tokio::test] + async fn corrupted_json_is_a_serialization_error() { + let temp = TempFs::new(); + std::fs::create_dir_all(temp.path("security")).unwrap(); + std::fs::write(temp.path("security/devices.json"), b"{not json").unwrap(); + let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy()); + + let err = store.load_devices().await.unwrap_err(); + + assert!(matches!(err, StoreError::Serialization(_))); + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 24ce547..032a11b 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -6,6 +6,7 @@ mod background_task; mod context; +mod device_session; mod embedder; mod live_state; mod memory; @@ -19,6 +20,7 @@ mod window_state; pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore}; pub use context::IdeaiContextStore; +pub use device_session::FsDeviceSessionStore; #[cfg(feature = "vector-onnx")] pub use embedder::OnnxEmbedder; #[cfg(feature = "vector-http")] diff --git a/crates/web-server/Cargo.toml b/crates/web-server/Cargo.toml index dc072d7..5b5c60c 100644 --- a/crates/web-server/Cargo.toml +++ b/crates/web-server/Cargo.toml @@ -25,8 +25,12 @@ uuid = { workspace = true } base64 = "0.22" bytes = "1.11" cookie = "0.18" +getrandom = { workspace = true } +hex = { workspace = true } http = "1.4" http-body-util = "0.1" +sha2 = { workspace = true } +subtle = { workspace = true } [features] vector-http = ["backend/vector-http"] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 08ee401..77f8c14 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -3,7 +3,7 @@ //! The shared backend core stays unaware of HTTP, cookies, origins and //! WebSocket framing; this module owns the secure web driving adapter. -use std::collections::HashSet; +use std::collections::HashMap; use std::env; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; use bytes::Bytes; use cookie::{Cookie, SameSite}; use domain::events::DomainEvent; @@ -23,6 +25,8 @@ use http::{HeaderMap, Method, Response, StatusCode, Uri}; use http_body_util::{BodyExt, Full}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::{mpsc, oneshot}; @@ -30,11 +34,13 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, - ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, + AuthenticateSessionInput, CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, + ListDevicesInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, + RateLimitKey, RenameDeviceInput, ResizeTerminalInput, RevokeDeviceInput, + RotateConversationLogInput, TouchDeviceInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; -use domain::{Project, SessionId}; +use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash}; use backend::dto::{ parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, @@ -47,12 +53,17 @@ use backend::BackendCore; const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; const SESSION_COOKIE: &str = "idea_session"; +const SESSION_COOKIE_MAX_AGE_SECONDS: i64 = 34_560_000; +const PAIRING_CODE_TTL_SECONDS: i64 = 600; +const PAIRING_CODE_TTL_MS: i64 = PAIRING_CODE_TTL_SECONDS * 1000; +const PAIRING_CODE_HASH_CONTEXT: &[u8] = b"idea-pairing-code-v1\0"; const WS_PATH: &str = "/api/ws"; const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const WS_MAX_PAYLOAD: usize = 64 * 1024; const WS_OUTPUT_BUFFER: usize = 512; type ResponseBody = Full; +static NEXT_PAIRING_GENERATION_ID: AtomicU64 = AtomicU64::new(1); /// Runs the `idea --serve` subcommand from already-split CLI arguments. pub fn run_from_args(args: Vec) -> ExitCode { @@ -102,6 +113,8 @@ pub struct ServerConfig { pub app_data_dir: PathBuf, /// Built frontend assets root. pub web_root: PathBuf, + /// Generate and print one ephemeral pairing code after startup. + pub new_code: bool, } /// Authorized reverse proxy peer IP or CIDR range. @@ -180,6 +193,7 @@ impl ServerConfig { let mut trusted_proxies = Vec::new(); let mut app_data_dir = default_app_data_dir(); let mut web_root = None; + let mut new_code = false; let mut it = args.into_iter(); while let Some(arg) = it.next() { @@ -218,6 +232,7 @@ impl ServerConfig { .ok_or_else(|| "--web-root requires a path".to_owned())?; web_root = Some(PathBuf::from(value)); } + "--new-code" => new_code = true, "--help" | "-h" => return Err(Self::usage()), other => return Err(format!("unknown --serve argument: {other}")), } @@ -232,6 +247,7 @@ impl ServerConfig { trusted_proxies, app_data_dir, web_root, + new_code, }) } @@ -277,7 +293,7 @@ impl ServerConfig { /// Human-readable CLI usage. #[must_use] pub fn usage() -> String { - "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy [--trusted-proxy IP_OR_CIDR]...]".to_owned() + "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--new-code] [--allow-remote --public-origin https://host --trust-reverse-proxy [--trusted-proxy IP_OR_CIDR]...]".to_owned() } } @@ -293,7 +309,7 @@ pub enum EmbeddedServerState { /// Handle returned by [`run_embedded`] and [`run_embedded_with_core`]. pub struct EmbeddedServerHandle { url: String, - pairing_code: String, + state_ref: Arc, shutdown: Option>, task: JoinHandle>, state: EmbeddedServerState, @@ -307,10 +323,9 @@ impl EmbeddedServerHandle { &self.url } - /// Pairing code accepted by `POST /api/pair`. - #[must_use] - pub fn pairing_code(&self) -> &str { - &self.pairing_code + /// Generates a new ephemeral pairing code on the embedded server. + pub fn generate_pairing_code(&self) -> PairingCodeDto { + self.state_ref.generate_pairing_code() } /// Current lifecycle state. @@ -359,12 +374,11 @@ pub async fn run_embedded_with_core( .map_err(|err| format!("failed to read listener address: {err}"))?; let effective_config = config_with_effective_listen(config.clone(), local_addr); let state = Arc::new(ServerState::with_core(effective_config, core)); - let pairing_code = state.pairing_code().to_owned(); let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let task = tokio::spawn(run_listener(listener, state, shutdown_rx)); + let task = tokio::spawn(run_listener(listener, Arc::clone(&state), shutdown_rx)); Ok(EmbeddedServerHandle { url: format!("http://{local_addr}"), - pairing_code, + state_ref: state, shutdown: Some(shutdown_tx), task, state: EmbeddedServerState::Running, @@ -418,12 +432,31 @@ fn validate_web_root(path: PathBuf, source: &str) -> Result { struct ServerState { config: ServerConfig, app: Arc, - pairing_code: String, - sessions: Mutex>, + pairing_code: Mutex>, ws_pty_bridge: Arc>, + active_connections: Arc, + _revocation_observer: Option>, security_logger: Arc, } +struct PairingCodeState { + code_hash: [u8; 32], + expires_at_ms: i64, + generation_id: u64, + used: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PairingCodeDto { + /// One-time pairing code, returned only at generation time. + pub code: String, + /// Expiration timestamp as epoch milliseconds. + pub expires_at_ms: u64, + /// Time-to-live in seconds. + pub ttl_seconds: i64, +} + impl ServerState { fn new(config: ServerConfig) -> Self { let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); @@ -431,12 +464,18 @@ impl ServerState { } fn with_core(config: ServerConfig, core: Arc) -> Self { + let active_connections = Arc::new(ActiveConnectionRegistry::default()); + let revocation_observer = spawn_device_revocation_observer( + core.event_bus.raw_receiver(), + Arc::clone(&active_connections), + ); Self { app: core, config, - pairing_code: new_pairing_code(), - sessions: Mutex::new(HashSet::new()), + pairing_code: Mutex::new(None), ws_pty_bridge: Arc::new(OutputBridge::new()), + active_connections, + _revocation_observer: revocation_observer, security_logger: Arc::new(StderrSecurityLogger), } } @@ -452,40 +491,128 @@ impl ServerState { pairing_code: impl Into, security_logger: Arc, ) -> Self { - Self { - app: Arc::new(BackendCore::build(config.app_data_dir.clone())), + let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); + let active_connections = Arc::new(ActiveConnectionRegistry::default()); + let revocation_observer = spawn_device_revocation_observer( + core.event_bus.raw_receiver(), + Arc::clone(&active_connections), + ); + let state = Self { + app: core, config, - pairing_code: pairing_code.into(), - sessions: Mutex::new(HashSet::new()), + pairing_code: Mutex::new(None), ws_pty_bridge: Arc::new(OutputBridge::new()), + active_connections, + _revocation_observer: revocation_observer, security_logger, + }; + state.set_pairing_code_for_test(pairing_code.into()); + state + } + + fn generate_pairing_code(&self) -> PairingCodeDto { + let code = new_pairing_code(); + let now = current_time_millis(); + let expires_at_ms = now.saturating_add(PAIRING_CODE_TTL_MS); + let generation_id = NEXT_PAIRING_GENERATION_ID.fetch_add(1, Ordering::Relaxed); + let mut guard = self + .pairing_code + .lock() + .expect("pairing code mutex poisoned"); + *guard = Some(PairingCodeState { + code_hash: pairing_code_hash(&code), + expires_at_ms, + generation_id, + used: false, + }); + PairingCodeDto { + code, + expires_at_ms: expires_at_ms.max(0) as u64, + ttl_seconds: PAIRING_CODE_TTL_SECONDS, } } - fn pairing_code(&self) -> &str { - &self.pairing_code + #[cfg(test)] + fn set_pairing_code_for_test(&self, code: String) { + let expires_at_ms = current_time_millis().saturating_add(PAIRING_CODE_TTL_MS); + let generation_id = NEXT_PAIRING_GENERATION_ID.fetch_add(1, Ordering::Relaxed); + let mut guard = self + .pairing_code + .lock() + .expect("pairing code mutex poisoned"); + *guard = Some(PairingCodeState { + code_hash: pairing_code_hash(&code), + expires_at_ms, + generation_id, + used: false, + }); } - fn create_session(&self) -> String { - let token = new_session_token(); - if let Ok(mut sessions) = self.sessions.lock() { - sessions.insert(token.clone()); + #[cfg(test)] + fn expire_pairing_code_for_test(&self) { + if let Some(code) = self + .pairing_code + .lock() + .expect("pairing code mutex poisoned") + .as_mut() + { + code.expires_at_ms = current_time_millis().saturating_sub(1); } - token } - fn has_session(&self, token: &str) -> bool { - self.sessions + fn consume_pairing_code(&self, code: &str) -> PairingCodeConsumeResult { + let now = current_time_millis(); + let candidate_hash = pairing_code_hash(code); + let mut guard = self + .pairing_code .lock() - .map(|sessions| sessions.contains(token)) - .unwrap_or(false) + .expect("pairing code mutex poisoned"); + let Some(current) = guard.as_mut() else { + return PairingCodeConsumeResult::InvalidOrExpired; + }; + if current.used || now > current.expires_at_ms { + return PairingCodeConsumeResult::InvalidOrExpired; + } + let matches: bool = current.code_hash.ct_eq(&candidate_hash).into(); + if !matches { + return PairingCodeConsumeResult::InvalidOrExpired; + } + let _generation_id = current.generation_id; + current.used = true; + PairingCodeConsumeResult::Valid } - fn revoke_session(&self, token: &str) -> bool { - self.sessions - .lock() - .map(|mut sessions| sessions.remove(token)) - .unwrap_or(false) + async fn authenticate_session(&self, token: &str) -> Option { + let token_bytes = decode_session_token(token).ok()?; + self + .app + .authenticate_session + .execute(AuthenticateSessionInput { token_bytes }) + .await + .unwrap_or_default() + } + + async fn touch_session(&self, device: &AuthenticatedDevice) { + let _ = self + .app + .touch_device + .execute(TouchDeviceInput { + device_id: device.device_id, + }) + .await; + } + + async fn revoke_session(&self, token: &str) -> bool { + let Some(device) = self.authenticate_session(token).await else { + return false; + }; + self.app + .revoke_device + .execute(RevokeDeviceInput { + device_id: device.device_id, + }) + .await + .is_ok() } fn log_security(&self, event: SecurityLogEvent) { @@ -493,6 +620,114 @@ impl ServerState { } } +fn spawn_device_revocation_observer( + mut rx: tokio::sync::broadcast::Receiver, + active_connections: Arc, +) -> Option> { + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return None; + }; + Some(handle.spawn(async move { + loop { + match rx.recv().await { + Ok(DomainEvent::DeviceRevoked { device_id }) => { + active_connections.close_device(device_id); + } + Ok(DomainEvent::AllDevicesRevoked) => { + active_connections.close_all(); + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })) +} + +#[derive(Default)] +struct ActiveConnectionRegistry { + next_id: AtomicU64, + connections: Mutex>>, +} + +struct ActiveConnectionHandle { + id: u64, + shutdown: oneshot::Sender<()>, +} + +struct ActiveConnectionRegistration { + device_id: DeviceId, + id: u64, + shutdown: oneshot::Receiver<()>, +} + +impl ActiveConnectionRegistry { + fn register(&self, device_id: DeviceId) -> ActiveConnectionRegistration { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (shutdown, rx) = oneshot::channel(); + self.connections + .lock() + .expect("active connection registry mutex poisoned") + .entry(device_id) + .or_default() + .push(ActiveConnectionHandle { id, shutdown }); + ActiveConnectionRegistration { + device_id, + id, + shutdown: rx, + } + } + + fn unregister(&self, device_id: DeviceId, id: u64) { + let mut connections = self + .connections + .lock() + .expect("active connection registry mutex poisoned"); + let Some(handles) = connections.get_mut(&device_id) else { + return; + }; + handles.retain(|handle| handle.id != id); + if handles.is_empty() { + connections.remove(&device_id); + } + } + + fn close_device(&self, device_id: DeviceId) { + let handles = self + .connections + .lock() + .expect("active connection registry mutex poisoned") + .remove(&device_id) + .unwrap_or_default(); + for handle in handles { + let _ = handle.shutdown.send(()); + } + } + + fn close_all(&self) { + let handles: Vec<_> = self + .connections + .lock() + .expect("active connection registry mutex poisoned") + .drain() + .flat_map(|(_, handles)| handles) + .collect(); + for handle in handles { + let _ = handle.shutdown.send(()); + } + } + + #[cfg(test)] + fn active_count(&self) -> usize { + self.connections + .lock() + .expect("active connection registry mutex poisoned") + .values() + .map(Vec::len) + .sum() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum SecurityLogEvent { PairingSucceeded { @@ -608,6 +843,7 @@ fn origin_label(origin: &Option) -> &str { } async fn run_server(config: ServerConfig) -> Result<(), String> { + let print_new_code = config.new_code; let listener = TcpListener::bind(config.listen) .await .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; @@ -616,7 +852,11 @@ async fn run_server(config: ServerConfig) -> Result<(), String> { .map_err(|err| format!("failed to read listener address: {err}"))?; let effective_config = config_with_effective_listen(config.clone(), local_addr); let state = Arc::new(ServerState::new(effective_config)); - eprintln!("IdeA pairing code: {}", state.pairing_code()); + if print_new_code { + let pairing = state.generate_pairing_code(); + eprintln!("IdeA pairing code: {}", pairing.code); + eprintln!("IdeA pairing code expires at: {}", pairing.expires_at_ms); + } eprintln!( "idea --serve: app data dir = {}", config.app_data_dir.display() @@ -827,8 +1067,8 @@ async fn handle_ws_upgrade( headers: HeaderMap, state: Arc, ) -> Result<(), String> { - let accept = match validate_ws_upgrade(&headers, Some(peer_ip), &state) { - Ok(accept) => accept, + let (accept, device) = match validate_ws_upgrade(&headers, Some(peer_ip), &state).await { + Ok(result) => result, Err(response) => return write_http_response(&mut stream, *response).await, }; @@ -842,17 +1082,15 @@ async fn handle_ws_upgrade( .write_all(response.as_bytes()) .await .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; - run_ws_connection(stream, state).await + run_ws_connection(stream, state, device).await } -fn validate_ws_upgrade( +async fn validate_ws_upgrade( headers: &HeaderMap, peer_ip: Option, state: &ServerState, -) -> Result>> { - if let Err(response) = validate_reverse_proxy(headers, peer_ip, state, WS_PATH) { - return Err(response); - } +) -> Result<(String, AuthenticatedDevice), Box>> { + validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?; let origin = match validate_request_origin(headers, &state.config) { Ok(origin) => origin, @@ -876,7 +1114,7 @@ fn validate_ws_upgrade( origin.as_deref(), ))); }; - if !state.has_session(&token) { + let Some(device) = state.authenticate_session(&token).await else { state.log_security(SecurityLogEvent::WsUpgradeRejected { origin: origin.clone(), reason: "invalid_session", @@ -887,7 +1125,8 @@ fn validate_ws_upgrade( "invalid session cookie", origin.as_deref(), ))); - } + }; + state.touch_session(&device).await; if !header_contains_token(headers, "upgrade", "websocket") || !header_contains_token(headers, "connection", "upgrade") { @@ -917,7 +1156,7 @@ fn validate_ws_upgrade( origin.as_deref(), ))); }; - Ok(websocket_accept(key)) + Ok((websocket_accept(key), device)) } fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { @@ -934,10 +1173,13 @@ fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool async fn run_ws_connection( stream: tokio::net::TcpStream, state: Arc, + device: AuthenticatedDevice, ) -> Result<(), String> { let (mut reader, mut writer) = stream.into_split(); let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); + let registration = state.active_connections.register(device.device_id); + let mut shutdown = registration.shutdown; let event_relay_task = spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); let writer_task = tokio::spawn(async move { @@ -954,17 +1196,22 @@ async fn run_ws_connection( }); loop { - let frame = match read_ws_frame(&mut reader).await { - Ok(frame) => frame, - Err(err) => { - let _ = tx - .send(ServerFrame::error( - None, - None, - "WS_PROTOCOL", - err.to_string(), - )) - .await; + let frame = tokio::select! { + frame = read_ws_frame(&mut reader) => match frame { + Ok(frame) => frame, + Err(err) => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "WS_PROTOCOL", + err.to_string(), + )) + .await; + break; + } + }, + _ = &mut shutdown => { break; } }; @@ -1006,6 +1253,9 @@ async fn run_ws_connection( state.ws_pty_bridge.unregister_if(session, *gen); } } + state + .active_connections + .unregister(registration.device_id, registration.id); event_relay_task.abort(); drop(tx); writer_task @@ -1413,6 +1663,95 @@ async fn dispatch_http( match (method, uri.path()) { (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, + (Method::POST, "/api/pairing-code") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + let Some(device) = state.authenticate_session(&token).await else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + }; + state.touch_session(&device).await; + let dto = state.generate_pairing_code(); + let body = serde_json::to_value(dto).expect("pairing code dto serializes"); + let mut response = json_response(StatusCode::OK, &body, origin.as_deref()); + refresh_session_cookie(&mut response, &state, &token); + response + } + (Method::GET, "/api/devices") => { + let (token, device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let mut response = + list_devices_response(Arc::clone(&state), &device, origin.as_deref()).await; + refresh_session_cookie(&mut response, &state, &token); + response + } + (Method::POST, "/api/devices/revoke-all") => { + let (_token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + revoke_all_devices_response(Arc::clone(&state), origin.as_deref()).await + } + (Method::POST, path) if path.starts_with("/api/devices/") && path.ends_with("/rename") => { + let (token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let raw_id = path + .trim_start_matches("/api/devices/") + .trim_end_matches("/rename") + .trim_end_matches('/'); + let device_id = match parse_device_id(raw_id) { + Ok(device_id) => device_id, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + err, + origin.as_deref(), + ); + } + }; + let mut response = + rename_device_response(body, Arc::clone(&state), device_id, origin.as_deref()) + .await; + refresh_session_cookie(&mut response, &state, &token); + response + } + (Method::POST, path) if path.starts_with("/api/devices/") && path.ends_with("/revoke") => { + let (_token, _device) = match authenticated_device(&headers, &state).await { + Ok(auth) => auth, + Err(response) => return response, + }; + let raw_id = path + .trim_start_matches("/api/devices/") + .trim_end_matches("/revoke") + .trim_end_matches('/'); + let device_id = match parse_device_id(raw_id) { + Ok(device_id) => device_id, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + err, + origin.as_deref(), + ); + } + }; + revoke_device_response(Arc::clone(&state), device_id, origin.as_deref()).await + } (Method::POST, "/api/logout") => { let Some(token) = session_cookie(&headers) else { return error_response( @@ -1422,7 +1761,7 @@ async fn dispatch_http( origin.as_deref(), ); }; - if !state.revoke_session(&token) { + if !state.revoke_session(&token).await { return error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", @@ -1444,17 +1783,34 @@ async fn dispatch_http( origin.as_deref(), ); }; - if !state.has_session(&token) { + let Some(device) = state.authenticate_session(&token).await else { return error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "invalid session cookie", origin.as_deref(), ); - } - invoke(body, state, origin.as_deref()).await + }; + state.touch_session(&device).await; + let mut response = invoke(body, Arc::clone(&state), origin.as_deref()).await; + refresh_session_cookie(&mut response, &state, &token); + response } - (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( + ( + _, + "/api/pair" + | "/api/pairing-code" + | "/api/invoke" + | "/api/logout" + | "/api/devices" + | "/api/devices/revoke-all", + ) => error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + origin.as_deref(), + ), + (_, path) if path.starts_with("/api/devices/") => error_response( StatusCode::METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "method not allowed", @@ -1590,6 +1946,170 @@ fn content_type(path: &Path) -> &'static str { #[derive(Deserialize)] struct PairRequest { code: String, + name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DeviceDto { + device_id: String, + name: String, + paired_at_ms: u64, + last_seen_at_ms: u64, + is_current_device: bool, +} + +#[derive(Deserialize)] +struct RenameDeviceRequest { + name: String, +} + +fn parse_device_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(DeviceId::from_uuid) + .map_err(|_| "invalid device id".to_owned()) +} + +async fn authenticated_device( + headers: &HeaderMap, + state: &ServerState, +) -> Result<(String, AuthenticatedDevice), Response> { + let origin = request_origin(headers); + let Some(token) = session_cookie(headers) else { + return Err(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + )); + }; + let Some(device) = state.authenticate_session(&token).await else { + return Err(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + )); + }; + state.touch_session(&device).await; + Ok((token, device)) +} + +async fn list_devices_response( + state: Arc, + device: &AuthenticatedDevice, + origin: Option<&str>, +) -> Response { + match state + .app + .list_devices + .execute(ListDevicesInput { + current_device_id: Some(device.device_id), + }) + .await + { + Ok(output) => { + let devices: Vec<_> = output + .devices + .into_iter() + .map(|device| DeviceDto { + device_id: device.device_id.to_string(), + name: device.name, + paired_at_ms: device.paired_at_ms, + last_seen_at_ms: device.last_seen_at_ms, + is_current_device: device.is_current_device, + }) + .collect(); + json_response(StatusCode::OK, &json!({ "devices": devices }), origin) + } + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } +} + +async fn rename_device_response( + body: Bytes, + state: Arc, + device_id: DeviceId, + origin: Option<&str>, +) -> Response { + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid rename request: {err}"), + origin, + ); + } + }; + match state + .app + .rename_device + .execute(RenameDeviceInput { + device_id, + name: request.name, + }) + .await + { + Ok(()) => json_response(StatusCode::OK, &json!({ "renamed": true }), origin), + Err(err @ application::AppError::Invalid(_)) => { + error_response(StatusCode::BAD_REQUEST, err.code(), err.to_string(), origin) + } + Err(err @ application::AppError::NotFound(_)) => { + error_response(StatusCode::NOT_FOUND, err.code(), err.to_string(), origin) + } + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } +} + +async fn revoke_device_response( + state: Arc, + device_id: DeviceId, + origin: Option<&str>, +) -> Response { + match state + .app + .revoke_device + .execute(RevokeDeviceInput { device_id }) + .await + { + Ok(()) => json_response(StatusCode::OK, &json!({ "revoked": true }), origin), + Err(err @ application::AppError::NotFound(_)) => { + error_response(StatusCode::NOT_FOUND, err.code(), err.to_string(), origin) + } + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } +} + +async fn revoke_all_devices_response( + state: Arc, + origin: Option<&str>, +) -> Response { + match state.app.revoke_all_devices.execute().await { + Ok(()) => json_response(StatusCode::OK, &json!({ "revoked": true }), origin), + Err(err) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.code(), + err.to_string(), + origin, + ), + } } async fn pair( @@ -1609,34 +2129,102 @@ async fn pair( } }; - if request.code != state.pairing_code() { + let limit_key = RateLimitKey { + origin: origin.unwrap_or("").to_owned(), + route: "/api/pair".to_owned(), + }; + match state + .app + .pair_attempt_limiter + .check(limit_key.clone()) + .await + { + Ok(PairAttemptDecision::Allowed) => {} + Ok(PairAttemptDecision::RateLimited) => { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "rate_limited", + }); + return error_response( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + "too many pairing attempts", + origin, + ); + } + Err(_) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL", + "pairing limiter failed", + origin, + ); + } + } + + let device_name = match DeviceName::new(request.name) { + Ok(name) => name, + Err(err) => { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "invalid_name", + }); + return error_response( + StatusCode::BAD_REQUEST, + "invalid_name", + err.to_string(), + origin, + ); + } + }; + + if state.consume_pairing_code(&request.code) != PairingCodeConsumeResult::Valid { + let _ = state + .app + .pair_attempt_limiter + .record_failure(limit_key) + .await; state.log_security(SecurityLogEvent::PairingFailed { origin: origin.map(str::to_owned), - reason: "wrong_code", + reason: "invalid_or_expired", }); return error_response( StatusCode::FORBIDDEN, - "FORBIDDEN", + "invalid_or_expired", "invalid pairing code", origin, ); } - let token = state.create_session(); + let token = new_session_token(); + let token_hash = SessionTokenHash::from_token_bytes(&token.bytes); + let pair_result = state + .app + .pair_device + .execute(PairDeviceInput { + name: device_name.as_str().to_owned(), + session_token_hash: token_hash, + }) + .await; + + if pair_result.is_err() { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "pair_device_failed", + }); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL", + "failed to pair device", + origin, + ); + } + state.log_security(SecurityLogEvent::PairingSucceeded { origin: origin.map(str::to_owned), }); - let cookie = Cookie::build((SESSION_COOKIE, token)) - .path("/") - .http_only(true) - .secure(state.config.secure_cookie()) - .same_site(SameSite::Strict) - .build(); let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), - ); + refresh_session_cookie(&mut response, &state, &token.value); response } @@ -1655,6 +2243,28 @@ fn logout_response(state: &ServerState, origin: Option<&str>) -> Response, state: &ServerState, token: &str) { + let cookie = Cookie::build((SESSION_COOKIE, token.to_owned())) + .path("/") + .http_only(true) + .secure(state.config.secure_cookie()) + .same_site(SameSite::Strict) + .max_age(cookie::time::Duration::seconds( + SESSION_COOKIE_MAX_AGE_SECONDS, + )) + .build(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), + ); +} + +#[derive(Debug, Clone)] +struct SessionToken { + value: String, + bytes: [u8; 32], +} + #[derive(Deserialize)] struct InvokeRequest { command: String, @@ -2218,17 +2828,43 @@ fn default_app_data_dir() -> PathBuf { } fn new_pairing_code() -> String { - Uuid::new_v4() - .simple() - .to_string() - .chars() - .take(8) - .collect::() - .to_ascii_uppercase() + let mut bytes = [0_u8; 4]; + getrandom::fill(&mut bytes).expect("OS CSPRNG must be available for pairing codes"); + hex::encode_upper(bytes) } -fn new_session_token() -> String { - format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PairingCodeConsumeResult { + Valid, + InvalidOrExpired, +} + +fn pairing_code_hash(code: &str) -> [u8; 32] { + let normalized = application::normalize_pairing_code(code); + let mut hasher = Sha256::new(); + hasher.update(PAIRING_CODE_HASH_CONTEXT); + hasher.update(normalized.as_bytes()); + hasher.finalize().into() +} + +fn current_time_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn new_session_token() -> SessionToken { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).expect("OS CSPRNG must be available for session tokens"); + SessionToken { + value: URL_SAFE_NO_PAD.encode(bytes), + bytes, + } +} + +fn decode_session_token(token: &str) -> Result, base64::DecodeError> { + URL_SAFE_NO_PAD.decode(token.as_bytes()) } fn invalid_args_error(err: serde_json::Error) -> ErrorDto { @@ -2773,6 +3409,7 @@ mod tests { trusted_proxies: Vec::new(), app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), web_root: create_web_root(), + new_code: false, } } @@ -2800,6 +3437,19 @@ mod tests { assert_eq!(default_app_data_dir(), override_dir); } + #[test] + fn serve_args_parse_new_code_without_persisting_config_side_effects() { + let web_root = create_web_root(); + let config = ServerConfig::from_args(vec![ + "--web-root".to_owned(), + web_root.to_string_lossy().into_owned(), + "--new-code".to_owned(), + ]) + .unwrap(); + + assert!(config.new_code); + } + fn state() -> Arc { Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) } @@ -2841,7 +3491,9 @@ mod tests { } handle_request_from_peer( builder - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(), state, Some(peer.parse().unwrap()), @@ -2879,10 +3531,11 @@ mod tests { let handle = run_embedded_with_core(config, Arc::clone(&core)) .await .unwrap(); + let pairing = handle.generate_pairing_code(); let (pair_status, _, pair_headers) = embedded_json_request( handle.url(), "/api/pair", - json!({ "code": handle.pairing_code() }), + json!({ "code": pairing.code, "name": "Test device" }), &[], ) .await; @@ -3026,7 +3679,7 @@ mod tests { state, Method::POST, "/api/pair", - json!({ "code": "PAIR1234" }), + json!({ "code": "PAIR1234", "name": "Test device" }), &[], ) .await; @@ -3042,6 +3695,38 @@ mod tests { .to_owned() } + async fn pair_and_cookie_with(state: Arc, code: &str, name: &str) -> String { + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": code, "name": name }), + &[], + ) + .await; + response + .headers() + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() + } + + async fn authenticated_device_id(state: &ServerState, cookie: &str) -> DeviceId { + let token = cookie + .strip_prefix("idea_session=") + .expect("test cookie contains session token"); + state + .authenticate_session(token) + .await + .expect("test cookie authenticates") + .device_id + } + async fn create_project_for_test(state: &Arc, name: &str) -> String { let root = std::env::temp_dir() .join(format!("idea-server-project-{}", Uuid::new_v4())) @@ -3303,59 +3988,62 @@ mod tests { ); } - #[test] - fn websocket_upgrade_requires_valid_cookie() { + #[tokio::test] + async fn websocket_upgrade_requires_valid_cookie() { let state = state(); let headers = ws_headers("http://127.0.0.1:17373", None); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - #[test] - fn websocket_upgrade_rejects_invalid_cookie() { + #[tokio::test] + async fn websocket_upgrade_rejects_invalid_cookie() { let state = state(); let headers = ws_headers( "http://127.0.0.1:17373", Some("idea_session=not-a-valid-session"), ); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - #[test] - fn websocket_upgrade_requires_allowed_origin() { + #[tokio::test] + async fn websocket_upgrade_requires_allowed_origin() { let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "https://evil.example", - Some(&format!("idea_session={token}")), - ); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let headers = ws_headers("https://evil.example", Some(&cookie)); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } - #[test] - fn websocket_upgrade_accepts_valid_cookie_and_origin() { + #[tokio::test] + async fn websocket_upgrade_accepts_valid_cookie_and_origin() { let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "http://127.0.0.1:17373", - Some(&format!("idea_session={token}")), - ); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); - let accept = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap(); + let (accept, device) = + validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap(); assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + assert_eq!( + device.device_id, + authenticated_device_id(&state, &cookie).await + ); } #[test] @@ -4304,6 +4992,8 @@ mod tests { async fn logout_revokes_session_for_http_and_websocket() { let state = state(); let cookie = pair_and_cookie(Arc::clone(&state)).await; + let device_id = authenticated_device_id(&state, &cookie).await; + let registration = state.active_connections.register(device_id); let response = request( Arc::clone(&state), @@ -4337,9 +5027,158 @@ mod tests { assert_eq!(body["code"], "UNAUTHORIZED"); let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); - let response = - validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).unwrap_err(); + let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + tokio::time::timeout(Duration::from_secs(2), registration.shutdown) + .await + .expect("logout revocation closes active connection") + .expect("shutdown sender is delivered"); + } + + #[tokio::test] + async fn devices_list_rename_and_revoke_expose_safe_contract() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::GET, + "/api/devices", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::OK); + let devices = body["devices"].as_array().unwrap(); + assert_eq!(devices.len(), 1); + let row = devices[0].as_object().unwrap(); + assert_eq!(row["name"], "Test device"); + assert_eq!(row["isCurrentDevice"], true); + assert!(row.contains_key("deviceId")); + assert!(row["pairedAtMs"].is_number()); + assert!(row["lastSeenAtMs"].is_number()); + assert!(!row.contains_key("sessionTokenHash")); + assert!(!row.contains_key("ip")); + assert!(!row.contains_key("userAgent")); + let device_id = row["deviceId"].as_str().unwrap().to_owned(); + + let response = request( + Arc::clone(&state), + Method::POST, + &format!("/api/devices/{device_id}/rename"), + json!({ "name": "Renamed phone" }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "renamed": true })); + + let response = request( + Arc::clone(&state), + Method::GET, + "/api/devices", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (_, body, _) = response_json(response).await; + assert_eq!(body["devices"][0]["name"], "Renamed phone"); + + let response = request( + Arc::clone(&state), + Method::POST, + &format!("/api/devices/{device_id}/revoke"), + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + + let response = request( + Arc::clone(&state), + Method::GET, + "/api/devices", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + + #[tokio::test] + async fn device_revocation_closes_only_matching_active_connections() { + let state = state(); + let cookie_a = pair_and_cookie_with(Arc::clone(&state), "PAIR1234", "Phone A").await; + state.set_pairing_code_for_test("PAIR5678".to_owned()); + let cookie_b = pair_and_cookie_with(Arc::clone(&state), "PAIR5678", "Phone B").await; + let device_a = authenticated_device_id(&state, &cookie_a).await; + let device_b = authenticated_device_id(&state, &cookie_b).await; + let registration_a = state.active_connections.register(device_a); + let mut registration_b = state.active_connections.register(device_b); + + let response = request( + Arc::clone(&state), + Method::POST, + &format!("/api/devices/{device_a}/revoke"), + json!({}), + &[("cookie", &cookie_b)], + ) + .await; + let (status, _, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + tokio::time::timeout(Duration::from_secs(2), registration_a.shutdown) + .await + .expect("revoked device connection closes") + .expect("shutdown sender is delivered"); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut registration_b.shutdown) + .await + .is_err() + ); + assert_eq!(state.active_connections.active_count(), 1); + } + + #[tokio::test] + async fn revoke_all_closes_all_active_connections() { + let state = state(); + let cookie_a = pair_and_cookie_with(Arc::clone(&state), "PAIR1234", "Phone A").await; + state.set_pairing_code_for_test("PAIR5678".to_owned()); + let cookie_b = pair_and_cookie_with(Arc::clone(&state), "PAIR5678", "Phone B").await; + let device_a = authenticated_device_id(&state, &cookie_a).await; + let device_b = authenticated_device_id(&state, &cookie_b).await; + let registration_a = state.active_connections.register(device_a); + let registration_b = state.active_connections.register(device_b); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/devices/revoke-all", + json!({}), + &[("cookie", &cookie_a)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + tokio::time::timeout(Duration::from_secs(2), registration_a.shutdown) + .await + .expect("first connection closes") + .expect("shutdown sender is delivered"); + tokio::time::timeout(Duration::from_secs(2), registration_b.shutdown) + .await + .expect("second connection closes") + .expect("shutdown sender is delivered"); + assert_eq!(state.active_connections.active_count(), 0); } #[tokio::test] @@ -4357,7 +5196,9 @@ mod tests { .uri("/api/pair") .header(ORIGIN, "https://evil.example") .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(), Arc::clone(&state), ) @@ -4368,7 +5209,7 @@ mod tests { Arc::clone(&state), Method::POST, "/api/pair", - json!({ "code": "WRONG999" }), + json!({ "code": "WRONG999", "name": "Test device" }), &[], ) .await; @@ -4388,7 +5229,7 @@ mod tests { "http://127.0.0.1:17373", Some("idea_session=invalid-token-value"), ); - let _ = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state); + let _ = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).await; let events = logger.events(); assert!(events.iter().any(|event| matches!( @@ -4398,7 +5239,7 @@ mod tests { assert!(events.iter().any(|event| matches!( event, SecurityLogEvent::PairingFailed { - reason: "wrong_code", + reason: "invalid_or_expired", .. } ))); @@ -4433,20 +5274,213 @@ mod tests { state, Method::POST, "/api/pair", - json!({ "code": "WRONG999" }), + json!({ "code": "WRONG999", "name": "Test device" }), &[], ) .await; let (status, body, headers) = response_json(response).await; assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body["code"], "FORBIDDEN"); + assert_eq!(body["code"], "invalid_or_expired"); assert!( !headers.contains_key(SET_COOKIE), "wrong pairing code must not issue a session cookie" ); } + #[tokio::test] + async fn pairing_rejects_when_no_code_exists_at_boot() { + let state = Arc::new(ServerState::new(test_config())); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Test device" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invalid_or_expired"); + assert!(!headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn pairing_code_expires_and_returns_public_invalid_or_expired() { + let state = state(); + state.expire_pairing_code_for_test(); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Test device" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invalid_or_expired"); + assert!(!headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn pairing_code_is_single_use() { + let state = state(); + let first = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "First device" }), + &[], + ) + .await; + let (first_status, _, first_headers) = response_json(first).await; + + let second = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Second device" }), + &[], + ) + .await; + let (second_status, second_body, second_headers) = response_json(second).await; + + assert_eq!(first_status, StatusCode::OK); + assert!(first_headers.contains_key(SET_COOKIE)); + assert_eq!(second_status, StatusCode::FORBIDDEN); + assert_eq!(second_body["code"], "invalid_or_expired"); + assert!(!second_headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn generating_new_pairing_code_invalidates_previous_code() { + let state = Arc::new(ServerState::new(test_config())); + let old = state.generate_pairing_code(); + let new = state.generate_pairing_code(); + + let old_response = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": old.code, "name": "Old device" }), + &[], + ) + .await; + let (old_status, old_body, _) = response_json(old_response).await; + + let new_response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": new.code, "name": "New device" }), + &[], + ) + .await; + let (new_status, _, new_headers) = response_json(new_response).await; + + assert_eq!(old_status, StatusCode::FORBIDDEN); + assert_eq!(old_body["code"], "invalid_or_expired"); + assert_eq!(new_status, StatusCode::OK); + assert!(new_headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn pairing_code_endpoint_requires_auth_and_returns_ttl() { + let state = state(); + let unauthenticated = request( + Arc::clone(&state), + Method::POST, + "/api/pairing-code", + json!({}), + &[], + ) + .await; + let (unauth_status, _, _) = response_json(unauthenticated).await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/pairing-code", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(unauth_status, StatusCode::UNAUTHORIZED); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["ttlSeconds"], 600); + assert_eq!(body["code"].as_str().unwrap().len(), 8); + assert!(headers.contains_key(SET_COOKIE)); + } + + #[tokio::test] + async fn invalid_or_expired_response_does_not_leak_cause() { + let absent = Arc::new(ServerState::new(test_config())); + let expired = state(); + expired.expire_pairing_code_for_test(); + let used = state(); + let _ = pair_and_cookie(Arc::clone(&used)).await; + + let cases = [ + (absent, json!({ "code": "PAIR1234", "name": "Absent" })), + (expired, json!({ "code": "PAIR1234", "name": "Expired" })), + (used, json!({ "code": "PAIR1234", "name": "Used" })), + (state(), json!({ "code": "WRONG999", "name": "Wrong" })), + ]; + + let mut bodies = Vec::new(); + for (state, body) in cases { + let response = request(state, Method::POST, "/api/pair", body, &[]).await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::FORBIDDEN); + bodies.push(body); + } + + assert!(bodies + .iter() + .all(|body| body["code"] == "invalid_or_expired")); + assert!(bodies.windows(2).all(|pair| pair[0] == pair[1])); + } + + #[tokio::test] + async fn pairing_rate_limits_failures_by_origin() { + let state = state(); + + for _ in 0..5 { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "WRONG999", "name": "Test device" }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invalid_or_expired"); + } + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "WRONG999", "name": "Test device" }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["code"], "rate_limited"); + } + #[tokio::test] async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { let state = state(); @@ -4455,7 +5489,7 @@ mod tests { state, Method::POST, "/api/pair", - json!({ "code": "PAIR1234" }), + json!({ "code": "PAIR1234", "name": "Test device" }), &[], ) .await; @@ -4467,12 +5501,69 @@ mod tests { assert!(cookie.contains("idea_session=")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); + assert!(cookie.contains("Max-Age=34560000")); assert!( !cookie.contains("Secure"), "loopback dev over HTTP deliberately avoids Secure so browsers store it" ); } + #[tokio::test] + async fn authenticated_invoke_renews_session_cookie_max_age() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, _, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let renewed = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); + assert!(renewed.contains(&cookie)); + assert!(renewed.contains("Max-Age=34560000")); + assert!(renewed.contains("HttpOnly")); + assert!(renewed.contains("SameSite=Strict")); + } + + #[tokio::test] + async fn pairing_rejects_missing_device_name() { + let state = state(); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_name"); + assert!(!headers.contains_key(SET_COOKIE)); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234", "name": "Recovered device" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "paired": true })); + assert!(headers.contains_key(SET_COOKIE)); + } + #[tokio::test] async fn pairing_sets_secure_cookie_for_remote_https_origin() { let config = ServerConfig { @@ -4492,7 +5583,9 @@ mod tests { .header("x-forwarded-proto", "https") .header("x-forwarded-host", "idea.example.com") .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(); *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = HeaderValue::from_static("application/json"); @@ -4506,6 +5599,7 @@ mod tests { assert!(cookie.contains("Secure")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); + assert!(cookie.contains("Max-Age=34560000")); } #[tokio::test] @@ -4640,7 +5734,9 @@ mod tests { .uri("/api/pair") .header(ORIGIN, "https://evil.example") .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .body(Full::new(Bytes::from_static( + br#"{"code":"PAIR1234","name":"Test device"}"#, + ))) .unwrap(); *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = HeaderValue::from_static("application/json"); diff --git a/frontend/src/adapters/device.ts b/frontend/src/adapters/device.ts new file mode 100644 index 0000000..3492865 --- /dev/null +++ b/frontend/src/adapters/device.ts @@ -0,0 +1,42 @@ +/** + * Tauri-backed device gateway (ticket #77, lot F2). + * + * The desktop app hosts the embedded server, so these commands reach the same + * device use cases through the composition root — **never** over HTTP to its own + * server (carnet #77, cadrage Architecture). + * + * Command names and envelopes are those B3 landed (`commands.rs`). + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { PairedDevice, PairingCode } from "@/domain"; +import type { DeviceGateway } from "@/ports"; + +/** Backend `DeviceListDto` — the list is wrapped, not returned bare. */ +interface DeviceListDto { + devices: PairedDevice[]; +} + +export class TauriDeviceGateway implements DeviceGateway { + async listDevices(): Promise { + const dto = await invoke("list_devices"); + return dto.devices; + } + + createPairingCode(): Promise { + return invoke("create_pairing_code"); + } + + renameDevice(deviceId: string, name: string): Promise { + return invoke("rename_device", { request: { deviceId, name } }); + } + + revokeDevice(deviceId: string): Promise { + return invoke("revoke_device", { request: { deviceId } }); + } + + revokeAllDevices(): Promise { + return invoke("revoke_all_devices"); + } +} diff --git a/frontend/src/adapters/http/deviceGateway.ts b/frontend/src/adapters/http/deviceGateway.ts new file mode 100644 index 0000000..1baacdc --- /dev/null +++ b/frontend/src/adapters/http/deviceGateway.ts @@ -0,0 +1,51 @@ +/** + * HTTP device gateway (ticket #77, lot F2) — the web sibling of + * `TauriDeviceGateway`, behind the unchanged {@link DeviceGateway} port. + * + * Routes live outside the generic `/api/invoke` RPC, next to the pre-existing + * `/api/pair` and `/api/logout`: the RPC endpoint is auth-gated, so the surface + * that manages auth cannot ride on it (carnet #77). + * + * Paths are those B3 landed (`web-server/src/lib.rs`). + */ + +import type { PairedDevice, PairingCode } from "@/domain"; +import type { DeviceGateway } from "@/ports"; +import type { HttpInvoker } from "./httpInvoker"; + +/** Backend shape of `GET /api/devices`: the list is wrapped, never bare. */ +interface DeviceListResponse { + devices: PairedDevice[]; +} + +export class HttpDeviceGateway implements DeviceGateway { + constructor(private readonly http: HttpInvoker) {} + + async listDevices(): Promise { + const body = await this.http.request("GET", "/api/devices"); + return body.devices; + } + + createPairingCode(): Promise { + return this.http.request("POST", "/api/pairing-code"); + } + + renameDevice(deviceId: string, name: string): Promise { + return this.http.request( + "POST", + `/api/devices/${encodeURIComponent(deviceId)}/rename`, + { name }, + ); + } + + revokeDevice(deviceId: string): Promise { + return this.http.request( + "POST", + `/api/devices/${encodeURIComponent(deviceId)}/revoke`, + ); + } + + revokeAllDevices(): Promise { + return this.http.request("POST", "/api/devices/revoke-all"); + } +} diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts index bca9db2..11476fd 100644 --- a/frontend/src/adapters/http/httpInvoker.ts +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -117,7 +117,25 @@ export class HttpInvoker { * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the * parsed result, or rejects with a {@link GatewayError}. */ - async invoke(command: string, args: Record = {}): Promise { + invoke(command: string, args: Record = {}): Promise { + return this.request("POST", "/api/invoke", { command, args }, `command '${command}'`); + } + + /** + * Sends one authenticated REST request and maps the response exactly like + * {@link invoke} (401 routing + `ErrorDto` preservation). + * + * The device/auth surface of ticket #77 lives on dedicated routes rather than + * the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` — + * the RPC endpoint is itself auth-gated, so the surface that *manages* auth + * cannot ride on it. `what` names the call in fallback error messages. + */ + async request( + method: string, + path: string, + body?: unknown, + what = `${method} ${path}`, + ): Promise { const headers: Record = { "Content-Type": "application/json", }; @@ -125,10 +143,10 @@ export class HttpInvoker { let res: Awaited>; try { - res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, { - method: "POST", + res = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, headers, - body: JSON.stringify({ command, args }), + body: body === undefined ? undefined : JSON.stringify(body), // Same-origin so the HttpOnly session cookie is sent automatically // (ticket #13: no secret in the URL/headers from JS). credentials: "same-origin", @@ -136,7 +154,7 @@ export class HttpInvoker { } catch (networkError) { const err: GatewayError = { code: "TRANSPORT_ERROR", - message: `HTTP request for '${command}' failed: ${String(networkError)}`, + message: `HTTP request for ${what} failed: ${String(networkError)}`, }; throw err; } @@ -152,16 +170,16 @@ export class HttpInvoker { } catch { parsed = undefined; } - throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`); + throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`); } // A 204 / empty body maps to `undefined` (the void commands). - let body: unknown; + let parsed: unknown; try { - body = await res.json(); + parsed = await res.json(); } catch { - body = undefined; + parsed = undefined; } - return body as T; + return parsed as T; } } diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 7873f36..41f0b6f 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -20,6 +20,7 @@ import { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; import { getWebSession } from "./webSession"; import { setWebLiveClient } from "./webLive"; +import { HttpDeviceGateway } from "./deviceGateway"; import { HttpConversationGateway, HttpEmbedderGateway, @@ -129,6 +130,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway skill: new HttpSkillGateway(http), memory: new HttpMemoryGateway(http), embedder: new HttpEmbedderGateway(http), + device: new HttpDeviceGateway(http), permission: new HttpPermissionGateway(http), workState: new HttpWorkStateGateway(http), conversation: new HttpConversationGateway(http), diff --git a/frontend/src/adapters/http/webSession.test.ts b/frontend/src/adapters/http/webSession.test.ts index bfbc76a..749d1ef 100644 --- a/frontend/src/adapters/http/webSession.test.ts +++ b/frontend/src/adapters/http/webSession.test.ts @@ -1,7 +1,12 @@ /** - * F2 — the web session: pairing handshake (success marks the flag; wrong code + * F2 — the web session: pairing handshake (success marks the flag; a failure * rejects with a clear message), and the 401 → unauthorized transition that * clears the flag and notifies subscribers. + * + * #77 extends the handshake to `{code, name}` and freezes the two exposed + * failure codes. The "no oracle" cases below are a **security** contract, not a + * wording preference: if they start distinguishing wrong from expired from + * already-used, the API is leaking to an attacker. */ import { describe, it, expect, vi } from "vitest"; @@ -35,35 +40,106 @@ function fetchReturning(status: number, body: unknown): { } describe("WebSession pairing", () => { - it("POSTs the code to /api/pair and marks the flag on success", async () => { + it("POSTs {code, name} to /api/pair and marks the flag on success", async () => { const store = memStore(); const { fetchImpl, calls } = fetchReturning(200, { ok: true }); const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); expect(session.isPaired()).toBe(false); - await session.pair("4821-93"); + await session.pair("AB12CD34", "iPhone"); expect(session.isPaired()).toBe(true); expect(calls[0].url).toBe("https://host/api/pair"); - expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" }); + expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ + code: "AB12CD34", + name: "iPhone", + }); }); - it("rejects a wrong code with a clear message and stays unpaired", async () => { + it("maps invalid_or_expired to the single frozen message", async () => { const store = memStore(); - const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" }); + const { fetchImpl } = fetchReturning(401, { + code: "invalid_or_expired", + message: "whatever the server says", + }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); - await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" }); + await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({ + code: "invalidOrExpired", + message: "Code invalide ou expiré.", + }); expect(session.isPaired()).toBe(false); }); - it("falls back to a default message when the server sends no body", async () => { + it("maps invalid_name to its own actionable message", async () => { + // B3 validates the name *before* consuming the code, so this failure leaves + // the code alive — the message must not send the user regenerating one. + const { fetchImpl } = fetchReturning(400, { + code: "invalid_name", + message: "device name must be 40 characters or fewer", + }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "x".repeat(60))).rejects.toMatchObject({ + code: "invalidName", + message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + }); + }); + + it("does not blame the name for an unrelated 400", async () => { + // Only the wire code may point at the name; a bare bad request must not send + // the user editing a field that was fine. + const { fetchImpl } = fetchReturning(400, undefined); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ + code: "pairingFailed", + }); + }); + + it("maps rate_limited to its own message", async () => { + const { fetchImpl } = fetchReturning(429, { code: "rate_limited", message: "slow down" }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ + code: "rateLimited", + message: "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.", + }); + }); + + it("never forwards the server's own wording, invalid_name included", async () => { + const { fetchImpl } = fetchReturning(400, { + code: "invalid_name", + message: "device name is required", + }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", " ")).rejects.toMatchObject({ + message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + }); + }); + + it("never forwards a server message that could distinguish failure causes", async () => { + // Even if a future backend leaks the distinction in its message, the UI must + // not repeat it: an attacker learns nothing about *why* a code was refused. + const { fetchImpl } = fetchReturning(401, { + code: "invalid_or_expired", + message: "code already used at 14:02 by device iPhone", + }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({ + message: "Code invalide ou expiré.", + }); + }); + + it("falls back to the invalid/expired message when the server sends no body", async () => { const { fetchImpl } = fetchReturning(403, undefined); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); - await expect(session.pair("x")).rejects.toMatchObject({ - code: "INVALID_PAIRING_CODE", - message: "Code d'appairage invalide.", + await expect(session.pair("x", "iPhone")).rejects.toMatchObject({ + code: "invalidOrExpired", + message: "Code invalide ou expiré.", }); }); }); @@ -113,7 +189,7 @@ describe("WebSession default fetch receiver", () => { try { // No `fetchImpl` injected ⇒ the global fallback is used. const session = new WebSession({ baseUrl: "https://host", store: memStore() }); - await session.pair("4821-93"); + await session.pair("AB12CD34", "iPhone"); } finally { globalThis.fetch = original; } diff --git a/frontend/src/adapters/http/webSession.ts b/frontend/src/adapters/http/webSession.ts index d31dd17..cf9e387 100644 --- a/frontend/src/adapters/http/webSession.ts +++ b/frontend/src/adapters/http/webSession.ts @@ -65,6 +65,63 @@ function defaultBaseUrl(): string { : "http://localhost"; } +/** + * The failure codes `POST /api/pair` exposes (#77). + * + * `invalidOrExpired` deliberately covers wrong, expired, superseded **and + * already-used** codes: the API gives an attacker no oracle to tell them apart, + * so neither may the UI. Do not add a case here without changing that decision. + * + * `invalidName` is *not* an exception to that rule: the name is the caller's own + * input, so rejecting it reveals nothing about the code. The server validates it + * **before** consuming the code (B3), which is what makes the distinction safe + * to surface — and what lets the user retry with the same code. + */ +export type PairingErrorCode = + | "invalidOrExpired" + | "invalidName" + | "rateLimited" + | "pairingFailed"; + +/** User-facing labels, frozen by UX in the #77 carnet. */ +const PAIRING_ERROR_MESSAGE: Record = { + invalidOrExpired: "Code invalide ou expiré.", + // Says which field is wrong and, just as importantly, that the code survived: + // without that clause the reflex is to close the panel and burn a new code. + invalidName: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + rateLimited: + "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.", + pairingFailed: "Échec de l'appairage.", +}; + +/** + * Maps a `/api/pair` failure onto one of the exposed codes. + * + * The server's own `message` is **not** forwarded: it is mapped through the + * frozen labels above so a future backend wording can never reintroduce the + * wrong/expired/used distinction in the UI. The wire code wins; the HTTP status + * is the fallback for an unparseable body. + */ +function toPairingError(parsed: unknown, status: number): GatewayError { + const wire = + parsed && typeof parsed === "object" && "code" in parsed + ? String((parsed as { code: unknown }).code) + : undefined; + + let code: PairingErrorCode; + if (wire === "invalid_or_expired") code = "invalidOrExpired"; + else if (wire === "invalid_name") code = "invalidName"; + else if (wire === "rate_limited") code = "rateLimited"; + else if (status === 429) code = "rateLimited"; + // A bare 400 is not mapped to `invalidName`: only the wire code may claim the + // name is at fault, or an unrelated bad request would send the user editing a + // field that was fine. + else if (status === 401 || status === 403) code = "invalidOrExpired"; + else code = "pairingFailed"; + + return { code, message: PAIRING_ERROR_MESSAGE[code] }; +} + /** * The web session state machine. One instance is shared between the pairing UI * and the HTTP invoker (via the module singleton below). @@ -118,17 +175,20 @@ export class WebSession { } /** - * Performs the pairing handshake: `POST /api/pair {code}`. On success the - * server sets the session cookie and this records the paired flag. A wrong code - * rejects with a {@link GatewayError} carrying a clear message. + * Performs the pairing handshake: `POST /api/pair {code, name}` (#77). `name` + * is the human label for *this* device, typed on it at pairing time. On success + * the server sets the session cookie and this records the paired flag. + * + * Failures reject with a {@link GatewayError} carrying a user-facing message + * from {@link pairingErrorMessage}. */ - async pair(code: string): Promise { + async pair(code: string, name: string): Promise { let res: Awaited>; try { res = await this.fetchImpl(`${this.baseUrl}/api/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), + body: JSON.stringify({ code, name }), }); } catch (networkError) { const err: GatewayError = { @@ -145,17 +205,7 @@ export class WebSession { } catch { parsed = undefined; } - const message = - parsed && typeof parsed === "object" && "message" in parsed - ? String((parsed as GatewayError).message) - : res.status === 401 || res.status === 403 - ? "Code d'appairage invalide." - : `Échec de l'appairage (HTTP ${res.status}).`; - const err: GatewayError = { - code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED", - message, - }; - throw err; + throw toPairingError(parsed, res.status); } // Cookie is now set by the server (HttpOnly — not read here); record the flag. diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 0d5dcb2..873cb48 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -25,6 +25,7 @@ import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; import { TauriEmbedderGateway } from "./embedder"; +import { TauriDeviceGateway } from "./device"; import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; @@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways { skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), embedder: new TauriEmbedderGateway(), + device: new TauriDeviceGateway(), permission: new TauriPermissionGateway(), workState: new TauriWorkStateGateway(), conversation: new TauriConversationGateway(), @@ -90,6 +92,7 @@ export { TauriSkillGateway, TauriMemoryGateway, TauriEmbedderGateway, + TauriDeviceGateway, TauriGitGateway, TauriPermissionGateway, TauriWorkStateGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3e377cc..f275d7c 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -32,6 +32,8 @@ import type { MemoryLink, MemoryType, EffectivePermissions, + PairedDevice, + PairingCode, PermissionSet, Project, ProjectPermissions, @@ -68,6 +70,7 @@ import type { CreateMemoryInput, CreateSkillInput, DesktopServerGateway, + DeviceGateway, EmbedderGateway, CreateTemplateInput, Gateways, @@ -1471,8 +1474,6 @@ export class MockDesktopServerGateway implements DesktopServerGateway { ? undefined : this.settings.publicOrigin, upstreamUrl: preview.upstreamUrl, - // Runtime-only, regenerated per start — never persisted. - pairingCode: "MOCK-PAIR-4242", }); return structuredClone(this.current); } @@ -2040,6 +2041,66 @@ export class MockInputGateway implements InputGateway { } } +/** + * In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while + * the backend lands, and drives the `VITE_USE_MOCK` dev surface. + * + * Models the parts of the frozen contract the UI can actually observe: a device + * list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL + * where generating invalidates the previous one, and revocation. It does **not** + * model the wire (no HTTP, no cookie): that is the HTTP gateway's job. + */ +export class MockDeviceGateway implements DeviceGateway { + private devices: PairedDevice[] = []; + private issued = 0; + + /** Seeds the device list (tests/dev). */ + _setDevices(devices: PairedDevice[]): void { + this.devices = structuredClone(devices); + } + + /** Every code handed out so far, newest last — the previous ones are dead. */ + _issuedCodes: string[] = []; + + async listDevices(): Promise { + return structuredClone(this.devices); + } + + async createPairingCode(): Promise { + // Deterministic 8-char uppercase hex, same shape as the server's. + const code = (0x10000000 + this.issued++) + .toString(16) + .toUpperCase() + .slice(0, 8); + this._issuedCodes.push(code); + const ttlSeconds = 600; + return { + code, + // Epoch ms, exactly as the server sends it — a mock that invents a + // friendlier encoding is how the ISO/epoch mismatch stayed green (#77). + expiresAtMs: Date.now() + ttlSeconds * 1000, + ttlSeconds, + }; + } + + async renameDevice(deviceId: string, name: string): Promise { + const device = this.devices.find((d) => d.deviceId === deviceId); + if (!device) { + const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` }; + throw err; + } + device.name = name; + } + + async revokeDevice(deviceId: string): Promise { + this.devices = this.devices.filter((d) => d.deviceId !== deviceId); + } + + async revokeAllDevices(): Promise { + this.devices = []; + } +} + /** In-memory permissions gateway. */ export class MockPermissionGateway implements PermissionGateway { private docs = new Map(); @@ -2782,6 +2843,7 @@ export function createMockGateways(): Gateways { skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), embedder: new MockEmbedderGateway(), + device: new MockDeviceGateway(), permission: new MockPermissionGateway(), workState: new MockWorkStateGateway(), conversation: new MockConversationGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 693f254..e56ca7a 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -17,6 +17,7 @@ describe("createMockGateways", () => { "agent", "conversation", "desktopServer", + "device", "embedder", "focusedProject", "git", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index aeb14a9..e30aa82 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -190,9 +190,11 @@ export type EmbeddedServerState = | "failed"; /** - * Embedded-server status (mirror of `EmbeddedServerStatusDto`). `pairingCode` - * is a runtime-only secret: it exists while the server runs, is never - * persisted, and must never be written into a settings field. + * Embedded-server status (mirror of `EmbeddedServerStatusDto`). + * + * Carries **no pairing code** (#77): a code no longer exists while the server + * runs, only when someone asks for one. Generating is a device-management action + * ({@link PairingCode}), not a property of the server's status. */ export interface EmbeddedServerStatus { state: EmbeddedServerState; @@ -202,8 +204,6 @@ export interface EmbeddedServerStatus { publicUrl?: string; /** Upstream URL to hand to the reverse proxy. */ upstreamUrl?: string; - /** Runtime pairing code — present only while running. */ - pairingCode?: string; /** Last failure, when `state` is `failed`. */ error?: GatewayError; } @@ -1321,3 +1321,52 @@ export type ReplyChunk = | { kind: "toolActivity"; label: string } | { kind: "final"; content: string } | { kind: "error"; message: string }; + +// --------------------------------------------------------------------------- +// Paired devices + pairing code (ticket #77) +// --------------------------------------------------------------------------- + +/** + * One device paired with this IdeA instance (mirror of the backend device DTO). + * + * Deliberately carries **no IP and no User-Agent**: the design is mono-user and + * the list is an access-management surface, not a forensics log. `name` is the + * human label typed on the device itself at pairing time. + */ +export interface PairedDevice { + deviceId: string; + name: string; + /** Epoch milliseconds the device was paired. */ + pairedAtMs: number; + /** Epoch milliseconds of the device's last authenticated request. */ + lastSeenAtMs: number; + /** True for the device rendering this list (never true on desktop). */ + isCurrentDevice: boolean; +} + +/** + * A freshly generated, single-use pairing code (mirror of the backend DTO). + * + * `code` is the **canonical** value — uppercase hex, no separator. Any grouping + * shown to the user is presentation only; see {@link normalizePairingCode}. + */ +export interface PairingCode { + code: string; + /** Epoch milliseconds the code stops being accepted. */ + expiresAtMs: number; + /** Lifetime granted at generation (600 s today). */ + ttlSeconds: number; +} + +/** + * Canonical form of a pairing code as the server compares it: uppercase, with + * spaces **and dashes** removed. + * + * Both separators matter. The UI groups the code visually (`AB12 CD34`) and a + * user may retype it with a dash out of habit, so a code copied by eye must + * still pair. The server normalises too (#76) — this keeps the client honest + * rather than being the only line of defence. + */ +export function normalizePairingCode(raw: string): string { + return raw.replace(/[\s-]+/g, "").toUpperCase(); +} diff --git a/frontend/src/features/devices/ConfirmDialog.tsx b/frontend/src/features/devices/ConfirmDialog.tsx new file mode 100644 index 0000000..1c2f14d --- /dev/null +++ b/frontend/src/features/devices/ConfirmDialog.tsx @@ -0,0 +1,80 @@ +/** + * A small modal confirmation, local to the devices feature (ticket #77, lot F2). + * + * Not `FloatingWindow`: that is a draggable desktop window, and this surface is + * mobile-first. Not a shared primitive either — adding a dialog to the kit is a + * design-system decision for UX/Architect, not a side effect of this ticket. + */ + +import { useEffect, useRef } from "react"; + +import { Button, zIndex } from "@/shared"; + +interface ConfirmDialogProps { + title: string; + body: string; + confirmLabel: string; + /** Paints the confirm action as destructive (revoke-all). */ + danger?: boolean; + busy?: boolean; + onConfirm: () => void | Promise; + onCancel: () => void; +} + +export function ConfirmDialog({ + title, + body, + confirmLabel, + danger = false, + busy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const cancelRef = useRef(null); + + // Mount-only: a confirmation opens focused on the safe choice. Depending on + // `onCancel` here would re-steal focus whenever the parent re-renders (#17). + useEffect(() => { + cancelRef.current?.focus(); + }, []); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl" + > +

{title}

+

{body}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/devices/DevicesScreen.test.tsx b/frontend/src/features/devices/DevicesScreen.test.tsx new file mode 100644 index 0000000..c169f9f --- /dev/null +++ b/frontend/src/features/devices/DevicesScreen.test.tsx @@ -0,0 +1,291 @@ +/** + * #77 lot F2 — the shared devices surface, driven through the mock gateway + * (B1/B2/B3 are not landed; the mock models the frozen DTO contract). + * + * The load-bearing assertions: + * - the copied code carries **no separator**, whatever the display shows; + * - no IP / User-Agent ever reaches the DOM; + * - revoking the current device ends the session instead of refreshing a list + * we are no longer allowed to read. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import type { PairedDevice } from "@/domain"; +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockDeviceGateway } from "@/adapters/mock"; +import { DevicesScreen } from "./DevicesScreen"; + +/** + * Epoch-ms fixtures — the encoding the backend actually sends (`*AtMs: number`). + * These were ISO strings, which let the suite pass while the real screen + * rendered "—" on every row. + */ +const DEVICES: PairedDevice[] = [ + { + deviceId: "d1", + name: "iPhone", + pairedAtMs: new Date(2026, 6, 12, 10, 0).getTime(), + lastSeenAtMs: Date.now(), + isCurrentDevice: true, + }, + { + deviceId: "d2", + name: "Chrome sur Windows", + pairedAtMs: new Date(2026, 5, 1, 10, 0).getTime(), + lastSeenAtMs: new Date(2026, 6, 12, 14, 32).getTime(), + isCurrentDevice: false, + }, +]; + +function setup(devices: PairedDevice[] = DEVICES) { + const gateways: Gateways = createMockGateways(); + const device = gateways.device as MockDeviceGateway; + device._setDevices(devices); + const onSessionEnded = vi.fn(); + render( + + + , + ); + return { device, onSessionEnded }; +} + +const writeText = vi.fn(async (_text: string) => {}); + +beforeEach(() => { + writeText.mockClear(); + Object.defineProperty(window.navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +}); + +const rowFor = async (name: string) => + (await screen.findByText(name)).closest("li") as HTMLElement; + +describe("DevicesScreen list", () => { + it("lists devices with their name, activity and pairing date", async () => { + setup(); + + const row = await rowFor("Chrome sur Windows"); + expect(within(row).getByText("Appairé le 1 juin")).toBeTruthy(); + expect(screen.getAllByTestId("device-row")).toHaveLength(2); + }); + + it("badges the current device", async () => { + setup(); + + const current = await rowFor("iPhone"); + expect(within(current).getByText("Cet appareil")).toBeTruthy(); + const other = await rowFor("Chrome sur Windows"); + expect(within(other).queryByText("Cet appareil")).toBeNull(); + }); + + it("never renders an IP or a User-Agent", async () => { + setup(); + await screen.findByTestId("device-list"); + + const text = screen.getByTestId("devices-screen").textContent ?? ""; + expect(text).not.toMatch(/Mozilla|AppleWebKit|\d+\.\d+\.\d+\.\d+/); + }); + + it("says so plainly when nothing is paired", async () => { + setup([]); + expect(await screen.findByText("Aucun appareil appairé.")).toBeTruthy(); + }); +}); + +describe("DevicesScreen pairing code", () => { + it("copies the canonical code, with no separator in the clipboard value", async () => { + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + const panel = await screen.findByTestId("pairing-code-panel"); + fireEvent.click(within(panel).getByRole("button", { name: "Copier" })); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + const copied = writeText.mock.calls[0][0]; + // The whole point of the arbitration: a dash here would be retyped by the + // user and refused by the server (#75). + expect(copied).toMatch(/^[0-9A-F]{8}$/); + expect(copied).not.toContain("-"); + expect(copied).not.toContain(" "); + }); + + it("groups the code visually while keeping the value intact for readers", async () => { + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + const value = await screen.findByTestId("pairing-code-value"); + + // Two blocks of 4 on screen… + const blocks = value.querySelectorAll("span"); + expect(blocks).toHaveLength(2); + expect(blocks[0].textContent).toHaveLength(4); + expect(blocks[1].textContent).toHaveLength(4); + // …but a screen reader hears the code it must type, unseparated. + expect(value.getAttribute("aria-label")).toMatch(/^[0-9A-F]{8}$/); + }); + + it("shows the instruction and a countdown", async () => { + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + + expect(await screen.findByText("Saisissez ce code sur le nouvel appareil.")).toBeTruthy(); + expect((await screen.findByTestId("pairing-code-countdown")).textContent).toBe( + "Expire dans 10 min", + ); + }); + + it("does not declare a freshly generated code expired", async () => { + // The visible symptom of the ISO/epoch mismatch (#77): `new Date()` + // was Invalid Date ⇒ the countdown read null ⇒ every brand-new code showed + // "Ce code a expiré." the instant it appeared. Guard the whole failure, not + // just the formatter. + setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + await screen.findByTestId("pairing-code-panel"); + + expect(screen.queryByText("Ce code a expiré.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Générer un nouveau code" })).toBeNull(); + expect(screen.getByTestId("pairing-code-countdown").textContent).toMatch( + /^Expire dans \d+ (min|s)$/, + ); + }); + + it("replaces the code rather than stacking panels when regenerating", async () => { + const { device } = setup(); + + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + await screen.findByTestId("pairing-code-panel"); + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + + await waitFor(() => expect(device._issuedCodes).toHaveLength(2)); + expect(screen.getAllByTestId("pairing-code-panel")).toHaveLength(1); + // Generating invalidates the previous code server-side; the UI shows only + // the live one so nobody reads a dead code aloud. + const shown = (await screen.findByTestId("pairing-code-value")).getAttribute("aria-label"); + expect(shown).toBe(device._issuedCodes[1]); + }); + + it("announces expiry without alarmism and offers a new code", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + setup(); + fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + await screen.findByTestId("pairing-code-panel"); + + await vi.advanceTimersByTimeAsync(601_000); + + expect(await screen.findByText("Ce code a expiré.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Générer un nouveau code" })).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("DevicesScreen rename", () => { + it("renames through the ⋯ menu", async () => { + const { device } = setup(); + + const row = await rowFor("Chrome sur Windows"); + fireEvent.click(within(row).getByRole("button", { name: "Actions pour Chrome sur Windows" })); + fireEvent.click(within(row).getByRole("menuitem", { name: "Renommer" })); + + fireEvent.change(screen.getByDisplayValue("Chrome sur Windows"), { + target: { value: "PC du bureau" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Renommer" })); + + expect(await screen.findByText("PC du bureau")).toBeTruthy(); + expect((await device.listDevices()).find((d) => d.deviceId === "d2")?.name).toBe( + "PC du bureau", + ); + }); +}); + +describe("DevicesScreen revocation", () => { + async function openRevoke(name: string) { + const row = await rowFor(name); + fireEvent.click(within(row).getByRole("button", { name: `Actions pour ${name}` })); + fireEvent.click(within(row).getByRole("menuitem", { name: "Révoquer" })); + return screen.findByRole("dialog"); + } + + it("confirms before revoking another device", async () => { + const { device, onSessionEnded } = setup(); + + const dialog = await openRevoke("Chrome sur Windows"); + expect(within(dialog).getByText("Révoquer cet appareil ?")).toBeTruthy(); + expect( + within(dialog).getByText("Il devra être appairé à nouveau pour accéder à IdeA."), + ).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" })); + + await waitFor(() => expect(screen.queryByText("Chrome sur Windows")).toBeNull()); + expect(await device.listDevices()).toHaveLength(1); + // Revoking someone else must not touch our own session. + expect(onSessionEnded).not.toHaveBeenCalled(); + }); + + it("cancelling leaves the device alone", async () => { + const { device } = setup(); + + const dialog = await openRevoke("Chrome sur Windows"); + fireEvent.click(within(dialog).getByRole("button", { name: "Annuler" })); + + expect(screen.getByText("Chrome sur Windows")).toBeTruthy(); + expect(await device.listDevices()).toHaveLength(2); + }); + + it("warns about immediate sign-out when revoking the current device", async () => { + setup(); + + const dialog = await openRevoke("iPhone"); + expect(within(dialog).getByText(/Vous serez déconnecté immédiatement\./)).toBeTruthy(); + }); + + it("ends the session when the current device revokes itself (nominal case)", async () => { + const { onSessionEnded } = setup(); + + const dialog = await openRevoke("iPhone"); + fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" })); + + await waitFor(() => expect(onSessionEnded).toHaveBeenCalled()); + }); + + it("revokes everything, current device included, behind a strong confirmation", async () => { + const { device, onSessionEnded } = setup(); + await screen.findByTestId("device-list"); + + fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Révoquer tous les appareils ?")).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Tout révoquer" })); + + await waitFor(() => expect(onSessionEnded).toHaveBeenCalled()); + expect(await device.listDevices()).toHaveLength(0); + }); + + it("does not end the session when revoke-all excludes the current device", async () => { + // Desktop: no device is ever `isCurrentDevice`, so wiping the list must not + // pretend the app just logged itself out. + const { onSessionEnded } = setup(DEVICES.map((d) => ({ ...d, isCurrentDevice: false }))); + await screen.findByTestId("device-list"); + + fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" })); + fireEvent.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Tout révoquer" }), + ); + + await waitFor(() => expect(screen.getByText("Aucun appareil appairé.")).toBeTruthy()); + expect(onSessionEnded).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/devices/DevicesScreen.tsx b/frontend/src/features/devices/DevicesScreen.tsx new file mode 100644 index 0000000..967b56b --- /dev/null +++ b/frontend/src/features/devices/DevicesScreen.tsx @@ -0,0 +1,317 @@ +/** + * `DevicesScreen` — the paired-device surface (ticket #77, lot F2). + * + * Mounted **identically** in the web UI and in the desktop app + * (`Paramètres → Appareils`): same component, same vocabulary, same actions. It + * is mobile-first by necessity, not by taste — a headless install has only the + * web UI to generate a code from, and that is often reached from a phone. + * + * It shows a name, a badge, an activity phrase and a pairing date. It never + * shows an IP or a User-Agent: this is an access-management surface for a + * single-user instance, not an audit log. + * + * Transport-neutral (gateways via DI). The session consequence of revoking the + * current device is *not* decided here: {@link onSessionEnded} hands it to the + * mounting surface, which is the only layer that knows what "logged out" means. + */ + +import { useEffect, useState } from "react"; + +import type { PairedDevice } from "@/domain"; +import { Button, Field, Input, Panel, Spinner, cn } from "@/shared"; +import { useDevices } from "./useDevices"; +import { formatLastSeen, formatPairedAt } from "./formatActivity"; +import { PairingCodePanel } from "./PairingCodePanel"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { DEVICE_NAME_MAX } from "@/features/web/deviceName"; + +interface DevicesScreenProps { + /** + * Called when this device's own session has just been revoked (directly or by + * "revoke all"). Web routes back to pairing; desktop leaves it unset — the + * desktop app hosts the server and is never itself a paired device. + */ + onSessionEnded?: () => void; +} + +export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) { + const vm = useDevices(); + const [confirmRevoke, setConfirmRevoke] = useState(null); + const [confirmRevokeAll, setConfirmRevokeAll] = useState(false); + const [renaming, setRenaming] = useState(null); + + useEffect(() => { + if (vm.sessionEnded) onSessionEnded?.(); + }, [vm.sessionEnded, onSessionEnded]); + + const devices = vm.devices; + + return ( +
+
+
+

Appareils

+

+ Les appareils autorisés à accéder à cette instance IdeA. +

+
+ +
+ + {vm.code && ( + void vm.generateCode()} + onDismiss={vm.dismissCode} + /> + )} + + {vm.error && ( + +

+ {vm.error} +

+
+ )} + + {devices === null ? ( + + Chargement des appareils… + + ) : devices.length === 0 ? ( +

Aucun appareil appairé.

+ ) : ( +
    + {devices.map((device) => ( + setRenaming(device.deviceId)} + onCancelRename={() => setRenaming(null)} + onRename={async (name) => { + await vm.rename(device.deviceId, name); + setRenaming(null); + }} + onRevoke={() => setConfirmRevoke(device)} + /> + ))} +
+ )} + + {devices !== null && devices.length > 0 && ( +
+ +
+ )} + + {confirmRevoke && ( + { + const target = confirmRevoke; + setConfirmRevoke(null); + await vm.revoke(target); + }} + onCancel={() => setConfirmRevoke(null)} + /> + )} + + {confirmRevokeAll && ( + { + setConfirmRevokeAll(false); + await vm.revokeAll(); + }} + onCancel={() => setConfirmRevokeAll(false)} + /> + )} +
+ ); +} + +/** One device: identity, activity, and the `⋯` actions. */ +function DeviceRow({ + device, + fresh, + renaming, + onStartRename, + onCancelRename, + onRename, + onRevoke, +}: { + device: PairedDevice; + fresh: boolean; + renaming: boolean; + onStartRename: () => void; + onCancelRename: () => void; + onRename: (name: string) => Promise; + onRevoke: () => void; +}) { + return ( +
  • + {renaming ? ( + + ) : ( +
    +
    + + {device.name} + {device.isCurrentDevice && ( + + Cet appareil + + )} + + {formatLastSeen(device.lastSeenAtMs)} + {formatPairedAt(device.pairedAtMs)} +
    + +
    + )} +
  • + ); +} + +/** Inline rename (1-40 characters), submitted with Enter or the button. */ +function RenameForm({ + device, + onSubmit, + onCancel, +}: { + device: PairedDevice; + onSubmit: (name: string) => Promise; + onCancel: () => void; +}) { + const [name, setName] = useState(device.name); + const trimmed = name.trim(); + + return ( +
    { + e.preventDefault(); + if (trimmed.length > 0) void onSubmit(trimmed); + }} + > + + {({ id }) => ( + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") onCancel(); + }} + /> + )} + +
    + + +
    +
    + ); +} + +/** + * The per-row `⋯` menu. Local to this feature rather than a shared primitive: + * the kit has no dropdown yet, and inventing one is a design-system decision + * (UX/Architect), not a side effect of this ticket. + */ +function RowMenu({ + deviceName, + onRename, + onRevoke, +}: { + deviceName: string; + onRename: () => void; + onRevoke: () => void; +}) { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return; + const close = () => setOpen(false); + // Any click outside dismisses; capture so a click on another row's trigger + // still opens that one (its handler runs after this closes us). + window.addEventListener("click", close); + return () => window.removeEventListener("click", close); + }, [open]); + + return ( +
    e.stopPropagation()}> + + {open && ( +
    + + +
    + )} +
    + ); +} diff --git a/frontend/src/features/devices/PairingCodePanel.tsx b/frontend/src/features/devices/PairingCodePanel.tsx new file mode 100644 index 0000000..77e0674 --- /dev/null +++ b/frontend/src/features/devices/PairingCodePanel.tsx @@ -0,0 +1,118 @@ +/** + * The generated pairing code panel (ticket #77, lot F2). + * + * **Grouping is presentation only.** The code is rendered as two blocks of four + * for readability, but the blocks are separate elements with CSS spacing — there + * is no separator character anywhere in the value, and `Copier` puts the exact + * canonical code (`AB12CD34`) on the clipboard. A dash rendered here would be + * retyped by the user and would reintroduce the very failure #75 just fixed + * (arbitrage Main, carnet #77). + * + * The countdown is honest about a code that is already dead: at expiry the panel + * switches to the expired state rather than letting someone type a code the + * server will refuse. + */ + +import { useEffect, useState } from "react"; + +import type { PairingCode } from "@/domain"; +import { Button, Panel } from "@/shared"; +import { formatExpiresIn } from "./formatActivity"; + +interface PairingCodePanelProps { + code: PairingCode; + onRegenerate: () => void; + onDismiss: () => void; +} + +/** Splits the code into fixed blocks of 4 for display. Never mutates the value. */ +export function codeBlocks(code: string, size = 4): string[] { + const blocks: string[] = []; + for (let i = 0; i < code.length; i += size) blocks.push(code.slice(i, i + size)); + return blocks; +} + +/** Copies text, preferring the async clipboard API and degrading silently. */ +async function copyToClipboard(text: string): Promise { + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Denied permission / insecure context: fall through to the failure notice. + } + return false; +} + +export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodePanelProps) { + const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs)); + const [copied, setCopied] = useState(false); + + useEffect(() => { + setRemaining(formatExpiresIn(code.expiresAtMs)); + const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs)), 1000); + return () => clearInterval(timer); + }, [code.expiresAtMs]); + + useEffect(() => { + setCopied(false); + }, [code.code]); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + const expired = remaining === null; + + return ( + + {expired ? ( + <> +

    Ce code a expiré.

    +
    + +
    + + ) : ( + <> +

    Saisissez ce code sur le nouvel appareil.

    +

    + {codeBlocks(code.code).map((block, i) => ( + + ))} +

    +
    + + + {remaining} + + +
    + + )} +
    + ); +} diff --git a/frontend/src/features/devices/formatActivity.test.ts b/frontend/src/features/devices/formatActivity.test.ts new file mode 100644 index 0000000..61de426 --- /dev/null +++ b/frontend/src/features/devices/formatActivity.test.ts @@ -0,0 +1,77 @@ +/** + * #77 lot F2 — the activity phrasing. Boundaries are calendar-based, so the + * interesting cases are the ones a 24-hour-span implementation gets wrong. + * + * Fixtures are **epoch milliseconds**, the encoding the backend actually sends. + * They used to be ISO strings, which is precisely why the whole surface passed + * its tests while rendering "—" against a real server. + */ +import { describe, it, expect } from "vitest"; + +import { formatExpiresIn, formatLastSeen, formatPairedAt } from "./formatActivity"; + +/** 2026-07-17 at 09:00 local. */ +const NOW = new Date(2026, 6, 17, 9, 0, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0, s = 0) => + new Date(y, m, d, h, min, s).getTime(); + +describe("formatLastSeen", () => { + it("reads as 'now' within the last couple of minutes", () => { + expect(formatLastSeen(at(2026, 6, 17, 8, 59), NOW)).toBe("Actif à l'instant"); + }); + + it("shows today's time once it is no longer 'now'", () => { + expect(formatLastSeen(at(2026, 6, 17, 6, 32), NOW)).toBe("Aujourd'hui à 06:32"); + }); + + it("says 'Hier' for the previous calendar day, however few hours ago", () => { + // 23:59 yesterday is 9 hours back: a 24h-window implementation would call + // this "today", which reads as a lie next to the clock. + expect(formatLastSeen(at(2026, 6, 16, 23, 59), NOW)).toBe("Hier"); + expect(formatLastSeen(at(2026, 6, 16, 0, 1), NOW)).toBe("Hier"); + }); + + it("falls back to a short date beyond yesterday", () => { + expect(formatLastSeen(at(2026, 6, 12, 14, 0), NOW)).toBe("12 juil."); + }); + + it("adds the year once it is not the current one", () => { + expect(formatLastSeen(at(2025, 11, 3, 14, 0), NOW)).toBe("3 déc. 2025"); + }); + + it("treats a slightly-ahead server clock as 'now', not as the future", () => { + expect(formatLastSeen(at(2026, 6, 17, 9, 0, 30), NOW)).toBe("Actif à l'instant"); + }); + + it("reads a raw epoch-ms number, the way the backend sends it", () => { + // The regression that started this: a `1784286814274`-shaped value must + // render a real date, not the "—" an ISO-only parser produced. + const ms = new Date(2026, 6, 12, 14, 0).getTime(); + expect(Number.isInteger(ms)).toBe(true); + expect(formatLastSeen(ms, NOW)).toBe("12 juil."); + }); +}); + +describe("formatPairedAt", () => { + it("reads as a sentence, not a timestamp", () => { + expect(formatPairedAt(at(2026, 6, 12), NOW)).toBe("Appairé le 12 juil."); + }); +}); + +describe("formatExpiresIn", () => { + const inSeconds = (s: number) => NOW.getTime() + s * 1000; + + it("counts down in minutes over the code's lifetime", () => { + expect(formatExpiresIn(inSeconds(600), NOW)).toBe("Expire dans 10 min"); + expect(formatExpiresIn(inSeconds(61), NOW)).toBe("Expire dans 2 min"); + }); + + it("switches to seconds in the last minute", () => { + expect(formatExpiresIn(inSeconds(30), NOW)).toBe("Expire dans 30 s"); + }); + + it("returns null once expired, so the panel can say so", () => { + expect(formatExpiresIn(inSeconds(0), NOW)).toBeNull(); + expect(formatExpiresIn(inSeconds(-5), NOW)).toBeNull(); + }); +}); diff --git a/frontend/src/features/devices/formatActivity.ts b/frontend/src/features/devices/formatActivity.ts new file mode 100644 index 0000000..5216201 --- /dev/null +++ b/frontend/src/features/devices/formatActivity.ts @@ -0,0 +1,70 @@ +/** + * Human phrasing of device timestamps (ticket #77, lot F2). + * + * The list answers "is this thing still being used?", not "when exactly?", so + * recent activity degrades from a phrase to a time to a date as it ages. Pure + * and `now`-injectable so the boundaries are testable without faking the clock. + * + * Instants are **epoch milliseconds** (`*AtMs`, `number`) — the codebase-wide + * convention, aligned with the backend store. The format is unambiguous, so + * these functions parse nothing and tolerate no alternative encoding. + */ + +/** Below this, activity reads as "now" rather than a timestamp. */ +const JUST_NOW_MS = 2 * 60 * 1000; + +function startOfDay(d: Date): number { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +/** `14:32` in 24-hour form. */ +function timeOfDay(d: Date): string { + return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); +} + +/** `12 juil.`, with the year appended once it is no longer the current one. */ +export function shortDate(ms: number, now: Date = new Date()): string { + const d = new Date(ms); + const sameYear = d.getFullYear() === now.getFullYear(); + return d.toLocaleDateString("fr-FR", { + day: "numeric", + month: "short", + ...(sameYear ? {} : { year: "numeric" }), + }); +} + +/** + * Last-activity label: `Actif à l'instant`, `Aujourd'hui à 14:32`, `Hier`, then + * a short date. Days are compared as calendar days, not 24-hour spans — 23:59 + * yesterday reads "Hier", not "Aujourd'hui". + */ +export function formatLastSeen(ms: number, now: Date = new Date()): string { + const delta = now.getTime() - ms; + // A clock skew that puts the server slightly ahead reads as "now", not as a + // date in the future. + if (delta < JUST_NOW_MS) return "Actif à l'instant"; + + const today = startOfDay(now); + const day = startOfDay(new Date(ms)); + if (day === today) return `Aujourd'hui à ${timeOfDay(new Date(ms))}`; + if (day === today - 86_400_000) return "Hier"; + return shortDate(ms, now); +} + +/** Secondary line: `Appairé le 12 juil.` */ +export function formatPairedAt(ms: number, now: Date = new Date()): string { + return `Appairé le ${shortDate(ms, now)}`; +} + +/** + * Remaining lifetime of a pairing code: `Expire dans 10 min`, then seconds in + * the last minute so the panel stays honest as it runs out. `null` once expired + * — the caller shows the expiry state instead. + */ +export function formatExpiresIn(expiresAtMs: number, now: Date = new Date()): string | null { + const remainingMs = expiresAtMs - now.getTime(); + if (remainingMs <= 0) return null; + const seconds = Math.ceil(remainingMs / 1000); + if (seconds < 60) return `Expire dans ${seconds} s`; + return `Expire dans ${Math.ceil(seconds / 60)} min`; +} diff --git a/frontend/src/features/devices/index.ts b/frontend/src/features/devices/index.ts new file mode 100644 index 0000000..820ab28 --- /dev/null +++ b/frontend/src/features/devices/index.ts @@ -0,0 +1,14 @@ +/** + * Paired-device management (ticket #77, lot F2) — one surface, mounted in both + * the web UI and the desktop app under `Paramètres → Appareils`. + */ + +export { DevicesScreen } from "./DevicesScreen"; +export { useDevices } from "./useDevices"; +export type { UseDevices } from "./useDevices"; +export { + formatLastSeen, + formatPairedAt, + formatExpiresIn, + shortDate, +} from "./formatActivity"; diff --git a/frontend/src/features/devices/useDevices.ts b/frontend/src/features/devices/useDevices.ts new file mode 100644 index 0000000..5ff5144 --- /dev/null +++ b/frontend/src/features/devices/useDevices.ts @@ -0,0 +1,186 @@ +/** + * Device-management state (ticket #77, lot F2). + * + * Owns the list, the ephemeral pairing code and the revoke/rename actions, so + * {@link DevicesScreen} stays a rendering concern. Transport-neutral: every call + * goes through the injected {@link DeviceGateway}, which is the Tauri adapter on + * desktop and the HTTP one on web. + * + * Revoking the **current** device ends this session, so the screen must not try + * to refresh afterwards — the hook reports it through `sessionEnded` and the + * mounting surface decides what that means (web: back to pairing; desktop: never + * happens, no device is ever `isCurrentDevice`). + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { GatewayError, PairedDevice, PairingCode } from "@/domain"; +import { useGateways } from "@/app/di"; + +/** How often the list is re-read while a code is live, to catch a new pairing. */ +const PAIRING_POLL_MS = 3000; +/** How long a freshly-paired row stays highlighted. */ +const HIGHLIGHT_MS = 2500; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +export interface UseDevices { + devices: PairedDevice[] | null; + error: string | null; + /** An action is in flight; drives the confirm dialogs' pending state. */ + busy: boolean; + /** Narrower than `busy`: only a code generation, so `Appairer` alone spins. */ + generating: boolean; + /** The live code, or `null` when no code has been generated (or it was dismissed). */ + code: PairingCode | null; + /** Device ids to highlight — the ones that appeared while a code was live. */ + freshDeviceIds: string[]; + /** Set once the current device's own session has been revoked. */ + sessionEnded: boolean; + refresh(): Promise; + generateCode(): Promise; + dismissCode(): void; + rename(deviceId: string, name: string): Promise; + revoke(device: PairedDevice): Promise; + revokeAll(): Promise; +} + +export function useDevices(): UseDevices { + const { device: gateway } = useGateways(); + const [devices, setDevices] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [generating, setGenerating] = useState(false); + const [code, setCode] = useState(null); + const [freshDeviceIds, setFreshDeviceIds] = useState([]); + const [sessionEnded, setSessionEnded] = useState(false); + // Read inside the poll callback without making it a dependency (which would + // restart the interval on every list change). + const knownIds = useRef | null>(null); + + const load = useCallback(async (): Promise => { + try { + const list = await gateway.listDevices(); + setDevices(list); + setError(null); + return list; + } catch (e) { + setError(describe(e)); + return null; + } + }, [gateway]); + + const refresh = useCallback(async () => { + const list = await load(); + if (list) knownIds.current = new Set(list.map((d) => d.deviceId)); + }, [load]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // While a code is live, a new device may pair at any moment and the backend + // pushes no event for it (B3 scope), so poll. The interval exists only for the + // few minutes the code is valid, never in steady state. + useEffect(() => { + if (!code) return; + const timer = setInterval(() => { + void (async () => { + const list = await load(); + if (!list) return; + const known = knownIds.current; + knownIds.current = new Set(list.map((d) => d.deviceId)); + if (!known) return; + const fresh = list.filter((d) => !known.has(d.deviceId)).map((d) => d.deviceId); + if (fresh.length > 0) setFreshDeviceIds(fresh); + })(); + }, PAIRING_POLL_MS); + return () => clearInterval(timer); + }, [code, load]); + + // The highlight is an acknowledgement, not a state: let it fade on its own. + useEffect(() => { + if (freshDeviceIds.length === 0) return; + const timer = setTimeout(() => setFreshDeviceIds([]), HIGHLIGHT_MS); + return () => clearTimeout(timer); + }, [freshDeviceIds]); + + const run = useCallback( + async (action: () => Promise): Promise => { + setBusy(true); + setError(null); + try { + await action(); + return true; + } catch (e) { + setError(describe(e)); + return false; + } finally { + setBusy(false); + } + }, + [], + ); + + const generateCode = useCallback(async () => { + setGenerating(true); + try { + await run(async () => { + // Generating invalidates the previous code server-side; mirror that by + // replacing it here rather than stacking panels. + setCode(await gateway.createPairingCode()); + }); + } finally { + setGenerating(false); + } + }, [gateway, run]); + + const dismissCode = useCallback(() => setCode(null), []); + + const rename = useCallback( + async (deviceId: string, name: string) => { + if (await run(() => gateway.renameDevice(deviceId, name))) await refresh(); + }, + [gateway, run, refresh], + ); + + const revoke = useCallback( + async (target: PairedDevice) => { + // Read `isCurrentDevice` *before* the call: afterwards the session may be + // gone and the list unreadable. + const wasCurrent = target.isCurrentDevice; + if (!(await run(() => gateway.revokeDevice(target.deviceId)))) return; + if (wasCurrent) setSessionEnded(true); + else await refresh(); + }, + [gateway, run, refresh], + ); + + const revokeAll = useCallback(async () => { + const includedCurrent = devices?.some((d) => d.isCurrentDevice) ?? false; + if (!(await run(() => gateway.revokeAllDevices()))) return; + if (includedCurrent) setSessionEnded(true); + else await refresh(); + }, [devices, gateway, run, refresh]); + + return { + devices, + error, + busy, + generating, + code, + freshDeviceIds, + sessionEnded, + refresh, + generateCode, + dismissCode, + rename, + revoke, + revokeAll, + }; +} diff --git a/frontend/src/features/settings/DeploymentSettings.test.tsx b/frontend/src/features/settings/DeploymentSettings.test.tsx index 937f974..a5fb94c 100644 --- a/frontend/src/features/settings/DeploymentSettings.test.tsx +++ b/frontend/src/features/settings/DeploymentSettings.test.tsx @@ -5,7 +5,7 @@ * These pin the UX invariants the screen exists for, not its styling: the mode * is a radio choice with consequences, the authorized-proxy field is explicitly * *not* a listen address, addresses come from the backend, a refusal is - * actionable, and the pairing code is runtime-only. + * actionable, and no pairing code is ever shown here (#77). */ import { describe, it, expect, vi } from "vitest"; @@ -142,30 +142,31 @@ describe("DeploymentSettings", () => { expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull(); }); - it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => { + it("never shows a pairing code, running or not (#77)", async () => { + // A code is no longer a property of a running server: it exists only when + // asked for. Starting the server must not make one appear here — this is + // what the old "runtime-only code" test asserted, and it can no longer be + // true of any backend response. renderView(); await settle(); - expect( - screen.getByText("Start the server to generate a pairing code."), - ).toBeTruthy(); expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => expect(screen.getByText("Running")).toBeTruthy()); - expect( - await screen.findByRole("button", { name: "copy pairing code" }), - ).toBeTruthy(); - expect( - screen.getByText(/Temporary code. It disappears when the server stops/), - ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); + expect(screen.queryByText(/generate a pairing code/i)).toBeNull(); + }); - fireEvent.click(screen.getByRole("button", { name: "Stop" })); - await waitFor(() => - expect( - screen.getByText("Start the server to generate a pairing code."), - ).toBeTruthy(), - ); + it("points to where pairing now lives instead of dead-ending", async () => { + // Someone who just started the server from this screen needs to know where + // to go next; silence would be a cul-de-sac. + renderView(); + await settle(); + + const pairing = screen.getByText(/Pairing is managed in/); + expect(within(pairing).getByText("Settings → Appareils")).toBeTruthy(); }); it("starts the server and reports the local URL", async () => { diff --git a/frontend/src/features/settings/DeploymentSettings.tsx b/frontend/src/features/settings/DeploymentSettings.tsx index cf7130b..e85706f 100644 --- a/frontend/src/features/settings/DeploymentSettings.tsx +++ b/frontend/src/features/settings/DeploymentSettings.tsx @@ -16,8 +16,11 @@ * the whole reason this screen is worded the way it is; the help text under * the field says so explicitly. * - * The pairing code is runtime-only: shown while running, never rendered into a - * persisted field, never mixed with the upstream value. + * This screen no longer shows a pairing code (#77). A code is not a property of + * a running server — it exists only when someone asks for one — so it lives in + * the Appareils surface next to the devices it authorises. What is left here is + * a signpost: starting the server from this screen and finding no way to pair is + * a dead end. */ import { useState } from "react"; @@ -334,21 +337,12 @@ export function DeploymentSettings() { )} - {/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */} + {/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */} - {running && status.pairingCode ? ( -
    - -

    - Temporary code. It disappears when the server stops. Do not save it - in configuration files. -

    -
    - ) : ( -

    - Start the server to generate a pairing code. -

    - )} +

    + Pairing is managed in Settings → Appareils, + where you can generate a code and revoke devices. +

    ); diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx index bfe7613..645ae06 100644 --- a/frontend/src/features/settings/SettingsView.tsx +++ b/frontend/src/features/settings/SettingsView.tsx @@ -17,19 +17,32 @@ import { Button, cn } from "@/shared"; import { ProfilesSettings } from "@/features/first-run"; +import { DevicesScreen } from "@/features/devices"; import { DeploymentSettings } from "./DeploymentSettings"; /** The Settings sections, in menu/nav order. */ -export type SettingsSection = "aiProfiles" | "deployment"; +export type SettingsSection = "aiProfiles" | "deployment" | "devices"; -/** Human labels, shared by the nav column and the `Settings` menu. */ +/** + * Human labels, shared by the nav column and the `Settings` menu. + * + * `Appareils` is French where its neighbours are English: the device vocabulary + * is frozen by UX across web and desktop (carnet #77), and the web surface it + * mirrors is French throughout. Aligning the whole Settings surface on one + * language is a UX call beyond this ticket. + */ export const SETTINGS_SECTION_LABEL: Record = { aiProfiles: "AI Profiles", deployment: "Deployment", + devices: "Appareils", }; /** Section order — the single source of truth for both nav and menu. */ -export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"]; +export const SETTINGS_SECTIONS: SettingsSection[] = [ + "aiProfiles", + "deployment", + "devices", +]; interface SettingsViewProps { section: SettingsSection; @@ -77,7 +90,15 @@ export function SettingsView({
    - {section === "aiProfiles" ? : } + {section === "aiProfiles" ? ( + + ) : section === "deployment" ? ( + + ) : ( + // No `onSessionEnded`: the desktop app hosts the server and is never + // itself a paired device, so it cannot revoke its own session. + + )}
    diff --git a/frontend/src/features/web/PairingScreen.test.tsx b/frontend/src/features/web/PairingScreen.test.tsx index 4aab76a..c0080a8 100644 --- a/frontend/src/features/web/PairingScreen.test.tsx +++ b/frontend/src/features/web/PairingScreen.test.tsx @@ -3,8 +3,13 @@ * - the field must summon the text keyboard, not the numeric keypad; * - a lowercase entry must reach `session.pair()` uppercased and space-free, * because the server compares the code strictly. + * + * #77 lot F1 — the handshake becomes `{code, name}`: + * - dashes normalise away too, so a code retyped as `AB12-CD34` still pairs; + * - the device name is prefilled from a readable derivation, editable, and + * required; it is never a raw User-Agent. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { WebSession } from "@/adapters/http"; @@ -28,50 +33,156 @@ const okFetch: FetchLike = async () => ({ text: async () => "{}", }); -function setup() { - const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); +/** A `/api/pair` that always fails with the given wire code. */ +function failingFetch(status: number, code: string): FetchLike { + return async () => ({ + ok: false, + status, + json: async () => ({ code, message: "server wording" }), + text: async () => "{}", + }); +} + +/** Pins the UA so the name prefill is deterministic. */ +function stubUserAgent(ua: string): void { + Object.defineProperty(window.navigator, "userAgent", { + value: ua, + configurable: true, + }); +} + +const ORIGINAL_UA = window.navigator.userAgent; + +function setup(fetchImpl: FetchLike = okFetch) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() }); const pair = vi.spyOn(session, "pair"); const onPaired = vi.fn(); render(); return { pair, onPaired }; } +const codeField = () => screen.getByLabelText("Code d'appairage"); +const nameField = () => screen.getByLabelText("Nom de cet appareil") as HTMLInputElement; +const submit = () => screen.getByRole("button", { name: "Appairer" }) as HTMLButtonElement; + +beforeEach(() => stubUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")); +afterEach(() => stubUserAgent(ORIGINAL_UA)); + describe("PairingScreen code field", () => { it("asks for the text keyboard, not the numeric keypad", () => { setup(); - const field = screen.getByLabelText("Code d'appairage"); - expect(field.getAttribute("inputmode")).toBe("text"); - expect(field.getAttribute("autocapitalize")).toBe("characters"); - expect(field.getAttribute("autocomplete")).toBe("one-time-code"); + expect(codeField().getAttribute("inputmode")).toBe("text"); + expect(codeField().getAttribute("autocapitalize")).toBe("characters"); + expect(codeField().getAttribute("autocomplete")).toBe("one-time-code"); }); it("describes the code without lying about its shape", () => { setup(); - const field = screen.getByLabelText("Code d'appairage"); - expect(field.getAttribute("placeholder")).toBe("p. ex. AB12CD34"); + expect(codeField().getAttribute("placeholder")).toBe("p. ex. AB12CD34"); expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy(); }); it("uppercases and strips spaces before pairing", async () => { const { pair, onPaired } = setup(); - fireEvent.change(screen.getByLabelText("Code d'appairage"), { - target: { value: " ab12cd34 " }, - }); - fireEvent.click(screen.getByRole("button", { name: "Appairer" })); + fireEvent.change(codeField(), { target: { value: " ab12cd34 " } }); + fireEvent.click(submit()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); - expect(pair).toHaveBeenCalledWith("AB12CD34"); + expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone"); }); - it("keeps submit disabled for a blank code", () => { - setup(); - const submit = screen.getByRole("button", { name: "Appairer" }); - expect((submit as HTMLButtonElement).disabled).toBe(true); + it("strips a dash the user retyped from the grouped display (#77)", async () => { + const { pair, onPaired } = setup(); - fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } }); - expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(codeField(), { target: { value: "AB12-CD34" } }); + fireEvent.click(submit()); + + await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); + expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone"); + }); +}); + +describe("PairingScreen device name", () => { + it("prefills a readable name, never the raw User-Agent", () => { + setup(); + + expect(nameField().value).toBe("iPhone"); + expect(document.body.textContent).not.toContain("Mozilla/5.0"); + }); + + it("sends the edited name with the code", async () => { + const { pair, onPaired } = setup(); + + fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); + fireEvent.change(nameField(), { target: { value: " Téléphone de Marie " } }); + fireEvent.click(submit()); + + await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); + expect(pair).toHaveBeenCalledWith("AB12CD34", "Téléphone de Marie"); + }); + + it("caps the name at 40 characters", () => { + setup(); + expect(nameField().maxLength).toBe(40); + }); + + it("requires both a code and a name", () => { + setup(); + + // A name alone (prefilled) is not enough. + expect(submit().disabled).toBe(true); + + fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); + expect(submit().disabled).toBe(false); + + // Clearing the name blocks it again: the list must never show a blank row. + fireEvent.change(nameField(), { target: { value: " " } }); + expect(submit().disabled).toBe(true); + }); + + it("keeps submit disabled for a blank or separator-only code", () => { + setup(); + + fireEvent.change(codeField(), { target: { value: " " } }); + expect(submit().disabled).toBe(true); + + fireEvent.change(codeField(), { target: { value: " - " } }); + expect(submit().disabled).toBe(true); + }); +}); + +describe("PairingScreen error placement", () => { + async function submitAndFail(status: number, code: string) { + setup(failingFetch(status, code)); + fireEvent.change(codeField(), { target: { value: "AB12CD34" } }); + fireEvent.click(submit()); + await screen.findByRole("alert"); + } + + it("shows a rejected name on the name field, not as a form error", async () => { + await submitAndFail(400, "invalid_name"); + + // Attached to the field: the fix is one edit away, and the code is still + // valid — a form-level error would read as "start over". + const message = screen.getByRole("alert"); + expect(message.textContent).toBe( + "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.", + ); + expect(nameField().getAttribute("aria-describedby")).toBe(message.id); + expect(nameField().getAttribute("aria-invalid")).toBe("true"); + expect(screen.queryByTestId("pairing-error")).toBeNull(); + // The code was never consumed, so it must not be painted as the culprit. + expect(codeField().getAttribute("aria-invalid")).not.toBe("true"); + }); + + it("keeps a rejected code as a form error", async () => { + await submitAndFail(401, "invalid_or_expired"); + + expect(screen.getByTestId("pairing-error").textContent).toBe("Code invalide ou expiré."); + // The name is not at fault, so it must not be marked as such. + expect(nameField().getAttribute("aria-invalid")).not.toBe("true"); }); }); diff --git a/frontend/src/features/web/PairingScreen.tsx b/frontend/src/features/web/PairingScreen.tsx index 89edf46..9f6ad7e 100644 --- a/frontend/src/features/web/PairingScreen.tsx +++ b/frontend/src/features/web/PairingScreen.tsx @@ -1,10 +1,15 @@ /** - * Web pairing screen — ticket #13, lot F2. + * Web pairing screen — ticket #13 lot F2, extended by #75 and #77 lot F1. * * Shown by {@link WebApp} when the client is not paired. The user types the code - * the server printed at first launch; on submit we `POST /api/pair {code}` via - * the {@link WebSession}. On success the server sets the HttpOnly session cookie - * and we advance to the workspace; a wrong code shows a clear message. + * generated from an already-paired device (or printed by `--new-code`) plus a + * name for *this* device; on submit we `POST /api/pair {code, name}` via the + * {@link WebSession}. On success the server sets the HttpOnly session cookie and + * we advance to the workspace. + * + * The device name is typed here, on the new device, because this is the only + * moment the person is holding it — the generating device cannot know what to + * call it. It is prefilled from a readable derivation (never a raw User-Agent). * * Transport-neutral at the component seam: it talks to the injected * {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke` @@ -13,9 +18,10 @@ import { useState, type FormEvent } from "react"; -import type { GatewayError } from "@/domain"; +import { normalizePairingCode, type GatewayError } from "@/domain"; import { Button, Field, Input, Panel } from "@/shared"; import type { WebSession } from "@/adapters/http"; +import { clampDeviceName, currentDeviceName, DEVICE_NAME_MAX } from "./deviceName"; interface PairingScreenProps { /** The shared web session performing the `POST /api/pair` handshake. */ @@ -24,34 +30,43 @@ interface PairingScreenProps { onPaired: () => void; } -function describe(e: unknown): string { - if (e && typeof e === "object" && "message" in e) { - return String((e as GatewayError).message); - } - return String(e); +/** The failure as the form needs it: a message plus which field to blame. */ +interface PairingFailure { + code: string; + message: string; } -/** - * The server compares the code strictly against the uppercase hex it generated, - * so the UI uppercases (and drops stray spaces) before sending. - */ -function normalize(raw: string): string { - return raw.replace(/\s+/g, "").toUpperCase(); +function describe(e: unknown): PairingFailure { + if (e && typeof e === "object" && "message" in e) { + const err = e as GatewayError; + return { code: String(err.code ?? "ERROR"), message: String(err.message) }; + } + return { code: "ERROR", message: String(e) }; } export function PairingScreen({ session, onPaired }: PairingScreenProps) { const [code, setCode] = useState(""); - const [error, setError] = useState(null); + const [name, setName] = useState(() => clampDeviceName(currentDeviceName())); + const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const normalizedCode = normalizePairingCode(code); + const trimmedName = name.trim(); + const canSubmit = normalizedCode.length > 0 && trimmedName.length > 0; + + // A rejected name is the one failure the user fixes in the form rather than by + // fetching a new code (the server validates it before consuming the code, so + // the code is still live). Point at the field instead of the whole form. + const nameError = error?.code === "invalidName" ? error.message : null; + const formError = error && !nameError ? error.message : null; + async function submit(e: FormEvent): Promise { e.preventDefault(); - const normalized = normalize(code); - if (!normalized || busy) return; + if (!canSubmit || busy) return; setBusy(true); setError(null); try { - await session.pair(normalized); + await session.pair(normalizedCode, clampDeviceName(trimmedName)); onPaired(); } catch (err) { setError(describe(err)); @@ -89,18 +104,39 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) { autoCorrect="off" spellCheck={false} disabled={busy} - invalid={!!error} + invalid={!!formError} /> )} - {error && ( + + {({ id, describedBy }) => ( + setName(e.target.value)} + maxLength={DEVICE_NAME_MAX} + autoComplete="off" + autoCorrect="off" + spellCheck={false} + disabled={busy} + invalid={!!nameError} + /> + )} + + + {formError && (

    - {error} + {formError}

    )} - diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index c222a49..d9cb050 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -8,12 +8,18 @@ * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the * server session (`POST /api/logout`), tears down the live WS singleton, and * returns to pairing. + * + * "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component + * the desktop shows under `Paramètres → Appareils`. It is what makes a headless + * install usable: generating a pairing code from an already-paired phone instead + * of restarting the server with `--new-code`. */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/shared"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; +import { DevicesScreen } from "@/features/devices"; import { PairingScreen } from "./PairingScreen"; import { WebWorkspace } from "./WebWorkspace"; @@ -26,11 +32,17 @@ export function WebApp({ session }: WebAppProps = {}) { const webSession = session ?? getWebSession(); const [paired, setPaired] = useState(() => webSession.isPaired()); const [signingOut, setSigningOut] = useState(false); + const [showDevices, setShowDevices] = useState(false); useEffect(() => { // A 401 anywhere clears the flag and fires this: return to pairing. The live - // WS is torn down by the composition-root 401 handler (F6). - return webSession.onUnauthorized(() => setPaired(false)); + // WS is torn down by the composition-root 401 handler (F6). This is also the + // path for a *suffered* revocation (#77): another device revoked us, the + // server rejects the next call, and we land back on pairing. + return webSession.onUnauthorized(() => { + setShowDevices(false); + setPaired(false); + }); }, [webSession]); async function signOut(): Promise { @@ -43,10 +55,24 @@ export function WebApp({ session }: WebAppProps = {}) { } finally { disconnectWebLive(); setSigningOut(false); + setShowDevices(false); setPaired(false); } } + /** + * This device just revoked itself (#77) — a nominal action, not an edge case. + * The cookie is already dead server-side; drop the local flag and the live + * socket so nothing keeps retrying, and land on pairing immediately rather + * than waiting for the next request to 401. + */ + const onSessionEnded = useCallback(() => { + webSession.forget(); + disconnectWebLive(); + setShowDevices(false); + setPaired(false); + }, [webSession]); + return (
    {paired && ( - +
    + + +
    )}
    - {paired ? ( - - ) : ( + {!paired ? ( setPaired(true)} /> + ) : showDevices ? ( +
    + +
    + ) : ( + )}
    diff --git a/frontend/src/features/web/deviceName.test.ts b/frontend/src/features/web/deviceName.test.ts new file mode 100644 index 0000000..cb913fc --- /dev/null +++ b/frontend/src/features/web/deviceName.test.ts @@ -0,0 +1,63 @@ +/** + * #77 lot F1 — the device-name prefill. The contract that matters is the + * negative one: whatever the UA says, the derived name is something a human + * would have typed, and never a fragment of the User-Agent itself. + */ +import { describe, it, expect } from "vitest"; + +import { clampDeviceName, deriveDeviceName } from "./deviceName"; + +const UA = { + iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + ipad: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + androidChrome: + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + chromeWindows: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", + safariMac: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + edgeWindows: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", +}; + +describe("deriveDeviceName", () => { + it("names phones and tablets by the device, not the browser", () => { + expect(deriveDeviceName(UA.iphone)).toBe("iPhone"); + expect(deriveDeviceName(UA.ipad)).toBe("iPad"); + expect(deriveDeviceName(UA.androidChrome)).toBe("Android"); + }); + + it("names desktops by browser and OS", () => { + expect(deriveDeviceName(UA.chromeWindows)).toBe("Chrome sur Windows"); + expect(deriveDeviceName(UA.firefoxLinux)).toBe("Firefox sur Linux"); + expect(deriveDeviceName(UA.safariMac)).toBe("Safari sur Mac"); + }); + + it("prefers the more specific browser when a UA claims several", () => { + // Edge's UA also contains "Chrome/" and "Safari/"; naming it "Chrome" would + // make two different browsers on one machine indistinguishable in the list. + expect(deriveDeviceName(UA.edgeWindows)).toBe("Edge sur Windows"); + }); + + it("falls back to a usable label rather than leaking the UA", () => { + expect(deriveDeviceName("")).toBe("Cet appareil"); + expect(deriveDeviceName("SomeBot/1.0 (compatible)")).toBe("Cet appareil"); + }); + + it("never returns a raw User-Agent fragment", () => { + for (const ua of Object.values(UA)) { + const name = deriveDeviceName(ua); + expect(name).not.toContain("Mozilla"); + expect(name).not.toContain("/"); + expect(name.length).toBeLessThanOrEqual(40); + } + }); +}); + +describe("clampDeviceName", () => { + it("trims and caps at 40 characters", () => { + expect(clampDeviceName(" iPhone ")).toBe("iPhone"); + expect(clampDeviceName("x".repeat(60))).toHaveLength(40); + }); +}); diff --git a/frontend/src/features/web/deviceName.ts b/frontend/src/features/web/deviceName.ts new file mode 100644 index 0000000..e43c21e --- /dev/null +++ b/frontend/src/features/web/deviceName.ts @@ -0,0 +1,77 @@ +/** + * Readable device-name derivation for the pairing screen (ticket #77, lot F1). + * + * The name is only a **prefill**: the user sees it, can rewrite it, and it is + * what the device list will show forever after. So it must read like something a + * human would type — `iPhone`, `Chrome sur Windows` — never a raw User-Agent. + * The UA string is an implementation detail of this derivation and must not + * reach any surface: no tooltip, no placeholder, no fallback text. + * + * Pure and UA-string-driven (no `navigator` access) so the mapping is testable + * without a browser; {@link currentDeviceName} is the thin impure wrapper. + */ + +/** Bounds enforced by the pairing form and the rename action. */ +export const DEVICE_NAME_MAX = 40; +export const DEVICE_NAME_MIN = 1; + +/** Last-resort label when nothing recognisable is in the UA. */ +const FALLBACK = "Cet appareil"; + +/** Browsers, most specific first: Edge/Opera also claim "Chrome"/"Safari". */ +const BROWSERS: ReadonlyArray<[RegExp, string]> = [ + [/\bEdg(?:e|A|iOS)?\//, "Edge"], + [/\bOPR\/|\bOpera\//, "Opera"], + [/\bFirefox\/|\bFxiOS\//, "Firefox"], + [/\bChrome\/|\bCriOS\//, "Chrome"], + [/\bSafari\//, "Safari"], +]; + +/** Desktop OSes only — phones/tablets are named by the device, not the OS. */ +const DESKTOP_OS: ReadonlyArray<[RegExp, string]> = [ + [/\bWindows\b/, "Windows"], + [/\bMac OS X\b|\bMacintosh\b/, "Mac"], + [/\bCrOS\b/, "ChromeOS"], + [/\bLinux\b|\bX11\b/, "Linux"], +]; + +/** + * Derives a human label from a User-Agent string. + * + * Phones and tablets are named by the device alone (`iPhone`, `Android`) because + * that is how people refer to them; on desktop the browser is the distinguishing + * fact when several browsers share one machine, hence `Chrome sur Windows`. + */ +export function deriveDeviceName(userAgent: string): string { + const ua = userAgent ?? ""; + + if (/\biPhone\b/.test(ua)) return "iPhone"; + if (/\biPad\b/.test(ua)) return "iPad"; + // iPadOS 13+ masquerades as a Mac; the touch points give it away, but that is + // a `navigator` fact, not a UA one — a plain "Mac" here is an honest miss. + if (/\bAndroid\b/.test(ua)) return "Android"; + + const browser = BROWSERS.find(([re]) => re.test(ua))?.[1]; + const os = DESKTOP_OS.find(([re]) => re.test(ua))?.[1]; + + if (browser && os) return `${browser} sur ${os}`; + return browser ?? os ?? FALLBACK; +} + +/** + * Clamps a derived or typed name to the contract's 1-40 characters. + * + * Counts **code points**, not UTF-16 units, to match the server's + * `DeviceName::new` (`chars().count()`). `slice(0, 40)` would both disagree with + * that bound on astral characters and be able to cut a surrogate pair in half, + * putting a lone surrogate on the wire. + */ +export function clampDeviceName(name: string): string { + return Array.from(name.trim()).slice(0, DEVICE_NAME_MAX).join(""); +} + +/** Derives the current browser's device name; `Cet appareil` outside a browser. */ +export function currentDeviceName(): string { + if (typeof navigator === "undefined" || !navigator.userAgent) return FALLBACK; + return deriveDeviceName(navigator.userAgent); +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 6dc481f..31caca6 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -34,6 +34,8 @@ import type { MemoryType, OpenCodeConfig, EffectivePermissions, + PairedDevice, + PairingCode, PermissionSet, PageDirection, Project, @@ -750,6 +752,31 @@ export interface EmbedderGateway { describeEmbedderEngines(): Promise; } +/** + * Paired-device management (ticket #77) — the access surface of the instance. + * + * Mounted identically on web and desktop, so it is a **port**, not a web + * concern: the desktop adapter reaches the same use cases through the Tauri + * composition root, the web adapter over HTTP. Revoking is authoritative + * server-side; revoking the *current* device ends this session, which callers + * detect from {@link PairedDevice.isCurrentDevice} before the call. + */ +export interface DeviceGateway { + /** Lists every paired device, most recently active first (backend order). */ + listDevices(): Promise; + /** + * Generates a fresh single-use pairing code, invalidating any previous one. + * The code exists only in server memory until it is consumed or expires. + */ + createPairingCode(): Promise; + /** Renames one device (1-40 characters). */ + renameDevice(deviceId: string, name: string): Promise; + /** Revokes one device; its live WebSockets are closed server-side (B3). */ + revokeDevice(deviceId: string): Promise; + /** Revokes every device, the current one included. */ + revokeAllDevices(): Promise; +} + /** Project and agent permission management (LP1). */ export interface PermissionGateway { /** Reads the full project permission document. */ @@ -1130,6 +1157,7 @@ export interface Gateways { skill: SkillGateway; memory: MemoryGateway; embedder: EmbedderGateway; + device: DeviceGateway; permission: PermissionGateway; workState: WorkStateGateway; conversation: ConversationGateway;