diff --git a/CHANGELOG.md b/CHANGELOG.md index 13bf048..91d85df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.20.0] — 2026-06-23 +### Added +- `src/api/routes.ts` : API HTTP (`registerApi`) — `POST /api/auth/login` + (rate-limit serré), JWT Bearer sur le reste, `POST /api/chat`, `GET /api/review`, + `POST /api/review/:id/{approve,refuse}`, `GET /api/state`. CORS restreint + + rate-limit global. Réutilise `ChatService` + `ReviewService`. +- Config auth API (`CHLOVA_ADMIN_USER/_PASSWORD_HASH/_TOTP_SECRET/_JWT_SECRET`, + `CHLOVA_WEB_ORIGIN`) + helper `apiAuth()` ; API activée seulement si configurée. +- Câblage `index.ts` (API montée si auth présente). Deps `@fastify/cors`, + `@fastify/rate-limit` épinglées. 5 tests API (inject). 65 tests, 0 vuln. + ## [0.19.0] — 2026-06-23 — début Phase 4 (UI : auth + service chat) ### Added - `src/api/auth.ts` : auth surface exposée — mot de passe scrypt (`node:crypto`), diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 0555226..032ed21 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -8,10 +8,12 @@ "name": "chlova-orchestrator", "version": "0.1.0", "dependencies": { + "@fastify/cors": "^11.2.0", + "@fastify/rate-limit": "^11.0.0", "@modelcontextprotocol/sdk": "1.29.0", "fastify": "5.8.5", - "jose": "^6.2.3", - "otplib": "^13.4.1", + "jose": "6.2.3", + "otplib": "13.4.1", "pino": "10.3.1", "zod": "3.24.1" }, @@ -522,6 +524,26 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -612,6 +634,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz", + "integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -631,6 +674,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1867,6 +1919,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index cb7bbb1..a924ef1 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -16,6 +16,8 @@ "test:watch": "vitest" }, "dependencies": { + "@fastify/cors": "11.2.0", + "@fastify/rate-limit": "11.0.0", "@modelcontextprotocol/sdk": "1.29.0", "fastify": "5.8.5", "jose": "6.2.3", diff --git a/orchestrator/src/api/routes.ts b/orchestrator/src/api/routes.ts new file mode 100644 index 0000000..73a8840 --- /dev/null +++ b/orchestrator/src/api/routes.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import cors from "@fastify/cors"; +import rateLimit from "@fastify/rate-limit"; +import type { ChatService } from "../agent/chat-service.js"; +import type { ReviewService } from "../gatekeeper/review.js"; +import { login, verifyJwt, type AuthConfig } from "./auth.js"; + +/** + * API HTTP de la surface exposée (Phase 4). JWT obligatoire sauf /api/auth/login. + * Réutilise ChatService (même comportement que Telegram) et ReviewService. + * + * Sécurité : login rate-limité (anti brute-force), JWT Bearer sur tout le reste, + * CORS restreint à l'origine du SPA (dev) — same-origin en prod. + */ +export interface ApiDeps { + auth: AuthConfig; + chat: ChatService; + review: ReviewService | null; + state: () => Record; + webOrigin?: string | undefined; +} + +export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise { + await app.register(cors, { + origin: deps.webOrigin ?? false, + methods: ["GET", "POST"], + allowedHeaders: ["content-type", "authorization"], + }); + await app.register(rateLimit, { max: 120, timeWindow: "1 minute" }); + + // Garde JWT : 401 si Bearer absent/invalide. + const requireAuth = async (req: FastifyRequest, reply: FastifyReply): Promise => { + const header = req.headers.authorization ?? ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : ""; + const sub = token ? await verifyJwt(token, deps.auth.jwtSecret) : null; + if (!sub) { + await reply.code(401).send({ error: "non autorisé" }); + } + }; + + // ── Login (rate-limit serré, anti brute-force) ────────────────────────── + app.post( + "/api/auth/login", + { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = (req.body ?? {}) as Record; + const user = String(body.user ?? ""); + const password = String(body.password ?? ""); + const totp = String(body.totp ?? ""); + const token = await login(deps.auth, { user, password, totp }); + if (!token) return reply.code(401).send({ error: "identifiants invalides" }); + return { token }; + }, + ); + + // ── Chat ──────────────────────────────────────────────────────────────── + app.post("/api/chat", { preHandler: requireAuth }, async (req, reply) => { + const body = (req.body ?? {}) as Record; + const message = String(body.message ?? "").trim(); + if (!message) return reply.code(400).send({ error: "message vide" }); + const replyText = await deps.chat.handle("api:owner", message); + return { reply: replyText }; + }); + + // ── Review ──────────────────────────────────────────────────────────────── + app.get("/api/review", { preHandler: requireAuth }, async () => { + return { assets: deps.review ? deps.review.listPending() : [] }; + }); + + const decide = (action: "approve" | "refuse") => + async (req: FastifyRequest, reply: FastifyReply) => { + if (!deps.review) return reply.code(409).send({ error: "review indisponible (Phase 1)" }); + const id = (req.params as { id: string }).id; + try { + const asset = action === "approve" ? deps.review.approve(id) : deps.review.refuse(id); + return { asset }; + } catch { + return reply.code(404).send({ error: "asset inconnu" }); + } + }; + app.post("/api/review/:id/approve", { preHandler: requireAuth }, decide("approve")); + app.post("/api/review/:id/refuse", { preHandler: requireAuth }, decide("refuse")); + + // ── État ──────────────────────────────────────────────────────────────── + app.get("/api/state", { preHandler: requireAuth }, async () => deps.state()); +} diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index 8d6060d..c3c3eae 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -67,6 +67,17 @@ const schema = z.object({ (v) => (typeof v === "string" && v.length > 0 ? v : undefined), z.string().url().optional(), ), + + // API/UI (Phase 4) — surface exposée, login fort. L'API n'est activée que si + // ces 4 valeurs sont présentes (apiAuth()). Tout optionnel sinon. + adminUser: z.string().optional(), + adminPasswordHash: z.string().optional(), // SECRET — hash scrypt (cf. scripts/hash-password) + totpSecret: z.string().optional(), // SECRET + jwtSecret: z.string().optional(), // SECRET + webOrigin: z.preprocess( + (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 }); export type Config = z.infer; @@ -78,6 +89,9 @@ const SECRET_KEYS = new Set([ "portainerMcpAuthToken", "telegramBotToken", "alertWebhookUrl", + "adminPasswordHash", + "totpSecret", + "jwtSecret", ]); export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { @@ -97,6 +111,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS, dbPath: env.CHLOVA_DB_PATH, alertWebhookUrl: env.ALERT_WEBHOOK_URL, + adminUser: env.CHLOVA_ADMIN_USER, + adminPasswordHash: env.CHLOVA_ADMIN_PASSWORD_HASH, + totpSecret: env.CHLOVA_TOTP_SECRET, + jwtSecret: env.CHLOVA_JWT_SECRET, + webOrigin: env.CHLOVA_WEB_ORIGIN, }); if (!parsed.success) { @@ -130,6 +149,22 @@ export function assertReadOnlyPhase(cfg: Config): void { } } +/** + * Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null + * (API/UI désactivée — surface non exposée). + */ +export function apiAuth(cfg: Config): import("./api/auth.js").AuthConfig | null { + if (cfg.adminUser && cfg.adminPasswordHash && cfg.totpSecret && cfg.jwtSecret) { + return { + adminUser: cfg.adminUser, + adminPasswordHash: cfg.adminPasswordHash, + totpSecret: cfg.totpSecret, + jwtSecret: cfg.jwtSecret, + }; + } + return null; +} + /** Vue de la config sûre pour les logs : secrets masqués. */ export function redactedConfig(cfg: Config): Record { const out: Record = {}; diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 874f545..22427d0 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -1,5 +1,6 @@ -import Fastify from "fastify"; -import { loadConfig, assertReadOnlyPhase, redactedConfig } from "./config.js"; +import Fastify, { type FastifyBaseLogger } from "fastify"; +import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js"; +import { registerApi } from "./api/routes.js"; import { createLogger } from "./audit/log.js"; import { OllamaClient } from "./llm/ollama.js"; import { McpRegistry } from "./mcp/registry.js"; @@ -103,12 +104,30 @@ async function main(): Promise { ); // ── Healthcheck interne (jamais publié) ───────────────────────────────── - const app = Fastify({ loggerInstance: logger }); - app.get("/health", async () => ({ - status: "ok", + // pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le + // type d'instance avec registerApi (comportement runtime identique). + const app = Fastify({ loggerInstance: logger as unknown as FastifyBaseLogger }); + const stateOf = () => ({ phase: cfg.phase === 2 ? "2-write-review" : "1-readonly", tools: tools.length, - })); + }); + app.get("/health", async () => ({ status: "ok", ...stateOf() })); + + // API/UI : activée seulement si l'auth est configurée (surface exposée). + const auth = apiAuth(cfg); + if (auth) { + await registerApi(app, { + auth, + chat, + review, + state: stateOf, + webOrigin: cfg.webOrigin, + }); + logger.info("API/UI activée (auth configurée)"); + } else { + logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule"); + } + await app.listen({ host: "0.0.0.0", port: 8080 }); logger.info({ port: 8080 }, "healthcheck interne prêt"); diff --git a/orchestrator/test/api.test.ts b/orchestrator/test/api.test.ts new file mode 100644 index 0000000..a3f1f8c --- /dev/null +++ b/orchestrator/test/api.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import { generateSync, generateSecret } from "otplib"; +import { registerApi } from "../src/api/routes.js"; +import { hashPassword, type AuthConfig } from "../src/api/auth.js"; +import { ChatService } from "../src/agent/chat-service.js"; +import { ReviewService } from "../src/gatekeeper/review.js"; +import { AssetRepository } from "../src/gatekeeper/repository.js"; +import { createAsset } from "../src/gatekeeper/assets.js"; +import { createLogger } from "../src/audit/log.js"; + +const log = createLogger("silent"); +const auth: AuthConfig = { + adminUser: "kantin", + adminPasswordHash: hashPassword("pw"), + totpSecret: generateSecret(), + jwtSecret: "jwt-secret-suffisamment-long-pour-hs256", +}; + +// ChatService stub : pas de vrai LLM. +const chatStub = { + handle: async (_actor: string, text: string) => `echo:${text}`, +} as unknown as ChatService; + +let app: FastifyInstance; +let repo: AssetRepository; + +beforeEach(async () => { + repo = new AssetRepository(":memory:"); + const review = new ReviewService(repo, log); + app = Fastify(); + await registerApi(app, { auth, chat: chatStub, review, state: () => ({ phase: "2-write-review", tools: 3 }) }); + await app.ready(); +}); +afterEach(async () => { + await app.close(); + repo.close(); +}); + +async function tokenOf(): Promise { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) }, + }); + return res.json().token as string; +} + +describe("API auth", () => { + it("login renvoie un token avec bons facteurs", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().token).toBeTruthy(); + }); + + it("login 401 avec mauvais mot de passe", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { user: "kantin", password: "bad", totp: generateSync({ secret: auth.totpSecret }) }, + }); + expect(res.statusCode).toBe(401); + }); + + it("/api/chat refuse sans token (401)", async () => { + const res = await app.inject({ method: "POST", url: "/api/chat", payload: { message: "salut" } }); + expect(res.statusCode).toBe(401); + }); + + it("/api/chat répond avec token", async () => { + const token = await tokenOf(); + const res = await app.inject({ + method: "POST", + url: "/api/chat", + headers: { authorization: `Bearer ${token}` }, + payload: { message: "salut" }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().reply).toBe("echo:salut"); + }); +}); + +describe("API review", () => { + it("liste, approuve et refuse", async () => { + repo.create(createAsset({ id: "tool:x", type: "tool", version: "1.0.0", riskTier: "privileged" })); + const token = await tokenOf(); + const auth_h = { authorization: `Bearer ${token}` }; + + const list = await app.inject({ method: "GET", url: "/api/review", headers: auth_h }); + expect(list.json().assets).toHaveLength(1); + + const ok = await app.inject({ method: "POST", url: "/api/review/tool:x/approve", headers: auth_h }); + expect(ok.json().asset.status).toBe("approuvé"); + + const missing = await app.inject({ method: "POST", url: "/api/review/nope/refuse", headers: auth_h }); + expect(missing.statusCode).toBe(404); + }); +});