fix: harden workspace deletion recovery

This commit is contained in:
2026-09-06 08:57:54 +09:00
parent ab4fb4c1ee
commit 38627c498b
9 changed files with 814 additions and 103 deletions
@@ -583,6 +583,37 @@ export function parseRepositoryDetailResponse(
};
}
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
const WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS = 4096;
const WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES = 512;
function deletionBoundedString(
value: unknown,
path: string,
maxBytes: number,
): string {
const candidate = string(value, path);
if (new TextEncoder().encode(candidate).length > maxBytes) {
throw new Error(`${path} is too long`);
}
return candidate;
}
function deletionBoundedArray(
value: unknown,
path: string,
maxItems: number,
): unknown[] {
const candidate = array(value, path);
if (candidate.length > maxItems) {
throw new Error(`${path} has too many items`);
}
return candidate;
}
const deletionStates = new Set<WorkspaceDeletionState>([
"queued",
"running",
@@ -619,14 +650,35 @@ function deletionBlocker(
if (!deletionBlockerKinds.has(kind)) {
throw new Error(`${path}.kind is invalid`);
}
const resourceKind = optionalNullableString(
item.resource_kind,
`${path}.resource_kind`,
);
const resourceKey = optionalNullableString(
item.resource_key,
`${path}.resource_key`,
);
return {
kind,
resource_kind:
optionalNullableString(item.resource_kind, `${path}.resource_kind`) ??
null,
resource_key:
optionalNullableString(item.resource_key, `${path}.resource_key`) ?? null,
message: string(item.message, `${path}.message`),
resource_kind: resourceKind === undefined || resourceKind === null
? null
: deletionBoundedString(
resourceKind,
`${path}.resource_kind`,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES,
),
resource_key: resourceKey === undefined || resourceKey === null
? null
: deletionBoundedString(
resourceKey,
`${path}.resource_key`,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES,
),
message: deletionBoundedString(
item.message,
`${path}.message`,
WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES,
),
};
}
@@ -677,9 +729,10 @@ export function parseWorkspaceDeletionPreflightResponse(
item.display_name,
"Workspace deletion preflight.display_name",
),
expected_revision: string(
expected_revision: deletionBoundedString(
item.expected_revision,
"Workspace deletion preflight.expected_revision",
WORKSPACE_DELETION_MAX_REVISION_BYTES,
),
can_delete: boolean(
item.can_delete,
@@ -689,7 +742,11 @@ export function parseWorkspaceDeletionPreflightResponse(
item.resources,
"Workspace deletion preflight.resources",
),
blockers: array(item.blockers, "Workspace deletion preflight.blockers").map(
blockers: deletionBoundedArray(
item.blockers,
"Workspace deletion preflight.blockers",
WORKSPACE_DELETION_MAX_BLOCKERS,
).map(
(entry, index) =>
deletionBlocker(
entry,
@@ -717,9 +774,10 @@ export function parseWorkspaceDeletionOperationResponse(
"completed_at",
], "Workspace deletion operation");
return {
operation_id: string(
operation_id: deletionBoundedString(
item.operation_id,
"Workspace deletion operation.operation_id",
WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
),
workspace_id: string(
item.workspace_id,
@@ -734,16 +792,22 @@ export function parseWorkspaceDeletionOperationResponse(
item.resources,
"Workspace deletion operation.resources",
),
child_operation_ids: array(
child_operation_ids: deletionBoundedArray(
item.child_operation_ids,
"Workspace deletion operation.child_operation_ids",
WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS,
).map((entry, index) =>
string(
deletionBoundedString(
entry,
`Workspace deletion operation.child_operation_ids[${index}]`,
WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
)
),
blockers: array(item.blockers, "Workspace deletion operation.blockers").map(
blockers: deletionBoundedArray(
item.blockers,
"Workspace deletion operation.blockers",
WORKSPACE_DELETION_MAX_BLOCKERS,
).map(
(entry, index) =>
deletionBlocker(
entry,
@@ -3,34 +3,44 @@ import type {
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
} from "$lib/generated/workspace-api";
import { loadJson } from "$lib/workspace/api/http";
import {
parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse,
} from "$lib/workspace/api/workspace-model";
async function responseJson(
response: Response,
context: string,
): Promise<unknown> {
const value: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message = typeof value === "object" && value !== null &&
"error" in value && typeof value.error === "string"
? value.error
: `${context} failed (${response.status})`;
throw new Error(message);
const deletionResponsePolicy = {
maxResponseBytes: 2 * 1024 * 1024,
diagnosticLabel: "Workspace deletion",
} as const;
async function deletionJson<T>(
path: string,
init: RequestInit | undefined,
parse: (value: unknown) => T,
): Promise<T> {
const result = await loadJson(
fetch,
path,
init,
parse,
deletionResponsePolicy,
);
if (result.error !== null || result.data === null) {
throw new Error(
result.error ?? "Workspace deletion response is unavailable",
);
}
return value;
return result.data;
}
export async function preflightWorkspaceDeletion(
workspaceId: string,
): Promise<WorkspaceDeletionPreflightResponse> {
const response = await fetch(
return await deletionJson(
`/api/workspaces/${encodeURIComponent(workspaceId)}/deletion`,
);
return parseWorkspaceDeletionPreflightResponse(
await responseJson(response, "Workspace deletion preflight"),
undefined,
parseWorkspaceDeletionPreflightResponse,
);
}
@@ -38,26 +48,23 @@ export async function startWorkspaceDeletion(
workspaceId: string,
request: WorkspaceDeletionRequest,
): Promise<WorkspaceDeletionOperationResponse> {
const response = await fetch(
return await deletionJson(
`/api/workspaces/${encodeURIComponent(workspaceId)}/deletion`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
return parseWorkspaceDeletionOperationResponse(
await responseJson(response, "Workspace deletion"),
parseWorkspaceDeletionOperationResponse,
);
}
export async function getWorkspaceDeletion(
operationId: string,
): Promise<WorkspaceDeletionOperationResponse> {
const response = await fetch(
return await deletionJson(
`/api/workspace-deletions/${encodeURIComponent(operationId)}`,
);
return parseWorkspaceDeletionOperationResponse(
await responseJson(response, "Workspace deletion status"),
undefined,
parseWorkspaceDeletionOperationResponse,
);
}
@@ -22,6 +22,10 @@ export type WorkspaceWorkersState = {
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
export function disposeWorkspaceWorkersStore(workspaceId: string): void {
stores.delete(workspaceId);
}
export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWorkersState> {
const cached = stores.get(workspaceId);
if (cached) return cached;
@@ -12,6 +12,7 @@
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css';
import '$lib/workspace/styles/tickets.css';
@@ -32,7 +33,10 @@
$effect(() => {
const workspaceId = data.workspace?.workspace_id;
if (!workspaceId) return;
return () => disposeWorkspaceMultiplexer(workspaceId);
return () => {
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
};
});
</script>
@@ -3,10 +3,13 @@
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
} 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,
@@ -34,8 +37,11 @@
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionOperationId = $state('');
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
@@ -92,7 +98,8 @@
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionOperationId = crypto.randomUUID();
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
@@ -103,26 +110,76 @@
}
}
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) return;
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
let operation = await startWorkspaceDeletion(workspaceId, {
operation_id: deletionOperationId,
expected_revision: deletionPreflight.expected_revision,
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;
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') {
disposeWorkspaceMultiplexer(workspaceId);
await goto('/');
}
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
@@ -187,7 +244,7 @@
{#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 ?? 'Workspace'}?</h2>
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
@@ -221,7 +278,7 @@
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || !deletionPreflight?.can_delete || deletionConfirmation !== (deletionPreflight?.display_name ?? '')}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
@@ -169,6 +169,22 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st
}),
".state is invalid",
);
assertThrows(
() =>
parseWorkspaceDeletionOperationResponse({
...operation,
operation_id: "x".repeat(129),
}),
".operation_id is too long",
);
assertThrows(
() =>
parseWorkspaceDeletionOperationResponse({
...operation,
blockers: Array.from({ length: 1025 }, () => operation.blockers[0]),
}),
".blockers has too many items",
);
});
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
@@ -185,6 +201,10 @@ Deno.test("Workspace settings exposes owner-gated typed destructive confirmation
"startWorkspaceDeletion",
"deletionConfirmation",
"disposeWorkspaceMultiplexer(workspaceId)",
"disposeWorkspaceWorkersStore(workspaceId)",
"sessionStorage.setItem(deletionStorageKey",
"storedDeletionRequest()",
"trackDeletion(request.operation_id)",
]
) {
if (!source.includes(token)) {