← back to The Ai Factory

server.js

248 lines

// The AI Factory — orchestrator (:9890)
// Stub server. Pipeline stages are no-ops; see PLAN.md before wiring.

require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const { Pool } = require('pg');

const PG_DATABASE = process.env.PG_DATABASE || 'ai_factory';

// Hard safety guard: this project must NEVER touch dw_unified.
// Steve's standing rule (2026-04-30) — The AI Factory has its own standalone DB.
if (PG_DATABASE === 'dw_unified') {
  console.error('[ai-factory] FATAL: PG_DATABASE=dw_unified is forbidden. Use the standalone `ai_factory` DB.');
  process.exit(1);
}

const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { runPipeline } = require('./src/pipeline');
const { runMemoryWrite } = require('./src/stages/memory_write');

const CLAUDE_AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
const CLAUDE_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '1mb' }));

// LAN/tailnet auth gate (skip /health for watchdog probes)
const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
app.use((req, res, next) => {
  if (req.path === '/health' || req.path === '/healthz') return next();
  if (req.get('authorization') === AUTH_HEADER) return next();
  res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
  res.status(401).send('auth required');
});

// SECURITY (P1 fix 2026-05-04): wildcard CORS was "safe since loopback" but
// CORS only restricts BROWSERS — any malicious page the user opens can fetch
// 127.0.0.1:9890 with credentials and trigger /runs or /runs/:id/activate.
// Allowlist the viewer origin only.
const VIEWER_ORIGIN = process.env.VIEWER_ORIGIN || 'http://127.0.0.1:9891';
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin === VIEWER_ORIGIN) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Vary', 'Origin');
    res.set('Access-Control-Allow-Credentials', 'true');
  }
  res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

// SECURITY (P0 fix 2026-05-04): ADMIN_TOKEN was declared in .env but never
// enforced — any local process could trigger /runs or activate artifacts into
// ~/.claude/. Mutating routes now require Authorization: Bearer $ADMIN_TOKEN.
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
function requireToken(req, res, next) {
  if (!ADMIN_TOKEN) return res.status(503).json({ error: 'ADMIN_TOKEN not configured on server' });
  const auth = String(req.headers['authorization'] || '');
  if (auth !== `Bearer ${ADMIN_TOKEN}`) return res.status(401).json({ error: 'unauthorized' });
  next();
}

const PORT = Number(process.env.ORCHESTRATOR_PORT || 9890);

// Build pool config; only include `password` if one is actually set, so that
// Unix-socket peer auth (the default on this Mac) works without forcing SCRAM.
const poolCfg = {
  host: process.env.PG_HOST || '/tmp',
  port: Number(process.env.PG_PORT || 5432),
  database: PG_DATABASE,
  user: process.env.PG_USER || 'stevestudio2'
};
if (process.env.PG_PASSWORD) poolCfg.password = process.env.PG_PASSWORD;
const pg = new Pool(poolCfg);

app.get('/health', (_req, res) => {
  res.json({ ok: true, service: 'ai-factory-orchestrator', port: PORT, db: PG_DATABASE });
});

app.get('/runs', async (_req, res) => {
  try {
    const { rows } = await pg.query(
      'SELECT id, prompt, artifact_type, artifact_name, status, current_stage, created_at, finished_at FROM runs ORDER BY id DESC LIMIT 100'
    );
    res.json({ runs: rows });
  } catch (err) {
    res.status(500).json({ error: err.message, hint: 'Did you `createdb ai_factory` and apply sql/001_init.sql?' });
  }
});

