amerc/server/amerc-api.mjs
artheru b055663372 amerc suite: 2D pixel tavern + zero-dep auth/admin/docs/pm backend
- Scene2D pixel tavern (replaces three.js 3D scene)
- amerc-api: node:http+node:sqlite+node:crypto, auth/admin/docs/files/boards
- docs.amerc.ai + pm.amerc.ai (whiteboard mindmap + netdisk) apps
- agent API keys for fleet read/write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:03:40 +08:00

259 lines
21 KiB
JavaScript

// amerc-api — zero-dependency backend: auth + admin + docs + netdisk + whiteboards.
// Node 22+ built-ins only: node:http, node:sqlite, node:crypto, node:fs.
// Run: node --experimental-sqlite amerc-api.mjs
// env: PORT, AMERC_DB, AMERC_SECRET, AMERC_COOKIE_DOMAIN, AMERC_FILES
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
import { dirname, join, basename } from 'node:path';
const PORT = Number(process.env.PORT || 5180);
const DB_PATH = process.env.AMERC_DB || './data/amerc.db';
const FILES_DIR = process.env.AMERC_FILES || './data/netdisk';
const SECRET = process.env.AMERC_SECRET || 'dev-insecure-secret-change-me';
const COOKIE_DOMAIN = process.env.AMERC_COOKIE_DOMAIN || ''; // e.g. .amerc.ai in prod
const COOKIE = 'amerc_session';
const DAY = 86400;
const MAX_UPLOAD = 16 * 1024 * 1024;
for (const d of [dirname(DB_PATH), FILES_DIR]) if (!existsSync(d)) mkdirSync(d, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, handle TEXT NOT NULL,
pass TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'embedded',
risk TEXT NOT NULL DEFAULT 'green', notes TEXT DEFAULT '', created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, company_id INTEGER, status TEXT NOT NULL DEFAULT 'draft',
price TEXT DEFAULT '', notes TEXT DEFAULT '', created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, prefix TEXT NOT NULL,
created_at INTEGER NOT NULL, last_used INTEGER);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, folder TEXT DEFAULT '', body TEXT DEFAULT '',
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, folder TEXT DEFAULT '', mime TEXT DEFAULT 'application/octet-stream',
size INTEGER DEFAULT 0, store TEXT NOT NULL, kind TEXT DEFAULT 'binary', text_body TEXT DEFAULT '',
created_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]',
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
`);
// ---- crypto -----------------------------------------------------------------
function hashPassword(pw) {
const salt = crypto.randomBytes(16);
return `scrypt$${salt.toString('hex')}$${crypto.scryptSync(pw, salt, 32).toString('hex')}`;
}
function verifyPassword(pw, stored) {
const [scheme, saltHex, hashHex] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 32);
const want = Buffer.from(hashHex, 'hex');
return hash.length === want.length && crypto.timingSafeEqual(hash, want);
}
const b64url = (buf) => Buffer.from(buf).toString('base64url');
function signToken(payload) {
const body = b64url(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 30 * DAY }));
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyToken(token) {
if (!token || !token.includes('.')) return null;
const [body, sig] = token.split('.');
const expect = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
try { const d = JSON.parse(Buffer.from(body, 'base64url').toString()); return d.exp < Math.floor(Date.now() / 1000) ? null : d; }
catch { return null; }
}
const hashKey = (k) => crypto.createHash('sha256').update(k).digest('hex');
// ---- http helpers -----------------------------------------------------------
function send(res, code, obj, extra = {}) {
const data = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(data);
}
function readBody(req, limit = 1e6) {
return new Promise((resolve) => {
let raw = ''; let over = false;
req.on('data', (c) => { raw += c; if (raw.length > limit) { over = true; req.destroy(); } });
req.on('end', () => { if (over) return resolve(null); try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } });
req.on('error', () => resolve(null));
});
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((p) => { const i = p.indexOf('='); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
return out;
}
const publicUser = (u) => u && { id: u.id, email: u.email, handle: u.handle, role: u.role, status: u.status };
function sessionCookie(token) {
const dom = COOKIE_DOMAIN ? `; Domain=${COOKIE_DOMAIN}` : '';
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${dom}; Max-Age=${30 * DAY}`;
}
const clearCookie = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax${COOKIE_DOMAIN ? `; Domain=${COOKIE_DOMAIN}` : ''}; Max-Age=0`;
// identity: returns { kind:'user', user } | { kind:'agent', name } | null
function identify(req) {
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) {
const key = auth.slice(7).trim();
const row = db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(hashKey(key));
if (row) { db.prepare('UPDATE api_keys SET last_used=? WHERE id=?').run(Date.now(), row.id); return { kind: 'agent', name: row.name }; }
}
const data = verifyToken(parseCookies(req)[COOKIE]);
if (data) { const u = db.prepare('SELECT id,email,handle,role,status FROM users WHERE id=?').get(data.uid); if (u) return { kind: 'user', user: u }; }
return null;
}
const who = (id) => (id.kind === 'agent' ? `agent:${id.name}` : id.user.handle);
// ---- generic collection CRUD (documents / boards) ---------------------------
function listRows(table, extraOrder = 'updated_at DESC') {
return db.prepare(`SELECT * FROM ${table} ORDER BY ${extraOrder}`).all();
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const path = url.pathname.replace(/^\/api/, '') || '/';
const method = req.method;
let m;
try {
if (path === '/health') return send(res, 200, { ok: true, service: 'amerc-api', version: 2 });
// ---------- AUTH ----------
if (path === '/auth/signup' && method === 'POST') {
const b = await readBody(req); if (!b) return send(res, 400, { ok: false, error: 'bad body' });
const { email, password, handle } = b;
if (!email || !password || password.length < 6) return send(res, 400, { ok: false, error: 'email and password (min 6) required' });
if (db.prepare('SELECT id FROM users WHERE email=?').get(String(email).toLowerCase())) return send(res, 409, { ok: false, error: 'email already registered' });
const role = db.prepare('SELECT COUNT(*) n FROM users').get().n === 0 ? 'admin' : 'member';
const info = db.prepare('INSERT INTO users(email,handle,pass,role,status,created_at) VALUES(?,?,?,?,?,?)')
.run(String(email).toLowerCase(), handle || String(email).split('@')[0], hashPassword(password), role, 'active', Date.now());
const u = db.prepare('SELECT id,email,handle,role,status FROM users WHERE id=?').get(Number(info.lastInsertRowid));
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
}
if (path === '/auth/login' && method === 'POST') {
const b = await readBody(req) || {};
const u = db.prepare('SELECT * FROM users WHERE email=?').get(String(b.email || '').toLowerCase());
if (!u || !verifyPassword(b.password || '', u.pass)) return send(res, 401, { ok: false, error: 'invalid credentials' });
if (u.status !== 'active') return send(res, 403, { ok: false, error: 'account suspended' });
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
}
if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); }
if (path === '/auth/password' && method === 'POST') {
const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
const b = await readBody(req) || {};
const u = db.prepare('SELECT * FROM users WHERE id=?').get(id.user.id);
if (!verifyPassword(b.currentPassword || '', u.pass)) return send(res, 401, { ok: false, error: 'current password incorrect' });
if (!b.newPassword || b.newPassword.length < 6) return send(res, 400, { ok: false, error: 'new password min 6 chars' });
db.prepare('UPDATE users SET pass=? WHERE id=?').run(hashPassword(b.newPassword), u.id);
return send(res, 200, { ok: true });
}
// ---------- ADMIN (user role=admin) ----------
if (path.startsWith('/admin/')) {
const id = identify(req);
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
if (id.user.role !== 'admin') return send(res, 403, { ok: false, error: 'admin only' });
const me = id.user;
if (path === '/admin/users' && method === 'GET')
return send(res, 200, { ok: true, users: db.prepare('SELECT id,email,handle,role,status,created_at FROM users ORDER BY id DESC').all() });
if ((m = path.match(/^\/admin\/users\/(\d+)$/)) && method === 'PATCH') {
const b = await readBody(req) || {}; const u = db.prepare('SELECT * FROM users WHERE id=?').get(Number(m[1]));
if (!u) return send(res, 404, { ok: false, error: 'not found' });
db.prepare('UPDATE users SET role=?,status=?,handle=? WHERE id=?').run(b.role || u.role, b.status || u.status, b.handle || u.handle, u.id);
return send(res, 200, { ok: true });
}
if ((m = path.match(/^\/admin\/users\/(\d+)$/)) && method === 'DELETE') {
if (Number(m[1]) === me.id) return send(res, 400, { ok: false, error: 'cannot delete yourself' });
db.prepare('DELETE FROM users WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true });
}
// api keys (agent tokens)
if (path === '/admin/keys' && method === 'GET')
return send(res, 200, { ok: true, keys: db.prepare('SELECT id,name,prefix,created_at,last_used FROM api_keys ORDER BY id DESC').all() });
if (path === '/admin/keys' && method === 'POST') {
const b = await readBody(req) || {}; const raw = `amk_${crypto.randomBytes(24).toString('base64url')}`;
const info = db.prepare('INSERT INTO api_keys(name,key_hash,prefix,created_at) VALUES(?,?,?,?)').run(b.name || 'agent', hashKey(raw), raw.slice(0, 12), Date.now());
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), key: raw, note: 'store this key now; it is not shown again' });
}
if ((m = path.match(/^\/admin\/keys\/(\d+)$/)) && method === 'DELETE') { db.prepare('DELETE FROM api_keys WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// companies + products generic CRUD
for (const [coll, cols] of [['companies', ['name', 'tier', 'risk', 'notes']], ['products', ['name', 'company_id', 'status', 'price', 'notes']]]) {
if (path === `/admin/${coll}` && method === 'GET') return send(res, 200, { ok: true, [coll]: db.prepare(`SELECT * FROM ${coll} ORDER BY id DESC`).all() });
if (path === `/admin/${coll}` && method === 'POST') {
const b = await readBody(req) || {}; const vals = cols.map((c) => b[c] ?? (c === 'company_id' ? null : ''));
const info = db.prepare(`INSERT INTO ${coll}(${cols.join(',')},created_at) VALUES(${cols.map(() => '?').join(',')},?)`).run(...vals, Date.now());
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
}
if ((m = path.match(new RegExp(`^/admin/${coll}/(\\d+)$`))) && method === 'PATCH') {
const b = await readBody(req) || {}; const row = db.prepare(`SELECT * FROM ${coll} WHERE id=?`).get(Number(m[1]));
if (!row) return send(res, 404, { ok: false, error: 'not found' });
db.prepare(`UPDATE ${coll} SET ${cols.map((c) => `${c}=?`).join(',')} WHERE id=?`).run(...cols.map((c) => b[c] ?? row[c]), row.id);
return send(res, 200, { ok: true });
}
if ((m = path.match(new RegExp(`^/admin/${coll}/(\\d+)$`))) && method === 'DELETE') { db.prepare(`DELETE FROM ${coll} WHERE id=?`).run(Number(m[1])); return send(res, 200, { ok: true }); }
}
return send(res, 404, { ok: false, error: 'not found' });
}
// ---------- CONTENT (docs / files / boards): any authenticated identity ----------
const id = identify(req);
const needAuth = () => send(res, 401, { ok: false, error: 'login or api key required' });
// DOCUMENTS
if (path === '/docs' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, docs: db.prepare('SELECT id,title,folder,updated_by,created_at,updated_at FROM documents ORDER BY folder,title').all() }); }
if (path === '/docs' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const now = Date.now();
const info = db.prepare('INSERT INTO documents(title,folder,body,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.title || 'Untitled', b.folder || '', b.body || '', who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); return d ? send(res, 200, { ok: true, doc: d }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); if (!d) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {};
db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// FILES (netdisk)
if (path === '/files' && method === 'GET') { if (!id) return needAuth(); const folder = url.searchParams.get('folder'); const rows = folder != null ? db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files WHERE folder=? ORDER BY name').all(folder) : db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files ORDER BY folder,name').all(); return send(res, 200, { ok: true, files: rows }); }
if (path === '/files' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD); if (!b) return send(res, 413, { ok: false, error: 'file too large (max 16MB)' });
const now = Date.now(); const name = String(b.name || 'file').replace(/[/\\]/g, '_');
if (b.kind === 'text' || (typeof b.text === 'string' && b.data == null)) {
const text = b.text || ''; const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,text_body,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'text/plain', Buffer.byteLength(text), '', 'text', text, who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
}
const buf = Buffer.from(String(b.data || ''), 'base64'); const store = `${now}-${crypto.randomBytes(4).toString('hex')}-${name}`;
fs.writeFileSync(join(FILES_DIR, store), buf);
const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'application/octet-stream', buf.length, store, 'binary', who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/files\/(\d+)\/raw$/)) && method === 'GET') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (!f) return send(res, 404, { ok: false, error: 'not found' });
if (f.kind === 'text') { res.writeHead(200, { 'Content-Type': f.mime || 'text/plain; charset=utf-8' }); return res.end(f.text_body || ''); }
const p = join(FILES_DIR, f.store); if (!existsSync(p)) return send(res, 410, { ok: false, error: 'blob missing' });
res.writeHead(200, { 'Content-Type': f.mime, 'Content-Length': f.size, 'Content-Disposition': `inline; filename="${basename(f.name)}"` }); return createReadStream(p).pipe(res); }
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); return f ? send(res, 200, { ok: true, file: f }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (!f) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req, MAX_UPLOAD) || {};
const text = b.text != null && f.kind === 'text' ? b.text : f.text_body;
db.prepare('UPDATE files SET name=?,folder=?,text_body=?,size=?,updated_at=? WHERE id=?').run(b.name ?? f.name, b.folder ?? f.folder, text, f.kind === 'text' ? Buffer.byteLength(text) : f.size, Date.now(), f.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (f) { if (f.store) try { fs.unlinkSync(join(FILES_DIR, f.store)); } catch {} db.prepare('DELETE FROM files WHERE id=?').run(f.id); } return send(res, 200, { ok: true }); }
// BOARDS (whiteboards)
if (path === '/boards' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, boards: db.prepare('SELECT id,name,links,updated_by,created_at,updated_at FROM boards ORDER BY updated_at DESC').all() }); }
if (path === '/boards' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD) || {}; const now = Date.now();
const info = db.prepare('INSERT INTO boards(name,data,links,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.name || 'Untitled board', b.data || '', JSON.stringify(b.links || []), who(id), now, now); return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const bd = db.prepare('SELECT * FROM boards WHERE id=?').get(Number(m[1])); return bd ? send(res, 200, { ok: true, board: bd }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const bd = db.prepare('SELECT * FROM boards WHERE id=?').get(Number(m[1])); if (!bd) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req, MAX_UPLOAD) || {};
db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
return send(res, 404, { ok: false, error: 'not found' });
} catch (err) {
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
}
});
server.listen(PORT, '127.0.0.1', () => console.log(`amerc-api v2 listening on 127.0.0.1:${PORT}, db=${DB_PATH}, files=${FILES_DIR}`));