feat(infra): prêt au déploiement GitOps Portainer + Telegram optionnel (v0.32.0)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
This commit is contained in:
+4
-2
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
+148
@@ -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 <URL_DEPOT_PRIVE>
|
||||
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 -- <admin_user> '<mot_de_passe_fort>'
|
||||
```
|
||||
|
||||
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://<portainer>.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).
|
||||
@@ -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:
|
||||
@@ -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).
|
||||
|
||||
+24
-16
@@ -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<void> {
|
||||
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<void> {
|
||||
});
|
||||
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<void> {
|
||||
|
||||
// Arrêt propre.
|
||||
const shutdown = async (): Promise<void> => {
|
||||
telegram.stop();
|
||||
telegram?.stop();
|
||||
stopCron?.();
|
||||
stopAlerts?.();
|
||||
await registry.close();
|
||||
@@ -178,12 +183,15 @@ async function main(): Promise<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user