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 runtimes = api.runtime.list_runtimes(limit);
|
||||||
let bindings = api
|
let bindings = api
|
||||||
.store
|
.store
|
||||||
.list_workspace_runtime_bindings(workspace_id, false)
|
.list_workspace_runtime_bindings(workspace_id, true)
|
||||||
.await?;
|
.await?;
|
||||||
let mut items = runtimes
|
let mut items = runtimes
|
||||||
.items
|
.items
|
||||||
@@ -22674,6 +22674,20 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(binding.binding_revision, 3);
|
assert_eq!(binding.binding_revision, 3);
|
||||||
assert!(binding.revoked_at.is_some());
|
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!(
|
assert!(
|
||||||
!api.runtime_binding_expectations
|
!api.runtime_binding_expectations
|
||||||
.read()
|
.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 {
|
function fail(path: string, message: string): never {
|
||||||
throw new RuntimeManagementValidationError(`${path} ${message}`);
|
throw new RuntimeManagementValidationError(`${path} ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-8
@@ -11,7 +11,9 @@
|
|||||||
revealRuntimeTrustKey,
|
revealRuntimeTrustKey,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
|
RuntimeTrustRouteFence,
|
||||||
RuntimeTrustRequestError,
|
RuntimeTrustRequestError,
|
||||||
|
type RuntimeTrustRouteOperation,
|
||||||
} from '$lib/workspace/api/runtime-management';
|
} from '$lib/workspace/api/runtime-management';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -30,6 +32,26 @@
|
|||||||
let replacementFingerprint = $state<string | null>(null);
|
let replacementFingerprint = $state<string | null>(null);
|
||||||
let replacementFingerprintError = $state<string | null>(null);
|
let replacementFingerprintError = $state<string | null>(null);
|
||||||
let fingerprintGeneration = 0;
|
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(() => {
|
$effect(() => {
|
||||||
const key = publicKey.trim();
|
const key = publicKey.trim();
|
||||||
@@ -79,6 +101,10 @@
|
|||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean {
|
||||||
|
return routeFence.isCurrent(operation, data.runtimeId);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busyAction !== null || !data.runtimeDetail) return;
|
if (busyAction !== null || !data.runtimeDetail) return;
|
||||||
@@ -123,9 +149,11 @@
|
|||||||
expected_revision: trust.revision ?? null,
|
expected_revision: trust.revision ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
busyAction = 'save';
|
busyAction = 'save';
|
||||||
try {
|
try {
|
||||||
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
fingerprintConfirmation = '';
|
fingerprintConfirmation = '';
|
||||||
revokeFingerprintConfirmation = '';
|
revokeFingerprintConfirmation = '';
|
||||||
@@ -138,6 +166,7 @@
|
|||||||
: 'Workspace trust was reactivated.';
|
: 'Workspace trust was reactivated.';
|
||||||
await reloadAuthority();
|
await reloadAuthority();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
fingerprintConfirmation = '';
|
fingerprintConfirmation = '';
|
||||||
if (error instanceof RuntimeTrustConflictError) {
|
if (error instanceof RuntimeTrustConflictError) {
|
||||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||||
@@ -148,7 +177,7 @@
|
|||||||
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
|
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busyAction = null;
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +199,7 @@
|
|||||||
fieldError = null;
|
fieldError = null;
|
||||||
requestError = null;
|
requestError = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
busyAction = 'revoke';
|
busyAction = 'revoke';
|
||||||
const request: RevokeRuntimeTrustKeyRequest = {
|
const request: RevokeRuntimeTrustKeyRequest = {
|
||||||
expected_revision: trust.revision,
|
expected_revision: trust.revision,
|
||||||
@@ -178,11 +208,12 @@
|
|||||||
try {
|
try {
|
||||||
await revokeRuntimeTrustKey(
|
await revokeRuntimeTrustKey(
|
||||||
data.workspaceId,
|
data.workspaceId,
|
||||||
data.runtimeId,
|
operation.runtimeId,
|
||||||
request,
|
request,
|
||||||
trust.fingerprint,
|
trust.fingerprint,
|
||||||
revokeFingerprintConfirmation,
|
revokeFingerprintConfirmation,
|
||||||
);
|
);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
fingerprintConfirmation = '';
|
fingerprintConfirmation = '';
|
||||||
revokeFingerprintConfirmation = '';
|
revokeFingerprintConfirmation = '';
|
||||||
@@ -191,6 +222,7 @@
|
|||||||
successMessage = 'Workspace trust was revoked.';
|
successMessage = 'Workspace trust was revoked.';
|
||||||
await reloadAuthority();
|
await reloadAuthority();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
if (error instanceof RuntimeTrustConflictError) {
|
if (error instanceof RuntimeTrustConflictError) {
|
||||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||||
await reloadAuthority();
|
await reloadAuthority();
|
||||||
@@ -198,7 +230,7 @@
|
|||||||
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
|
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busyAction = null;
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,35 +241,42 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (busyAction !== null) return;
|
if (busyAction !== null) return;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
busyAction = 'reveal';
|
busyAction = 'reveal';
|
||||||
requestError = null;
|
requestError = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
try {
|
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;
|
revealedPublicKey = response.public_key;
|
||||||
showPublicKey = true;
|
showPublicKey = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
|
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
|
||||||
} finally {
|
} finally {
|
||||||
busyAction = null;
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyPublicKey(): Promise<void> {
|
async function copyPublicKey(): Promise<void> {
|
||||||
if (busyAction !== null) return;
|
if (busyAction !== null) return;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
busyAction = 'copy';
|
busyAction = 'copy';
|
||||||
requestError = null;
|
requestError = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
try {
|
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);
|
await navigator.clipboard.writeText(response.public_key);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
successMessage = 'Public key copied.';
|
successMessage = 'Public key copied.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
requestError = error instanceof Error
|
requestError = error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'The browser could not copy the public key.';
|
: 'The browser could not copy the public key.';
|
||||||
} finally {
|
} finally {
|
||||||
busyAction = null;
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
"Revoke Workspace trust",
|
"Revoke Workspace trust",
|
||||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||||
"RuntimeTrustConflictError",
|
"RuntimeTrustConflictError",
|
||||||
|
"RuntimeTrustRouteFence",
|
||||||
|
"routeFence.enter(data.runtimeId)",
|
||||||
|
"showPublicKey = false",
|
||||||
|
"revealedPublicKey = null",
|
||||||
|
"publicKey = ''",
|
||||||
|
"fingerprintConfirmation = ''",
|
||||||
|
"revokeFingerprintConfirmation = ''",
|
||||||
|
"requestError = null",
|
||||||
|
"successMessage = null",
|
||||||
|
"isCurrentRoute(operation)",
|
||||||
"revealRuntimeTrustKey",
|
"revealRuntimeTrustKey",
|
||||||
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
|
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
|
||||||
"await reloadAuthority()",
|
"await reloadAuthority()",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
putRuntimeTrustKey,
|
putRuntimeTrustKey,
|
||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
|
RuntimeTrustRouteFence,
|
||||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
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");
|
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 () => {
|
Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => {
|
||||||
const fingerprint = await previewRuntimePublicKeyFingerprint(
|
const fingerprint = await previewRuntimePublicKeyFingerprint(
|
||||||
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
|||||||
Reference in New Issue
Block a user