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 ( - -
- - {context ? `${context}: ` : ''} - {heading} - - -
- {problem.detail ?

{problem.detail}

: 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");