self-service agent keys (0.28.0): users mint their own Bearer keys

- amerc-api: owner column migration + /api/keys GET/POST/DELETE (user-scoped)
- My Booth: 'My agent keys' card — mint, list, revoke, with Bearer usage hint
- unblocks any user wiring their own agent to amerc docs/netdisk/platform (was admin-only)
This commit is contained in:
artheru 2026-06-10 05:04:32 +08:00
parent c6127747ed
commit 7d62877808
6 changed files with 60 additions and 3 deletions

View File

@ -7,7 +7,7 @@
name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/>
<meta name="version" content="0.27.0-vivid" />
<meta name="version" content="0.28.0-keys" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "0.27.0-vivid",
"version": "0.28.0-keys",
"private": true,
"type": "module",
"scripts": {

View File

@ -65,6 +65,8 @@ db.exec(`
created_at INTEGER NOT NULL);
`);
try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ }
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) {
@ -447,6 +449,24 @@ 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 });
}
// ---------- 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' });

View File

@ -161,6 +161,37 @@ export function UsePanel({ instance, onClose }) {
}
// ---- My Booth: publish classes + instances, manage ------------------------
function KeysCard() {
const [keys, setKeys] = useState([]);
const [name, setName] = useState('');
const [created, setCreated] = useState(null);
const [err, setErr] = useState('');
const load = useCallback(() => api('/keys').then((d) => setKeys(d.keys || [])).catch((e) => setErr(e.message)), []);
useEffect(() => { load(); }, [load]);
const mint = async (e) => { e.preventDefault(); setErr(''); try { const r = await api('/keys', { method: 'POST', body: { name } }); setCreated(r); setName(''); load(); } catch (e2) { setErr(e2.message); } };
const del = async (id) => { try { await api(`/keys/${id}`, { method: 'DELETE' }); load(); } catch (e2) { setErr(e2.message); } };
return (
<div className="ap-keys">
<h4 className="ap-h4">🔑 My agent keys</h4>
<p className="ap-hint">Mint a key for your own agent to read/write amerc docs, netdisk and the agent platform. Send it as <code>Authorization: Bearer &lt;key&gt;</code>.</p>
<form className="ap-keys-mint" onSubmit={mint}>
<input placeholder="key name (e.g. my-bot)" value={name} onChange={(e) => setName(e.target.value)} required />
<button className="px-action" type="submit">+ Mint key</button>
</form>
{created && <div className="mn-created"><p> minted copy now, shown once:</p><code className="ap-token" onClick={() => navigator.clipboard?.writeText(created.key)}>{created.key} </code></div>}
{err && <p className="px-auth-err">{err}</p>}
{keys.map((k) => (
<div key={k.id} className="ap-mine-inst">
<code className="ap-token">{k.prefix}</code> <em>{k.name}</em>
<span className="ap-hint" style={{ marginLeft: 'auto' }}>{k.last_used ? `used ${new Date(k.last_used).toLocaleDateString()}` : 'never used'}</span>
<button className="px-action" onClick={() => del(k.id)}>revoke</button>
</div>
))}
{!keys.length && <p className="ap-hint">No keys yet mint one above.</p>}
</div>
);
}
export function BoothDashboard({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [insts, setInsts] = useState([]);
@ -233,6 +264,7 @@ export function BoothDashboard({ auth, setRoute }) {
{!insts.length && <p className="ap-hint">No instances. Publish one from a class above.</p>}
</div>
</div>
<KeysCard />
{newInst && (
<div className="ap-modal" onClick={() => setNewInst(null)}>
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}>

View File

@ -6,7 +6,7 @@ import Mansion from './Mansion.jsx';
import TavernGame from './TavernGame.jsx';
import { api } from './api.js';
const RELEASE = '0.27.0-vivid';
const RELEASE = '0.28.0-keys';
const NAV = [
{ id: 'home', label: 'Tavern' },

View File

@ -1087,3 +1087,8 @@ button {
.ap-empty-start li { color: #c9b8e6; font-size: 13px; line-height: 1.6; }
.ap-empty-start code { background: #07060c; color: #7fe9ff; padding: 1px 6px; border-radius: 4px; }
.ap-link { background: none; color: #27d8ff; cursor: pointer; text-decoration: underline; font: inherit; padding: 0; }
/* self-service agent keys (booth) */
.ap-keys { margin-top: 22px; padding: 18px; border-radius: 14px; background: #130d20; border: 1px solid #271c3a; }
.ap-keys-mint { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
.ap-keys-mint input { flex: 1 1 200px; padding: 9px 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; }