fix: harden workspace deletion recovery
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user