Compare commits
41 Commits
506d58956b
...
17d6baf15a
| Author | SHA1 | Date | |
|---|---|---|---|
| 17d6baf15a | |||
| 1be5f8cd18 | |||
| 3f1e132e88 | |||
| 8fe93d1652 | |||
| 9463b7e6bf | |||
| 481a7e2498 | |||
| 120d0d1ac2 | |||
| aec4293750 | |||
| 4de25d2bce | |||
| f605989fa5 | |||
| 25728dee74 | |||
| 5ca2ab77f3 | |||
| a7197fc53b | |||
| 50d237e836 | |||
| 166adc3c4b | |||
| a13a6c1801 | |||
| f965f64b07 | |||
| b30c9c7590 | |||
| 7efa634f23 | |||
| 49e1d5a157 | |||
| 7fbaa8eba6 | |||
| ae01297b6b | |||
| 42804fc55d | |||
| 75d1ce87fc | |||
| e8a72f5173 | |||
| 677f64d18e | |||
| f915635e07 | |||
| 7c677c4fb4 | |||
| 328c941f22 | |||
| b7d38f1d30 | |||
| 77684ea183 | |||
| 56b9f5f619 | |||
| 32c670a2b3 | |||
| 24f5e85974 | |||
| f0f2f3b497 | |||
| 62ecefe1e8 | |||
| 48b8853214 | |||
| fe0e53ec59 | |||
| 5ee25d1ef6 | |||
| 4f57e5a0db | |||
| 0e15482ce2 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -9,6 +9,9 @@ target/
|
||||
# Dependencies and build output (package-lock.json IS committed).
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
# Sortie du build web (VITE_TRANSPORT=http) servie par idea-serve : meme
|
||||
# classe que dist/, rebuildable depuis les sources — not versioned (#65).
|
||||
frontend/dist-web/
|
||||
# Root-level node_modules (dev tooling installs at repo root — never versioned).
|
||||
/node_modules/
|
||||
# npm/yarn/pnpm debug logs
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -62,3 +62,6 @@
|
||||
- [ticket13-f5-web-live-surfaces-delivered](ticket13-f5-web-live-surfaces-delivered.md) — Les surfaces live web (workstate live via event.domain, background tasks cancel/retry, inbox, re-synchro au reconnect) du chantier client/serveur #13 sont livrées et vertes.
|
||||
- [frontend-uses-npm-not-pnpm](frontend-uses-npm-not-pnpm.md) — memory note frontend-uses-npm-not-pnpm
|
||||
- [idea-distribution-strategy-desktop-vs-docker](idea-distribution-strategy-desktop-vs-docker.md) — memory note idea-distribution-strategy-desktop-vs-docker
|
||||
- [web-client-is-single-column-no-desktop-shell](web-client-is-single-column-no-desktop-shell.md) — memory note web-client-is-single-column-no-desktop-shell
|
||||
- [sandbox-eperm-bind-false-green-web-server](sandbox-eperm-bind-false-green-web-server.md) — memory note sandbox-eperm-bind-false-green-web-server
|
||||
- [ticket74-f1-web-bundle-transport-seam](ticket74-f1-web-bundle-transport-seam.md) — Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
|
||||
|
||||
42
.ideai/memory/sandbox-eperm-bind-false-green-web-server.md
Normal file
42
.ideai/memory/sandbox-eperm-bind-false-green-web-server.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: sandbox-eperm-bind-false-green-web-server
|
||||
description: memory note sandbox-eperm-bind-false-green-web-server
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
---
|
||||
name: sandbox-eperm-bind-false-green-web-server
|
||||
description: Les sandboxes de DevBackend et QA bloquent TcpListener::bind (EPERM) ; tout `cargo test` sur web-server/app-tauri y rend un VERT QUI NE PROUVE RIEN. Vérifier hors sandbox.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
# Faux vert : `TcpListener::bind` interdit dans les sandboxes agents
|
||||
|
||||
## Le fait
|
||||
|
||||
Les environnements d'exécution de **DevBackend et de QA** refusent `TcpListener::bind("127.0.0.1:0")` avec `Operation not permitted (os error 1)`. Tout test qui ouvre un socket y est structurellement inexécutable.
|
||||
|
||||
Crates concernés : `web-server`, `app-tauri` (pont MCP, serveur embarqué).
|
||||
|
||||
## Pourquoi c'est dangereux, pas juste gênant
|
||||
|
||||
Le 2026-07-16, ce piège s'est refermé **deux fois dans la même journée** :
|
||||
|
||||
1. **#68 B1** — DevBackend annonce « 57 passed ». En réalité le test `run_embedded_stop_shuts_down_accept_loop` sortait en silence sur EPERM via un repli, et le test du core partagé basculait sur un dispatch in-process. Rejoué **hors sandbox** : échec réel, révélant un **vrai bug produit** — `run_embedded` ne réconciliait pas le port effectif avec la config, donc `origin_allowed` comparait l'origine à `http://127.0.0.1:0` et un serveur embarqué sur port éphémère **rejetait toutes les requêtes API en 403**. Le trou n'avait jamais été vu parce que rien ne consommait `run_embedded`.
|
||||
2. **#72** — même schéma, cette fois signalé honnêtement par DevBackend (« 63 passed; 2 failed » sur EPERM) après consigne explicite.
|
||||
|
||||
Un repli silencieux sur EPERM transforme un test en décoration : il passe sans rien exercer, et masque la classe de bug qu'il était censé attraper.
|
||||
|
||||
## La règle
|
||||
|
||||
- **Aucun repli EPERM silencieux.** Un test qui ne peut pas s'exécuter doit **échouer visiblement** ou être `#[ignore]` explicite avec sa raison — jamais « passer ».
|
||||
- **Tout vert sur `web-server`/`app-tauri` doit être produit hors sandbox** (`dangerouslyDisableSandbox: true` côté Main, qui n'a pas la restriction). Ne jamais accepter un vert sandboxé comme preuve sur ces crates.
|
||||
- DevBackend et QA doivent **dire ce qu'ils ont pu exécuter et ce qu'ils n'ont pas pu**, plutôt que de rendre un chiffre global.
|
||||
- Les `#[ignore = "requires local socket bind permission"]` légitimes (ex. `mcp_bridge::tests::end_to_end_over_real_loopback`) se vérifient en les **lançant** hors sandbox avec `--ignored`, pas en raisonnant dessus.
|
||||
|
||||
## Principe général
|
||||
|
||||
Ne pas confondre « les tests sont verts » et « le code marche ». Le vert d'un environnement contraint ne dit rien du produit. Cette leçon vaut au-delà du bind : un environnement qui ne peut pas exercer un chemin ne peut pas le valider.
|
||||
|
||||
Lien : [[appimage-build-no-strip-relr-dyn-fix]] (autre piège d'environnement de cette machine),
|
||||
[[rendezvous-600s-cap-too-short-heavy-tasks]].
|
||||
18
.ideai/memory/ticket74-f1-web-bundle-transport-seam.md
Normal file
18
.ideai/memory/ticket74-f1-web-bundle-transport-seam.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: ticket74-f1-web-bundle-transport-seam
|
||||
description: Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
Le frontend produit **deux** artefacts Vite depuis les mêmes sources :
|
||||
- `frontend/dist` → transport `tauri` (desktop, `build.frontendDist`) — `npm run build`.
|
||||
- `frontend/dist-web` → transport `http` (packagé en ressource Tauri `web/`) — `npm run build:web`.
|
||||
- `npm run build:bundle` produit les deux : c'est le point d'entrée du packaging Tauri.
|
||||
|
||||
**Le mode web passe par `--mode web` + `frontend/.env.web` (`VITE_TRANSPORT=http`), jamais par un préfixe inline `VITE_TRANSPORT=http vite build`** : non portable Windows/NSIS, cible active du bundle. `.env.web` est versionné (non gitignoré) ; `dist-web/` est ignoré.
|
||||
|
||||
**Anti-divergence de `__IDEA_TRANSPORT__`** : `frontend/src/app/transport.ts` expose `transportFromEnv(value)`, importé à la fois par `di.tsx` (`resolveTransport`/`shouldUseHttp`) et par `vite.config.ts`, qui lui passe `VITE_TRANSPORT` via `loadEnv(mode)`. Même variable + même prédicat = une seule source. Modifier le prédicat déplace constante et runtime ensemble. `vitest.config.ts` réplique le `define` via le même helper.
|
||||
|
||||
La constante est **publiée sur `window` dans `main.tsx`** : `define` ne remplace que les occurrences existantes et une constante non référencée serait tree-shakée ; l'affectation est un effet de bord qui survit à la minification. Elle reporte le transport hors mock (`VITE_USE_MOCK` reste un switch distinct).
|
||||
|
||||
Pour identifier le transport d'un bundle bâti : `grep -o '__IDEA_TRANSPORT__="[a-z]*"' dist*/assets/*.js`. **Ne pas se fier à la présence de `__TAURI_INTERNALS__` ni à un nom de symbole minifié** : les deux jeux d'adapters sont présents dans les deux bundles, un grep ne les discrimine pas.
|
||||
@ -0,0 +1,44 @@
|
||||
---
|
||||
name: web-client-is-single-column-no-desktop-shell
|
||||
description: memory note web-client-is-single-column-no-desktop-shell
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Le client web n'a pas de shell desktop (ni docks, ni layout grid)
|
||||
|
||||
Constat établi en livrant le ticket #69 (adaptibilité téléphone), sur `develop @ 506d589`.
|
||||
|
||||
## Le fait
|
||||
|
||||
Le client web livré par #13 (`VITE_TRANSPORT=http`) **n'a jamais eu** de docks
|
||||
gauche/droite, de fenêtres flottantes, de `LayoutTree`, de tabs projet, de menus ni
|
||||
de toasts. `main.tsx` route `isWeb` vers `WebApp`, qui est une surface autonome :
|
||||
|
||||
- `WebApp` → `PairingScreen` **ou** `WebWorkspace` (rien d'autre) ;
|
||||
- `WebWorkspace` → une **colonne verticale unique** (`flex flex-col overflow-y-auto`) ;
|
||||
- `WebAgentCell` → `TerminalView`.
|
||||
|
||||
Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et
|
||||
`workstate/useProjectWorkState`. Le shell desktop (`App`, layout, docks) n'est
|
||||
atteignable que par la branche non-web de `main.tsx`.
|
||||
|
||||
## Pourquoi ça compte
|
||||
|
||||
Le cadrage de #69 prévoyait un lot « remplacer les docks gauche/droite/floating par
|
||||
une pile verticale, drawer ou bottom sheet ». Ce lot était **sans objet** : il n'y a
|
||||
pas de dock à remplacer, et l'arbitrage produit « bottom sheet vs drawer » ne se pose
|
||||
pas sur cette surface. Le vrai travail téléphone était ailleurs (terminal xterm
|
||||
pilotable au doigt, `dvh`, safe-area, rangées qui débordent à 360px).
|
||||
|
||||
**Avant de cadrer un ticket « responsive / mobile » sur le client web, partir de
|
||||
`features/web/`, pas du shell desktop.** Les deux surfaces ne partagent que
|
||||
`TerminalView` et les hooks transport-neutres.
|
||||
|
||||
## Garde en place
|
||||
|
||||
`frontend/src/features/web/WebMobile.test.tsx` épingle la frontière : le client web
|
||||
ne doit monter aucun `layout-grid` / `layout-grid-container` / `layout-split` /
|
||||
`layout-tabs`. Si un jour on veut du desktop-shell dans le web, ce test tombe — et
|
||||
c'est le signal qu'il faut re-cadrer, pas le supprimer.
|
||||
|
||||
Voir aussi [[ui-rework-sprint-scoping-contracts]].
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
issueRef: "#13"
|
||||
version: 18
|
||||
version: 19
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784184002362
|
||||
updatedAt: 1784193634690
|
||||
---
|
||||
|
||||
# Ticket #13 — Server/client mode (carnet de chantier)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "036783fa-9856-4643-8c28-653b93ac36e1"
|
||||
number: 13
|
||||
title: "Server/client mode"
|
||||
status: "inProgress"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: []
|
||||
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783184188272
|
||||
updatedAt: 1784184002362
|
||||
version: 18
|
||||
updatedAt: 1784193634690
|
||||
version: 19
|
||||
---
|
||||
J'aimerais pouvoir utiliser IdeA sous forme de client/server. C'est a dire que IdeA aurait son backend sur une machine et son interface qui serait un frontend web. Les agents etc seraient tous côté server. C'est a dire que la cli de Claude serait celle côté serveur, l'interface client ne serait que l'affichage frontend, une UI. Je pense que tout est faisable, il faudrait cependant parler du côté CLI. Est ce qu'il serait possible de garde rle CLI natif comme il est actuellement, de l'afficher côté client tout en gardant l'installation claude code, codex etc côté serveur ? Peut etre qu'en ssh ça passerait ? Ce ticket ne pourra pas être fait en autonomie par un agent sans une review agent-utilisateur avant.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#60"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784095176619
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784194089605
|
||||
---
|
||||
|
||||
@ -4,14 +4,14 @@ number: 60
|
||||
title: "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)"
|
||||
status: "inProgress"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: [{"target":"#25","kind":"relatesTo"},{"target":"#27","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1784094260499
|
||||
updatedAt: 1784095176619
|
||||
version: 2
|
||||
updatedAt: 1784194089605
|
||||
version: 3
|
||||
---
|
||||
Rapporté par l'utilisateur (2026-07-15) : dans l'édition d'un ticket, le bouton « Assistant IA » ouvre une conversation, mais l'envoi d'un message ne produit JAMAIS de réponse visible — la conversation reste muette. PROFIL UTILISÉ : modèle local OpenAI-compatible (llamacpp).
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#64"
|
||||
version: 2
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784187610934
|
||||
updatedAt: 1784193766706
|
||||
---
|
||||
|
||||
@ -4,14 +4,14 @@ number: 64
|
||||
title: "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#13","kind":"dependsOn"},{"target":"#66","kind":"dependsOn"}]
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#13","kind":"dependsOn"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784183727404
|
||||
updatedAt: 1784187610934
|
||||
version: 2
|
||||
updatedAt: 1784193766706
|
||||
version: 5
|
||||
---
|
||||
Issu de la validation live de #13 (mode client/serveur web). En web, le bouton « Browse » (choisir un dossier de projet) est inopérant : `pickFolder()` renvoie `UNSUPPORTED_ON_WEB` car il s'appuie sur le dialogue natif OS de Tauri, absent du navigateur. Sélectionner un projet EXISTANT est déjà couvert par la liste (list_projects/open_project). Il manque CRÉER/AJOUTER un projet = choisir un dossier côté SERVEUR.
|
||||
|
||||
|
||||
@ -1,6 +1,46 @@
|
||||
---
|
||||
issueRef: "#65"
|
||||
version: 1
|
||||
version: 7
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784187583229
|
||||
updatedAt: 1784210420516
|
||||
---
|
||||
# Ticket #65 — Serveur headless `idea-serve` (carnet de chantier)
|
||||
|
||||
Statut : **QA — en attente de validation live utilisateur (non-régression desktop)**.
|
||||
Branche : `feature/ticket65-idea-serve-headless` (base `develop` @ `506d589`). Local, aucune action sortante.
|
||||
|
||||
## Arbitrages Architect (tranchés)
|
||||
- **DTO/events → propriétaire canonique = crate `backend`.** PAS de crate `presentation-dto`/`backend-api` : `backend` est déjà le composition root transport-neutre et la façade des use cases pour adapters entrants. `web-server` ET `app-tauri` le consomment ⇒ contrat canonique partagé entre deux driving adapters.
|
||||
- **Frontière** : les DTO dans `backend` ne tirent NI Tauri, NI HTTP/WS/cookies, NI Axum/Hyper, NI UI. `domain`/`application` ne dépendent PAS des DTO. Vérifié QA.
|
||||
- **Dédup events = dans L1, pas en dette** (sinon deux sources de vérité au moment où #65 stabilise le contrat). Dette acceptable uniquement pour le Tauri-spécifique (relay `app_handle.emit`, wiring runtime).
|
||||
|
||||
## Structure cible (livrée)
|
||||
- `crates/backend/src/dto.rs` + `crates/backend/src/events.rs` ; `lib.rs` expose `pub mod dto; pub mod events;`
|
||||
- `app-tauri/src/dto.rs` = shim `pub use backend::dto::*;`
|
||||
- `app-tauri/src/events.rs` = relay Tauri uniquement, DTO importés de `backend::events`.
|
||||
- `web-server` consomme `backend::{dto, events}`.
|
||||
|
||||
## Livré — L1/L2/L3 + run_embedded
|
||||
Commits : `82e8e77` (wip de sûreté, ne fige rien) · `ddbea7b` (dédup DTO events).
|
||||
`web_server::run_embedded(...)` présent : `ServerConfig`, `EmbeddedServerHandle`, `url()`, `pairing_code()`, `state()`, `stop()`, shutdown via `oneshot`. **C'est le point d'extension qui prépare #68** (le desktop consommera `web-server` comme lib, pas de fork).
|
||||
|
||||
## Vérif QA (exécution réelle) — VERT
|
||||
- `cargo build -p web-server --bin idea-serve` + `-p app-tauri --bin app-tauri` : OK.
|
||||
- `cargo test -p backend` 41 passed · `-p web-server` 55 passed · `-p app-tauri` 35 passed.
|
||||
- **Invariant clé** : `ldd target/debug/idea-serve | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry'` → **aucune sortie**. Comparatif `app-tauri` tire bien libwebkit2gtk/libgtk/libsoup/libjavascriptcore.
|
||||
- Contrat HTTP/WS inchangé (55 tests web-server : pair/invoke/logout/ws/static/allowlist/B8). `git diff --name-only 506d589...HEAD -- frontend` → **vide**.
|
||||
|
||||
## Faux mystère élucidé (à ne pas refaire paniquer)
|
||||
`cargo test -p app-tauri` est passé de **45 → 35** : les tests ne sont PAS perdus, ils ont MIGRÉ. Écart réel depuis develop = **68 tests déplacés** (`src/lib.rs` : 104 → 36) : 55 `server::tests::*` → `web-server`, 10 `events::tests::*` → `backend::events`, 3 `dto::ls6_pagination_tests::*` → `backend::dto`. Vérifié par `cargo test -p app-tauri -- --list` comparé sur `506d589`.
|
||||
|
||||
## Faux négatif connu (arbitré NON bloquant)
|
||||
`cargo test --workspace` : 10 échecs `infrastructure::session::openai_compat::*` + `factory_routes_openai_compatible_to_http_session` sur `bind: Os { code: 1, kind: PermissionDenied }` = la sandbox interdit l'écoute réseau. **Confirmé préexistant sur `506d589`** (même liste). Validation retenue par crates ciblés.
|
||||
|
||||
## Réserve QA restante
|
||||
Aucun test dédié à `run_embedded().stop()` (un test de bind réel serait soumis à la même restriction sandbox). À couvrir dans #68, qui consommera ce handle.
|
||||
|
||||
## Incident de topologie (résolu — leçon)
|
||||
DevBackend a d'abord travaillé sur la branche de #69 sans committer. Git a rapatrié le diff Rust (stash `-u` limité à `Cargo.lock Cargo.toml crates/`), intégrité vérifiée par empreinte SHA-256 avant/après (`c1aecea8…c499d3b` / `701bc7ee…4d6ff016`), rien perdu. **Leçon : vérifier la branche courante AVANT de commencer.**
|
||||
|
||||
## Prochaine étape
|
||||
Validation live utilisateur : (1) le desktop ne perd rien, (2) `idea-serve` sert le client web. Si OK → Git merge dans develop (Git refuse de merger sans QA réelle sur les DEUX binaires ; point sensible = workspace `Cargo.toml` members). Débloque ensuite #68 (via `run_embedded`) et #66 (Docker).
|
||||
|
||||
@ -2,16 +2,16 @@
|
||||
id: "f9f7067b-92b7-4737-ad99-4487ad8c8b13"
|
||||
number: 65
|
||||
title: "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#13","kind":"dependsOn"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784187583229
|
||||
updatedAt: 1784187583229
|
||||
version: 1
|
||||
updatedAt: 1784210420516
|
||||
version: 7
|
||||
---
|
||||
Objectif : produire un binaire serveur headless (`idea-serve`) SANS dépendance Tauri/WebKit, bâti sur le même cœur backend que le desktop (pas de fork). Prérequis de l'image Docker serveur/client. Cadré par Architect (suite #13).
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#66"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784187597004
|
||||
version: 2
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784193460805
|
||||
---
|
||||
|
||||
@ -4,14 +4,14 @@ number: 66
|
||||
title: "Image Docker serveur/client IdeA"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#65","kind":"dependsOn"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1784187597004
|
||||
updatedAt: 1784187597004
|
||||
version: 1
|
||||
updatedAt: 1784193460805
|
||||
version: 2
|
||||
---
|
||||
Objectif : livrer une image Docker exécutant le mode serveur/client d'IdeA, bâtie sur le binaire headless `idea-serve` (#65), pas sur le binaire Tauri. Cadré par Architect (suite #13).
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#67"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784187618710
|
||||
version: 2
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784193467784
|
||||
---
|
||||
|
||||
@ -4,14 +4,14 @@ number: 67
|
||||
title: "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: null
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#13","kind":"dependsOn"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1784187618710
|
||||
updatedAt: 1784187618710
|
||||
version: 1
|
||||
updatedAt: 1784193467784
|
||||
version: 2
|
||||
---
|
||||
Issu de la validation live #13. `idea --serve` et l'app desktop peuvent écrire le même app-data-dir (`~/.local/share/app.idea.ide`) simultanément — pas de lock inter-process → risque de corruption d'état (projects.json/profiles.json/tasks). Aujourd'hui contourné par une simple consigne de doc (« ne pas lancer les deux en même temps »).
|
||||
|
||||
|
||||
63
.ideai/tickets/68/carnet.md
Normal file
63
.ideai/tickets/68/carnet.md
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
issueRef: "#68"
|
||||
version: 8
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784277313706
|
||||
---
|
||||
# Ticket #68 — Serveur embarqué activable depuis le desktop : carnet de chantier
|
||||
|
||||
Statut : **livré et mergé dans `develop` (`f965f64`)** le 2026-07-16, en exécution autonome de nuit.
|
||||
**Reste à faire : la validation live utilisateur.** Rien n'a été cliqué dans l'app réelle — voir la réserve plus bas.
|
||||
|
||||
## Livraison en deux temps
|
||||
|
||||
**B1 — seam shared-core** (mergé plus tôt, `b7d38f1` + `328c941`). `web_server::run_embedded_with_core(config, Arc<BackendCore>)`. Sans lui, le desktop aurait construit une **seconde composition root dans le même processus** — deux event bus, deux registres live, deux jeux de stores — et le conflit d'écriture app-data-dir serait réapparu à l'intérieur d'un seul process.
|
||||
|
||||
**B2/F1/F2 — le reste** (`49e1d5a` backend, `7efa634` frontend, merge `b30c9c7`).
|
||||
- `crates/app-tauri/src/embedded_server.rs` : controller de cycle de vie sur `AppState`, commandes `embedded_server_start/stop/status` et `get/save/preview_server_exposure_settings`, `FsServerExposureSettingsStore`.
|
||||
- `frontend/src/features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment` ; `adapters/desktopServer.ts` ; port `DesktopServerGateway`.
|
||||
- `ProjectsView` : `showSettings: boolean` → navigation interne `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles » a disparu.
|
||||
|
||||
## Décisions structurantes appliquées
|
||||
|
||||
- **Pas de `PanelId: "settings"`.** Architect s'est corrigé en cours de route : Settings existe déjà dans `ProjectsView` comme surface principale, hors modèle `viewPlacement`. C'est de la configuration globale persistée, pas une vue projet dockable/détachable.
|
||||
- **Persistance dans `<app-data-dir>/deployment/server-exposure.json`**, pas `localStorage`/`uiPreferences` : falsifier cette config peut exposer le serveur, c'est de la config de sécurité. Écriture atomique, `0600` posé **avant** le `rename`.
|
||||
- **Le pairing code n'est jamais persisté** — secret runtime, affiché seulement si `running`.
|
||||
- **Le frontend n'invente jamais une IP** : `candidateLanAddresses` et `upstreamUrl` viennent du backend, d'où l'existence de `preview`.
|
||||
- `EmbedderSettings` / `ModelServersPanel` **non rapatriés** : refonte latérale hors sprint. La liste de sections est le seam où ils s'inséreront.
|
||||
|
||||
## ⚠️ Le brief de Main était FAUX — intercepté par DevFrontend
|
||||
|
||||
Main décrivait le mode `remoteProxyOtherMachine` avec deux champs (origine publique + IP du proxy). **`validate_settings` en exige un troisième : `lanBindAddress`**, et rejette loopback/unspecified. Construit selon le brief, le lot aurait été **vert et cassé** : chaque `save` et chaque `start` en mode 3 auraient échoué, sans qu'aucun test ne le voie.
|
||||
|
||||
Corrigé par un select alimenté par `candidateLanAddresses` — la règle « le frontend n'invente jamais une IP » tient. Leçon consignée dans `7efa634`.
|
||||
|
||||
## Vérification réelle (Main puis Git, tous deux hors sandbox)
|
||||
|
||||
```
|
||||
npm run typecheck → propre
|
||||
npx vitest run → 87 files / 789 passed
|
||||
cargo test -p app-tauri → 43 passed, 1 ignored
|
||||
cargo test -p web-server → 66 passed
|
||||
```
|
||||
Re-vérifié sur `develop` après merge : 24 suites Rust, 0 échec.
|
||||
|
||||
**Invariants vérifiés par exécution, pas par lecture de rapport :** `run_embedded_with_core` est le seul appelant (aucun `run_embedded` nu dans `app-tauri`) ; aucune fuite d'`EmbeddedServer*`/`ServerExposure*` dans `domain` ni `application` ; ordre du store `write(tmp)` → `chmod 0600` → `rename` ; `stop()` appelé à la sortie d'app (`lib.rs:153`) ; `features/web` n'importe pas la surface Settings (épinglé par test) et le transport web rend `UNSUPPORTED_ON_WEB`.
|
||||
|
||||
**Piège d'environnement :** `app-tauri` et `web-server` ont un sandbox qui bloque `TcpListener::bind` (EPERM). Le test `end_to_end_over_real_loopback` est `#[ignore]` **préexistant** — lancé hors sandbox avec `--ignored`, il passe. Garde d'environnement confirmée par exécution, pas faux vert.
|
||||
|
||||
## RÉSERVE — aucune validation live
|
||||
|
||||
Tout est vert, **rien n'a été cliqué**. Le panneau n'a jamais été ouvert dans l'app réelle : ni le démarrage/arrêt, ni l'affichage du pairing code, ni les trois modes, ni la persistance entre deux lancements. jsdom ne prouve pas le rendu. Une AppImage a été construite pour que l'utilisateur teste — c'est le point de vérité qui manque.
|
||||
|
||||
## Dette identifiée, non traitée (ne bloque rien)
|
||||
|
||||
1. **`preview` valide avant de prévisualiser** (`embedded_server.rs:211`) : un brouillon mode 3 ne peut pas être prévisualisé tant qu'il n'a pas le `lanBindAddress` que la liste des candidats doit justement fournir. Contourné côté UI par une sonde `localOnly` toujours valide. Défaut d'ordonnancement backend.
|
||||
2. **Code mort** : le warning `missingTrustedProxy` (`embedded_server.rs:452`) est inatteignable — `validate_settings` rejette les `trustedProxies` vides en mode 3 avant que `preview_settings` ne puisse le produire.
|
||||
3. **Aucun événement de statut** : le backend n'émet rien, `onStatusChanged` est du polling **dans l'adapter Tauri**. La forme du port est prête pour un vrai événement, sans toucher l'UI.
|
||||
4. **Fenêtre umask sur le temporaire** du store : le `0600` arrive après la création. Aucun secret dans ce fichier (modes, ports, IP), risque faible. Un `OpenOptions::mode(0o600)` à la création fermerait le sujet.
|
||||
5. **Piège latent hors #68** : les stubs `unsupported.ts` préexistants (`WebRemoteGateway`, `WebWindowGateway`) *throwent de façon synchrone* depuis des méthodes qui rendent des Promise — `.then(ok, err)` ne les attraperait pas. Sans effet aujourd'hui (les appelants `await`), mais mérite un nettoyage.
|
||||
|
||||
## Contexte : pourquoi #68 est passé avant #71 et #73
|
||||
|
||||
Arbitrage **utilisateur**, contre la recommandation d'Architect. Ce dernier proposait #72 → #73 → #71 → #68, ce qui reléguait en dernier la fonctionnalité demandée le matin même. L'utilisateur a tranché : #72 ajusté, puis #68. #71 (diagnostics) et #73 (TLS intégré) suivent.
|
||||
16
.ideai/tickets/68/issue.md
Normal file
16
.ideai/tickets/68/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "13821b24-f566-402e-bc10-02ac6e8a5f7c"
|
||||
number: 68
|
||||
title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#65","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784193318419
|
||||
updatedAt: 1784277313706
|
||||
version: 8
|
||||
---
|
||||
J'iamerais que depuis l'app desktop, on puisse quand même embarquer le serveur. Comme ça un utilisateur pourrait continuer son travail en cours en remote
|
||||
56
.ideai/tickets/69/carnet.md
Normal file
56
.ideai/tickets/69/carnet.md
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
issueRef: "#69"
|
||||
version: 11
|
||||
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
|
||||
updatedAt: 1784207736267
|
||||
---
|
||||
# Ticket #69 — Adaptibilité client téléphone (carnet de chantier)
|
||||
|
||||
Statut : **validation live utilisateur PARTIELLE effectuée — mergé dans `develop` avec réserve explicite sur le lot 3**.
|
||||
Branche : `feature/ticket69-mobile-responsive-client`, **rebasée sur `develop` @ `fe0e53e`** (base d'origine `506d589`). Commits après rebase : `48b8853` (shell), `62ecefe` (terminal + tests), `f0f2f3b` (fix typecheck) — anciennement `e22ea5b`/`8f15ad6`/`6ed0087`. Arbre `frontend/` identique bit pour bit avant/après rebase (`git diff 6ed0087 HEAD -- frontend` vide) ; historique d'origine conservé sur `backup/ticket69-pre-rebase-6ed0087`. Frontend uniquement — vérifié.
|
||||
|
||||
## Cadrage Architect
|
||||
Ticket **frontend-pur** : aucun changement DTO, use case, ni modèle `LayoutTree`. Contrats HTTP/WS et layout/terminal inchangés. Mais PAS du « CSS pur ».
|
||||
|
||||
## ⚠️ Le cadrage initial reposait sur un modèle FAUX (constat majeur)
|
||||
Le lot 2 prévoyait « remplacer les docks gauche/droite/floating par une pile verticale/drawer/bottom sheet ». **Il n'y a rien à remplacer** : le client web n'a JAMAIS eu de docks, de fenêtres flottantes ni de `LayoutTree`. `main.tsx` route le mode `http` vers `WebApp`, surface autonome livrée par #13 : pairing **ou** `WebWorkspace`, déjà une colonne verticale unique. Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et `workstate` — le shell desktop n'est pas atteignable depuis le web.
|
||||
⇒ **La question « bottom sheet vs drawer » ne se pose pas.** Frontière épinglée par un test (`WebMobile.test.tsx`) + mémoire `web-client-is-single-column-no-desktop-shell`, pour que le cadrage ne reparte pas du même modèle faux.
|
||||
|
||||
## Le vrai blocage était le terminal, pas le layout
|
||||
Le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches : on pouvait taper un prompt à un agent CLI mais pas l'interrompre, compléter un chemin, sortir d'un éditeur ou rappeler l'historique.
|
||||
|
||||
## Livré
|
||||
- **Lot 1 (shell)** : `100dvh` (le `height:100%` se résolvait sur le *large* viewport ⇒ la barre d'URL rétractable masquait le bas), `viewport-fit=cover` + safe-area insets, padding responsive, `inputMode="numeric"` sur le code d'appairage. Zoom laissé libre (accessibilité).
|
||||
- **Lot 2 (réinterprété)** : `AgentLiveRow` + rangée de tâche de fond (statut/kind/Cancel/Retry ne tenaient pas à 360px) s'empilent sous `sm`, ligne unique dès `sm`.
|
||||
- **Lot 3 (terminal)** : `TerminalView` expose `onReady(api)` **optionnel** (desktop inchangé) ; `api.send` passe par `term.input()` — même chemin qu'une frappe réelle, donc relais PTY + suspension du write-portal s'appliquent à l'identique (écrire sur le handle aurait court-circuité le portal). `TerminalKeyBar` : Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips 44px ; **chaque tap annule le déplacement de focus** puis refocalise xterm (sinon le clavier virtuel se referme à chaque touche). Cellule `h-64` → `h-[60dvh]`.
|
||||
|
||||
## Vérif QA (exécution réelle) — VERT
|
||||
- `npm run typecheck` exit 0 · `npx vitest run` → 85 files / **774 passed** · `VITE_TRANSPORT=http npx vite build` OK.
|
||||
- **CSS mobile confirmé DANS le bundle** (les valeurs arbitraires Tailwind avec `env()` no-opent en silence) : `100dvh`, `60dvh`, les 4 `safe-area-inset-*`, `viewport-fit=cover`, `.h-\[60dvh\]{height:60dvh}`.
|
||||
- Non-régression desktop : `LayoutGrid` ne passe pas `onReady` ; chemin desktop inchangé.
|
||||
- Rendu réel Chrome headless 360×780 : pairing tient sans débordement horizontal.
|
||||
- **Ré-exécutée par Git sur la base rebasée `fe0e53e` avant merge** (les chiffres du carnet ne valaient que sur `506d589`) : `npm run typecheck` exit 0 · `npx vitest run` → 85 files / **774 passed**, 0 échec · `VITE_TRANSPORT=http npx vite build` exit 0. Identique — le rebase n'a rien cassé.
|
||||
|
||||
## Réserve ASSUMÉE — trou de couverture réel
|
||||
**jsdom n'évalue PAS les media queries** : les tests sont structurels, pas visuels. Ils ne prouvent NI le rendu aux breakpoints, NI l'effet réel de `dvh`, NI les safe areas, NI le clavier virtuel/focus sur navigateur mobile. QA : « acceptable pour merger avec réserve explicite », pas rouge. **C'est exactement ce que la validation utilisateur doit couvrir.**
|
||||
|
||||
## Validation live utilisateur — PÉRIMÈTRE EXACT (2026-07-16)
|
||||
|
||||
Effectuée par l'utilisateur sur un **vrai téléphone**, contre le serveur `idea-serve` exposé via le reverse proxy `https://idea.anthonybouteiller.ovh`.
|
||||
|
||||
**Couvert — verdict utilisateur : OK.** « Le terminal s'ouvre correctement pour le téléphone. » Atteindre le terminal implique d'avoir traversé l'appairage **puis** le workspace : cela valide en réel le shell (lot 1) et la mise en page à taille de téléphone (lot 2), y compris le `100dvh` et le `60dvh` de la cellule terminal, que jsdom ne pouvait pas prouver.
|
||||
|
||||
**NON couvert — angle mort qui subsiste.** Le **lot 3 n'a pas été exercé** : la `TerminalKeyBar` (Esc/Tab/Ctrl-C/Ctrl-D/flèches) n'a pas été utilisée, et le comportement de focus au tap — l'invariant « chaque tap annule le déplacement de focus puis refocalise xterm », sans lequel le clavier virtuel se referme à chaque touche — n'a **pas** été observé sur un navigateur mobile réel. Or c'est le cœur du ticket : le carnet identifie le terminal, pas le layout, comme le vrai blocage.
|
||||
|
||||
Le test qui fermerait ce trou tient en 30 s : lancer une commande longue, taper **Ctrl-C** dans la barre de touches, vérifier (a) que la commande s'interrompt et (b) que le clavier virtuel reste ouvert.
|
||||
|
||||
**Arbitrage Main : merge autorisé avec cette réserve écrite, pas effacée.** Le risque résiduel est borné (surface frontend-pure, desktop non atteint, non-régression prouvée) et ne justifie pas de bloquer le sprint. Mais tant que le test Ctrl-C n'est pas fait, **le lot 3 est livré et non prouvé en conditions réelles** — à ne pas présenter comme validé.
|
||||
|
||||
**Report de la réserve dans l'historique git (Git, 2026-07-16)** : la réserve est recopiée dans le corps du commit de merge de `develop`. Un carnet peut être réécrit ; un message de commit mergé, non. Le fait « lot 3 non prouvé en réel au moment du merge » est donc daté et infalsifiable, indépendamment de ce fichier.
|
||||
|
||||
## Écart de périmètre — Playwright : TRANCHÉ, reporté vers #63
|
||||
Lot 4 demandait « Playwright ou équivalent ». DevFrontend a livré l'équivalent (vitest/jsdom) et REFUSÉ d'installer Playwright unilatéralement (binaires navigateur + surface CI = décision de dépendance qui remonte à Architect/Main) — refus fondé. QA : non bloquant pour ce lot.
|
||||
**Décision Main (2026-07-16) : ne bloque pas #69 et ne donne PAS lieu à un ticket neuf.** La décision de dépendance appartient à **#63 « Système de test de l'UI »**, déjà ouvert, où elle sera tranchée avec la stratégie de test UI dans son ensemble plutôt qu'au coin d'un sprint serveur/client. Rationnel conservé : Playwright rend un vrai navigateur (media queries, `dvh`, safe areas évalués pour de vrai) et aurait attrapé automatiquement les deux bugs de classe « CSS silencieusement absent du bundle » corrigés ici ; il **n'émule** cependant qu'un téléphone et ne remplace pas la validation manuelle du clavier virtuel — sa valeur est d'empêcher la régression silencieuse entre deux validations manuelles.
|
||||
|
||||
## Prochaine étape
|
||||
Merge fait par Git (rebase + `--no-ff` dans `develop`, sans conflit). **Le test Ctrl-C sur téléphone réel reste ouvert** : s'il échoue, il ouvre un bug, il ne réverte pas le ticket. Le passage du ticket à un statut terminal appartient à Main : tant que le lot 3 n'est pas prouvé en réel, « mergé » ≠ « validé ».
|
||||
16
.ideai/tickets/69/issue.md
Normal file
16
.ideai/tickets/69/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "7f5a40cf-837f-46f7-8c5f-653c80975396"
|
||||
number: 69
|
||||
title: "Adaptibilité client téléphone"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
|
||||
createdAt: 1784193391258
|
||||
updatedAt: 1784207736267
|
||||
version: 11
|
||||
---
|
||||
J'aimerais un format vertical qui rende l'app client compatible téléphone
|
||||
6
.ideai/tickets/70/carnet.md
Normal file
6
.ideai/tickets/70/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#70"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784194049447
|
||||
---
|
||||
16
.ideai/tickets/70/issue.md
Normal file
16
.ideai/tickets/70/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "e73ed25f-8e22-48a5-84ea-521212ea80b5"
|
||||
number: 70
|
||||
title: "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1784194016529
|
||||
updatedAt: 1784194049447
|
||||
version: 4
|
||||
---
|
||||
Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer
|
||||
6
.ideai/tickets/71/carnet.md
Normal file
6
.ideai/tickets/71/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#71"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784210443885
|
||||
---
|
||||
39
.ideai/tickets/71/issue.md
Normal file
39
.ideai/tickets/71/issue.md
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
id: "4d78a0e9-b54c-4db6-92e5-1461c17d7c84"
|
||||
number: 71
|
||||
title: "Diagnostic d'accessibilité du serveur : dire pourquoi ça ne marche pas, pas seulement que ça tourne"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#68","kind":"relatesTo"},{"target":"#65","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784210443885
|
||||
updatedAt: 1784210443885
|
||||
version: 1
|
||||
---
|
||||
Issu d'une session de debug live réelle (2026-07-16) où un serveur `idea-serve` CORRECTEMENT configuré est resté injoignable, sans que rien dans le produit n'indique pourquoi. Le serveur tournait, se croyait bon, affichait « listening on … ». Deux boucles de debug successives ont été nécessaires, dont aucune n'était diagnosticable depuis le produit.
|
||||
|
||||
**Problème de fond : l'échec est invisible.** Un serveur peut être parfaitement configuré et totalement inatteignable ; aujourd'hui la seule façon de le savoir est de sortir du produit (curl, ss, ufw, dig).
|
||||
|
||||
Les trois échecs réels rencontrés, tous silencieux :
|
||||
|
||||
1. **Bind loopback + reverse proxy sur une autre machine** → le proxy ouvre une connexion vers *sa propre* loopback, la requête n'arrive jamais. Aucun message, juste un timeout côté navigateur. Aggravé par `docs/server-client-mode-remote.md`, dont l'exemple du cas distant utilise `--listen 127.0.0.1:17373` en supposant sans le dire que le proxy est co-localisé (correction doc traitée à part).
|
||||
2. **Pare-feu hôte (ufw actif, port non ouvert)** → dissymétrie caractéristique : joignable depuis la machine locale, timeout depuis toute autre machine. Rien ne le signale.
|
||||
3. **Origine non conforme → 403 muet** → `--public-origin` compare en ÉGALITÉ STRICTE. Ouvrir l'UI par l'IP (`http://192.168.1.75:17373`) au lieu du nom de domaine donne un 403 sur toute l'API, sans explication dans l'UI. C'est le piège n°1 pour un nouvel utilisateur.
|
||||
|
||||
**Périmètre proposé (à cadrer par Architect).**
|
||||
|
||||
**Lot 1 — Surfacer les origines rejetées. Meilleur rapport valeur/coût : l'événement EXISTE déjà.** `SecurityLogEvent::OriginRejected { origin, route }` dans `crates/web-server/src/lib.rs` est aujourd'hui simplement `eprintln!` sur stderr — donc invisible en desktop, et invisible en Docker sans aller lire les logs. L'exposer (DTO + surface UI) transforme le mystère en diagnostic d'une ligne : « un client s'est connecté depuis `http://192.168.1.75:17373`, or l'origine autorisée est `https://idea.anthonybouteiller.ovh` ». Attention : c'est un log de sécurité, borner la rétention et ne pas en faire un canal de fuite d'information.
|
||||
|
||||
**Lot 2 — Dire quand le serveur est JOIGNABLE, pas seulement qu'il tourne.** Le serveur ne peut pas se tester depuis l'extérieur, mais il peut détecter ce qui rend l'échec certain :
|
||||
- pare-feu hôte actif dont le port d'écoute n'est pas autorisé (ufw/firewalld) ;
|
||||
- `public_origin` dont le DNS ne résout vers aucune interface locale ⇒ indice fort que le proxy est ailleurs, donc qu'un bind loopback ne peut pas marcher ;
|
||||
- afficher l'upstream exact à coller dans la config du proxy (`http://192.168.1.75:17373`) plutôt que de le laisser deviner.
|
||||
|
||||
Ces détections sont des heuristiques : elles doivent INFORMER, jamais bloquer le démarrage.
|
||||
|
||||
**Hors périmètre :** le choix du mode par intention (« proxy sur cette machine » vs « sur une autre machine ») appartient à F2 de #68 — c'est le panneau desktop, ça ne change aucun contrat backend.
|
||||
|
||||
**Attention frontière :** les lots ci-dessus doivent servir AUSSI le mode headless/Docker (`idea-serve` sans desktop), pas seulement le panneau desktop. Le diagnostic ne doit pas naître prisonnier de l'UI Tauri.
|
||||
55
.ideai/tickets/72/carnet.md
Normal file
55
.ideai/tickets/72/carnet.md
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
issueRef: "#72"
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784236365445
|
||||
---
|
||||
# Ticket #72 — Confiance reverse proxy : carnet de chantier
|
||||
|
||||
Statut : **livré et mergé dans `develop` (`7fbaa8e`)** le 2026-07-16.
|
||||
Commits : `75d1ce8` (code), `42804fc` (doc), `ae01297` (merge `--no-ff`). Branche supprimée.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
- `trusted_proxies` dans `ServerConfig` + flag CLI `--trusted-proxy` (répétable, IP ou CIDR).
|
||||
- Refus **au démarrage** d'un bind non-loopback distant sans proxy autorisé.
|
||||
- Guard runtime sur les **trois** surfaces — statique, `/api/*`, WebSocket : le `peer_addr` est vérifié **avant** toute lecture de header.
|
||||
- `X-Forwarded-Proto: https` obligatoire en mode proxy.
|
||||
- `X-Forwarded-For` n'autorise **jamais** rien — journalisé uniquement.
|
||||
- **B0** : réconciliation du port effectif dans `run_server` (le chemin CLI standalone souffrait du même bug que l'embedded : `origin_allowed` comparait à `http://127.0.0.1:0`).
|
||||
- Diagnostics : `UntrustedProxyPeer`, `ForwardedProtoRejected`, `ForwardedHostMismatch`.
|
||||
|
||||
## Ajustement en cours de route — le hard reject `X-Forwarded-Host` a été RETIRÉ
|
||||
|
||||
Cadré initialement comme un refus, il est devenu un **warning de diagnostic**. Raison (Architect) : redondant avec la vérification d'`Origin` en égalité stricte déjà appliquée à toutes les routes `/api/*`, et il **cassait la configuration par défaut de nginx**, qui n'envoie pas cet en-tête. Le vrai contrôle d'accès reste pairing/session + pair proxy autorisé. Gain trop faible pour la friction créée.
|
||||
|
||||
Le repli de lecture accepte `X-Forwarded-Host`, `Forwarded host=` **ou** `Host`.
|
||||
|
||||
## Vérification réelle (Main, hors sandbox)
|
||||
|
||||
`cargo test -p web-server` → **66 passed / 0 failed**. Re-vérifié sur `develop` après merge : 66 · 41 (backend) · 35 (app-tauri), 0 échec.
|
||||
|
||||
**Comportement exercé sur un serveur réellement lancé**, pas seulement en test :
|
||||
- proxy autorisé + `X-Forwarded-Proto: https` + **host absent** (= nginx par défaut) → **passe** le guard, `forwardedHostMismatch` en diagnostic.
|
||||
- host **différent** → passe également, warning seulement.
|
||||
- `X-Forwarded-Proto` absent → **403**, message : « Configure the reverse proxy to send it; for nginx add: proxy_set_header X-Forwarded-Proto $scheme; ».
|
||||
|
||||
## ⚠️ Piège d'environnement — un faux vert s'est produit ici, deux fois
|
||||
|
||||
**DevBackend et QA sont tous deux bloqués par EPERM sur `TcpListener::bind`.** Un `cargo test -p web-server` sandboxé rend un vert qui ne prouve rien : sur #68 B1, un « 57 passed » sandboxé a masqué un **vrai bug produit** (port éphémère vs `origin_allowed`). Tout vert sur ce crate doit être produit **hors sandbox**. C'est consigné dans les messages de commit, pas seulement ici.
|
||||
|
||||
## Revue de sécurité (Git, au-delà du vert)
|
||||
|
||||
Guard câblé sur les trois surfaces, aucune route qui y échappe. Le chemin de production propage le vrai pair (`handle_tcp_connection` → `Some(peer_addr.ip())`). Le repli `peer_ip.unwrap_or(config.listen.ip())` n'est atteignable que depuis des appelants `#[cfg(test)]`. CIDR correct, y compris `prefix == 0` (qui aurait débordé au shift sans traitement à part).
|
||||
|
||||
## Changement de comportement ASSUMÉ
|
||||
|
||||
Le durcissement **casse volontairement les configurations existantes** : un bind non-loopback distant sans `--trusted-proxy` est désormais refusé au démarrage, avec un message qui dit quoi faire. Consigné dans l'historique git.
|
||||
|
||||
## Origine du ticket
|
||||
|
||||
Trouvé par **Git**, pas par une revue d'architecture : c'est lui qui a établi que `--trust-reverse-proxy` n'avait aucun effet runtime — il n'était lu que pour exiger sa propre présence. La trilogie déclarait une topologie sécurisée sans jamais la garantir.
|
||||
|
||||
## Point ouvert, remonté par Git — à arbitrer par l'utilisateur
|
||||
|
||||
**#73 (TLS intégré) rendrait une partie de #72 obsolète.** Si `idea-serve` termine lui-même TLS, la cérémonie proxy — `--trusted-proxy`, le guard, ces diagnostics — ne concerne plus que les déploiements qui gardent un proxy devant. Ce n'est pas du travail perdu (#72 corrige un vrai trou aujourd'hui, et B0 était un bug réel), mais l'ordre des deux mérite un regard.
|
||||
53
.ideai/tickets/72/issue.md
Normal file
53
.ideai/tickets/72/issue.md
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
id: "0fba0a7f-8f04-460e-b81a-93790e10b066"
|
||||
number: 72
|
||||
title: "Sécurité : donner un effet réel à la confiance reverse proxy (--trust-reverse-proxy est un drapeau creux)"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#68","kind":"blocks"},{"target":"#71","kind":"relatesTo"},{"target":"#65","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784218596269
|
||||
updatedAt: 1784236365445
|
||||
version: 3
|
||||
---
|
||||
**Vulnérabilité de conception existante, dans le mode CLI d'aujourd'hui — indépendante de #68.** Trouvée par Git pendant la mise en place de #68, confirmée et cadrée par Architect.
|
||||
|
||||
**Le constat.** `--trust-reverse-proxy` n'a **aucun effet à l'exécution**. Vérifié dans `crates/web-server/src/lib.rs` : le champ n'est lu qu'en ligne 192, dans `ServerConfig::validate()`, **pour exiger sa propre présence** quand `allow_remote` est vrai. Le serveur ne parse jamais `X-Forwarded-*`. C'est un drapeau d'intention pur.
|
||||
|
||||
**Le problème.** `validate()` valide une configuration, pas la réalité : rien ne vérifie qu'un proxy est réellement devant le serveur. **Binder `0.0.0.0` avec les trois drapeaux, sans aucun proxy, satisfait `validate()` et sert du HTTP en clair au monde entier.** La trilogie `allow_remote` + origine HTTPS + `trust_reverse_proxy` DÉCLARE une topologie sécurisée, elle ne la GARANTIT pas.
|
||||
|
||||
Verdict Architect : « la trilogie actuelle n'est pas un invariant de sécurité, c'est seulement une déclaration d'intention ». Aggravé par #68 : en CLI, taper trois drapeaux est un acte délibéré d'opérateur ; dans un panneau desktop, cocher un bouton ne l'est pas. Le drapeau donnerait un faux sentiment de protection en un clic.
|
||||
|
||||
**Invariant runtime corrigé (Architect).**
|
||||
- `allow_remote=true` signifie : mode public accepté UNIQUEMENT derrière un proxy HTTPS déclaré.
|
||||
- Les headers `X-Forwarded-*` ne sont fiables QUE si le `peer_addr` réseau est un proxy autorisé — vérifier le pair AVANT de leur faire confiance.
|
||||
- `X-Forwarded-Proto: https` exigé en mode remote.
|
||||
- `X-Forwarded-Host` (ou équivalent) doit correspondre à `public_origin`.
|
||||
- **`X-Forwarded-For` ne doit JAMAIS servir à autoriser une requête** — spoofable, log uniquement.
|
||||
- Proxy sur cette machine : bind loopback, peer forcément loopback, `trust_reverse_proxy` reste acceptable.
|
||||
- Proxy sur une autre machine : bind LAN autorisé SEULEMENT avec un proxy explicite (`--trusted-proxy 192.168.1.10` ou CIDR). Toute requête dont le `peer_addr` est hors allowlist est refusée, même porteuse de `X-Forwarded-Proto: https`.
|
||||
|
||||
Conséquence produit : le mode desktop « proxy autre machine » **ne peut pas être un clic magique**. Il doit demander l'IP/CIDR du proxy autorisé — ce n'est pas une « listen address » mais un contrôle de sécurité compréhensible : « quelle machine a le droit de parler au serveur IdeA ? ».
|
||||
|
||||
**Lots (Architect).**
|
||||
- **B1 config** : `trusted_proxies: Vec<IpNet/Cidr>` dans `ServerConfig`, flag CLI `--trusted-proxy`, DTO desktop équivalent.
|
||||
- **B2 runtime guard** : en tête du handling HTTP/WS, vérifier `peer_addr`, `X-Forwarded-Proto=https`, host public conforme.
|
||||
- **B3 diagnostics** : événements `untrustedProxyPeer`, `forwardedProtoRejected`, `forwardedHostRejected` (à croiser avec #71).
|
||||
- **B4 docs** : exemples séparés proxy local vs proxy autre machine.
|
||||
|
||||
**Points de vérité QA.**
|
||||
- `0.0.0.0 + allow_remote + public_origin + trust_reverse_proxy` sans trusted proxy → REFUSÉ.
|
||||
- peer non autorisé vers bind LAN → rejeté.
|
||||
- peer autorisé mais `X-Forwarded-Proto=http` → rejeté.
|
||||
- peer autorisé + https + host conforme → passe.
|
||||
- origine API non conforme → toujours 403.
|
||||
- aucun test ne fait confiance à `X-Forwarded-For`.
|
||||
|
||||
**Compat / migration.** Garder `--trust-reverse-proxy` pour la CLI, mais le déclasser en option de mode : il ne suffit plus pour un bind non-loopback. Chemin de migration à choisir (refus au démarrage vs refus runtime).
|
||||
|
||||
⚠️ **Impact utilisateur réel connu** : l'installation actuelle de l'utilisateur (bind `192.168.1.75:17373` + trilogie, proxy sur une autre machine, exposé via `https://idea.anthonybouteiller.ovh`) sera **refusée** par la validation corrigée tant qu'elle ne déclare pas `--trusted-proxy`. Le chemin de migration doit être explicite et le message d'erreur doit dire quoi faire, pas seulement refuser.
|
||||
|
||||
**Ordre imposé par Architect** : #68 core (B1 shared-core) peut continuer, il n'est pas exposé utilisateur. Mais **#72 doit être mergé dans `develop` AVANT `feature/ticket68-embedded-server-panel`** — pas de panneau remote avant ce durcissement.
|
||||
6
.ideai/tickets/73/carnet.md
Normal file
6
.ideai/tickets/73/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#73"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784223044517
|
||||
---
|
||||
46
.ideai/tickets/73/issue.md
Normal file
46
.ideai/tickets/73/issue.md
Normal file
@ -0,0 +1,46 @@
|
||||
---
|
||||
id: "a43a07d0-573f-41e9-9b7b-2f88beb4e660"
|
||||
number: 73
|
||||
title: "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy"
|
||||
status: "open"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#72","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"},{"target":"#71","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784223044517
|
||||
updatedAt: 1784223044517
|
||||
version: 1
|
||||
---
|
||||
**Décision utilisateur (2026-07-16) : IdeA gagne le TLS intégré.** Contrainte posée : prendre la technologie la plus légère.
|
||||
|
||||
**La cause racine, établie par une session de debug réelle.** Un utilisateur a mis une matinée à exposer `idea-serve` derrière son nginx. Cinq obstacles, découverts un par un en tapant dans le mur : bind loopback injoignable par un proxy distant (timeout muet), ufw (timeout muet), `--trusted-proxy` obligatoire (#72), `X-Forwarded-Proto` absent (403), `X-Forwarded-Host` absent (403).
|
||||
|
||||
**Ces cinq obstacles ont UNE cause : le serveur ne sait pas chiffrer.** Ne pouvant pas terminer le TLS, il doit *croire quelqu'un d'autre sur parole* quand on lui dit que la connexion publique était en HTTPS. `--trusted-proxy` sert à savoir qui a le droit de lui mentir ; `X-Forwarded-Proto` est le mensonge lui-même. Toute la machinerie de #72 n'existe **que** parce qu'il manque cette capacité. #66 avait acté l'exclusion noir sur blanc (« Pas de TLS applicatif V1 », « Hors périmètre V1 : TLS intégré ») — c'est cette ligne qui a produit les cinq obstacles.
|
||||
|
||||
**Ce que le TLS intégré change.** L'utilisateur ouvre le 443 de son routeur, donne son domaine, terminé : pas de `--trusted-proxy`, pas de `X-Forwarded-*`, pas de proxy du tout. Un reverse proxy devant reste possible, mais devient un **choix**, plus une obligation structurelle. Une bonne partie de #72 se simplifie mécaniquement — sans disparaître : le mode « derrière proxy » reste supporté et garde ses contrôles.
|
||||
|
||||
**Constat technique qui répond à la contrainte de légèreté (vérifié dans `Cargo.lock`) :**
|
||||
- `rustls` 0.23, `tokio-rustls`, `ring`, `webpki-roots`, `hyper-rustls` sont **DÉJÀ** dans l'arbre de dépendances.
|
||||
- **Aucun** `openssl`, `openssl-sys` ni `native-tls`.
|
||||
- Le coût marginal d'un TLS serveur est donc quasi nul : la brique est déjà vendue avec le projet. Pas de nouvelle chaîne de dépendances, pas de dépendance système (contrairement à OpenSSL).
|
||||
|
||||
**Pistes ACME à arbitrer par Architect :**
|
||||
- `rustls-acme` — bâti sur rustls/tokio, fournit le resolver de certificats et le renouvellement automatique. Plus intégré, moins de code à écrire.
|
||||
- `instant-acme` — plus bas niveau, pure Rust, minimal ; le challenge et le stockage restent à notre charge.
|
||||
- **Argument fort pour le challenge TLS-ALPN-01 : il ne nécessite QUE le port 443.** Pas de port 80, pas de web root séparé pour HTTP-01. Cohérent avec « l'utilisateur ouvre un port ».
|
||||
|
||||
**Limites à assumer et à documenter (ne pas vendre du rêve) :**
|
||||
- ACME exige un **domaine public** et un port joignable depuis l'extérieur. Couvre l'exposition Internet, **pas** le LAN pur sans domaine.
|
||||
- LAN pur ⇒ certificat auto-signé ⇒ avertissement navigateur. À traiter comme un mode distinct, pas à masquer.
|
||||
- IdeA devient **responsable des certificats** : stockage, permissions, renouvellement, gestion des rate limits Let's Encrypt (utiliser l'environnement de staging en test, sous peine de blocage).
|
||||
- Ne jamais mettre de certificat/clé dans un dossier synchronisé ou versionné.
|
||||
|
||||
**Interactions à cadrer :**
|
||||
- **#72** : le mode « TLS direct » ne doit exiger ni `--trusted-proxy` ni `X-Forwarded-*` — le serveur SAIT que la connexion est HTTPS puisqu'il la termine. Le mode « derrière proxy » garde ses contrôles.
|
||||
- **#66 (Docker)** : son défaut `--listen 0.0.0.0:17373` + distant est **déjà invalide** depuis #72 (refusé au démarrage sans `--trusted-proxy`). Le TLS intégré rouvre la question de l'image : proxy embarqué ou TLS applicatif ?
|
||||
- **#68** : les modes d'exposition du panneau desktop gagnent un mode « TLS direct », et le champ « IP du proxy autorisé » disparaît dans ce mode.
|
||||
- **#71** : les diagnostics doivent couvrir les échecs ACME (challenge, DNS, port 443 injoignable), qui seront la nouvelle classe d'échec silencieux.
|
||||
|
||||
**Exigence produit transverse, issue du même incident :** l'échec ne doit jamais être muet. Un certificat qui ne s'obtient pas doit le dire, avec la raison et l'action. C'est exactement le défaut qu'on corrige partout ailleurs dans ce sprint (#71).
|
||||
6
.ideai/tickets/74/carnet.md
Normal file
6
.ideai/tickets/74/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#74"
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784277308704
|
||||
---
|
||||
67
.ideai/tickets/74/issue.md
Normal file
67
.ideai/tickets/74/issue.md
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f"
|
||||
number: 74
|
||||
title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#68","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#13","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784271086298
|
||||
updatedAt: 1784277308704
|
||||
version: 3
|
||||
---
|
||||
## Symptôme (constaté live, exposition derrière reverse proxy)
|
||||
|
||||
UI chargée dans le navigateur via `https://idea.anthonybouteiller.ovh` (nginx → 192.168.1.75:17373, serveur embarqué desktop, mode `remoteProxyOtherMachine`) :
|
||||
|
||||
- `Error: can't access property "invoke", window.__TAURI_INTERNALS__ is undefined` (x2)
|
||||
- Aucun projet affiché.
|
||||
|
||||
## Cause racine (confirmée)
|
||||
|
||||
`crates/app-tauri/tauri.conf.json` utilise **un seul** `frontend/dist` pour deux besoins incompatibles :
|
||||
|
||||
- `build.frontendDist: "../../frontend/dist"` + `beforeBuildCommand: "npm --prefix ../frontend run build"` → `vite build` **sans** `VITE_TRANSPORT` → bundle **desktop** (transport `tauri`).
|
||||
- `bundle.resources: { "../../frontend/dist": "web" }` → ce **même** bundle desktop est packagé en ressource `web/`.
|
||||
|
||||
Or `resolve_web_root()` (`crates/app-tauri/src/embedded_server.rs:498-517`) sert `resource_dir.join("web")`. Le serveur embarqué sert donc le bundle desktop au navigateur.
|
||||
|
||||
Le transport est figé **au build** par Vite : `resolveTransport()` (`frontend/src/app/di.tsx:39-46`) lit `import.meta.env.VITE_TRANSPORT`, constant-folded à la compilation.
|
||||
|
||||
**Preuve** dans `frontend/dist/assets/index-nl-2Eu6U.js` (build du 2026-07-17 08:00) :
|
||||
|
||||
```js
|
||||
function Cb(){return"tauri"} // resolveTransport() figé sur tauri
|
||||
```
|
||||
|
||||
Les deux jeux d'adapters sont bien présents dans le bundle, mais le sélecteur ne pointera jamais sur `createHttpWsGateways()`.
|
||||
|
||||
## Attendu
|
||||
|
||||
L'AppImage doit packager en ressource `web/` un bundle construit avec `VITE_TRANSPORT=http`, distinct du `frontendDist` desktop. Un seul `dist` ne peut pas servir les deux transports.
|
||||
|
||||
## Contexte
|
||||
|
||||
- `docs/server-client-mode-remote.md` documente déjà que le build web **doit** être produit en `VITE_TRANSPORT=http`, sinon exactement ce symptôme.
|
||||
- Le carnet de #13 avait classé ce symptôme en « erreur de commande de test, pas un bug de code » — vrai à l'époque de `idea --serve --web-root`, mais le packaging AppImage de #66/#68 réintroduit le problème par défaut.
|
||||
- #66 L4 anticipait le besoin (« produire les assets client Vite en `VITE_TRANSPORT=http` … packager dans `/usr/share/idea/web` ») ; la conf actuelle ne le fait pas.
|
||||
- npm, jamais pnpm (mémoire `frontend-uses-npm-not-pnpm`).
|
||||
|
||||
## Workaround live
|
||||
|
||||
Build séparé + `IDEA_WEB_ROOT` (premier candidat de `resolve_web_root`, court-circuite la ressource packagée) :
|
||||
|
||||
```bash
|
||||
cd frontend && VITE_TRANSPORT=http npx vite build --outDir dist-web --emptyOutDir
|
||||
IDEA_WEB_ROOT=…/frontend/dist-web ./IdeA.AppImage
|
||||
```
|
||||
|
||||
## DoD
|
||||
|
||||
- Build AppImage produit une ressource `web/` en transport http, `frontendDist` desktop inchangé.
|
||||
- Vérif automatisable : le bundle servi ne doit pas résoudre le transport sur `tauri`.
|
||||
- Validation live navigateur derrière reverse proxy : UI + projets listés, aucune erreur `__TAURI_INTERNALS__`.
|
||||
- Rebuild AppImage livrable pour test utilisateur.
|
||||
6
.ideai/tickets/75/carnet.md
Normal file
6
.ideai/tickets/75/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#75"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784277530792
|
||||
---
|
||||
48
.ideai/tickets/75/issue.md
Normal file
48
.ideai/tickets/75/issue.md
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
id: "d54a1f20-03e6-4925-982b-923a37331749"
|
||||
number: 75
|
||||
title: "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#69","kind":"relatesTo"},{"target":"#74","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784277353320
|
||||
updatedAt: 1784277530792
|
||||
version: 2
|
||||
---
|
||||
## Symptôme (constaté live, téléphone)
|
||||
|
||||
Sur l'écran d'appairage web (`PairingScreen`), le clavier virtuel qui s'ouvre est un pavé **numérique uniquement**, alors que le code d'appairage à saisir contient des **lettres**. Impossible de saisir le code sans changer de clavier à la main.
|
||||
|
||||
## Cause racine (confirmée)
|
||||
|
||||
Le code généré côté serveur est **hexadécimal, 8 caractères, majuscules** (`0-9A-F`) :
|
||||
|
||||
```rust
|
||||
// crates/web-server/src/lib.rs:2220
|
||||
fn new_pairing_code() -> String {
|
||||
Uuid::new_v4().simple().to_string().chars().take(8)
|
||||
.collect::<String>().to_ascii_uppercase()
|
||||
}
|
||||
```
|
||||
|
||||
Or `frontend/src/features/web/PairingScreen.tsx:79` force `inputMode="numeric"` (introduit par #69 en supposant un code purement chiffré), et le placeholder `p. ex. 4821-93` (ligne 74) décrit un format qui n'existe pas.
|
||||
|
||||
## Attendu
|
||||
|
||||
- Le clavier mobile permet de saisir lettres **et** chiffres.
|
||||
- Le placeholder reflète le format réel (hex 8 caractères majuscules).
|
||||
- La saisie reste sans autocorrection ni capitalisation surprise ; le code étant en majuscules, la casse ne doit pas être un piège pour l'utilisateur.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Frontend pur. Le format du code côté serveur n'est pas remis en cause par ce ticket.
|
||||
|
||||
## DoD
|
||||
|
||||
- Saisie possible au clavier mobile standard, lettres incluses.
|
||||
- Placeholder cohérent avec `new_pairing_code()`.
|
||||
- Test de non-régression sur le champ.
|
||||
6
.ideai/tickets/76/carnet.md
Normal file
6
.ideai/tickets/76/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#76"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784277540034
|
||||
---
|
||||
40
.ideai/tickets/76/issue.md
Normal file
40
.ideai/tickets/76/issue.md
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
id: "c91f547f-96f0-4c9c-9fde-dbe7d2772dbc"
|
||||
number: 76
|
||||
title: "Appairage : la comparaison du code est sensible à la casse côté serveur"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: null
|
||||
links: [{"target":"#75","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784277540034
|
||||
updatedAt: 1784277540034
|
||||
version: 1
|
||||
---
|
||||
## Constat
|
||||
|
||||
`crates/web-server/src/lib.rs:1612` compare le code d'appairage reçu au code attendu par égalité stricte :
|
||||
|
||||
```rust
|
||||
if request.code != state.pairing_code() { … }
|
||||
```
|
||||
|
||||
Or `new_pairing_code()` (ligne 2220) génère le code en majuscules. Un client qui envoie `3f7a9c21` au lieu de `3F7A9C21` est donc rejeté.
|
||||
|
||||
## Pourquoi c'est une dette et pas un bug bloquant
|
||||
|
||||
Le fix #75 rend l'écran web tolérant en normalisant la saisie en majuscules avant `POST /api/pair`. La tolérance vit donc **côté client** : tout autre client de l'API (curl, un futur client mobile natif, un test manuel) échouera encore sur une saisie minuscule, avec un message d'erreur qui n'explique pas pourquoi.
|
||||
|
||||
## Attendu
|
||||
|
||||
La comparaison serveur normalise la casse (et les espaces) avant égalité. La normalisation côté UI de #75 devient alors redondante mais inoffensive — pas besoin de la retirer.
|
||||
|
||||
## Vigilance
|
||||
|
||||
La comparaison doit rester en temps constant si elle l'est aujourd'hui : normaliser ne doit pas introduire de court-circuit exploitable en timing. À vérifier lors de l'implémentation.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Backend pur, `crates/web-server`.
|
||||
153
.ideai/tickets/77/carnet.md
Normal file
153
.ideai/tickets/77/carnet.md
Normal file
@ -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<ConnectionHandle>`. `AuthenticateSession` retourne `AuthenticatedDevice { device_id, token_hash }` — **pas un booléen** — pour que `run_ws_connection` s'enregistre sous le bon `device_id`.
|
||||
|
||||
Séquence : le use case supprime du store → publie l'événement → l'adapter observe → ferme les WS concernés via un canal `shutdown` que la boucle sélectionne en parallèle de `read_ws_frame`, puis unregister `ws_pty_bridge` comme aujourd'hui. `OutputBridge` reste un outil de flux PTY, **jamais le mécanisme d'autorisation**.
|
||||
|
||||
### Rate-limit
|
||||
|
||||
Port `PairAttemptLimiter` dans l'application, appelé par `PairDevice` **avant** validation du code. Adapter prod `InMemoryPairAttemptLimiter`, adapter test à horloge fixe. Clé `RateLimitKey { origin, route }` — **pas de headers HTTP dans le port**, la résolution proxy reste dans l'adapter. Repères : 5 échecs/min par origine, 30 échecs/min global.
|
||||
|
||||
### 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.
|
||||
77
.ideai/tickets/77/issue.md
Normal file
77
.ideai/tickets/77/issue.md
Normal file
@ -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<HashSet<String>>`, 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.
|
||||
6
.ideai/tickets/78/carnet.md
Normal file
6
.ideai/tickets/78/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#78"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784284746767
|
||||
---
|
||||
34
.ideai/tickets/78/issue.md
Normal file
34
.ideai/tickets/78/issue.md
Normal file
@ -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 ».
|
||||
6
.ideai/tickets/79/carnet.md
Normal file
6
.ideai/tickets/79/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#79"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784287450082
|
||||
---
|
||||
47
.ideai/tickets/79/issue.md
Normal file
47
.ideai/tickets/79/issue.md
Normal file
@ -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.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 68
|
||||
"nextNumber": 80
|
||||
}
|
||||
@ -149,13 +149,13 @@
|
||||
"issueRef": "#13",
|
||||
"path": "13",
|
||||
"title": "Server/client mode",
|
||||
"status": "inProgress",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1784184002362
|
||||
"updatedAt": 1784193634690
|
||||
},
|
||||
{
|
||||
"issueRef": "#14",
|
||||
@ -627,9 +627,9 @@
|
||||
"title": "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)",
|
||||
"status": "inProgress",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784095176619
|
||||
"updatedAt": 1784194089605
|
||||
},
|
||||
{
|
||||
"issueRef": "#61",
|
||||
@ -669,19 +669,19 @@
|
||||
"title": "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784187610934
|
||||
"updatedAt": 1784193766706
|
||||
},
|
||||
{
|
||||
"issueRef": "#65",
|
||||
"path": "65",
|
||||
"title": "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784187583229
|
||||
"updatedAt": 1784210420516
|
||||
},
|
||||
{
|
||||
"issueRef": "#66",
|
||||
@ -689,9 +689,9 @@
|
||||
"title": "Image Docker serveur/client IdeA",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784187597004
|
||||
"updatedAt": 1784193460805
|
||||
},
|
||||
{
|
||||
"issueRef": "#67",
|
||||
@ -699,9 +699,135 @@
|
||||
"title": "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784193467784
|
||||
},
|
||||
{
|
||||
"issueRef": "#68",
|
||||
"path": "68",
|
||||
"title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1784277313706
|
||||
},
|
||||
{
|
||||
"issueRef": "#69",
|
||||
"path": "69",
|
||||
"title": "Adaptibilité client téléphone",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1784207736267
|
||||
},
|
||||
{
|
||||
"issueRef": "#70",
|
||||
"path": "70",
|
||||
"title": "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1784194049447
|
||||
},
|
||||
{
|
||||
"issueRef": "#71",
|
||||
"path": "71",
|
||||
"title": "Diagnostic d'accessibilité du serveur : dire pourquoi ça ne marche pas, pas seulement que ça tourne",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784187618710
|
||||
"updatedAt": 1784210443885
|
||||
},
|
||||
{
|
||||
"issueRef": "#72",
|
||||
"path": "72",
|
||||
"title": "Sécurité : donner un effet réel à la confiance reverse proxy (--trust-reverse-proxy est un drapeau creux)",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784236365445
|
||||
},
|
||||
{
|
||||
"issueRef": "#73",
|
||||
"path": "73",
|
||||
"title": "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784223044517
|
||||
},
|
||||
{
|
||||
"issueRef": "#74",
|
||||
"path": "74",
|
||||
"title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784277308704
|
||||
},
|
||||
{
|
||||
"issueRef": "#75",
|
||||
"path": "75",
|
||||
"title": "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784277530792
|
||||
},
|
||||
{
|
||||
"issueRef": "#76",
|
||||
"path": "76",
|
||||
"title": "Appairage : la comparaison du code est sensible à la casse côté serveur",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
26
CLAUDE.md
26
CLAUDE.md
@ -30,14 +30,17 @@ pas d'outil de remise de résultat.
|
||||
|
||||
Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet.
|
||||
|
||||
**La liste des rôles ci-dessous fait foi, mais elle n'est pas la source de vérité des agents réellement déclarés.** Avant de conclure qu'un rôle n'existe pas, appeler `idea_list_agents` : le manifeste du projet fait autorité. Un rôle absent de ce document n'est pas un rôle absent du projet.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rôles
|
||||
|
||||
- **Main** : chef d'orchestre. Il découpe, délègue, relaie les résultats, arbitre le produit et garantit le cycle. Il ne code pas les features.
|
||||
- **Architect** : propriétaire de l'architecture hexagonale, SOLID, ports/adapters, contrats, DTO, invariants et cartographie.
|
||||
- **UX** : propriétaire de la conception UI/UX — surfaces, navigation, libellés, hiérarchie de l'information, formulation des erreurs, parcours utilisateur. Décide de la forme ; Architect décide des frontières techniques.
|
||||
- **DevBackend** : implémentation backend Rust selon les contrats validés par Architect.
|
||||
- **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect.
|
||||
- **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect et la conception validée par UX.
|
||||
- **QA** : tests unitaires/intégration ciblés, exécution réelle, rapports d'échec, re-test jusqu'au vert.
|
||||
- **Git** : propriétaire des branches, commits, merges/rebases locaux. Aucune action sortante sans validation explicite.
|
||||
|
||||
@ -48,16 +51,21 @@ Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans c
|
||||
Pour toute feature ou correction applicative :
|
||||
|
||||
```text
|
||||
1. Architect cadre ou valide l'architecture et les contrats.
|
||||
2. Git décide de la branche locale.
|
||||
3. DevBackend et/ou DevFrontend implémente.
|
||||
4. QA écrit/exécute les tests.
|
||||
5. Si KO : Main relaie le rapport réel au dev, puis retour QA.
|
||||
6. Si OK : Git committe et décide du merge local éventuel.
|
||||
1. UX conçoit la surface, dès qu'il y a de l'UI (voir ci-dessous).
|
||||
2. Architect cadre ou valide l'architecture et les contrats.
|
||||
3. Git décide de la branche locale.
|
||||
4. DevBackend et/ou DevFrontend implémente.
|
||||
5. QA écrit/exécute les tests.
|
||||
6. Si KO : Main relaie le rapport réel au dev, puis retour QA.
|
||||
7. Si OK : Git committe et décide du merge local éventuel.
|
||||
```
|
||||
|
||||
Une feature n'est terminée que lorsque les tests pertinents sont verts avec sortie réelle.
|
||||
|
||||
**Quand solliciter UX — la règle.** Dès qu'une demande touche ce que l'utilisateur voit, lit ou manipule : nouvelle surface, nouvel écran, menu, libellé, message d'erreur, parcours, responsive. Ne pas dessiner l'UI à sa place pour « gagner du temps » ni la laisser tomber dans les mains d'un dev par défaut. UX passe **avant** Architect quand la forme conditionne les contrats (ce que l'UI doit exposer détermine les DTO), et **avec** lui quand une contrainte technique borne la conception.
|
||||
|
||||
UX ne bloque pas les tickets sans surface utilisateur (lots purement backend, seams, refactors internes).
|
||||
|
||||
---
|
||||
|
||||
## 5. Produit : repères communs
|
||||
@ -74,7 +82,7 @@ Repères stables :
|
||||
- Git, SSH et WSL intégrés.
|
||||
- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`.
|
||||
|
||||
Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA.
|
||||
Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. La conception des surfaces appartient au contexte d'UX. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA.
|
||||
|
||||
---
|
||||
|
||||
@ -93,4 +101,4 @@ Consulter la mémoire projet selon le besoin au lieu de recopier tous les détai
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-06-20*
|
||||
*Dernière mise à jour : 2026-07-16*
|
||||
27
Cargo.lock
generated
27
Cargo.lock
generated
@ -90,6 +90,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"web-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -100,6 +101,7 @@ dependencies = [
|
||||
"domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@ -904,8 +906,11 @@ name = "domain"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@ -5236,6 +5241,28 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-server"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"backend",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"domain",
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.99"
|
||||
|
||||
@ -5,6 +5,7 @@ members = [
|
||||
"crates/application",
|
||||
"crates/infrastructure",
|
||||
"crates/backend",
|
||||
"crates/web-server",
|
||||
"crates/app-tauri",
|
||||
]
|
||||
|
||||
@ -21,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.
|
||||
@ -31,6 +36,7 @@ domain = { path = "crates/domain" }
|
||||
application = { path = "crates/application" }
|
||||
infrastructure = { path = "crates/infrastructure" }
|
||||
backend = { path = "crates/backend" }
|
||||
web-server = { path = "crates/web-server" }
|
||||
|
||||
# Tauri v2
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
@ -24,6 +24,7 @@ domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
backend = { workspace = true }
|
||||
web-server = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
# `io-std` gives the headless `mcp-server` bridge access to
|
||||
|
||||
@ -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,
|
||||
@ -61,9 +61,13 @@ use crate::dto::{
|
||||
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
};
|
||||
use crate::embedded_server::{
|
||||
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
|
||||
};
|
||||
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).
|
||||
@ -83,6 +87,220 @@ pub fn health(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `get_server_exposure_settings` — read persisted embedded-server exposure settings.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when persisted settings are invalid.
|
||||
#[tauri::command]
|
||||
pub fn get_server_exposure_settings(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ServerExposureSettingsDto, ErrorDto> {
|
||||
state.embedded_server.get_settings()
|
||||
}
|
||||
|
||||
/// `save_server_exposure_settings` — validate and persist embedded-server exposure settings.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when settings are invalid or cannot be written.
|
||||
#[tauri::command]
|
||||
pub fn save_server_exposure_settings(
|
||||
settings: ServerExposureSettingsDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
state.embedded_server.save_settings(settings)
|
||||
}
|
||||
|
||||
/// `preview_server_exposure_settings` — derive LAN candidates and upstream URL.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when settings are invalid.
|
||||
#[tauri::command]
|
||||
pub fn preview_server_exposure_settings(
|
||||
settings: ServerExposureSettingsDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ServerExposurePreviewDto, ErrorDto> {
|
||||
state.embedded_server.preview(settings)
|
||||
}
|
||||
|
||||
/// `embedded_server_status` — return the current embedded server status.
|
||||
#[tauri::command]
|
||||
pub fn embedded_server_status(state: State<'_, AppState>) -> EmbeddedServerStatusDto {
|
||||
state.embedded_server.status()
|
||||
}
|
||||
|
||||
/// `embedded_server_start` — start the embedded server with the shared backend core.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when settings are invalid or the listener cannot start.
|
||||
#[tauri::command]
|
||||
pub async fn embedded_server_start(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
|
||||
state.embedded_server.start(state.core()).await
|
||||
}
|
||||
|
||||
/// `embedded_server_stop` — stop the embedded server.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when stopping the listener fails.
|
||||
#[tauri::command]
|
||||
pub async fn embedded_server_stop(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
|
||||
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<web_server::PairingCodeDto, ErrorDto> {
|
||||
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<DeviceDto>,
|
||||
}
|
||||
|
||||
/// 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<DeviceId, ErrorDto> {
|
||||
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<DeviceListDto, ErrorDto> {
|
||||
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<web_server::PairingCodeDto, ErrorDto> {
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
819
crates/app-tauri/src/embedded_server.rs
Normal file
819
crates/app-tauri/src/embedded_server.rs
Normal file
@ -0,0 +1,819 @@
|
||||
//! Desktop adapter for the embedded HTTP server lifecycle and exposure config.
|
||||
//!
|
||||
//! This module stays in the Tauri adapter layer: it owns persisted exposure
|
||||
//! settings, starts/stops the embedded web server, and never crosses into the
|
||||
//! domain/application layers.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use backend::BackendCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy};
|
||||
|
||||
use crate::dto::ErrorDto;
|
||||
|
||||
/// Remote exposure mode selected by the desktop settings UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ServerExposureMode {
|
||||
/// Bind only loopback and serve local HTTP.
|
||||
LocalOnly,
|
||||
/// Bind loopback and expect a same-machine HTTPS reverse proxy.
|
||||
RemoteProxyLocal,
|
||||
/// Bind a LAN address and expect a trusted reverse proxy on another host.
|
||||
RemoteProxyOtherMachine,
|
||||
}
|
||||
|
||||
/// Persisted server exposure settings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerExposureSettingsDto {
|
||||
/// Exposure mode.
|
||||
pub mode: ServerExposureMode,
|
||||
/// TCP port to bind. `0` asks the OS for an ephemeral port.
|
||||
pub port: u16,
|
||||
/// Public HTTPS origin used by reverse-proxy modes.
|
||||
pub public_origin: Option<String>,
|
||||
/// Authorized proxy peers as IPs or CIDR ranges.
|
||||
#[serde(default)]
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// LAN bind address for `remoteProxyOtherMachine`.
|
||||
pub lan_bind_address: Option<String>,
|
||||
}
|
||||
|
||||
/// Non-fatal diagnostic shown by the settings UI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticWarningDto {
|
||||
/// Stable warning code.
|
||||
pub code: String,
|
||||
/// Human-readable warning.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Derived exposure preview for the desktop UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerExposurePreviewDto {
|
||||
/// Candidate LAN addresses discovered by the backend.
|
||||
pub candidate_lan_addresses: Vec<String>,
|
||||
/// URL the reverse proxy should use as its upstream.
|
||||
pub upstream_url: Option<String>,
|
||||
/// Non-fatal diagnostics.
|
||||
pub warnings: Vec<DiagnosticWarningDto>,
|
||||
}
|
||||
|
||||
/// Current embedded server lifecycle state exposed to the desktop UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EmbeddedServerStatusStateDto {
|
||||
/// No embedded server is running.
|
||||
Stopped,
|
||||
/// Start is in progress.
|
||||
Starting,
|
||||
/// Server is running.
|
||||
Running,
|
||||
/// Stop is in progress.
|
||||
Stopping,
|
||||
/// Last start/stop failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Embedded server status exposed to the desktop UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EmbeddedServerStatusDto {
|
||||
/// Lifecycle state.
|
||||
pub state: EmbeddedServerStatusStateDto,
|
||||
/// Local URL, when running.
|
||||
pub local_url: Option<String>,
|
||||
/// Public URL, when configured.
|
||||
pub public_url: Option<String>,
|
||||
/// Reverse-proxy upstream URL derived from settings.
|
||||
pub upstream_url: Option<String>,
|
||||
/// Last failure, when state is `failed`.
|
||||
pub error: Option<ErrorDto>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EmbeddedServerInner {
|
||||
handle: Option<EmbeddedServerHandle>,
|
||||
state: EmbeddedServerStatusStateDto,
|
||||
upstream_url: Option<String>,
|
||||
public_url: Option<String>,
|
||||
error: Option<ErrorDto>,
|
||||
}
|
||||
|
||||
impl Default for EmbeddedServerStatusStateDto {
|
||||
fn default() -> Self {
|
||||
Self::Stopped
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-backed exposure settings store.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsServerExposureSettingsStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FsServerExposureSettingsStore {
|
||||
/// Creates a store at `<app-data-dir>/deployment/server-exposure.json`.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
path: app_data_dir.join("deployment").join("server-exposure.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads persisted settings, or returns defaults when no file exists.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when the file exists but is invalid.
|
||||
pub fn read(&self) -> Result<ServerExposureSettingsDto, ErrorDto> {
|
||||
if !self.path.exists() {
|
||||
return Ok(default_settings());
|
||||
}
|
||||
let bytes = std::fs::read(&self.path).map_err(io_error)?;
|
||||
let settings = serde_json::from_slice::<ServerExposureSettingsDto>(&bytes)
|
||||
.map_err(|err| invalid_error(format!("invalid server exposure settings: {err}")))?;
|
||||
validate_settings(&settings)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Validates and atomically writes settings with user-only permissions when
|
||||
/// the platform supports them.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] when validation or persistence fails.
|
||||
pub fn write(&self, settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
|
||||
validate_settings(settings)?;
|
||||
let bytes = serde_json::to_vec_pretty(settings).map_err(|err| ErrorDto {
|
||||
code: "INTERNAL".to_owned(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
write_atomic_user_only(&self.path, &bytes).map_err(io_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop-owned embedded server manager.
|
||||
pub struct EmbeddedServerController {
|
||||
app_data_dir: PathBuf,
|
||||
resource_dir: Option<PathBuf>,
|
||||
store: FsServerExposureSettingsStore,
|
||||
inner: Mutex<EmbeddedServerInner>,
|
||||
}
|
||||
|
||||
impl EmbeddedServerController {
|
||||
/// Creates the controller.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||
Self::with_resource_dir(app_data_dir, None)
|
||||
}
|
||||
|
||||
/// Creates the controller with the Tauri bundle resource directory.
|
||||
#[must_use]
|
||||
pub fn with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
store: FsServerExposureSettingsStore::new(app_data_dir.clone()),
|
||||
app_data_dir,
|
||||
resource_dir,
|
||||
inner: Mutex::new(EmbeddedServerInner::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current status.
|
||||
#[must_use]
|
||||
pub fn status(&self) -> EmbeddedServerStatusDto {
|
||||
let inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||
status_from_inner(&inner)
|
||||
}
|
||||
|
||||
/// Reads persisted exposure settings.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if persisted settings are invalid.
|
||||
pub fn get_settings(&self) -> Result<ServerExposureSettingsDto, ErrorDto> {
|
||||
self.store.read()
|
||||
}
|
||||
|
||||
/// Persists exposure settings.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if settings are invalid or cannot be written.
|
||||
pub fn save_settings(&self, settings: ServerExposureSettingsDto) -> Result<(), ErrorDto> {
|
||||
self.store.write(&settings)
|
||||
}
|
||||
|
||||
/// Builds a preview for the provided settings without starting the server.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if settings are invalid.
|
||||
pub fn preview(
|
||||
&self,
|
||||
settings: ServerExposureSettingsDto,
|
||||
) -> Result<ServerExposurePreviewDto, ErrorDto> {
|
||||
validate_settings(&settings)?;
|
||||
Ok(preview_settings(&settings))
|
||||
}
|
||||
|
||||
/// Starts the embedded server. Idempotent when already running/starting.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] if settings are invalid or the listener fails.
|
||||
pub async fn start(&self, core: Arc<BackendCore>) -> Result<EmbeddedServerStatusDto, ErrorDto> {
|
||||
{
|
||||
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||
if inner.handle.is_some() || inner.state == EmbeddedServerStatusStateDto::Starting {
|
||||
return Ok(status_from_inner(&inner));
|
||||
}
|
||||
inner.state = EmbeddedServerStatusStateDto::Starting;
|
||||
inner.error = None;
|
||||
}
|
||||
|
||||
let settings = match self.store.read() {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
self.mark_failed(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let preview = preview_settings(&settings);
|
||||
let config = match server_config_from_settings(
|
||||
&settings,
|
||||
self.app_data_dir.clone(),
|
||||
resolve_web_root(self.resource_dir.as_deref())?,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.mark_failed(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
match web_server::run_embedded_with_core(config, core).await {
|
||||
Ok(handle) => {
|
||||
let status = {
|
||||
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||
inner.public_url = settings.public_origin.clone();
|
||||
inner.upstream_url = preview.upstream_url;
|
||||
inner.state = EmbeddedServerStatusStateDto::Running;
|
||||
inner.error = None;
|
||||
inner.handle = Some(handle);
|
||||
status_from_inner(&inner)
|
||||
};
|
||||
Ok(status)
|
||||
}
|
||||
Err(message) => {
|
||||
let err = ErrorDto {
|
||||
code: "PROCESS".to_owned(),
|
||||
message,
|
||||
};
|
||||
self.mark_failed(err.clone());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PairingCodeDto, ErrorDto> {
|
||||
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
|
||||
/// Returns an [`ErrorDto`] when stopping the listener task fails.
|
||||
pub async fn stop(&self) -> Result<EmbeddedServerStatusDto, ErrorDto> {
|
||||
let handle = {
|
||||
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||
let Some(handle) = inner.handle.take() else {
|
||||
inner.state = EmbeddedServerStatusStateDto::Stopped;
|
||||
inner.public_url = None;
|
||||
inner.upstream_url = None;
|
||||
return Ok(status_from_inner(&inner));
|
||||
};
|
||||
inner.state = EmbeddedServerStatusStateDto::Stopping;
|
||||
handle
|
||||
};
|
||||
|
||||
if let Err(message) = handle.stop().await {
|
||||
let err = ErrorDto {
|
||||
code: "PROCESS".to_owned(),
|
||||
message,
|
||||
};
|
||||
self.mark_failed(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||
inner.state = EmbeddedServerStatusStateDto::Stopped;
|
||||
inner.public_url = None;
|
||||
inner.upstream_url = None;
|
||||
inner.error = None;
|
||||
Ok(status_from_inner(&inner))
|
||||
}
|
||||
|
||||
fn mark_failed(&self, err: ErrorDto) {
|
||||
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||
inner.handle = None;
|
||||
inner.state = EmbeddedServerStatusStateDto::Failed;
|
||||
inner.public_url = None;
|
||||
inner.upstream_url = None;
|
||||
inner.error = Some(err);
|
||||
}
|
||||
}
|
||||
|
||||
fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
|
||||
let error = (inner.state == EmbeddedServerStatusStateDto::Failed)
|
||||
.then(|| inner.error.clone())
|
||||
.flatten();
|
||||
EmbeddedServerStatusDto {
|
||||
state: inner.state,
|
||||
local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
|
||||
public_url: inner.public_url.clone(),
|
||||
upstream_url: inner.upstream_url.clone(),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_settings() -> ServerExposureSettingsDto {
|
||||
ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
port: 17373,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
|
||||
match settings.mode {
|
||||
ServerExposureMode::LocalOnly => {}
|
||||
ServerExposureMode::RemoteProxyLocal => {
|
||||
let origin = require_https_origin(settings)?;
|
||||
validate_origin_shape(origin)?;
|
||||
}
|
||||
ServerExposureMode::RemoteProxyOtherMachine => {
|
||||
let origin = require_https_origin(settings)?;
|
||||
validate_origin_shape(origin)?;
|
||||
let Some(addr) = &settings.lan_bind_address else {
|
||||
return Err(invalid_error(
|
||||
"remoteProxyOtherMachine requires lanBindAddress",
|
||||
));
|
||||
};
|
||||
let ip = parse_ip(addr, "lanBindAddress")?;
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return Err(invalid_error(
|
||||
"remoteProxyOtherMachine requires a concrete LAN bind address",
|
||||
));
|
||||
}
|
||||
if settings.trusted_proxies.is_empty() {
|
||||
return Err(invalid_error(
|
||||
"remoteProxyOtherMachine requires at least one trustedProxies entry",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for proxy in &settings.trusted_proxies {
|
||||
TrustedProxy::parse(proxy).map_err(invalid_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_https_origin(settings: &ServerExposureSettingsDto) -> Result<&str, ErrorDto> {
|
||||
let Some(origin) = settings.public_origin.as_deref() else {
|
||||
return Err(invalid_error("remote exposure requires publicOrigin"));
|
||||
};
|
||||
if !origin.starts_with("https://") {
|
||||
return Err(invalid_error(
|
||||
"remote exposure requires publicOrigin to start with https://",
|
||||
));
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
fn validate_origin_shape(origin: &str) -> Result<(), ErrorDto> {
|
||||
if origin == "*" || origin.contains('*') {
|
||||
return Err(invalid_error("publicOrigin must be exact, not a wildcard"));
|
||||
}
|
||||
if origin.ends_with('/') {
|
||||
return Err(invalid_error("publicOrigin must not end with '/'"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn server_config_from_settings(
|
||||
settings: &ServerExposureSettingsDto,
|
||||
app_data_dir: PathBuf,
|
||||
web_root: PathBuf,
|
||||
) -> Result<ServerConfig, ErrorDto> {
|
||||
validate_settings(settings)?;
|
||||
let listen_ip = match settings.mode {
|
||||
ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
ServerExposureMode::RemoteProxyOtherMachine => parse_ip(
|
||||
settings.lan_bind_address.as_deref().unwrap_or_default(),
|
||||
"lanBindAddress",
|
||||
)?,
|
||||
};
|
||||
let allow_remote = settings.mode != ServerExposureMode::LocalOnly;
|
||||
let trusted_proxies = settings
|
||||
.trusted_proxies
|
||||
.iter()
|
||||
.map(|proxy| TrustedProxy::parse(proxy).map_err(invalid_error))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let config = ServerConfig {
|
||||
listen: SocketAddr::new(listen_ip, settings.port),
|
||||
public_origin: allow_remote
|
||||
.then(|| settings.public_origin.clone())
|
||||
.flatten(),
|
||||
allow_remote,
|
||||
trust_reverse_proxy: allow_remote,
|
||||
trusted_proxies,
|
||||
app_data_dir,
|
||||
web_root,
|
||||
new_code: false,
|
||||
};
|
||||
config.validate().map_err(invalid_error)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn preview_settings(settings: &ServerExposureSettingsDto) -> ServerExposurePreviewDto {
|
||||
let candidate_lan_addresses = candidate_lan_addresses();
|
||||
let bind_address = match settings.mode {
|
||||
ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => {
|
||||
Some(Ipv4Addr::LOCALHOST.to_string())
|
||||
}
|
||||
ServerExposureMode::RemoteProxyOtherMachine => settings.lan_bind_address.clone(),
|
||||
};
|
||||
let upstream_url = bind_address.map(|addr| format!("http://{addr}:{}", settings.port));
|
||||
let upstream_url = (settings.mode != ServerExposureMode::LocalOnly)
|
||||
.then_some(upstream_url)
|
||||
.flatten();
|
||||
let mut warnings = Vec::new();
|
||||
if settings.mode == ServerExposureMode::RemoteProxyOtherMachine
|
||||
&& settings.trusted_proxies.is_empty()
|
||||
{
|
||||
warnings.push(DiagnosticWarningDto {
|
||||
code: "missingTrustedProxy".to_owned(),
|
||||
message:
|
||||
"Add the IP address or CIDR of the reverse proxy that connects to this server."
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
ServerExposurePreviewDto {
|
||||
candidate_lan_addresses,
|
||||
upstream_url,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_lan_addresses() -> Vec<String> {
|
||||
let mut addresses = Vec::new();
|
||||
if let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
|
||||
if socket.connect((Ipv4Addr::new(192, 0, 2, 1), 80)).is_ok() {
|
||||
if let Ok(addr) = socket.local_addr() {
|
||||
if let IpAddr::V4(ip) = addr.ip() {
|
||||
if !ip.is_loopback() && !ip.is_unspecified() {
|
||||
addresses.push(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
addresses.sort();
|
||||
addresses.dedup();
|
||||
addresses
|
||||
}
|
||||
|
||||
fn parse_ip(value: &str, field: &str) -> Result<IpAddr, ErrorDto> {
|
||||
value
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| invalid_error(format!("{field} must be an IP address")))
|
||||
}
|
||||
|
||||
fn resolve_web_root(resource_dir: Option<&Path>) -> Result<PathBuf, ErrorDto> {
|
||||
let explicit_web_root = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from);
|
||||
let candidates = web_root_candidates(explicit_web_root, resource_dir);
|
||||
first_web_root(candidates).ok_or_else(|| {
|
||||
invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT")
|
||||
})
|
||||
}
|
||||
|
||||
fn web_root_candidates(
|
||||
explicit_web_root: Option<PathBuf>,
|
||||
resource_dir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = explicit_web_root {
|
||||
candidates.push(path);
|
||||
}
|
||||
if let Some(resource_dir) = resource_dir {
|
||||
candidates.push(resource_dir.join("web"));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("web"));
|
||||
candidates.push(dir.join("frontend").join("dist"));
|
||||
}
|
||||
}
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
candidates.push(cwd.join("frontend").join("dist"));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn first_web_root(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.join("index.html").is_file())
|
||||
}
|
||||
|
||||
fn write_atomic_user_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
set_user_only_permissions(&tmp)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_user_only_permissions(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_user_only_permissions(_path: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalid_error(message: impl Into<String>) -> ErrorDto {
|
||||
ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> ErrorDto {
|
||||
ErrorDto {
|
||||
code: "STORE".to_owned(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
std::env::set_var(self.key, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp_app_data() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("idea-embedded-server-test-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn tmp_web_root() -> PathBuf {
|
||||
let web_root =
|
||||
std::env::temp_dir().join(format!("idea-embedded-server-web-root-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&web_root).unwrap();
|
||||
std::fs::write(
|
||||
web_root.join("index.html"),
|
||||
"<!doctype html><title>IdeA</title>",
|
||||
)
|
||||
.unwrap();
|
||||
web_root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
|
||||
let explicit = tmp_app_data().join("explicit-web-root");
|
||||
let resource_dir = tmp_app_data().join("resources");
|
||||
|
||||
let candidates = web_root_candidates(Some(explicit.clone()), Some(&resource_dir));
|
||||
|
||||
assert_eq!(candidates[0], explicit);
|
||||
assert_eq!(candidates[1], resource_dir.join("web"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_web_root_uses_first_candidate_with_index_html() {
|
||||
let missing = tmp_app_data().join("missing-web-root");
|
||||
let resource_dir = tmp_app_data().join("resources");
|
||||
let resource_web = resource_dir.join("web");
|
||||
std::fs::create_dir_all(&resource_web).unwrap();
|
||||
std::fs::write(
|
||||
resource_web.join("index.html"),
|
||||
"<!doctype html><title>Packaged</title>",
|
||||
)
|
||||
.unwrap();
|
||||
let later = tmp_web_root();
|
||||
|
||||
let resolved = first_web_root([missing, resource_web.clone(), later])
|
||||
.expect("web root should resolve");
|
||||
|
||||
assert_eq!(resolved, resource_web);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_loopback_remote_requires_trusted_proxy() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
||||
port: 17373,
|
||||
public_origin: Some("https://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: Some("192.0.2.75".to_owned()),
|
||||
};
|
||||
|
||||
let err = validate_settings(&settings).unwrap_err();
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(err.message.contains("trustedProxies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_requires_https_public_origin() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyLocal,
|
||||
port: 17373,
|
||||
public_origin: Some("http://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
};
|
||||
|
||||
let err = validate_settings(&settings).unwrap_err();
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(err.message.contains("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_only_derives_loopback_config() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
port: 0,
|
||||
public_origin: Some("https://ignored.example".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
};
|
||||
|
||||
let config =
|
||||
server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap();
|
||||
|
||||
assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap());
|
||||
assert!(!config.allow_remote);
|
||||
assert_eq!(config.public_origin, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_proxy_local_accepts_loopback_ephemeral_port() {
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyLocal,
|
||||
port: 0,
|
||||
public_origin: Some("https://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
};
|
||||
|
||||
let config =
|
||||
server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap();
|
||||
|
||||
assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap());
|
||||
assert!(config.allow_remote);
|
||||
assert!(config.trusted_proxies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_round_trips_without_pairing_code() {
|
||||
let store = FsServerExposureSettingsStore::new(tmp_app_data());
|
||||
let settings = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
||||
port: 17373,
|
||||
public_origin: Some("https://idea.example.com".to_owned()),
|
||||
trusted_proxies: vec!["192.0.2.22".to_owned()],
|
||||
lan_bind_address: Some("192.0.2.75".to_owned()),
|
||||
};
|
||||
|
||||
store.write(&settings).unwrap();
|
||||
let read = store.read().unwrap();
|
||||
|
||||
assert_eq!(read, settings);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_is_idempotent_when_already_stopped() {
|
||||
let controller = EmbeddedServerController::new(tmp_app_data());
|
||||
|
||||
let first = controller.stop().await.unwrap();
|
||||
let second = controller.stop().await.unwrap();
|
||||
|
||||
assert!(matches!(first.state, EmbeddedServerStatusStateDto::Stopped));
|
||||
assert!(matches!(
|
||||
second.state,
|
||||
EmbeddedServerStatusStateDto::Stopped
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_is_idempotent_and_stop_stops_running_server() {
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
port: 0,
|
||||
public_origin: None,
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
})
|
||||
.unwrap();
|
||||
let core = Arc::new(BackendCore::build(app_data));
|
||||
|
||||
let first = controller.start(Arc::clone(&core)).await.unwrap();
|
||||
let second = controller.start(core).await.unwrap();
|
||||
|
||||
assert!(matches!(first.state, EmbeddedServerStatusStateDto::Running));
|
||||
assert!(matches!(
|
||||
second.state,
|
||||
EmbeddedServerStatusStateDto::Running
|
||||
));
|
||||
assert_eq!(first.local_url, second.local_url);
|
||||
assert!(first
|
||||
.local_url
|
||||
.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"));
|
||||
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();
|
||||
|
||||
assert!(matches!(
|
||||
stopped.state,
|
||||
EmbeddedServerStatusStateDto::Stopped
|
||||
));
|
||||
assert!(stopped.local_url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_with_invalid_persisted_settings_fails_closed() {
|
||||
let app_data = tmp_app_data();
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let bad = ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::RemoteProxyLocal,
|
||||
port: 17373,
|
||||
public_origin: Some("http://idea.example.com".to_owned()),
|
||||
trusted_proxies: Vec::new(),
|
||||
lan_bind_address: None,
|
||||
};
|
||||
let path = app_data.join("deployment").join("server-exposure.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
|
||||
let core = Arc::new(BackendCore::build(app_data));
|
||||
|
||||
let err = controller.start(core).await.unwrap_err();
|
||||
let status = controller.status();
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@
|
||||
pub mod chat;
|
||||
pub mod commands;
|
||||
pub mod dto;
|
||||
pub mod embedded_server;
|
||||
pub mod events;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
@ -95,13 +96,14 @@ pub fn run() {
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve the app data directory");
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
// Point the orchestrator's best-effort diagnostics at a persistent file
|
||||
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
|
||||
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
|
||||
// if the file can't be opened the beacons simply stay on stderr.
|
||||
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
|
||||
application::diag!("[startup] IdeA launched; diagnostics log armed");
|
||||
let app_state = AppState::build(app_data_dir);
|
||||
let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
|
||||
|
||||
// Wire the domain event bus → Tauri events relay.
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
@ -129,6 +131,7 @@ pub fn run() {
|
||||
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
|
||||
let model_servers =
|
||||
std::sync::Arc::clone(&state.ensure_local_model_server);
|
||||
let embedded_server = std::sync::Arc::clone(&state.embedded_server);
|
||||
let open_projects = state.open_project_ids();
|
||||
let handles = state.terminal_sessions.handles();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
@ -148,6 +151,7 @@ pub fn run() {
|
||||
let _ = pty.kill(&h).await;
|
||||
}
|
||||
let _ = model_servers.stop_on_app_exit().await;
|
||||
let _ = embedded_server.stop().await;
|
||||
});
|
||||
}
|
||||
|
||||
@ -282,6 +286,18 @@ pub fn run() {
|
||||
commands::cancel_background_task,
|
||||
commands::retry_background_task,
|
||||
commands::list_background_tasks,
|
||||
commands::get_server_exposure_settings,
|
||||
commands::save_server_exposure_settings,
|
||||
commands::preview_server_exposure_settings,
|
||||
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");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ use infrastructure::TicketToolProvider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
use crate::embedded_server::EmbeddedServerController;
|
||||
use crate::pty::PtyBridge;
|
||||
use crate::tickets::AppTicketToolProvider;
|
||||
|
||||
@ -38,6 +39,8 @@ pub struct AppState {
|
||||
pub pty_bridge: Arc<PtyBridge>,
|
||||
/// Structured reply to Tauri Channel bridge registry.
|
||||
pub chat_bridge: Arc<ChatBridge>,
|
||||
/// Desktop-owned embedded HTTP server lifecycle manager.
|
||||
pub embedded_server: Arc<EmbeddedServerController>,
|
||||
/// Project currently focused by the main window; panel-only windows follow it.
|
||||
focused_project: Mutex<Option<FocusedProjectDto>>,
|
||||
}
|
||||
@ -48,7 +51,14 @@ impl AppState {
|
||||
/// into the backend core.
|
||||
#[must_use]
|
||||
pub fn build(app_data_dir: PathBuf) -> Self {
|
||||
let core = Arc::new(BackendCore::build(app_data_dir));
|
||||
Self::build_with_resource_dir(app_data_dir, None)
|
||||
}
|
||||
|
||||
/// Builds the shared backend core with the Tauri bundle resource directory
|
||||
/// available to desktop-only adapters.
|
||||
#[must_use]
|
||||
pub fn build_with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
|
||||
let core = Arc::new(BackendCore::build(app_data_dir.clone()));
|
||||
core.ticket_tool_binder
|
||||
.bind(Arc::new(AppTicketToolProvider {
|
||||
create: Arc::clone(&core.create_issue),
|
||||
@ -66,10 +76,21 @@ impl AppState {
|
||||
core,
|
||||
pty_bridge: Arc::new(PtyBridge::new()),
|
||||
chat_bridge: Arc::new(ChatBridge::new()),
|
||||
embedded_server: Arc::new(EmbeddedServerController::with_resource_dir(
|
||||
app_data_dir,
|
||||
resource_dir,
|
||||
)),
|
||||
focused_project: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clones the shared backend core for adapter components that must share
|
||||
/// the desktop composition root.
|
||||
#[must_use]
|
||||
pub fn core(&self) -> Arc<BackendCore> {
|
||||
Arc::clone(&self.core)
|
||||
}
|
||||
|
||||
/// Updates the focused project state. `None` means no project is focused/open.
|
||||
pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) {
|
||||
*self
|
||||
|
||||
@ -1743,15 +1743,6 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketTo
|
||||
.ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}")))
|
||||
}
|
||||
|
||||
impl ErrorDto {
|
||||
fn invalid(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: "INVALID".to_owned(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm --prefix ../frontend run dev"
|
||||
"beforeDevCommand": "npm --prefix ../frontend run dev",
|
||||
"beforeBuildCommand": "npm --prefix ../frontend run build:bundle"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
@ -24,6 +25,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage", "nsis"],
|
||||
"icon": ["icons/icon.png"]
|
||||
"icon": ["icons/icon.png"],
|
||||
"resources": {
|
||||
"../../frontend/dist-web": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
482
crates/application/src/device.rs
Normal file
482
crates/application/src/device.rs
Normal file
@ -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<dyn DeviceSessionStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl PairDevice {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn DeviceSessionStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> 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<PairDeviceOutput, AppError> {
|
||||
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<PairAttemptDecision, StoreError>;
|
||||
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
/// Authenticates a persisted device session.
|
||||
pub struct AuthenticateSession {
|
||||
store: Arc<dyn DeviceSessionStore>,
|
||||
}
|
||||
|
||||
impl AuthenticateSession {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn DeviceSessionStore>) -> 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<Option<AuthenticatedDevice>, 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<DeviceId>,
|
||||
}
|
||||
|
||||
/// Output for [`ListDevices`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListDevicesOutput {
|
||||
/// Paired devices.
|
||||
pub devices: Vec<DeviceView>,
|
||||
}
|
||||
|
||||
/// Lists paired devices without exposing transport-sensitive fields.
|
||||
pub struct ListDevices {
|
||||
store: Arc<dyn DeviceSessionStore>,
|
||||
}
|
||||
|
||||
impl ListDevices {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the listing.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`AppError::Store`] for persistence failures.
|
||||
pub async fn execute(&self, input: ListDevicesInput) -> Result<ListDevicesOutput, AppError> {
|
||||
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<dyn DeviceSessionStore>,
|
||||
}
|
||||
|
||||
impl RenameDevice {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn DeviceSessionStore>) -> 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<dyn DeviceSessionStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl RevokeDevice {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> 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<dyn DeviceSessionStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl RevokeAllDevices {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> 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<dyn DeviceSessionStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl TouchDevice {
|
||||
/// Builds the use case from injected ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn DeviceSessionStore>, clock: Arc<dyn Clock>) -> Self {
|
||||
Self { store, clock }
|
||||
}
|
||||
|
||||
/// Executes the touch.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`AppError::Store`] for persistence failures.
|
||||
pub async fn execute(&self, input: TouchDeviceInput) -> Result<TouchDeviceOutput, AppError> {
|
||||
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<Vec<PairedDevice>>,
|
||||
saves: Mutex<usize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceSessionStore for FakeStore {
|
||||
async fn load_devices(&self) -> Result<Vec<PairedDevice>, 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<dyn DeviceSessionStore>,
|
||||
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<dyn DeviceSessionStore>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
3495
crates/backend/src/dto.rs
Normal file
3495
crates/backend/src/dto.rs
Normal file
File diff suppressed because it is too large
Load Diff
1326
crates/backend/src/events.rs
Normal file
1326
crates/backend/src/events.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,20 +72,23 @@ 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,
|
||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
pub mod dto;
|
||||
pub mod events;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
pub mod stream;
|
||||
@ -796,6 +802,24 @@ impl AgentResumer for AppAgentResumer {
|
||||
pub struct BackendCore {
|
||||
/// Trivial health use case validating the end-to-end wiring.
|
||||
pub health: Arc<HealthUseCase>,
|
||||
/// Pair a persistent device session.
|
||||
pub pair_device: Arc<PairDevice>,
|
||||
/// Limits failed pairing attempts.
|
||||
pub pair_attempt_limiter: Arc<dyn PairAttemptLimiter>,
|
||||
/// Authenticate a persistent device session.
|
||||
pub authenticate_session: Arc<AuthenticateSession>,
|
||||
/// Throttled update of the authenticated device last-seen timestamp.
|
||||
pub touch_device: Arc<TouchDevice>,
|
||||
/// List paired devices.
|
||||
pub list_devices: Arc<ListDevices>,
|
||||
/// Rename a paired device.
|
||||
pub rename_device: Arc<RenameDevice>,
|
||||
/// Revoke one paired device.
|
||||
pub revoke_device: Arc<RevokeDevice>,
|
||||
/// Revoke every paired device.
|
||||
pub revoke_all_devices: Arc<RevokeAllDevices>,
|
||||
/// Paired-device store port, exposed for legacy logout revocation.
|
||||
pub device_session_store: Arc<dyn DeviceSessionStore>,
|
||||
/// Create a project (init `.ideai/`, register it).
|
||||
pub create_project: Arc<CreateProject>,
|
||||
/// Open a project (load meta + manifest).
|
||||
@ -1132,11 +1156,21 @@ impl BackendCore {
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let device_session_store = Arc::new(FsDeviceSessionStore::new(
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
|
||||
Arc::clone(&clock) as Arc<dyn Clock>
|
||||
));
|
||||
|
||||
// Port-typed handles for injection.
|
||||
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
||||
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
||||
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
||||
let device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
|
||||
let pair_attempt_limiter_port =
|
||||
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||
|
||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||
@ -1145,6 +1179,27 @@ impl BackendCore {
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let pair_device = Arc::new(PairDevice::new(
|
||||
Arc::clone(&device_session_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
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<dyn Clock>,
|
||||
));
|
||||
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),
|
||||
@ -2314,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,
|
||||
|
||||
@ -159,7 +159,7 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
pub fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
|
||||
@ -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 }
|
||||
|
||||
184
crates/domain/src/device.rs
Normal file
184
crates/domain/src/device.rs
Normal file
@ -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<String>) -> Result<Self, DeviceError> {
|
||||
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<String>) -> Result<Self, DeviceError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Vec<PairedDevice>, 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<bool, StoreError> {
|
||||
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<Option<AuthenticatedDevice>, 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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
153
crates/infrastructure/src/pair_attempt_limiter.rs
Normal file
153
crates/infrastructure/src/pair_attempt_limiter.rs
Normal file
@ -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<dyn Clock>,
|
||||
inner: Mutex<LimiterState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LimiterState {
|
||||
by_origin: HashMap<String, Vec<i64>>,
|
||||
global: Vec<i64>,
|
||||
}
|
||||
|
||||
impl InMemoryPairAttemptLimiter {
|
||||
/// Creates a limiter using the supplied clock.
|
||||
#[must_use]
|
||||
pub fn new(clock: Arc<dyn Clock>) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
inner: Mutex::new(LimiterState::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PairAttemptLimiter for InMemoryPairAttemptLimiter {
|
||||
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, 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);
|
||||
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<i64>, 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<dyn Clock>);
|
||||
|
||||
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<dyn Clock>);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
176
crates/infrastructure/src/store/device_session.rs
Normal file
176
crates/infrastructure/src/store/device_session.rs
Normal file
@ -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<PairedDevice>,
|
||||
}
|
||||
|
||||
/// JSON-file implementation for `{app_data_dir}/security/devices.json`.
|
||||
#[derive(Clone)]
|
||||
pub struct FsDeviceSessionStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
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<dyn FileSystem>, app_data_dir: impl Into<String>) -> 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<Vec<PairedDevice>, 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<dyn FileSystem>,
|
||||
}
|
||||
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
@ -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")]
|
||||
|
||||
37
crates/web-server/Cargo.toml
Normal file
37
crates/web-server/Cargo.toml
Normal file
@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "web-server"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "IdeA headless HTTP/WebSocket driving adapter, reusable by desktop and server binaries."
|
||||
|
||||
[lib]
|
||||
name = "web_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "idea-serve"
|
||||
path = "src/bin/idea-serve.rs"
|
||||
|
||||
[dependencies]
|
||||
backend = { workspace = true }
|
||||
application = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
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"]
|
||||
vector-onnx = ["backend/vector-onnx"]
|
||||
5
crates/web-server/src/bin/idea-serve.rs
Normal file
5
crates/web-server/src/bin/idea-serve.rs
Normal file
@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
web_server::run_from_args(std::env::args().skip(1).collect())
|
||||
}
|
||||
5966
crates/web-server/src/lib.rs
Normal file
5966
crates/web-server/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -59,8 +59,42 @@ idea --serve \
|
||||
--trust-reverse-proxy
|
||||
```
|
||||
|
||||
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`.
|
||||
Do not expose the backend on a public non-loopback address without TLS proxying.
|
||||
This example is for a reverse proxy running on the same machine as the server.
|
||||
If the proxy runs on another machine, bind an address reachable by that proxy,
|
||||
for example `--listen 192.0.2.75:17373`, and point the proxy upstream to that
|
||||
address and port. Also authorize only that proxy peer:
|
||||
|
||||
```bash
|
||||
idea --serve \
|
||||
--listen 192.0.2.75:17373 \
|
||||
--allow-remote \
|
||||
--public-origin https://idea.example.com \
|
||||
--trust-reverse-proxy \
|
||||
--trusted-proxy 192.0.2.22
|
||||
```
|
||||
|
||||
The link from proxy to server is still plain HTTP on the LAN; only the public
|
||||
access is HTTPS.
|
||||
|
||||
For any non-loopback bind, keep the full remote HTTPS configuration:
|
||||
`--allow-remote`, `--public-origin https://...`, `--trust-reverse-proxy`, and at
|
||||
least one `--trusted-proxy IP_OR_CIDR`. `ServerConfig::validate()` rejects a
|
||||
non-loopback bind without it.
|
||||
That check covers the configuration, not reality: it cannot verify that a proxy
|
||||
is actually terminating HTTPS in front of the server. Do not expose the backend
|
||||
on a public non-loopback address without TLS proxying.
|
||||
|
||||
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`, and
|
||||
must send `X-Forwarded-Proto: https`. Forwarding the public host is recommended
|
||||
for diagnostics, but the strict access check remains the `Origin` match against
|
||||
`--public-origin`.
|
||||
`--public-origin` is matched by strict equality against the request `Origin`:
|
||||
open the UI through the configured domain, not through the server IP, otherwise
|
||||
API calls are rejected with 403.
|
||||
|
||||
With a LAN bind, also check the host firewall. If the server is reachable from
|
||||
the local machine but times out from every other host, open the port for the LAN
|
||||
subnet or, preferably, only for the proxy IP. Avoid opening it broadly: the
|
||||
server does not provide its own TLS.
|
||||
Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an
|
||||
`HttpOnly`, `Secure`, `SameSite=Strict` session cookie.
|
||||
|
||||
|
||||
7
frontend/.env.web
Normal file
7
frontend/.env.web
Normal file
@ -0,0 +1,7 @@
|
||||
# Vite mode `web` (`vite build --mode web`, ticket #74 lot F1): the bundle the
|
||||
# embedded server serves to a browser talks HTTP+WS instead of Tauri IPC.
|
||||
#
|
||||
# This lives in a mode env file rather than an inline `VITE_TRANSPORT=http vite
|
||||
# build` prefix because the npm scripts must also run on Windows (NSIS bundle),
|
||||
# where that shell syntax does not exist.
|
||||
VITE_TRANSPORT=http
|
||||
@ -2,7 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- `viewport-fit=cover` (#69) lets the web client paint under a notch and
|
||||
exposes the `env(safe-area-inset-*)` values the shell pads with. Zoom is
|
||||
deliberately left unrestricted (no `maximum-scale`/`user-scalable=no`):
|
||||
pinch-zoom is an accessibility affordance. -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>IdeA</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build:web": "tsc --noEmit && vite build --mode web --outDir dist-web --emptyOutDir",
|
||||
"build:bundle": "npm run typecheck && vite build --outDir dist --emptyOutDir && vite build --mode web --outDir dist-web --emptyOutDir",
|
||||
"test:bundle-transport": "node scripts/assert-bundle-transport.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
78
frontend/scripts/assert-bundle-transport.mjs
Normal file
78
frontend/scripts/assert-bundle-transport.mjs
Normal file
@ -0,0 +1,78 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const EXPECTED_TRANSPORT_BY_DIR = new Map([
|
||||
["dist", "tauri"],
|
||||
["dist-web", "http"],
|
||||
]);
|
||||
|
||||
async function readJavaScriptAssets(buildDir) {
|
||||
const assetsDir = join(process.cwd(), buildDir, "assets");
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(assetsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read ${assetsDir}. Run npm run build:bundle before this check. ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
|
||||
.map((entry) => join(assetsDir, entry.name));
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No JavaScript assets found in ${assetsDir}.`);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
files.map(async (file) => ({
|
||||
file,
|
||||
source: await readFile(file, "utf8"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function transportMarkers(source) {
|
||||
return [...source.matchAll(/__IDEA_TRANSPORT__="([a-z]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
}
|
||||
|
||||
async function assertBundleTransport(buildDir, expectedTransport) {
|
||||
const assets = await readJavaScriptAssets(buildDir);
|
||||
const markers = assets.flatMap(({ file, source }) =>
|
||||
transportMarkers(source).map((transport) => ({ file, transport })),
|
||||
);
|
||||
|
||||
if (markers.length === 0) {
|
||||
throw new Error(
|
||||
`${buildDir} does not publish a __IDEA_TRANSPORT__ marker in its built JavaScript assets.`,
|
||||
);
|
||||
}
|
||||
|
||||
const unexpected = markers.filter(
|
||||
({ transport }) => transport !== expectedTransport,
|
||||
);
|
||||
if (unexpected.length > 0) {
|
||||
const details = unexpected
|
||||
.map(({ file, transport }) => `${file}: ${transport}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`${buildDir} resolves the wrong transport; expected ${expectedTransport}.\n${details}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${buildDir}: __IDEA_TRANSPORT__="${expectedTransport}" (${markers.length} marker${markers.length === 1 ? "" : "s"})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [buildDir, expectedTransport] of EXPECTED_TRANSPORT_BY_DIR) {
|
||||
await assertBundleTransport(buildDir, expectedTransport);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
82
frontend/src/adapters/desktopServer.ts
Normal file
82
frontend/src/adapters/desktopServer.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Tauri adapter for {@link DesktopServerGateway} (ticket #68). One of the only
|
||||
* places that calls `invoke()`; features reach it exclusively through the port.
|
||||
*
|
||||
* Commands and payload keys are camelCase, matching the backend DTO convention.
|
||||
* `save_server_exposure_settings` / `preview_server_exposure_settings` take the
|
||||
* settings directly under a `settings` key (no `request` wrapper).
|
||||
*
|
||||
* **Polling note.** The backend exposes no status *event* — `embedded_server.rs`
|
||||
* emits nothing — so `onStatusChanged` is implemented here by polling
|
||||
* `embedded_server_status`. That keeps the port shape push-like (and swappable
|
||||
* for a real event later) while the polling schedule stays an adapter detail.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
EmbeddedServerStatus,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type { DesktopServerGateway } from "@/ports";
|
||||
|
||||
/** Status poll interval (ms). Cheap, in-process command; transitions are coarse. */
|
||||
const STATUS_POLL_MS = 2000;
|
||||
|
||||
export class TauriDesktopServerGateway implements DesktopServerGateway {
|
||||
constructor(private readonly pollMs: number = STATUS_POLL_MS) {}
|
||||
|
||||
getExposureSettings(): Promise<ServerExposureSettings> {
|
||||
return invoke<ServerExposureSettings>("get_server_exposure_settings");
|
||||
}
|
||||
|
||||
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
|
||||
await invoke("save_server_exposure_settings", { settings });
|
||||
}
|
||||
|
||||
previewExposure(
|
||||
settings: ServerExposureSettings,
|
||||
): Promise<ServerExposurePreview> {
|
||||
return invoke<ServerExposurePreview>("preview_server_exposure_settings", {
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
status(): Promise<EmbeddedServerStatus> {
|
||||
return invoke<EmbeddedServerStatus>("embedded_server_status");
|
||||
}
|
||||
|
||||
start(): Promise<EmbeddedServerStatus> {
|
||||
return invoke<EmbeddedServerStatus>("embedded_server_start");
|
||||
}
|
||||
|
||||
stop(): Promise<EmbeddedServerStatus> {
|
||||
return invoke<EmbeddedServerStatus>("embedded_server_stop");
|
||||
}
|
||||
|
||||
async onStatusChanged(
|
||||
handler: (status: EmbeddedServerStatus) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
let stopped = false;
|
||||
const timer = setInterval(() => {
|
||||
void this.status().then(
|
||||
(status) => {
|
||||
// The unsubscribe may land while a poll is in flight; drop late results
|
||||
// so a torn-down consumer never gets called.
|
||||
if (!stopped) handler(status);
|
||||
},
|
||||
() => {
|
||||
// A failed poll is not a status: swallow it and let the next tick try
|
||||
// again, rather than tearing the subscription down.
|
||||
},
|
||||
);
|
||||
}, this.pollMs);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
}
|
||||
42
frontend/src/adapters/device.ts
Normal file
42
frontend/src/adapters/device.ts
Normal file
@ -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<PairedDevice[]> {
|
||||
const dto = await invoke<DeviceListDto>("list_devices");
|
||||
return dto.devices;
|
||||
}
|
||||
|
||||
createPairingCode(): Promise<PairingCode> {
|
||||
return invoke<PairingCode>("create_pairing_code");
|
||||
}
|
||||
|
||||
renameDevice(deviceId: string, name: string): Promise<void> {
|
||||
return invoke<void>("rename_device", { request: { deviceId, name } });
|
||||
}
|
||||
|
||||
revokeDevice(deviceId: string): Promise<void> {
|
||||
return invoke<void>("revoke_device", { request: { deviceId } });
|
||||
}
|
||||
|
||||
revokeAllDevices(): Promise<void> {
|
||||
return invoke<void>("revoke_all_devices");
|
||||
}
|
||||
}
|
||||
51
frontend/src/adapters/http/deviceGateway.ts
Normal file
51
frontend/src/adapters/http/deviceGateway.ts
Normal file
@ -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<PairedDevice[]> {
|
||||
const body = await this.http.request<DeviceListResponse>("GET", "/api/devices");
|
||||
return body.devices;
|
||||
}
|
||||
|
||||
createPairingCode(): Promise<PairingCode> {
|
||||
return this.http.request<PairingCode>("POST", "/api/pairing-code");
|
||||
}
|
||||
|
||||
renameDevice(deviceId: string, name: string): Promise<void> {
|
||||
return this.http.request<void>(
|
||||
"POST",
|
||||
`/api/devices/${encodeURIComponent(deviceId)}/rename`,
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
revokeDevice(deviceId: string): Promise<void> {
|
||||
return this.http.request<void>(
|
||||
"POST",
|
||||
`/api/devices/${encodeURIComponent(deviceId)}/revoke`,
|
||||
);
|
||||
}
|
||||
|
||||
revokeAllDevices(): Promise<void> {
|
||||
return this.http.request<void>("POST", "/api/devices/revoke-all");
|
||||
}
|
||||
}
|
||||
@ -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<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return this.request<T>("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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
what = `${method} ${path}`,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
@ -125,10 +143,10 @@ export class HttpInvoker {
|
||||
|
||||
let res: Awaited<ReturnType<FetchLike>>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
@ -42,6 +43,7 @@ import {
|
||||
HttpTicketGateway,
|
||||
} from "./streamGateways";
|
||||
import {
|
||||
WebDesktopServerGateway,
|
||||
WebFocusedProjectGateway,
|
||||
WebRemoteGateway,
|
||||
WebWindowGateway,
|
||||
@ -121,10 +123,14 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
remote: new WebRemoteGateway(),
|
||||
profile: new HttpProfileGateway(http),
|
||||
modelServer: new HttpModelServerGateway(http),
|
||||
// Desktop-only (#68): the web client is served *by* the embedded server and
|
||||
// must not reconfigure it.
|
||||
desktopServer: new WebDesktopServerGateway(),
|
||||
template: new HttpTemplateGateway(http),
|
||||
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),
|
||||
|
||||
@ -9,8 +9,15 @@
|
||||
* browser to replace `pickFolder`), it gets its own lot.
|
||||
*/
|
||||
|
||||
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||
import type {
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
DesktopServerGateway,
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
RemoteGateway,
|
||||
@ -75,3 +82,40 @@ export class WebRemoteGateway implements RemoteGateway {
|
||||
return unsupportedOnWeb("Remote (SSH/WSL) connection");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web stub: the embedded server is desktop-only (ticket #68). A web client is
|
||||
* *served by* this very server, so letting it reconfigure or stop the server
|
||||
* would let a remote device saw off the branch it sits on. The desktop app owns
|
||||
* this surface; every call fails explicitly rather than reaching for Tauri.
|
||||
*/
|
||||
export class WebDesktopServerGateway implements DesktopServerGateway {
|
||||
// `async` on purpose: these must *reject* rather than throw synchronously, so
|
||||
// callers using `.then(ok, err)` (not just `await`) still see the failure.
|
||||
async getExposureSettings(): Promise<ServerExposureSettings> {
|
||||
return unsupportedOnWeb("Embedded server settings");
|
||||
}
|
||||
async saveExposureSettings(_settings: ServerExposureSettings): Promise<void> {
|
||||
return unsupportedOnWeb("Embedded server settings");
|
||||
}
|
||||
async previewExposure(
|
||||
_settings: ServerExposureSettings,
|
||||
): Promise<ServerExposurePreview> {
|
||||
return unsupportedOnWeb("Embedded server settings");
|
||||
}
|
||||
async status(): Promise<EmbeddedServerStatus> {
|
||||
return unsupportedOnWeb("Embedded server status");
|
||||
}
|
||||
async start(): Promise<EmbeddedServerStatus> {
|
||||
return unsupportedOnWeb("Starting the embedded server");
|
||||
}
|
||||
async stop(): Promise<EmbeddedServerStatus> {
|
||||
return unsupportedOnWeb("Stopping the embedded server");
|
||||
}
|
||||
onStatusChanged(
|
||||
_handler: (status: EmbeddedServerStatus) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
// Never fires on web; a no-op unsubscribe keeps callers' teardown honest.
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<PairingErrorCode, string> = {
|
||||
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<void> {
|
||||
async pair(code: string, name: string): Promise<void> {
|
||||
let res: Awaited<ReturnType<FetchLike>>;
|
||||
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.
|
||||
|
||||
@ -20,10 +20,12 @@ import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
import { TauriProfileGateway } from "./profile";
|
||||
import { TauriModelServerGateway } from "./modelServer";
|
||||
import { TauriDesktopServerGateway } from "./desktopServer";
|
||||
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";
|
||||
@ -60,10 +62,12 @@ export function createTauriGateways(): Gateways {
|
||||
remote: new TauriRemoteGateway(),
|
||||
profile: new TauriProfileGateway(),
|
||||
modelServer: new TauriModelServerGateway(),
|
||||
desktopServer: new TauriDesktopServerGateway(),
|
||||
template: new TauriTemplateGateway(),
|
||||
skill: new TauriSkillGateway(),
|
||||
memory: new TauriMemoryGateway(),
|
||||
embedder: new TauriEmbedderGateway(),
|
||||
device: new TauriDeviceGateway(),
|
||||
permission: new TauriPermissionGateway(),
|
||||
workState: new TauriWorkStateGateway(),
|
||||
conversation: new TauriConversationGateway(),
|
||||
@ -83,10 +87,12 @@ export {
|
||||
TauriLayoutGateway,
|
||||
TauriProfileGateway,
|
||||
TauriModelServerGateway,
|
||||
TauriDesktopServerGateway,
|
||||
TauriTemplateGateway,
|
||||
TauriSkillGateway,
|
||||
TauriMemoryGateway,
|
||||
TauriEmbedderGateway,
|
||||
TauriDeviceGateway,
|
||||
TauriGitGateway,
|
||||
TauriPermissionGateway,
|
||||
TauriWorkStateGateway,
|
||||
|
||||
@ -8,9 +8,11 @@ import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
DiagnosticWarning,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
EmbeddedServerStatus,
|
||||
FirstRunState,
|
||||
GatewayError,
|
||||
GitBranches,
|
||||
@ -30,12 +32,16 @@ import type {
|
||||
MemoryLink,
|
||||
MemoryType,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
ReplyChunk,
|
||||
SkillScope,
|
||||
@ -63,6 +69,8 @@ import type {
|
||||
CreateAgentInput,
|
||||
CreateMemoryInput,
|
||||
CreateSkillInput,
|
||||
DesktopServerGateway,
|
||||
DeviceGateway,
|
||||
EmbedderGateway,
|
||||
CreateTemplateInput,
|
||||
Gateways,
|
||||
@ -1373,6 +1381,146 @@ export class MockModelServerGateway implements ModelServerGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror of the backend `default_settings()` (ticket #68). */
|
||||
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
|
||||
mode: "localOnly",
|
||||
port: 17373,
|
||||
trustedProxies: [],
|
||||
};
|
||||
|
||||
/** Fixed LAN candidates so the offline UI has something to pick from. */
|
||||
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];
|
||||
|
||||
function invalid(message: string): never {
|
||||
const err: GatewayError = { code: "INVALID", message };
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory embedded-server gateway (ticket #68).
|
||||
*
|
||||
* Mirrors the backend `validate_settings` / `preview_settings` closely enough
|
||||
* that the Deployment UI — including its rejection paths — is testable offline.
|
||||
* It is never the source of truth: production runs the real Tauri commands.
|
||||
* `candidateLanAddresses` is fixed data here precisely because the UI must take
|
||||
* addresses from the gateway rather than deriving them.
|
||||
*/
|
||||
export class MockDesktopServerGateway implements DesktopServerGateway {
|
||||
private settings: ServerExposureSettings = structuredClone(
|
||||
DEFAULT_EXPOSURE_SETTINGS,
|
||||
);
|
||||
private current: EmbeddedServerStatus = { state: "stopped" };
|
||||
private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();
|
||||
|
||||
async getExposureSettings(): Promise<ServerExposureSettings> {
|
||||
return structuredClone(this.settings);
|
||||
}
|
||||
|
||||
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
|
||||
this.validate(settings);
|
||||
this.settings = structuredClone(settings);
|
||||
}
|
||||
|
||||
async previewExposure(
|
||||
settings: ServerExposureSettings,
|
||||
): Promise<ServerExposurePreview> {
|
||||
// The real `preview_server_exposure_settings` validates *before* previewing,
|
||||
// so an incomplete draft is rejected rather than previewed. Mirrored here —
|
||||
// the UI relies on it as its validation authority.
|
||||
this.validate(settings);
|
||||
const warnings: DiagnosticWarning[] = [];
|
||||
if (
|
||||
settings.mode === "remoteProxyOtherMachine" &&
|
||||
settings.trustedProxies.length === 0
|
||||
) {
|
||||
// Mirrors the backend's `missingTrustedProxy` warning — which the backend
|
||||
// itself cannot currently reach, since `validate_settings` rejects an
|
||||
// empty `trustedProxies` for this mode first. Kept aligned on purpose: if
|
||||
// the backend reorders, the mock already matches.
|
||||
warnings.push({
|
||||
code: "missingTrustedProxy",
|
||||
message:
|
||||
"Add the IP address or CIDR of the reverse proxy that connects to this server.",
|
||||
});
|
||||
}
|
||||
const bindAddress =
|
||||
settings.mode === "remoteProxyOtherMachine"
|
||||
? settings.lanBindAddress
|
||||
: "127.0.0.1";
|
||||
const upstreamUrl =
|
||||
settings.mode !== "localOnly" && bindAddress
|
||||
? `http://${bindAddress}:${settings.port}`
|
||||
: undefined;
|
||||
return {
|
||||
candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
|
||||
upstreamUrl,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async status(): Promise<EmbeddedServerStatus> {
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
async start(): Promise<EmbeddedServerStatus> {
|
||||
// The backend re-validates on start; a bad config fails there, not silently.
|
||||
this.validate(this.settings);
|
||||
const preview = await this.previewExposure(this.settings);
|
||||
this.emit({
|
||||
state: "running",
|
||||
localUrl: `http://127.0.0.1:${this.settings.port}`,
|
||||
publicUrl:
|
||||
this.settings.mode === "localOnly"
|
||||
? undefined
|
||||
: this.settings.publicOrigin,
|
||||
upstreamUrl: preview.upstreamUrl,
|
||||
});
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
async stop(): Promise<EmbeddedServerStatus> {
|
||||
this.emit({ state: "stopped" });
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
async onStatusChanged(
|
||||
handler: (status: EmbeddedServerStatus) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
|
||||
setStatus(status: EmbeddedServerStatus): void {
|
||||
this.emit(status);
|
||||
}
|
||||
|
||||
private emit(status: EmbeddedServerStatus): void {
|
||||
this.current = status;
|
||||
for (const handler of this.handlers) handler(structuredClone(status));
|
||||
}
|
||||
|
||||
private validate(settings: ServerExposureSettings): void {
|
||||
if (settings.mode !== "localOnly") {
|
||||
const origin = settings.publicOrigin;
|
||||
if (!origin) invalid("remote exposure requires publicOrigin");
|
||||
if (!origin.startsWith("https://")) {
|
||||
invalid("remote exposure requires publicOrigin to start with https://");
|
||||
}
|
||||
if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
|
||||
if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
|
||||
}
|
||||
if (settings.mode === "remoteProxyOtherMachine") {
|
||||
if (!settings.lanBindAddress) {
|
||||
invalid("remoteProxyOtherMachine requires lanBindAddress");
|
||||
}
|
||||
if (settings.trustedProxies.length === 0) {
|
||||
invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful in-memory template gateway.
|
||||
*
|
||||
@ -1893,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<PairedDevice[]> {
|
||||
return structuredClone(this.devices);
|
||||
}
|
||||
|
||||
async createPairingCode(): Promise<PairingCode> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
|
||||
}
|
||||
|
||||
async revokeAllDevices(): Promise<void> {
|
||||
this.devices = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory permissions gateway. */
|
||||
export class MockPermissionGateway implements PermissionGateway {
|
||||
private docs = new Map<string, ProjectPermissions>();
|
||||
@ -2630,10 +2838,12 @@ export function createMockGateways(): Gateways {
|
||||
remote: new MockRemoteGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
modelServer: new MockModelServerGateway(),
|
||||
desktopServer: new MockDesktopServerGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
memory: new MockMemoryGateway(),
|
||||
embedder: new MockEmbedderGateway(),
|
||||
device: new MockDeviceGateway(),
|
||||
permission: new MockPermissionGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
conversation: new MockConversationGateway(),
|
||||
|
||||
@ -16,6 +16,8 @@ describe("createMockGateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"conversation",
|
||||
"desktopServer",
|
||||
"device",
|
||||
"embedder",
|
||||
"focusedProject",
|
||||
"git",
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
shouldUseMock,
|
||||
shouldUseHttp,
|
||||
} from "./di";
|
||||
import { transportFromEnv } from "./transport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@ -66,6 +67,28 @@ describe("resolveTransport / web (HTTP+WS) selection (ticket #13, F1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transportFromEnv (ticket #74, F1)", () => {
|
||||
// The build config derives `__IDEA_TRANSPORT__` from this same predicate, so
|
||||
// pinning it here pins both the runtime transport and the build constant.
|
||||
it("selects http only for the exact opt-in value", () => {
|
||||
expect(transportFromEnv("http")).toBe("http");
|
||||
});
|
||||
|
||||
it.each([undefined, "", "tauri", "HTTP", "http ", "web"])(
|
||||
"falls back to tauri for %o",
|
||||
(value) => {
|
||||
expect(transportFromEnv(value)).toBe("tauri");
|
||||
},
|
||||
);
|
||||
|
||||
it("agrees with shouldUseHttp for the value the build reads", () => {
|
||||
vi.stubEnv("VITE_TRANSPORT", "http");
|
||||
expect(shouldUseHttp()).toBe(transportFromEnv("http") === "http");
|
||||
vi.stubEnv("VITE_TRANSPORT", "");
|
||||
expect(shouldUseHttp()).toBe(transportFromEnv("") === "http");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIProvider / useGateways", () => {
|
||||
it("provides explicit gateways to consumers", () => {
|
||||
const gateways = resolveGatewaysMock();
|
||||
|
||||
@ -18,11 +18,11 @@ import type { Gateways } from "@/ports";
|
||||
import { createTauriGateways } from "@/adapters";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { createHttpWsGateways } from "@/adapters/http";
|
||||
import { transportFromEnv, type Transport } from "./transport";
|
||||
|
||||
const GatewaysContext = createContext<Gateways | null>(null);
|
||||
|
||||
/** The selected transport backing the gateways. */
|
||||
export type Transport = "mock" | "http" | "tauri";
|
||||
export type { Transport };
|
||||
|
||||
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
|
||||
export function shouldUseMock(): boolean {
|
||||
@ -36,14 +36,13 @@ export function shouldUseMock(): boolean {
|
||||
* and is never affected (ticket #13, lot F1).
|
||||
*/
|
||||
export function shouldUseHttp(): boolean {
|
||||
return import.meta.env.VITE_TRANSPORT === "http";
|
||||
return transportFromEnv(import.meta.env.VITE_TRANSPORT) === "http";
|
||||
}
|
||||
|
||||
/** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */
|
||||
export function resolveTransport(): Transport {
|
||||
if (shouldUseMock()) return "mock";
|
||||
if (shouldUseHttp()) return "http";
|
||||
return "tauri";
|
||||
return transportFromEnv(import.meta.env.VITE_TRANSPORT);
|
||||
}
|
||||
|
||||
/** Resolves the gateway set for the current environment. */
|
||||
|
||||
@ -19,6 +19,14 @@ if (!root) {
|
||||
// follows the main window's focused project; otherwise the full app.
|
||||
const viewParams = parseViewWindowParams(window.location.search);
|
||||
|
||||
// Ticket #74, F1: publish the transport this bundle was built for. `define`
|
||||
// inlines it as a literal, and assigning it is a side effect, so it survives
|
||||
// minification and tree-shaking — a built bundle can be identified (by QA, or
|
||||
// from devtools) without grepping for a minified symbol. Both adapter sets ship
|
||||
// in either bundle, so their mere presence proves nothing.
|
||||
(window as unknown as { __IDEA_TRANSPORT__?: string }).__IDEA_TRANSPORT__ =
|
||||
__IDEA_TRANSPORT__;
|
||||
|
||||
// Ticket #13, F2: in web (HTTP) transport mode the browser client renders the
|
||||
// pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders
|
||||
// the full `App` (or a detached view window). Detached windows are desktop-only.
|
||||
|
||||
19
frontend/src/app/transport.ts
Normal file
19
frontend/src/app/transport.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The transport signal, shared by the app and the Vite build config.
|
||||
*
|
||||
* `vite.config.ts` imports {@link transportFromEnv} to compute the
|
||||
* `__IDEA_TRANSPORT__` build constant from the very same `VITE_TRANSPORT`
|
||||
* variable that {@link shouldUseHttp} reads (ticket #74, F1). One predicate,
|
||||
* one variable: the greppable constant and the resolved transport cannot drift.
|
||||
*/
|
||||
|
||||
/** The selected transport backing the gateways. */
|
||||
export type Transport = "mock" | "http" | "tauri";
|
||||
|
||||
/**
|
||||
* The transport a raw `VITE_TRANSPORT` value selects, mock aside. Web is opt-in
|
||||
* (`"http"`) so the desktop path stays the default and is never affected.
|
||||
*/
|
||||
export function transportFromEnv(value: string | undefined): "http" | "tauri" {
|
||||
return value === "http" ? "http" : "tauri";
|
||||
}
|
||||
@ -117,6 +117,97 @@ export interface ModelServerCommandPreview {
|
||||
display: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the embedded web server is exposed (ticket #68, mirror of the backend
|
||||
* `ServerExposureMode`).
|
||||
*
|
||||
* - `localOnly` — binds loopback, no remote access.
|
||||
* - `remoteProxyLocal` — binds loopback; an HTTPS reverse proxy runs on *this*
|
||||
* machine and reaches IdeA over loopback.
|
||||
* - `remoteProxyOtherMachine` — binds a concrete LAN address so a proxy on
|
||||
* *another* host can reach it; that proxy must be declared in
|
||||
* `trustedProxies`.
|
||||
*/
|
||||
export type ServerExposureMode =
|
||||
| "localOnly"
|
||||
| "remoteProxyLocal"
|
||||
| "remoteProxyOtherMachine";
|
||||
|
||||
/**
|
||||
* Persisted embedded-server exposure settings (mirror of the backend
|
||||
* `ServerExposureSettingsDto`).
|
||||
*
|
||||
* The backend validates every field on save/start; the UI never derives network
|
||||
* facts (LAN addresses, upstream URL) from these — it asks `previewExposure`.
|
||||
*/
|
||||
export interface ServerExposureSettings {
|
||||
mode: ServerExposureMode;
|
||||
/** TCP port to bind. `0` asks the OS for an ephemeral port. */
|
||||
port: number;
|
||||
/** Public HTTPS origin, required by both remote modes. */
|
||||
publicOrigin?: string;
|
||||
/**
|
||||
* Peers allowed to contact IdeA, as IPs or CIDRs. **Not** a listen address.
|
||||
* Required (non-empty) by `remoteProxyOtherMachine`.
|
||||
*/
|
||||
trustedProxies: string[];
|
||||
/**
|
||||
* Concrete LAN address to bind, required by `remoteProxyOtherMachine`. Must
|
||||
* come from {@link ServerExposurePreview.candidateLanAddresses} — the UI is
|
||||
* never allowed to invent one.
|
||||
*/
|
||||
lanBindAddress?: string;
|
||||
}
|
||||
|
||||
/** A non-fatal diagnostic from the exposure preview (never blocks a start). */
|
||||
export interface DiagnosticWarning {
|
||||
/** Stable warning code (e.g. `missingTrustedProxy`). */
|
||||
code: string;
|
||||
/** Human-readable, actionable message. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend-derived preview of a draft exposure config (mirror of
|
||||
* `ServerExposurePreviewDto`). The backend is the sole authority on LAN
|
||||
* addresses and the proxy upstream URL.
|
||||
*/
|
||||
export interface ServerExposurePreview {
|
||||
/** LAN addresses the backend discovered on this host. */
|
||||
candidateLanAddresses: string[];
|
||||
/** URL the reverse proxy should target; absent in `localOnly`. */
|
||||
upstreamUrl?: string;
|
||||
/** Non-fatal diagnostics to surface alongside the form. */
|
||||
warnings: DiagnosticWarning[];
|
||||
}
|
||||
|
||||
/** Embedded-server lifecycle state (mirror of `EmbeddedServerStatusStateDto`). */
|
||||
export type EmbeddedServerState =
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "failed";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** Local URL, when running. */
|
||||
localUrl?: string;
|
||||
/** Public URL, when a remote mode is configured. */
|
||||
publicUrl?: string;
|
||||
/** Upstream URL to hand to the reverse proxy. */
|
||||
upstreamUrl?: string;
|
||||
/** Last failure, when `state` is `failed`. */
|
||||
error?: GatewayError;
|
||||
}
|
||||
|
||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
@ -1230,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();
|
||||
}
|
||||
|
||||
80
frontend/src/features/devices/ConfirmDialog.tsx
Normal file
80
frontend/src/features/devices/ConfirmDialog.tsx
Normal file
@ -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<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(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 (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
|
||||
style={{ zIndex: zIndex.floatingWindow }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-content">{title}</h3>
|
||||
<p className="text-sm text-muted">{body}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={danger ? "danger" : "primary"}
|
||||
loading={busy}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
frontend/src/features/devices/DevicesScreen.test.tsx
Normal file
291
frontend/src/features/devices/DevicesScreen.test.tsx
Normal file
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<DevicesScreen onSessionEnded={onSessionEnded} />
|
||||
</DIProvider>,
|
||||
);
|
||||
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(<epoch-ms>)`
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
317
frontend/src/features/devices/DevicesScreen.tsx
Normal file
317
frontend/src/features/devices/DevicesScreen.tsx
Normal file
@ -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<PairedDevice | null>(null);
|
||||
const [confirmRevokeAll, setConfirmRevokeAll] = useState(false);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (vm.sessionEnded) onSessionEnded?.();
|
||||
}, [vm.sessionEnded, onSessionEnded]);
|
||||
|
||||
const devices = vm.devices;
|
||||
|
||||
return (
|
||||
<section data-testid="devices-screen" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h2 className="text-base font-semibold tracking-tight">Appareils</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Les appareils autorisés à accéder à cette instance IdeA.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void vm.generateCode()} loading={vm.generating}>
|
||||
Appairer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{vm.code && (
|
||||
<PairingCodePanel
|
||||
code={vm.code}
|
||||
onRegenerate={() => void vm.generateCode()}
|
||||
onDismiss={vm.dismissCode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{vm.error && (
|
||||
<Panel className="border-danger/40">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{devices === null ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||
<Spinner size={12} /> Chargement des appareils…
|
||||
</span>
|
||||
) : devices.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun appareil appairé.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border" data-testid="device-list">
|
||||
{devices.map((device) => (
|
||||
<DeviceRow
|
||||
key={device.deviceId}
|
||||
device={device}
|
||||
fresh={vm.freshDeviceIds.includes(device.deviceId)}
|
||||
renaming={renaming === device.deviceId}
|
||||
onStartRename={() => setRenaming(device.deviceId)}
|
||||
onCancelRename={() => setRenaming(null)}
|
||||
onRename={async (name) => {
|
||||
await vm.rename(device.deviceId, name);
|
||||
setRenaming(null);
|
||||
}}
|
||||
onRevoke={() => setConfirmRevoke(device)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{devices !== null && devices.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmRevokeAll(true)}>
|
||||
Révoquer tous les appareils
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmRevoke && (
|
||||
<ConfirmDialog
|
||||
title="Révoquer cet appareil ?"
|
||||
body={
|
||||
confirmRevoke.isCurrentDevice
|
||||
? "Il devra être appairé à nouveau pour accéder à IdeA. Vous serez déconnecté immédiatement."
|
||||
: "Il devra être appairé à nouveau pour accéder à IdeA."
|
||||
}
|
||||
confirmLabel="Révoquer"
|
||||
busy={vm.busy}
|
||||
onConfirm={async () => {
|
||||
const target = confirmRevoke;
|
||||
setConfirmRevoke(null);
|
||||
await vm.revoke(target);
|
||||
}}
|
||||
onCancel={() => setConfirmRevoke(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmRevokeAll && (
|
||||
<ConfirmDialog
|
||||
title="Révoquer tous les appareils ?"
|
||||
// Stated without needing the list: the point of this action is the lost
|
||||
// phone you cannot enumerate calmly.
|
||||
body="Tous les appareils, y compris celui-ci, devront être appairés à nouveau. Vous serez déconnecté immédiatement."
|
||||
confirmLabel="Tout révoquer"
|
||||
danger
|
||||
busy={vm.busy}
|
||||
onConfirm={async () => {
|
||||
setConfirmRevokeAll(false);
|
||||
await vm.revokeAll();
|
||||
}}
|
||||
onCancel={() => setConfirmRevokeAll(false)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<void>;
|
||||
onRevoke: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
data-testid="device-row"
|
||||
data-fresh={fresh || undefined}
|
||||
className={cn(
|
||||
"py-3 transition-colors first:pt-0 last:pb-0",
|
||||
fresh && "bg-success/10",
|
||||
)}
|
||||
>
|
||||
{renaming ? (
|
||||
<RenameForm device={device} onSubmit={onRename} onCancel={onCancelRename} />
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-content">{device.name}</span>
|
||||
{device.isCurrentDevice && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
|
||||
Cet appareil
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{formatLastSeen(device.lastSeenAtMs)}</span>
|
||||
<span className="text-[0.7rem] text-faint">{formatPairedAt(device.pairedAtMs)}</span>
|
||||
</div>
|
||||
<RowMenu deviceName={device.name} onRename={onStartRename} onRevoke={onRevoke} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline rename (1-40 characters), submitted with Enter or the button. */
|
||||
function RenameForm({
|
||||
device,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
device: PairedDevice;
|
||||
onSubmit: (name: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(device.name);
|
||||
const trimmed = name.trim();
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (trimmed.length > 0) void onSubmit(trimmed);
|
||||
}}
|
||||
>
|
||||
<Field label="Nom de l'appareil" hideLabel>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={DEVICE_NAME_MAX}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={trimmed.length === 0}>
|
||||
Renommer
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`Actions pour ${deviceName}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-raised hover:text-content"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-10 mt-1 flex min-w-36 flex-col rounded-md border border-border bg-raised py-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRename();
|
||||
}}
|
||||
className="px-3 py-1.5 text-left text-sm text-content transition-colors hover:bg-canvas"
|
||||
>
|
||||
Renommer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRevoke();
|
||||
}}
|
||||
className="px-3 py-1.5 text-left text-sm text-danger transition-colors hover:bg-canvas"
|
||||
>
|
||||
Révoquer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user