Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
atlas 8ad2af735e swarm-controller: refuse linking over an existing account
The matrix, forge and github link routes wrote their credential
unconditionally, so linking a name that was already linked replaced the
working account. For matrix that lost the device the agent's crypto store
belongs to (#4838).

Each route now reads the account's store path first and answers 409,
naming the existing account, when something is stored there. Nothing is
written. Replacing an account takes the delete from #4899, then a link.

The matrix route checks before password mode's login, so a refused link
mints no new device at the homeserver.

The check is a read then a write, not an atomic step; two concurrent
links to one name can still both pass it.

Closes #4856
2026-10-03 13:49:03 +02:00

238 lines
9.1 KiB
TypeScript

// <LinkMatrixAccountForm> — writes a matrix account credential for one
// agent into the swarm secret store, in one of two modes (mirrors
// swarm-controller's `PutMatrixAccountRequest`):
// token — paste an already-obtained bearer token, optional homeserver.
// password — user id + password; swarm-controller logs into the
// (required, here) homeserver itself and stores the token
// that comes back. The password is sent once, over this
// PUT, straight to swarm-controller — never held here past
// the request, never stored.
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
// 200 (`{ user_id? }`) on success, 400/409/500 on the documented failure
// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
// same as every other form here, since the response is `problem+json`
// regardless of which arm fired.
//
// A blind set action: no route hands a token back, so there is
// nothing to edit. What is already linked shows on the agent panel
// (`LinkedAccounts`), names and hosts only. That matches "make it 1:1 for now, we will split later" and
// "adding them and assigning them should be separate things" — both
// mara's rulings on that issue — there's no assignment step here yet,
// just the write.
//
// Rendered inside a `Dialog` from `AgentsPage`, one per row — same
// mount shape as `CreateAgentForm`, own file for the same reason (a
// multi-field form with real submit-state handling is more than
// `WantedMenu`-sized, which is why that one stays inline in
// `AgentsPage.tsx` and this one doesn't).
import { 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 { TextField } from "../ui/text-field/TextField.js";
import { SelectField } from "../ui/select-field/SelectField.js";
import { Button } from "../ui/button/Button.js";
import "./LinkMatrixAccountForm.css";
type Mode = "token" | "password";
// The store's own key-space, not `hive_types::Ident` — atlas's measured
// gap on the issue: an agent name is an `Ident` (lowercase-only) but an
// account name is a bare attrset key under `matrixAccounts`, so it
// permits e.g. `Ops_Relay9`. `pattern` here is a UX hint only, same
// caveat as `CreateAgentForm`'s `NAME_PATTERN` — the server is the real
// gate, this just avoids a round-trip for an obviously-bad name.
const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
// `userId` is set in password mode (the server resolves it from the
// login response) and `undefined` in token mode — see
// `PutMatrixAccountResponse`'s own doc for why token mode doesn't
// spend a round trip resolving one.
| { status: "done"; userId?: string }
| { status: "error"; problem: ProblemDetails };
const MODE_OPTIONS = [
{ value: "token", label: "paste a token" },
{ value: "password", label: "log in with a password" },
];
export function LinkMatrixAccountForm({
hive,
agent,
onClose,
}: {
hive: string;
agent: string;
/** Rendered as the panel header's own close button — this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */
onClose?: () => void;
}) {
const [account, setAccount] = useState("");
const [mode, setMode] = useState<Mode>("token");
const [token, setToken] = useState("");
const [userId, setUserId] = useState("");
const [password, setPassword] = useState("");
const [homeserver, setHomeserver] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" });
async function submit(e: Event) {
e.preventDefault();
setResult({ status: "submitting" });
try {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/matrix-accounts/${encodeURIComponent(account)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(
mode === "token"
? {
mode,
token,
// Omitted rather than sent empty — the contract marks
// it optional in token mode, and an empty string
// isn't "no homeserver", it's a homeserver named "".
homeserver: homeserver.trim() || undefined,
}
: {
mode,
user_id: userId,
password,
// Required in password mode — the field below enforces
// that before this branch is ever reachable.
homeserver: homeserver.trim(),
},
),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
const body = (await r.json().catch(() => ({}))) as {
user_id?: string;
};
setResult({ status: "done", userId: body.user_id });
// Secrets cleared on success — nothing left needing them in the
// form once the store holds the derived credential, and a secret
// sitting in a field after the action that used it is exactly the
// kind of stale-credential risk the whole store exists to avoid.
// Account name kept, so the success message below reads against
// what actually landed.
setToken("");
setPassword("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
return (
<Panel
title={`link a matrix account — ${agent}`}
icon="🔗"
onClose={onClose}
>
<p>
Writes the credential to the swarm secret store. The agent's own matrix
daemon reads it from there within two minutes — nothing here restarts
anything directly.
</p>
<form class="link-matrix-account-form" onSubmit={submit}>
<TextField
id="matrix-account-name"
label="account"
value={account}
pattern={ACCOUNT_PATTERN}
title="1-63 chars: letters, digits, underscore, hyphen"
// Not "primary" — that reads as naming the hive-provided default
// account (`main`, reserved: see `hyperhive.matrixAccounts`'s own
// module doc), which this route can neither create nor touch.
// Picking a name with no relationship to that default avoids the
// misread entirely, rather than requiring a reader to already know
// the reservation to see through it.
placeholder="e.g. ops-relay"
required
onInput={setAccount}
/>
<SelectField
id="matrix-account-mode"
label="credential"
value={mode}
options={MODE_OPTIONS}
onChange={(v) => setMode(v as Mode)}
/>
{mode === "token" ? (
<TextField
id="matrix-account-token"
label="access token"
type="password"
value={token}
required
onInput={setToken}
/>
) : (
<>
<TextField
id="matrix-account-user-id"
label="matrix user id"
placeholder="@name:matrix.example.org"
value={userId}
required
onInput={setUserId}
/>
<TextField
id="matrix-account-password"
label="password"
type="password"
value={password}
required
onInput={setPassword}
/>
</>
)}
<TextField
id="matrix-account-homeserver"
// Optional in token mode (the hive falls back to its own
// default); required in password mode, since swarm-controller
// logs in against exactly this URL and has no fallback of its
// own to defer to — see `PutMatrixAccountRequest::homeserver`'s
// own doc for why the two modes differ here.
label={mode === "token" ? "homeserver (optional)" : "homeserver"}
type="url"
value={homeserver}
placeholder="https://matrix.example.org"
required={mode === "password"}
onInput={setHomeserver}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === "submitting"}
>
{result.status === "submitting" ? "linking…" : "link account"}
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
linked <strong>{account}</strong> to <strong>{agent}</strong>
{result.userId ? (
<>
{" "}
— logged in as <strong>{result.userId}</strong>
</>
) : null}
</p>
)}
{result.status === "error" && (
<ApiErrorPanel
context="failed to link the account"
problem={result.problem}
/>
)}
</Panel>
);
}