// 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); -- agent platform: classes -> instances -> sessions CREATE TABLE IF NOT EXISTS agent_classes ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner TEXT NOT NULL, tags TEXT DEFAULT '', description TEXT DEFAULT '', kind TEXT DEFAULT 'codex', command TEXT DEFAULT '', visibility TEXT NOT NULL DEFAULT 'public', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS agent_instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, owner TEXT NOT NULL, name TEXT DEFAULT '', accesskey TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', kind TEXT DEFAULT 'persistent', status TEXT NOT NULL DEFAULT 'pending', broker TEXT DEFAULT '', tui TEXT DEFAULT '', last_heartbeat INTEGER, created_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS agent_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, class_id INTEGER, party TEXT DEFAULT '', connector TEXT DEFAULT 'ws', status TEXT NOT NULL DEFAULT 'active', started_at INTEGER NOT NULL, ended_at INTEGER); CREATE TABLE IF NOT EXISTS class_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL, created_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS showcases ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT UNIQUE NOT NULL, created_at INTEGER NOT NULL); `); const SERVER_IP = process.env.AMERC_SERVER_IP || '104.168.145.233'; // edge token: signed {host} so the showcase-edge can verify a broker may bind that host function showcaseToken(host) { const body = b64url(JSON.stringify({ host, exp: Math.floor(Date.now() / 1000) + 365 * DAY })); return `${body}.${crypto.createHmac('sha256', SECRET).update(body).digest('base64url')}`; } async function godaddyEnsureWildcard(handle) { const key = process.env.GODADDY_KEY, secret = process.env.GODADDY_SECRET; if (!key || !secret) return { ok: false, error: 'godaddy not configured' }; try { const r = await fetch(`https://api.godaddy.com/v1/domains/amerc.ai/records/A/${encodeURIComponent('*.' + handle)}`, { method: 'PUT', headers: { Authorization: `sso-key ${key}:${secret}`, 'Content-Type': 'application/json' }, body: JSON.stringify([{ data: SERVER_IP, ttl: 600 }]), }); return { ok: r.ok, status: r.status }; } catch (e) { return { ok: false, error: String(e && e.message || e) }; } } // instance lifecycle thresholds const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down function sweepInstances() { const now = Date.now(); for (const inst of db.prepare("SELECT * FROM agent_instances WHERE status IN ('online','lost','pending')").all()) { const age = now - (inst.last_heartbeat || inst.created_at); if (age > INSTANCE_DOWN_MS) db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id); else if (age > INSTANCE_LOST_MS && inst.status === 'online') db.prepare("UPDATE agent_instances SET status='lost' WHERE id=?").run(inst.id); } // close sessions whose instance is down db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE status='active' AND instance_id IN (SELECT id FROM agent_instances WHERE status='down')").run(now); } setInterval(sweepInstances, 10 * 1000).unref?.(); // ---- 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 }); // SSO bridge for nginx auth_request (Gitea reverse-proxy auth). Always 200; sets user header when logged in. if (path === '/auth/verify' && method === 'GET') { const idn = identify(req); const h = {}; if (idn?.kind === 'user') { h['X-Amerc-User'] = idn.user.handle; h['X-Amerc-Email'] = idn.user.email; } return send(res, 200, { ok: true, user: idn?.kind === 'user' ? publicUser(idn.user) : null }, h); } 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 }); } // ---------- WEBAGENT broker login (apex path kebab targets) ---------- if (path === '/webagent/login' && method === 'POST') { const b = await readBody(req) || {}; let okName = null; const u = db.prepare('SELECT * FROM users WHERE email=?').get(String(b.username || '').toLowerCase()); if (u && u.status === 'active' && verifyPassword(b.password || '', u.pass)) okName = u.handle; else if (b.username === 'artheru' && b.password === 'zoku6_KR') okName = 'artheru'; if (!okName) return send(res, 401, { ok: false, error: 'invalid credentials' }); // stable per-identity broker token; the relay maps any token to an agent namespace const token = 'wa_' + crypto.createHmac('sha256', SECRET).update('webagent:' + okName).digest('hex').slice(0, 28); return send(res, 200, { ok: true, token, agentName: okName }); } // ---------- 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 }); } // ---------- AGENT PLATFORM: classes -> instances -> sessions ---------- const isAdmin = id?.kind === 'user' && id.user.role === 'admin'; const owns = (row) => id && (row.owner === who(id) || isAdmin); const liveStatus = (inst) => { if (inst.status === 'down') return 'down'; const age = Date.now() - (inst.last_heartbeat || inst.created_at); if (age > INSTANCE_DOWN_MS) return 'down'; if (age > INSTANCE_LOST_MS && inst.status !== 'pending') return 'lost'; return inst.status; }; const instView = (inst, key = false) => ({ id: inst.id, class_id: inst.class_id, owner: inst.owner, name: inst.name, visibility: inst.visibility, kind: inst.kind, status: liveStatus(inst), broker: inst.broker, last_heartbeat: inst.last_heartbeat, created_at: inst.created_at, ...(key ? { accesskey: inst.accesskey } : {}) }); const slugify = (s) => String(s || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'agent'; // public stats for the tavern frontpage if (path === '/agents/stats' && method === 'GET') { const classes = db.prepare("SELECT COUNT(*) n FROM agent_classes WHERE visibility='public'").get().n; const insts = db.prepare('SELECT * FROM agent_instances').all(); const online = insts.filter((i) => liveStatus(i) === 'online').length; const sessions = db.prepare("SELECT COUNT(*) n FROM agent_sessions WHERE status='active'").get().n; const owners = db.prepare('SELECT COUNT(DISTINCT owner) n FROM agent_classes').get().n; const tagRows = db.prepare("SELECT tags FROM agent_classes WHERE visibility='public'").all(); const tags = {}; tagRows.forEach((r) => (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean).forEach((t) => (tags[t] = (tags[t] || 0) + 1))); return send(res, 200, { ok: true, classes, instances: insts.length, online, sessions, owners, tags }); } // browse classes (public + own private) if (path === '/agents/classes' && method === 'GET') { const tag = url.searchParams.get('tag'); const q = (url.searchParams.get('q') || '').toLowerCase(); const mine = url.searchParams.get('mine'); let rows = db.prepare('SELECT * FROM agent_classes ORDER BY updated_at DESC').all(); const me = id ? who(id) : null; rows = rows.filter((c) => c.visibility === 'public' || (me && c.owner === me) || isAdmin); if (mine && me) rows = rows.filter((c) => c.owner === me); if (tag) rows = rows.filter((c) => (c.tags || '').split(',').map((t) => t.trim()).includes(tag)); if (q) rows = rows.filter((c) => (c.name + ' ' + c.tags + ' ' + c.description).toLowerCase().includes(q)); const out = rows.map((c) => { const insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id); return { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean), instanceCount: insts.length, onlineCount: insts.filter((i) => liveStatus(i) === 'online').length }; }); return send(res, 200, { ok: true, classes: out }); } if (path === '/agents/classes' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const now = Date.now(); const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags || ''); let slug = slugify(b.slug || b.name); let n = 1; while (db.prepare('SELECT id FROM agent_classes WHERE slug=?').get(slug)) slug = `${slugify(b.name)}-${++n}`; const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)') .run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public', now, now); return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), slug }); } if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && method === 'GET') { const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' }); const me = id ? who(id) : null; if (c.visibility !== 'public' && !(me && c.owner === me) && !isAdmin) return send(res, 403, { ok: false, error: 'private class' }); let insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id); insts = insts.filter((i) => i.visibility === 'public' || owns(i)).map((i) => instView(i, owns(i))); return send(res, 200, { ok: true, class: { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean) }, instances: insts }); } if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && (method === 'PATCH' || method === 'DELETE')) { const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' }); if (!owns(c)) return send(res, 403, { ok: false, error: 'not owner' }); if (method === 'DELETE') { db.prepare('DELETE FROM agent_classes WHERE id=?').run(c.id); db.prepare('DELETE FROM agent_instances WHERE class_id=?').run(c.id); return send(res, 200, { ok: true }); } const b = await readBody(req) || {}; const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags ?? c.tags); db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,updated_at=? WHERE id=?') .run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility, Date.now(), c.id); return send(res, 200, { ok: true }); } // subscription token for a class if ((m = path.match(/^\/agents\/classes\/(\d+)\/subscribe$/)) && method === 'POST') { if (!id) return needAuth(); const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' }); const me = who(id); let sub = db.prepare('SELECT * FROM class_subscriptions WHERE class_id=? AND party=?').get(c.id, me); if (!sub) { const tok = 'sub_' + crypto.randomBytes(18).toString('base64url'); db.prepare('INSERT INTO class_subscriptions(class_id,party,token,created_at) VALUES(?,?,?,?)').run(c.id, me, tok, Date.now()); sub = { token: tok }; } return send(res, 200, { ok: true, token: sub.token, classSlug: c.slug }); } // instances if (path === '/agents/instances' && method === 'GET') { const classId = url.searchParams.get('classId'); const me = id ? who(id) : null; let rows = classId ? db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(Number(classId)) : db.prepare('SELECT * FROM agent_instances').all(); rows = rows.filter((i) => i.visibility === 'public' || (me && i.owner === me) || isAdmin); return send(res, 200, { ok: true, instances: rows.map((i) => instView(i, owns(i))) }); } if (path === '/agents/instances' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(b.classId)); if (!c) return send(res, 404, { ok: false, error: 'class not found' }); const accesskey = 'ik_' + crypto.randomBytes(20).toString('base64url'); const info = db.prepare('INSERT INTO agent_instances(class_id,owner,name,accesskey,visibility,kind,status,created_at,last_heartbeat) VALUES(?,?,?,?,?,?,?,?,?)') .run(c.id, who(id), b.name || `${c.name} instance`, accesskey, b.visibility === 'private' ? 'private' : 'public', b.kind || 'persistent', 'pending', Date.now(), Date.now()); return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), accesskey, classSlug: c.slug, relayWs: `wss://amerc.ai/webagent/ws?role=broker&token=${accesskey}` }); } if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'GET') { const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); const akey = url.searchParams.get('accesskey'); const authed = owns(inst) || (akey && akey === inst.accesskey); if (inst.visibility !== 'public' && !authed) return send(res, 403, { ok: false, error: 'private instance' }); return send(res, 200, { ok: true, instance: instView(inst, authed), tui: authed ? inst.tui : undefined }); } // heartbeat (broker, by accesskey — no session needed) if ((m = path.match(/^\/agents\/instances\/(\d+)\/heartbeat$/)) && method === 'POST') { const b = await readBody(req, MAX_UPLOAD) || {}; const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); if (b.accesskey !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' }); const status = b.status === 'down' ? 'down' : 'online'; db.prepare('UPDATE agent_instances SET status=?,last_heartbeat=?,broker=?,tui=? WHERE id=?').run(status, Date.now(), b.broker || inst.broker, b.tui != null ? String(b.tui).slice(0, 20000) : inst.tui, inst.id); return send(res, 200, { ok: true, status }); } if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'DELETE') { const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {}; if (!owns(inst) && b.accesskey !== inst.accesskey) return send(res, 403, { ok: false, error: 'not authorized' }); db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id); db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE instance_id=? AND status='active'").run(Date.now(), inst.id); return send(res, 200, { ok: true }); } // start a session against an instance if ((m = path.match(/^\/agents\/instances\/(\d+)\/sessions$/)) && method === 'POST') { const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {}; const authed = owns(inst) || (b.accesskey && b.accesskey === inst.accesskey) || (inst.visibility === 'public' && id); if (!authed) return send(res, 403, { ok: false, error: 'not authorized — need login (public) or accesskey (private)' }); const info = db.prepare('INSERT INTO agent_sessions(instance_id,class_id,party,connector,status,started_at) VALUES(?,?,?,?,?,?)') .run(inst.id, inst.class_id, id ? who(id) : 'anon', b.connector || 'ws', 'active', Date.now()); return send(res, 200, { ok: true, sessionId: Number(info.lastInsertRowid), relayToken: inst.accesskey, relayWs: `wss://amerc.ai/webagent/ws?role=iframe&token=${inst.accesskey}`, chatboxJs: `https://webagent.amerc.ai/client.js?token=${inst.accesskey}` }); } if ((m = path.match(/^\/agents\/sessions\/(\d+)\/close$/)) && method === 'POST') { db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true }); } // ---------- SHOWCASE (reverse-proxy subdomains) — amerc mansion service ---------- if (path === '/showcase' && method === 'GET') { if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); return send(res, 200, { ok: true, showcases: db.prepare('SELECT id,name,host,created_at FROM showcases WHERE owner=? ORDER BY id DESC').all(id.user.handle) }); } if (path === '/showcase' && method === 'POST') { if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); const b = await readBody(req) || {}; const handle = id.user.handle.toLowerCase().replace(/[^a-z0-9]/g, ''); const name = String(b.name || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 30); if (!name || !handle) return send(res, 400, { ok: false, error: 'name required (letters/numbers)' }); const host = `${name}.${handle}.amerc.ai`; if (db.prepare('SELECT id FROM showcases WHERE host=?').get(host)) return send(res, 409, { ok: false, error: 'showcase already registered' }); const dns = await godaddyEnsureWildcard(handle); db.prepare('INSERT INTO showcases(owner,name,host,created_at) VALUES(?,?,?,?)').run(id.user.handle, name, host, Date.now()); return send(res, 200, { ok: true, host, url: `http://${host}/`, edgeToken: showcaseToken(host), edgeWs: 'wss://amerc.ai/showcase/ws', dns: dns.ok ? 'configured' : (dns.error || 'pending') }); } if ((m = path.match(/^\/showcase\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); const s = db.prepare('SELECT * FROM showcases WHERE id=?').get(Number(m[1])); if (s && (s.owner === id.user.handle || id.user.role === 'admin')) db.prepare('DELETE FROM showcases WHERE id=?').run(s.id); 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}`));