diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index ac15d012..8a086e4e 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -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([ "stale_revision", "fingerprint_in_use", ]); +const BINDING_STATES = new Set([ + "configured", + "verified", + "revoked", +]); +const AUTHENTICATION_MODES = new Set([ + "legacy_server_issuer", + "workspace_identity", +]); const encoder = new TextEncoder(); type JsonObject = Record; @@ -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 { + 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, diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte index 35c87b2c..25e022e4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -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 { - 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; + 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 { @@ -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 @@

Add remote Runtime

-
+
+

Trust this Workspace on the Runtime

+ {#if data.signingIdentityError} +

{data.signingIdentityError}

+ {:else if data.signingIdentity?.public_bundle} +

+ Save this public bundle as workspace-public-bundle.json on the Runtime host. + It contains no private key material. +

+
{workspacePublicBundle()}
+
yoi-runtime trust-workspace add --bundle workspace-public-bundle.json
+

+ Runtime registration remains configured until authenticated verification is completed. +

+ {:else} +

Loading Workspace public identity…

+ {/if} +
{/if} - {#if !runtime.management?.config_managed} + {#if runtime.management?.binding?.state === 'configured'} + Verification required + {:else if !runtime.management?.config_managed} Test unavailable {/if}
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts index cf7f727b..37ed3880 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts @@ -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, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index bceedc8f..c4901da5 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -315,7 +315,10 @@
Kind
{runtime.kind}
Endpoint
{detail.endpoint ?? 'Not configured'}
Status
{runtime.status}
-
Binding status
{trust.status}
+
Relationship state
{runtime.management.binding?.state ?? 'Not configured'}
+
Authentication mode
{runtime.management.binding?.authentication_mode ?? '—'}
+
Workspace signing key
{runtime.management.binding?.workspace_key_id ?? '—'}
+
Runtime key status
{trust.status}
Fingerprint
{trust.fingerprint ?? '—'}
Revision
{trust.revision?.toString() ?? '—'}
Created
{formatTimestamp(trust.created_at)}
diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index 3d5c83b6..948346b5 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -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" }),