// 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}`));