Files
sde-meeting-toolkit/server.js

205 lines
7.4 KiB
JavaScript

/*
* Local server for the SDE meeting toolkit.
* Serves the static tool files.
* Proxies AI draft requests to the Anthropic API, so the API key
* stays on the server and never appears in the browser.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const ROOT = __dirname;
const PORT = process.env.PORT || 5173;
const ANTHROPIC_VERSION = '2023-06-01';
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.skill': 'application/zip'
};
function loadEnvFile() {
const envPath = path.join(ROOT, '.env');
let content;
try {
content = fs.readFileSync(envPath, 'utf8');
} catch (err) {
return; // no .env file; that is allowed, the AI draft route will report it
}
content.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const idx = trimmed.indexOf('=');
if (idx === -1) return;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
if (key && !(key in process.env)) process.env[key] = value;
});
}
loadEnvFile();
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(body);
}
/* ===== Access control =====
* Set APP_USERNAME and APP_PASSWORD (in .env or the container environment)
* to require a login for the whole app. Leave both unset for solo local use
* with no login prompt. This is a simple shared-credential gate, meant to sit
* behind the company VPN and internal network, not to replace them. */
function checkBasicAuth(req) {
const user = process.env.APP_USERNAME;
const pass = process.env.APP_PASSWORD;
if (!user || !pass) return true;
const header = req.headers['authorization'] || '';
const expected = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
return header === expected;
}
function requireAuth(res) {
res.writeHead(401, {
'WWW-Authenticate': 'Basic realm="SDE Meeting Toolkit"',
'Content-Type': 'text/plain'
});
res.end('Authentication required.');
}
/* ===== Rate limiting for the AI proxy =====
* A shared company API key means one leaked link or one runaway script can
* generate real cost. This caps AI draft calls per source IP. It resets if
* the process restarts; that is an accepted tradeoff for a small internal tool. */
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10);
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || String(5 * 60 * 1000), 10);
const rateLimitHits = new Map();
function isRateLimited(ip) {
const now = Date.now();
const hits = (rateLimitHits.get(ip) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
hits.push(now);
rateLimitHits.set(ip, hits);
return hits.length > RATE_LIMIT_MAX;
}
/* ===== Mock mode =====
* Set MOCK_AI=true to test the whole app, including the AI draft button,
* with no API key and no real API call. Returns a canned breadcrumb draft
* shaped like a real Anthropic response, so the frontend parsing and gate
* checks run exactly as they would against the live API. Use this for
* testing the login gate, rate limit, and Docker setup without cost. */
function mockClaudeResponse(res) {
const draft = {
domain: 'MOCK: Progress Visibility',
driver: 'MOCK driver: schedule forecasts drift from field reality.',
cap: 'MOCK capability: a rules-of-credit progress check against the field.',
metric: 'MOCK metric: claiming accuracy, reported % vs field-verified %, by phase.',
method: 'MOCK method: monthly spot audit of 30+ sampled assets.',
baseline: 'MOCK baseline: first audit result.'
};
const body = {
content: [{ type: 'text', text: JSON.stringify(draft) }]
};
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
}
function proxyToClaude(req, res) {
if (String(process.env.MOCK_AI).toLowerCase() === 'true') {
mockClaudeResponse(res);
return;
}
if (!process.env.ANTHROPIC_API_KEY) {
sendJson(res, 500, {
error: 'ANTHROPIC_API_KEY is not set. Add it to the .env file in this folder, then restart the server.'
});
return;
}
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', async () => {
try {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': ANTHROPIC_VERSION
},
body
});
const text = await upstream.text();
res.writeHead(upstream.status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text);
} catch (err) {
sendJson(res, 502, { error: 'Could not reach the Anthropic API: ' + err.message });
}
});
}
function serveStatic(req, res) {
const urlPath = decodeURIComponent(req.url.split('?')[0]);
if (urlPath === '/') {
const index = `<!doctype html><html><body style="font:14px system-ui;padding:2rem">
<h1>SDE Meeting Toolkit</h1>
<ul>
<li><a href="/tools/field-problem-workshop.html">Field Problem Workshop</a></li>
<li><a href="/tools/scope-lock-meeting-suite.html">Scope Lock Meeting Suite</a></li>
</ul>
</body></html>`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(index);
return;
}
const filePath = path.normalize(path.join(ROOT, urlPath));
if (!filePath.startsWith(ROOT)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found: ' + urlPath);
return;
}
const ext = path.extname(filePath);
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(data);
});
}
const server = http.createServer((req, res) => {
if (!checkBasicAuth(req)) {
requireAuth(res);
return;
}
if (req.method === 'POST' && req.url === '/api/claude') {
const ip = req.socket.remoteAddress || 'unknown';
if (isRateLimited(ip)) {
sendJson(res, 429, { error: `Rate limit reached (${RATE_LIMIT_MAX} AI drafts per ${Math.round(RATE_LIMIT_WINDOW_MS / 60000)} min). Wait a bit and try again.` });
return;
}
proxyToClaude(req, res);
return;
}
if (req.method === 'GET') {
serveStatic(req, res);
return;
}
res.writeHead(405, { 'Content-Type': 'text/plain' });
res.end('Method not allowed');
});
server.listen(PORT, () => {
console.log(`SDE meeting toolkit running at http://localhost:${PORT}`);
if (String(process.env.MOCK_AI).toLowerCase() === 'true') {
console.log('MOCK_AI=true: AI draft button returns a canned response. No API key used or needed.');
} else {
console.log(process.env.ANTHROPIC_API_KEY ? 'ANTHROPIC_API_KEY loaded: AI draft button is live.' : 'No ANTHROPIC_API_KEY found: AI draft button will return an error until you add one to .env.');
}
console.log((process.env.APP_USERNAME && process.env.APP_PASSWORD) ? 'Login required: APP_USERNAME/APP_PASSWORD are set.' : 'No login required: APP_USERNAME/APP_PASSWORD are not set.');
});