/* * 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. * Identifies each caller by login and enforces a daily token quota per person. */ 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 ===== * Named users. Set APP_USERS as a comma-separated list of user:password * pairs, for example: APP_USERS=alice:pass1,bob:pass2 * Each name is also the identity used for the daily token quota below. * * Legacy single-user mode: set APP_USERNAME and APP_PASSWORD instead. That * name becomes the one identity everyone shares (no per-person quota). * * Leave all of the above unset for solo local use: no login prompt, and * usage is tracked under the identity "local". * * This is a simple shared-credential gate, meant to sit behind the company * VPN and internal network, not to replace them. */ function loadUserMap() { const map = {}; if (process.env.APP_USERS) { process.env.APP_USERS.split(',').forEach((pair) => { const idx = pair.indexOf(':'); if (idx === -1) return; const name = pair.slice(0, idx).trim(); const pass = pair.slice(idx + 1).trim(); if (name && pass) map[name] = pass; }); } else if (process.env.APP_USERNAME && process.env.APP_PASSWORD) { map[process.env.APP_USERNAME] = process.env.APP_PASSWORD; } return map; } const USER_MAP = loadUserMap(); const AUTH_REQUIRED = Object.keys(USER_MAP).length > 0; // Returns the identified username on success, or null on failure. function checkBasicAuth(req) { if (!AUTH_REQUIRED) return 'local'; const header = req.headers['authorization'] || ''; if (!header.startsWith('Basic ')) return null; let decoded; try { decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); } catch (err) { return null; } const idx = decoded.indexOf(':'); if (idx === -1) return null; const name = decoded.slice(0, idx); const pass = decoded.slice(idx + 1); return USER_MAP[name] === pass ? name : null; } 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 ===== * Caps AI draft calls per source IP, independent of the token quota below. * This catches a runaway script quickly, before it burns through a whole * day's token quota in a few seconds. Resets if the process restarts. */ 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; } /* ===== Per-user daily token quota (off by default) ===== * On the back burner: real usage does not look large enough to justify * running this. Left in place, disabled, in case that changes. Set * TOKEN_LIMIT_PER_USER to a positive number to turn it back on; leave it * unset or 0 for unlimited, which skips the check and the usage file. * * TOKEN_LIMIT_PER_USER caps combined input+output tokens per identified user, * per UTC calendar day. Usage is written to TOKEN_USAGE_FILE after every AI * call, so it survives a container restart. Mount that file's folder as a * volume in Docker or it resets on every redeploy. * * The check runs before the API call using the day's tally so far: if the * user is already at or over the limit, the call is refused with no cost. * A single call in progress when the limit is reached can still push the * tally past the cap by that one call's tokens; the next call is blocked. * That is an accepted tradeoff, since real token cost of a call is only * known once its response returns. */ const TOKEN_LIMIT_PER_USER = parseInt(process.env.TOKEN_LIMIT_PER_USER || '0', 10); const TOKEN_LIMIT_ENABLED = TOKEN_LIMIT_PER_USER > 0; const TOKEN_USAGE_FILE = process.env.TOKEN_USAGE_FILE || path.join(ROOT, 'data', 'token-usage.json'); function todayUTC() { return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD" } function loadUsage() { try { return JSON.parse(fs.readFileSync(TOKEN_USAGE_FILE, 'utf8')); } catch (err) { return {}; } } function saveUsage(usage) { try { fs.mkdirSync(path.dirname(TOKEN_USAGE_FILE), { recursive: true }); fs.writeFileSync(TOKEN_USAGE_FILE, JSON.stringify(usage)); } catch (err) { console.error('Could not write token usage file:', err.message); } } function usedTokensToday(usage, user) { const rec = usage[user]; if (!rec || rec.date !== todayUTC()) return 0; return rec.tokens; } function addTokens(user, tokens) { const usage = loadUsage(); const today = todayUTC(); const rec = usage[user] && usage[user].date === today ? usage[user] : { date: today, tokens: 0 }; rec.tokens += tokens; usage[user] = rec; saveUsage(usage); return rec.tokens; } function secondsUntilUTCMidnight() { const now = new Date(); const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); return Math.round((midnight - now) / 1000); } /* ===== Mock mode ===== * Set MOCK_AI=true to test the whole app, including the AI draft button and * the token quota above, with no API key and no real API call. Returns a * canned breadcrumb draft with a synthetic usage count, shaped like a real * Anthropic response, so the frontend parsing, gate checks, and quota * accounting all run exactly as they would against the live API. */ function mockClaudeResponse(res, user) { 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 usage = { input_tokens: 950, output_tokens: 280 }; const body = { content: [{ type: 'text', text: JSON.stringify(draft) }], usage }; if (TOKEN_LIMIT_ENABLED) addTokens(user, usage.input_tokens + usage.output_tokens); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); } function proxyToClaude(req, res, user) { if (String(process.env.MOCK_AI).toLowerCase() === 'true') { mockClaudeResponse(res, user); 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); if (TOKEN_LIMIT_ENABLED) { try { const parsed = JSON.parse(text); if (parsed.usage) { addTokens(user, (parsed.usage.input_tokens || 0) + (parsed.usage.output_tokens || 0)); } } catch (err) { // Response was not JSON, or had no usage field. Nothing to record. } } } 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 = `

SDE Meeting Toolkit

`; 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) => { const user = checkBasicAuth(req); if (!user) { 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; } if (TOKEN_LIMIT_ENABLED) { const usedToday = usedTokensToday(loadUsage(), user); if (usedToday >= TOKEN_LIMIT_PER_USER) { sendJson(res, 429, { error: `Daily token quota reached (${usedToday}/${TOKEN_LIMIT_PER_USER} tokens for "${user}"). Resets in ${secondsUntilUTCMidnight()}s, at UTC midnight.` }); return; } } proxyToClaude(req, res, user); 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(AUTH_REQUIRED ? `Login required: ${Object.keys(USER_MAP).length} named user(s) configured.` : 'No login required: no APP_USERS or APP_USERNAME/APP_PASSWORD are set.'); console.log(TOKEN_LIMIT_ENABLED ? `Per-user daily token quota: ${TOKEN_LIMIT_PER_USER} tokens. Usage file: ${TOKEN_USAGE_FILE}` : 'No per-user token quota: TOKEN_LIMIT_PER_USER is unset or 0 (unlimited, feature on the back burner).'); });