dashboard: matrix-accounts page — provision/login per-agent external matrix accounts

New standalone H0M3 page (/matrix-accounts.html) and tile. An agent
picker drives a list of that agent's configured matrix accounts (name,
homeserver, token-stored status) and a provision form that logs in by
password or stores an existing token.

Frontend half of the per-agent external matrix-account provisioning
work. Built against the v1 backend contract:
  GET  /matrix-accounts?agent=<name>
       -> { accounts: [ { name, homeserver, token_present } ] }
  POST /matrix-account-login  (x-www-form-urlencoded, operator-auth)
       fields: agent, account, homeserver, mode=password|token,
               user_id?, password?, token?
       -> 2xx { ok, user_id } | 4xx { error }

The token is never echoed back; secret inputs are cleared on submit.
Token-status dot reflects token-stored, not live session (a true
up/down indicator needs the daemon account registry, a follow-up). The
form carries an experimental notice pending per-account failure
isolation on the matrix daemon.

Blocked from merge on the backend endpoints and the daemon
failure-isolation fix; opening for review + to pin the UI/backend wire
contract.
This commit is contained in:
iris 2026-06-15 23:17:32 +02:00 committed by mara
commit 9293fe3ac9
6 changed files with 394 additions and 3 deletions

View file

@ -200,6 +200,37 @@ omitted — agents share the host netns, so there is no per-container net
counter (per-agent network needs the netns-isolation roadmap in
`docs/network.md`).
## M4TR1X ACC0UNTS page (`/matrix-accounts.html`)
Operator surface to provision / log in a per-agent **external** matrix
account and store its access token, without editing the agent's config
repo. Standalone page reached from the **Matrix accounts** tile on the
H0M3 hub, same minimal chrome as `/core.html` (a `← home` back-link +
title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads
`/api/state` once for the agent picker and otherwise works off two
purpose-built endpoints.
An agent picker (populated from `state.agents`) drives a list of that
agent's configured accounts — name, homeserver, and a token-status dot —
read from `GET /matrix-accounts?agent=<name>`
`{ accounts: [ { name, homeserver, token_present } ] }`. The status
reflects only whether a token is **stored** (labelled "token stored",
not "online"); a true live up/down indicator needs the matrix daemon's
account registry and is a follow-up.
The provision form (account name, homeserver, login method) posts
`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
fields `agent, account, homeserver, mode=password|token, user_id?,
password?, token?` → `2xx { ok, user_id }` on success or
`4xx { error }` on failure. The host coordinator performs the login
(password) or validates the token (`whoami`) and writes the bearer to
the agent's `matrixAccounts.<account>.tokenFile` via the same
privileged write path as the hive-internal `matrix-token`; the token is
**never** echoed back, and the page clears the secret inputs on submit
regardless of outcome. Because a bad credential can currently disturb
the agent's whole matrix session until per-account failure isolation
lands on the daemon, the form carries an explicit experimental notice.
## P3RM1SS10NS tab
Per-agent permission configuration. Two sections, each rendered as a

View file

@ -55,7 +55,7 @@ mkdirSync(staticDir(''), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js')],
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('matrix-accounts.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
@ -94,7 +94,7 @@ await build({
// so a swap replaces only it) + theme.css (the semantic derivation
// layer) + common.css (shared typography, badges, buttons, inbox, side
// panel) plus its own page-specific bundle.
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css']) {
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'matrix-accounts.css']) {
await build({
entryPoints: [src(entry)],
outfile: staticDir(entry),
@ -104,7 +104,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', '
});
}
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html']) {
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'matrix-accounts.html']) {
copyFileSync(src(html), dist(html));
}

View file

@ -76,6 +76,14 @@
<span class="home-tile-desc">rebuild queue · meta inputs · kept state · container load</span>
</a>
<a class="home-tile" href="/matrix-accounts.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Matrix accounts</span>
</span>
<span class="home-tile-desc">provision · log in · store per-agent matrix tokens</span>
</a>
<!-- Forge tile: hidden until home.js confirms the hive-forge
container is up (state.forge_present); home.js also fills the
href from state.forge_public_url (or the :3000 fallback), so

View file

@ -0,0 +1,87 @@
/* M4TR1X ACC0UNTS page (/matrix-accounts.html) only. Page chrome
(.page-header / .page-back / .page-title) comes from the shared
chrome.css imported by common.css; this file holds the account-list
+ provision-form styling specific to this surface. */
.ma-main {
max-width: 720px;
margin: 0 auto;
padding: 1rem 1.25rem 3rem;
}
.ma-experimental {
border: 1px solid var(--amber);
background: color-mix(in srgb, var(--amber) 10%, transparent);
color: var(--fg);
padding: 0.6rem 0.85rem;
border-radius: 6px;
margin: 0.8rem 0 1.4rem;
font-size: 0.9rem;
line-height: 1.4;
}
.ma-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.55rem 0;
}
.ma-field > span { font-size: 0.8rem; color: var(--muted); }
.ma-field input,
.ma-field select {
padding: 0.4rem 0.5rem;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
}
.ma-field input:focus,
.ma-field select:focus {
outline: none;
border-color: var(--purple);
}
.ma-mode {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.45rem 0.75rem 0.6rem;
margin: 0.85rem 0;
}
.ma-mode legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.3rem; }
.ma-mode label { margin-right: 1.3rem; cursor: pointer; }
.ma-modefields { margin: 0.4rem 0; }
.ma-list { margin: 0.5rem 0 1.2rem; }
.ma-accounts { list-style: none; padding: 0; margin: 0; }
.ma-account {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.2rem;
border-bottom: 1px solid var(--border);
}
.ma-dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
flex: none;
}
.ma-dot.ok { background: var(--green); }
.ma-dot.absent { background: var(--muted); }
.ma-name { font-weight: 600; color: var(--fg); }
.ma-hs { color: var(--muted); font-size: 0.85rem; }
.ma-status { margin-left: auto; font-size: 0.8rem; }
.ma-status.ok { color: var(--green); }
.ma-status.absent { color: var(--muted); }
.ma-result {
margin-top: 0.7rem;
font-size: 0.9rem;
min-height: 1.2em;
}
.ma-result.ok { color: var(--green); }
.ma-result.err { color: var(--red); }
.ma-list .err { color: var(--red); font-size: 0.9rem; }

View file

@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // M4TR1X ACC0UNTS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/matrix-accounts.css">
</head>
<body class="ma-shell">
<!-- Minimal chrome: back link + title, same pattern as settings.html /
core.html. Back link points to the H0M3 hub (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">M4TR1X ACC0UNTS</span>
</header>
<main class="ma-main">
<p class="meta">provision or log in an <strong>external</strong> matrix account for an agent and store its access token. the token is written to the agent's <code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host coordinator &mdash; it is never displayed back on this page.</p>
<div class="ma-experimental" role="note">
<strong>experimental.</strong> a wrong password or token entered here can currently disrupt the target agent's whole matrix session until per-account failure isolation lands on the daemon. use with care on a live agent.
</div>
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<h3>◇ configured accounts</h3>
<p class="meta">status reflects whether a <em>token is stored</em>, not a live session &mdash; a true online/offline indicator is a follow-up that needs the daemon's account registry.</p>
<div id="ma-list" class="ma-list"><p class="meta">select an agent to see its matrix accounts.</p></div>
<h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>account name</span>
<input type="text" name="account" placeholder="e.g. public" required>
</label>
<label class="ma-field">
<span>homeserver</span>
<input type="text" name="homeserver" placeholder="https://matrix.org" required>
</label>
<fieldset class="ma-mode">
<legend>login method</legend>
<label><input type="radio" name="mode" value="password" checked> password</label>
<label><input type="radio" name="mode" value="token"> existing token</label>
</fieldset>
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input type="text" name="user_id" placeholder="@user:matrix.org" autocomplete="username">
</label>
<label class="ma-field">
<span>password</span>
<input type="password" name="password" autocomplete="new-password">
</label>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off">
</label>
<label class="ma-field">
<span>user id <span class="meta">(optional &mdash; derived via whoami)</span></span>
<input type="text" name="user_id" placeholder="@user:matrix.org">
</label>
</div>
<button type="submit" class="btn btn-spawn">log in &amp; store token</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</main>
<script type="module" src="/static/matrix-accounts.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,180 @@
// M4TR1X ACC0UNTS page entry (/matrix-accounts.html).
//
// Operator surface to provision / log in a per-agent EXTERNAL matrix
// account and store its access token, without editing the agent's config
// repo. Companion to the multi-account harness support.
//
// Backend contract (v1):
// GET /matrix-accounts?agent=<name>
// -> { accounts: [ { name, homeserver, token_present: bool } ] }
// POST /matrix-account-login (x-www-form-urlencoded, operator-auth)
// fields: agent, account, homeserver, mode=password|token,
// user_id?, password?, token?
// -> 2xx { ok: true, user_id } on success
// -> 4xx { error: "<msg>" } on failure
// The token is NEVER echoed back in any response, and this page never
// re-renders a submitted secret.
//
// Live up/down (a true green/red dot) needs the daemon's account
// registry; until that follow-up lands the dot only reflects whether a
// token is STORED, labelled "token stored" rather than "online".
//
// Hard dependency: per-account failure isolation on the matrix daemon.
// Until that lands a bad credential entered here can crash the agent's
// whole matrix session, so the form carries an explicit experimental
// notice in the markup.
import { $, el, esc, renderServerWarnings } from './common.js';
let agents = [];
async function loadState() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) return;
const s = await resp.json();
renderServerWarnings(s.server_warnings);
agents = (s.agents || [])
.map((a) => (typeof a === 'string' ? a : a && a.name))
.filter(Boolean)
.sort();
} catch {
// best-effort: on a failed state read the picker renders empty
// ("— no agents —") and the submit guard blocks until an agent is
// selected, rather than guessing a roster.
}
}
function renderAgentPicker() {
const sel = $('ma-agent');
sel.replaceChildren();
if (!agents.length) {
sel.append(el('option', { value: '' }, '— no agents —'));
return;
}
sel.append(el('option', { value: '' }, '— select agent —'));
for (const a of agents) sel.append(el('option', { value: a }, a));
}
async function loadAccounts(agent) {
const list = $('ma-list');
if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.'));
return;
}
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
let data;
try {
const resp = await fetch('/matrix-accounts?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
data = await resp.json();
} catch (err) {
list.replaceChildren(el('p', { class: 'err' },
'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
return;
}
const accounts = data.accounts || [];
list.replaceChildren();
if (!accounts.length) {
list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.'));
return;
}
const ul = el('ul', { class: 'ma-accounts' });
for (const acc of accounts) {
const present = !!acc.token_present;
ul.append(el('li', { class: 'ma-account' },
el('span', {
class: 'ma-dot ' + (present ? 'ok' : 'absent'),
title: present ? 'token stored' : 'no token yet',
}),
el('span', { class: 'ma-name' }, acc.name || '(unnamed)'),
el('span', { class: 'ma-hs' }, acc.homeserver || '—'),
el('span', { class: 'ma-status ' + (present ? 'ok' : 'absent') },
present ? 'token stored ✓' : 'no token'),
));
}
list.append(ul);
}
// Show only the fields for the selected login method, and DISABLE the
// hidden section's inputs so they don't ride along in the FormData (both
// sections carry a `user_id` field, so without this the wrong one — or
// both — would be submitted).
function toggleModeFields() {
const mode = document.querySelector('input[name="mode"]:checked');
const value = mode ? mode.value : 'password';
const pw = $('ma-pw-fields');
const tok = $('ma-token-fields');
pw.hidden = value !== 'password';
tok.hidden = value !== 'token';
pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; });
tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; });
}
function clearSecrets(formEl) {
formEl.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => { i.value = ''; });
}
async function submitLogin(e) {
e.preventDefault();
const formEl = e.target;
const out = $('ma-result');
out.className = 'ma-result';
out.textContent = '';
const agent = $('ma-agent').value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'logging in…';
try {
const resp = await fetch('/matrix-account-login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fd),
});
let body = {};
try { body = await resp.json(); } catch { /* tolerate non-JSON error pages */ }
if (resp.ok && body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.';
clearSecrets(formEl);
loadAccounts(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ ' + (body.error || ('login failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
async function init() {
await loadState();
renderAgentPicker();
$('ma-agent').addEventListener('change', (e) => loadAccounts(e.target.value));
document.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener('change', toggleModeFields));
toggleModeFields();
$('ma-form').addEventListener('submit', submitLogin);
loadAccounts('');
}
init();