feat: UI vue Review + backend sert le SPA, fin Phase 4 v1 (v0.24.0)

Vue Review (liste assets, approuver/refuser + confirm, refresh, 401→logout).
Backend sert le SPA same-origin (@fastify/static + fallback) si CHLOVA_WEB_ROOT.
Dockerfile multi-stage build web+API (contexte racine), image embarque /app/web.
Compose contexte .., image chlova/backend:0.2.0. 65 tests, 0 vuln, compose OK.

Palier de risque : privilégié (surface exposée complète) — non déployée ;
auth + CHLOVA_PHASE requis pour activer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 05:56:01 +02:00
parent aee86b811e
commit e6edf1a8bc
9 changed files with 324 additions and 14 deletions
+2
View File
@@ -52,6 +52,8 @@ CHLOVA_TOTP_SECRET= # SECRET — secret TOTP (2FA)
CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT
# Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin. # Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin.
CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement) 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). # Domaine public derrière Traefik (label compose).
CHLOVA_DOMAIN=chlova.example.com CHLOVA_DOMAIN=chlova.example.com
# Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI. # Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI.
+12
View File
@@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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 ## [0.23.0] — 2026-06-23
### Added ### Added
- UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via - UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via
+3 -2
View File
@@ -78,8 +78,9 @@ services:
# ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ────────────── # ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ──────────────
backend: backend:
build: build:
context: ../orchestrator # Dockerfile ajouté en Phase 1 context: .. # racine du dépôt (image = API + SPA web)
image: chlova/backend:0.1.0 # tag versionné local dockerfile: orchestrator/Dockerfile
image: chlova/backend:0.2.0 # tag versionné local (API+UI)
restart: unless-stopped restart: unless-stopped
env_file: ../.env env_file: ../.env
environment: environment:
+22 -8
View File
@@ -1,21 +1,35 @@
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest). # 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. # TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel.
FROM node:24.13-bookworm-slim AS build # ── Build du SPA (web/) ─────────────────────────────────────────────────
WORKDIR /app FROM node:24.13-bookworm-slim AS web-build
COPY package.json package-lock.json* ./ WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci RUN npm ci
COPY tsconfig.json tsconfig.build.json ./ COPY web/ ./
COPY src ./src
RUN npm run build 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 FROM node:24.13-bookworm-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV CHLOVA_WEB_ROOT=/app/web
WORKDIR /app 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 RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist COPY --from=api-build /app/dist ./dist
# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root. 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 RUN mkdir -p /app/data && chown -R node:node /app
USER node USER node
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
+165 -2
View File
@@ -8,8 +8,9 @@
"name": "chlova-orchestrator", "name": "chlova-orchestrator",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "11.2.0",
"@fastify/rate-limit": "^11.0.0", "@fastify/rate-limit": "11.0.0",
"@fastify/static": "^9.1.3",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"jose": "6.2.3", "jose": "6.2.3",
@@ -503,6 +504,22 @@
"node": ">=18" "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": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -655,6 +672,53 @@
"toad-cache": "^3.7.0" "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": { "node_modules/@hono/node-server": {
"version": "1.19.14", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1392,6 +1456,15 @@
"fastq": "^1.17.1" "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": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1429,6 +1502,18 @@
"url": "https://opencollective.com/express" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2076,6 +2161,23 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2556,6 +2658,15 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2596,6 +2707,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2621,6 +2744,30 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2752,6 +2899,22 @@
"node": ">=8" "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": { "node_modules/path-to-regexp": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/rate-limit": "11.0.0", "@fastify/rate-limit": "11.0.0",
"@fastify/static": "9.1.3",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"jose": "6.2.3", "jose": "6.2.3",
+3
View File
@@ -78,6 +78,8 @@ const schema = z.object({
(v) => (typeof v === "string" && v.length > 0 ? v : undefined), (v) => (typeof v === "string" && v.length > 0 ? v : undefined),
z.string().url().optional(), z.string().url().optional(),
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin ), // 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<typeof schema>; export type Config = z.infer<typeof schema>;
@@ -116,6 +118,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
totpSecret: env.CHLOVA_TOTP_SECRET, totpSecret: env.CHLOVA_TOTP_SECRET,
jwtSecret: env.CHLOVA_JWT_SECRET, jwtSecret: env.CHLOVA_JWT_SECRET,
webOrigin: env.CHLOVA_WEB_ORIGIN, webOrigin: env.CHLOVA_WEB_ORIGIN,
webRoot: env.CHLOVA_WEB_ROOT,
}); });
if (!parsed.success) { if (!parsed.success) {
+21
View File
@@ -1,4 +1,7 @@
import Fastify, { type FastifyBaseLogger } from "fastify"; 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 { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
import { registerApi } from "./api/routes.js"; import { registerApi } from "./api/routes.js";
import { createLogger } from "./audit/log.js"; import { createLogger } from "./audit/log.js";
@@ -128,6 +131,24 @@ async function main(): Promise<void> {
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule"); 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 }); await app.listen({ host: "0.0.0.0", port: 8080 });
logger.info({ port: 8080 }, "healthcheck interne prêt"); logger.info({ port: 8080 }, "healthcheck interne prêt");
+95 -2
View File
@@ -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() { export function Review() {
return <div className="p-6 text-muted">Review (v0.24.0)</div>; const { token, logout } = useAuth();
const [assets, setAssets] = useState<Asset[]>([]);
const [error, setError] = useState<string | null>(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<void> => {
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<void> => {
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 (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Review</h2>
<button onClick={() => void refresh()} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
Rafraîchir
</button>
</div>
{error && <p role="alert" className="text-danger text-sm">{error}</p>}
{loading && <p className="text-muted text-sm">Chargement</p>}
{!loading && assets.length === 0 && <p className="text-muted text-sm">Aucun asset en attente.</p>}
<ul className="space-y-2">
{assets.map((a) => (
<li key={a.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{a.id}</span>
<span className={`text-xs ${badge(a)}`}>{a.riskTier}</span>
<span className="text-xs text-muted">{a.status} · v{a.version}</span>
{a.expiresAt && (
<span className="text-xs text-warning">
expire {new Date(a.expiresAt).toISOString().slice(0, 10)}
</span>
)}
<div className="ml-auto flex gap-2">
<button
onClick={() => void decide(a.id, "approve")}
className="rounded-md bg-success/20 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
>
Approuver
</button>
<button
onClick={() => void decide(a.id, "refuse")}
className="rounded-md bg-danger/20 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
>
Refuser
</button>
</div>
</div>
</li>
))}
</ul>
</div>
);
} }