app.post('/runs', requireToken, async (req, res) => {
  const { prompt, artifact_type = null, artifact_name = null } = req.body || {};
  if (!prompt || typeof prompt !== 'string') {
    return res.status(400).json({ error: 'prompt (string) required' });
  }
  try {
    const { rows } = await pg.query(
      'INSERT INTO runs (prompt, artifact_type, artifact_name) VALUES ($1,$2,$3) RETURNING *',
      [prompt, artifact_type, artifact_name]
    );
    const run = rows[0];
    // Fire-and-forget pipeline; errors are persisted to events/runs.
    runPipeline(pg, run).catch(err => console.error('[ai-factory] pipeline crash:', err));
    res.json({ run });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

/**
 * Manual activation: copy a sandboxed artifact into ~/.claude/agents or ~/.claude/skills.
 * Hard-gated:
 *   - Run status must be `awaiting_activation*` (the critic approved the final version),
 *     unless `?force=true` is passed.
 *   - Will not overwrite an existing target file unless `?overwrite=true` is passed.
 *   - Reads the artifact location from the artifacts table — only files in our sandbox
 *     under ~/Projects/the-ai-factory/output/ are eligible.
 */
app.post('/runs/:id/activate', requireToken, async (req, res) => {
  const runId = Number(req.params.id);
  const force = req.query.force === 'true';
  const overwrite = req.query.overwrite === 'true';
  try {
    const { rows: runRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
    if (!runRows.length) return res.status(404).json({ error: 'run not found' });
    const run = runRows[0];

    if (!force && !String(run.status).startsWith('awaiting_activation')) {
      return res.status(409).json({
        error: `run status is ${run.status}; activation requires awaiting_activation* (or ?force=true)`,
        hint: 'critic did not approve. Inspect the artifact, fix manually, and pass ?force=true to override.'
      });
    }

    const { rows: artRows } = await pg.query(
      "SELECT * FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1",
      [runId]
    );
    if (!artRows.length) return res.status(409).json({ error: 'no artifact for this run' });
    const art = artRows[0];

    const sandboxRoot = path.resolve(path.join(__dirname, 'output'));
    const src = path.resolve(art.location);
    if (!src.startsWith(sandboxRoot + path.sep)) {
      return res.status(400).json({ error: `refusing to activate from outside sandbox: ${src}` });
    }

    // SECURITY (P0 fix 2026-05-04): re-validate artifact_name from DB before
    // building the dest path. Intake regex ran earlier but a tampered DB row
    // (or a future bug in intake) could let `../../.ssh/authorized_keys`
    // resolve outside CLAUDE_AGENTS_DIR / CLAUDE_SKILLS_DIR.
    if (!/^[a-z0-9-]{2,60}$/.test(String(art.artifact_name || ''))) {
      return res.status(400).json({ error: 'artifact_name failed validation' });
    }
    let dest, allowedRoot;
    if (art.artifact_type === 'subagent') {
      allowedRoot = path.resolve(CLAUDE_AGENTS_DIR);
      dest = path.join(CLAUDE_AGENTS_DIR, `${art.artifact_name}.md`);
    } else if (art.artifact_type === 'skill') {
      allowedRoot = path.resolve(CLAUDE_SKILLS_DIR);
      dest = path.join(CLAUDE_SKILLS_DIR, art.artifact_name, 'SKILL.md');
    } else {
      return res.status(400).json({ error: `unsupported artifact_type: ${art.artifact_type}` });
    }
    // Defense-in-depth: confirm resolved dest stays under allowedRoot.
    const resolvedDest = path.resolve(dest);
    if (!resolvedDest.startsWith(allowedRoot + path.sep)) {
      return res.status(400).json({ error: 'dest path traversal blocked' });
    }

    let existed = false;
    try { await fs.access(dest); existed = true; } catch { /* ok */ }
    if (existed && !overwrite) {
      return res.status(409).json({
        error: `target already exists: ${dest}`,
        hint: 'pass ?overwrite=true to replace'
      });
    }

    await fs.mkdir(path.dirname(dest), { recursive: true });
    await fs.copyFile(src, dest);

    await pg.query("UPDATE artifacts SET status='activated', location=$1 WHERE id=$2", [dest, art.id]);
    await pg.query("UPDATE runs SET status='activated', updated_at=now() WHERE id=$1", [runId]);
    await pg.query(
      "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
      [runId, 10, 'activate', 'info', existed ? 'overwrote target' : 'wrote target', JSON.stringify({ src, dest, force, overwrite })]
    );

    // Stage 9 — memory_write. Best-effort: log but don't fail activation if the
    // index append blows up. Spec is recovered from the intake event payload.
    let memory = null;
    try {
      const { rows: specRows } = await pg.query(
        "SELECT payload FROM events WHERE run_id=$1 AND stage_name='intake' AND message='spec extracted' ORDER BY id DESC LIMIT 1",
        [runId]
      );
      const spec = specRows[0]?.payload?.spec || null;
      memory = await runMemoryWrite({
        runId,
        artifactType: art.artifact_type,
        artifactName: art.artifact_name,
        spec,
        dest
      });
      await pg.query(
        "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
        [runId, 9, 'memory_write', 'info',
          memory.skipped ? `skipped: ${memory.reason}` : 'appended to MEMORY.md',
          JSON.stringify({ line: memory.line })]
      );
    } catch (err) {
      await pg.query(
        "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
        [runId, 9, 'memory_write', 'error', 'memory_write failed', JSON.stringify({ error: err.message })]
      );
    }

    res.json({ ok: true, src, dest, existed_before: existed, memory });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.get('/runs/:id/events', async (req, res) => {
  try {
    const { rows } = await pg.query(
      'SELECT id, stage, stage_name, level, message, payload, created_at FROM events WHERE run_id=$1 ORDER BY id ASC',
      [Number(req.params.id)]
    );
    res.json({ events: rows });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`[ai-factory] orchestrator listening on http://127.0.0.1:${PORT} (db=${PG_DATABASE})`);
});