From 099fd7616f2379633053c90a68b6a00979b8ec7d Mon Sep 17 00:00:00 2001 From: artheru Date: Wed, 10 Jun 2026 04:50:16 +0800 Subject: [PATCH] Showcase reverse-proxy + amerc Mansion UI (0.26.0) - amerc-api /api/showcase: register subdomain + GoDaddy per-user wildcard DNS automation - server/showcase-edge: ngrok-style HTTP+WS reverse tunnel (ws), broker-token verified - nginx: apex /showcase/ws (broker) + wildcard vhost for *..amerc.ai showcases - Mansion page: services hub, Showcase registration + kebab delivery-center instructions - verified on the public internet (http://demo1.artheru.amerc.ai/ via local-port tunnel) - reference broker sent to kebab for their delivery center + ws/http demo --- index.html | 2 +- package.json | 2 +- server/amerc-api.mjs | 45 +++++++++++++++ server/showcase-edge/index.mjs | 78 ++++++++++++++++++++++++++ server/showcase-edge/package-lock.json | 34 +++++++++++ server/showcase-edge/package.json | 1 + src/Mansion.jsx | 69 +++++++++++++++++++++++ src/Scene2D.jsx | 7 ++- src/styles.css | 18 ++++++ 9 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 server/showcase-edge/index.mjs create mode 100644 server/showcase-edge/package-lock.json create mode 100644 server/showcase-edge/package.json create mode 100644 src/Mansion.jsx diff --git a/index.html b/index.html index afe3fc3..7999d81 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ name="description" content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries." /> - + diff --git a/package.json b/package.json index a47ebfc..d980a02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.25.0-lpc", + "version": "0.26.0-mansion", "private": true, "type": "module", "scripts": { diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs index 8662443..5a73607 100644 --- a/server/amerc-api.mjs +++ b/server/amerc-api.mjs @@ -60,8 +60,29 @@ db.exec(` CREATE TABLE IF NOT EXISTS class_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL, created_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS showcases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT UNIQUE NOT NULL, + created_at INTEGER NOT NULL); `); +const SERVER_IP = process.env.AMERC_SERVER_IP || '104.168.145.233'; +// edge token: signed {host} so the showcase-edge can verify a broker may bind that host +function showcaseToken(host) { + const body = b64url(JSON.stringify({ host, exp: Math.floor(Date.now() / 1000) + 365 * DAY })); + return `${body}.${crypto.createHmac('sha256', SECRET).update(body).digest('base64url')}`; +} +async function godaddyEnsureWildcard(handle) { + const key = process.env.GODADDY_KEY, secret = process.env.GODADDY_SECRET; + if (!key || !secret) return { ok: false, error: 'godaddy not configured' }; + try { + const r = await fetch(`https://api.godaddy.com/v1/domains/amerc.ai/records/A/${encodeURIComponent('*.' + handle)}`, { + method: 'PUT', headers: { Authorization: `sso-key ${key}:${secret}`, 'Content-Type': 'application/json' }, + body: JSON.stringify([{ data: SERVER_IP, ttl: 600 }]), + }); + return { ok: r.ok, status: r.status }; + } catch (e) { return { ok: false, error: String(e && e.message || e) }; } +} + // instance lifecycle thresholds const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down @@ -426,6 +447,30 @@ const server = http.createServer(async (req, res) => { db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true }); } + // ---------- SHOWCASE (reverse-proxy subdomains) — amerc mansion service ---------- + if (path === '/showcase' && method === 'GET') { + if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); + return send(res, 200, { ok: true, showcases: db.prepare('SELECT id,name,host,created_at FROM showcases WHERE owner=? ORDER BY id DESC').all(id.user.handle) }); + } + if (path === '/showcase' && method === 'POST') { + if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); + const b = await readBody(req) || {}; + const handle = id.user.handle.toLowerCase().replace(/[^a-z0-9]/g, ''); + const name = String(b.name || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 30); + if (!name || !handle) return send(res, 400, { ok: false, error: 'name required (letters/numbers)' }); + const host = `${name}.${handle}.amerc.ai`; + if (db.prepare('SELECT id FROM showcases WHERE host=?').get(host)) return send(res, 409, { ok: false, error: 'showcase already registered' }); + const dns = await godaddyEnsureWildcard(handle); + db.prepare('INSERT INTO showcases(owner,name,host,created_at) VALUES(?,?,?,?)').run(id.user.handle, name, host, Date.now()); + return send(res, 200, { ok: true, host, url: `http://${host}/`, edgeToken: showcaseToken(host), edgeWs: 'wss://amerc.ai/showcase/ws', dns: dns.ok ? 'configured' : (dns.error || 'pending') }); + } + if ((m = path.match(/^\/showcase\/(\d+)$/)) && method === 'DELETE') { + if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); + const s = db.prepare('SELECT * FROM showcases WHERE id=?').get(Number(m[1])); + if (s && (s.owner === id.user.handle || id.user.role === 'admin')) db.prepare('DELETE FROM showcases WHERE id=?').run(s.id); + return send(res, 200, { ok: true }); + } + return send(res, 404, { ok: false, error: 'not found' }); } catch (err) { return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) }); diff --git a/server/showcase-edge/index.mjs b/server/showcase-edge/index.mjs new file mode 100644 index 0000000..ce4d60d --- /dev/null +++ b/server/showcase-edge/index.mjs @@ -0,0 +1,78 @@ +// amerc showcase-edge — reverse proxy (ngrok-style). Inbound HTTP/WS from the internet on +// ..amerc.ai is tunneled over a broker WS to the user's local port. +// Node 22 + ws (pure JS). env: PORT, AMERC_SECRET. +import http from 'node:http'; +import crypto from 'node:crypto'; +import { WebSocketServer, WebSocket } from 'ws'; + +const PORT = Number(process.env.PORT || 5191); +const SECRET = process.env.AMERC_SECRET || 'dev-insecure-secret-change-me'; + +const binds = new Map(); // host -> { ws (broker), port } +const pendingHttp = new Map(); // reqId -> res +const browserWs = new Map(); // connId -> client ws + +function verifyEdgeToken(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 hostOf = (req) => String(req.headers.host || '').split(':')[0].toLowerCase(); + +const server = http.createServer((req, res) => { + const host = hostOf(req); + if (host === 'amerc.ai' || req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, service: 'showcase-edge', binds: binds.size })); } + const bind = binds.get(host); + if (!bind || bind.ws.readyState !== WebSocket.OPEN) { + res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(`amerc showcase

🏰 amerc showcase

No service is bound to ${host} yet.

Open the kebab delivery center (交付中心) and map a local port to this showcase.

`); + } + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const reqId = crypto.randomUUID(); + pendingHttp.set(reqId, res); + try { bind.ws.send(JSON.stringify({ type: 'http_req', reqId, method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('base64') })); } + catch { pendingHttp.delete(reqId); try { res.writeHead(502); res.end('broker gone'); } catch {} } + setTimeout(() => { if (pendingHttp.has(reqId)) { pendingHttp.delete(reqId); try { res.writeHead(504); res.end('upstream timeout'); } catch {} } }, 30000); + }); +}); + +const wss = new WebSocketServer({ noServer: true }); +server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url, 'http://x'); const host = hostOf(req); + if (url.pathname === '/showcase/ws' && url.searchParams.get('role') === 'edge') { + wss.handleUpgrade(req, socket, head, (ws) => onBroker(ws, url)); + } else { + const bind = binds.get(host); + if (!bind || bind.ws.readyState !== WebSocket.OPEN) { socket.destroy(); return; } + wss.handleUpgrade(req, socket, head, (ws) => onBrowserWs(ws, req, bind)); + } +}); + +function onBroker(ws, url) { + const tok = verifyEdgeToken(url.searchParams.get('token')); + if (!tok) { try { ws.close(4001, 'bad token'); } catch {} return; } + ws.on('message', (data) => { + let m; try { m = JSON.parse(data); } catch { return; } + if (m.type === 'bind') { if (m.host === tok.host) { binds.set(m.host, { ws, port: m.port }); ws.send(JSON.stringify({ type: 'bound', host: m.host })); } else ws.send(JSON.stringify({ type: 'error', error: 'token not valid for ' + m.host })); } + else if (m.type === 'http_res') { const res = pendingHttp.get(m.reqId); if (res) { pendingHttp.delete(m.reqId); try { const h = m.headers || {}; delete h['content-length']; delete h['transfer-encoding']; res.writeHead(m.status || 200, h); res.end(Buffer.from(m.body || '', 'base64')); } catch {} } } + else if (m.type === 'ws_msg') { const c = browserWs.get(m.connId); if (c && c.readyState === WebSocket.OPEN) c.send(m.binary ? Buffer.from(m.data, 'base64') : m.data); } + else if (m.type === 'ws_close') { const c = browserWs.get(m.connId); if (c) try { c.close(); } catch {} browserWs.delete(m.connId); } + }); + ws.on('close', () => { for (const [h, b] of binds) if (b.ws === ws) binds.delete(h); }); + ws.on('error', () => {}); +} + +function onBrowserWs(ws, req, bind) { + const connId = crypto.randomUUID(); browserWs.set(connId, ws); + try { bind.ws.send(JSON.stringify({ type: 'ws_open', connId, url: req.url, headers: req.headers })); } catch {} + ws.on('message', (data, isBinary) => { try { bind.ws.send(JSON.stringify({ type: 'ws_msg', connId, data: isBinary ? data.toString('base64') : data.toString(), binary: !!isBinary })); } catch {} }); + ws.on('close', () => { try { bind.ws.send(JSON.stringify({ type: 'ws_close', connId })); } catch {} browserWs.delete(connId); }); + ws.on('error', () => {}); +} + +server.listen(PORT, '127.0.0.1', () => console.log(`showcase-edge on 127.0.0.1:${PORT}`)); diff --git a/server/showcase-edge/package-lock.json b/server/showcase-edge/package-lock.json new file mode 100644 index 0000000..b8f54d6 --- /dev/null +++ b/server/showcase-edge/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "amerc-showcase-edge", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "amerc-showcase-edge", + "dependencies": { + "ws": "^8.21.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/showcase-edge/package.json b/server/showcase-edge/package.json new file mode 100644 index 0000000..daefe19 --- /dev/null +++ b/server/showcase-edge/package.json @@ -0,0 +1 @@ +{"name":"amerc-showcase-edge","private":true,"type":"module","dependencies":{"ws":"^8.21.0"}} \ No newline at end of file diff --git a/src/Mansion.jsx b/src/Mansion.jsx new file mode 100644 index 0000000..2e66a85 --- /dev/null +++ b/src/Mansion.jsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { api } from './api.js'; + +// amerc Mansion — a hub of amerc services. First service: Showcase (reverse proxy). +export default function Mansion({ auth, setRoute }) { + if (!auth.ready) return

Loading…

; + if (!auth.user) return ( +

The Mansion

Log in to use amerc services.

+ ); + return ( +
+

The Mansion

{auth.user.handle}'s services
+

amerc hosts your services behind its edge. Each one lives in a room of the mansion.

+
+ +
🗄️
Netdisk relaycoming soon
+
🤖
Hosted webagentcoming soon
+
+
+ ); +} + +function Showcase({ handle }) { + const [list, setList] = useState([]); + const [name, setName] = useState(''); + const [created, setCreated] = useState(null); + const [err, setErr] = useState(''); + const load = useCallback(async () => { try { const d = await api('/showcase'); setList(d.showcases || []); } catch (e) { setErr(e.message); } }, []); + useEffect(() => { load(); }, [load]); + const register = async (e) => { + e.preventDefault(); setErr(''); + try { const r = await api('/showcase', { method: 'POST', body: { name } }); setCreated(r); setName(''); load(); } + catch (e2) { setErr(e2.message); } + }; + const del = async (id) => { if (!window.confirm('Delete this showcase?')) return; try { await api(`/showcase/${id}`, { method: 'DELETE' }); load(); } catch (e2) { setErr(e2.message); } }; + const copy = (t) => navigator.clipboard?.writeText(t); + + return ( +
+
🌐
Showcasereverse proxy
+

Publish a web service from a local port to a public subdomain. Register a name, then in kebab's delivery center (交付中心) map your local port — requests to the public URL tunnel through your broker.

+
+ register + setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} required /> + .{handle}.amerc.ai + +
+ {err &&

{err}

} + {created && ( +
+

{created.host} reserved · DNS {created.dns}

+ + + +

In the kebab delivery center: connect a broker to the WS with this token, send {`{type:"bind",host:"${created.host}",port:}`}, and your local service is live at the public URL.

+
+ )} +

My showcases

+ {list.map((s) => ( +
+ {s.host} + registered {new Date(s.created_at).toLocaleDateString()} + +
+ ))} + {!list.length &&

No showcases yet — register one above.

} +
+ ); +} diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index 68fb20d..073ea1e 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -2,17 +2,19 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Menu, X } from 'lucide-react'; import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx'; import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx'; +import Mansion from './Mansion.jsx'; import TavernGame from './TavernGame.jsx'; import { api } from './api.js'; -const RELEASE = '0.25.0-lpc'; +const RELEASE = '0.26.0-mansion'; const NAV = [ { id: 'home', label: 'Tavern' }, { id: 'agents', label: 'Browse Agents' }, + { id: 'mansion', label: 'Mansion' }, { id: 'booth', label: 'My Booth' }, ]; -const VALID_ROUTES = ['home', 'agents', 'booth', 'admin', 'signin']; +const VALID_ROUTES = ['home', 'agents', 'mansion', 'booth', 'admin', 'signin']; function useHashRoute() { const normalize = () => { @@ -92,6 +94,7 @@ function Page({ children }) { return
{children}
; function Scene({ route, setRoute, auth }) { if (route === 'home') return ; if (route === 'agents') return ; + if (route === 'mansion') return ; if (route === 'booth') return ; if (route === 'signin') return
GATEHOUSE

Open Booth

One amerc account across tavern, docs, PM and git.

diff --git a/src/styles.css b/src/styles.css index f9bcb89..14d4072 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1055,3 +1055,21 @@ button { outline: 2px solid #7fe3ff; outline-offset: 1px; filter: brightness(1.18); } .hero-hot:hover { background: rgba(80,210,255,0.2) !important; box-shadow: 0 0 0 3px rgba(120,225,255,0.85), 0 0 26px rgba(80,210,255,0.6) !important; } + +/* mansion (services hub) */ +.mn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; } +.mn-card { grid-column: 1 / -1; padding: 18px; border-radius: 14px; background: #130d20; border: 1px solid #2a1f3e; } +.mn-card-wide { max-width: 720px; } +.mn-card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } +.mn-card-head strong { font-size: 17px; color: #fff; display: block; } +.mn-soon-ico { font-size: 26px; } +.mn-tag { font-size: 11px; color: #27d8ff; } +.mn-soon { padding: 18px; border-radius: 14px; background: #0e0a18; border: 1px dashed #2a1f3e; opacity: 0.6; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; } +.mn-soon strong { color: #c9b8e6; } .mn-soon span { font-size: 12px; color: #7a6a98; } +.mn-reg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.mn-reg input { padding: 9px 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; width: 130px; } +.mn-prefix, .mn-suffix { color: #8a7aa8; font-size: 13px; } +.mn-created { background: #0e0a18; border: 1px solid #241a36; border-radius: 10px; padding: 12px; margin: 10px 0; } +.mn-created p { margin: 0 0 8px; font-size: 13px; color: #d8c4a0; } .mn-created code { background: #07060c; padding: 1px 5px; border-radius: 4px; color: #7fe9ff; } +.mn-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid #241a36; font-size: 13px; } +.mn-row a { color: #27d8ff; } .mn-row span { color: #8a7aa8; margin-left: auto; }