SPB Git

spb/admin-ka

Public
36.7 KB · 766 lines javascript
1// admin-ka v2 — backend : pont HTTP/WebSocket ⇄ Claude Code CLI (stream-json)2// Sessions détachées (survivent à la déconnexion du client), permissions3// relayées en cartes, monitoring Écosystème temps réel.4import http from 'node:http';5import crypto from 'node:crypto';6import fs from 'node:fs';7import path from 'node:path';8import os from 'node:os';9import { spawn } from 'node:child_process';10import { fileURLToPath } from 'node:url';11import { WebSocketServer } from 'ws';12import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, SITES } from './monitor.js';13import { execFile } from 'node:child_process';1415const __dirname = path.dirname(fileURLToPath(import.meta.url));16const APP_DIR = path.join(__dirname, '..');17const DATA_DIR = path.join(APP_DIR, 'data');18const TRANSCRIPTS_DIR = path.join(DATA_DIR, 'transcripts');19const PUBLIC_DIR = path.join(APP_DIR, 'public');20const HOME = os.homedir();2122fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });2324// ---------- config ----------25const CONFIG_PATH = path.join(DATA_DIR, 'config.json');26if (!fs.existsSync(CONFIG_PATH)) {27  console.error('config.json manquant — exécuter: node server/set-password.js <motdepasse>');28  process.exit(1);29}30const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));31const PORT = config.port || 3300;32const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude';33const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node';3435// toutes les variables de ~/.claude/.env (ANTHROPIC_API_KEY, APIFY_TOKEN, …)36// sont injectées dans les runs claude locaux (les runs distants font37// `export $(cat ~/.claude/.env | xargs)` côté nœud)38function loadClaudeEnv() {39  const out = {};40  try {41    for (const line of fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8').split('\n')) {42      const m = line.match(/^([A-Z0-9_]+)=(\S+)/);43      if (m) out[m[1]] = m[2];44    }45  } catch {}46  return out;47}48const CLAUDE_ENV = loadClaudeEnv();49const ANTHROPIC_API_KEY = CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';5051// ---------- Upgradeur de prompt (Anthropic Messages API) ----------52const UPGRADER_KEY = config.anthropicKey || CLAUDE_ENV.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';53const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8';5455// Connaissance du Groupe KA injectée dans l'upgradeur pour produire des prompts56// Claude Code précis, conformes aux standards de l'écosystème.57const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration-ka.com. Ta seule tâche : réécrire la demande brute de l'utilisateur en un prompt EXCELLENT destiné à Claude Code, qui s'exécute sur le nœud d'une app du Groupe KA. Tu ne réponds jamais à la demande toi-même, tu ne codes pas : tu produis UNIQUEMENT le prompt amélioré, prêt à être envoyé.5859# Écosystème Groupe KA (12 apps web, stack Node/React, cluster MacLustr)60- lou-ka (lou-ka.com) : location immobilière/logements. immo-ka (immo-ka.com) : achat/vente immobilier. vrai-prix (vrai-prix.com) : comparateur de prix. valoplex : immobilier/valorisation. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. resto-ka (resto-ka.com) : restaurants. sorti-ka (sorti-ka.com) : sorties/événements. crea-ka (crea-ka.com) : créateurs de contenu QC (multi-plateforme). job-ka (job-ka.com) : emplois. trouve-ka (trouve-ka.com) : recherche/annuaire. groupe-ka.com : hub + KA ID (SSO). Marché cible : Québec (fr-CA).61- Architecture commune : back Node (souvent Express/Fastify), front React/Next ou Vite, données via connecteurs (scrapers) + SQLite/Postgres, PM2 pour les process, ngrok pour les domaines. Beaucoup d'apps ont un backend "sync"/crawler et une couche qualité (published/quarantaine).6263# Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent)64- Remote-first : la source de vérité est le repo de PROD sur le nœud de déploiement ; on édite là, on build, on redémarre PM2, puis git add/commit/push origin main. origin = spbgit (git perso, git.spboucher.ai), PAS GitHub.65- Après toute modif : rebuild si nécessaire + pm2 restart <app> + vérifier le healthcheck/site + commit & push spbgit. Ne jamais laisser de modif non commitée/non déployée.66- Standards UI Groupe KA : header/nav opaques fixes ; échelle de z-index par variables --z-* ; mobile-first, contenu jamais masqué par le clavier (dvh + safe-area) ; anti-zoom iOS (inputs 16px). Pages détail : l'ordre du DOM = l'ordre visuel identique mobile ET desktop (galerie → prix/adresse → description → inclusions → détails → analyses → carte → KA Scores → quartier), JAMAIS de réordonnancement via order:/column-reverse entre breakpoints.67- Design system ka-ui : tokens/couleurs/footer centralisés (ecosystem.json). Toujours valider en mobile (simulateur/Playwright) après un changement d'UI.6869# Outils de scraping/recherche disponibles sur chaque nœud (variables dans ~/.claude/.env)70- Serper (SERPER_API_KEY, recherche Google, gl:ca hl:fr) et Tavily (TAVILY_API_KEY, search/map/extract/crawl) pour DÉCOUVRIR des sources de connecteurs.71- Scrapfly (SCRAPFLY_KEY) et Bright Data Web Unlocker (BRIGHTDATA_API_KEY, zone web_unlocker1) et Oxylabs (Realtime API OXYLABS_USER/PASS + proxies résidentiels OXYLABS_PROXY_* avec ciblage -cc-CA) pour l'anti-bot.72- Apify : 13 acteurs maison gorgeous_thistle/ka-<plateforme> (instagram, tiktok, x, youtube, twitch, kick, fansly, onlyfans, facebook, threads, snapchat, discord, patreon) pour l'enrichissement multi-plateforme de crea-ka ; apify-cli installé (apify push pour builder).7374# Comment écrire le prompt amélioré751. Écris en français, à l'impératif, adressé à Claude Code.762. Commence par l'objectif clair, puis le périmètre précis (fichiers/zones probables), puis les critères d'acceptation vérifiables.773. Ajoute les détails manquants que la demande sous-entend (edge cases, validation, mobile, i18n fr-CA) sans dénaturer l'intention.784. Rappelle les étapes de clôture attendues (build, pm2 restart, healthcheck, commit+push spbgit) UNIQUEMENT si la tâche modifie du code.795. Demande à Claude Code d'explorer/lire avant de modifier, et de vérifier son travail.806. Reste concis et actionnable : pas de blabla, pas de sections inutiles. N'invente pas de faits spécifiques non fournis.817. Sortie = le prompt amélioré SEUL, sans préambule ("Voici…"), sans guillemets englobants, sans commentaire méta.`;8283async function upgradePrompt(rawPrompt, project) {84  if (!UPGRADER_KEY) throw new Error('Clé Anthropic non configurée (config.anthropicKey).');85  const ctx = project ? `\n\nContexte : la demande vise le projet "${project.name}" (déployé sur le nœud ${project.node || SELF_NODE}, répertoire ${project.dir}).` : '';86  const resp = await fetch('https://api.anthropic.com/v1/messages', {87    method: 'POST',88    headers: {89      'x-api-key': UPGRADER_KEY,90      'anthropic-version': '2023-06-01',91      'content-type': 'application/json',92    },93    body: JSON.stringify({94      model: UPGRADER_MODEL,95      max_tokens: 1500,96      system: KA_KNOWLEDGE,97      messages: [{ role: 'user', content: `Réécris cette demande en un prompt Claude Code excellent.${ctx}\n\nDemande brute :\n${rawPrompt}` }],98    }),99  });100  const data = await resp.json();101  if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');102  const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();103  if (!text) throw new Error('Réponse vide du modèle');104  return text;105}106107// ---------- audit ----------108const AUDIT_PATH = path.join(DATA_DIR, 'audit.jsonl');109function audit(entry) {110  fs.appendFile(AUDIT_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', () => {});111}112113// ---------- sessions (auth) ----------114const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json');115let sessions = {};116try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {}117function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); }118const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000;119const SESSION_MAX_MS = 30 * 24 * 3600 * 1000;120121function verifyPassword(pw) {122  const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex');123  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(config.passwordHash));124}125function newSession() {126  const token = crypto.randomBytes(32).toString('hex');127  sessions[token] = { created: Date.now(), last: Date.now() };128  saveSessions();129  return token;130}131function getSession(req) {132  const cookie = req.headers.cookie || '';133  const m = cookie.match(/(?:^|;\s*)akid=([a-f0-9]{64})/);134  if (!m) return null;135  const s = sessions[m[1]];136  if (!s) return null;137  const now = Date.now();138  if (now - s.last > SESSION_IDLE_MS || now - s.created > SESSION_MAX_MS) {139    delete sessions[m[1]]; saveSessions(); return null;140  }141  s.last = now;142  return m[1];143}144145const loginFails = new Map();146function clientIp(req) {147  const xf = req.headers['x-forwarded-for'];148  return (xf ? String(xf).split(',')[0].trim() : req.socket.remoteAddress) || '?';149}150function loginAllowed(ip) {151  const rec = loginFails.get(ip);152  if (!rec) return true;153  if (Date.now() - rec.first > 15 * 60 * 1000) { loginFails.delete(ip); return true; }154  return rec.count < 8;155}156function loginFailed(ip) {157  const rec = loginFails.get(ip) || { first: Date.now(), count: 0 };158  rec.count++; loginFails.set(ip, rec);159}160161// ---------- chats ----------162const CHATS_PATH = path.join(DATA_DIR, 'chats.json');163let chats = [];164try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {}165// migration v1 → v2166for (const c of chats) {167  if (!c.model) c.model = 'fable';168  if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default';169  if (!c.state) c.state = 'idle';170  if (c.totalCost === undefined) c.totalCost = 0;171  if (!c.alwaysAllow) c.alwaysAllow = [];172  if (c.autoAllowAll === undefined) c.autoAllowAll = false;173}174function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); }175function getChat(id) { return chats.find(c => c.id === id); }176function publicChat(c) {177  return {178    id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName,179    model: c.model, permMode: c.permMode, state: c.state, autoAllowAll: !!c.autoAllowAll,180    claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null,181    totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0,182    created: c.created, updated: c.updated,183  };184}185function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); }186function appendTranscript(chatId, event) {187  fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {});188}189function readTranscript(chatId) {190  try {191    return fs.readFileSync(transcriptPath(chatId), 'utf8')192      .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } })193      .filter(Boolean);194  } catch { return []; }195}196197// ---------- projets (12 apps Groupe KA) ----------198const SELF_NODE = 'M3U96a';199const PROJECTS = [200  { id: 'groupe-ka@M3U96b', name: 'Groupe·Ka', node: 'M3U96b', dir: '~/apps/groupe-ka' },201  { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' },202  { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' },203  { id: 'vrai-prix@M3U96a', name: 'Vrai-Prix', node: null, dir: path.join(HOME, 'apps', 'vrai-prix') },204  { id: 'valoplex@M3U96a', name: 'ValoPlex', node: null, dir: path.join(HOME, 'apps', 'valoplex') },205  { id: 'auto-ka@M4M64b', name: 'Auto·Ka', node: 'M4M64b', dir: '~/auto-ka' },206  { id: 'fabri-ka@M4M64a', name: 'Fabri·Ka', node: 'M4M64a', dir: '~/fabri-ka' },207  { id: 'food-ka@M4M64b', name: 'Food·Ka', node: 'M4M64b', dir: '~/apps/food-ka' },208  { id: 'resto-ka@M3U96b', name: 'Resto·Ka', node: 'M3U96b', dir: '~/apps/resto-ka' },209  { id: 'sorti-ka@M3U96a', name: 'Sorti·Ka', node: null, dir: path.join(HOME, 'apps', 'sorti-ka') },210  { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },211  { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },212  { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },213];214function listProjects() {215  return PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir }));216}217function findProject(id) { return PROJECTS.find(p => p.id === id) || null; }218219// ---------- exécution Claude Code ----------220const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];221// mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions)222const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };223const DEFAULT_MODEL = 'fable';224const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass'];225const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task';226227const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`;228229const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`;230231// chatId -> { proc, permToken, stderr, remote }232const running = new Map();233// requestId -> { chatId, resolve, timer, input, toolKey }234const pendingPerms = new Map();235236function broadcast(chatId, msg) {237  const data = JSON.stringify(msg);238  for (const ws of wss.clients) {239    if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data);240  }241}242function broadcastAll(msg) {243  const data = JSON.stringify(msg);244  for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data);245}246function broadcastEco(msg) {247  const data = JSON.stringify(msg);248  for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data);249}250function emitAndLog(chatId, msg) {251  appendTranscript(chatId, msg);252  broadcast(chatId, msg);253}254function setChatState(chat, state) {255  chat.state = state;256  chat.updated = Date.now();257  saveChats();258  broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });259}260261function permKey(tool, input) {262  if (tool === 'Bash') {263    const first = String(input?.command || '').trim().split(/\s+/)[0] || '?';264    return 'Bash:' + first;265  }266  return tool;267}268269function startClaude(chat, prompt) {270  if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.');271  const permToken = crypto.randomBytes(16).toString('hex');272  const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL;273  const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default';274  let proc;275276  const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',277    '--model', CLI_MODEL[model], '--append-system-prompt', SYSTEM_APPEND];278  if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId);279280  if (!chat.node) {281    // exécution locale (app déployée sur M3U96a)282    const args = [...baseArgs];283    if (permMode === 'bypass') {284      args.push('--dangerously-skip-permissions');285    } else {286      args.push('--permission-mode', permMode);287      const mcpCfg = {288        mcpServers: {289          adminka: {290            command: NODE_BIN,291            args: [path.join(__dirname, 'perm-mcp.js')],292            env: {293              ADMIN_KA_PERM_URL: `http://127.0.0.1:${PORT}/internal/perm`,294              ADMIN_KA_PERM_TOKEN: permToken,295            },296          },297        },298      };299      const cfgPath = path.join(DATA_DIR, `mcp-${chat.id}.json`);300      fs.writeFileSync(cfgPath, JSON.stringify(mcpCfg));301      args.push('--mcp-config', cfgPath);302      args.push('--permission-prompt-tool', 'mcp__adminka__approve');303      args.push('--allowedTools', ALLOWED_READONLY);304    }305    proc = spawn(CLAUDE_BIN, args, {306      cwd: chat.dir,307      env: {308        ...process.env, ...CLAUDE_ENV,309        PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME,310      },311      stdio: ['pipe', 'pipe', 'pipe'],312      detached: false,313    });314  } else {315    // exécution distante : claude tourne SUR le nœud de déploiement de l'app,316    // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée).317    const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6'];318    let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' ');319    if (permMode === 'bypass') {320      cmd += ' --dangerously-skip-permissions';321    } else {322      const revPort = 21000 + Math.floor(Math.random() * 3000);323      sshArgs.push('-R', `127.0.0.1:${revPort}:127.0.0.1:${PORT}`);324      const mcpCfg = {325        mcpServers: {326          adminka: {327            command: '/opt/homebrew/bin/node',328            args: ['/Users/simon-pierreboucher/.adminka/perm-mcp.js'],329            env: {330              ADMIN_KA_PERM_URL: `http://127.0.0.1:${revPort}/internal/perm`,331              ADMIN_KA_PERM_TOKEN: permToken,332            },333          },334        },335      };336      const cfgFile = `/tmp/adminka-mcp-${permToken}.json`;337      cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`;338    }339    const remoteCmd = [340      `cd ${chat.dir}`,341      'export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH',342      'export $(cat ~/.claude/.env | xargs)',343      `echo $$ > /tmp/adminka-pid-${permToken}`,344      cmd,345    ].join(' && ');346    proc = spawn('ssh', [...sshArgs, chat.node, remoteCmd], {347      env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME },348      stdio: ['pipe', 'pipe', 'pipe'],349    });350  }351  proc.stdin.write(prompt);352  proc.stdin.end();353354  const state = { proc, permToken, stderr: '', remote: chat.node || null };355  running.set(chat.id, state);356  audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) });357  emitAndLog(chat.id, { type: 'status', state: 'running' });358  setChatState(chat, 'running');359360  let buf = '';361  proc.stdout.on('data', (d) => {362    buf += d.toString('utf8');363    let idx;364    while ((idx = buf.indexOf('\n')) >= 0) {365      const line = buf.slice(0, idx).trim();366      buf = buf.slice(idx + 1);367      if (!line) continue;368      let ev;369      try { ev = JSON.parse(line); } catch { continue; }370      handleClaudeEvent(chat, ev);371    }372  });373  proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); });374  proc.on('close', (code) => {375    running.delete(chat.id);376    for (const [rid, p] of pendingPerms) {377      if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); }378    }379    if (code !== 0 && code !== null) {380      emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` });381    }382    emitAndLog(chat.id, { type: 'status', state: 'idle' });383    setChatState(chat, 'idle');384    broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName });385  });386  proc.on('error', (err) => {387    running.delete(chat.id);388    emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message });389    emitAndLog(chat.id, { type: 'status', state: 'idle' });390    setChatState(chat, 'idle');391  });392}393394function handleClaudeEvent(chat, ev) {395  if (ev.type === 'system' && ev.subtype === 'init') {396    if (ev.session_id) chat.claudeSessionId = ev.session_id;397    if (ev.model) chat.lastModel = ev.model;398    saveChats();399    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });400  }401  if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {402    for (const block of ev.message.content) {403      if (block.type === 'tool_use') {404        audit({ kind: 'tool_use', chatId: chat.id, tool: block.name, input: JSON.stringify(block.input || {}).slice(0, 1000) });405      }406    }407  }408  if (ev.type === 'result') {409    chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0);410    chat.turns = (chat.turns || 0) + (ev.num_turns || 0);411    const u = ev.usage || {};412    chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);413    saveChats();414    audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns });415    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });416  }417  if (ev.type === 'stream_event') {418    broadcast(chat.id, { type: 'claude', event: ev });419  } else {420    emitAndLog(chat.id, { type: 'claude', event: ev });421  }422}423424function interrupt(chatId) {425  const st = running.get(chatId);426  if (!st) return;427  audit({ kind: 'interrupt', chatId });428  if (st.remote) {429    spawn('ssh', ['-o', 'BatchMode=yes', st.remote, `kill -TERM $(cat /tmp/adminka-pid-${st.permToken} 2>/dev/null) 2>/dev/null; rm -f /tmp/adminka-pid-${st.permToken}`], {430      env: { ...process.env, HOME }, stdio: 'ignore',431    });432  }433  st.proc.kill('SIGTERM');434  setTimeout(() => { try { st.proc.kill('SIGKILL'); } catch {} }, 5000);435}436437// exécuter un script sur un nœud (local ou ssh) — pour les actions d'admin438function runOnNode(node, script) {439  return new Promise((resolve) => {440    const full = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH; ${script}`;441    const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';442    const args = node === SELF_NODE ? ['-c', full] : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, full];443    execFile(bin, args, { timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME } },444      (err, stdout, stderr) => resolve({ stdout: String(stdout || ''), stderr: String(stderr || ''), error: err ? (stderr || err.message) : null }));445  });446}447448// ---------- HTTP ----------449const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.woff2': 'font/woff2' };450451function readBody(req, limit = 512 * 1024) {452  return new Promise((resolve, reject) => {453    let data = '';454    req.on('data', (c) => { data += c; if (data.length > limit) { reject(new Error('body trop gros')); req.destroy(); } });455    req.on('end', () => resolve(data));456    req.on('error', reject);457  });458}459function json(res, code, obj) {460  res.writeHead(code, { 'Content-Type': 'application/json' });461  res.end(JSON.stringify(obj));462}463464const server = http.createServer(async (req, res) => {465  const url = new URL(req.url, 'http://x');466  const p = url.pathname;467468  if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }469470  // --- endpoint interne de permission (long-poll par perm-mcp.js) ---471  if (p === '/internal/perm' && req.method === 'POST') {472    let body;473    try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }474    let chatId = null;475    for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid;476    if (!chatId) return json(res, 403, { error: 'bad token' });477    const chat = getChat(chatId);478    const toolKey = permKey(body.tool_name, body.input);479480    // « Tout autoriser » activé → toute la session est auto-approuvée : la tâche481    // va au bout sans jamais attendre l'utilisateur482    if (chat && chat.autoAllowAll) {483      audit({ kind: 'permission_auto_all', chatId, tool: body.tool_name });484      emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: 'tout autoriser', input: body.input });485      return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });486    }487488    // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation489    if (chat && (chat.alwaysAllow || []).includes(toolKey)) {490      audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey });491      emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input });492      return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} });493    }494495    const requestId = crypto.randomBytes(8).toString('hex');496    const p2 = new Promise((resolve) => {497      const timer = setTimeout(() => {498        pendingPerms.delete(requestId);499        emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' });500        if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');501        resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" });502      }, 30 * 60 * 1000);503      pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey });504    });505    audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) });506    emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input });507    if (chat) setChatState(chat, 'waiting_perm');508    broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name });509    const decision = await p2;510    return json(res, 200, decision);511  }512513  // --- auth ---514  if (p === '/api/login' && req.method === 'POST') {515    const ip = clientIp(req);516    if (!loginAllowed(ip)) return json(res, 429, { error: 'Trop de tentatives — réessayer dans 15 minutes.' });517    let body;518    try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }519    if (typeof body.password !== 'string' || !verifyPassword(body.password)) {520      loginFailed(ip);521      audit({ kind: 'login_fail', ip });522      return json(res, 401, { error: 'Mot de passe invalide.' });523    }524    const token = newSession();525    audit({ kind: 'login_ok', ip });526    res.writeHead(200, {527      'Content-Type': 'application/json',528      'Set-Cookie': `akid=${token}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax; Secure`,529    });530    return res.end(JSON.stringify({ ok: true }));531  }532  if (p === '/api/logout' && req.method === 'POST') {533    const t = getSession(req);534    if (t) { delete sessions[t]; saveSessions(); }535    res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'akid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax; Secure' });536    return res.end(JSON.stringify({ ok: true }));537  }538539  // --- API authentifiée ---540  if (p.startsWith('/api/')) {541    if (!getSession(req)) return json(res, 401, { error: 'auth' });542    if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '2.0.0' });543    if (p === '/api/projects') return json(res, 200, { projects: listProjects() });544    if (p === '/api/chats' && req.method === 'GET') {545      const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);546      return json(res, 200, { chats: list });547    }548    if (p.startsWith('/api/chats/') && req.method === 'DELETE') {549      const id = p.split('/')[3];550      const chat = getChat(id);551      if (chat && !running.has(id)) {552        chats = chats.filter(c => c.id !== id); saveChats();553        try { fs.unlinkSync(transcriptPath(id)); } catch {}554        return json(res, 200, { ok: true });555      }556      return json(res, 400, { error: 'introuvable ou en cours' });557    }558    if (p === '/api/upgrade-prompt' && req.method === 'POST') {559      let body;560      try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }561      const raw = String(body.prompt || '').trim();562      if (!raw) return json(res, 400, { error: 'prompt vide' });563      try {564        const project = findProject(body.project);565        const upgraded = await upgradePrompt(raw, project);566        audit({ kind: 'upgrade_prompt', project: body.project, len_in: raw.length, len_out: upgraded.length });567        return json(res, 200, { upgraded });568      } catch (e) {569        return json(res, 502, { error: e.message });570      }571    }572    if (p === '/api/eco/summary') return json(res, 200, ecoSummary());573    if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });574    if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());575    if (p.startsWith('/api/eco/site/')) {576      const d = ecoSite(p.split('/')[4]);577      return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' });578    }579580    // --- actions d'administration par site (pm2 / logs / commits) ---581    if (p === '/api/admin/action' && req.method === 'POST') {582      let body;583      try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }584      const site = SITES.find(s => s.app === body.app);585      const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null;586      if (!site || !action) return json(res, 400, { error: 'app ou action invalide' });587      audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) });588      const out = await runOnNode(site.node, `pm2 ${action} ${site.pm2} && pm2 jlist | python3 -c "import json,sys; l=json.load(sys.stdin); p=[x for x in l if x['name']=='${site.pm2}']; print(p[0]['pm2_env']['status'] if p else 'introuvable')"`);589      return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) });590    }591    if (p.startsWith('/api/admin/logs/')) {592      const site = SITES.find(s => s.app === p.split('/')[4]);593      if (!site) return json(res, 404, { error: 'site inconnu' });594      const out = await runOnNode(site.node, `pm2 logs ${site.pm2} --nostream --lines 60 2>&1 | tail -70`);595      return json(res, 200, { logs: out.stdout || out.error || '(vide)' });596    }597    if (p.startsWith('/api/admin/commits/')) {598      const site = SITES.find(s => s.app === p.split('/')[4]);599      if (!site) return json(res, 404, { error: 'site inconnu' });600      const out = await runOnNode(site.node, `cd ${site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`);601      return json(res, 200, { text: out.stdout || out.error || '(vide)' });602    }603    return json(res, 404, { error: 'not found' });604  }605606  // --- statique ---607  let file = p === '/' ? '/index.html' : p;608  file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');609  const full = path.join(PUBLIC_DIR, file);610  if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); return res.end(); }611  fs.readFile(full, (err, data) => {612    if (err) { res.writeHead(404); return res.end('not found'); }613    res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream', 'Cache-Control': full.includes('/vendor/') ? 'public, max-age=86400' : 'no-cache' });614    res.end(data);615  });616});617618// ---------- WebSocket ----------619const wss = new WebSocketServer({ noServer: true });620621server.on('upgrade', (req, socket, head) => {622  const url = new URL(req.url, 'http://x');623  if (url.pathname !== '/ws' || !getSession(req)) {624    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');625    return socket.destroy();626  }627  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));628});629630wss.on('connection', (ws) => {631  ws.chatId = null;632  ws.eco = false;633  ws.isAlive = true;634  ws.on('pong', () => { ws.isAlive = true; });635  ws.on('message', (raw) => {636    let msg;637    try { msg = JSON.parse(raw.toString()); } catch { return; }638    try { handleWsMessage(ws, msg); } catch (e) {639      ws.send(JSON.stringify({ type: 'error', message: e.message }));640    }641  });642  // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours.643});644645setInterval(() => {646  for (const ws of wss.clients) {647    if (!ws.isAlive) { ws.terminate(); continue; }648    ws.isAlive = false; ws.ping();649  }650}, 30000);651652function handleWsMessage(ws, msg) {653  if (msg.type === 'open') {654    const chat = getChat(msg.chatId);655    if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' }));656    ws.chatId = chat.id;657    // rejoue tout l'historique persisté puis raccroche le live658    ws.send(JSON.stringify({659      type: 'history',660      chat: publicChat(chat),661      events: readTranscript(chat.id),662      running: running.has(chat.id),663    }));664    return;665  }666  if (msg.type === 'start') {667    const prompt = String(msg.prompt || '').trim();668    if (!prompt) return;669    let chat = msg.chatId ? getChat(msg.chatId) : null;670    if (!chat) {671      const proj = findProject(msg.project) || PROJECTS[0];672      chat = {673        id: crypto.randomBytes(8).toString('hex'),674        title: prompt.slice(0, 60),675        projectId: proj.id, projectName: proj.name,676        node: proj.node, dir: proj.dir,677        model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL,678        permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default',679        claudeSessionId: null, state: 'idle',680        totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [],681        created: Date.now(), updated: Date.now(),682      };683      chats.push(chat); saveChats();684      ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) }));685    } else {686      if (MODELS.includes(msg.model)) chat.model = msg.model;687      if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;688      saveChats();689    }690    ws.chatId = chat.id;691    emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() });692    startClaude(chat, prompt);693    return;694  }695  if (msg.type === 'set_opts') {696    const chat = getChat(msg.chatId);697    if (!chat) return;698    if (MODELS.includes(msg.model)) chat.model = msg.model;699    if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode;700    if (typeof msg.autoAllowAll === 'boolean') chat.autoAllowAll = msg.autoAllowAll;701    saveChats();702    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });703    return;704  }705  if (msg.type === 'clear') {706    const chat = getChat(msg.chatId);707    if (!chat || running.has(chat.id)) return;708    chat.claudeSessionId = null;709    chat.contextTokens = 0;710    saveChats();711    emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' });712    broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });713    return;714  }715  if (msg.type === 'perm') {716    const pending = pendingPerms.get(msg.requestId);717    if (!pending) return;718    clearTimeout(pending.timer);719    pendingPerms.delete(msg.requestId);720    const chat = getChat(pending.chatId);721    if (msg.allow && msg.allowAll && chat) {722      // « Tout autoriser » : plus AUCUNE permission ne sera demandée pour cette723      // conversation — le serveur approuve tout jusqu'à la fin de la tâche724      chat.autoAllowAll = true;725      saveChats();726      emitAndLog(chat.id, { type: 'notice', text: '🔓 Tout autoriser activé — la tâche ira au bout sans autre demande de permission.' });727      broadcastAll({ type: 'chat_meta', chat: publicChat(chat) });728    }729    if (msg.allow && msg.always && chat) {730      if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey);731      saveChats();732    }733    audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });734    emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always });735    if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running');736    pending.resolve(msg.allow737      ? { behavior: 'allow', updatedInput: pending.input || {} }738      : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" });739    return;740  }741  if (msg.type === 'interrupt') {742    if (msg.chatId) interrupt(msg.chatId);743    else if (ws.chatId) interrupt(ws.chatId);744    return;745  }746  if (msg.type === 'eco_sub') { ws.eco = true; return; }747  if (msg.type === 'eco_unsub') { ws.eco = false; return; }748}749750// ---------- monitoring Écosystème ----------751startMonitor({752  dataDir: DATA_DIR,753  onEvent: (ev) => {754    if (ev.type === 'eco_alert') broadcastAll(ev);   // alertes → tout le monde755    else broadcastEco(ev);                            // ticks → abonnés à l'onglet756  },757});758759// au démarrage, les chats marqués "running" par un ancien processus sont orphelins760for (const c of chats) if (c.state !== 'idle') c.state = 'idle';761saveChats();762763server.listen(PORT, '::', () => {764  console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`);765});766
Files · 27fcf70e4d95f96917c03d667658fae374d7bec3
Menu

Theme