324 lines
12 KiB
JavaScript
324 lines
12 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.
|
|
* 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 = `<!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) => {
|
|
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).');
|
|
});
|