- 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>
872 lines
66 KiB
JavaScript
872 lines
66 KiB
JavaScript
// amerc-api — zero-dependency backend: auth + admin + docs + netdisk + whiteboards.
|
||
// Node 22+ built-ins only: node:http, node:sqlite, node:crypto, node:fs.
|
||
// Run: node --experimental-sqlite amerc-api.mjs
|
||
// env: PORT, AMERC_DB, AMERC_SECRET, AMERC_COOKIE_DOMAIN, AMERC_FILES
|
||
import http from 'node:http';
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import { 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(/[ |