diff --git a/Cargo.lock b/Cargo.lock
index 041b08a9..edbb6a33 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4591,6 +4591,7 @@ dependencies = [
"hive-jobq",
"hive-jobq-wire",
"hive-types",
+ "problem_details",
"reqwest 0.13.1",
"serde",
"serde_json",
diff --git a/docs/conventions.md b/docs/conventions.md
index bbc97f2f..c19fedfe 100644
--- a/docs/conventions.md
+++ b/docs/conventions.md
@@ -318,6 +318,28 @@ refactor's concern. The dashboard frontend parses via
`util.js::epochSec` wherever it needs arithmetic and feeds the string
straight to `new Date(s)` for display.
+### HTTP error bodies
+
+Every HTTP API in this repo answers failures with **RFC 9457
+`application/problem+json`** (`{ type, title, status, detail }`), with the
+human-readable cause in `detail`. An endpoint of ours returning a bare string
+or a bespoke error shape is a **bug to file against the backend**, not
+something for the caller to work around.
+
+Use the `problem_details` crate (`features = ["axum"]`), which the daemons
+already depend on: type a handler `Result<_, ProblemDetails>` and hand
+`ProblemDetails::from_status_code(...).with_detail(...)` to `Err`.
+
+The reason is the consumer, not tidiness. The UIs show errors through one
+shared component with a copy button, so a caller has to know **which part of
+the body is the message**. A bare string forces it to treat the whole payload
+as prose, which is the difference between offering "copy the cause" and
+dumping a response — and the cause is frequently the entire diagnosis (a
+JetStream permission refusal, a TLS chain failure) rather than a summary.
+
+Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
+JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
+
## Tool groups
The MCP tool surface an agent receives is derived from a set of named
diff --git a/docs/setup.md b/docs/setup.md
index 9af3819f..d5ec447c 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -21,16 +21,19 @@ operator has to place, and where.
### 1 · Forge
```bash
-# Provision (or refresh) ruth's own forge account — do this first
+# Provision (or refresh) ruth's own forge account — do this first. Ruth's
+# bootstrap bypasses the normal spawn-approval flow (see step 6), so unlike
+# every other agent it does not get its forge account auto-provisioned —
+# this manual step is still load-bearing.
hivectl forge create-user ruth
-# Create a human operator account (prints the token to stdout)
-hivectl forge create-user mara --password hunter2
-
-# Provision forge accounts for any sub-agents spawned later
-hivectl forge create-user
+# Sub-agents spawned later (via the approval flow in step 6) get their
+# forge accounts auto-provisioned — nothing to run here for them.
```
+The human operator's own forge account is created via swarm SSO instead of
+a manual `hivectl` step — see step 3 (`swarmctl user add`).
+
### 2 · Gateway (HTTP Basic auth)
```bash
@@ -89,20 +92,21 @@ control: [`swarm/ui.md`](swarm/ui.md).
# 5a. Ensure the hive-internal admin account exists first
hivectl matrix sync-admin
-# 5b. Provision ruth's own matrix account
+# 5b. Provision ruth's own matrix account — same bootstrap-bypass reasoning
+# as forge above, still a required manual step.
hivectl matrix create-user ruth
-# 5c. Create a human matrix account
-hivectl matrix create-user mara --password hunter2
-
-# 5d. Invite the operator to the hive Space (and optionally to rooms)
+# 5c. Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
-# 5e. Promote the operator to homeserver admin if needed
+# 5d. Promote the operator to homeserver admin if needed
hivectl matrix promote-user mara
```
+The human operator's own matrix account is created via swarm SSO instead of
+a manual `hivectl` step — see step 3 (`swarmctl user add`).
+
### 6 · Spawn sub-agents
Sub-agent creation goes through the approval queue — ruth proposes, the
diff --git a/frontend/packages/dashboard/src/credentials.js b/frontend/packages/dashboard/src/credentials.js
index 5d2420c3..4ad106ec 100644
--- a/frontend/packages/dashboard/src/credentials.js
+++ b/frontend/packages/dashboard/src/credentials.js
@@ -23,6 +23,7 @@ import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
+import { readApiError, problemMessage } from '@hive/shared/api-error.js';
let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by
@@ -65,28 +66,13 @@ function renderAgentPicker() {
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 '';
- }
-}
+// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
+// lives in `@hive/shared/api-error.js` now — `readApiError` +
+// `problemMessage` (this page only needs the one-line message, not the
+// full `ApiErrorPanel`; its result lines are single-line `aria-live`
+// regions, not a swap-in-a-panel context). Was a local function here
+// originally; promoted so swarm-ui shares the same
+// shape-agnostic reader instead of each side maintaining its own copy.
// ─── MATRIX tab ────────────────────────────────────────────────────────────
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
@@ -253,7 +239,7 @@ async function submitLogin(e) {
clearSecrets(formEl);
}
} else {
- const msg = await readErrorBody(resp);
+ const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@@ -337,7 +323,7 @@ async function submitGithub(e) {
clearSecrets(formEl);
}
} else {
- const msg = await readErrorBody(resp);
+ const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@@ -419,7 +405,7 @@ async function onForgeRemoveClick(agent, forge, btn) {
loadForgeAccounts(agent);
return;
}
- const msg = await readErrorBody(resp);
+ const msg = problemMessage(await readApiError(resp));
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
@@ -474,7 +460,7 @@ async function submitForgeAccount(e) {
clearSecrets(formEl);
}
} else {
- const msg = await readErrorBody(resp);
+ const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json
index ba195859..02bde7a9 100644
--- a/frontend/packages/shared/package.json
+++ b/frontend/packages/shared/package.json
@@ -31,7 +31,12 @@
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css",
"./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx",
- "./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css"
+ "./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css",
+ "./api-error.js": "./src/api-error.ts",
+ "./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx",
+ "./api-error-panel.css": "./src/api-error-panel/api-error-panel.css",
+ "./warn-banner.js": "./src/warn-banner/WarnBanner.tsx",
+ "./warn-banner.css": "./src/warn-banner/WarnBanner.css"
},
"files": [
"src/"
diff --git a/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx b/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx
new file mode 100644
index 00000000..f0ebb3ce
--- /dev/null
+++ b/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx
@@ -0,0 +1,75 @@
+// ApiErrorPanel.tsx — , the shared "show the operator why
+// an API call failed" component (mara, on the issue that asked for this:
+// "the error display component is for showing ProblemDetails in a nicer
+// way with copy button [...] wherever we want to show an error, this
+// component should be used"). Renders a `ProblemDetails` (./api-error.ts)
+// — heading + the full `detail` text, unclamped (it can be a raw NATS/
+// JetStream error, and on the incident that prompted this component
+// *that string was the entire diagnosis* — truncating it defeats the
+// point) — plus a copy button for pasting straight into a bug report.
+//
+// Composed from `WarnBanner` (../warn-banner/WarnBanner.js) rather than
+// owning the border/colour/pulse styling itself — mara, on review: "i
+// want each component to import its own css file itself[;] if you need
+// a bunch of extra css externally, its not a proper component". This
+// file's own `.css` only lays out what's specific to it (heading, copy
+// button, detail text); the banner "shape" belongs to `WarnBanner` and
+// stays there so any future error/warning surface reuses it too, rather
+// than every caller growing its own copy of the same rules.
+//
+// Same shared-component shape as `JobqRollup`/`JobqGraph`:
+// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
+// `` from swarm-ui's JSX.
+import { useState } from 'preact/hooks';
+import { WarnBanner } from '../warn-banner/WarnBanner.js';
+import type { ProblemDetails } from '../api-error.js';
+import './api-error-panel.css';
+
+export interface ApiErrorPanelProps {
+ problem: ProblemDetails;
+ // Optional short prefix naming what failed ("failed to load the hive
+ // roster") — the panel is otherwise just the server's own words, which
+ // don't always say what the caller was trying to do.
+ context?: string;
+}
+
+function formatForCopy(p: ProblemDetails): string {
+ const lines: string[] = [];
+ if (p.status !== undefined) lines.push(`status: ${p.status}`);
+ if (p.title) lines.push(`title: ${p.title}`);
+ if (p.type) lines.push(`type: ${p.type}`);
+ if (p.detail) lines.push(`detail: ${p.detail}`);
+ return lines.length ? lines.join('\n') : 'request failed';
+}
+
+export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
+ const [copied, setCopied] = useState(false);
+ const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(formatForCopy(problem));
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // clipboard permission denied / unavailable (e.g. non-secure
+ // context) — the text is still fully visible to select by hand,
+ // so this is a silent no-op rather than a second error to show.
+ }
+ }
+
+ return (
+
+
: null}
+
+ );
+}
diff --git a/frontend/packages/shared/src/api-error-panel/api-error-panel.css b/frontend/packages/shared/src/api-error-panel/api-error-panel.css
new file mode 100644
index 00000000..df4c853c
--- /dev/null
+++ b/frontend/packages/shared/src/api-error-panel/api-error-panel.css
@@ -0,0 +1,40 @@
+/* api-error-panel.css — layout for ``'s content, plus the
+ one spacing concern that's genuinely this component's own (the top
+ margin — `WarnBanner` only sets `margin-bottom`, tuned for a banner
+ that's the first thing in a page, not a panel that usually follows a
+ form or a table). Colour/border/pulse belong to `WarnBanner`
+ (../warn-banner/WarnBanner.css) — not duplicated here. */
+
+.api-error-panel {
+ margin-top: 1em;
+}
+.api-error-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.8em;
+}
+.api-error-title {
+ font-weight: 600;
+}
+.api-error-copy {
+ flex: none;
+ background: transparent;
+ color: inherit;
+ border: 1px solid currentColor;
+ border-radius: 0.3em;
+ padding: 0.1em 0.6em;
+ font: inherit;
+ font-size: 0.85em;
+ cursor: pointer;
+}
+.api-error-copy:hover {
+ background: color-mix(in srgb, currentColor 12%, transparent);
+}
+.api-error-detail {
+ margin: 0.5em 0 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 0.9em;
+ opacity: 0.9;
+}
diff --git a/frontend/packages/shared/src/api-error.ts b/frontend/packages/shared/src/api-error.ts
new file mode 100644
index 00000000..ff69de49
--- /dev/null
+++ b/frontend/packages/shared/src/api-error.ts
@@ -0,0 +1,63 @@
+// api-error.ts — reads a failed fetch `Response` into a `ProblemDetails`
+// (RFC 9457, `application/problem+json`) object, shape-agnostically.
+//
+// Promoted from `dashboard/src/credentials.js`'s original `readErrorBody`,
+// which returned a flat message string. This returns the structured object
+// instead so a caller — chiefly `ApiErrorPanel` (./api-error-panel/) — can
+// render title/status/detail separately and build a useful copy-button
+// payload, rather than re-parsing a pre-squashed string.
+//
+// RFC 9457 is this hive's committed error-body contract for first-party
+// APIs (mara: "any api of our own responding with error that is not rfc
+// shaped is a bug to be filed against backend") — every first-party route
+// emits it as of this writing, so the non-JSON fallback below is NOT
+// compatibility with an in-flight backend transition (that transition is
+// finished). It's for bodies we did not author: a caller can be pointed
+// at something that isn't ours, and an nginx-synthesised 502/504 never
+// reaches our handlers at all, so it's plain HTML, not problem+json. A
+// first-party route landing here anyway is a bug to file, not something
+// this reader should quietly paper over.
+
+export interface ProblemDetails {
+ type?: string;
+ title?: string;
+ status?: number;
+ detail?: string;
+}
+
+export async function readApiError(resp: Response): Promise {
+ const status = resp.status;
+ let raw: string;
+ try {
+ raw = (await resp.text()).trim();
+ } catch {
+ return { status };
+ }
+ if (raw && (raw[0] === '{' || raw[0] === '[')) {
+ try {
+ const body = JSON.parse(raw) as Record;
+ return {
+ type: typeof body.type === 'string' ? body.type : undefined,
+ title: typeof body.title === 'string' ? body.title : undefined,
+ status: typeof body.status === 'number' ? body.status : status,
+ detail:
+ typeof body.detail === 'string'
+ ? body.detail
+ : typeof body.error === 'string'
+ ? body.error
+ : undefined,
+ };
+ } catch {
+ // not JSON after all — fall through to the raw-text path below
+ }
+ }
+ return { status, detail: raw || undefined };
+}
+
+// One-line message for a context that only has room for a single string
+// (a form's inline result line, a toast) rather than the full panel —
+// same fallback order `readErrorBody` used: detail (or its `error` alias,
+// already folded in above) → title → a bare status line.
+export function problemMessage(p: ProblemDetails): string {
+ return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
+}
diff --git a/frontend/packages/shared/src/warn-banner/WarnBanner.css b/frontend/packages/shared/src/warn-banner/WarnBanner.css
new file mode 100644
index 00000000..1f16ea22
--- /dev/null
+++ b/frontend/packages/shared/src/warn-banner/WarnBanner.css
@@ -0,0 +1,36 @@
+/* WarnBanner.css — three levels, not an open-ended severity+modifier
+ combination (same rule `hive-warn.css` states, copied faithfully since
+ this is that component's visual language, just reached through a
+ class instead of a `:host([level])` attribute selector). Colours reuse
+ theme.css's own documented semantics: --cyan = "info accents", --amber
+ = "warnings", --red = "errors, fail state". */
+
+.warn-banner {
+ display: block;
+ border: 1px solid var(--amber);
+ border-radius: 4px;
+ padding: 0.5em 0.8em;
+ margin-bottom: 0.6em;
+ color: var(--amber);
+ background: color-mix(in srgb, var(--amber) 8%, transparent);
+}
+.warn-banner-info {
+ border-color: var(--cyan);
+ color: var(--cyan);
+ background: color-mix(in srgb, var(--cyan) 8%, transparent);
+}
+.warn-banner-error {
+ border-color: var(--red);
+ color: var(--red);
+ background: color-mix(in srgb, var(--red) 8%, transparent);
+ /* Only `error` pulses — an active incident, not a standing caveat. */
+ text-shadow: 0 0 6px color-mix(in srgb, currentColor 40%, transparent);
+ animation: warn-banner-pulse 2.4s ease-in-out infinite;
+}
+@keyframes warn-banner-pulse {
+ 0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
+ 50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
+}
+.warn-banner strong {
+ color: inherit;
+}
diff --git a/frontend/packages/shared/src/warn-banner/WarnBanner.tsx b/frontend/packages/shared/src/warn-banner/WarnBanner.tsx
new file mode 100644
index 00000000..f036d465
--- /dev/null
+++ b/frontend/packages/shared/src/warn-banner/WarnBanner.tsx
@@ -0,0 +1,38 @@
+// WarnBanner.tsx — , the Preact-component successor to the
+// shadow-DOM custom element (../hive-warn/hive-warn.js).
+// Same three-tier visual language (mara, on `hive-warn`'s own review:
+// "there should be distinction between info, warning, error — all
+// warnings should be styled identically") — built as a plain Preact
+// component instead so it composes cleanly wherever this package's other
+// shared components do (JobqRollup, JobqGraph, ApiErrorPanel), rather
+// than needing a shadow root + CSS-as-text build step that only resolves
+// under one of the two consuming packages' esbuild configs. `ApiErrorPanel`
+// is the first consumer — compose from this instead of duplicating the
+// banner's own border/colour/pulse rules locally.
+//
+// `level` defaults to 'warning', matching `hive-warn.js`'s own default.
+// Only `error` pulses — an active incident, not a standing caveat, same
+// rule `hive-warn.css`'s own comment states.
+import type { ComponentChildren } from 'preact';
+import './WarnBanner.css';
+
+export type WarnLevel = 'info' | 'warning' | 'error';
+
+export interface WarnBannerProps {
+ level?: WarnLevel;
+ // Passed straight through to the root element's `class` list —
+ // for a caller-specific concern the banner itself has no opinion on
+ // (e.g. `ApiErrorPanel` adding a top margin for the context it usually
+ // sits in), not for re-styling the banner's own look.
+ class?: string;
+ children?: ComponentChildren;
+}
+
+export function WarnBanner({ level = 'warning', class: extraClass, children }: WarnBannerProps) {
+ const classes = ['warn-banner', `warn-banner-${level}`, extraClass].filter(Boolean).join(' ');
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css
index eeabde39..61f8f368 100644
--- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css
+++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css
@@ -49,6 +49,3 @@
.create-agent-result-ok {
color: var(--green);
}
-.create-agent-result-error {
- color: var(--red);
-}
diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx
index c06ae2eb..ed500fe8 100644
--- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx
@@ -23,6 +23,8 @@
// comment states.
import { useState } from 'preact/hooks';
import { Link } from 'wouter-preact';
+import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
+import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import './CreateAgentPage.css';
@@ -34,13 +36,22 @@ type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'done'; nodeId: number }
- | { status: 'error'; message: string };
+ | { status: 'error'; problem: ProblemDetails };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
// validation; a name this rejects would fail server-side anyway, so
// catching it before a round-trip is a pure UX win, not a new gate.
-const NAME_PATTERN = '[a-z0-9-]{1,63}';
+//
+// Hyphen escaped (`\-`) rather than left trailing in the class: modern
+// browsers validate the `pattern` attribute's regex in Unicode-set (`v`)
+// mode, which is far stricter about hyphen placement than classic mode —
+// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though
+// it's valid classic-mode regex (a trailing `-` is unambiguous there).
+// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
+// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console
+// exception report on this page.
+const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
export function CreateAgentPage() {
const [name, setName] = useState('');
@@ -55,12 +66,15 @@ export function CreateAgentPage() {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
});
- if (!r.ok) throw new Error((await r.text()) || `http ${r.status}`);
+ if (!r.ok) {
+ setResult({ status: 'error', problem: await readApiError(r) });
+ return;
+ }
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
} catch (err) {
- setResult({ status: 'error', message: String(err) });
+ setResult({ status: 'error', problem: { detail: String(err) } });
}
}
@@ -96,9 +110,7 @@ export function CreateAgentPage() {
)}
{result.status === 'error' && (
-
- failed to queue: {result.message}
-
+
)}
);
diff --git a/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx b/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
index b78f8f12..a7451303 100644
--- a/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
@@ -8,6 +8,8 @@
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
// routing, a page owns its own fetch + render.
import { useEffect, useState } from 'preact/hooks';
+import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
+import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
@@ -60,21 +62,22 @@ const COLUMNS: TableColumn[] = [
export function OverviewPage() {
const [hives, setHives] = useState(null);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(null);
useEffect(() => {
- fetch('/api/hives/status')
- .then((r) => {
- if (!r.ok) throw new Error(`http ${r.status}`);
- return r.json() as Promise;
- })
- .then(setHives)
- .catch((e: unknown) => setError(String(e)));
+ (async () => {
+ const r = await fetch('/api/hives/status');
+ if (!r.ok) {
+ setError(await readApiError(r));
+ return;
+ }
+ setHives((await r.json()) as HiveStatus[]);
+ })().catch((e: unknown) => setError({ detail: String(e) }));
}, []);
return (
- {error ?
h.name} /> : null}
diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix
index 878f4b1f..79a99907 100644
--- a/nix/host-modules/swarm-grafana.nix
+++ b/nix/host-modules/swarm-grafana.nix
@@ -174,6 +174,30 @@ in
'';
};
};
+
+ plugins = lib.mkOption {
+ type = lib.types.listOf lib.types.package;
+ default = [ ];
+ example = lib.literalExpression "[ pkgs.grafanaPlugins.grafana-piechart-panel ]";
+ description = ''
+ Grafana plugins to install, as packages. Declarative rather than
+ installed through the UI, which is the only shape that works here:
+ plugin management is **server-admin** scoped, and on an SSO hive
+ nobody holds that role — `users.auto_assign_org_role` grants an
+ *org* role, and the built-in local admin that does hold server
+ admin cannot log in because the login form is disabled whenever
+ SSO is configured.
+
+ That is a deliberate pair of decisions rather than an oversight,
+ and this option is the way through it: plugins live in the store
+ and in git, so they survive a container rebuild and a state reset,
+ and the container needs no runtime egress to grafana.com.
+
+ Empty by default, which leaves grafana's own plugin handling
+ untouched. Setting it takes over the plugin directory entirely —
+ anything installed by other means stops being visible.
+ '';
+ };
};
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
@@ -437,6 +461,25 @@ in
enable = true;
package = cfg.package;
+ # Passed through unconditionally, empty default included.
+ # Upstream distinguishes `null` from `[ ]`, and both differences
+ # favour always handing it a list:
+ #
+ # - `null` points the plugin path at grafana's mutable
+ # `/plugins`; any list points it at a store path.
+ # Switching on the day someone adds their first plugin would
+ # bury that change inside an unrelated one.
+ # - upstream defaults its plugin update-check to
+ # `declarativePlugins == null`, so a list also stops the
+ # container phoning grafana.com. That is the no-runtime-egress
+ # property this option exists for — it should not arrive only
+ # once a plugin happens to be listed.
+ #
+ # Nothing is taken over by claiming the directory on a hive with no
+ # plugins: manual installation is already impossible here (see the
+ # option's description), so there is nothing in it to lose.
+ declarativePlugins = cfg.plugins;
+
settings = {
server = {
# Already upstream's default (measured), but pinned rather
diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml
index aad95ae4..6ac33c60 100644
--- a/swarm-controller/Cargo.toml
+++ b/swarm-controller/Cargo.toml
@@ -27,6 +27,10 @@ forgejo-api.workspace = true
# raw bytes.
base64.workspace = true
futures-util.workspace = true
+# RFC 9457 `application/problem+json` error bodies. Same version + `axum`
+# feature as hive-c0re: the two daemons answer the same operator UIs, so a
+# reader that handles one's failures has to handle the other's.
+problem_details = { version = "0.9.0", features = ["axum"] }
# The graph itself, held directly rather than behind a c0re-style wrapper
# module — that layering (`hive-c0re::job_queue`) is partially legacy (predates
# `hive-jobq`'s extraction into its own crate) and this daemon does not need it
diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs
index 26aa08fd..f0a9841d 100644
--- a/swarm-controller/src/main.rs
+++ b/swarm-controller/src/main.rs
@@ -428,14 +428,29 @@ async fn get_links(State(state): State) -> Json> {
/// body because a bare 503 on an operator-facing diagnostic is how a
/// misconfiguration costs an afternoon; it is a queue/JetStream error
/// string, and this surface is already behind the swarm's SSO.
+///
+/// It travels in `detail` of an RFC 9457 `application/problem+json` body
+/// rather than as a bare string, so the cause is an addressable field
+/// instead of the whole payload — see `error_problem` below.
struct StatusUnavailable(String);
impl axum::response::IntoResponse for StatusUnavailable {
fn into_response(self) -> axum::response::Response {
- (axum::http::StatusCode::SERVICE_UNAVAILABLE, self.0).into_response()
+ error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response()
}
}
+/// Every error this daemon returns, in one shape.
+///
+/// RFC 9457 `application/problem+json` is the hive-wide contract for HTTP
+/// error bodies (`docs/conventions.md`), and the operator UIs read `detail`
+/// for display. A bare string forces the reader to treat the entire body as
+/// the message, which is the difference between a UI that can offer "copy the
+/// cause" and one that can only dump a response.
+fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_details::ProblemDetails {
+ problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
+}
+
/// What each hive last said about itself, read from the swarm queue at
/// request time.
///
@@ -522,17 +537,17 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
- (status = 400, description = "`name` is not a valid identifier", body = String),
- (status = 500, description = "the job chain could not be queued", body = String),
+ (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
+ (status = 500, description = "the job chain could not be queued (problem+json)", body = String),
),
tag = "agents"
)]
async fn create_agent(
State(state): State,
Json(req): Json,
-) -> Result, (axum::http::StatusCode, String)> {
+) -> Result, problem_details::ProblemDetails> {
let agent = hive_types::Ident::parse(&req.name)
- .map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))?
+ .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let repo = agent.clone();
@@ -562,7 +577,12 @@ async fn create_agent(
.after_ok(create_repo);
vec![create_identity.guid()]
})
- .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ .map_err(|e| {
+ error_problem(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ &e.to_string(),
+ )
+ })?;
let [id] = ids[..] else {
unreachable!("exactly one handle was asked for");
};
@@ -770,11 +790,46 @@ async fn main() -> Result<()> {
#[cfg(test)]
mod tests {
use super::{
- DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, SwarmNodeKind, WorkerDeps,
- load_hives, load_links, run_swarm_node,
+ DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable,
+ SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node,
};
use std::path::Path;
+ /// The 503 this route returns is what the operator UIs' shared error
+ /// component renders, so assert the RENDERED response rather than the
+ /// `problem_details` crate: the contract a UI depends on is the content
+ /// type plus a `detail` it can address, and a handler that built the
+ /// value and returned it as a bare string would satisfy any test
+ /// written against the type alone.
+ #[tokio::test]
+ async fn status_unavailable_renders_problem_json_with_the_cause_in_detail() {
+ use axum::response::IntoResponse as _;
+
+ let cause = "listing status bucket keys: timed out";
+ let resp = StatusUnavailable(cause.to_owned()).into_response();
+
+ assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
+ let ct = resp
+ .headers()
+ .get(axum::http::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or_default()
+ .to_owned();
+ assert!(
+ ct.starts_with("application/problem+json"),
+ "RFC 9457 media type, got {ct:?}"
+ );
+
+ let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
+ .await
+ .expect("body reads");
+ let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses");
+ assert_eq!(v["status"], 503);
+ // The cause is an addressable field, not the entire payload — that
+ // distinction is the point of the change, so it is what is asserted.
+ assert_eq!(v["detail"], cause);
+ }
+
/// Drives `SwarmNodeKind::CreateRepo` through the real
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
/// rather than only through `create_agent`'s endpoint test (there
diff --git a/swarmctl/src/users.rs b/swarmctl/src/users.rs
index 400d89a3..a423d10d 100644
--- a/swarmctl/src/users.rs
+++ b/swarmctl/src/users.rs
@@ -122,6 +122,37 @@ fn quote(s: &str) -> String {
out
}
+/// Domain for an address this crate invents.
+///
+/// The same one `hive-c0re` already gives every agent's forge account
+/// (`forge::users::agent_email`) and every hyperhive-authored git commit. A
+/// deployment-derived domain was considered and rejected: it would have to be
+/// passed in from config, and an operator who is already supplying a domain
+/// may as well supply the whole address — while a *second* convention for
+/// synthetic identities is a thing to keep in sync forever.
+///
+/// Never routable, and that is correct rather than a compromise. Nothing
+/// sends mail here; the address exists so that a relying party asking for an
+/// `email` claim gets one.
+const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
+
+/// The address a user with no explicit one is rendered as.
+///
+/// Every user needs an email in the rendered file, because a relying party
+/// that asks for the `email` claim and gets nothing does not degrade — it
+/// fails. Grafana's OIDC login is the measured case: with no email claim it
+/// falls through to `/emails`, a GitHub-ism authelia does not
+/// implement, and the login dies with `InternalError` rather than anything
+/// naming the missing field.
+///
+/// Synthesised in the **renderer**, never written to the store: `users.json`
+/// stays honest that no address was supplied, so an operator who later sets a
+/// real one is not fighting a value swarmctl invented, and every existing user
+/// is fixed by the next render with no migration step.
+fn synthetic_email(username: &str) -> String {
+ format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}")
+}
+
/// Render the store as authelia's block-style YAML users database.
///
/// Fallible because it re-runs validation over every value it is about to
@@ -142,10 +173,15 @@ pub fn render_yaml(store: &UserStore) -> Result {
writeln!(out, " {name}:")?;
writeln!(out, " displayname: {}", quote(&user.displayname))?;
writeln!(out, " password: {}", quote(&user.password))?;
- if let Some(email) = &user.email {
- reject_control_chars("email", email)?;
- writeln!(out, " email: {}", quote(email))?;
- }
+ // Unconditional: an absent email is the failure mode, not a valid
+ // rendering. The synthetic address is validated on the same path as
+ // a supplied one so neither can smuggle a control character.
+ let email = match &user.email {
+ Some(supplied) => supplied.clone(),
+ None => synthetic_email(name),
+ };
+ reject_control_chars("email", &email)?;
+ writeln!(out, " email: {}", quote(&email))?;
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups {
@@ -325,21 +361,61 @@ mod tests {
}
#[test]
- fn optional_fields_are_omitted_rather_than_emitted_empty() {
+ fn an_empty_group_list_is_omitted_rather_than_emitted_empty() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
- assert!(
- !out.contains("email"),
- "absent email must not appear:\n{out}"
- );
assert!(
!out.contains("groups"),
"an empty group list must not appear:\n{out}"
);
}
+ /// Email is deliberately NOT in the test above any more. It used to
+ /// assert that an absent one is omitted, which pinned the behaviour that
+ /// broke grafana's login: authelia serves no `email` claim, and a relying
+ /// party that wants one fails rather than degrading.
+ #[test]
+ fn a_user_with_no_email_still_renders_one_from_the_shared_domain() {
+ let mut store = UserStore::default();
+ store.users.insert("mara".to_owned(), user("$argon2id$x"));
+
+ let out = render_yaml(&store).expect("renders");
+ assert!(
+ out.contains(r#"email: "mara@hyperhive.local""#),
+ "a user with no email must still render one:\n{out}"
+ );
+ }
+
+ #[test]
+ fn a_supplied_email_is_never_replaced_by_the_synthetic_one() {
+ let mut u = user("$argon2id$x");
+ u.email = Some("real@elsewhere.example".to_owned());
+ let mut store = UserStore::default();
+ store.users.insert("mara".to_owned(), u);
+
+ let out = render_yaml(&store).expect("renders");
+ assert!(
+ out.contains(r#"email: "real@elsewhere.example""#),
+ "the supplied address must win:\n{out}"
+ );
+ assert!(
+ !out.contains("mara@hyperhive.local"),
+ "the synthetic address must not also appear:\n{out}"
+ );
+ }
+
+ /// The synthetic address goes through the same validation as a supplied
+ /// one. A username is already constrained to `[A-Za-z0-9._-]`, so this
+ /// cannot currently fail — which is exactly why it is worth pinning: the
+ /// day username rules loosen, the renderer must still refuse rather than
+ /// quietly emit whatever it built.
+ #[test]
+ fn the_synthetic_address_is_built_from_the_username_and_domain() {
+ assert_eq!(synthetic_email("mara"), "mara@hyperhive.local");
+ }
+
#[test]
fn groups_render_as_a_block_sequence() {
let mut u = user("$argon2id$x");