// CR3D3NTIALS page entry (/credentials.html). // // Operator surface to provision per-agent credentials without editing the // agent's config repo. Two sub-tabs, sharing one agent picker: // MATRIX — external matrix account login (carried over verbatim from the // old /matrix-accounts.html — see matrix_accounts.rs backend doc // comments for the account/status contract + endpoint shapes). // GITHUB — single-account PAT paste against /api/github-account // (GET -> {present}, POST form-encoded {agent, token} -> // {ok:true}; same error_response shape as matrix-account-login). // No account name / homeserver / login mode, and no // live/heartbeat concept for a static PAT — just present/absent. // FORGES — external forge accounts, entirely dashboard-provisioned (no // host-side config): GET /api/extra-forges?agent= lists the // agent's stored {label, base_url} pairs, POST // /api/extra-forge-account (form agent/label/base_url/token/ // action=add|remove) stores or removes one. No remote account // creation — the operator makes the token on the external forge // themselves and pastes it in, same trust model as GITHUB. // Per-tab detail comments live next to their section below. import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js'; import { createTabStrip } from '@hive/shared/tabs.js'; import { themedConfirm, themedToast } from './modal.js'; let agents = []; // agent name → container running (bool), from /api/state. Cross-referenced by // the live dot: a `live: true` account whose container is DOWN is definitively // stale (the daemon can't be up if the container isn't), so we flag it rather // than show a lying green. `undefined` (agent not in the map) = unknown → we // don't flag stale. const containerRunning = new Map(); async function loadState() { try { const resp = await fetch('/api/state'); if (!resp.ok) return; const s = await resp.json(); renderServerWarnings(s.server_warnings); // `/api/state` exposes the live roster under `containers` (each entry an // object carrying `.name` + `.running`); there is no top-level `agents` // field, so the picker stays compatible with both string + object shapes. const containers = (s.containers || []) .map((a) => (typeof a === 'string' ? { name: a } : a)) .filter((c) => c && c.name); agents = containers.map((c) => c.name).sort(); containerRunning.clear(); for (const c of containers) containerRunning.set(c.name, !!c.running); } 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)); } // ─── shape-agnostic error-body parsing (shared by both tabs' submit // handlers) ─────────────────────────────────────────────────────────────── // The BE error-body shape is in transition: today hive-c0re's // error_response sends a bare plain-text body; the RFC 9457 rework moves it // to application/problem+json ({ type, title, detail, … }). Read shape- // agnostically: pull the body once as text, and if it parses as JSON // surface `detail` (problem+json) → `error`/`title` fallback, else use the // raw text. A bare HTTP code is the last resort. async function readErrorBody(resp) { try { const raw = (await resp.text()).trim(); if (raw && (raw[0] === '{' || raw[0] === '[')) { try { const body = JSON.parse(raw); return body.detail || body.error || body.title || raw; } catch { /* not JSON after all — keep the raw text */ } } return raw; } catch { return ''; } } // ─── MATRIX tab ──────────────────────────────────────────────────────────── // Live status dot — the daemon heartbeats every ~30s (advances as_of_unix), // so a stalled as_of = daemon dead, not just stale snapshot: // green live + running + fresh = online // dim green live but as_of stale > ~90s = heartbeat stopped // amber live + container DOWN = definitively stale // amber token_present + !live = provisioned but offline // grey no token = not provisioned // Container state takes precedence; as_of_unix is tooltipped for freshness. // v1 backend (no `live` field) falls back to token-present rendering. 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('/api/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 || []; const asOf = typeof data.as_of_unix === 'number' ? data.as_of_unix : null; // `false` only when the container is explicitly down; `undefined` (unknown, // e.g. a failed /api/state read) is treated as not-down so we never flag a // false stale. const running = containerRunning.get(agent); // The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances // while it's alive — this is a heartbeat, and a stalled value is meaningful. const ageSecs = asOf != null ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) : null; const asOfText = asOf != null ? 'matrix snapshot · live as of ' + fmtAgeSecs(ageSecs) + ' ago' : 'no daemon snapshot yet'; // 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is // NOT down means the daemon stopped publishing (dead/wedged) — dim its dot. const STALE_AGE_SECS = 90; const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS; 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; // 3-state dot. `live` is absent on the v1 backend (pre BE-4); when // undefined, fall back to the v1 token-present rendering so the page // degrades cleanly before the snapshot backend deploys. let cls; let statusText; let dotTitle; if (acc.live === undefined) { cls = present ? 'ok' : 'absent'; statusText = present ? 'token stored ✓' : 'no token'; dotTitle = present ? 'token stored' : 'no token yet'; } else if (acc.live && running === false) { // container down ⟹ daemon down ⟹ a "live" snapshot is stale. cls = 'stale'; statusText = 'container stopped'; dotTitle = 'container is stopped — live status is stale. ' + asOfText; } else if (acc.live && staleByAge) { // Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't // advanced in > ~90s while the container is NOT down — the daemon stopped // publishing, so the "live" is no longer trustworthy. Keep the green // family but dim it (distinct from the amber container-down 'stale'). cls = 'live stale-age'; statusText = 'online · no heartbeat'; dotTitle = 'snapshot says live but the daemon heartbeat stalled ' + fmtAgeSecs(ageSecs) + ' ago (publishes every ~30s) — likely dead or wedged. ' + asOfText; } else if (acc.live) { cls = 'live'; statusText = 'online ✓'; dotTitle = asOfText; } else if (present) { cls = 'offline'; statusText = 'token stored · offline'; dotTitle = 'provisioned but not live. ' + asOfText; } else { cls = 'absent'; statusText = 'no token'; dotTitle = 'no token yet'; } ul.append(el('li', { class: 'ma-account' }, el('span', { class: 'ma-dot ' + cls, title: dotTitle }), el('span', { class: 'ma-name' }, acc.name || '(unnamed)'), acc.user_id ? el('span', { class: 'ma-uid' }, acc.user_id) : null, el('span', { class: 'ma-hs' }, acc.homeserver || '—'), el('span', { class: 'ma-status ' + cls, title: asOfText }, statusText), )); } 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('/api/matrix-account-login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fd), }); if (resp.ok) { // Success is 200 + JSON { ok, user_id }. let body = {}; try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ } if (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 = '✗ login failed (unexpected response).'; clearSecrets(formEl); } } else { const msg = await readErrorBody(resp); out.className = 'ma-result err'; out.textContent = '✗ ' + (msg || ('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; } } // ─── GITHUB tab ───────────────────────────────────────────────────────── async function loadGithubStatus(agent) { const status = $('gh-status'); if (!agent) { status.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its github credential status.')); return; } status.replaceChildren(el('p', { class: 'meta' }, 'loading…')); let data; try { const resp = await fetch('/api/github-account?agent=' + encodeURIComponent(agent)); if (!resp.ok) throw new Error('HTTP ' + resp.status); data = await resp.json(); } catch (err) { status.replaceChildren(el('p', { class: 'err' }, 'could not load status: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).')); return; } const present = !!data.present; status.replaceChildren(el('div', { class: 'gh-status-line' }, el('span', { class: 'gh-dot ' + (present ? 'present' : 'absent') }), el('span', { class: 'gh-status-text ' + (present ? 'present' : 'absent') }, present ? 'token stored ✓' : 'not set'), )); } async function submitGithub(e) { e.preventDefault(); const formEl = e.target; const out = $('gh-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 = 'storing…'; try { const resp = await fetch('/api/github-account', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fd), }); if (resp.ok) { let body = {}; try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ } if (body.ok) { out.className = 'ma-result ok'; out.textContent = '✓ token stored.'; clearSecrets(formEl); loadGithubStatus(agent); } else { out.className = 'ma-result err'; out.textContent = '✗ store failed (unexpected response).'; clearSecrets(formEl); } } else { const msg = await readErrorBody(resp); out.className = 'ma-result err'; out.textContent = '✗ ' + (msg || ('store 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; } } // ─── FORGES tab ───────────────────────────────────────────────────────── // Entirely dashboard-provisioned, no host-side nix config: per-agent list // (GET /api/extra-forges?agent=, derived from the agent's own // forge-