// `/admin/install?token=<jwt>` — install-claim ceremony.
//
// Distinct from a plugin: the install URL is the public entry point
// for an unauthenticated operator, so it lives outside the plugin
// routing tree (which is gated on auth once login lands). Reads
// `?token=` from the URL, drives `navigator.credentials.create`,
// posts to `/v1/install/claim/{start,finish}`.
//
// Modelled on `affinidi-webvh-service/webvh-ui/lib/passkey.ts` +
// `app/enroll.tsx`: standard WebAuthn registration with no custom
// binding-signature step. The admin DID is carried in the install
// token, not derived from the passkey.
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
decodePublicKeyOptions,
serializeRegistration,
type JsonPublicKeyOptions,
} from "@/lib/webauthn";
const TRUST_TASK_START =
"https://trusttasks.org/openvtc/vtc/install/claim/start/1.0";
const TRUST_TASK_FINISH =
"https://trusttasks.org/openvtc/vtc/install/claim/finish/1.0";
type Phase =
| { kind: "awaiting-code" }
| { kind: "registering" }
| { kind: "success"; adminDid: string; setupSessionToken: string }
| { kind: "error"; title: string; message: string; hint?: string };
async function postJson(
path: string,
trustTask: string,
body: unknown,
): Promise<{ status: number; body: unknown }> {
// Install ceremony is pre-session: no CSRF cookie yet, no
// credentials. Plain fetch by design — the regular
// `lib/api.ts::postJson` helper assumes a logged-in session.
const res = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Trust-Task": trustTask,
},
body: JSON.stringify(body),
});
let parsed: unknown = null;
try {
parsed = await res.json();
} catch {
/* non-JSON body — leave null */
}
return { status: res.status, body: parsed };
}
export function Install() {
const [params] = useSearchParams();
const token = params.get("token");
const [phase, setPhase] = useState<Phase>(
token
? { kind: "awaiting-code" }
: {
kind: "error",
title: "Missing install token",
message: "The install URL must include `?token=<jwt>`.",
hint: "Re-open the URL the wizard or admin console issued.",
},
);
const [claimCode, setClaimCode] = useState("");
const runCeremony = async (code: string) => {
if (!token) return;
setPhase({ kind: "registering" });
// ── claim/start ──
const start = await postJson(
"/v1/install/claim/start",
TRUST_TASK_START,
{
install_token: token,
claim_secret: code.trim() === "" ? undefined : code.trim(),
},
);
if (start.status === 401) {
const errCode =
(start.body as { error?: string } | null)?.error ?? "";
if (errCode === "claim_secret_required") {
setPhase({
kind: "error",
title: "Claim code required",
message:
"This install URL is paired with an out-of-band claim code. Ask whoever sent you the URL for the matching code, then try again.",
});
return;
}
if (errCode === "claim_secret_invalid") {
setPhase({
kind: "error",
title: "Wrong claim code",
message:
"The claim code you typed doesn't match the one the daemon stored. Double-check the code and retry — repeated wrong attempts will not lock the invite, but they slow you down.",
});
return;
}
setPhase({
kind: "error",
title: "Install URL expired or already used",
message:
"The install URL is single-use and expires 15 minutes after it's minted.",
hint:
"Ask the daemon operator to mint a new one via `vtc admin invite` or the admin console's Invites panel.",
});
return;
}
if (start.status === 409) {
setPhase({
kind: "error",
title: "Install ceremony already in progress",
message:
"Another browser session is mid-ceremony with this token.",
hint:
"Wait a few minutes for it to time out, then retry — or ask the operator for a fresh URL.",
});
return;
}
if (start.status !== 200) {
const b = start.body as { error?: string; message?: string } | null;
setPhase({
kind: "error",
title: `Server error (${start.status})`,
message: b?.error ?? b?.message ?? "Unexpected response from the daemon.",
});
return;
}
const startBody = start.body as {
registrationId: string;
options: { publicKey: JsonPublicKeyOptions };
};
// ── browser WebAuthn create ──
const publicKey = decodePublicKeyOptions(
startBody.options.publicKey,
) as PublicKeyCredentialCreationOptions;
let credential: PublicKeyCredential | null = null;
try {
credential = (await navigator.credentials.create({
publicKey,
})) as PublicKeyCredential | null;
} catch (err) {
const e = err as Error;
setPhase({
kind: "error",
title: "Passkey registration cancelled or failed",
message: e.message || String(err),
hint:
"Try again, or use a different authenticator (USB security key, platform passkey).",
});
return;
}
if (!credential) {
setPhase({
kind: "error",
title: "Passkey registration returned no credential",
message:
"Your browser dismissed the ceremony without producing a credential.",
hint: "Retry the install URL.",
});
return;
}
// ── claim/finish ──
const finish = await postJson(
"/v1/install/claim/finish",
TRUST_TASK_FINISH,
{
install_token: token,
registration_id: startBody.registrationId,
webauthn_response: serializeRegistration(credential),
},
);
if (finish.status !== 200) {
const b = finish.body as { error?: string; message?: string } | null;
setPhase({
kind: "error",
title: `Install ceremony failed (${finish.status})`,
message: b?.error ?? b?.message ?? "The daemon rejected the WebAuthn response.",
hint:
finish.status === 401
? "The install token may have expired between start and finish — ask the operator for a fresh URL."
: "Check the daemon logs for the rejection reason.",
});
return;
}
const finishBody = finish.body as {
adminDid: string;
setupSessionToken: string;
};
setPhase({
kind: "success",
adminDid: finishBody.adminDid,
setupSessionToken: finishBody.setupSessionToken,
});
};
const onSubmitCode = (e: React.FormEvent) => {
e.preventDefault();
void runCeremony(claimCode);
};
return (
<section className="page install-page">
<h2>Claim Admin Passkey</h2>
<p className="lead">
One-shot install ceremony for the first administrator of this
Verifiable Trust Community.
</p>
{phase.kind === "awaiting-code" && (
<section className="card">
<h3>Enter your claim code</h3>
<p>
The operator who sent you this URL also sent a short
claim code through a separate channel (Signal, SMS, in
person). Type the code below to start the passkey
ceremony — the URL alone is not enough to claim the
invite.
</p>
<form onSubmit={onSubmitCode} className="form-stack">
<label className="field">
<span className="field-label">Claim code</span>
<input
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
placeholder="e.g. ABCDEFGHJK"
value={claimCode}
onChange={(e) => setClaimCode(e.target.value.toUpperCase())}
required
autoFocus
/>
</label>
<div className="form-actions">
<button type="submit" className="primary">
Continue
</button>
</div>
</form>
</section>
)}
{phase.kind === "registering" && (
<section className="card">
<h3>Registering passkey…</h3>
<p>
Follow your browser's prompts to register a passkey for
this server. The admin DID is decided server-side from
the install token, so any passkey algorithm your
authenticator offers (ES256, RS256, EdDSA) is fine.
</p>
</section>
)}
{phase.kind === "success" && (
<section className="card">
<h3>Passkey registered ✅</h3>
<dl>
<dt>Admin DID</dt>
<dd>
<code>{phase.adminDid}</code>
</dd>
</dl>
<p>
Save the setup-session token below — your CNM CLI uses it
to complete the bootstrap handshake.
</p>
<pre>{phase.setupSessionToken}</pre>
</section>
)}
{phase.kind === "error" && (
<section className="card error">
<h3>{phase.title}</h3>
<p>{phase.message}</p>
{phase.hint && <p className="lead">{phase.hint}</p>}
{token &&
(phase.title === "Claim code required" ||
phase.title === "Wrong claim code") && (
<div className="form-actions">
<button
type="button"
className="primary"
onClick={() => {
setClaimCode("");
setPhase({ kind: "awaiting-code" });
}}
>
Try again
</button>
</div>
)}
</section>
)}
<footer>
<p className="lead">
The install URL is single-use and expires after 15 minutes.
If yours has expired, the daemon operator can mint a fresh
one via <code>vtc admin invite --did <admin-did></code>.
</p>
</footer>
</section>
);
}