diff --git a/docker-compose.yml b/docker-compose.yml
index 3f6c03c..a0410d0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,10 +18,12 @@ services:
- ADMIN_PASSWORD=Itinova69!
- GITEA_URL=https://git.recette.santinova-soft.org
- GITEA_USERNAME=manus-admin
- - GITEA_PASSWORD=ManusGitea2026!
- - GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
+ - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
- GITEA_RECETTE_URL=http://gitea:3000
- - GITEA_PASSWORD_PROD=ManusGitea2026!
+ - GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
+ - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur}
+ - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}
+ - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-}
- APPS_BASE_PATH=/opt/manus-deploy/apps
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
# Contrôles bornés et dédoublonnés par healthcheck.js.
diff --git a/ops/gitea-token-rotation-apply.path b/ops/gitea-token-rotation-apply.path
new file mode 100644
index 0000000..7bc8e58
--- /dev/null
+++ b/ops/gitea-token-rotation-apply.path
@@ -0,0 +1,9 @@
+[Unit]
+Description=Surveillance de la validation manuelle du jeton Gitea Santinova
+
+[Path]
+PathExists=/opt/manus-deploy/secrets/gitea-token-rotation/approval.request
+Unit=gitea-token-rotation-apply.service
+
+[Install]
+WantedBy=multi-user.target
diff --git a/ops/gitea-token-rotation-apply.service b/ops/gitea-token-rotation-apply.service
new file mode 100644
index 0000000..f59472e
--- /dev/null
+++ b/ops/gitea-token-rotation-apply.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Application manuelle validée du jeton Gitea Santinova
+After=docker.service network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh apply
+User=root
+Group=root
+UMask=0077
diff --git a/ops/gitea-token-rotation.service b/ops/gitea-token-rotation.service
new file mode 100644
index 0000000..e20b21b
--- /dev/null
+++ b/ops/gitea-token-rotation.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Préparation contrôlée de la rotation du jeton Gitea Santinova
+After=docker.service network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh prepare
+User=root
+Group=root
+UMask=0077
+Nice=10
diff --git a/ops/gitea-token-rotation.sh b/ops/gitea-token-rotation.sh
new file mode 100644
index 0000000..d100925
--- /dev/null
+++ b/ops/gitea-token-rotation.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+readonly COMMAND="${1:-status}"
+readonly FORCE="${2:-}"
+readonly GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
+readonly GITEA_URL="${GITEA_URL:-https://git.recette.santinova-soft.org}"
+readonly GITEA_USER="${GITEA_USERNAME:-manus-admin}"
+readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
+readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
+readonly STATE_DIR="${GITEA_ROTATION_STATE_DIR:-/opt/manus-deploy/secrets/gitea-token-rotation}"
+readonly STATUS_FILE="${GITEA_ROTATION_STATUS_FILE:-/var/log/manus-deploy/gitea-token-rotation.json}"
+readonly PENDING_TOKEN_FILE="${STATE_DIR}/pending.token"
+readonly PENDING_NAME_FILE="${STATE_DIR}/pending.name"
+readonly CURRENT_NAME_FILE="${STATE_DIR}/current.name"
+readonly LAST_ROTATION_FILE="${STATE_DIR}/last-rotation"
+readonly LOCK_FILE="${STATE_DIR}/rotation.lock"
+readonly APPROVAL_FILE="${STATE_DIR}/approval.request"
+readonly TOKEN_PREFIX="santinova-recette"
+readonly MAX_AGE_DAYS="90"
+
+install -d -m 0700 "${STATE_DIR}"
+install -d -m 0750 "$(dirname "${STATUS_FILE}")"
+exec 9>"${LOCK_FILE}"
+flock -n 9 || { echo "Une opération de rotation Gitea est déjà en cours." >&2; exit 1; }
+
+json_escape() {
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]/ /g'
+}
+
+write_status() {
+ local state="$1"
+ local message="$2"
+ local token_name="${3:-}"
+ local temporary
+ temporary="$(mktemp "${STATUS_FILE}.XXXXXX")"
+ chmod 0640 "${temporary}"
+ printf '{"state":"%s","message":"%s","tokenName":"%s","updatedAt":"%s"}\n' \
+ "$(json_escape "${state}")" \
+ "$(json_escape "${message}")" \
+ "$(json_escape "${token_name}")" \
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${temporary}"
+ mv "${temporary}" "${STATUS_FILE}"
+}
+
+read_env_token() {
+ sed -n -E 's/^GITEA_TOKEN=(.*)$/\1/p' "${DASHBOARD_ENV_FILE}" | tail -n 1
+}
+
+validate_token() {
+ local token="$1"
+ local response
+ response="$(mktemp /tmp/gitea-token-validation.XXXXXX)"
+ trap 'rm -f "${response}"' RETURN
+ local status
+ status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")"
+ [[ "${status}" == "200" ]] || return 1
+ grep -q '"login":"manus-admin"' "${response}"
+ status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/manus-dashboard")"
+ [[ "${status}" == "200" ]] || return 1
+ status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")"
+ [[ "${status}" == "200" ]]
+}
+
+rotation_due() {
+ [[ "${FORCE}" == "--force" ]] && return 0
+ [[ -f "${LAST_ROTATION_FILE}" ]] || return 0
+ local last_epoch now_epoch max_age_seconds
+ last_epoch="$(date -d "$(<"${LAST_ROTATION_FILE}")" +%s 2>/dev/null || echo 0)"
+ now_epoch="$(date +%s)"
+ max_age_seconds="$((MAX_AGE_DAYS * 86400))"
+ (( now_epoch - last_epoch >= max_age_seconds ))
+}
+
+prepare_rotation() {
+ if [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]]; then
+ local existing_pending
+ existing_pending="$(<"${PENDING_TOKEN_FILE}")"
+ validate_token "${existing_pending}"
+ write_status "pending_approval" "Un jeton préparé et validé attend déjà une approbation manuelle." "$(<"${PENDING_NAME_FILE}")"
+ echo "GITEA_ROTATION_PENDING"
+ return 0
+ fi
+ if ! rotation_due; then
+ write_status "current" "La rotation trimestrielle n’est pas encore requise." "$(cat "${CURRENT_NAME_FILE}" 2>/dev/null || true)"
+ echo "GITEA_ROTATION_NOT_DUE"
+ return 0
+ fi
+ local current_token token_name pending_token
+ current_token="$(read_env_token)"
+ [[ -n "${current_token}" ]] || { write_status "failed" "Jeton actif absent du fichier .env serveur."; exit 1; }
+ validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe pas les contrôles Gitea."; exit 1; }
+ token_name="${TOKEN_PREFIX}-$(date -u +%Y%m%d-%H%M%S)"
+ pending_token="$(docker exec --user git "${GITEA_CONTAINER}" gitea admin user generate-access-token \
+ --username "${GITEA_USER}" --token-name "${token_name}" --scopes all --raw)"
+ [[ -n "${pending_token}" ]] || { write_status "failed" "Gitea n’a pas généré de nouveau jeton." "${token_name}"; exit 1; }
+ printf '%s' "${pending_token}" > "${PENDING_TOKEN_FILE}"
+ printf '%s' "${token_name}" > "${PENDING_NAME_FILE}"
+ chmod 0600 "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
+ if ! validate_token "${pending_token}"; then
+ rm -f "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
+ write_status "failed" "Le nouveau jeton a été créé mais n’a pas passé les contrôles." "${token_name}"
+ exit 1
+ fi
+ write_status "pending_approval" "Nouveau jeton créé et validé. Approbation manuelle requise avant basculement." "${token_name}"
+ echo "GITEA_ROTATION_PREPARED"
+}
+
+apply_rotation() {
+ trap 'rm -f "${APPROVAL_FILE}"' EXIT
+ [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]] || { write_status "failed" "Aucun jeton préparé à appliquer."; exit 1; }
+ local pending_token pending_name current_token backup
+ pending_token="$(<"${PENDING_TOKEN_FILE}")"
+ pending_name="$(<"${PENDING_NAME_FILE}")"
+ current_token="$(read_env_token)"
+ validate_token "${pending_token}" || { write_status "failed" "Le jeton en attente n’est plus valide." "${pending_name}"; exit 1; }
+ validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe plus les contrôles préalables." "${pending_name}"; exit 1; }
+ backup="$(mktemp "${STATE_DIR}/dashboard.env.backup.XXXXXX")"
+ chmod 0600 "${backup}"
+ cp "${DASHBOARD_ENV_FILE}" "${backup}"
+ sed -i -E "s/^GITEA_TOKEN=.*/GITEA_TOKEN=${pending_token}/" "${DASHBOARD_ENV_FILE}"
+ if ! grep -q '^GITEA_TOKEN=' "${DASHBOARD_ENV_FILE}"; then
+ printf '\nGITEA_TOKEN=%s\n' "${pending_token}" >> "${DASHBOARD_ENV_FILE}"
+ fi
+ write_status "applying" "Basculement du dashboard vers le jeton validé." "${pending_name}"
+ if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null && sleep 8 && validate_token "${pending_token}"); then
+ cp "${backup}" "${DASHBOARD_ENV_FILE}"
+ (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
+ rm -f "${backup}"
+ write_status "rollback" "Le basculement a échoué ; l’ancien jeton a été restauré." "${pending_name}"
+ exit 1
+ fi
+ rm -f "${backup}" "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
+ printf '%s' "${pending_name}" > "${CURRENT_NAME_FILE}"
+ date -u +%Y-%m-%dT%H:%M:%SZ > "${LAST_ROTATION_FILE}"
+ chmod 0600 "${CURRENT_NAME_FILE}" "${LAST_ROTATION_FILE}"
+ write_status "active_revoke_previous" "Basculement validé. Révoquer manuellement l’ancien jeton dans Gitea après le contrôle final." "${pending_name}"
+ echo "GITEA_ROTATION_APPLIED"
+}
+
+case "${COMMAND}" in
+ prepare) prepare_rotation ;;
+ apply) apply_rotation ;;
+ status)
+ if [[ -f "${STATUS_FILE}" ]]; then cat "${STATUS_FILE}"; else write_status "not_configured" "Aucune rotation n’a encore été préparée."; cat "${STATUS_FILE}"; fi
+ ;;
+ *) echo "Usage: $0 {prepare [--force]|apply|status}" >&2; exit 2 ;;
+esac
diff --git a/ops/gitea-token-rotation.timer b/ops/gitea-token-rotation.timer
new file mode 100644
index 0000000..34e35b6
--- /dev/null
+++ b/ops/gitea-token-rotation.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Préparation trimestrielle du jeton Gitea Santinova
+
+[Timer]
+OnCalendar=*-01,04,07,10-01 07:00:00
+Persistent=true
+RandomizedDelaySec=30m
+Unit=gitea-token-rotation.service
+
+[Install]
+WantedBy=timers.target
diff --git a/ops/install-gitea-token-rotation.sh b/ops/install-gitea-token-rotation.sh
new file mode 100644
index 0000000..571a59e
--- /dev/null
+++ b/ops/install-gitea-token-rotation.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+install -D -m 0700 "${SCRIPT_DIR}/gitea-token-rotation.sh" /opt/manus-deploy/scripts/gitea-token-rotation.sh
+install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.service" /etc/systemd/system/gitea-token-rotation.service
+install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.timer" /etc/systemd/system/gitea-token-rotation.timer
+install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.service" /etc/systemd/system/gitea-token-rotation-apply.service
+install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.path" /etc/systemd/system/gitea-token-rotation-apply.path
+install -d -m 0700 /opt/manus-deploy/secrets/gitea-token-rotation
+install -d -m 0750 /var/log/manus-deploy
+systemctl daemon-reload
+systemctl enable --now gitea-token-rotation.timer
+systemctl enable --now gitea-token-rotation-apply.path
+systemctl is-enabled gitea-token-rotation.timer
+systemctl is-enabled gitea-token-rotation-apply.path
+systemctl list-timers gitea-token-rotation.timer --no-pager
diff --git a/ops/provision-dashboard-alert-token.sh b/ops/provision-dashboard-alert-token.sh
new file mode 100644
index 0000000..0bbee60
--- /dev/null
+++ b/ops/provision-dashboard-alert-token.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
+readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
+readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}"
+readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}"
+
+backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)"
+temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)"
+chmod 0600 "${backup}" "${temporary}"
+cp "${DASHBOARD_ENV_FILE}" "${backup}"
+
+cleanup() {
+ rm -f "${backup}" "${temporary}"
+ unset token result
+}
+trap cleanup EXIT
+
+result="$(docker exec "${REFERENTIEL_CONTAINER}" sh -lc 'cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts prepare')"
+token="${result%%$'\t'*}"
+token_id="${result##*$'\t'}"
+[[ -n "${token}" && "${token_id}" =~ ^[0-9]+$ ]] || { echo "Le Référentiel IA n’a pas retourné un jeton exploitable." >&2; exit 1; }
+
+awk -v value="${token}" '
+ BEGIN { replaced = 0 }
+ /^IA_REFERENTIEL_TOKEN=/ { print "IA_REFERENTIEL_TOKEN=" value; replaced = 1; next }
+ { print }
+ END { if (!replaced) print "IA_REFERENTIEL_TOKEN=" value }
+' "${DASHBOARD_ENV_FILE}" > "${temporary}"
+mv "${temporary}" "${DASHBOARD_ENV_FILE}"
+chmod 0600 "${DASHBOARD_ENV_FILE}"
+
+if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null); then
+ cp "${backup}" "${DASHBOARD_ENV_FILE}"
+ exit 1
+fi
+sleep 8
+health="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${token}" "${REFERENTIEL_URL}/api/integration/v1/alerts/ci/health")"
+if [[ "${health}" != "204" ]]; then
+ cp "${backup}" "${DASHBOARD_ENV_FILE}"
+ (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
+ echo "Le nouveau jeton d’alerte a échoué au contrôle HTTP (${health}); rollback effectué." >&2
+ exit 1
+fi
+
+docker exec "${REFERENTIEL_CONTAINER}" sh -lc "cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts finalize ${token_id}" >/dev/null
+echo "DASHBOARD_ALERT_TOKEN_PROVISIONED"
diff --git a/ops/setup-gitea-webhooks.py b/ops/setup-gitea-webhooks.py
new file mode 100644
index 0000000..b6ddbe1
--- /dev/null
+++ b/ops/setup-gitea-webhooks.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+import json
+import os
+import secrets
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/")
+GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin")
+DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard.recette.santinova-soft.org").rstrip("/")
+DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard"))
+ENV_FILE = DASHBOARD_APP_DIR / ".env"
+WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea"
+
+
+def read_env() -> dict[str, str]:
+ values: dict[str, str] = {}
+ if ENV_FILE.exists():
+ for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
+ if line and not line.lstrip().startswith("#") and "=" in line:
+ key, value = line.split("=", 1)
+ values[key.strip()] = value.strip()
+ return values
+
+
+def update_env(key: str, value: str) -> None:
+ lines = ENV_FILE.read_text(encoding="utf-8").splitlines() if ENV_FILE.exists() else []
+ updated: list[str] = []
+ found = False
+ for line in lines:
+ if line.startswith(f"{key}="):
+ updated.append(f"{key}={value}")
+ found = True
+ else:
+ updated.append(line)
+ if not found:
+ updated.append(f"{key}={value}")
+ temporary = ENV_FILE.with_suffix(".env.next")
+ temporary.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
+ temporary.chmod(0o600)
+ temporary.replace(ENV_FILE)
+
+
+def api(token: str, method: str, path: str, payload: dict | None = None):
+ data = json.dumps(payload).encode() if payload is not None else None
+ request = urllib.request.Request(
+ f"{GITEA_URL}/api/v1{path}",
+ method=method,
+ data=data,
+ headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=20) as response:
+ body = response.read()
+ return json.loads(body) if body else None
+ except urllib.error.HTTPError as error:
+ detail = error.read().decode(errors="replace")
+ raise RuntimeError(f"Gitea {method} {path} : HTTP {error.code} {detail[:300]}") from error
+
+
+def ensure_dashboard_secret(values: dict[str, str]) -> tuple[dict[str, str], bool]:
+ if values.get("WEBHOOK_SECRET"):
+ return values, False
+ update_env("WEBHOOK_SECRET", secrets.token_hex(32))
+ return read_env(), True
+
+
+def main() -> None:
+ values, generated = ensure_dashboard_secret(read_env())
+ token = values.get("GITEA_TOKEN", "")
+ secret = values.get("WEBHOOK_SECRET", "")
+ if not token or not secret:
+ raise RuntimeError("GITEA_TOKEN et WEBHOOK_SECRET sont requis dans le .env serveur.")
+ if generated:
+ subprocess.run(["docker", "compose", "up", "-d", "--force-recreate", "dashboard"], cwd=DASHBOARD_APP_DIR, check=True)
+ time.sleep(8)
+ identity = api(token, "GET", "/user")
+ if identity.get("login") != GITEA_USER:
+ raise RuntimeError("Le jeton Gitea n’appartient pas au compte attendu.")
+ repositories = api(token, "GET", "/user/repos?limit=100&page=1") or []
+ configured = 0
+ for repository in repositories:
+ owner = repository.get("owner", {}).get("login")
+ name = repository.get("name")
+ if owner != GITEA_USER or not name:
+ continue
+ hooks = api(token, "GET", f"/repos/{owner}/{name}/hooks") or []
+ existing = next((hook for hook in hooks if hook.get("config", {}).get("url") == WEBHOOK_ENDPOINT), None)
+ payload = {
+ "active": True,
+ "branch_filter": "main",
+ "config": {"url": WEBHOOK_ENDPOINT, "content_type": "json", "secret": secret},
+ "events": ["push", "workflow_run"],
+ }
+ if existing:
+ api(token, "PATCH", f"/repos/{owner}/{name}/hooks/{existing['id']}", payload)
+ else:
+ api(token, "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **payload})
+ configured += 1
+ print(f"WEBHOOK_OK {owner}/{name}")
+ if configured == 0:
+ raise RuntimeError("Aucun dépôt Gitea n’a été configuré.")
+ print(f"GITEA_WEBHOOKS_CONFIGURED {configured}")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as exc:
+ print(f"GITEA_WEBHOOK_SETUP_FAILED: {exc}", file=sys.stderr)
+ raise SystemExit(1)
diff --git a/src/backend/package.json b/src/backend/package.json
index fb8dfd6..1ead78e 100644
--- a/src/backend/package.json
+++ b/src/backend/package.json
@@ -6,7 +6,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
- "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js"
+ "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js"
},
"dependencies": {
"express": "^4.21.0",
diff --git a/src/backend/src/app-creator.js b/src/backend/src/app-creator.js
index c388d66..06d44a5 100644
--- a/src/backend/src/app-creator.js
+++ b/src/backend/src/app-creator.js
@@ -1,11 +1,40 @@
const fs = require('fs');
const fsp = require('fs/promises');
+const os = require('os');
const path = require('path');
const { exec, execSync } = require('child_process');
const config = require('./config');
const { createRepo, giteaClient } = require('./gitea');
const { refreshApps, writeAppManifest } = require('./app-registry');
+function shellQuote(value) {
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
+}
+
+async function createGiteaGitAuth() {
+ if (!config.gitea.token) throw new Error('GITEA_TOKEN est requis pour les opérations Git.');
+ const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dashboard-gitea-auth-'));
+ const tokenPath = path.join(directory, 'token');
+ const askpassPath = path.join(directory, 'askpass.sh');
+ await fsp.writeFile(tokenPath, config.gitea.token, { mode: 0o600 });
+ await fsp.writeFile(askpassPath, `#!/bin/sh\ncase "$1" in\n *Username*|*username*) printf '%s\\n' '${config.gitea.username}' ;;\n *) cat '${tokenPath}' ;;\nesac\n`, { mode: 0o700 });
+ return {
+ env: { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: '0' },
+ cleanup: () => fsp.rm(directory, { recursive: true, force: true }),
+ };
+}
+
+function normalizeGiteaRepoUrl(value) {
+ const url = new URL(value);
+ const configured = new URL(config.gitea.url);
+ if (url.protocol !== 'https:' || url.hostname !== configured.hostname) {
+ throw new Error('Le dépôt doit utiliser le serveur Gitea Santinova configuré en HTTPS.');
+ }
+ url.username = '';
+ url.password = '';
+ return url.toString();
+}
+
// ============ TEMPLATES DOCKERFILE ============
const DOCKERFILE_TEMPLATES = {
@@ -436,6 +465,7 @@ async function createApplication(params, logCallback) {
const domain = `${subdomain}.recette.santinova-soft.org`;
const appDir = path.join(config.appsBasePath, appId);
const srcDir = path.join(appDir, 'src');
+ let gitAuth = null;
const log = (msg) => {
const line = `[${new Date().toISOString()}] ${msg}`;
@@ -444,6 +474,7 @@ async function createApplication(params, logCallback) {
};
try {
+ gitAuth = await createGiteaGitAuth();
// 1. Créer le répertoire de l'application
log(`Création du répertoire ${appDir}...`);
await fsp.mkdir(appDir, { recursive: true });
@@ -455,11 +486,8 @@ async function createApplication(params, logCallback) {
if (sourceType === 'gitea') {
// 2a. Cloner depuis un dépôt Gitea existant
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
- const cloneUrl = giteaRepoUrl.replace(
- 'https://',
- `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
- );
- await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
+ const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl);
+ await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env);
log('Clonage terminé.');
// Extraire le nom du repo depuis l'URL
@@ -503,10 +531,11 @@ async function createApplication(params, logCallback) {
// Initialiser git et pousser le code
log('Initialisation Git et push vers Gitea...');
- const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`;
+ const gitUrl = `${config.gitea.url.replace(/\/$/, '')}/${config.gitea.username}/${appId}.git`;
await execCommand(
- `cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`,
- 120000
+ `cd ${shellQuote(srcDir)} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m ${shellQuote(`Initial commit: ${name}`)} && git remote add origin ${shellQuote(gitUrl)} && git push -u origin main --force`,
+ 120000,
+ gitAuth.env
);
log('Code poussé sur Gitea.');
}
@@ -548,7 +577,8 @@ async function createApplication(params, logCallback) {
if (sourceType === 'zip') {
await execCommand(
`cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`,
- 60000
+ 60000,
+ gitAuth.env
);
log('Dockerfile commité et poussé sur Gitea.');
}
@@ -642,12 +672,14 @@ async function createApplication(params, logCallback) {
} catch (err) {
log(`ERREUR: ${err.message}`);
throw err;
+ } finally {
+ await gitAuth?.cleanup();
}
}
-function execCommand(cmd, timeout = 60000) {
+function execCommand(cmd, timeout = 60000, env = process.env) {
return new Promise((resolve, reject) => {
- exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
+ exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout, env }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
} else {
diff --git a/src/backend/src/ci-alerts.js b/src/backend/src/ci-alerts.js
new file mode 100644
index 0000000..a48b153
--- /dev/null
+++ b/src/backend/src/ci-alerts.js
@@ -0,0 +1,82 @@
+const fs = require('fs');
+const path = require('path');
+const config = require('./config');
+
+const LOG_DIR = '/var/log/manus-deploy';
+const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json');
+const MAX_ALERTS = 100;
+
+function normalizeWorkflowFailure(payload, deliveryId = null) {
+ if (payload?.action !== 'completed') return null;
+ const run = payload?.workflow_run || {};
+ const conclusion = String(run.conclusion || '').toLowerCase();
+ if (!conclusion || conclusion === 'success') return null;
+ const repository = payload?.repository?.full_name || payload?.repository?.name || 'dépôt inconnu';
+ const commit = run.head_sha || run.head_commit?.id || 'commit-inconnu';
+ return {
+ id: deliveryId || `${repository}-${run.id || commit}-${run.completed_at || Date.now()}`,
+ repository,
+ workflow: payload?.workflow?.name || run.name || run.workflow_id || 'Validation CI',
+ commit,
+ conclusion,
+ url: run.html_url || null,
+ occurredAt: run.completed_at || new Date().toISOString(),
+ receivedAt: new Date().toISOString(),
+ dsiNotificationSent: false,
+ };
+}
+
+function readCiAlerts() {
+ try {
+ const value = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
+ return Array.isArray(value) ? value : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveCiAlert(alert) {
+ fs.mkdirSync(LOG_DIR, { recursive: true });
+ const alerts = readCiAlerts().filter((candidate) => candidate.id !== alert.id);
+ alerts.unshift(alert);
+ const temporary = `${ALERTS_FILE}.${process.pid}.tmp`;
+ fs.writeFileSync(temporary, JSON.stringify(alerts.slice(0, MAX_ALERTS), null, 2), { mode: 0o640 });
+ fs.renameSync(temporary, ALERTS_FILE);
+ return alert;
+}
+
+async function notifyDsi(alert) {
+ if (!config.dsiAlerts.token) {
+ console.error('[CI Alert] IA_REFERENTIEL_TOKEN absent : notification DSI non envoyée.');
+ return false;
+ }
+ try {
+ const response = await fetch(`${config.dsiAlerts.url.replace(/\/$/, '')}/api/integration/v1/alerts/ci`, {
+ method: 'POST',
+ headers: {
+ authorization: `Bearer ${config.dsiAlerts.token}`,
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({
+ repository: alert.repository,
+ workflow: alert.workflow,
+ commit: alert.commit,
+ conclusion: alert.conclusion,
+ url: alert.url,
+ occurredAt: alert.occurredAt,
+ deliveryId: alert.id,
+ }),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!response.ok) {
+ console.error(`[CI Alert] Notification DSI refusée (${response.status}).`);
+ return false;
+ }
+ return true;
+ } catch (error) {
+ console.error('[CI Alert] Notification DSI impossible:', error.message);
+ return false;
+ }
+}
+
+module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi };
diff --git a/src/backend/src/config.js b/src/backend/src/config.js
index 747d129..3725514 100644
--- a/src/backend/src/config.js
+++ b/src/backend/src/config.js
@@ -25,8 +25,14 @@ module.exports = {
gitea: {
url: process.env.GITEA_URL || 'http://gitea:3000',
username: process.env.GITEA_USERNAME || 'manus-admin',
- password: process.env.GITEA_PASSWORD || 'Itinova69!',
token: process.env.GITEA_TOKEN || null,
+ productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org',
+ productionToken: process.env.GITEA_TOKEN_PROD || null,
+ },
+
+ dsiAlerts: {
+ url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org',
+ token: process.env.IA_REFERENTIEL_TOKEN || null,
},
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
diff --git a/src/backend/src/gitea.js b/src/backend/src/gitea.js
index 52b1d2f..2c2a0fc 100644
--- a/src/backend/src/gitea.js
+++ b/src/backend/src/gitea.js
@@ -2,18 +2,9 @@ const axios = require('axios');
const https = require('https');
const config = require('./config');
-// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
-// Utiliser le token API si disponible, sinon auth basique
-const giteaClientHeaders = config.gitea.token
- ? { 'Authorization': `token ${config.gitea.token}` }
- : {};
-
const giteaClient = axios.create({
baseURL: `${config.gitea.url}/api/v1`,
- ...(config.gitea.token
- ? { headers: giteaClientHeaders }
- : { auth: { username: config.gitea.username, password: config.gitea.password } }
- ),
+ headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {},
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
timeout: 10000,
});
@@ -155,6 +146,35 @@ async function getWorkflowRunSummary(owner, repo) {
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
}
+async function getRepoSnapshot(baseUrl, owner, repo, token, publicBaseUrl = baseUrl) {
+ if (!token) return { present: false, url: null, version: null };
+ const client = axios.create({
+ baseURL: `${baseUrl}/api/v1`,
+ headers: { Authorization: `token ${token}` },
+ httpsAgent: new https.Agent({ rejectUnauthorized: false }),
+ timeout: 7000,
+ });
+ try {
+ const [repoResponse, commitsResponse] = await Promise.all([
+ client.get(`/repos/${owner}/${repo}`),
+ client.get(`/repos/${owner}/${repo}/commits`, { params: { limit: 1 } }),
+ ]);
+ const commit = commitsResponse.data?.[0];
+ const sha = commit?.sha?.substring(0, 7) || null;
+ const date = commit?.commit?.author?.date
+ ? new Date(commit.commit.author.date).toLocaleDateString('fr-FR')
+ : null;
+ return {
+ present: Boolean(repoResponse.data?.id),
+ url: `${publicBaseUrl}/${owner}/${repo}`,
+ version: sha && date ? `${sha} (${date})` : sha,
+ };
+ } catch (error) {
+ console.error(`Erreur Gitea getRepoSnapshot ${owner}/${repo}:`, error.message);
+ return { present: false, url: null, version: null };
+ }
+}
+
/**
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
*/
@@ -201,6 +221,7 @@ module.exports = {
getWorkflowRuns,
getWorkflowRunSummary,
getWorkflowRunForCommit,
+ getRepoSnapshot,
normalizeWorkflowRun,
calculateCiSummary,
listRepos,
diff --git a/src/backend/src/index.js b/src/backend/src/index.js
index b8c951e..2c30bef 100644
--- a/src/backend/src/index.js
+++ b/src/backend/src/index.js
@@ -99,6 +99,9 @@ async function runHealthChecks() {
// Démarrage
async function start() {
+ if (!config.gitea.token) {
+ throw new Error('GITEA_TOKEN est requis : aucun retour à une authentification par mot de passe n’est autorisé.');
+ }
await initAuth();
// Charger les applications dynamiques
diff --git a/src/backend/src/routes.js b/src/backend/src/routes.js
index 7cfd592..81ad828 100644
--- a/src/backend/src/routes.js
+++ b/src/backend/src/routes.js
@@ -2,11 +2,13 @@ const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
+const { execFile } = require('child_process');
const { authenticate, authMiddleware } = require('./auth');
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
-const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
+const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, getRepoSnapshot, listRepos } = require('./gitea');
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
+const { readCiAlerts } = require('./ci-alerts');
const config = require('./config');
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
@@ -541,17 +543,63 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
}
});
+router.get('/ci-alerts', authMiddleware, (req, res) => {
+ res.json(readCiAlerts());
+});
+
+const GITEA_ROTATION_SCRIPT = '/opt/manus-deploy/scripts/gitea-token-rotation.sh';
+const GITEA_ROTATION_STATUS_FILE = '/var/log/manus-deploy/gitea-token-rotation.json';
+const GITEA_ROTATION_APPROVAL_FILE = '/opt/manus-deploy/secrets/gitea-token-rotation/approval.request';
+
+function readGiteaRotationStatus() {
+ try {
+ return JSON.parse(fs.readFileSync(GITEA_ROTATION_STATUS_FILE, 'utf8'));
+ } catch {
+ return { state: 'not_configured', message: 'Aucune rotation n’a encore été préparée.', tokenName: '', updatedAt: null };
+ }
+}
+
+function runGiteaRotation(args) {
+ return new Promise((resolve, reject) => {
+ execFile(GITEA_ROTATION_SCRIPT, args, { timeout: 120000 }, (error, stdout, stderr) => {
+ if (error) return reject(new Error((stderr || stdout || error.message).trim()));
+ resolve(stdout.trim());
+ });
+ });
+}
+
+router.get('/security/gitea-token-rotation', authMiddleware, (req, res) => {
+ res.json(readGiteaRotationStatus());
+});
+
+router.post('/security/gitea-token-rotation/prepare', authMiddleware, async (req, res) => {
+ try {
+ await runGiteaRotation(['prepare', '--force']);
+ res.json(readGiteaRotationStatus());
+ } catch (error) {
+ res.status(500).json({ error: error.message, status: readGiteaRotationStatus() });
+ }
+});
+
+router.post('/security/gitea-token-rotation/approve', authMiddleware, (req, res) => {
+ const status = readGiteaRotationStatus();
+ if (status.state !== 'pending_approval') {
+ return res.status(409).json({ error: 'Aucun jeton validé n’attend une approbation.', status });
+ }
+ fs.mkdirSync(path.dirname(GITEA_ROTATION_APPROVAL_FILE), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(GITEA_ROTATION_APPROVAL_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
+ res.status(202).json({ ...status, state: 'approval_requested', message: 'Basculement demandé à l’hôte. Le dashboard sera brièvement redémarré après validation.' });
+});
+
module.exports = router;
// ===== INVENTAIRE DES APPLICATIONS =====
-const { exec } = require('child_process');
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
-const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
+const GITEA_PROD_EXTERNAL = config.gitea.productionUrl;
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
-const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
-const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
+const GITEA_TOKEN_PRD = config.gitea.productionToken;
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
function getInventoryApps() {
@@ -562,41 +610,6 @@ function getInventoryApps() {
}));
}
-function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
- return new Promise((resolve) => {
- const authHeader = token
- ? `-H "Authorization: token ${token}"`
- : `-u "${GITEA_USER_INV}:${pass}"`;
- const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
- exec(cmd, { timeout: 7000 }, (err, stdout) => {
- if (err || !stdout) return resolve({ present: false, url: null, version: null });
- try {
- const data = JSON.parse(stdout);
- if (!data.id) return resolve({ present: false, url: null, version: null });
- // Récupérer le dernier commit
- const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
- exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
- let version = null;
- try {
- const commits = JSON.parse(stdout2 || '[]');
- if (commits.length > 0) {
- const c = commits[0];
- const sha = c.sha ? c.sha.substring(0, 7) : null;
- const date = c.commit && c.commit.author && c.commit.author.date
- ? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
- version = sha && date ? `${sha} (${date})` : sha;
- }
- } catch(e) {}
- const displayUrl = publicBaseUrl || baseUrl;
- resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
- });
- } catch(e) {
- resolve({ present: false, url: null, version: null });
- }
- });
- });
-}
-
router.get('/inventory', authMiddleware, async (req, res) => {
try {
const owner = GITEA_USER_INV;
@@ -604,8 +617,8 @@ router.get('/inventory', authMiddleware, async (req, res) => {
const results = await Promise.all(
INVENTORY_APPS.map(async (app) => {
const [repoRecette, repoProd] = await Promise.all([
- curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
- curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
+ getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC),
+ getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD),
]);
return { ...app, repoRecette, repoProd };
})
diff --git a/src/backend/src/webhook.js b/src/backend/src/webhook.js
index 7559a06..b50b2ec 100644
--- a/src/backend/src/webhook.js
+++ b/src/backend/src/webhook.js
@@ -11,6 +11,7 @@ const fs = require('fs');
const config = require('./config');
const { findAppByRepo } = require('./app-registry');
const { getWorkflowRunForCommit } = require('./gitea');
+const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts');
const router = express.Router();
@@ -160,6 +161,22 @@ router.post('/gitea', (req, res) => {
return res.status(401).json({ error: 'Signature invalide' });
}
const event = req.headers['x-gitea-event'] || 'unknown';
+ if (event === 'workflow_run') {
+ const alert = normalizeWorkflowFailure(req.body, req.headers['x-gitea-delivery'] || null);
+ if (!alert) return res.status(200).json({ message: 'Workflow sans échec ignoré' });
+ saveCiAlert(alert);
+ global.wsBroadcast?.({ type: 'ci_alert', data: alert });
+ res.status(202).json({ message: 'Alerte CI enregistrée', id: alert.id });
+ setImmediate(async () => {
+ const sent = await notifyDsi(alert);
+ if (sent) {
+ const notifiedAlert = { ...alert, dsiNotificationSent: true };
+ saveCiAlert(notifiedAlert);
+ global.wsBroadcast?.({ type: 'ci_alert', data: notifiedAlert });
+ }
+ });
+ return;
+ }
if (event !== 'push') {
return res.status(200).json({ message: `Événement ${event} ignoré` });
}
diff --git a/src/backend/test/ci-alerts.test.js b/src/backend/test/ci-alerts.test.js
new file mode 100644
index 0000000..3c11bf7
--- /dev/null
+++ b/src/backend/test/ci-alerts.test.js
@@ -0,0 +1,22 @@
+const assert = require('assert');
+const { normalizeWorkflowFailure } = require('../src/ci-alerts');
+
+const failure = normalizeWorkflowFailure({
+ action: 'completed',
+ repository: { full_name: 'manus-admin/referentiel-ia' },
+ workflow: { name: 'Validation CI' },
+ workflow_run: {
+ id: 42,
+ conclusion: 'failure',
+ head_sha: '1234567890abcdef',
+ completed_at: '2026-08-21T12:00:00.000Z',
+ html_url: 'https://git.recette.santinova-soft.org/actions/runs/42',
+ },
+}, 'delivery-42');
+
+assert.equal(failure.id, 'delivery-42');
+assert.equal(failure.repository, 'manus-admin/referentiel-ia');
+assert.equal(failure.conclusion, 'failure');
+assert.equal(normalizeWorkflowFailure({ action: 'completed', workflow_run: { conclusion: 'success' } }), null);
+assert.equal(normalizeWorkflowFailure({ action: 'in_progress', workflow_run: { conclusion: 'failure' } }), null);
+console.log('OK ci alerts');
diff --git a/src/backend/test/dashboard-alert-token.test.js b/src/backend/test/dashboard-alert-token.test.js
new file mode 100644
index 0000000..0fd56af
--- /dev/null
+++ b/src/backend/test/dashboard-alert-token.test.js
@@ -0,0 +1,13 @@
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '../../..');
+const source = fs.readFileSync(path.join(root, 'ops/provision-dashboard-alert-token.sh'), 'utf8');
+
+assert.match(source, /dashboard-alert-token\.ts prepare/);
+assert.match(source, /alerts\/ci\/health/);
+assert.match(source, /dashboard-alert-token\.ts finalize/);
+assert.match(source, /rollback effectué/);
+assert.doesNotMatch(source, /GITEA_PASSWORD/);
+console.log('OK dashboard alert token');
diff --git a/src/backend/test/gitea-auth.test.js b/src/backend/test/gitea-auth.test.js
new file mode 100644
index 0000000..d990992
--- /dev/null
+++ b/src/backend/test/gitea-auth.test.js
@@ -0,0 +1,23 @@
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '../..');
+const files = [
+ 'backend/src/config.js',
+ 'backend/src/gitea.js',
+ 'backend/src/app-creator.js',
+ 'backend/src/routes.js',
+ 'docker-compose.yml',
+];
+
+for (const relativePath of files) {
+ const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
+ assert.equal(content.includes('GITEA_PASSWORD'), false, `${relativePath} contient encore GITEA_PASSWORD`);
+ assert.equal(content.includes('config.gitea.password'), false, `${relativePath} utilise encore le mot de passe Gitea`);
+}
+
+const compose = fs.readFileSync(path.resolve(root, '../docker-compose.yml'), 'utf8');
+assert.match(compose, /GITEA_TOKEN=\$\{GITEA_TOKEN:\?/);
+assert.equal(/GITEA_TOKEN=[a-f0-9]{20,}/.test(compose), false);
+console.log('OK gitea auth');
diff --git a/src/backend/test/token-rotation.test.js b/src/backend/test/token-rotation.test.js
new file mode 100644
index 0000000..3f99f32
--- /dev/null
+++ b/src/backend/test/token-rotation.test.js
@@ -0,0 +1,19 @@
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '../../..');
+const rotation = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.sh'), 'utf8');
+const timer = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.timer'), 'utf8');
+const approvalPath = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation-apply.path'), 'utf8');
+
+assert.match(rotation, /generate-access-token/);
+assert.match(rotation, /pending_approval/);
+assert.match(rotation, /GITEA_ROTATION_APPLIED/);
+assert.match(rotation, /chmod 0600/);
+assert.match(rotation, /rollback/);
+assert.doesNotMatch(rotation, /GITEA_PASSWORD/);
+assert.match(timer, /OnCalendar=\*-01,04,07,10-01 07:00:00/);
+assert.match(timer, /Persistent=true/);
+assert.match(approvalPath, /PathExists=.*approval\.request/);
+console.log('OK token rotation');
diff --git a/src/backend/test/webhook-setup.test.js b/src/backend/test/webhook-setup.test.js
new file mode 100644
index 0000000..53c92f8
--- /dev/null
+++ b/src/backend/test/webhook-setup.test.js
@@ -0,0 +1,14 @@
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '../../..');
+const setup = fs.readFileSync(path.join(root, 'ops/setup-gitea-webhooks.py'), 'utf8');
+const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8');
+
+assert.match(setup, /"events": \["push", "workflow_run"\]/);
+assert.match(setup, /secrets\.token_hex\(32\)/);
+assert.match(setup, /method=method/);
+assert.doesNotMatch(setup, /GITEA_PASSWORD/);
+assert.match(compose, /WEBHOOK_SECRET=\$\{WEBHOOK_SECRET:\?/);
+console.log('OK webhook setup');
diff --git a/src/docker-compose.yml b/src/docker-compose.yml
index 9b5bd8a..3bb23f7 100644
--- a/src/docker-compose.yml
+++ b/src/docker-compose.yml
@@ -13,10 +13,12 @@ services:
- ADMIN_PASSWORD=Itinova69!
- GITEA_URL=https://git.recette.santinova-soft.org
- GITEA_USERNAME=manus-admin
- - GITEA_PASSWORD=ManusGitea2026!
- - GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
+ - GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
- GITEA_RECETTE_URL=http://gitea:3000
- - GITEA_PASSWORD_PROD=ManusGitea2026!
+ - GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
+ - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur}
+ - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}
+ - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-}
- APPS_BASE_PATH=/opt/manus-deploy/apps
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
- HEALTH_CHECK_INTERVAL=30000
diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx
index dcbd001..d6b2a62 100644
--- a/src/frontend/src/App.jsx
+++ b/src/frontend/src/App.jsx
@@ -10,13 +10,15 @@ import DocumentationPage from './pages/DocumentationPage';
import InventairePage from './pages/InventairePage';
import TerminalPage from './pages/TerminalPage';
import Sidebar from './components/Sidebar';
+import CiAlertBanner from './components/CiAlertBanner';
import useWebSocket from './hooks/useWebSocket';
-import { getMe, getApps, logout as apiLogout } from './utils/api';
+import { getMe, getApps, getCiAlerts, logout as apiLogout } from './utils/api';
export default function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState('dashboard');
const [apps, setApps] = useState([]);
+ const [ciAlerts, setCiAlerts] = useState([]);
// Vérifier l'authentification au chargement
useEffect(() => {
const token = localStorage.getItem('dashboard_token');
@@ -25,6 +27,7 @@ export default function App() {
.then(() => {
setAuthenticated(true);
fetchApps();
+ fetchCiAlerts();
})
.catch(() => {
localStorage.removeItem('dashboard_token');
@@ -43,11 +46,22 @@ export default function App() {
console.error('Erreur chargement apps:', err);
}
};
+ const fetchCiAlerts = async () => {
+ try {
+ const response = await getCiAlerts();
+ setCiAlerts(response.data);
+ } catch (error) {
+ console.error('Erreur chargement alertes CI:', error);
+ }
+ };
// WebSocket handler
const handleWsMessage = useCallback((data) => {
if (data.type === 'health_update') {
setApps(data.data);
}
+ if (data.type === 'ci_alert') {
+ setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
+ }
}, []);
const { connected: wsConnected } = useWebSocket(
authenticated ? handleWsMessage : null
@@ -55,6 +69,7 @@ export default function App() {
const handleLogin = (data) => {
setAuthenticated(true);
fetchApps();
+ fetchCiAlerts();
};
const handleLogout = async () => {
try {
@@ -65,6 +80,7 @@ export default function App() {
localStorage.removeItem('dashboard_token');
setAuthenticated(false);
setApps([]);
+ setCiAlerts([]);
};
if (loading) {
return (
@@ -126,7 +142,10 @@ export default function App() {
onLogout={handleLogout}
wsConnected={wsConnected}
/>
-
Échec CI — {alert.repository}
+{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}
+Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}
+{rotation?.message || 'Chargement du statut…'}
+ {rotation?.tokenName &&{rotation.tokenName}
} + {rotation?.updatedAt &&Mis à jour : {formatDate(rotation.updatedAt)}
} + {rotationError &&{rotationError}
} +