From d824d16eedd94c0da1d74d509b6a1ca310d52e45 Mon Sep 17 00:00:00 2001 From: Kantin-Petit Date: Tue, 23 Jun 2026 11:25:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(infra):=20pr=C3=AAt=20au=20d=C3=A9ploiemen?= =?UTF-8?q?t=20GitOps=20Portainer=20+=20Telegram=20optionnel=20(v0.32.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose de prod docker-compose.prod.yml (GitOps, sans env_file, réseau proxy réel, certresolver letsencrypt) + runbook docs/deploy.md (Phase 1, users chlova restreints Portainer/n8n). Surface Telegram rendue optionnelle pour un déploiement UI-only ; garde assertHasSurface fail-closed. Typecheck + 78 tests verts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi --- .env.example | 6 +- CHANGELOG.md | 17 ++++ docs/deploy.md | 148 +++++++++++++++++++++++++++++++ infra/docker-compose.prod.yml | 139 +++++++++++++++++++++++++++++ orchestrator/src/config.ts | 21 ++++- orchestrator/src/index.ts | 40 +++++---- orchestrator/test/config.test.ts | 29 +++++- 7 files changed, 379 insertions(+), 21 deletions(-) create mode 100644 docs/deploy.md create mode 100644 infra/docker-compose.prod.yml diff --git a/.env.example b/.env.example index 8b4bc06..a71b389 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,10 @@ PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RES PORTAINER_READ_ONLY=true MCP_PORTAINER_URL=http://mcp-portainer:3000 -# ── Surface Telegram (Phase 1) ───────────────────────────────────────── -TELEGRAM_BOT_TOKEN= # SECRET — token du bot +# ── Surface Telegram (OPTIONNELLE) ───────────────────────────────────── +# Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une +# AUTRE surface (API/UI ci-dessous), sinon il refuse de démarrer (fail-closed). +TELEGRAM_BOT_TOKEN= # SECRET — token du bot (vide = pas de Telegram) TELEGRAM_ALLOWED_USER_IDS= # liste d'IDs autorisés, séparés par virgule # ── Backend CHLOVA ───────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a6aea..0728b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.32.0] — 2026-06-23 — prêt au déploiement (GitOps Portainer, Phase 1) +### Added +- **`infra/docker-compose.prod.yml`** : compose de PRODUCTION pour GitOps + Portainer. Sans `env_file` (secrets via variables de stack), réseau Traefik + réel `proxy` (external), certresolver `letsencrypt`, backend joint `proxy` + pour atteindre n8n en interne. Cible : env `vps-pogoo-002`. +- **`docs/deploy.md`** : runbook Phase 1 — dépôt git, génération secrets, + users restreints `chlova` (Portainer + n8n, étapes UI), variables de stack, + déploiement, vérification, rollback, bascule Phase 2. +- `config.assertHasSurface()` : refuse de démarrer si aucune surface (Telegram + OU API/UI) n'est configurée. 3 tests (11 au total dans config). +### Changed +- **Surface Telegram rendue optionnelle** (`telegramBotToken` optionnel ; + `index.ts` ne démarre la surface que si le token est présent). Permet le + déploiement **UI-only** voulu. `.env.example` documenté en conséquence. +- Typecheck vert, 78 tests verts. + ## [0.31.0] — 2026-06-23 — app mobile (React Native / Expo) ### Added - Package **`mobile/`** : app Expo SDK 56 (expo-router) réutilisant l'API backend. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..ea4d1af --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,148 @@ +# Déploiement CHLOVA (Phase 1 — lecture seule, GitOps Portainer) + +Procédure de mise en production réelle sur le homelab. Cible : +environnement Portainer **`vps-pogoo-002`** (endpoint 11), aux côtés de +`proxy` (Traefik), `n8n`, `jenkins`. + +> **Modèle de risque.** Phase 1 = lecture seule : `CHLOVA_PHASE=1`, +> `PORTAINER_READ_ONLY=true`, MCP read-only filtré. Aucune écriture branchée. +> Les secrets ne transitent **jamais** par l'agent : ils sont saisis par +> l'opérateur dans les variables de stack Portainer (UI) — voir §4. + +## Vue d'ensemble + +| Brique | Réseau | Exposé ? | +|---|---|---| +| `backend` (API + SPA) | `chlova-internal` + `proxy` | **Oui** — `chlova.pogoo.app` via Traefik/TLS | +| `ollama` (proxy cloud) | `chlova-internal` + `chlova-egress` | Non | +| `mcp-portainer` (sidecar) | `chlova-internal` | Non | +| `socket-proxy` (Docker RO) | `chlova-internal` | Non | +| n8n (MCP natif) | `proxy` (existant) | déjà exposé en `n8n.pogoo.app` | + +Compose de prod : [`infra/docker-compose.prod.yml`](../infra/docker-compose.prod.yml). +Build de l'image fait **par Portainer sur le VPS** (GitOps : clone du dépôt + +`docker compose build`). Contexte de build = racine du dépôt. + +--- + +## 1. Prérequis — dépôt git accessible au VPS + +GitOps exige un remote que Portainer peut cloner. Choisir un hébergeur privé +(GitHub privé, Gitea du homelab…), puis : + +```bash +git remote add origin +git push -u origin main +``` + +Noter l'URL de clone (HTTPS) + des identifiants en lecture (PAT/deploy key) pour +Portainer. + +## 2. Prérequis — secrets & login fort (générés, jamais commités) + +Générer le hash de mot de passe + secrets TOTP/JWT pour l'admin de l'UI : + +```bash +cd orchestrator +npm run provision-auth -- '' +``` + +La commande imprime `CHLOVA_ADMIN_PASSWORD_HASH`, `CHLOVA_TOTP_SECRET`, +`CHLOVA_JWT_SECRET` et un `otpauth://…` à scanner dans une app TOTP. **Conserver +ces valeurs pour l'étape 4 (UI Portainer).** Ne pas les committer. + +## 3. Prérequis — users restreints (`chlova`) + +> Le MCP Portainer n'expose pas la gestion d'utilisateurs/tokens : ces étapes se +> font dans les **UI** Portainer et n8n. Principe : CHLOVA n'accède qu'à ses +> ressources, avec des tokens à portée minimale. + +### 3a. Portainer — user `chlova` + token + +1. **Users → Add user** : `chlova`, mot de passe fort. **Non-administrateur.** +2. **Environments → vps-pogoo-002 → Access** : donner à `chlova` le rôle le plus + bas suffisant. En CE, la granularité est **par environnement** (pas par + stack) — l'accès se limite donc à ce seul environnement. *(La restriction + par-ressource fine nécessite l'édition Business ; à défaut, le verrou réel + reste `PORTAINER_READ_ONLY=true` côté sidecar.)* +3. Se connecter en tant que `chlova` → **My account → Access tokens → Add token**. + Copier le token → ce sera `PORTAINER_MCP_AUTH_TOKEN`. + +### 3b. n8n — projet + membre `chlova` + +1. **Admin → Users** : inviter/créer un utilisateur `chlova`. +2. **Projects → New project** « CHLOVA » ; ajouter `chlova` comme membre. + Y déplacer les workflows que CHLOVA doit voir (les autres restent invisibles). +3. **Settings → n8n API / MCP** : activer le serveur MCP natif, générer le + **MCP Access Token** scopé → ce sera `MCP_N8N_AUTH_TOKEN`. + Endpoint interne : `http://n8n:5678/mcp-server/http` (backend sur réseau `proxy`). + +## 4. Déploiement du stack (Portainer) + +`infra/docker-compose.prod.yml` n'utilise **aucun** `env_file` : toutes les +variables ci-dessous sont fournies comme **variables d'environnement du stack** +Portainer (les secrets y sont saisis par l'opérateur, jamais par l'agent). + +### Variables de stack à renseigner + +| Variable | Exemple / valeur | Secret ? | +|---|---|---| +| `CHLOVA_DOMAIN` | `chlova.pogoo.app` | non | +| `CHLOVA_PHASE` | `1` | non | +| `PORTAINER_READ_ONLY` | `true` | non | +| `OLLAMA_API_KEY` | clé Ollama cloud | **oui** | +| `OLLAMA_MODEL` | `qwen3:cloud` | non | +| `MCP_N8N_AUTH_TOKEN` | token MCP n8n (§3b) | **oui** | +| `PORTAINER_URL` | URL interne de l'API Portainer (cf. §4 note) | non | +| `PORTAINER_MCP_AUTH_TOKEN` | token user chlova (§3a) | **oui** | +| `CHLOVA_ADMIN_USER` | ex. `kantin` | non | +| `CHLOVA_ADMIN_PASSWORD_HASH` | (§2) | **oui** | +| `CHLOVA_TOTP_SECRET` | (§2) | **oui** | +| `CHLOVA_JWT_SECRET` | (§2) | **oui** | + +> **`PORTAINER_URL`** = URL **publique** du serveur Portainer. Topologie réelle : +> le serveur Portainer (`portainer-ce`) tourne sur l'hôte `local`, tandis que +> CHLOVA et son sidecar tournent sur `vps-pogoo-002` (un **autre hôte**). Le +> sidecar ne peut donc pas joindre Portainer en interne : utiliser l'URL publique +> (la même que celle configurée pour le MCP `portainer-pogoo`), p. ex. +> `https://.pogoo.app`. Pas de `:9443` interne ici. + +### Procédure UI (recommandée — l'opérateur saisit les secrets) + +**Stacks → Add stack → Git repository** : +- Repository URL = remote de l'étape 1 ; Reference = `refs/heads/main` ; + Compose path = `infra/docker-compose.prod.yml`. +- Authentication = identifiants de lecture (§1). +- Environment variables = tableau ci-dessus. +- **Deploy**. Portainer clone, build l'image et lance le stack. + +### Procédure assistée (MCP) + +CHLOVA peut créer le stack via `StackCreateDockerStandaloneRepository` une fois +le remote prêt — **mais** les valeurs secrètes ne doivent pas transiter par +l'agent. Schéma retenu : l'agent crée le stack avec les variables **non +secrètes** et l'opérateur ajoute/édite les secrètes dans l'UI avant le 1er +déploiement, **ou** l'opérateur fait l'étape UI ci-dessus de bout en bout. + +## 5. Vérification (post-déploiement) + +1. Portainer → le stack `chlova` est *running* ; conteneurs `backend`, + `ollama`, `mcp-portainer`, `socket-proxy` *up*. +2. Logs `backend` : `API/UI activée (auth configurée)` + `healthcheck interne + prêt`. Pas d'erreur de config fail-closed. +3. `https://chlova.pogoo.app` répond (certificat `letsencrypt` émis) → page de + login (mot de passe + TOTP). +4. Connexion → l'onglet Chat répond ; les outils MCP read-only (n8n/Portainer) + sont listés dans l'état (header UI). + +## 6. Rollback + +GitOps : revert du commit compose + redeploy, ou **Stacks → chlova → Stop/Remove**. +Le volume `chlova-data` (SQLite) persiste ; le supprimer pour repartir de zéro. + +## 7. Passage en Phase 2 (plus tard, hors de cette procédure) + +Écriture sous gatekeeper + need-review : `CHLOVA_PHASE=2` et +`PORTAINER_READ_ONLY=false` (le sidecar autorise alors les mutations, toujours +filtrées par le gatekeeper + paliers de risque). À ne faire qu'après validation +du cerveau en lecture seule. Voir [`docs/need-review.md`](./need-review.md). diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml new file mode 100644 index 0000000..5f8b188 --- /dev/null +++ b/infra/docker-compose.prod.yml @@ -0,0 +1,139 @@ +# CHLOVA — compose de PRODUCTION (déploiement GitOps via Portainer). +# +# Différences avec ../infra/docker-compose.yml (dev local) : +# • Aucun `env_file` : le clone git ne contient PAS de .env. TOUS les secrets +# et réglages arrivent par les VARIABLES DE STACK Portainer (interpolation +# ${VAR}). L'agent ne voit jamais de secret en clair (CLAUDE.md). +# • Réseau Traefik réel du homelab = `proxy` (external), pas `traefik-public`. +# • certresolver réel = `letsencrypt` (cf. stack proxy), pas `le`. +# • Le backend rejoint `proxy` pour joindre n8n en interne (http://n8n:5678). +# +# Cible : environnement Portainer `vps-pogoo-002` (endpoint 11). +# Build : Portainer clone ce dépôt et build l'image sur le VPS (GitOps). +# → chemin compose = infra/docker-compose.prod.yml ; contexte build = racine. +# +# Phase 1 (lecture seule) : CHLOVA_PHASE=1, PORTAINER_READ_ONLY=true. +# Voir docs/deploy.md pour la procédure complète + variables de stack à fournir. + +name: chlova + +services: + # ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ────── + ollama: + image: ollama/ollama:0.6.8 + restart: unless-stopped + environment: + OLLAMA_API_KEY: ${OLLAMA_API_KEY:?OLLAMA_API_KEY requis} + OLLAMA_HOST: 0.0.0.0:11434 + volumes: + - ollama-data:/root/.ollama + networks: + - chlova-internal + - chlova-egress + # AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé. + + # ── socket-proxy : accès Docker filtré, LECTURE SEULE (Phase 1) ───────── + socket-proxy: + image: tecnativa/docker-socket-proxy:0.3.0 + restart: unless-stopped + environment: + CONTAINERS: 1 + IMAGES: 1 + NETWORKS: 1 + VOLUMES: 1 + SERVICES: 1 + TASKS: 1 + NODES: 1 + INFO: 1 + VERSION: 1 + POST: 0 + EXEC: 0 + AUTH: 0 + SECRETS: 0 + CONFIGS: 0 + BUILD: 0 + COMMIT: 0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - chlova-internal + # AUCUN port publié. + + # ── MCP Portainer (portainer/portainer-mcp) — read-only en Phase 1 ────── + mcp-portainer: + image: portainer/portainer-mcp:0.6.0 + restart: unless-stopped + environment: + PORTAINER_URL: ${PORTAINER_URL:?PORTAINER_URL requis} # URL PUBLIQUE Portainer (serveur sur autre hôte — cf. deploy.md) + PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # token user chlova restreint + PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : NE PAS passer à false + DOCKER_HOST: tcp://socket-proxy:2375 + depends_on: + - socket-proxy + networks: + - chlova-internal + # AUCUN port publié. + + # ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ────────────── + backend: + build: + context: .. # racine du dépôt (image = API + SPA web) + dockerfile: orchestrator/Dockerfile + image: chlova/backend:0.2.0 + restart: unless-stopped + environment: + # — Runtime / phase — + CHLOVA_ENV: ${CHLOVA_ENV:-production} + CHLOVA_LOG_LEVEL: ${CHLOVA_LOG_LEVEL:-info} + CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule + CHLOVA_DB_PATH: ${CHLOVA_DB_PATH:-/app/data/chlova.db} + # — Ollama (cloud proxy) — + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434} + OLLAMA_API_KEY: ${OLLAMA_API_KEY:?requis} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:cloud} + # — MCP n8n (natif, interne via réseau proxy) — + MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n:5678/mcp-server/http} + MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis} + # — MCP Portainer (sidecar) — + MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000} + PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} + PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} + # — Alertes (Phase 3) : vide = log-only — + ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} + # — API/UI (surface exposée) : login fort — + CHLOVA_ADMIN_USER: ${CHLOVA_ADMIN_USER:?requis} + CHLOVA_ADMIN_PASSWORD_HASH: ${CHLOVA_ADMIN_PASSWORD_HASH:?requis} + CHLOVA_TOTP_SECRET: ${CHLOVA_TOTP_SECRET:?requis} + CHLOVA_JWT_SECRET: ${CHLOVA_JWT_SECRET:?requis} + # — Auto-extension (Phase 5) : off par défaut — + CHLOVA_AUTOEXT_ENABLED: ${CHLOVA_AUTOEXT_ENABLED:-false} + CHLOVA_REPO_ROOT: ${CHLOVA_REPO_ROOT:-/app/repo} + volumes: + - chlova-data:/app/data # SQLite (table assets, P2+) + depends_on: + - ollama + - mcp-portainer + networks: + - chlova-internal + - proxy # joint Traefik + n8n (réseau homelab) + labels: + traefik.enable: "true" + traefik.docker.network: proxy + traefik.http.routers.chlova.rule: Host(`${CHLOVA_DOMAIN:-chlova.pogoo.app}`) + traefik.http.routers.chlova.entrypoints: websecure + traefik.http.routers.chlova.tls: "true" + traefik.http.routers.chlova.tls.certresolver: letsencrypt + traefik.http.services.chlova.loadbalancer.server.port: "8080" + +networks: + chlova-internal: + internal: true # aucune route vers l'extérieur + chlova-egress: + driver: bridge # sortie contrôlée (Ollama → ollama.com) + proxy: + name: proxy + external: true # réseau Traefik existant du homelab (cf. stacks proxy/n8n) + +volumes: + ollama-data: + chlova-data: diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index c7361c3..4264a1e 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -45,8 +45,10 @@ const schema = z.object({ .default("true") .transform((v) => v.toLowerCase() !== "false"), - // Surface Telegram - telegramBotToken: nonEmpty, // SECRET + // Surface Telegram (OPTIONNELLE) : si le token est absent, la surface n'est + // pas démarrée. Le boot exige alors une autre surface (API/UI) — voir + // assertHasSurface(). Permet un déploiement UI-only sans bot Telegram. + telegramBotToken: z.string().optional(), // SECRET telegramAllowedUserIds: z .string() .default("") @@ -162,6 +164,21 @@ export function assertReadOnlyPhase(cfg: Config): void { } } +/** + * Au moins une surface doit être active, sinon le cerveau tourne sans entrée : + * démarrage refusé (fail-closed). Surfaces possibles : Telegram (token présent) + * ou API/UI (auth complète). Évite un déploiement « muet » par mégarde. + */ +export function assertHasSurface(cfg: Config): void { + if (!cfg.telegramBotToken && !apiAuth(cfg)) { + throw new Error( + "Aucune surface configurée : fournis TELEGRAM_BOT_TOKEN (bot) " + + "ou les 4 variables API/UI (CHLOVA_ADMIN_USER/_PASSWORD_HASH/" + + "_TOTP_SECRET/_JWT_SECRET). Voir docs/deploy.md.", + ); + } +} + /** * 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). diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 509ab22..3a952a8 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -2,7 +2,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 { loadConfig, assertReadOnlyPhase, assertHasSurface, redactedConfig, apiAuth } from "./config.js"; import { registerApi } from "./api/routes.js"; import { createLogger } from "./audit/log.js"; import { OllamaClient } from "./llm/ollama.js"; @@ -34,6 +34,7 @@ import { TelegramSurface } from "./surfaces/telegram.js"; async function main(): Promise { const cfg = loadConfig(); assertReadOnlyPhase(cfg); + assertHasSurface(cfg); const logger = createLogger(cfg.logLevel); logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage"); @@ -109,14 +110,18 @@ async function main(): Promise { }); const chat = new ChatService({ client, tools, guard, systemPrompt, logger }); - // ── Surface Telegram (long-polling) ───────────────────────────────────── - const telegram = new TelegramSurface( - { - botToken: cfg.telegramBotToken, - allowedUserIds: cfg.telegramAllowedUserIds, - }, - logger, - ); + // ── Surface Telegram (long-polling) — optionnelle ─────────────────────── + // Démarrée seulement si un token est fourni. Sinon, surface API/UI seule + // (garantie par assertHasSurface au boot). + const telegram = cfg.telegramBotToken + ? new TelegramSurface( + { + botToken: cfg.telegramBotToken, + allowedUserIds: cfg.telegramAllowedUserIds, + }, + logger, + ) + : null; // ── Healthcheck interne (jamais publié) ───────────────────────────────── // pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le @@ -166,7 +171,7 @@ async function main(): Promise { // Arrêt propre. const shutdown = async (): Promise => { - telegram.stop(); + telegram?.stop(); stopCron?.(); stopAlerts?.(); await registry.close(); @@ -178,12 +183,15 @@ async function main(): Promise { process.on("SIGINT", () => void shutdown()); // Boucle de service : commande de review (Phase 2) ou tour d'agent. - await telegram.start(async ({ userId, text }) => { - if (review && isCommand(text)) { - return handleReviewCommand(review, text); - } - return chat.handle(`telegram:${userId}`, text); - }); + // Sans Telegram, le process reste vivant via le serveur Fastify (API/UI). + if (telegram) { + await telegram.start(async ({ userId, text }) => { + if (review && isCommand(text)) { + return handleReviewCommand(review, text); + } + return chat.handle(`telegram:${userId}`, text); + }); + } } main().catch((err: unknown) => { diff --git a/orchestrator/test/config.test.ts b/orchestrator/test/config.test.ts index 6ffbf7d..ccd7304 100644 --- a/orchestrator/test/config.test.ts +++ b/orchestrator/test/config.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { loadConfig, assertReadOnlyPhase, redactedConfig } from "../src/config.js"; +import { + loadConfig, + assertReadOnlyPhase, + assertHasSurface, + redactedConfig, +} from "../src/config.js"; const fullEnv = (): NodeJS.ProcessEnv => ({ OLLAMA_BASE_URL: "http://ollama:11434", @@ -61,6 +66,28 @@ describe("verrou lecture seule Phase 1", () => { }); }); +describe("garde de surface (fail-closed)", () => { + it("Telegram seul suffit", () => { + expect(() => assertHasSurface(loadConfig(fullEnv()))).not.toThrow(); + }); + + it("API/UI seule suffit (sans Telegram)", () => { + const env = fullEnv(); + delete env.TELEGRAM_BOT_TOKEN; + env.CHLOVA_ADMIN_USER = "kantin"; + env.CHLOVA_ADMIN_PASSWORD_HASH = "hash"; + env.CHLOVA_TOTP_SECRET = "totp"; + env.CHLOVA_JWT_SECRET = "jwt"; + expect(() => assertHasSurface(loadConfig(env))).not.toThrow(); + }); + + it("refuse de démarrer sans aucune surface", () => { + const env = fullEnv(); + delete env.TELEGRAM_BOT_TOKEN; + expect(() => assertHasSurface(loadConfig(env))).toThrow(/surface/i); + }); +}); + describe("redactedConfig masque les secrets", () => { it("ne révèle aucun secret", () => { const red = redactedConfig(loadConfig(fullEnv()));