diff --git a/package.json b/package.json index 448abfe..8eff7f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "1.2.0-legioncards", + "version": "1.3.0-mansionfiles", "private": true, "type": "module", "scripts": { diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs index c8efa9a..8ae3ad8 100644 --- a/server/amerc-api.mjs +++ b/server/amerc-api.mjs @@ -72,6 +72,12 @@ db.exec(` 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 */ } @@ -135,6 +141,55 @@ function claimFile(token, consumerRes) { 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 (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 @@ -362,10 +417,15 @@ const server = http.createServer(async (req, res) => { 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) + // 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) }); @@ -624,6 +684,62 @@ const server = http.createServer(async (req, res) => { 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' }); diff --git a/src/Mansion.jsx b/src/Mansion.jsx index 7fc8d38..e5d6e48 100644 --- a/src/Mansion.jsx +++ b/src/Mansion.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { api } from './api.js'; import { copyToast } from './toast.js'; +import Netdisk from './Netdisk.jsx'; // amerc Mansion — a hall of amerc services. First live service: Showcase (reverse proxy). export default function Mansion({ auth, setRoute }) { @@ -22,11 +23,8 @@ export default function Mansion({ auth, setRoute }) {
- + + { + if (!loggedIn) return; + api('/fm').then((d) => setMaps(d.maps || [])).catch((e) => { setErr(e.message); setMaps([]); }); + }, [loggedIn]); + return ( + + {!loggedIn ? ( +
+ ) : ( + <> + {err &&

{err}

} +
+

My mapped files

+ {maps === null ?

loading…

: maps.length ? maps.map((f) => ( +
+ + {f.name} + {f.class_name || `instance #${f.instance_id}`} + +
+ )) :

No mapped files yet. Your broker registers one with POST /api/agents/instances/<id>/fm {'{accesskey, name, fileName, size, mime}'} — it becomes a stable link instantly.

} +
+ + )} +
+ ); +} + +// Net-disk: real storage on amerc, 100 MB per account. +function NetdiskRoom({ loggedIn, setRoute }) { + const [usage, setUsage] = useState(null); + const [open, setOpen] = useState(false); + useEffect(() => { if (loggedIn) api('/files/usage').then(setUsage).catch(() => {}); }, [loggedIn, open]); + const pct = usage ? Math.min(100, Math.round((usage.used / usage.quota) * 100)) : 0; + return ( + + {!loggedIn ? ( +
+ ) : ( + <> + {usage && ( +
+ 90 ? '#ff7a8c' : undefined }} /> + {(usage.used / 1048576).toFixed(1)} / 100 MB +
+ )} + + {open &&
} + + )} +
+ ); +} + function ShowcaseRoom({ loggedIn, handle, setRoute }) { const [list, setList] = useState([]); const [name, setName] = useState(''); diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index 17d5017..c02a98d 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -10,7 +10,7 @@ import { api } from './api.js'; const Menu = ({ size = 20 }) => (); const X = ({ size = 20 }) => (); -const RELEASE = '1.2.0-legioncards'; +const RELEASE = '1.3.0-mansionfiles'; const NAV = [ { id: 'home', label: 'Tavern' }, @@ -477,6 +477,7 @@ function StatusPage() { } const CHANGELOG = [ + { v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] }, { v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] }, { v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] }, { v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion — your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] }, diff --git a/src/styles.css b/src/styles.css index c381954..695454a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1640,3 +1640,11 @@ button { .ap-mine-cardwrap { display: flex; flex-direction: column; gap: 7px; } .ap-mine-cardwrap .ap-card { width: 100%; } .ap-mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + +/* mansion file rooms (1.3): file-mapper + net-disk quota */ +.mn-quota { display: flex; align-items: center; gap: 10px; margin: 8px 0 10px; } +.mn-quota-bar { flex: 1; height: 8px; background: #1a2438; border-radius: 99px; overflow: hidden; } +.mn-quota-bar i { display: block; height: 100%; background: linear-gradient(90deg, #46c8e0, #7fd9ea); border-radius: 99px; transition: width 0.3s; } +.mn-quota-label { color: #8fa3b8; font-size: 11.5px; flex: 0 0 auto; } +.mn-fm-meta { color: #6e7e94; font-size: 11px; } +.mn-nd { margin-top: 10px; background: #0b0f1a; border: 1px solid #20283c; border-radius: 10px; padding: 8px; }