fix: fence runtime detail route state
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
+47
-8
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user