feat: add manual Runtime trust setup UI
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
CreateRemoteRuntimeRequest,
|
||||||
PutRuntimeTrustKeyRequest,
|
PutRuntimeTrustKeyRequest,
|
||||||
RevokeRuntimeTrustKeyRequest,
|
RevokeRuntimeTrustKeyRequest,
|
||||||
RuntimeIdentityAuthority,
|
RuntimeIdentityAuthority,
|
||||||
@@ -14,6 +15,9 @@ import type {
|
|||||||
RuntimeTrustKeyRevealResponse,
|
RuntimeTrustKeyRevealResponse,
|
||||||
RuntimeTrustKeyState,
|
RuntimeTrustKeyState,
|
||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
|
WorkspaceRuntimeAuthenticationMode,
|
||||||
|
WorkspaceRuntimeBindingState,
|
||||||
|
WorkspaceRuntimeBindingSummary,
|
||||||
WorkspaceRuntimeDetail,
|
WorkspaceRuntimeDetail,
|
||||||
WorkspaceRuntimeResource,
|
WorkspaceRuntimeResource,
|
||||||
} from "$lib/generated/workspace-api.ts";
|
} from "$lib/generated/workspace-api.ts";
|
||||||
@@ -67,6 +71,15 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
|||||||
"stale_revision",
|
"stale_revision",
|
||||||
"fingerprint_in_use",
|
"fingerprint_in_use",
|
||||||
]);
|
]);
|
||||||
|
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
|
||||||
|
"configured",
|
||||||
|
"verified",
|
||||||
|
"revoked",
|
||||||
|
]);
|
||||||
|
const AUTHENTICATION_MODES = new Set<WorkspaceRuntimeAuthenticationMode>([
|
||||||
|
"legacy_server_issuer",
|
||||||
|
"workspace_identity",
|
||||||
|
]);
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
type JsonObject = Record<string, unknown>;
|
type JsonObject = Record<string, unknown>;
|
||||||
@@ -286,6 +299,54 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeBinding(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): WorkspaceRuntimeBindingSummary {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(
|
||||||
|
item,
|
||||||
|
["state", "authentication_mode", "revision"],
|
||||||
|
["workspace_key_id", "workspace_key_generation"],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
const authenticationMode = enumValue(
|
||||||
|
item.authentication_mode,
|
||||||
|
`${path}.authentication_mode`,
|
||||||
|
AUTHENTICATION_MODES,
|
||||||
|
);
|
||||||
|
const workspaceKeyId = optionalNullableString(
|
||||||
|
item.workspace_key_id,
|
||||||
|
`${path}.workspace_key_id`,
|
||||||
|
LIMITS.idBytes,
|
||||||
|
);
|
||||||
|
const workspaceKeyGeneration = optionalNullableRevision(
|
||||||
|
item.workspace_key_generation,
|
||||||
|
`${path}.workspace_key_generation`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
authenticationMode === "workspace_identity" &&
|
||||||
|
(workspaceKeyId == null || workspaceKeyGeneration == null)
|
||||||
|
) {
|
||||||
|
return fail(path, "requires Workspace signing key identity metadata");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
authenticationMode === "legacy_server_issuer" &&
|
||||||
|
(workspaceKeyId != null || workspaceKeyGeneration != null)
|
||||||
|
) {
|
||||||
|
return fail(path, "must not attach Workspace key metadata to legacy authority");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: enumValue(item.state, `${path}.state`, BINDING_STATES),
|
||||||
|
authentication_mode: authenticationMode,
|
||||||
|
revision: safeRevision(item.revision, `${path}.revision`),
|
||||||
|
...(workspaceKeyId === undefined ? {} : { workspace_key_id: workspaceKeyId }),
|
||||||
|
...(workspaceKeyGeneration === undefined
|
||||||
|
? {}
|
||||||
|
: { workspace_key_generation: workspaceKeyGeneration }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function runtimeManagement(
|
function runtimeManagement(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -300,9 +361,12 @@ function runtimeManagement(
|
|||||||
"endpoint_configured",
|
"endpoint_configured",
|
||||||
"token_ref_configured",
|
"token_ref_configured",
|
||||||
],
|
],
|
||||||
[],
|
["binding"],
|
||||||
path,
|
path,
|
||||||
);
|
);
|
||||||
|
const binding = item.binding == null
|
||||||
|
? undefined
|
||||||
|
: runtimeBinding(item.binding, `${path}.binding`);
|
||||||
return {
|
return {
|
||||||
built_in: boolean(item.built_in, `${path}.built_in`),
|
built_in: boolean(item.built_in, `${path}.built_in`),
|
||||||
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||||
@@ -315,6 +379,7 @@ function runtimeManagement(
|
|||||||
item.token_ref_configured,
|
item.token_ref_configured,
|
||||||
`${path}.token_ref_configured`,
|
`${path}.token_ref_configured`,
|
||||||
),
|
),
|
||||||
|
...(binding === undefined ? {} : { binding }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,6 +778,30 @@ async function finishMutation(
|
|||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRemoteRuntime(
|
||||||
|
workspaceId: string,
|
||||||
|
request: CreateRemoteRuntimeRequest,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<WorkspaceRuntimeResource> {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
workspaceApiPath(workspaceId, "/runtimes"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = await readBoundedJson(response);
|
||||||
|
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||||
|
const runtime = runtimeResource(payload, "Runtime create response");
|
||||||
|
if (runtime.runtime_id !== request.public_bundle.identity_id) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime create response did not match the submitted public bundle",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
export async function revealRuntimeTrustKey(
|
export async function revealRuntimeTrustKey(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type {
|
import type {
|
||||||
RuntimeConnectionTestResponse,
|
RuntimeConnectionTestResponse,
|
||||||
|
RuntimePublicIdentityBundle,
|
||||||
WorkspaceRuntimeResource,
|
WorkspaceRuntimeResource,
|
||||||
} from '$lib/generated/workspace-api';
|
} from '$lib/generated/workspace-api';
|
||||||
|
import {
|
||||||
|
createRemoteRuntime,
|
||||||
|
RuntimeTrustRequestError,
|
||||||
|
} from '$lib/workspace/api/runtime-management';
|
||||||
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const runtimeBundlePlaceholder =
|
||||||
|
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let runtimeId = $state('');
|
let runtimePublicBundle = $state('');
|
||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
let endpoint = $state('');
|
let endpoint = $state('');
|
||||||
let showAddRuntime = $state(false);
|
let showAddRuntime = $state(false);
|
||||||
@@ -46,11 +53,33 @@
|
|||||||
return 'Observed';
|
return 'Observed';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function responseError(response: Response): Promise<string> {
|
function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
|
||||||
const payload = await response.json().catch(() => null) as
|
let parsed: unknown;
|
||||||
| { message?: string; error?: string }
|
try {
|
||||||
| null;
|
parsed = JSON.parse(value);
|
||||||
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
|
} catch {
|
||||||
|
throw new Error('Runtime public bundle must be valid JSON');
|
||||||
|
}
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new Error('Runtime public bundle must be a JSON object');
|
||||||
|
}
|
||||||
|
const item = parsed as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
Object.keys(item).length !== 2 ||
|
||||||
|
typeof item.identity_id !== 'string' ||
|
||||||
|
item.identity_id.length === 0 ||
|
||||||
|
typeof item.public_key !== 'string' ||
|
||||||
|
item.public_key.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('Runtime public bundle must contain only identity_id and public_key');
|
||||||
|
}
|
||||||
|
return { identity_id: item.identity_id, public_key: item.public_key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspacePublicBundle(): string {
|
||||||
|
return data.signingIdentity?.public_bundle
|
||||||
|
? JSON.stringify(data.signingIdentity.public_bundle, null, 2)
|
||||||
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRuntime(event: SubmitEvent): Promise<void> {
|
async function addRuntime(event: SubmitEvent): Promise<void> {
|
||||||
@@ -58,23 +87,22 @@
|
|||||||
requestError = null;
|
requestError = null;
|
||||||
busyRuntimeId = 'create';
|
busyRuntimeId = 'create';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), {
|
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
|
||||||
method: 'POST',
|
await createRemoteRuntime(data.workspaceId, {
|
||||||
headers: { 'content-type': 'application/json' },
|
public_bundle: publicBundle,
|
||||||
body: JSON.stringify({
|
|
||||||
runtime_id: runtimeId,
|
|
||||||
display_name: displayName || null,
|
display_name: displayName || null,
|
||||||
endpoint,
|
endpoint,
|
||||||
}),
|
expected_revision: null,
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await responseError(response));
|
runtimePublicBundle = '';
|
||||||
runtimeId = '';
|
|
||||||
displayName = '';
|
displayName = '';
|
||||||
endpoint = '';
|
endpoint = '';
|
||||||
showAddRuntime = false;
|
showAddRuntime = false;
|
||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
requestError = error instanceof Error ? error.message : String(error);
|
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
} finally {
|
} finally {
|
||||||
busyRuntimeId = null;
|
busyRuntimeId = null;
|
||||||
}
|
}
|
||||||
@@ -116,9 +144,16 @@
|
|||||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||||
<h2>Add remote Runtime</h2>
|
<h2>Add remote Runtime</h2>
|
||||||
<div class="settings-form-grid">
|
<div class="settings-form-grid">
|
||||||
<label>
|
<label class="settings-form-wide">
|
||||||
Runtime ID
|
Runtime public bundle
|
||||||
<input bind:value={runtimeId} required autocomplete="off" />
|
<small>Run <code>yoi-runtime identity show --json</code> on the Runtime host and paste the result.</small>
|
||||||
|
<textarea
|
||||||
|
bind:value={runtimePublicBundle}
|
||||||
|
required
|
||||||
|
rows="5"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={runtimeBundlePlaceholder}
|
||||||
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Display name
|
Display name
|
||||||
@@ -129,6 +164,24 @@
|
|||||||
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
|
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-trust-heading">
|
||||||
|
<h3 id="runtime-trust-heading">Trust this Workspace on the Runtime</h3>
|
||||||
|
{#if data.signingIdentityError}
|
||||||
|
<p class="section-state error">{data.signingIdentityError}</p>
|
||||||
|
{:else if data.signingIdentity?.public_bundle}
|
||||||
|
<p>
|
||||||
|
Save this public bundle as <code>workspace-public-bundle.json</code> on the Runtime host.
|
||||||
|
It contains no private key material.
|
||||||
|
</p>
|
||||||
|
<pre>{workspacePublicBundle()}</pre>
|
||||||
|
<pre>yoi-runtime trust-workspace add --bundle workspace-public-bundle.json</pre>
|
||||||
|
<p>
|
||||||
|
Runtime registration remains <code>configured</code> until authenticated verification is completed.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="section-state">Loading Workspace public identity…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
|
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
|
||||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||||
@@ -174,7 +227,9 @@
|
|||||||
<small><code>{runtime.runtime_id}</code></small>
|
<small><code>{runtime.runtime_id}</code></small>
|
||||||
</td>
|
</td>
|
||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
<td>{runtime.status}</td>
|
<td>
|
||||||
|
{runtime.management?.binding?.state ?? runtime.status}
|
||||||
|
</td>
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{managementLabel(runtime)}</td>
|
<td>{managementLabel(runtime)}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -184,14 +239,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
{#if runtime.management?.config_managed}
|
{#if runtime.management?.config_managed && runtime.management.binding?.state === 'verified'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyRuntimeId !== null}
|
disabled={busyRuntimeId !== null}
|
||||||
onclick={() => testRuntime(runtime)}
|
onclick={() => testRuntime(runtime)}
|
||||||
>Test</button>
|
>Test</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !runtime.management?.config_managed}
|
{#if runtime.management?.binding?.state === 'configured'}
|
||||||
|
<span class="settings-muted-action">Verification required</span>
|
||||||
|
{:else if !runtime.management?.config_managed}
|
||||||
<span class="settings-muted-action">Test unavailable</span>
|
<span class="settings-muted-action">Test unavailable</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||||
|
import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
@@ -16,9 +17,24 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const signingIdentity = await loadJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/signing-identity"),
|
||||||
|
undefined,
|
||||||
|
(value) => {
|
||||||
|
const response = parseWorkspaceSigningIdentityResponse(value);
|
||||||
|
if (response.identity.workspace_id !== params.workspaceId) {
|
||||||
|
throw new Error("Workspace signing identity did not match the route");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
runtimes: runtimes.data,
|
runtimes: runtimes.data,
|
||||||
runtimesError: runtimes.error,
|
runtimesError: runtimes.error,
|
||||||
|
signingIdentity: signingIdentity.data,
|
||||||
|
signingIdentityError: signingIdentity.error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -315,7 +315,10 @@
|
|||||||
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||||
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||||
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
||||||
<div><dt>Binding status</dt><dd>{trust.status}</dd></div>
|
<div><dt>Relationship state</dt><dd>{runtime.management.binding?.state ?? 'Not configured'}</dd></div>
|
||||||
|
<div><dt>Authentication mode</dt><dd>{runtime.management.binding?.authentication_mode ?? '—'}</dd></div>
|
||||||
|
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
|
||||||
|
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
|
||||||
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||||
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
||||||
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ function runtime() {
|
|||||||
removable: false,
|
removable: false,
|
||||||
endpoint_configured: true,
|
endpoint_configured: true,
|
||||||
token_ref_configured: false,
|
token_ref_configured: false,
|
||||||
|
binding: {
|
||||||
|
state: "verified",
|
||||||
|
authentication_mode: "workspace_identity",
|
||||||
|
revision: 3,
|
||||||
|
workspace_key_id: "WK-1",
|
||||||
|
workspace_key_generation: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
runtime_id: "arcadia",
|
runtime_id: "arcadia",
|
||||||
label: "Arcadia",
|
label: "Arcadia",
|
||||||
@@ -95,6 +102,11 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
|
|||||||
"Runtime ID was not preserved",
|
"Runtime ID was not preserved",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
list.items[0]?.management.binding?.state === "verified",
|
||||||
|
"binding state was not preserved",
|
||||||
|
);
|
||||||
|
|
||||||
const parsed = parseWorkspaceRuntimeDetail(detail());
|
const parsed = parseWorkspaceRuntimeDetail(detail());
|
||||||
assert(
|
assert(
|
||||||
parsed.trust_key.revision === 3,
|
parsed.trust_key.revision === 3,
|
||||||
@@ -106,6 +118,20 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
|
||||||
|
const payload = detail();
|
||||||
|
const binding = payload.runtime.management.binding as Partial<
|
||||||
|
typeof payload.runtime.management.binding
|
||||||
|
>;
|
||||||
|
delete binding.workspace_key_id;
|
||||||
|
delete binding.workspace_key_generation;
|
||||||
|
binding.state = "configured";
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail(payload),
|
||||||
|
"requires Workspace signing key identity metadata",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
|
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user