amerc/server/amerc-api.mjs
artheru 3b92827563 chat: player-to-player tavern chat (4.3.0)
- backend: in-memory ring buffer POST/GET /agents/tavern-chat
  {id,name,text}, last ~60, control-char sanitized
- RpgTavern: Enter/T (or 💬) opens a chat bar; your line floats as a
  canvas speech bubble above your avatar for ~5.5s AND lands in a small
  chat log; others' bubbles render above their avatars (keyed by presence
  id); typing freezes movement
- verified across two browsers: A's message appeared in B's log and as a
  bubble over A's avatar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:43:08 +08:00

872 lines
66 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { spawn } from 'node:child_process';
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);
-- chatlog: every session keeps its messages. kind 'file' stores only a
-- placeholder (name/size/mime) — bytes relay through memory, never stored.
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, seq INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'user', kind TEXT NOT NULL DEFAULT 'text', content TEXT DEFAULT '',
file_name TEXT DEFAULT '', file_size INTEGER DEFAULT 0, file_mime TEXT DEFAULT '', file_token TEXT DEFAULT '',
created_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_chat_session_seq ON chat_messages(session_id, seq);
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);
-- file-mapper: agent-published named files; each download is fetched live
-- from the broker and piped through — the server stores no bytes.
CREATE TABLE IF NOT EXISTS file_maps (
id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, owner TEXT NOT NULL,
name TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER DEFAULT 0, mime TEXT DEFAULT 'application/octet-stream',
created_at INTEGER NOT NULL, UNIQUE(instance_id, name));
`);
try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE agent_instances ADD COLUMN skills TEXT DEFAULT ''"); } catch { /* column exists */ }
// class runtime properties: amerc-managed (loads amerc skills), terminal mode
// per session ('new' | 'shared'), and whether a shared terminal reloads the
// client's skills on each new session.
try { db.exec("ALTER TABLE agent_classes ADD COLUMN managed INTEGER DEFAULT 0"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE agent_classes ADD COLUMN terminal_mode TEXT DEFAULT 'new'"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE agent_classes ADD COLUMN skills_reload INTEGER DEFAULT 1"); } catch { /* column exists */ }
// ---- live chat plumbing (in-memory; survives nothing, stores nothing) -------
// waiters: long-poll watchers per session, woken when a message lands.
const chatWaiters = new Map(); // sessionId -> Set<fn>
function wakeChat(sessionId) {
const set = chatWaiters.get(sessionId);
if (!set) return;
chatWaiters.delete(sessionId);
for (const fn of set) { try { fn(); } catch { /* waiter gone */ } }
}
function waitChat(sessionId, ms) {
return new Promise((resolve) => {
let set = chatWaiters.get(sessionId);
if (!set) { set = new Set(); chatWaiters.set(sessionId, set); }
const done = () => { clearTimeout(t); set.delete(fn); resolve(); };
const fn = done;
set.add(fn);
const t = setTimeout(done, ms);
});
}
// web-pty: browser keystrokes queue per instance; the broker long-polls them.
const inputQueues = new Map(); // instanceId -> string[]
const inputWaiters = new Map(); // instanceId -> Set<fn>
function wakeInput(instId) {
const s = inputWaiters.get(instId); if (!s) return;
inputWaiters.delete(instId);
for (const fn of s) { try { fn(); } catch { /* gone */ } }
}
function waitInput(instId, ms) {
return new Promise((resolve) => {
let s = inputWaiters.get(instId);
if (!s) { s = new Set(); inputWaiters.set(instId, s); }
const fn = () => { clearTimeout(t); s.delete(fn); resolve(); };
s.add(fn);
const t = setTimeout(fn, ms);
});
}
// online presence: a public, in-memory lobby of players walking the tavern.
const presence = new Map(); // id -> { id, name, x, y, dir, frame, sheet, room, at }
const PRESENCE_TTL = 6000;
function presenceList(exclude) {
const now = Date.now(); const out = [];
for (const [k, v] of presence) { if (now - v.at > PRESENCE_TTL) { presence.delete(k); continue; } if (k !== exclude) out.push(v); }
return out.slice(0, 50);
}
// tavern chat: a tiny in-memory ring buffer of recent player messages.
const tavernChat = []; let chatSeq = 0;
// file rendezvous: producer's request stream is piped to the consumer's
// response when it claims the token. The server never writes the bytes.
const FILE_CLAIM_MS = 5 * 60 * 1000;
const fileRelays = new Map(); // token -> { req, res, meta, timer, claimed }
function offerFile(token, req, res, meta) {
req.pause();
const entry = { req, res, meta, claimed: false };
entry.timer = setTimeout(() => {
if (!entry.claimed && fileRelays.get(token) === entry) {
fileRelays.delete(token);
send(res, 408, { ok: false, error: 'file not claimed within 5 minutes' });
}
}, FILE_CLAIM_MS);
fileRelays.set(token, entry);
req.on('close', () => { if (!entry.claimed && fileRelays.get(token) === entry) { clearTimeout(entry.timer); fileRelays.delete(token); } });
}
function claimFile(token, consumerRes) {
const entry = fileRelays.get(token);
if (!entry || entry.claimed) return false;
entry.claimed = true;
clearTimeout(entry.timer);
fileRelays.delete(token);
const { req: prodReq, res: prodRes, meta } = entry;
const headers = { 'Content-Type': meta.mime || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${encodeURIComponent(meta.name || 'file')}"`, 'Cache-Control': 'no-store' };
if (meta.size) headers['Content-Length'] = meta.size;
consumerRes.writeHead(200, headers);
prodReq.pipe(consumerRes);
prodReq.on('end', () => send(prodRes, 200, { ok: true, delivered: true }));
prodReq.on('error', () => { try { consumerRes.destroy(); } catch { /* gone */ } });
consumerRes.on('close', () => { if (!consumerRes.writableEnded) try { prodReq.destroy(); send(prodRes, 499, { ok: false, error: 'receiver disconnected' }); } catch { /* gone */ } });
prodReq.resume();
return true;
}
// broker fetch-jobs (file-mapper): the browser arrives FIRST and waits while
// the broker is told to push the bytes. Consumer-first rendezvous.
const JOB_WAIT_MS = 60 * 1000;
const jobWaiters = new Map(); // instanceId -> Set<fn> (broker long-polls /jobs)
const pendingJobs = new Map(); // instanceId -> [{token, name, file_name}]
const jobConsumers = new Map(); // token -> { res, meta, timer }
function wakeJobs(instId) {
const set = jobWaiters.get(instId); if (!set) return;
jobWaiters.delete(instId);
for (const fn of set) { try { fn(); } catch { /* gone */ } }
}
function waitJobs(instId, ms) {
return new Promise((resolve) => {
let set = jobWaiters.get(instId);
if (!set) { set = new Set(); jobWaiters.set(instId, set); }
const fn = () => { clearTimeout(t); set.delete(fn); resolve(); };
set.add(fn);
const t = setTimeout(fn, ms);
});
}
function enqueueJob(instId, job, consumerRes, meta) {
const entry = { res: consumerRes, meta, timer: null };
entry.timer = setTimeout(() => {
if (jobConsumers.get(job.token) === entry) {
jobConsumers.delete(job.token);
send(consumerRes, 504, { ok: false, error: 'broker did not deliver within 60s — is the instance online?' });
}
}, JOB_WAIT_MS);
jobConsumers.set(job.token, entry);
consumerRes.on('close', () => { if (jobConsumers.get(job.token) === entry) { clearTimeout(entry.timer); jobConsumers.delete(job.token); } });
if (!pendingJobs.has(instId)) pendingJobs.set(instId, []);
pendingJobs.get(instId).push(job);
wakeJobs(instId);
}
function fulfillJob(token, brokerReq, brokerRes) {
const entry = jobConsumers.get(token);
if (!entry) return false;
clearTimeout(entry.timer);
jobConsumers.delete(token);
const headers = { 'Content-Type': entry.meta.mime || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${encodeURIComponent(entry.meta.name || 'file')}"`, 'Cache-Control': 'no-store' };
const len = Number(brokerReq.headers['content-length']) || entry.meta.size;
if (len) headers['Content-Length'] = len;
entry.res.writeHead(200, headers);
brokerReq.pipe(entry.res);
brokerReq.on('end', () => send(brokerRes, 200, { ok: true, delivered: true }));
brokerReq.on('error', () => { try { entry.res.destroy(); } catch { /* gone */ } });
entry.res.on('close', () => { if (!entry.res.writableEnded) try { brokerReq.destroy(); send(brokerRes, 499, { ok: false, error: 'receiver disconnected' }); } catch { /* gone */ } });
return true;
}
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') { 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') { 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) — each identity gets a 100 MB quota
const NETDISK_QUOTA = 100 * 1024 * 1024;
const netdiskUsed = (owner) => db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE created_by=?').get(owner).s;
if (path === '/files/usage' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, used: netdiskUsed(who(id)), quota: NETDISK_QUOTA }); }
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, '_');
const incoming = b.kind === 'text' || (typeof b.text === 'string' && b.data == null) ? Buffer.byteLength(b.text || '') : Math.floor(String(b.data || '').length * 0.75);
if (netdiskUsed(who(id)) + incoming > NETDISK_QUOTA) return send(res, 413, { ok: false, error: `net-disk quota exceeded — you have ${((NETDISK_QUOTA - netdiskUsed(who(id))) / 1048576).toFixed(1)} MB of 100 MB left` });
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 parseSkills = (s) => { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
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, skills: parseSkills(inst.skills), last_heartbeat: inst.last_heartbeat, created_at: inst.created_at, ...(key ? { accesskey: inst.accesskey } : {}) });
// chatlog insert: persists the message (file kinds keep only the placeholder) and wakes long-pollers
const addMsg = (sessionId, role, kind, content, file = {}) => {
const seq = db.prepare('SELECT COALESCE(MAX(seq),0) s FROM chat_messages WHERE session_id=?').get(sessionId).s + 1;
db.prepare('INSERT INTO chat_messages(session_id,seq,role,kind,content,file_name,file_size,file_mime,file_token,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.run(sessionId, seq, role, kind, String(content || '').slice(0, 32000), String(file.name || '').slice(0, 200), Number(file.size) || 0, String(file.mime || '').slice(0, 100), file.token || '', Date.now());
wakeChat(sessionId);
return seq;
};
// who may touch a session: the instance's broker (accesskey) acts as 'agent';
// the hiring party / instance owner / admin act as 'user'.
const sessionRole = (sess, inst, akey) => {
if (akey && akey === inst.accesskey) return 'agent';
if (id && (sess.party === who(id) || inst.owner === who(id) || isAdmin)) return 'user';
return null;
};
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 });
}
// live activity feed (public, derived from existing tables)
if (path === '/agents/activity' && method === 'GET') {
const items = [];
for (const c of db.prepare("SELECT name, owner, created_at FROM agent_classes WHERE visibility='public' ORDER BY created_at DESC LIMIT 8").all())
items.push({ kind: 'class', text: `${c.owner} published “${c.name}`, at: c.created_at });
for (const i of db.prepare("SELECT i.created_at, i.owner, c.name cn FROM agent_instances i JOIN agent_classes c ON c.id=i.class_id WHERE i.visibility='public' ORDER BY i.created_at DESC LIMIT 8").all())
items.push({ kind: 'instance', text: `${i.owner} ran an instance of “${i.cn}`, at: i.created_at });
for (const s of db.prepare("SELECT s.started_at, c.name cn FROM agent_sessions s LEFT JOIN agent_classes c ON c.id=s.class_id ORDER BY s.started_at DESC LIMIT 8").all())
items.push({ kind: 'session', text: `a session started on “${s.cn || 'an agent'}`, at: s.started_at });
for (const sc of db.prepare('SELECT owner, host, created_at FROM showcases ORDER BY created_at DESC LIMIT 5').all())
items.push({ kind: 'showcase', text: `${sc.owner} opened a showcase ${sc.host}`, at: sc.created_at });
items.sort((a, b) => b.at - a.at);
return send(res, 200, { ok: true, activity: items.slice(0, 12) });
}
// online presence (public lobby — no auth; visitors see each other in the tavern)
if (path === '/agents/presence' && method === 'POST') {
const b = await readBody(req) || {};
const pid = String(b.id || '').slice(0, 40); if (!pid) return send(res, 400, { ok: false, error: 'id required' });
const dir = ['up', 'down', 'left', 'right'].includes(b.dir) ? b.dir : 'down';
presence.set(pid, { id: pid, name: String(b.name || 'wanderer').slice(0, 24), x: Math.round(Number(b.x) || 0), y: Math.round(Number(b.y) || 0), dir, frame: Math.max(0, Math.min(8, Number(b.frame) || 0)), sheet: String(b.sheet || 'soldier2').slice(0, 16), room: String(b.room || 'tavern').slice(0, 16), at: Date.now() });
return send(res, 200, { ok: true, players: presenceList(pid) });
}
if (path === '/agents/presence' && method === 'GET') return send(res, 200, { ok: true, players: presenceList(null) });
// tavern chat (public lobby chat — bubbles + a small log)
if (path === '/agents/tavern-chat' && method === 'POST') {
const b = await readBody(req) || {};
const text = String(b.text || '').replace(/[-]/g, ' ').trim().slice(0, 200);
if (!text) return send(res, 400, { ok: false, error: 'text required' });
const msg = { seq: ++chatSeq, id: String(b.id || '').slice(0, 40), name: String(b.name || 'wanderer').slice(0, 24), text, at: Date.now() };
tavernChat.push(msg); if (tavernChat.length > 60) tavernChat.shift();
return send(res, 200, { ok: true, seq: msg.seq });
}
if (path === '/agents/tavern-chat' && method === 'GET') {
const after = Number(url.searchParams.get('after')) || 0;
return send(res, 200, { ok: true, messages: tavernChat.filter((m) => m.seq > after).slice(-40), seq: chatSeq });
}
// 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,managed,terminal_mode,skills_reload,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',
b.managed ? 1 : 0, b.terminalMode === 'shared' ? 'shared' : 'new', b.skillsReload === false ? 0 : 1, 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=?,managed=?,terminal_mode=?,skills_reload=?,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,
b.managed != null ? (b.managed ? 1 : 0) : c.managed, b.terminalMode != null ? (b.terminalMode === 'shared' ? 'shared' : 'new') : c.terminal_mode,
b.skillsReload != null ? (b.skillsReload ? 1 : 0) : c.skills_reload, 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';
const skills = Array.isArray(b.skills) ? JSON.stringify(b.skills.slice(0, 64).map((s) => String(s).slice(0, 80))) : inst.skills;
db.prepare('UPDATE agent_instances SET status=?,last_heartbeat=?,broker=?,tui=?,skills=? WHERE id=?').run(status, Date.now(), b.broker || inst.broker, b.tui != null ? String(b.tui).slice(0, 20000) : inst.tui, skills, inst.id);
// tell the broker which of its sessions are live, so it can long-poll their chat
const live = db.prepare("SELECT id FROM agent_sessions WHERE instance_id=? AND status='active'").all(inst.id).map((r) => r.id);
return send(res, 200, { ok: true, status, activeSessions: live });
}
// web-pty: lightweight tui frame push from the broker (keeps it online too)
if ((m = path.match(/^\/agents\/instances\/(\d+)\/tui$/)) && 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' });
db.prepare('UPDATE agent_instances SET tui=?,last_heartbeat=?,status=? WHERE id=?').run(String(b.tui || '').slice(0, 20000), Date.now(), inst.status === 'down' ? 'online' : inst.status, inst.id);
return send(res, 200, { ok: true });
}
// web-pty: browser posts keystrokes (owner/accesskey); broker long-polls them
if ((m = path.match(/^\/agents\/instances\/(\d+)\/input$/)) && 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) || {};
if (!(owns(inst) || (b.accesskey && b.accesskey === inst.accesskey))) return send(res, 403, { ok: false, error: 'only the owner can drive this terminal' });
if (typeof b.data !== 'string') return send(res, 400, { ok: false, error: 'data required' });
let q = inputQueues.get(inst.id); if (!q) { q = []; inputQueues.set(inst.id, q); }
q.push(b.data.slice(0, 8192));
if (q.length > 256) q.splice(0, q.length - 256);
wakeInput(inst.id);
return send(res, 200, { ok: true });
}
if ((m = path.match(/^\/agents\/instances\/(\d+)\/input$/)) && 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' });
if (url.searchParams.get('accesskey') !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
let q = inputQueues.get(inst.id) || [];
if (!q.length && url.searchParams.get('wait')) { await waitInput(inst.id, 25000); q = inputQueues.get(inst.id) || []; }
inputQueues.set(inst.id, []);
return send(res, 200, { ok: true, input: q });
}
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 || 'chat', 'active', Date.now());
const sid = Number(info.lastInsertRowid);
addMsg(sid, 'system', 'event', `session opened by ${id ? who(id) : 'anon'}`);
return send(res, 200, { ok: true, sessionId: sid, 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]));
addMsg(Number(m[1]), 'system', 'event', 'session closed');
return send(res, 200, { ok: true });
}
// ---------- CHAT: native amerc chat — no external relay needed ----------
// my chatlog: every session I started, or that runs on an instance I own
if (path === '/agents/sessions' && method === 'GET') {
if (!id) return needAuth();
const me = who(id);
const rows = db.prepare(`SELECT s.*, c.name class_name, c.slug class_slug, i.name inst_name, i.owner inst_owner
FROM agent_sessions s LEFT JOIN agent_classes c ON c.id=s.class_id LEFT JOIN agent_instances i ON i.id=s.instance_id
WHERE s.party=? OR i.owner=?${isAdmin ? ' OR 1=1' : ''} ORDER BY s.started_at DESC LIMIT 100`).all(me, me);
const out = rows.map((s) => {
const last = db.prepare('SELECT role,kind,content,file_name,created_at FROM chat_messages WHERE session_id=? ORDER BY seq DESC LIMIT 1').get(s.id);
const n = db.prepare("SELECT COUNT(*) n FROM chat_messages WHERE session_id=? AND kind!='event'").get(s.id).n;
return { ...s, messageCount: n, last };
});
return send(res, 200, { ok: true, sessions: out });
}
// poll/long-poll messages: GET ?after=<seq>&wait=1 — agent side passes ?accesskey=
if ((m = path.match(/^\/agents\/sessions\/(\d+)\/messages$/)) && method === 'GET') {
const sess = db.prepare('SELECT * FROM agent_sessions WHERE id=?').get(Number(m[1])); if (!sess) return send(res, 404, { ok: false, error: 'not found' });
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(sess.instance_id);
const role = sessionRole(sess, inst, url.searchParams.get('accesskey'));
if (!role) return send(res, 403, { ok: false, error: 'not in this session' });
const after = Number(url.searchParams.get('after') || 0);
const fetchRows = () => db.prepare('SELECT seq,role,kind,content,file_name,file_size,file_mime,file_token,created_at FROM chat_messages WHERE session_id=? AND seq>? ORDER BY seq LIMIT 200').all(sess.id, after);
let rows = fetchRows();
if (!rows.length && url.searchParams.get('wait')) { await waitChat(sess.id, 25000); rows = fetchRows(); }
const fresh = db.prepare('SELECT status FROM agent_sessions WHERE id=?').get(sess.id);
return send(res, 200, { ok: true, messages: rows, sessionStatus: fresh.status, instanceStatus: liveStatus(inst), skills: parseSkills(inst.skills) });
}
// send a message: browser (cookie) or broker (accesskey in body)
if ((m = path.match(/^\/agents\/sessions\/(\d+)\/messages$/)) && method === 'POST') {
const sess = db.prepare('SELECT * FROM agent_sessions WHERE id=?').get(Number(m[1])); if (!sess) return send(res, 404, { ok: false, error: 'not found' });
if (sess.status !== 'active') return send(res, 409, { ok: false, error: 'session closed' });
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(sess.instance_id);
const b = await readBody(req) || {};
const role = sessionRole(sess, inst, b.accesskey);
if (!role) return send(res, 403, { ok: false, error: 'not in this session' });
if (!b.content || typeof b.content !== 'string') return send(res, 400, { ok: false, error: 'content required' });
const seq = addMsg(sess.id, role, 'text', b.content);
return send(res, 200, { ok: true, seq });
}
// FILE RELAY (no cache): producer streams the raw body here; the bytes wait
// in this paused request until the other side claims the token — the server
// stores nothing. The chatlog records only "there was a file".
if ((m = path.match(/^\/agents\/sessions\/(\d+)\/files$/)) && method === 'POST') {
const sess = db.prepare('SELECT * FROM agent_sessions WHERE id=?').get(Number(m[1])); if (!sess) return send(res, 404, { ok: false, error: 'not found' });
if (sess.status !== 'active') return send(res, 409, { ok: false, error: 'session closed' });
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(sess.instance_id);
const role = sessionRole(sess, inst, url.searchParams.get('accesskey'));
if (!role) return send(res, 403, { ok: false, error: 'not in this session' });
const meta = {
name: (url.searchParams.get('name') || 'file').replace(/[/\\]/g, '_').slice(0, 200),
size: Number(req.headers['content-length']) || Number(url.searchParams.get('size')) || 0,
mime: req.headers['content-type'] && req.headers['content-type'] !== 'application/x-www-form-urlencoded' ? req.headers['content-type'] : 'application/octet-stream',
};
const token = 'f_' + crypto.randomBytes(18).toString('base64url');
offerFile(token, req, res, meta);
addMsg(sess.id, role, 'file', `sent ${meta.name}`, { ...meta, token });
return; // responds when claimed (delivered) or after the 5-min timeout
}
// claim a relayed file: single-use capability token from the chat message
if ((m = path.match(/^\/agents\/files\/([A-Za-z0-9_-]+)$/)) && method === 'GET') {
if (claimFile(m[1], res)) return; // piped
return send(res, 410, { ok: false, error: 'file gone — relay links are single-use and expire in 5 minutes' });
}
// ---------- FILE-MAPPER: stable agent-published file links, broker-relayed ----------
// agent registers/lists/unregisters named files on its instance
if ((m = path.match(/^\/agents\/instances\/(\d+)\/fm$/))) {
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 (method === 'GET') {
const akey = url.searchParams.get('accesskey');
if (!(owns(inst) || (akey && akey === inst.accesskey))) return send(res, 403, { ok: false, error: 'not authorized' });
return send(res, 200, { ok: true, maps: db.prepare('SELECT * FROM file_maps WHERE instance_id=? ORDER BY name').all(inst.id) });
}
const b = await readBody(req) || {};
if (!(owns(inst) || (b.accesskey && b.accesskey === inst.accesskey))) return send(res, 403, { ok: false, error: 'not authorized' });
if (method === 'POST') {
const name = String(b.name || '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 80);
if (!name) return send(res, 400, { ok: false, error: 'name required (letters/numbers/._-)' });
db.prepare('INSERT INTO file_maps(instance_id,owner,name,file_name,size,mime,created_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(instance_id,name) DO UPDATE SET file_name=excluded.file_name,size=excluded.size,mime=excluded.mime')
.run(inst.id, inst.owner, name, String(b.fileName || name).slice(0, 200), Number(b.size) || 0, String(b.mime || 'application/octet-stream').slice(0, 100), Date.now());
return send(res, 200, { ok: true, url: `https://amerc.ai/api/fm/${inst.id}/${name}` });
}
if (method === 'DELETE') {
db.prepare('DELETE FROM file_maps WHERE instance_id=? AND name=?').run(inst.id, String(b.name || ''));
return send(res, 200, { ok: true });
}
}
// my mapped files across my instances (Mansion room)
if (path === '/fm' && method === 'GET') {
if (!id) return needAuth();
const rows = db.prepare(`SELECT f.*, i.status inst_status, i.last_heartbeat, i.created_at inst_created, c.name class_name
FROM file_maps f JOIN agent_instances i ON i.id=f.instance_id LEFT JOIN agent_classes c ON c.id=i.class_id
WHERE f.owner=? ORDER BY f.created_at DESC`).all(who(id));
return send(res, 200, { ok: true, maps: rows.map((r) => ({ ...r, inst_status: liveStatus({ status: r.inst_status, last_heartbeat: r.last_heartbeat, created_at: r.inst_created }), url: `https://amerc.ai/api/fm/${r.instance_id}/${r.name}` })) });
}
// public download: relayed live through the broker, nothing cached
if ((m = path.match(/^\/fm\/(\d+)\/([a-z0-9._-]+)$/)) && method === 'GET') {
const map = db.prepare('SELECT * FROM file_maps WHERE instance_id=? AND name=?').get(Number(m[1]), m[2]);
if (!map) return send(res, 404, { ok: false, error: 'no such mapped file' });
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(map.instance_id);
if (!inst || liveStatus(inst) !== 'online') return send(res, 503, { ok: false, error: 'the instance serving this file is offline' });
const token = 'j_' + crypto.randomBytes(18).toString('base64url');
enqueueJob(inst.id, { token, kind: 'file', name: map.name, file_name: map.file_name }, res, { name: map.file_name, size: map.size, mime: map.mime });
return; // responds when the broker delivers (or 504)
}
// broker long-polls its fetch jobs
if ((m = path.match(/^\/agents\/instances\/(\d+)\/jobs$/)) && 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' });
if (url.searchParams.get('accesskey') !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
let jobs = pendingJobs.get(inst.id) || [];
if (!jobs.length && url.searchParams.get('wait')) { await waitJobs(inst.id, 25000); jobs = pendingJobs.get(inst.id) || []; }
pendingJobs.delete(inst.id);
return send(res, 200, { ok: true, jobs });
}
// broker pushes the bytes for a job (raw body) -> piped to the waiting browser
if ((m = path.match(/^\/agents\/jobs\/([A-Za-z0-9_-]+)$/)) && method === 'POST') {
if (fulfillJob(m[1], req, res)) return;
return send(res, 410, { ok: false, error: 'job gone — the requester disconnected or timed out' });
}
// ---------- self-service agent keys (any logged-in user) ----------
if (path === '/keys' && method === 'GET') {
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
return send(res, 200, { ok: true, keys: db.prepare('SELECT id,name,prefix,created_at,last_used FROM api_keys WHERE owner=? ORDER BY id DESC').all(id.user.handle) });
}
if (path === '/keys' && method === 'POST') {
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
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,owner,created_at) VALUES(?,?,?,?,?)').run((b.name || 'my-agent').slice(0, 40), hashKey(raw), raw.slice(0, 12), id.user.handle, 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(/^\/keys\/(\d+)$/)) && method === 'DELETE') {
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
const k = db.prepare('SELECT * FROM api_keys WHERE id=?').get(Number(m[1]));
if (k && (k.owner === id.user.handle || id.user.role === 'admin')) db.prepare('DELETE FROM api_keys WHERE id=?').run(k.id);
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);
// provision per-user wildcard TLS cert + nginx vhost (idempotent, async)
try { if (existsSync('/home/amerc/showcase-cert.sh')) spawn('/home/amerc/showcase-cert.sh', [handle], { detached: true, stdio: 'ignore' }).unref(); } catch { /* best effort */ }
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: `https://${host}/`, edgeToken: showcaseToken(host), edgeWs: 'wss://amerc.ai/showcase/ws', dns: dns.ok ? 'configured' : (dns.error || 'pending'), tls: 'HTTPS provisions automatically (~1-2 min on your first showcase)' });
}
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}`));