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 { import type {
Diagnostic, Diagnostic,
CreateRemoteRuntimeRequest,
PutRuntimeTrustKeyRequest, PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeIdentityAuthority, RuntimeIdentityAuthority,
@@ -14,6 +15,9 @@ import type {
RuntimeTrustKeyRevealResponse, RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState, RuntimeTrustKeyState,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
WorkspaceRuntimeAuthenticationMode,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts"; } from "$lib/generated/workspace-api.ts";
@@ -67,6 +71,15 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision", "stale_revision",
"fingerprint_in_use", "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(); const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>; 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( function runtimeManagement(
value: unknown, value: unknown,
path: string, path: string,
@@ -300,9 +361,12 @@ function runtimeManagement(
"endpoint_configured", "endpoint_configured",
"token_ref_configured", "token_ref_configured",
], ],
[], ["binding"],
path, path,
); );
const binding = item.binding == null
? undefined
: runtimeBinding(item.binding, `${path}.binding`);
return { return {
built_in: boolean(item.built_in, `${path}.built_in`), built_in: boolean(item.built_in, `${path}.built_in`),
config_managed: boolean(item.config_managed, `${path}.config_managed`), config_managed: boolean(item.config_managed, `${path}.config_managed`),
@@ -315,6 +379,7 @@ function runtimeManagement(
item.token_ref_configured, item.token_ref_configured,
`${path}.token_ref_configured`, `${path}.token_ref_configured`,
), ),
...(binding === undefined ? {} : { binding }),
}; };
} }
@@ -713,6 +778,30 @@ async function finishMutation(
return detail; 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( export async function revealRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -2,14 +2,21 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { import type {
RuntimeConnectionTestResponse, RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection'; import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let runtimeId = $state(''); let runtimePublicBundle = $state('');
let displayName = $state(''); let displayName = $state('');
let endpoint = $state(''); let endpoint = $state('');
let showAddRuntime = $state(false); let showAddRuntime = $state(false);
@@ -46,11 +53,33 @@
return 'Observed'; return 'Observed';
} }
async function responseError(response: Response): Promise<string> { function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
const payload = await response.json().catch(() => null) as let parsed: unknown;
| { message?: string; error?: string } try {
| null; parsed = JSON.parse(value);
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`; } 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> { async function addRuntime(event: SubmitEvent): Promise<void> {
@@ -58,23 +87,22 @@
requestError = null; requestError = null;
busyRuntimeId = 'create'; busyRuntimeId = 'create';
try { try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), { const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
method: 'POST', await createRemoteRuntime(data.workspaceId, {
headers: { 'content-type': 'application/json' }, public_bundle: publicBundle,
body: JSON.stringify({
runtime_id: runtimeId,
display_name: displayName || null, display_name: displayName || null,
endpoint, endpoint,
}), expected_revision: null,
}); });
if (!response.ok) throw new Error(await responseError(response)); runtimePublicBundle = '';
runtimeId = '';
displayName = ''; displayName = '';
endpoint = ''; endpoint = '';
showAddRuntime = false; showAddRuntime = false;
await invalidateAll(); await invalidateAll();
} catch (error) { } catch (error) {
requestError = error instanceof Error ? error.message : String(error); requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
? error.message
: String(error);
} finally { } finally {
busyRuntimeId = null; busyRuntimeId = null;
} }
@@ -116,9 +144,16 @@
<form class="settings-runtime-form" onsubmit={addRuntime}> <form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2> <h2>Add remote Runtime</h2>
<div class="settings-form-grid"> <div class="settings-form-grid">
<label> <label class="settings-form-wide">
Runtime ID Runtime public bundle
<input bind:value={runtimeId} required autocomplete="off" /> <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>
<label> <label>
Display name Display name
@@ -129,6 +164,24 @@
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" /> <input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label> </label>
</div> </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"> <div class="settings-action-row">
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button> <button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}> <button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
@@ -174,7 +227,9 @@
<small><code>{runtime.runtime_id}</code></small> <small><code>{runtime.runtime_id}</code></small>
</td> </td>
<td>{runtime.kind}</td> <td>{runtime.kind}</td>
<td>{runtime.status}</td> <td>
{runtime.management?.binding?.state ?? runtime.status}
</td>
<td>{runtimePlatform(runtime)}</td> <td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td> <td>{managementLabel(runtime)}</td>
<td> <td>
@@ -184,14 +239,16 @@
</td> </td>
<td> <td>
<div class="settings-action-row"> <div class="settings-action-row">
{#if runtime.management?.config_managed} {#if runtime.management?.config_managed && runtime.management.binding?.state === 'verified'}
<button <button
type="button" type="button"
disabled={busyRuntimeId !== null} disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)} onclick={() => testRuntime(runtime)}
>Test</button> >Test</button>
{/if} {/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> <span class="settings-muted-action">Test unavailable</span>
{/if} {/if}
</div> </div>
@@ -1,5 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { 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 { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
runtimes: runtimes.data, runtimes: runtimes.data,
runtimesError: runtimes.error, runtimesError: runtimes.error,
signingIdentity: signingIdentity.data,
signingIdentityError: signingIdentity.error,
}; };
}; };
@@ -315,7 +315,10 @@
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div> <div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div> <div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Status</dt><dd>{runtime.status}</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>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div> <div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div> <div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
@@ -39,6 +39,13 @@ function runtime() {
removable: false, removable: false,
endpoint_configured: true, endpoint_configured: true,
token_ref_configured: false, 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", runtime_id: "arcadia",
label: "Arcadia", label: "Arcadia",
@@ -95,6 +102,11 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
"Runtime ID was not preserved", "Runtime ID was not preserved",
); );
assert(
list.items[0]?.management.binding?.state === "verified",
"binding state was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail()); const parsed = parseWorkspaceRuntimeDetail(detail());
assert( assert(
parsed.trust_key.revision === 3, 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", () => { Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows( assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }), () => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),