fix: restore remote Runtime management contracts
This commit is contained in:
@@ -377,6 +377,7 @@ function runtimeVerification(
|
||||
function runtimeBinding(
|
||||
value: unknown,
|
||||
path: string,
|
||||
requiresWorkspaceIdentity: boolean,
|
||||
): WorkspaceRuntimeBindingSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
@@ -396,6 +397,7 @@ function runtimeBinding(
|
||||
);
|
||||
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
|
||||
if (
|
||||
requiresWorkspaceIdentity &&
|
||||
state !== "revoked" &&
|
||||
(workspaceKeyId == null || workspaceKeyGeneration == null)
|
||||
) {
|
||||
@@ -416,6 +418,7 @@ function runtimeBinding(
|
||||
return fail(path, "verification must match the current binding revision");
|
||||
}
|
||||
if (
|
||||
requiresWorkspaceIdentity &&
|
||||
connectionState === "verified" &&
|
||||
(verification === undefined ||
|
||||
verification.verified_at === null ||
|
||||
@@ -457,11 +460,12 @@ function runtimeManagement(
|
||||
["binding"],
|
||||
path,
|
||||
);
|
||||
const builtIn = boolean(item.built_in, `${path}.built_in`);
|
||||
const binding = item.binding == null
|
||||
? undefined
|
||||
: runtimeBinding(item.binding, `${path}.binding`);
|
||||
: runtimeBinding(item.binding, `${path}.binding`, !builtIn);
|
||||
return {
|
||||
built_in: boolean(item.built_in, `${path}.built_in`),
|
||||
built_in: builtIn,
|
||||
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||
removable: boolean(item.removable, `${path}.removable`),
|
||||
endpoint_configured: boolean(
|
||||
|
||||
@@ -24,6 +24,10 @@ Deno.test("settings section navigation stays under the settings route", () => {
|
||||
settingsSectionHref("configuration-sources") === "/settings/configuration",
|
||||
"shared configuration editor route should stay canonical",
|
||||
);
|
||||
assert(
|
||||
settingsSectionHref("workspace-identity") === "/settings",
|
||||
"Workspace identity should use the settings root without a redundant workspace segment",
|
||||
);
|
||||
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
const href = settingsSectionHref(section.id);
|
||||
@@ -54,7 +58,9 @@ Deno.test("settings shell advertises scoped account authority", () => {
|
||||
});
|
||||
|
||||
Deno.test("Repository settings expose the canonical list and Add route", () => {
|
||||
const section = SETTINGS_SECTIONS.find((entry) => entry.id === "repositories");
|
||||
const section = SETTINGS_SECTIONS.find((entry) =>
|
||||
entry.id === "repositories"
|
||||
);
|
||||
assert(section?.status === "editable", "Repositories should be editable");
|
||||
assert(
|
||||
settingsSectionHref("repositories") === "/settings/repositories",
|
||||
|
||||
@@ -134,7 +134,7 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
case "profile-sources":
|
||||
return `${SETTINGS_ROUTE}/profiles`;
|
||||
case "workspace-identity":
|
||||
return `${SETTINGS_ROUTE}/workspace`;
|
||||
return SETTINGS_ROUTE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -519,7 +519,7 @@ export async function fetchWorkspaceMetadata(
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMetadataSettingsResponse> {
|
||||
return await parseResponse(
|
||||
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`),
|
||||
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings`),
|
||||
parseWorkspaceMetadataSettingsResponse,
|
||||
);
|
||||
}
|
||||
@@ -530,7 +530,7 @@ export async function updateWorkspaceMetadata(
|
||||
): Promise<WorkspaceMetadataMutationResponse> {
|
||||
return await parseResponse(
|
||||
await fetch(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -546,9 +546,7 @@ export async function fetchWorkspaceSigningIdentity(
|
||||
): Promise<WorkspaceSigningIdentityResponse> {
|
||||
return await parseResponse(
|
||||
await fetch(
|
||||
`/api/w/${
|
||||
encodeURIComponent(workspaceId)
|
||||
}/settings/workspace/signing-identity`,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/signing-identity`,
|
||||
),
|
||||
parseWorkspaceSigningIdentityResponse,
|
||||
);
|
||||
@@ -561,7 +559,7 @@ export async function provisionWorkspaceSigningIdentity(
|
||||
await fetch(
|
||||
`/api/w/${
|
||||
encodeURIComponent(workspaceId)
|
||||
}/settings/workspace/signing-identity/provision`,
|
||||
}/settings/signing-identity/provision`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
parseWorkspaceSigningIdentityResponse,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
import type { SettingsSectionId } from '$lib/workspace/settings/model';
|
||||
import type { SidebarSnippet } from './context';
|
||||
|
||||
let {
|
||||
@@ -17,8 +18,9 @@
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
function isActive(href: string, sectionId: SettingsSectionId): boolean {
|
||||
return currentPath === href ||
|
||||
(sectionId !== 'workspace-identity' && currentPath.startsWith(`${href}/`));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -32,10 +34,10 @@
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a
|
||||
class:active={isActive(href)}
|
||||
class:active={isActive(href, section.id)}
|
||||
class="sidebar-link"
|
||||
href={href}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
aria-current={isActive(href, section.id) ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">{section.label}</span>
|
||||
</a>
|
||||
|
||||
@@ -5,7 +5,12 @@ export function liveWorkerState(worker: {
|
||||
worker_state?: WorkerStateSnapshot | null;
|
||||
}): string {
|
||||
const state = worker.worker_state?.state;
|
||||
if (!state) return worker.state === "stopped" ? "stopped" : "unknown";
|
||||
if (!state) {
|
||||
if (worker.state === "missing" || worker.state === "stopped") {
|
||||
return worker.state;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
if (state.kind === "idle") return "idle";
|
||||
if (state.state.kind === "maintenance") return "running";
|
||||
return state.state.state === "paused" ? "paused" : "running";
|
||||
|
||||
@@ -46,6 +46,10 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro
|
||||
|
||||
const unavailable = worker('runtime-a', 'worker-2', 1);
|
||||
assertEquals(liveWorkerState(unavailable), 'unknown');
|
||||
assertEquals(
|
||||
liveWorkerState({ ...unavailable, state: 'missing' }),
|
||||
'missing',
|
||||
);
|
||||
unavailable.state = 'stopped';
|
||||
assertEquals(liveWorkerState(unavailable), 'stopped');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
Diagnostic,
|
||||
WorkspaceDeletionOperationResponse,
|
||||
WorkspaceDeletionPreflightResponse,
|
||||
WorkspaceDeletionRequest,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceSigningIdentityResponse,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
|
||||
import {
|
||||
getWorkspaceDeletion,
|
||||
preflightWorkspaceDeletion,
|
||||
startWorkspaceDeletion,
|
||||
} from '$lib/workspace/settings/workspace-deletion-api';
|
||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||
import {
|
||||
fetchWorkspaceMetadata,
|
||||
fetchWorkspaceSigningIdentity,
|
||||
provisionWorkspaceSigningIdentity,
|
||||
updateWorkspaceMetadata,
|
||||
} from '$lib/workspace/settings/profile-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
|
||||
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
||||
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
|
||||
let identityLoading = $state(true);
|
||||
let identityError = $state<string | null>(null);
|
||||
let provisioningIdentity = $state(false);
|
||||
let identityCopied = $state(false);
|
||||
let identityBundleText = $derived(
|
||||
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
|
||||
);
|
||||
let displayNameDraft = $state('');
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let message = $state<string | null>(null);
|
||||
let diagnostics = $state<Diagnostic[]>([]);
|
||||
let deletionOpen = $state(false);
|
||||
let deletionLoading = $state(false);
|
||||
let deletionSubmitting = $state(false);
|
||||
let deletionConfirmation = $state('');
|
||||
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
|
||||
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
|
||||
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
|
||||
let deletionError = $state<string | null>(null);
|
||||
function deletionStorageKey(): string {
|
||||
return `yoi:workspace-deletion:${workspaceId}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
loading = true;
|
||||
message = null;
|
||||
try {
|
||||
const response = await fetchWorkspaceMetadata(workspaceId);
|
||||
if (!cancelled) {
|
||||
workspaceMetadata = response;
|
||||
displayNameDraft = response.display_name;
|
||||
diagnostics = response.diagnostics;
|
||||
if (data.workspace?.permissions.delete_workspace) {
|
||||
try {
|
||||
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
|
||||
} finally {
|
||||
identityLoading = false;
|
||||
}
|
||||
} else {
|
||||
identityLoading = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
message = err instanceof Error ? err.message : 'workspace settings request failed';
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) loading = false;
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function submitWorkspaceName() {
|
||||
if (!workspaceMetadata) return;
|
||||
submitting = true;
|
||||
message = null;
|
||||
try {
|
||||
const response = await updateWorkspaceMetadata(workspaceId, {
|
||||
display_name: displayNameDraft,
|
||||
revision: workspaceMetadata.revision
|
||||
});
|
||||
workspaceMetadata = response.workspace;
|
||||
displayNameDraft = response.workspace.display_name;
|
||||
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
|
||||
message = 'Workspace display name updated.';
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : 'workspace update failed';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionIdentity() {
|
||||
provisioningIdentity = true;
|
||||
identityError = null;
|
||||
try {
|
||||
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
|
||||
} finally {
|
||||
provisioningIdentity = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIdentityBundle() {
|
||||
const bundle = signingIdentity?.public_bundle;
|
||||
if (!bundle) return;
|
||||
identityCopied = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
|
||||
identityCopied = true;
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeletionConfirmation() {
|
||||
deletionOpen = true;
|
||||
deletionLoading = true;
|
||||
deletionError = null;
|
||||
deletionOperation = null;
|
||||
deletionRequest = null;
|
||||
sessionStorage.removeItem(deletionStorageKey());
|
||||
deletionConfirmation = '';
|
||||
try {
|
||||
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
|
||||
} catch (err) {
|
||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
|
||||
} finally {
|
||||
deletionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trackDeletion(operationId: string) {
|
||||
let operation = await getWorkspaceDeletion(operationId);
|
||||
deletionOperation = operation;
|
||||
while (operation.state === 'queued' || operation.state === 'running') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
operation = await getWorkspaceDeletion(operation.operation_id);
|
||||
deletionOperation = operation;
|
||||
}
|
||||
if (operation.state === 'succeeded') {
|
||||
sessionStorage.removeItem(deletionStorageKey());
|
||||
disposeWorkspaceMultiplexer(workspaceId);
|
||||
disposeWorkspaceWorkersStore(workspaceId);
|
||||
await goto('/');
|
||||
}
|
||||
}
|
||||
|
||||
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
|
||||
if (typeof value !== 'object' || value === null) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
|
||||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
|
||||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
|
||||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
|
||||
) return null;
|
||||
return {
|
||||
operation_id: record.operation_id,
|
||||
expected_revision: record.expected_revision,
|
||||
confirmation: record.confirmation,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!data.workspace?.permissions.delete_workspace) return;
|
||||
const request = storedDeletionRequest();
|
||||
if (!request) return;
|
||||
deletionRequest = request;
|
||||
deletionConfirmation = request.confirmation;
|
||||
deletionOpen = true;
|
||||
deletionSubmitting = true;
|
||||
void trackDeletion(request.operation_id)
|
||||
.catch((err) => {
|
||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
|
||||
})
|
||||
.finally(() => {
|
||||
deletionSubmitting = false;
|
||||
});
|
||||
});
|
||||
|
||||
async function deleteWorkspace() {
|
||||
if (!deletionPreflight && !deletionRequest) return;
|
||||
deletionSubmitting = true;
|
||||
deletionError = null;
|
||||
try {
|
||||
const request = deletionRequest ?? {
|
||||
operation_id: crypto.randomUUID(),
|
||||
expected_revision: deletionPreflight!.expected_revision,
|
||||
confirmation: deletionConfirmation,
|
||||
};
|
||||
deletionRequest = request;
|
||||
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
|
||||
const operation = await startWorkspaceDeletion(workspaceId, request);
|
||||
deletionOperation = operation;
|
||||
await trackDeletion(operation.operation_id);
|
||||
} catch (err) {
|
||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
|
||||
} finally {
|
||||
deletionSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workspace settings · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card settings-section" aria-labelledby="workspace-settings-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">editable</p>
|
||||
<h2 id="workspace-settings-title">Workspace Identity</h2>
|
||||
</div>
|
||||
<span class="badge success">Backend scoped</span>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="status-message">Loading workspace settings…</p>
|
||||
{:else}
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={displayNameDraft} autocomplete="off" />
|
||||
</label>
|
||||
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
|
||||
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
|
||||
</form>
|
||||
|
||||
<dl class="settings-identity-list">
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if message}
|
||||
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
|
||||
{/if}
|
||||
<DiagnosticsList {diagnostics} />
|
||||
</section>
|
||||
|
||||
{#if data.workspace?.permissions.delete_workspace}
|
||||
<section class="settings-section" aria-labelledby="workspace-identity-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="workspace-identity-title">Workspace public identity</h2>
|
||||
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
|
||||
</div>
|
||||
{#if signingIdentity?.public_bundle}
|
||||
<button type="button" onclick={() => void copyIdentityBundle()}>
|
||||
{identityCopied ? 'Copied' : 'Copy bundle'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if identityError}
|
||||
<p class="status-message error">{identityError}</p>
|
||||
{/if}
|
||||
{#if identityLoading}
|
||||
<p>Loading identity…</p>
|
||||
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
|
||||
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={provisioningIdentity}
|
||||
onclick={() => void provisionIdentity()}
|
||||
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
|
||||
{:else if signingIdentity?.public_bundle}
|
||||
<dl class="metadata-list">
|
||||
<div>
|
||||
<dt>Key</dt>
|
||||
<dd><code>{signingIdentity.identity.key_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fingerprint</dt>
|
||||
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd><code>{signingIdentity.identity.revision}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label class="identity-bundle">
|
||||
<span>Public identity bundle</span>
|
||||
<textarea readonly rows="9" value={identityBundleText}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
|
||||
<div>
|
||||
<h2 id="workspace-danger-title">Danger zone</h2>
|
||||
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
|
||||
</div>
|
||||
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if deletionOpen}
|
||||
<div class="modal-backdrop" role="presentation">
|
||||
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
|
||||
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
|
||||
{#if deletionLoading}
|
||||
<p>Loading deletion impact…</p>
|
||||
{:else if deletionPreflight}
|
||||
<p>This operation cannot be undone. It will remove:</p>
|
||||
<ul>
|
||||
<li>{deletionPreflight.resources.workers} Workers</li>
|
||||
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
|
||||
<li>{deletionPreflight.resources.repositories} repositories</li>
|
||||
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
|
||||
<li>{deletionPreflight.resources.secrets} secret records</li>
|
||||
<li>{deletionPreflight.resources.artifacts} artifacts</li>
|
||||
</ul>
|
||||
{#each deletionPreflight.blockers as blocker}
|
||||
<p class="status-message error">{blocker.message}</p>
|
||||
{/each}
|
||||
<label>
|
||||
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
|
||||
<input bind:value={deletionConfirmation} autocomplete="off" />
|
||||
</label>
|
||||
{/if}
|
||||
{#if deletionOperation}
|
||||
<p class="status-message">Deletion state: {deletionOperation.state}</p>
|
||||
{#each deletionOperation.blockers as blocker}
|
||||
<p class="status-message error">{blocker.message}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
|
||||
<div class="dialog-actions">
|
||||
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
|
||||
<button
|
||||
class="danger-button"
|
||||
type="button"
|
||||
onclick={() => void deleteWorkspace()}
|
||||
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
|
||||
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
|
||||
.section-heading p { margin-block: var(--space-1) 0; }
|
||||
.metadata-list { display: grid; gap: var(--space-2); }
|
||||
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
|
||||
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
|
||||
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
|
||||
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
|
||||
.danger-zone p { max-width: 68ch; }
|
||||
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
|
||||
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
|
||||
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
|
||||
const signingIdentity = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/signing-identity"),
|
||||
workspaceApiPath(params.workspaceId, "/settings/signing-identity"),
|
||||
undefined,
|
||||
(value) => {
|
||||
const response = parseWorkspaceSigningIdentityResponse(value);
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
Diagnostic,
|
||||
WorkspaceDeletionOperationResponse,
|
||||
WorkspaceDeletionPreflightResponse,
|
||||
WorkspaceDeletionRequest,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceSigningIdentityResponse,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
|
||||
import {
|
||||
getWorkspaceDeletion,
|
||||
preflightWorkspaceDeletion,
|
||||
startWorkspaceDeletion,
|
||||
} from '$lib/workspace/settings/workspace-deletion-api';
|
||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||
import {
|
||||
fetchWorkspaceMetadata,
|
||||
fetchWorkspaceSigningIdentity,
|
||||
provisionWorkspaceSigningIdentity,
|
||||
updateWorkspaceMetadata,
|
||||
} from '$lib/workspace/settings/profile-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
|
||||
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
||||
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
|
||||
let identityLoading = $state(true);
|
||||
let identityError = $state<string | null>(null);
|
||||
let provisioningIdentity = $state(false);
|
||||
let identityCopied = $state(false);
|
||||
let identityBundleText = $derived(
|
||||
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
|
||||
);
|
||||
let displayNameDraft = $state('');
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let message = $state<string | null>(null);
|
||||
let diagnostics = $state<Diagnostic[]>([]);
|
||||
let deletionOpen = $state(false);
|
||||
let deletionLoading = $state(false);
|
||||
let deletionSubmitting = $state(false);
|
||||
let deletionConfirmation = $state('');
|
||||
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
|
||||
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
|
||||
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
|
||||
let deletionError = $state<string | null>(null);
|
||||
function deletionStorageKey(): string {
|
||||
return `yoi:workspace-deletion:${workspaceId}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
loading = true;
|
||||
message = null;
|
||||
try {
|
||||
const response = await fetchWorkspaceMetadata(workspaceId);
|
||||
if (!cancelled) {
|
||||
workspaceMetadata = response;
|
||||
displayNameDraft = response.display_name;
|
||||
diagnostics = response.diagnostics;
|
||||
if (data.workspace?.permissions.delete_workspace) {
|
||||
try {
|
||||
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
|
||||
} finally {
|
||||
identityLoading = false;
|
||||
}
|
||||
} else {
|
||||
identityLoading = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
message = err instanceof Error ? err.message : 'workspace settings request failed';
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) loading = false;
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function submitWorkspaceName() {
|
||||
if (!workspaceMetadata) return;
|
||||
submitting = true;
|
||||
message = null;
|
||||
try {
|
||||
const response = await updateWorkspaceMetadata(workspaceId, {
|
||||
display_name: displayNameDraft,
|
||||
revision: workspaceMetadata.revision
|
||||
});
|
||||
workspaceMetadata = response.workspace;
|
||||
displayNameDraft = response.workspace.display_name;
|
||||
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
|
||||
message = 'Workspace display name updated.';
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : 'workspace update failed';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionIdentity() {
|
||||
provisioningIdentity = true;
|
||||
identityError = null;
|
||||
try {
|
||||
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
|
||||
} finally {
|
||||
provisioningIdentity = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIdentityBundle() {
|
||||
const bundle = signingIdentity?.public_bundle;
|
||||
if (!bundle) return;
|
||||
identityCopied = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
|
||||
identityCopied = true;
|
||||
} catch (err) {
|
||||
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeletionConfirmation() {
|
||||
deletionOpen = true;
|
||||
deletionLoading = true;
|
||||
deletionError = null;
|
||||
deletionOperation = null;
|
||||
deletionRequest = null;
|
||||
sessionStorage.removeItem(deletionStorageKey());
|
||||
deletionConfirmation = '';
|
||||
try {
|
||||
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
|
||||
} catch (err) {
|
||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
|
||||
} finally {
|
||||
deletionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trackDeletion(operationId: string) {
|
||||
let operation = await getWorkspaceDeletion(operationId);
|
||||
deletionOperation = operation;
|
||||
while (operation.state === 'queued' || operation.state === 'running') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
operation = await getWorkspaceDeletion(operation.operation_id);
|
||||
deletionOperation = operation;
|
||||
}
|
||||
if (operation.state === 'succeeded') {
|
||||
sessionStorage.removeItem(deletionStorageKey());
|
||||
disposeWorkspaceMultiplexer(workspaceId);
|
||||
disposeWorkspaceWorkersStore(workspaceId);
|
||||
await goto('/');
|
||||
}
|
||||
}
|
||||
|
||||
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
|
||||
if (typeof value !== 'object' || value === null) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
|
||||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
|
||||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
|
||||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
|
||||
) return null;
|
||||
return {
|
||||
operation_id: record.operation_id,
|
||||
expected_revision: record.expected_revision,
|
||||
confirmation: record.confirmation,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!data.workspace?.permissions.delete_workspace) return;
|
||||
const request = storedDeletionRequest();
|
||||
if (!request) return;
|
||||
deletionRequest = request;
|
||||
deletionConfirmation = request.confirmation;
|
||||
deletionOpen = true;
|
||||
deletionSubmitting = true;
|
||||
void trackDeletion(request.operation_id)
|
||||
.catch((err) => {
|
||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
|
||||
})
|
||||
.finally(() => {
|
||||
deletionSubmitting = false;
|
||||
});
|
||||
});
|
||||
|
||||
async function deleteWorkspace() {
|
||||
if (!deletionPreflight && !deletionRequest) return;
|
||||
deletionSubmitting = true;
|
||||
deletionError = null;
|
||||
try {
|
||||
const request = deletionRequest ?? {
|
||||
operation_id: crypto.randomUUID(),
|
||||
expected_revision: deletionPreflight!.expected_revision,
|
||||
confirmation: deletionConfirmation,
|
||||
};
|
||||
deletionRequest = request;
|
||||
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
|
||||
const operation = await startWorkspaceDeletion(workspaceId, request);
|
||||
deletionOperation = operation;
|
||||
await trackDeletion(operation.operation_id);
|
||||
} catch (err) {
|
||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
|
||||
} finally {
|
||||
deletionSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workspace settings · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card settings-section" aria-labelledby="workspace-settings-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">editable</p>
|
||||
<h2 id="workspace-settings-title">Workspace Identity</h2>
|
||||
</div>
|
||||
<span class="badge success">Backend scoped</span>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="status-message">Loading workspace settings…</p>
|
||||
{:else}
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={displayNameDraft} autocomplete="off" />
|
||||
</label>
|
||||
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
|
||||
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
|
||||
</form>
|
||||
|
||||
<dl class="settings-identity-list">
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if message}
|
||||
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
|
||||
{/if}
|
||||
<DiagnosticsList {diagnostics} />
|
||||
</section>
|
||||
|
||||
{#if data.workspace?.permissions.delete_workspace}
|
||||
<section class="settings-section" aria-labelledby="workspace-identity-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="workspace-identity-title">Workspace public identity</h2>
|
||||
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
|
||||
</div>
|
||||
{#if signingIdentity?.public_bundle}
|
||||
<button type="button" onclick={() => void copyIdentityBundle()}>
|
||||
{identityCopied ? 'Copied' : 'Copy bundle'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if identityError}
|
||||
<p class="status-message error">{identityError}</p>
|
||||
{/if}
|
||||
{#if identityLoading}
|
||||
<p>Loading identity…</p>
|
||||
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
|
||||
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={provisioningIdentity}
|
||||
onclick={() => void provisionIdentity()}
|
||||
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
|
||||
{:else if signingIdentity?.public_bundle}
|
||||
<dl class="metadata-list">
|
||||
<div>
|
||||
<dt>Key</dt>
|
||||
<dd><code>{signingIdentity.identity.key_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fingerprint</dt>
|
||||
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd><code>{signingIdentity.identity.revision}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label class="identity-bundle">
|
||||
<span>Public identity bundle</span>
|
||||
<textarea readonly rows="9" value={identityBundleText}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
|
||||
<div>
|
||||
<h2 id="workspace-danger-title">Danger zone</h2>
|
||||
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
|
||||
</div>
|
||||
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if deletionOpen}
|
||||
<div class="modal-backdrop" role="presentation">
|
||||
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
|
||||
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
|
||||
{#if deletionLoading}
|
||||
<p>Loading deletion impact…</p>
|
||||
{:else if deletionPreflight}
|
||||
<p>This operation cannot be undone. It will remove:</p>
|
||||
<ul>
|
||||
<li>{deletionPreflight.resources.workers} Workers</li>
|
||||
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
|
||||
<li>{deletionPreflight.resources.repositories} repositories</li>
|
||||
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
|
||||
<li>{deletionPreflight.resources.secrets} secret records</li>
|
||||
<li>{deletionPreflight.resources.artifacts} artifacts</li>
|
||||
</ul>
|
||||
{#each deletionPreflight.blockers as blocker}
|
||||
<p class="status-message error">{blocker.message}</p>
|
||||
{/each}
|
||||
<label>
|
||||
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
|
||||
<input bind:value={deletionConfirmation} autocomplete="off" />
|
||||
</label>
|
||||
{/if}
|
||||
{#if deletionOperation}
|
||||
<p class="status-message">Deletion state: {deletionOperation.state}</p>
|
||||
{#each deletionOperation.blockers as blocker}
|
||||
<p class="status-message error">{blocker.message}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
|
||||
<div class="dialog-actions">
|
||||
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
|
||||
<button
|
||||
class="danger-button"
|
||||
type="button"
|
||||
onclick={() => void deleteWorkspace()}
|
||||
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
|
||||
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
|
||||
.section-heading p { margin-block: var(--space-1) 0; }
|
||||
.metadata-list { display: grid; gap: var(--space-2); }
|
||||
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
|
||||
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
|
||||
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
|
||||
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
|
||||
.danger-zone p { max-width: 68ch; }
|
||||
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
|
||||
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
|
||||
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
|
||||
</style>
|
||||
@@ -26,10 +26,12 @@ function assertThrows<T extends Error>(
|
||||
import {
|
||||
fetchProfileSettings,
|
||||
fetchWorkspaceMetadata,
|
||||
fetchWorkspaceSigningIdentity,
|
||||
parseProfileSettingsResponse,
|
||||
parseWorkspaceMetadataSettingsResponse,
|
||||
parseWorkspaceSigningIdentityResponse,
|
||||
ProfileApiError,
|
||||
provisionWorkspaceSigningIdentity,
|
||||
updateWorkspaceMetadata,
|
||||
} from "../src/lib/workspace/settings/profile-api.ts";
|
||||
|
||||
@@ -127,8 +129,8 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
|
||||
"workspace 1",
|
||||
);
|
||||
assertEquals(requests.map((request) => request.url), [
|
||||
"/api/w/workspace%201/settings/workspace",
|
||||
"/api/w/workspace%201/settings/workspace",
|
||||
"/api/w/workspace%201/settings",
|
||||
"/api/w/workspace%201/settings",
|
||||
]);
|
||||
assertEquals(requests[1].init?.method, "PUT");
|
||||
assertEquals(
|
||||
@@ -140,6 +142,37 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Workspace signing identity requests use flat settings routes", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({ url: String(input), init });
|
||||
return Promise.resolve(Response.json({
|
||||
identity: {
|
||||
workspace_id: "workspace 1",
|
||||
key_id: "workspace-signing-key",
|
||||
algorithm: "ed25519",
|
||||
revision: 1,
|
||||
state: "pending_provisioning",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchWorkspaceSigningIdentity("workspace 1");
|
||||
await provisionWorkspaceSigningIdentity("workspace 1");
|
||||
assertEquals(requests.map((request) => request.url), [
|
||||
"/api/w/workspace%201/settings/signing-identity",
|
||||
"/api/w/workspace%201/settings/signing-identity/provision",
|
||||
]);
|
||||
assertEquals(requests[0].init, undefined);
|
||||
assertEquals(requests[1].init?.method, "POST");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => {
|
||||
const missing = profileSettingsFixture();
|
||||
delete missing.profiles;
|
||||
|
||||
@@ -27,6 +27,11 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
|
||||
listLoader.includes("parseWorkspaceRuntimeList(value)"),
|
||||
"Runtime list loader should validate unknown JSON",
|
||||
);
|
||||
assert(
|
||||
listLoader.includes('"/settings/signing-identity"') &&
|
||||
!listLoader.includes("/settings/workspace"),
|
||||
"Runtime list loader should use the canonical Workspace signing identity route",
|
||||
);
|
||||
assert(
|
||||
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
|
||||
"Runtime detail loader should validate unknown JSON",
|
||||
|
||||
@@ -129,6 +129,33 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime list parser accepts the built-in Runtime's internal binding", () => {
|
||||
const embedded = runtime();
|
||||
embedded.runtime_id = "embedded";
|
||||
embedded.label = "Embedded Runtime";
|
||||
embedded.kind = "embedded";
|
||||
embedded.management.built_in = true;
|
||||
embedded.management.endpoint_configured = false;
|
||||
const binding = embedded.management.binding as Partial<
|
||||
typeof embedded.management.binding
|
||||
>;
|
||||
delete binding.workspace_key_id;
|
||||
delete binding.workspace_key_generation;
|
||||
delete binding.verification;
|
||||
|
||||
const list = parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 200,
|
||||
items: [embedded],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
});
|
||||
assert(
|
||||
list.items[0]?.management.binding?.connection_state === "verified",
|
||||
"built-in Runtime binding was not preserved",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
|
||||
const payload = detail();
|
||||
const binding = payload.runtime.management.binding as Partial<
|
||||
|
||||
@@ -190,7 +190,7 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st
|
||||
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/workspace/+page.svelte",
|
||||
"../src/routes/w/[workspaceId]/settings/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user