diff --git a/.env.example b/.env.example index e4b0bc6..7034c77 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,8 @@ CHLOVA_TOTP_SECRET= # SECRET — secret TOTP (2FA) CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT # Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin. CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement) +# Racine du SPA buildé servi same-origin. Défaut image = /app/web. Vide = pas de SPA. +CHLOVA_WEB_ROOT= # laisser vide en conteneur (défaut /app/web) # Domaine public derrière Traefik (label compose). CHLOVA_DOMAIN=chlova.example.com # Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bae654..61add83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.24.0] — 2026-06-23 — fin Phase 4 (UI v1 : chat + review) +### Added +- UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente, + approuver / refuser (confirmation sur refus, palier coloré), refresh, 401→logout. +- Backend sert le **SPA same-origin** (`@fastify/static` 9.1.3 + fallback routes + client) si `CHLOVA_WEB_ROOT` présent ; config `webRoot`. +### Changed +- Dockerfile multi-stage : build **web + API** (contexte = racine dépôt), image + embarque le SPA (`/app/web`, `CHLOVA_WEB_ROOT` par défaut). Compose : contexte + `..`, `dockerfile: orchestrator/Dockerfile`, image `chlova/backend:0.2.0`. + Compose revalidé. 65 tests, 0 vuln. + ## [0.23.0] — 2026-06-23 ### Added - UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index ddab1f1..df243cc 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -78,8 +78,9 @@ services: # ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ────────────── backend: build: - context: ../orchestrator # Dockerfile ajouté en Phase 1 - image: chlova/backend:0.1.0 # tag versionné local + context: .. # racine du dépôt (image = API + SPA web) + dockerfile: orchestrator/Dockerfile + image: chlova/backend:0.2.0 # tag versionné local (API+UI) restart: unless-stopped env_file: ../.env environment: diff --git a/orchestrator/Dockerfile b/orchestrator/Dockerfile index cbb1d15..ca438ae 100644 --- a/orchestrator/Dockerfile +++ b/orchestrator/Dockerfile @@ -1,21 +1,35 @@ # CHLOVA backend — image multi-stage, base épinglée (jamais :latest). +# Contexte de build = RACINE du dépôt (voir infra/docker-compose.yml) : l'image +# embarque l'API ET le SPA web (servi same-origin). # TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel. -FROM node:24.13-bookworm-slim AS build -WORKDIR /app -COPY package.json package-lock.json* ./ +# ── Build du SPA (web/) ───────────────────────────────────────────────── +FROM node:24.13-bookworm-slim AS web-build +WORKDIR /web +COPY web/package.json web/package-lock.json* ./ RUN npm ci -COPY tsconfig.json tsconfig.build.json ./ -COPY src ./src +COPY web/ ./ RUN npm run build +# ── Build de l'API (orchestrator/) ────────────────────────────────────── +FROM node:24.13-bookworm-slim AS api-build +WORKDIR /app +COPY orchestrator/package.json orchestrator/package-lock.json* ./ +RUN npm ci +COPY orchestrator/tsconfig.json orchestrator/tsconfig.build.json ./ +COPY orchestrator/src ./src +RUN npm run build + +# ── Runtime ───────────────────────────────────────────────────────────── FROM node:24.13-bookworm-slim AS runtime ENV NODE_ENV=production +ENV CHLOVA_WEB_ROOT=/app/web WORKDIR /app -COPY package.json package-lock.json* ./ +COPY orchestrator/package.json orchestrator/package-lock.json* ./ RUN npm ci --omit=dev && npm cache clean --force -COPY --from=build /app/dist ./dist -# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root. +COPY --from=api-build /app/dist ./dist +COPY --from=web-build /web/dist ./web +# Données runtime (SQLite). L'utilisateur node ne tourne pas en root. RUN mkdir -p /app/data && chown -R node:node /app USER node HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 032ed21..3cfb08d 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -8,8 +8,9 @@ "name": "chlova-orchestrator", "version": "0.1.0", "dependencies": { - "@fastify/cors": "^11.2.0", - "@fastify/rate-limit": "^11.0.0", + "@fastify/cors": "11.2.0", + "@fastify/rate-limit": "11.0.0", + "@fastify/static": "^9.1.3", "@modelcontextprotocol/sdk": "1.29.0", "fastify": "5.8.5", "jose": "6.2.3", @@ -503,6 +504,22 @@ "node": ">=18" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -655,6 +672,53 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", + "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1392,6 +1456,15 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1429,6 +1502,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2076,6 +2161,23 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2556,6 +2658,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2596,6 +2707,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2621,6 +2744,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2752,6 +2899,22 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index b46f118..5063855 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -19,6 +19,7 @@ "dependencies": { "@fastify/cors": "11.2.0", "@fastify/rate-limit": "11.0.0", + "@fastify/static": "9.1.3", "@modelcontextprotocol/sdk": "1.29.0", "fastify": "5.8.5", "jose": "6.2.3", diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index c3c3eae..510809f 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -78,6 +78,8 @@ const schema = z.object({ (v) => (typeof v === "string" && v.length > 0 ? v : undefined), z.string().url().optional(), ), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin + // Racine du SPA buildé, servi same-origin (vide = ne sert pas de SPA). + webRoot: z.string().optional(), }); export type Config = z.infer; @@ -116,6 +118,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { totpSecret: env.CHLOVA_TOTP_SECRET, jwtSecret: env.CHLOVA_JWT_SECRET, webOrigin: env.CHLOVA_WEB_ORIGIN, + webRoot: env.CHLOVA_WEB_ROOT, }); if (!parsed.success) { diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 22427d0..45b62bc 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -1,4 +1,7 @@ import Fastify, { type FastifyBaseLogger } from "fastify"; +import fastifyStatic from "@fastify/static"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js"; import { registerApi } from "./api/routes.js"; import { createLogger } from "./audit/log.js"; @@ -128,6 +131,24 @@ async function main(): Promise { logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule"); } + // SPA same-origin : sert le build web + fallback (routes client) si présent. + if (auth && cfg.webRoot) { + const root = resolve(cfg.webRoot); + if (existsSync(root)) { + await app.register(fastifyStatic, { root }); + app.setNotFoundHandler((req, reply) => { + // /api et /health restent gérés ; le reste retombe sur l'app SPA. + if (req.url.startsWith("/api") || req.url.startsWith("/health")) { + return reply.code(404).send({ error: "not found" }); + } + return reply.sendFile("index.html"); + }); + logger.info({ root }, "SPA servi same-origin"); + } else { + logger.warn({ root }, "CHLOVA_WEB_ROOT introuvable — SPA non servi"); + } + } + await app.listen({ host: "0.0.0.0", port: 8080 }); logger.info({ port: 8080 }, "healthcheck interne prêt"); diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index 8b3fa54..f4ff596 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -1,4 +1,97 @@ -// Vue Review — remplie en v0.24.0. +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "../auth"; +import { api, ApiError, type Asset } from "../api"; + export function Review() { - return
Review (v0.24.0)…
; + const { token, logout } = useAuth(); + const [assets, setAssets] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const guard = useCallback( + (err: unknown): void => { + if (err instanceof ApiError && err.status === 401) logout(); + else setError(err instanceof Error ? err.message : "Erreur"); + }, + [logout], + ); + + const refresh = useCallback(async (): Promise => { + if (!token) return; + setLoading(true); + try { + const { assets } = await api.review(token); + setAssets(assets); + setError(null); + } catch (err) { + guard(err); + } finally { + setLoading(false); + } + }, [token, guard]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const decide = async (id: string, action: "approve" | "refuse"): Promise => { + if (!token) return; + if (action === "refuse" && !confirm(`Refuser définitivement ${id} ?`)) return; + try { + if (action === "approve") await api.approve(token, id); + else await api.refuse(token, id); + await refresh(); + } catch (err) { + guard(err); + } + }; + + const badge = (a: Asset): string => + a.riskTier === "privileged" ? "text-danger" : "text-success"; + + return ( +
+
+

Review

+ +
+ + {error &&

{error}

} + {loading &&

Chargement…

} + {!loading && assets.length === 0 &&

Aucun asset en attente.

} + +
    + {assets.map((a) => ( +
  • +
    + {a.id} + {a.riskTier} + {a.status} · v{a.version} + {a.expiresAt && ( + + expire {new Date(a.expiresAt).toISOString().slice(0, 10)} + + )} +
    + + +
    +
    +
  • + ))} +
+
+ ); }