feat: add manual Runtime trust setup UI

This commit is contained in:
2026-09-08 04:22:45 +09:00
parent 243a081874
commit 7fb1d4056c
5 changed files with 217 additions and 26 deletions
@@ -1,5 +1,6 @@
import type {
Diagnostic,
CreateRemoteRuntimeRequest,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeIdentityAuthority,
@@ -14,6 +15,9 @@ import type {
RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
WorkspaceRuntimeAuthenticationMode,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts";
@@ -67,6 +71,15 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision",
"fingerprint_in_use",
]);
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
"configured",
"verified",
"revoked",
]);
const AUTHENTICATION_MODES = new Set<WorkspaceRuntimeAuthenticationMode>([
"legacy_server_issuer",
"workspace_identity",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
@@ -286,6 +299,54 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
};
}
function runtimeBinding(
value: unknown,
path: string,
): WorkspaceRuntimeBindingSummary {
const item = object(value, path);
exactKeys(
item,
["state", "authentication_mode", "revision"],
["workspace_key_id", "workspace_key_generation"],
path,
);
const authenticationMode = enumValue(
item.authentication_mode,
`${path}.authentication_mode`,
AUTHENTICATION_MODES,
);
const workspaceKeyId = optionalNullableString(
item.workspace_key_id,
`${path}.workspace_key_id`,
LIMITS.idBytes,
);
const workspaceKeyGeneration = optionalNullableRevision(
item.workspace_key_generation,
`${path}.workspace_key_generation`,
);
if (
authenticationMode === "workspace_identity" &&
(workspaceKeyId == null || workspaceKeyGeneration == null)
) {
return fail(path, "requires Workspace signing key identity metadata");
}
if (
authenticationMode === "legacy_server_issuer" &&
(workspaceKeyId != null || workspaceKeyGeneration != null)
) {
return fail(path, "must not attach Workspace key metadata to legacy authority");
}
return {
state: enumValue(item.state, `${path}.state`, BINDING_STATES),
authentication_mode: authenticationMode,
revision: safeRevision(item.revision, `${path}.revision`),
...(workspaceKeyId === undefined ? {} : { workspace_key_id: workspaceKeyId }),
...(workspaceKeyGeneration === undefined
? {}
: { workspace_key_generation: workspaceKeyGeneration }),
};
}
function runtimeManagement(
value: unknown,
path: string,
@@ -300,9 +361,12 @@ function runtimeManagement(
"endpoint_configured",
"token_ref_configured",
],
[],
["binding"],
path,
);
const binding = item.binding == null
? undefined
: runtimeBinding(item.binding, `${path}.binding`);
return {
built_in: boolean(item.built_in, `${path}.built_in`),
config_managed: boolean(item.config_managed, `${path}.config_managed`),
@@ -315,6 +379,7 @@ function runtimeManagement(
item.token_ref_configured,
`${path}.token_ref_configured`,
),
...(binding === undefined ? {} : { binding }),
};
}
@@ -713,6 +778,30 @@ async function finishMutation(
return detail;
}
export async function createRemoteRuntime(
workspaceId: string,
request: CreateRemoteRuntimeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeResource> {
const response = await fetchImpl(
workspaceApiPath(workspaceId, "/runtimes"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
const payload = await readBoundedJson(response);
if (!response.ok) throw requestErrorFrom(payload, response.status);
const runtime = runtimeResource(payload, "Runtime create response");
if (runtime.runtime_id !== request.public_bundle.identity_id) {
throw new RuntimeTrustRequestError(
"Runtime create response did not match the submitted public bundle",
);
}
return runtime;
}
export async function revealRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
@@ -2,14 +2,21 @@
import { invalidateAll } from '$app/navigation';
import type {
RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource,
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: PageProps = $props();
let runtimeId = $state('');
let runtimePublicBundle = $state('');
let displayName = $state('');
let endpoint = $state('');
let showAddRuntime = $state(false);
@@ -46,11 +53,33 @@
return 'Observed';
}
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error('Runtime public bundle must be valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime public bundle must be a JSON object');
}
const item = parsed as Record<string, unknown>;
if (
Object.keys(item).length !== 2 ||
typeof item.identity_id !== 'string' ||
item.identity_id.length === 0 ||
typeof item.public_key !== 'string' ||
item.public_key.length === 0
) {
throw new Error('Runtime public bundle must contain only identity_id and public_key');
}
return { identity_id: item.identity_id, public_key: item.public_key };
}
function workspacePublicBundle(): string {
return data.signingIdentity?.public_bundle
? JSON.stringify(data.signingIdentity.public_bundle, null, 2)
: '';
}
async function addRuntime(event: SubmitEvent): Promise<void> {
@@ -58,23 +87,22 @@
requestError = null;
busyRuntimeId = 'create';
try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
display_name: displayName || null,
endpoint,
}),
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
display_name: displayName || null,
endpoint,
expected_revision: null,
});
if (!response.ok) throw new Error(await responseError(response));
runtimeId = '';
runtimePublicBundle = '';
displayName = '';
endpoint = '';
showAddRuntime = false;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
? error.message
: String(error);
} finally {
busyRuntimeId = null;
}
@@ -116,9 +144,16 @@
<form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2>
<div class="settings-form-grid">
<label>
Runtime ID
<input bind:value={runtimeId} required autocomplete="off" />
<label class="settings-form-wide">
Runtime public bundle
<small>Run <code>yoi-runtime identity show --json</code> on the Runtime host and paste the result.</small>
<textarea
bind:value={runtimePublicBundle}
required
rows="5"
spellcheck="false"
placeholder={runtimeBundlePlaceholder}
></textarea>
</label>
<label>
Display name
@@ -129,6 +164,24 @@
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label>
</div>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-trust-heading">
<h3 id="runtime-trust-heading">Trust this Workspace on the Runtime</h3>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.public_bundle}
<p>
Save this public bundle as <code>workspace-public-bundle.json</code> on the Runtime host.
It contains no private key material.
</p>
<pre>{workspacePublicBundle()}</pre>
<pre>yoi-runtime trust-workspace add --bundle workspace-public-bundle.json</pre>
<p>
Runtime registration remains <code>configured</code> until authenticated verification is completed.
</p>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<div class="settings-action-row">
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
@@ -174,7 +227,9 @@
<small><code>{runtime.runtime_id}</code></small>
</td>
<td>{runtime.kind}</td>
<td>{runtime.status}</td>
<td>
{runtime.management?.binding?.state ?? runtime.status}
</td>
<td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td>
<td>
@@ -184,14 +239,16 @@
</td>
<td>
<div class="settings-action-row">
{#if runtime.management?.config_managed}
{#if runtime.management?.config_managed && runtime.management.binding?.state === 'verified'}
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if !runtime.management?.config_managed}
{#if runtime.management?.binding?.state === 'configured'}
<span class="settings-muted-action">Verification required</span>
{:else if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span>
{/if}
</div>
@@ -1,5 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
@@ -16,9 +17,24 @@ export const load: PageLoad = async ({ fetch, params }) => {
},
);
const signingIdentity = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/signing-identity"),
undefined,
(value) => {
const response = parseWorkspaceSigningIdentityResponse(value);
if (response.identity.workspace_id !== params.workspaceId) {
throw new Error("Workspace signing identity did not match the route");
}
return response;
},
);
return {
workspaceId: params.workspaceId,
runtimes: runtimes.data,
runtimesError: runtimes.error,
signingIdentity: signingIdentity.data,
signingIdentityError: signingIdentity.error,
};
};
@@ -315,7 +315,10 @@
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
<div><dt>Binding status</dt><dd>{trust.status}</dd></div>
<div><dt>Relationship state</dt><dd>{runtime.management.binding?.state ?? 'Not configured'}</dd></div>
<div><dt>Authentication mode</dt><dd>{runtime.management.binding?.authentication_mode ?? '—'}</dd></div>
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
@@ -39,6 +39,13 @@ function runtime() {
removable: false,
endpoint_configured: true,
token_ref_configured: false,
binding: {
state: "verified",
authentication_mode: "workspace_identity",
revision: 3,
workspace_key_id: "WK-1",
workspace_key_generation: 1,
},
},
runtime_id: "arcadia",
label: "Arcadia",
@@ -95,6 +102,11 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
"Runtime ID was not preserved",
);
assert(
list.items[0]?.management.binding?.state === "verified",
"binding state was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail());
assert(
parsed.trust_key.revision === 3,
@@ -106,6 +118,20 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
);
});
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
const payload = detail();
const binding = payload.runtime.management.binding as Partial<
typeof payload.runtime.management.binding
>;
delete binding.workspace_key_id;
delete binding.workspace_key_generation;
binding.state = "configured";
assertThrows(
() => parseWorkspaceRuntimeDetail(payload),
"requires Workspace signing key identity metadata",
);
});
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),