diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4992164
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+VITE_SOC2_SYNC_MODE=local
+VITE_SOC2_API_BASE_URL=http://localhost:4177/api
diff --git a/README.md b/README.md
index 6e5612d..ea95995 100644
--- a/README.md
+++ b/README.md
@@ -1,101 +1,130 @@
# Chezlepro SOC 2 Type II Cockpit
-Cockpit web React orienté **conformité opérationnelle SOC 2 Type II** pour Chezlepro Inc.
+Cockpit web React + API Node pour piloter une conformité opérationnelle **SOC 2 Type II**.
-L’application fournit :
+Cette version n’est plus seulement un prototype d’interface. Le dépôt contient maintenant :
-- un **radar des 5 piliers** SOC 2 ;
-- un suivi **pondéré des jalons** ;
-- une **bibliothèque de preuves** éditable ;
-- une **salle auditeur** avec paquets de revue ;
-- une **source de vérité JSON** persistée localement ;
-- des **exports/manifests** pour préparer un dépôt Git versionné.
+- un **frontend React/Vite** ;
+- un **backend HTTP Node** ;
+- un **registre JSON versionné** dans `compliance/soc2/registry/` ;
+- des **manifests dérivés** ;
+- un répertoire d’**artefacts de preuves** ;
+- un **contrat de connecteurs** pour Forgejo / Icinga2 / Keycloak.
-## Architecture
+## Structure
```text
src/
App.jsx
- styles.css
model/
- schema.js
- seed.js
- stages.js
lib/
- utils.js
- validation.js
- selectors.js
- view.js
- features/
- hooks/
- useSoc2Registry.js
+ features/hooks/
components/
- PillarOverviewCard.jsx
- MilestoneCard.jsx
- EvidenceLibrary.jsx
- EvidenceEditor.jsx
- DataTab.jsx
- ui/
- primitives.jsx
+server/
+ index.js
+ config.js
+ lib/
+ http.js
+ store.js
+ contracts/
+ connectors.json
+ scripts/
+ seed-registry.mjs
+compliance/soc2/
+ registry/
+ soc2-registry.json
+ evidence-manifest.json
+ auditor-pack-index.json
+ evidence/
+ packs/
```
-## Principes de conception
+## Modes de fonctionnement
-- **Modèle** : constantes, schéma, seed data, états des contrôles.
-- **Lib métier** : normalisation, validation, calculs, sélecteurs.
-- **Hook de registre** : persistance locale, import/export, mutations synchronisées.
-- **Composants** : interface séparée du moteur métier.
+### Mode local
-Le cockpit est prêt à évoluer vers une vraie source de vérité Git/Forgejo :
-
-- chemin recommandé pour le registre : `compliance/soc2/registry/soc2-registry.json`
-- pièces jointes recommandées : `compliance/soc2/evidence/`
-
-## Lancer le projet
+Le frontend persiste le registre dans `localStorage`.
```bash
+cp .env.example .env
+# laisser VITE_SOC2_SYNC_MODE=local
npm install
npm run dev
```
-Build de production :
+### Mode API
+
+Le frontend devient client d’une source de vérité sur disque, servie par l’API locale.
```bash
-npm run build
-npm run preview
+cp .env.example .env
+# définir VITE_SOC2_SYNC_MODE=api
+npm install
+npm run seed:registry
+npm run server
+npm run dev
```
-## Dépendances
+API par défaut : `http://localhost:4177/api`
-- React
-- Vite
-- Recharts
-- Framer Motion
-- Lucide React
+## Endpoints backend
-## Source de vérité
+- `GET /api/health`
+- `GET /api/registry`
+- `PUT /api/registry`
+- `POST /api/registry/reset`
+- `GET /api/evidence`
+- `POST /api/evidence`
-Le cockpit persiste son état dans `localStorage` pour le prototype.
+### Exemple : écrire le registre
-La prochaine étape naturelle est de remplacer cette persistance par :
+```bash
+curl -X PUT http://localhost:4177/api/registry \
+ -H 'Content-Type: application/json' \
+ --data @compliance/soc2/registry/soc2-registry.json
+```
-1. un fichier JSON versionné dans un dépôt Forgejo ;
-2. une API légère de lecture/écriture ;
-3. éventuellement une ingestion automatique depuis Icinga, Keycloak et les registres internes.
+### Exemple : pousser un artefact de preuve
-## Limites actuelles
+```bash
+python - <<'PY'
+import base64, json
+from pathlib import Path
+payload = {
+ "filename": "example-proof.txt",
+ "contentBase64": base64.b64encode(b"preuve de test").decode(),
+ "mimeType": "text/plain"
+}
+Path("payload.json").write_text(json.dumps(payload), encoding="utf-8")
+PY
-- pas d’authentification applicative ;
-- pas de backend ;
-- pas de gestion de fichiers binaires de preuves ;
-- pas encore de synchronisation distante.
+curl -X POST http://localhost:4177/api/evidence \
+ -H 'Content-Type: application/json' \
+ --data @payload.json
+```
-## Recommandation d’évolution
+## Connecteurs prévus
-Séquence recommandée :
+Le fichier `server/contracts/connectors.json` décrit les contrats attendus pour :
-1. brancher le registre JSON versionné ;
-2. ajouter gestion de pièces jointes ;
-3. ajouter règles de validation plus strictes ;
-4. ajouter API backend ;
-5. brancher les collecteurs automatiques de preuves.
+- **Forgejo** : changements, PR, approbations
+- **Icinga2** : disponibilité, incidents, tendances
+- **Keycloak** : MFA, rôles admin, revues d’accès
+
+Le dépôt n’effectue pas encore les collectes réelles sans les paramètres d’accès du site cible, mais l’architecture est prête pour les implémenter.
+
+## Ce qui rend cette version utile
+
+- le registre n’est plus seulement stocké côté navigateur ;
+- l’API peut servir de **source de vérité locale** ;
+- les manifests sont régénérés à chaque écriture ;
+- les artefacts de preuves ont un emplacement physique ;
+- le front peut fonctionner en **mode local** ou **mode API**.
+
+## Séquence suivante recommandée
+
+1. brancher les vrais accès Forgejo / Icinga2 / Keycloak ;
+2. ajouter une authentification applicative ;
+3. gérer le téléversement multipart réel ;
+4. ajouter des jobs de collecte planifiés ;
+5. produire des packs auditeur exportables en HTML/PDF.
diff --git a/package.json b/package.json
index f1d1a90..d5494fb 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "server": "node server/index.js",
+ "seed:registry": "node server/scripts/seed-registry.mjs",
+ "dev:full": "sh -c \"node server/index.js & vite\""
},
"dependencies": {
"framer-motion": "^12.23.12",
diff --git a/server/config.js b/server/config.js
new file mode 100644
index 0000000..a159960
--- /dev/null
+++ b/server/config.js
@@ -0,0 +1,12 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export const repoRoot = path.resolve(__dirname, "..");
+export const host = process.env.SOC2_HOST || "127.0.0.1";
+export const port = Number(process.env.SOC2_PORT || 4177);
+export const registryPath = path.join(repoRoot, "compliance", "soc2", "registry", "soc2-registry.json");
+export const manifestsDir = path.join(repoRoot, "compliance", "soc2", "registry");
+export const evidenceDir = path.join(repoRoot, "compliance", "soc2", "evidence");
diff --git a/server/contracts/connectors.json b/server/contracts/connectors.json
new file mode 100644
index 0000000..f0a2d85
--- /dev/null
+++ b/server/contracts/connectors.json
@@ -0,0 +1,34 @@
+{
+ "connectors": [
+ {
+ "name": "forgejo",
+ "purpose": "Collecter les changements, pull requests, approbations et tags de release.",
+ "input": {
+ "baseUrl": "https://forge.example.tld",
+ "tokenEnv": "FORGEJO_TOKEN",
+ "repositories": ["org/repo"]
+ },
+ "producesEvidence": ["change-logs", "pull-request-approvals", "release-history"]
+ },
+ {
+ "name": "icinga2",
+ "purpose": "Collecter la disponibilité, les incidents, les SLA internes et les tendances.",
+ "input": {
+ "apiUrl": "https://icinga.example.tld:5665/v1",
+ "usernameEnv": "ICINGA_USER",
+ "passwordEnv": "ICINGA_PASSWORD"
+ },
+ "producesEvidence": ["uptime-reports", "incident-history", "alert-summary"]
+ },
+ {
+ "name": "keycloak",
+ "purpose": "Collecter les rôles administrateurs, MFA et revues d'accès.",
+ "input": {
+ "baseUrl": "https://keycloak.example.tld",
+ "realm": "master",
+ "tokenEnv": "KEYCLOAK_TOKEN"
+ },
+ "producesEvidence": ["admin-role-exports", "mfa-status", "access-review-snapshots"]
+ }
+ ]
+}
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..3558fb4
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,76 @@
+import http from "node:http";
+import { URL } from "node:url";
+import { host, port } from "./config.js";
+import { readJsonBody, sendJson, sendText } from "./lib/http.js";
+import { listEvidenceArtifacts, loadRegistry, resetRegistry, writeEvidenceArtifact, writeRegistry } from "./lib/store.js";
+import { validateRegistry } from "../src/lib/validation.js";
+
+const server = http.createServer(async (req, res) => {
+ if (!req.url) return sendJson(res, 400, { error: "Missing URL." });
+ const url = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`);
+
+ if (req.method === "OPTIONS") {
+ res.writeHead(204, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ });
+ return res.end();
+ }
+
+ try {
+ if (req.method === "GET" && url.pathname === "/health") {
+ return sendText(res, 200, "ok");
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/health") {
+ const registry = await loadRegistry();
+ const validation = validateRegistry(registry);
+ return sendJson(res, 200, {
+ ok: true,
+ service: "soc2-cockpit-api",
+ registryValid: validation.isValid,
+ warningCount: validation.warnings.length,
+ errorCount: validation.errors.length,
+ });
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/registry") {
+ const registry = await loadRegistry();
+ return sendJson(res, 200, { registry, validation: validateRegistry(registry) });
+ }
+
+ if (req.method === "PUT" && url.pathname === "/api/registry") {
+ const payload = await readJsonBody(req);
+ const result = await writeRegistry(payload);
+ return sendJson(res, 200, result);
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/registry/reset") {
+ const result = await resetRegistry();
+ return sendJson(res, 200, result);
+ }
+
+ if (req.method === "GET" && url.pathname === "/api/evidence") {
+ const artifacts = await listEvidenceArtifacts();
+ return sendJson(res, 200, { artifacts });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/evidence") {
+ const payload = await readJsonBody(req);
+ const artifact = await writeEvidenceArtifact(payload);
+ return sendJson(res, 201, { artifact });
+ }
+
+ return sendJson(res, 404, { error: "Not found." });
+ } catch (error) {
+ if (error?.validation) {
+ return sendJson(res, 400, { error: error.message, validation: error.validation });
+ }
+ return sendJson(res, 500, { error: error?.message || "Internal server error." });
+ }
+});
+
+server.listen(port, host, () => {
+ console.log(`SOC 2 cockpit API listening on http://${host}:${port}`);
+});
diff --git a/server/lib/http.js b/server/lib/http.js
new file mode 100644
index 0000000..2f018c9
--- /dev/null
+++ b/server/lib/http.js
@@ -0,0 +1,29 @@
+import { Buffer } from "node:buffer";
+
+export function sendJson(res, status, payload) {
+ const body = JSON.stringify(payload, null, 2);
+ res.writeHead(status, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Length": Buffer.byteLength(body),
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ });
+ res.end(body);
+}
+
+export function sendText(res, status, text) {
+ res.writeHead(status, {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Content-Length": Buffer.byteLength(text),
+ "Access-Control-Allow-Origin": "*",
+ });
+ res.end(text);
+}
+
+export async function readJsonBody(req) {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
+ return raw ? JSON.parse(raw) : {};
+}
diff --git a/server/lib/store.js b/server/lib/store.js
new file mode 100644
index 0000000..ae71b93
--- /dev/null
+++ b/server/lib/store.js
@@ -0,0 +1,78 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { buildRegistryManifests, normalizeDb, validateRegistry } from "../../src/lib/validation.js";
+import { INITIAL_DB } from "../../src/model/seed.js";
+import { evidenceDir, manifestsDir, registryPath } from "../config.js";
+
+async function ensureDirs() {
+ await fs.mkdir(manifestsDir, { recursive: true });
+ await fs.mkdir(evidenceDir, { recursive: true });
+}
+
+export async function loadRegistry() {
+ await ensureDirs();
+ try {
+ const raw = await fs.readFile(registryPath, "utf-8");
+ return normalizeDb(JSON.parse(raw));
+ } catch {
+ return normalizeDb(INITIAL_DB);
+ }
+}
+
+export async function writeRegistry(input) {
+ await ensureDirs();
+ const normalized = normalizeDb(input);
+ const validation = validateRegistry(normalized);
+ if (!validation.isValid) {
+ const error = new Error("Registry validation failed.");
+ error.validation = validation;
+ throw error;
+ }
+
+ await fs.writeFile(registryPath, JSON.stringify(normalized, null, 2), "utf-8");
+ const manifests = buildRegistryManifests(normalized);
+ await fs.writeFile(path.join(manifestsDir, "evidence-manifest.json"), JSON.stringify(manifests.evidenceManifest, null, 2), "utf-8");
+ await fs.writeFile(path.join(manifestsDir, "auditor-pack-index.json"), JSON.stringify(manifests.auditorPackIndex, null, 2), "utf-8");
+ return { registry: normalized, validation, manifests };
+}
+
+export async function resetRegistry() {
+ return writeRegistry(INITIAL_DB);
+}
+
+export async function listEvidenceArtifacts() {
+ await ensureDirs();
+ const names = await fs.readdir(evidenceDir).catch(() => []);
+ const stats = await Promise.all(
+ names
+ .filter((name) => !name.startsWith("."))
+ .map(async (name) => {
+ const fullPath = path.join(evidenceDir, name);
+ const stat = await fs.stat(fullPath);
+ return {
+ filename: name,
+ size: stat.size,
+ modifiedAt: stat.mtime.toISOString(),
+ path: fullPath,
+ };
+ })
+ );
+ return stats.sort((a, b) => a.filename.localeCompare(b.filename));
+}
+
+export async function writeEvidenceArtifact({ filename, contentBase64 }) {
+ await ensureDirs();
+ if (!filename || typeof filename !== "string") throw new Error("filename is required.");
+ if (!contentBase64 || typeof contentBase64 !== "string") throw new Error("contentBase64 is required.");
+
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
+ const fullPath = path.join(evidenceDir, safeName);
+ await fs.writeFile(fullPath, Buffer.from(contentBase64, "base64"));
+ const stat = await fs.stat(fullPath);
+ return {
+ filename: safeName,
+ size: stat.size,
+ modifiedAt: stat.mtime.toISOString(),
+ path: fullPath,
+ };
+}
diff --git a/server/scripts/seed-registry.mjs b/server/scripts/seed-registry.mjs
new file mode 100644
index 0000000..18d7b0c
--- /dev/null
+++ b/server/scripts/seed-registry.mjs
@@ -0,0 +1,4 @@
+import { resetRegistry } from "../lib/store.js";
+
+const payload = await resetRegistry();
+console.log(`Registry seeded. Evidence items: ${payload.registry.evidence.length}`);
diff --git a/src/App.jsx b/src/App.jsx
index 692996d..275691b 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -96,7 +96,7 @@ function DashboardPanel({ scoredPillars, targetReadiness, overall, selectedPilla
export default function App() {
const registry = useSoc2Registry();
- const { db, lastSavedAt, importError, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
+ const { db, lastSavedAt, importError, syncMode, syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
const [activeTab, setActiveTab] = useState("dashboard");
const [selectedPillar, setSelectedPillar] = useState("security");
@@ -170,7 +170,7 @@ export default function App() {
{blocker ?
Architecture modulaire, source de vérité versionnable, radar des 5 piliers, bibliothèque de preuves et salle auditeur.
+Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.