diff --git a/Cargo.lock b/Cargo.lock
index edbb6a33..041b08a9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4591,7 +4591,6 @@ 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 c19fedfe..bbc97f2f 100644
--- a/docs/conventions.md
+++ b/docs/conventions.md
@@ -318,28 +318,6 @@ 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 d5ec447c..9af3819f 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -21,18 +21,15 @@ operator has to place, and where.
### 1 · Forge
```bash
-# 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.
+# Provision (or refresh) ruth's own forge account — do this first
hivectl forge create-user ruth
-# Sub-agents spawned later (via the approval flow in step 6) get their
-# forge accounts auto-provisioned — nothing to run here for them.
-```
+# Create a human operator account (prints the token to stdout)
+hivectl forge create-user mara --password hunter2
-The human operator's own forge account is created via swarm SSO instead of
-a manual `hivectl` step — see step 3 (`swarmctl user add`).
+# Provision forge accounts for any sub-agents spawned later
+hivectl forge create-user
+```
### 2 · Gateway (HTTP Basic auth)
@@ -92,21 +89,20 @@ 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 — same bootstrap-bypass reasoning
-# as forge above, still a required manual step.
+# 5b. Provision ruth's own matrix account
hivectl matrix create-user ruth
-# 5c. Invite the operator to the hive Space (and optionally to rooms)
+# 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)
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
-# 5d. Promote the operator to homeserver admin if needed
+# 5e. 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 4ad106ec..5d2420c3 100644
--- a/frontend/packages/dashboard/src/credentials.js
+++ b/frontend/packages/dashboard/src/credentials.js
@@ -23,7 +23,6 @@ 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
@@ -66,13 +65,28 @@ 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)
-// 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.
+// ─── 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),
@@ -239,7 +253,7 @@ async function submitLogin(e) {
clearSecrets(formEl);
}
} else {
- const msg = problemMessage(await readApiError(resp));
+ const msg = await readErrorBody(resp);
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@@ -323,7 +337,7 @@ async function submitGithub(e) {
clearSecrets(formEl);
}
} else {
- const msg = problemMessage(await readApiError(resp));
+ const msg = await readErrorBody(resp);
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@@ -405,7 +419,7 @@ async function onForgeRemoveClick(agent, forge, btn) {
loadForgeAccounts(agent);
return;
}
- const msg = problemMessage(await readApiError(resp));
+ const msg = await readErrorBody(resp);
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
@@ -460,7 +474,7 @@ async function submitForgeAccount(e) {
clearSecrets(formEl);
}
} else {
- const msg = problemMessage(await readApiError(resp));
+ const msg = await readErrorBody(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 02bde7a9..ba195859 100644
--- a/frontend/packages/shared/package.json
+++ b/frontend/packages/shared/package.json
@@ -31,12 +31,7 @@
"./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",
- "./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"
+ "./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.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
deleted file mode 100644
index f0ebb3ce..00000000
--- a/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-// 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
deleted file mode 100644
index df4c853c..00000000
--- a/frontend/packages/shared/src/api-error-panel/api-error-panel.css
+++ /dev/null
@@ -1,40 +0,0 @@
-/* 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
deleted file mode 100644
index ff69de49..00000000
--- a/frontend/packages/shared/src/api-error.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-// 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
deleted file mode 100644
index 1f16ea22..00000000
--- a/frontend/packages/shared/src/warn-banner/WarnBanner.css
+++ /dev/null
@@ -1,36 +0,0 @@
-/* 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
deleted file mode 100644
index f036d465..00000000
--- a/frontend/packages/shared/src/warn-banner/WarnBanner.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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 61f8f368..eeabde39 100644
--- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css
+++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css
@@ -49,3 +49,6 @@
.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 ed500fe8..c06ae2eb 100644
--- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx
@@ -23,8 +23,6 @@
// 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';
@@ -36,22 +34,13 @@ type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'done'; nodeId: number }
- | { status: 'error'; problem: ProblemDetails };
+ | { status: 'error'; message: string };
// 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.
-//
-// 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}';
+const NAME_PATTERN = '[a-z0-9-]{1,63}';
export function CreateAgentPage() {
const [name, setName] = useState('');
@@ -66,15 +55,12 @@ export function CreateAgentPage() {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
});
- if (!r.ok) {
- setResult({ status: 'error', problem: await readApiError(r) });
- return;
- }
+ if (!r.ok) throw new Error((await r.text()) || `http ${r.status}`);
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
} catch (err) {
- setResult({ status: 'error', problem: { detail: String(err) } });
+ setResult({ status: 'error', message: String(err) });
}
}
@@ -110,7 +96,9 @@ 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 a7451303..b78f8f12 100644
--- a/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
@@ -8,8 +8,6 @@
// 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';
@@ -62,22 +60,21 @@ const COLUMNS: TableColumn[] = [
export function OverviewPage() {
const [hives, setHives] = useState(null);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(null);
useEffect(() => {
- (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) }));
+ 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)));
}, []);
return (
- {error ? : null}
+ {error ?
failed to load the hive roster: {error}
: null}
{!error && hives === null ?
loading…
: null}
{hives ?
h.name} /> : null}
diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix
index 79a99907..878f4b1f 100644
--- a/nix/host-modules/swarm-grafana.nix
+++ b/nix/host-modules/swarm-grafana.nix
@@ -174,30 +174,6 @@ 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) {
@@ -461,25 +437,6 @@ 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 6ac33c60..aad95ae4 100644
--- a/swarm-controller/Cargo.toml
+++ b/swarm-controller/Cargo.toml
@@ -27,10 +27,6 @@ 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 f0a9841d..26aa08fd 100644
--- a/swarm-controller/src/main.rs
+++ b/swarm-controller/src/main.rs
@@ -428,29 +428,14 @@ 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 {
- error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response()
+ (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.
///
@@ -537,17 +522,17 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
- (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),
+ (status = 400, description = "`name` is not a valid identifier", body = String),
+ (status = 500, description = "the job chain could not be queued", body = String),
),
tag = "agents"
)]
async fn create_agent(
State(state): State,
Json(req): Json,
-) -> Result, problem_details::ProblemDetails> {
+) -> Result, (axum::http::StatusCode, String)> {
let agent = hive_types::Ident::parse(&req.name)
- .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
+ .map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))?
.into_string();
let repo = agent.clone();
@@ -577,12 +562,7 @@ async fn create_agent(
.after_ok(create_repo);
vec![create_identity.guid()]
})
- .map_err(|e| {
- error_problem(
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- &e.to_string(),
- )
- })?;
+ .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let [id] = ids[..] else {
unreachable!("exactly one handle was asked for");
};
@@ -790,46 +770,11 @@ async fn main() -> Result<()> {
#[cfg(test)]
mod tests {
use super::{
- DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable,
- SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node,
+ DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, 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 a423d10d..400d89a3 100644
--- a/swarmctl/src/users.rs
+++ b/swarmctl/src/users.rs
@@ -122,37 +122,6 @@ 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
@@ -173,15 +142,10 @@ pub fn render_yaml(store: &UserStore) -> Result {
writeln!(out, " {name}:")?;
writeln!(out, " displayname: {}", quote(&user.displayname))?;
writeln!(out, " password: {}", quote(&user.password))?;
- // 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 let Some(email) = &user.email {
+ reject_control_chars("email", email)?;
+ writeln!(out, " email: {}", quote(email))?;
+ }
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups {
@@ -361,61 +325,21 @@ mod tests {
}
#[test]
- fn an_empty_group_list_is_omitted_rather_than_emitted_empty() {
+ fn optional_fields_are_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");