fix: fence runtime detail route state

This commit is contained in:
2026-09-06 06:11:23 +09:00
parent 5fd2ccf084
commit 668a9062b3
5 changed files with 122 additions and 9 deletions
+15 -1
View File
@@ -14684,7 +14684,7 @@ async fn workspace_runtime_resources_response(
let runtimes = api.runtime.list_runtimes(limit);
let bindings = api
.store
.list_workspace_runtime_bindings(workspace_id, false)
.list_workspace_runtime_bindings(workspace_id, true)
.await?;
let mut items = runtimes
.items
@@ -22674,6 +22674,20 @@ mod tests {
.unwrap();
assert_eq!(binding.binding_revision, 3);
assert!(binding.revoked_at.is_some());
let listed = workspace_runtime_resources_response(&api, TEST_WORKSPACE_ID)
.await
.unwrap();
let listed_runtime = listed
.items
.iter()
.find(|resource| resource.runtime.runtime_id == "runtime-a")
.expect("revoked binding must remain listed");
assert!(listed_runtime.management.config_managed);
let detail = workspace_runtime_detail(&api, TEST_WORKSPACE_ID, "runtime-a")
.await
.unwrap();
assert_eq!(detail.trust_key.status, RuntimeTrustKeyStatus::Revoked);
assert!(detail.runtime.management.config_managed);
assert!(
!api.runtime_binding_expectations
.read()
@@ -98,6 +98,34 @@ export class RuntimeTrustRequestError extends Error {
}
}
export type RuntimeTrustRouteOperation = Readonly<{
runtimeId: string;
generation: number;
}>;
export class RuntimeTrustRouteFence {
#runtimeId: string | null = null;
#generation = 0;
enter(runtimeId: string): number {
if (this.#runtimeId !== runtimeId) {
this.#runtimeId = runtimeId;
this.#generation += 1;
}
return this.#generation;
}
capture(runtimeId: string): RuntimeTrustRouteOperation {
return { runtimeId, generation: this.enter(runtimeId) };
}
isCurrent(operation: RuntimeTrustRouteOperation, runtimeId: string): boolean {
return operation.runtimeId === runtimeId &&
operation.generation === this.#generation &&
this.#runtimeId === runtimeId;
}
}
function fail(path: string, message: string): never {
throw new RuntimeManagementValidationError(`${path} ${message}`);
}
@@ -11,7 +11,9 @@
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
RuntimeTrustRequestError,
type RuntimeTrustRouteOperation,
} from '$lib/workspace/api/runtime-management';
import type { PageProps } from './$types';
@@ -30,6 +32,26 @@
let replacementFingerprint = $state<string | null>(null);
let replacementFingerprintError = $state<string | null>(null);
let fingerprintGeneration = 0;
const routeFence = new RuntimeTrustRouteFence();
let routeGeneration = 0;
$effect(() => {
const nextGeneration = routeFence.enter(data.runtimeId);
if (nextGeneration === routeGeneration) return;
routeGeneration = nextGeneration;
fingerprintGeneration += 1;
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
busyAction = null;
fieldError = null;
requestError = null;
successMessage = null;
replacementFingerprint = null;
replacementFingerprintError = null;
});
$effect(() => {
const key = publicKey.trim();
@@ -79,6 +101,10 @@
await invalidateAll();
}
function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean {
return routeFence.isCurrent(operation, data.runtimeId);
}
async function saveTrustKey(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
@@ -123,9 +149,11 @@
expected_revision: trust.revision ?? null,
};
const operation = routeFence.capture(data.runtimeId);
busyAction = 'save';
try {
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request);
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
@@ -138,6 +166,7 @@
: 'Workspace trust was reactivated.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
@@ -148,7 +177,7 @@
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
}
} finally {
busyAction = null;
if (isCurrentRoute(operation)) busyAction = null;
}
}
@@ -170,6 +199,7 @@
fieldError = null;
requestError = null;
successMessage = null;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'revoke';
const request: RevokeRuntimeTrustKeyRequest = {
expected_revision: trust.revision,
@@ -178,11 +208,12 @@
try {
await revokeRuntimeTrustKey(
data.workspaceId,
data.runtimeId,
operation.runtimeId,
request,
trust.fingerprint,
revokeFingerprintConfirmation,
);
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
@@ -191,6 +222,7 @@
successMessage = 'Workspace trust was revoked.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
@@ -198,7 +230,7 @@
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
}
} finally {
busyAction = null;
if (isCurrentRoute(operation)) busyAction = null;
}
}
@@ -209,35 +241,42 @@
return;
}
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'reveal';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId);
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
revealedPublicKey = response.public_key;
showPublicKey = true;
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
} finally {
busyAction = null;
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function copyPublicKey(): Promise<void> {
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'copy';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId);
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await navigator.clipboard.writeText(response.public_key);
if (!isCurrentRoute(operation)) return;
successMessage = 'Public key copied.';
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error
? error.message
: 'The browser could not copy the public key.';
} finally {
busyAction = null;
if (isCurrentRoute(operation)) busyAction = null;
}
}
</script>
@@ -110,6 +110,16 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"RuntimeTrustConflictError",
"RuntimeTrustRouteFence",
"routeFence.enter(data.runtimeId)",
"showPublicKey = false",
"revealedPublicKey = null",
"publicKey = ''",
"fingerprintConfirmation = ''",
"revokeFingerprintConfirmation = ''",
"requestError = null",
"successMessage = null",
"isCurrentRoute(operation)",
"revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()",
@@ -11,6 +11,7 @@ import {
putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
} from "../src/lib/workspace/api/runtime-management.ts";
function assert(condition: unknown, message: string): asserts condition {
@@ -207,6 +208,27 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
assert(requests === 0, "mismatched fingerprint sent a revoke request");
});
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
const fence = new RuntimeTrustRouteFence();
fence.enter("runtime-a");
const operation = fence.capture("runtime-a");
let renderedKey: string | null = null;
let resolveReveal!: (key: string) => void;
const delayedReveal = new Promise<string>((resolve) => {
resolveReveal = resolve;
}).then((key) => {
if (fence.isCurrent(operation, "runtime-b")) renderedKey = key;
});
fence.enter("runtime-b");
resolveReveal("runtime-a-public-key");
await delayedReveal;
assert(
renderedKey === null,
"Runtime A key rendered after navigating to Runtime B",
);
});
Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => {
const fingerprint = await previewRuntimePublicKeyFingerprint(
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",