feat: complete Workspace Runtime management flow
This commit is contained in:
@@ -919,6 +919,30 @@ export async function createRemoteRuntime(
|
|||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteRemoteRuntime(
|
||||||
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(runtimeId)}`,
|
||||||
|
),
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (response.ok) return;
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await readBoundedJson(response);
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
`Runtime registration delete failed (${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw requestErrorFrom(payload, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
export async function revealRuntimeTrustKey(
|
export async function revealRuntimeTrustKey(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
|||||||
@@ -678,7 +678,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
|
|||||||
"Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
|
"Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
runtimesPage.includes("Add remote Runtime") &&
|
runtimesPage.includes("Connect a remote Runtime") &&
|
||||||
runtimesPage.includes("Open workdirs") &&
|
runtimesPage.includes("Open workdirs") &&
|
||||||
runtimesPage.includes("settings-runtime-table") &&
|
runtimesPage.includes("settings-runtime-table") &&
|
||||||
runtimesPage.includes("testRuntimeConnection") &&
|
runtimesPage.includes("testRuntimeConnection") &&
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
RuntimeTrustRequestError,
|
RuntimeTrustRequestError,
|
||||||
} from '$lib/workspace/api/runtime-management';
|
} from '$lib/workspace/api/runtime-management';
|
||||||
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||||
|
import { provisionWorkspaceSigningIdentity } from '$lib/workspace/settings/profile-api';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
const runtimeBundlePlaceholder =
|
const runtimeBundlePlaceholder =
|
||||||
@@ -21,10 +22,10 @@
|
|||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
let endpoint = $state('');
|
let endpoint = $state('');
|
||||||
let runtimeFingerprint = $state<string | null>(null);
|
let runtimeFingerprint = $state<string | null>(null);
|
||||||
let fingerprintConfirmation = $state('');
|
|
||||||
let showAddRuntime = $state(false);
|
let showAddRuntime = $state(false);
|
||||||
let busyRuntimeId = $state<string | null>(null);
|
let busyRuntimeId = $state<string | null>(null);
|
||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
|
let requestNotice = $state<string | null>(null);
|
||||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
||||||
let connectionTestGeneration = 0;
|
let connectionTestGeneration = 0;
|
||||||
|
|
||||||
@@ -88,6 +89,25 @@
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspaceBundleFilename(): string {
|
||||||
|
return `workspace-${data.workspaceId}-public-bundle.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function provisionSigningIdentity(): Promise<void> {
|
||||||
|
requestError = null;
|
||||||
|
requestNotice = null;
|
||||||
|
busyRuntimeId = 'provision-workspace-identity';
|
||||||
|
try {
|
||||||
|
await provisionWorkspaceSigningIdentity(data.workspaceId);
|
||||||
|
await invalidateAll();
|
||||||
|
requestNotice = 'Workspace identity provisioned. Copy its public bundle to the Runtime host.';
|
||||||
|
} catch (error) {
|
||||||
|
requestError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
busyRuntimeId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyWorkspaceBundle(): Promise<void> {
|
async function copyWorkspaceBundle(): Promise<void> {
|
||||||
requestError = null;
|
requestError = null;
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +120,6 @@
|
|||||||
async function previewRuntimeFingerprint(): Promise<void> {
|
async function previewRuntimeFingerprint(): Promise<void> {
|
||||||
requestError = null;
|
requestError = null;
|
||||||
runtimeFingerprint = null;
|
runtimeFingerprint = null;
|
||||||
fingerprintConfirmation = '';
|
|
||||||
busyRuntimeId = 'preview';
|
busyRuntimeId = 'preview';
|
||||||
try {
|
try {
|
||||||
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
|
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
|
||||||
@@ -115,15 +134,13 @@
|
|||||||
async function addRuntime(event: SubmitEvent): Promise<void> {
|
async function addRuntime(event: SubmitEvent): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
requestError = null;
|
requestError = null;
|
||||||
|
requestNotice = null;
|
||||||
busyRuntimeId = 'create';
|
busyRuntimeId = 'create';
|
||||||
try {
|
try {
|
||||||
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
|
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
|
||||||
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
|
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
|
||||||
if (
|
if (runtimeFingerprint !== currentFingerprint) {
|
||||||
runtimeFingerprint !== currentFingerprint ||
|
throw new Error('Preview the Runtime public key fingerprint before registration');
|
||||||
fingerprintConfirmation.trim() !== currentFingerprint
|
|
||||||
) {
|
|
||||||
throw new Error('Preview and confirm the exact Runtime public key fingerprint before registration');
|
|
||||||
}
|
}
|
||||||
await createRemoteRuntime(data.workspaceId, {
|
await createRemoteRuntime(data.workspaceId, {
|
||||||
public_bundle: publicBundle,
|
public_bundle: publicBundle,
|
||||||
@@ -133,10 +150,10 @@
|
|||||||
});
|
});
|
||||||
runtimePublicBundle = '';
|
runtimePublicBundle = '';
|
||||||
runtimeFingerprint = null;
|
runtimeFingerprint = null;
|
||||||
fingerprintConfirmation = '';
|
|
||||||
displayName = '';
|
displayName = '';
|
||||||
endpoint = '';
|
endpoint = '';
|
||||||
showAddRuntime = false;
|
showAddRuntime = false;
|
||||||
|
requestNotice = 'Runtime registered for this Workspace. Run Test to complete authenticated verification.';
|
||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
|
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
|
||||||
@@ -205,71 +222,107 @@
|
|||||||
|
|
||||||
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
|
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
|
||||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||||
<h2>Add remote Runtime</h2>
|
<header>
|
||||||
<div class="settings-form-grid">
|
<h2>Connect a remote Runtime</h2>
|
||||||
<label class="settings-form-wide">
|
<p>
|
||||||
Runtime public bundle
|
This creates a binding for this Workspace. The Runtime can remain connected to other Workspaces;
|
||||||
<small>Run <code>yoi-runtime identity show --json</code> on the Runtime host and paste the result.</small>
|
their trust entries are not replaced.
|
||||||
<textarea
|
</p>
|
||||||
bind:value={runtimePublicBundle}
|
</header>
|
||||||
oninput={() => {
|
|
||||||
runtimeFingerprint = null;
|
<section class="settings-runtime-trust-instructions" aria-labelledby="workspace-to-runtime-heading">
|
||||||
fingerprintConfirmation = '';
|
<h3 id="workspace-to-runtime-heading">1. Trust this Workspace on the Runtime</h3>
|
||||||
}}
|
<p>
|
||||||
required
|
Each Workspace has its own signing identity. Add this Workspace public bundle to the same store used
|
||||||
rows="5"
|
when starting the Runtime.
|
||||||
spellcheck="false"
|
</p>
|
||||||
placeholder={runtimeBundlePlaceholder}
|
|
||||||
></textarea>
|
|
||||||
<button type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
|
|
||||||
Preview fingerprint
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
{#if runtimeFingerprint}
|
|
||||||
<label>
|
|
||||||
Runtime key fingerprint
|
|
||||||
<code>{runtimeFingerprint}</code>
|
|
||||||
<input
|
|
||||||
bind:value={fingerprintConfirmation}
|
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Enter the fingerprint exactly"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
<label>
|
|
||||||
Display name
|
|
||||||
<input bind:value={displayName} autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Endpoint
|
|
||||||
<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}
|
{#if data.signingIdentityError}
|
||||||
<p class="section-state error">{data.signingIdentityError}</p>
|
<p class="section-state error">{data.signingIdentityError}</p>
|
||||||
|
{:else if data.signingIdentity?.identity.state === 'pending_provisioning'}
|
||||||
|
<p>This Workspace does not have an active signing identity yet.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyRuntimeId !== null}
|
||||||
|
onclick={() => void provisionSigningIdentity()}
|
||||||
|
>
|
||||||
|
{busyRuntimeId === 'provision-workspace-identity' ? 'Provisioning…' : 'Provision Workspace identity'}
|
||||||
|
</button>
|
||||||
{:else if data.signingIdentity?.public_bundle}
|
{:else if data.signingIdentity?.public_bundle}
|
||||||
<p>
|
<p>
|
||||||
Save this public bundle as <code>workspace-public-bundle.json</code> on the Runtime host.
|
Save the bundle as <code>{workspaceBundleFilename()}</code> on the Runtime host. It contains no
|
||||||
It contains no private key material.
|
private key material.
|
||||||
</p>
|
</p>
|
||||||
<pre>{workspacePublicBundle()}</pre>
|
<pre>{workspacePublicBundle()}</pre>
|
||||||
<button type="button" onclick={copyWorkspaceBundle}>Copy Workspace public bundle</button>
|
<button type="button" disabled={busyRuntimeId !== null} onclick={copyWorkspaceBundle}>
|
||||||
<pre>yoi-runtime trust-workspace add --bundle workspace-public-bundle.json</pre>
|
Copy Workspace public bundle
|
||||||
<p>
|
</button>
|
||||||
Runtime registration remains <code>configured</code> until authenticated verification is completed.
|
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
|
||||||
</p>
|
<small>
|
||||||
|
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
|
||||||
|
service. Existing Workspace trust entries are preserved.
|
||||||
|
</small>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="section-state">Loading Workspace public identity…</p>
|
<p class="section-state">Loading Workspace public identity…</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-to-workspace-heading">
|
||||||
|
<h3 id="runtime-to-workspace-heading">2. Verify the Runtime identity</h3>
|
||||||
|
<p>
|
||||||
|
On the Runtime host, run <code>yoi-runtime identity show --json</code> with the same Runtime storage
|
||||||
|
options, then paste the public bundle below.
|
||||||
|
</p>
|
||||||
|
<div class="settings-form-grid">
|
||||||
|
<label class="settings-form-wide">
|
||||||
|
Runtime public bundle
|
||||||
|
<textarea
|
||||||
|
bind:value={runtimePublicBundle}
|
||||||
|
oninput={() => {
|
||||||
|
runtimeFingerprint = null;
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
rows="5"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={runtimeBundlePlaceholder}
|
||||||
|
></textarea>
|
||||||
|
<button type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
|
||||||
|
Preview fingerprint
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{#if runtimeFingerprint}
|
||||||
|
<dl class="runtime-facts">
|
||||||
|
<div>
|
||||||
|
<dt>Runtime fingerprint</dt>
|
||||||
|
<dd><code>{runtimeFingerprint}</code></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-connection-heading">
|
||||||
|
<h3 id="runtime-connection-heading">3. Register the connection</h3>
|
||||||
|
<div class="settings-form-grid">
|
||||||
|
<label>
|
||||||
|
Display name
|
||||||
|
<input bind:value={displayName} autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Endpoint
|
||||||
|
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Registration stores this Workspace-scoped binding. After it appears in the list, run
|
||||||
|
<strong>Test</strong> to complete authenticated verification.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busyRuntimeId !== null || !runtimeFingerprint || fingerprintConfirmation.trim() !== runtimeFingerprint}
|
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint}
|
||||||
>Add Runtime</button>
|
>Register Runtime</button>
|
||||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -280,6 +333,9 @@
|
|||||||
{#if requestError}
|
{#if requestError}
|
||||||
<p class="section-state error">{requestError}</p>
|
<p class="section-state error">{requestError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if requestNotice}
|
||||||
|
<p class="section-state">{requestNotice}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.runtimesError}
|
{#if data.runtimesError}
|
||||||
<p class="section-state error">{data.runtimesError}</p>
|
<p class="section-state error">{data.runtimesError}</p>
|
||||||
|
|||||||
+93
-54
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import type {
|
import type {
|
||||||
RevokeRuntimeTrustKeyRequest,
|
RevokeRuntimeTrustKeyRequest,
|
||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
} from '$lib/generated/workspace-api';
|
} from '$lib/generated/workspace-api';
|
||||||
import {
|
import {
|
||||||
createRemoteRuntime,
|
createRemoteRuntime,
|
||||||
|
deleteRemoteRuntime,
|
||||||
previewRuntimePublicKeyFingerprint,
|
previewRuntimePublicKeyFingerprint,
|
||||||
revealRuntimeTrustKey,
|
revealRuntimeTrustKey,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
@@ -22,10 +23,10 @@
|
|||||||
let showPublicKey = $state(false);
|
let showPublicKey = $state(false);
|
||||||
let revealedPublicKey = $state<string | null>(null);
|
let revealedPublicKey = $state<string | null>(null);
|
||||||
let publicKey = $state('');
|
let publicKey = $state('');
|
||||||
let fingerprintConfirmation = $state('');
|
let deleteRuntimeConfirmation = $state('');
|
||||||
let revokeFingerprintConfirmation = $state('');
|
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
||||||
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
|
|
||||||
let fieldError = $state<string | null>(null);
|
let fieldError = $state<string | null>(null);
|
||||||
|
let deleteRuntimeError = $state<string | null>(null);
|
||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
let successMessage = $state<string | null>(null);
|
let successMessage = $state<string | null>(null);
|
||||||
let replacementFingerprint = $state<string | null>(null);
|
let replacementFingerprint = $state<string | null>(null);
|
||||||
@@ -42,10 +43,10 @@
|
|||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
revealedPublicKey = null;
|
revealedPublicKey = null;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
fingerprintConfirmation = '';
|
deleteRuntimeConfirmation = '';
|
||||||
revokeFingerprintConfirmation = '';
|
|
||||||
busyAction = null;
|
busyAction = null;
|
||||||
fieldError = null;
|
fieldError = null;
|
||||||
|
deleteRuntimeError = null;
|
||||||
requestError = null;
|
requestError = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
replacementFingerprint = null;
|
replacementFingerprint = null;
|
||||||
@@ -132,16 +133,6 @@
|
|||||||
|
|
||||||
const trust = data.runtimeDetail.trust_key;
|
const trust = data.runtimeDetail.trust_key;
|
||||||
const action = trustAction(trust.status);
|
const action = trustAction(trust.status);
|
||||||
if (action !== 'create') {
|
|
||||||
if (!trust.fingerprint) {
|
|
||||||
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
|
|
||||||
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const operation = routeFence.capture(data.runtimeId);
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
busyAction = 'save';
|
busyAction = 'save';
|
||||||
@@ -163,8 +154,6 @@
|
|||||||
});
|
});
|
||||||
if (!isCurrentRoute(operation)) return;
|
if (!isCurrentRoute(operation)) return;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
fingerprintConfirmation = '';
|
|
||||||
revokeFingerprintConfirmation = '';
|
|
||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
revealedPublicKey = null;
|
revealedPublicKey = null;
|
||||||
successMessage = action === 'create'
|
successMessage = action === 'create'
|
||||||
@@ -175,7 +164,6 @@
|
|||||||
await reloadAuthority();
|
await reloadAuthority();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCurrentRoute(operation)) return;
|
if (!isCurrentRoute(operation)) return;
|
||||||
fingerprintConfirmation = '';
|
|
||||||
if (error instanceof RuntimeTrustConflictError) {
|
if (error instanceof RuntimeTrustConflictError) {
|
||||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||||
await reloadAuthority();
|
await reloadAuthority();
|
||||||
@@ -196,11 +184,8 @@
|
|||||||
requestError = 'Only active Workspace trust can be revoked.';
|
requestError = 'Only active Workspace trust can be revoked.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (!trust.fingerprint) {
|
||||||
!trust.fingerprint ||
|
requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
|
||||||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
|
|
||||||
) {
|
|
||||||
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +204,10 @@
|
|||||||
operation.runtimeId,
|
operation.runtimeId,
|
||||||
request,
|
request,
|
||||||
trust.fingerprint,
|
trust.fingerprint,
|
||||||
revokeFingerprintConfirmation,
|
trust.fingerprint,
|
||||||
);
|
);
|
||||||
if (!isCurrentRoute(operation)) return;
|
if (!isCurrentRoute(operation)) return;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
fingerprintConfirmation = '';
|
|
||||||
revokeFingerprintConfirmation = '';
|
|
||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
revealedPublicKey = null;
|
revealedPublicKey = null;
|
||||||
successMessage = 'Workspace trust was revoked.';
|
successMessage = 'Workspace trust was revoked.';
|
||||||
@@ -242,6 +225,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteRegistration(): Promise<void> {
|
||||||
|
if (busyAction !== null || !data.runtimeDetail) return;
|
||||||
|
const runtime = data.runtimeDetail.runtime;
|
||||||
|
if (runtime.management.built_in) return;
|
||||||
|
if (deleteRuntimeConfirmation.trim() !== data.runtimeId) {
|
||||||
|
deleteRuntimeError = 'Enter the Runtime ID exactly to confirm deletion.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
busyAction = 'delete';
|
||||||
|
deleteRuntimeError = null;
|
||||||
|
try {
|
||||||
|
if (data.runtimeDetail.trust_key.status !== 'revoked') {
|
||||||
|
const trust = data.runtimeDetail.trust_key;
|
||||||
|
if (trust.revision == null || !trust.fingerprint) {
|
||||||
|
throw new Error('Runtime trust revision and fingerprint are required before deletion.');
|
||||||
|
}
|
||||||
|
await revokeRuntimeTrustKey(
|
||||||
|
data.workspaceId,
|
||||||
|
operation.runtimeId,
|
||||||
|
{ expected_revision: trust.revision },
|
||||||
|
trust.fingerprint,
|
||||||
|
trust.fingerprint,
|
||||||
|
);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
}
|
||||||
|
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
|
||||||
|
replaceState: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
deleteRuntimeError = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Runtime registration deletion failed.';
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function togglePublicKeyReveal(): Promise<void> {
|
async function togglePublicKeyReveal(): Promise<void> {
|
||||||
if (showPublicKey) {
|
if (showPublicKey) {
|
||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
@@ -397,18 +422,6 @@
|
|||||||
<p class="field-error">{replacementFingerprintError}</p>
|
<p class="field-error">{replacementFingerprintError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if currentAction !== 'create'}
|
|
||||||
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
|
|
||||||
<input
|
|
||||||
id="runtime-fingerprint-confirmation"
|
|
||||||
bind:value={fingerprintConfirmation}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
placeholder={trust.fingerprint ?? ''}
|
|
||||||
/>
|
|
||||||
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if fieldError}
|
{#if fieldError}
|
||||||
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
|
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -423,25 +436,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>Revoke Workspace trust</strong>
|
<strong>Revoke Workspace trust</strong>
|
||||||
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
|
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
|
||||||
<label>
|
|
||||||
Confirm current fingerprint
|
|
||||||
<input
|
|
||||||
bind:value={revokeFingerprintConfirmation}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
disabled={trust.status !== 'active' || busyAction !== null}
|
|
||||||
/>
|
|
||||||
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly before revocation.</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="danger"
|
class="danger"
|
||||||
disabled={
|
disabled={busyAction !== null || trust.status !== 'active'}
|
||||||
busyAction !== null ||
|
|
||||||
trust.status !== 'active' ||
|
|
||||||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
|
|
||||||
}
|
|
||||||
onclick={revokeTrust}
|
onclick={revokeTrust}
|
||||||
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
|
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,5 +479,45 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
|
||||||
|
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
|
||||||
|
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
|
||||||
|
<p>
|
||||||
|
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process,
|
||||||
|
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host.
|
||||||
|
</p>
|
||||||
|
{#if trust.status !== 'revoked'}
|
||||||
|
<p class="section-state warning">
|
||||||
|
Deletion will revoke this Workspace trust first. Stop or move active Workers before continuing.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
|
||||||
|
<input
|
||||||
|
id="runtime-delete-confirmation"
|
||||||
|
bind:value={deleteRuntimeConfirmation}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
placeholder={data.runtimeId}
|
||||||
|
/>
|
||||||
|
<small>Enter <code>{data.runtimeId}</code> exactly.</small>
|
||||||
|
{#if deleteRuntimeError}
|
||||||
|
<p class="section-state error" role="alert">{deleteRuntimeError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
|
||||||
|
onclick={deleteRegistration}
|
||||||
|
>{busyAction === 'delete'
|
||||||
|
? 'Deleting…'
|
||||||
|
: trust.status === 'revoked'
|
||||||
|
? 'Delete registration'
|
||||||
|
: 'Revoke trust and delete registration'}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -44,6 +44,48 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime registration presents the complete multi-Workspace trust sequence", async () => {
|
||||||
|
const page = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"1. Trust this Workspace on the Runtime",
|
||||||
|
"Provision Workspace identity",
|
||||||
|
"provisionWorkspaceSigningIdentity(data.workspaceId)",
|
||||||
|
"Existing Workspace trust entries are preserved.",
|
||||||
|
"--fs-root",
|
||||||
|
"--fs-runtime-dir",
|
||||||
|
"2. Verify the Runtime identity",
|
||||||
|
"yoi-runtime identity show --json",
|
||||||
|
"3. Register the connection",
|
||||||
|
"Register Runtime",
|
||||||
|
"Run Test to complete authenticated verification.",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
page.includes(token),
|
||||||
|
`Runtime registration should include ${token}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
page.includes(
|
||||||
|
"data.signingIdentity?.identity.state === 'pending_provisioning'",
|
||||||
|
) &&
|
||||||
|
page.includes("Loading Workspace public identity…"),
|
||||||
|
"pending identity must have a dedicated provisioning state before loading fallback",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!page.includes("fingerprintConfirmation") &&
|
||||||
|
!page.includes("Confirm Runtime fingerprint"),
|
||||||
|
"Runtime registration must not require retyping a fingerprint",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
|
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
|
||||||
const page = await Deno.readTextFile(
|
const page = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
@@ -111,22 +153,26 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
"Create Workspace trust",
|
"Create Workspace trust",
|
||||||
"Replace trusted key",
|
"Replace trusted key",
|
||||||
"Reactivate with this key",
|
"Reactivate with this key",
|
||||||
"Confirm current fingerprint",
|
|
||||||
"Revoke Workspace trust",
|
"Revoke Workspace trust",
|
||||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||||
|
"await revokeRuntimeTrustKey(",
|
||||||
|
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)",
|
||||||
|
"Revoke trust and delete registration",
|
||||||
|
"trust.status !== 'revoked'",
|
||||||
|
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
|
||||||
|
"Delete Runtime registration",
|
||||||
|
"Delete registration",
|
||||||
|
"This does not stop the Runtime process",
|
||||||
"RuntimeTrustConflictError",
|
"RuntimeTrustConflictError",
|
||||||
"RuntimeTrustRouteFence",
|
"RuntimeTrustRouteFence",
|
||||||
"routeFence.enter(data.runtimeId)",
|
"routeFence.enter(data.runtimeId)",
|
||||||
"showPublicKey = false",
|
"showPublicKey = false",
|
||||||
"revealedPublicKey = null",
|
"revealedPublicKey = null",
|
||||||
"publicKey = ''",
|
"publicKey = ''",
|
||||||
"fingerprintConfirmation = ''",
|
|
||||||
"revokeFingerprintConfirmation = ''",
|
|
||||||
"requestError = null",
|
"requestError = null",
|
||||||
"successMessage = null",
|
"successMessage = null",
|
||||||
"isCurrentRoute(operation)",
|
"isCurrentRoute(operation)",
|
||||||
"revealRuntimeTrustKey",
|
"revealRuntimeTrustKey",
|
||||||
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
|
|
||||||
"await reloadAuthority()",
|
"await reloadAuthority()",
|
||||||
"busyAction !== null",
|
"busyAction !== null",
|
||||||
"Workdirs",
|
"Workdirs",
|
||||||
@@ -135,6 +181,15 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
) {
|
) {
|
||||||
assert(page.includes(token), `Runtime detail should include ${token}`);
|
assert(page.includes(token), `Runtime detail should include ${token}`);
|
||||||
}
|
}
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"fingerprintConfirmation",
|
||||||
|
"revokeFingerprintConfirmation",
|
||||||
|
"Confirm current fingerprint",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(!page.includes(token), `Runtime detail must not require ${token}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
|
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare const Deno: {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createRemoteRuntime,
|
createRemoteRuntime,
|
||||||
|
deleteRemoteRuntime,
|
||||||
parseRuntimeTrustConflict,
|
parseRuntimeTrustConflict,
|
||||||
parseRuntimeTrustKeyRevealResponse,
|
parseRuntimeTrustKeyRevealResponse,
|
||||||
parseWorkspaceRuntimeDetail,
|
parseWorkspaceRuntimeDetail,
|
||||||
@@ -272,6 +273,24 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
|
|||||||
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
|
||||||
|
let requestedUrl = "";
|
||||||
|
let requestedMethod = "";
|
||||||
|
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
requestedUrl = String(input);
|
||||||
|
requestedMethod = init?.method ?? "GET";
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
await deleteRemoteRuntime("workspace a", "runtime/a", fetchImpl);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa",
|
||||||
|
`unexpected delete URL: ${requestedUrl}`,
|
||||||
|
);
|
||||||
|
assert(requestedMethod === "DELETE", "Runtime delete must use DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
|
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
|
||||||
const fence = new RuntimeTrustRouteFence();
|
const fence = new RuntimeTrustRouteFence();
|
||||||
fence.enter("runtime-a");
|
fence.enter("runtime-a");
|
||||||
|
|||||||
Reference in New Issue
Block a user