feat: add workspace runtime trust key management
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = {
|
||||
export type WorkspacePermissionSummary = {
|
||||
manage_repositories: boolean;
|
||||
manage_secrets: boolean;
|
||||
manage_runtimes: boolean;
|
||||
};
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
@@ -221,6 +222,107 @@ export type RepositoryLogResponse = {
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http";
|
||||
|
||||
export type RuntimeSourceStatus = "active" | "reserved";
|
||||
|
||||
export type RuntimeIdentityAuthority =
|
||||
| "runtime_registry_projection"
|
||||
| "server_runtime_configuration";
|
||||
|
||||
export type RuntimeSourceSummary = {
|
||||
kind: RuntimeSourceKind;
|
||||
status: RuntimeSourceStatus;
|
||||
identity_authority: RuntimeIdentityAuthority;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type RuntimeSummary = {
|
||||
runtime_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
source: RuntimeSourceSummary;
|
||||
host_ids: Array<string>;
|
||||
worker_creation_available: boolean;
|
||||
os: string;
|
||||
arch: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeManagementSummary = {
|
||||
built_in: boolean;
|
||||
config_managed: boolean;
|
||||
removable: boolean;
|
||||
endpoint_configured: boolean;
|
||||
token_ref_configured: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceRuntimeResource = {
|
||||
management: RuntimeManagementSummary;
|
||||
runtime_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
source: RuntimeSourceSummary;
|
||||
host_ids: Array<string>;
|
||||
worker_creation_available: boolean;
|
||||
os: string;
|
||||
arch: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||
|
||||
export type RuntimeTrustKeyState = {
|
||||
status: RuntimeTrustKeyStatus;
|
||||
public_key?: string | null;
|
||||
fingerprint?: string | null;
|
||||
revision?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeTrustAuditAction =
|
||||
| "created"
|
||||
| "replaced"
|
||||
| "reactivated"
|
||||
| "revoked";
|
||||
|
||||
export type RuntimeTrustAuditEntry = {
|
||||
action: RuntimeTrustAuditAction;
|
||||
actor_account_id: string;
|
||||
old_fingerprint?: string | null;
|
||||
new_fingerprint?: string | null;
|
||||
revision: number;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceRuntimeDetail = {
|
||||
workspace_id: string;
|
||||
runtime: WorkspaceRuntimeResource;
|
||||
endpoint?: string | null;
|
||||
trust_key: RuntimeTrustKeyState;
|
||||
recent_audit: Array<RuntimeTrustAuditEntry>;
|
||||
};
|
||||
|
||||
export type PutRuntimeTrustKeyRequest = {
|
||||
public_key: string;
|
||||
expected_revision: number | null;
|
||||
};
|
||||
|
||||
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
|
||||
|
||||
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
|
||||
|
||||
export type RuntimeTrustConflictResponse = {
|
||||
error: RuntimeTrustConflictKind;
|
||||
message: string;
|
||||
current_revision?: number;
|
||||
current_fingerprint?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||
|
||||
export type RuntimeConnectionTestFailureKind =
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
import type {
|
||||
Diagnostic,
|
||||
PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeIdentityAuthority,
|
||||
RuntimeManagementSummary,
|
||||
RuntimeSourceKind,
|
||||
RuntimeSourceStatus,
|
||||
RuntimeSourceSummary,
|
||||
RuntimeTrustAuditAction,
|
||||
RuntimeTrustAuditEntry,
|
||||
RuntimeTrustConflictKind,
|
||||
RuntimeTrustConflictResponse,
|
||||
RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus,
|
||||
WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
} from "$lib/generated/workspace-api.ts";
|
||||
import type { ListResponse } from "$lib/workspace/sidebar/types";
|
||||
import { workspaceApiPath } from "./http.ts";
|
||||
|
||||
export type WorkspaceRuntimeList = ListResponse<WorkspaceRuntimeResource>;
|
||||
|
||||
const LIMITS = {
|
||||
runtimeItems: 200,
|
||||
auditEntries: 20,
|
||||
hostIds: 128,
|
||||
diagnostics: 64,
|
||||
idBytes: 256,
|
||||
labelBytes: 512,
|
||||
kindBytes: 128,
|
||||
statusBytes: 128,
|
||||
noteBytes: 2_048,
|
||||
endpointBytes: 4_096,
|
||||
publicKeyBytes: 16 * 1_024,
|
||||
fingerprintBytes: 512,
|
||||
timestampBytes: 128,
|
||||
diagnosticCodeBytes: 128,
|
||||
diagnosticMessageBytes: 2_048,
|
||||
conflictMessageBytes: 1_024,
|
||||
responseBytes: 512 * 1_024,
|
||||
} as const;
|
||||
|
||||
const SOURCE_KINDS = new Set<RuntimeSourceKind>([
|
||||
"embedded_worker_runtime",
|
||||
"remote_http",
|
||||
]);
|
||||
const SOURCE_STATUSES = new Set<RuntimeSourceStatus>(["active", "reserved"]);
|
||||
const IDENTITY_AUTHORITIES = new Set<RuntimeIdentityAuthority>([
|
||||
"runtime_registry_projection",
|
||||
"server_runtime_configuration",
|
||||
]);
|
||||
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
|
||||
const TRUST_STATUSES = new Set<RuntimeTrustKeyStatus>([
|
||||
"unconfigured",
|
||||
"active",
|
||||
"revoked",
|
||||
]);
|
||||
const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
|
||||
"created",
|
||||
"replaced",
|
||||
"reactivated",
|
||||
"revoked",
|
||||
]);
|
||||
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
||||
"stale_revision",
|
||||
"fingerprint_in_use",
|
||||
]);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export class RuntimeManagementValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message.slice(0, 256));
|
||||
this.name = "RuntimeManagementValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeTrustConflictError extends Error {
|
||||
readonly conflict: RuntimeTrustConflictResponse;
|
||||
|
||||
constructor(conflict: RuntimeTrustConflictResponse) {
|
||||
super(conflict.message);
|
||||
this.name = "RuntimeTrustConflictError";
|
||||
this.conflict = conflict;
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeTrustRequestError extends Error {
|
||||
readonly field: "public_key" | null;
|
||||
|
||||
constructor(message: string, field: "public_key" | null = null) {
|
||||
super(message.slice(0, 256));
|
||||
this.name = "RuntimeTrustRequestError";
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
throw new RuntimeManagementValidationError(`${path} ${message}`);
|
||||
}
|
||||
|
||||
function object(value: unknown, path: string): JsonObject {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return fail(path, "must be an object");
|
||||
}
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: JsonObject,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
path: string,
|
||||
): void {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) {
|
||||
fail(`${path}.${key}`, "is not part of the wire contract");
|
||||
}
|
||||
}
|
||||
for (const key of required) {
|
||||
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
|
||||
}
|
||||
}
|
||||
|
||||
function array(value: unknown, path: string, max: number): unknown[] {
|
||||
if (!Array.isArray(value)) return fail(path, "must be an array");
|
||||
if (value.length > max) {
|
||||
return fail(path, `must contain at most ${max} items`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
allowEmpty = false,
|
||||
): string {
|
||||
if (typeof value !== "string") return fail(path, "must be a string");
|
||||
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
|
||||
if (encoder.encode(value).byteLength > maxBytes) {
|
||||
return fail(path, `must be at most ${maxBytes} UTF-8 bytes`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") return fail(path, "must be a boolean");
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, path: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum
|
||||
) {
|
||||
return fail(path, `must be a safe integer of at least ${minimum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeRevision(value: unknown, path: string): number {
|
||||
return safeInteger(value, path, 1);
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
allowEmpty = false,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return boundedString(value, path, maxBytes, allowEmpty);
|
||||
}
|
||||
|
||||
function optionalRevision(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return safeRevision(value, path);
|
||||
}
|
||||
|
||||
function optionalNullableRevision(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return safeRevision(value, path);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, path: string): string {
|
||||
const result = boundedString(value, path, LIMITS.timestampBytes);
|
||||
if (
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||
.test(result)
|
||||
) {
|
||||
return fail(path, "must be an RFC 3339 timestamp");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalNullableTimestamp(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return timestamp(value, path);
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
path: string,
|
||||
variants: ReadonlySet<T>,
|
||||
): T {
|
||||
const result = boundedString(value, path, LIMITS.kindBytes);
|
||||
if (!variants.has(result as T)) {
|
||||
return fail(path, "contains an unknown enum value");
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown, path: string): Diagnostic {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["code", "severity", "message"], [], path);
|
||||
const severity = enumValue(
|
||||
item.severity,
|
||||
`${path}.severity`,
|
||||
DIAGNOSTIC_SEVERITIES,
|
||||
) as Diagnostic["severity"];
|
||||
return {
|
||||
code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes),
|
||||
severity,
|
||||
message: boundedString(
|
||||
item.message,
|
||||
`${path}.message`,
|
||||
LIMITS.diagnosticMessageBytes,
|
||||
true,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path);
|
||||
return {
|
||||
kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS),
|
||||
status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES),
|
||||
identity_authority: enumValue(
|
||||
item.identity_authority,
|
||||
`${path}.identity_authority`,
|
||||
IDENTITY_AUTHORITIES,
|
||||
),
|
||||
note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeManagement(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): RuntimeManagementSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"built_in",
|
||||
"config_managed",
|
||||
"removable",
|
||||
"endpoint_configured",
|
||||
"token_ref_configured",
|
||||
],
|
||||
[],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
built_in: boolean(item.built_in, `${path}.built_in`),
|
||||
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||
removable: boolean(item.removable, `${path}.removable`),
|
||||
endpoint_configured: boolean(
|
||||
item.endpoint_configured,
|
||||
`${path}.endpoint_configured`,
|
||||
),
|
||||
token_ref_configured: boolean(
|
||||
item.token_ref_configured,
|
||||
`${path}.token_ref_configured`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeResource(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): WorkspaceRuntimeResource {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"management",
|
||||
"runtime_id",
|
||||
"label",
|
||||
"kind",
|
||||
"status",
|
||||
"source",
|
||||
"host_ids",
|
||||
"worker_creation_available",
|
||||
"os",
|
||||
"arch",
|
||||
"diagnostics",
|
||||
],
|
||||
[],
|
||||
path,
|
||||
);
|
||||
const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map(
|
||||
(entry, index) =>
|
||||
boundedString(
|
||||
entry,
|
||||
`${path}.host_ids[${index}]`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
);
|
||||
if (new Set(hostIds).size !== hostIds.length) {
|
||||
fail(`${path}.host_ids`, "must not contain duplicate IDs");
|
||||
}
|
||||
return {
|
||||
management: runtimeManagement(item.management, `${path}.management`),
|
||||
runtime_id: boundedString(
|
||||
item.runtime_id,
|
||||
`${path}.runtime_id`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes),
|
||||
kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes),
|
||||
status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes),
|
||||
source: runtimeSource(item.source, `${path}.source`),
|
||||
host_ids: hostIds,
|
||||
worker_creation_available: boolean(
|
||||
item.worker_creation_available,
|
||||
`${path}.worker_creation_available`,
|
||||
),
|
||||
os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true),
|
||||
arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true),
|
||||
diagnostics: array(
|
||||
item.diagnostics,
|
||||
`${path}.diagnostics`,
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
|
||||
};
|
||||
}
|
||||
|
||||
function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["status"],
|
||||
[
|
||||
"public_key",
|
||||
"fingerprint",
|
||||
"revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"revoked_at",
|
||||
],
|
||||
path,
|
||||
);
|
||||
const result: RuntimeTrustKeyState = {
|
||||
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
|
||||
public_key: optionalNullableString(
|
||||
item.public_key,
|
||||
`${path}.public_key`,
|
||||
LIMITS.publicKeyBytes,
|
||||
),
|
||||
fingerprint: optionalNullableString(
|
||||
item.fingerprint,
|
||||
`${path}.fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
revision: optionalNullableRevision(item.revision, `${path}.revision`),
|
||||
created_at: optionalNullableTimestamp(
|
||||
item.created_at,
|
||||
`${path}.created_at`,
|
||||
),
|
||||
updated_at: optionalNullableTimestamp(
|
||||
item.updated_at,
|
||||
`${path}.updated_at`,
|
||||
),
|
||||
revoked_at: optionalNullableTimestamp(
|
||||
item.revoked_at,
|
||||
`${path}.revoked_at`,
|
||||
),
|
||||
};
|
||||
|
||||
const hasBinding = result.status !== "unconfigured";
|
||||
if (
|
||||
hasBinding &&
|
||||
(result.fingerprint == null || result.revision == null ||
|
||||
result.created_at == null || result.updated_at == null)
|
||||
) {
|
||||
fail(
|
||||
path,
|
||||
"must include fingerprint, revision, created_at, and updated_at",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!hasBinding &&
|
||||
Object.entries(result).some(([key, entry]) =>
|
||||
key !== "status" && entry != null
|
||||
)
|
||||
) {
|
||||
fail(path, "must not include binding values while unconfigured");
|
||||
}
|
||||
if (result.status === "revoked" && result.revoked_at == null) {
|
||||
fail(`${path}.revoked_at`, "is required for a revoked key");
|
||||
}
|
||||
if (result.status === "active" && result.revoked_at != null) {
|
||||
fail(`${path}.revoked_at`, "must be absent for an active key");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["action", "actor_account_id", "revision", "at"],
|
||||
["old_fingerprint", "new_fingerprint"],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS),
|
||||
actor_account_id: boundedString(
|
||||
item.actor_account_id,
|
||||
`${path}.actor_account_id`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
old_fingerprint: optionalNullableString(
|
||||
item.old_fingerprint,
|
||||
`${path}.old_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
new_fingerprint: optionalNullableString(
|
||||
item.new_fingerprint,
|
||||
`${path}.new_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
revision: safeRevision(item.revision, `${path}.revision`),
|
||||
at: timestamp(item.at, `${path}.at`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceRuntimeList(
|
||||
value: unknown,
|
||||
): WorkspaceRuntimeList {
|
||||
const response = object(value, "Runtime list response");
|
||||
exactKeys(
|
||||
response,
|
||||
["workspace_id", "limit", "items", "source", "diagnostics"],
|
||||
[],
|
||||
"Runtime list response",
|
||||
);
|
||||
const limit = safeInteger(response.limit, "Runtime list response.limit", 0);
|
||||
if (limit > LIMITS.runtimeItems) {
|
||||
fail(
|
||||
"Runtime list response.limit",
|
||||
`must not exceed ${LIMITS.runtimeItems}`,
|
||||
);
|
||||
}
|
||||
const items = array(
|
||||
response.items,
|
||||
"Runtime list response.items",
|
||||
LIMITS.runtimeItems,
|
||||
).map((entry, index) =>
|
||||
runtimeResource(entry, `Runtime list response.items[${index}]`)
|
||||
);
|
||||
if (items.length > limit) {
|
||||
fail("Runtime list response.items", "must not exceed the declared limit");
|
||||
}
|
||||
return {
|
||||
workspace_id: boundedString(
|
||||
response.workspace_id,
|
||||
"Runtime list response.workspace_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
limit,
|
||||
items,
|
||||
source: boundedString(
|
||||
response.source,
|
||||
"Runtime list response.source",
|
||||
LIMITS.kindBytes,
|
||||
),
|
||||
diagnostics: array(
|
||||
response.diagnostics,
|
||||
"Runtime list response.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime list response.diagnostics[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceRuntimeDetail(
|
||||
value: unknown,
|
||||
): WorkspaceRuntimeDetail {
|
||||
const response = object(value, "Runtime detail response");
|
||||
exactKeys(
|
||||
response,
|
||||
["workspace_id", "runtime", "trust_key", "recent_audit"],
|
||||
["endpoint"],
|
||||
"Runtime detail response",
|
||||
);
|
||||
return {
|
||||
workspace_id: boundedString(
|
||||
response.workspace_id,
|
||||
"Runtime detail response.workspace_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
runtime: runtimeResource(
|
||||
response.runtime,
|
||||
"Runtime detail response.runtime",
|
||||
),
|
||||
endpoint: optionalNullableString(
|
||||
response.endpoint,
|
||||
"Runtime detail response.endpoint",
|
||||
LIMITS.endpointBytes,
|
||||
),
|
||||
trust_key: trustKey(
|
||||
response.trust_key,
|
||||
"Runtime detail response.trust_key",
|
||||
),
|
||||
recent_audit: array(
|
||||
response.recent_audit,
|
||||
"Runtime detail response.recent_audit",
|
||||
LIMITS.auditEntries,
|
||||
).map((entry, index) =>
|
||||
auditEntry(entry, `Runtime detail response.recent_audit[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeTrustConflict(
|
||||
value: unknown,
|
||||
): RuntimeTrustConflictResponse {
|
||||
const response = object(value, "Runtime trust conflict");
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message"],
|
||||
["current_revision", "current_fingerprint"],
|
||||
"Runtime trust conflict",
|
||||
);
|
||||
return {
|
||||
error: enumValue(
|
||||
response.error,
|
||||
"Runtime trust conflict.error",
|
||||
CONFLICT_KINDS,
|
||||
),
|
||||
message: boundedString(
|
||||
response.message,
|
||||
"Runtime trust conflict.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
),
|
||||
current_revision: optionalRevision(
|
||||
response.current_revision,
|
||||
"Runtime trust conflict.current_revision",
|
||||
),
|
||||
current_fingerprint: optionalNullableString(
|
||||
response.current_fingerprint,
|
||||
"Runtime trust conflict.current_fingerprint",
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function revisionForJson(revision: number | null): number | null {
|
||||
if (revision === null) return null;
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust revision is not a safe integer",
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
const parsed = Number(contentLength);
|
||||
if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response exceeds its byte limit",
|
||||
);
|
||||
}
|
||||
}
|
||||
const text = await response.text();
|
||||
if (encoder.encode(text).byteLength > LIMITS.responseBytes) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response exceeds its byte limit",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response is not valid JSON",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestErrorFrom(
|
||||
value: unknown,
|
||||
status: number,
|
||||
): RuntimeTrustRequestError {
|
||||
try {
|
||||
const response = object(value, "Runtime trust error");
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message", "diagnostics"],
|
||||
[],
|
||||
"Runtime trust error",
|
||||
);
|
||||
const diagnostics = array(
|
||||
response.diagnostics,
|
||||
"Runtime trust error.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
||||
);
|
||||
const message = boundedString(
|
||||
response.message,
|
||||
"Runtime trust error.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
);
|
||||
const field = diagnostics.some((entry) =>
|
||||
entry.code.startsWith("runtime_public_key_")
|
||||
)
|
||||
? "public_key"
|
||||
: null;
|
||||
return new RuntimeTrustRequestError(message, field);
|
||||
} catch {
|
||||
return new RuntimeTrustRequestError(
|
||||
`Runtime trust request failed (${status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishMutation(
|
||||
response: Response,
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const payload = await readBoundedJson(response);
|
||||
if (response.status === 409) {
|
||||
try {
|
||||
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeTrustConflictError) throw error;
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust conflict response was invalid",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
let detail: WorkspaceRuntimeDetail;
|
||||
try {
|
||||
detail = parseWorkspaceRuntimeDetail(payload);
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError("Runtime trust response was invalid");
|
||||
}
|
||||
if (
|
||||
detail.workspace_id !== workspaceId ||
|
||||
detail.runtime.runtime_id !== runtimeId
|
||||
) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response did not match the selected Runtime",
|
||||
);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
export async function putRuntimeTrustKey(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
request: PutRuntimeTrustKeyRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||
),
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
public_key: request.public_key,
|
||||
expected_revision: revisionForJson(request.expected_revision),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return await finishMutation(response, workspaceId, runtimeId);
|
||||
}
|
||||
|
||||
export async function revokeRuntimeTrustKey(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
request: RevokeRuntimeTrustKeyRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_revision: revisionForJson(request.expected_revision),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return await finishMutation(response, workspaceId, runtimeId);
|
||||
}
|
||||
@@ -367,13 +367,18 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
|
||||
|
||||
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
|
||||
exactKeys(
|
||||
item,
|
||||
["manage_repositories", "manage_secrets", "manage_runtimes"],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
manage_repositories: boolean(
|
||||
item.manage_repositories,
|
||||
`${path}.manage_repositories`,
|
||||
),
|
||||
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
|
||||
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +342,196 @@
|
||||
.settings-test-result.failed {
|
||||
border-inline-start: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.runtime-detail-page {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.runtime-detail-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.runtime-detail-section h2,
|
||||
.runtime-detail-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.runtime-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||
gap: var(--space-3) var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.runtime-detail-grid div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime-detail-grid dt {
|
||||
margin-bottom: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.runtime-detail-grid dd {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions,
|
||||
.runtime-revoke-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions button,
|
||||
.runtime-revoke-row button,
|
||||
.runtime-trust-form button {
|
||||
border: 0;
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions button.secondary {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.runtime-public-key-actions button:disabled,
|
||||
.runtime-revoke-row button:disabled,
|
||||
.runtime-trust-form button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.runtime-public-key,
|
||||
.runtime-trust-form textarea,
|
||||
.runtime-trust-form input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.runtime-public-key {
|
||||
max-height: 14rem;
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-trust-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.runtime-trust-form label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-trust-form textarea,
|
||||
.runtime-trust-form input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.runtime-trust-form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.runtime-trust-form small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-trust-form .field-error,
|
||||
.runtime-detail-page .section-state.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.runtime-detail-page .section-state.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.runtime-revoke-row {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.runtime-revoke-row div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.runtime-revoke-row p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-revoke-row button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.runtime-audit-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.runtime-audit-table {
|
||||
width: 100%;
|
||||
min-width: 48rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.runtime-audit-table th,
|
||||
.runtime-audit-table td {
|
||||
padding: 0.7rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.runtime-audit-table th {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.runtime-audit-table code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.runtime-revoke-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { RuntimeConnectionTestResponse } from '$lib/generated/workspace-api';
|
||||
import type {
|
||||
RuntimeConnectionTestResponse,
|
||||
WorkspaceRuntimeResource,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -15,7 +17,7 @@
|
||||
let requestError = $state<string | null>(null);
|
||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
||||
|
||||
function runtimePlatform(runtime: Runtime): string {
|
||||
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
|
||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||
}
|
||||
|
||||
@@ -38,7 +40,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function managementLabel(runtime: Runtime): string {
|
||||
function managementLabel(runtime: WorkspaceRuntimeResource): string {
|
||||
if (runtime.management?.built_in) return 'Built-in';
|
||||
if (runtime.management?.config_managed) return 'Managed remote';
|
||||
return 'Observed';
|
||||
@@ -78,27 +80,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRuntime(runtime: Runtime): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const nextResults = { ...testResults };
|
||||
delete nextResults[runtime.runtime_id];
|
||||
testResults = nextResults;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRuntime(runtime: Runtime): Promise<void> {
|
||||
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
@@ -123,12 +105,14 @@
|
||||
<h1 id="runtimes-heading">Runtimes</h1>
|
||||
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||
</button>
|
||||
{#if data.workspace.permissions.manage_runtimes}
|
||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showAddRuntime}
|
||||
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
|
||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||
<h2>Add remote Runtime</h2>
|
||||
<div class="settings-form-grid">
|
||||
@@ -182,7 +166,11 @@
|
||||
{#each data.runtimes.items as runtime}
|
||||
<tr class:inactive={runtime.status !== 'active'}>
|
||||
<td>
|
||||
<strong>{runtime.label}</strong>
|
||||
<strong>
|
||||
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
|
||||
{runtime.label}
|
||||
</a>
|
||||
</strong>
|
||||
<small><code>{runtime.runtime_id}</code></small>
|
||||
</td>
|
||||
<td>{runtime.kind}</td>
|
||||
@@ -203,15 +191,8 @@
|
||||
onclick={() => testRuntime(runtime)}
|
||||
>Test</button>
|
||||
{/if}
|
||||
{#if runtime.management?.removable}
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => deleteRuntime(runtime)}
|
||||
>Delete</button>
|
||||
{:else}
|
||||
<span class="settings-muted-action">Not removable</span>
|
||||
{#if !runtime.management?.config_managed}
|
||||
<span class="settings-muted-action">Test unavailable</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { ListResponse, Runtime } from "$lib/workspace/sidebar/types";
|
||||
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const runtimes = await loadJson<ListResponse<Runtime>>(
|
||||
const runtimes = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/runtimes"),
|
||||
undefined,
|
||||
(value) => {
|
||||
const response = parseWorkspaceRuntimeList(value);
|
||||
if (response.workspace_id !== params.workspaceId) {
|
||||
throw new Error("Runtime list Workspace did not match the route");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type {
|
||||
PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyStatus,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import {
|
||||
putRuntimeTrustKey,
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRequestError,
|
||||
} from '$lib/workspace/api/runtime-management';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type TrustAction = 'create' | 'replace' | 'reactivate';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let revealPublicKey = $state(false);
|
||||
let publicKey = $state('');
|
||||
let fingerprintConfirmation = $state('');
|
||||
let busyAction = $state<'save' | 'revoke' | 'copy' | null>(null);
|
||||
let fieldError = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
|
||||
if (status === 'unconfigured') return 'create';
|
||||
if (status === 'revoked') return 'reactivate';
|
||||
return 'replace';
|
||||
}
|
||||
|
||||
function actionLabel(action: TrustAction): string {
|
||||
switch (action) {
|
||||
case 'create': return 'Create Workspace trust';
|
||||
case 'replace': return 'Replace trusted key';
|
||||
case 'reactivate': return 'Reactivate with this key';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
async function reloadAuthority(): Promise<void> {
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
|
||||
fieldError = null;
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
|
||||
const key = publicKey.trim();
|
||||
if (!key) {
|
||||
fieldError = 'Enter the Runtime public key.';
|
||||
return;
|
||||
}
|
||||
if (utf8Bytes(key) > 16 * 1024) {
|
||||
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
|
||||
return;
|
||||
}
|
||||
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
const action = trustAction(trust.status);
|
||||
if (action !== 'create') {
|
||||
if (!trust.fingerprint) {
|
||||
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
|
||||
return;
|
||||
}
|
||||
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
|
||||
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const request: PutRuntimeTrustKeyRequest = {
|
||||
public_key: key,
|
||||
expected_revision: trust.revision ?? null,
|
||||
};
|
||||
|
||||
busyAction = 'save';
|
||||
try {
|
||||
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
||||
publicKey = '';
|
||||
fingerprintConfirmation = '';
|
||||
revealPublicKey = false;
|
||||
successMessage = action === 'create'
|
||||
? 'Workspace trust was created.'
|
||||
: action === 'replace'
|
||||
? 'The trusted Runtime key was replaced.'
|
||||
: 'Workspace trust was reactivated.';
|
||||
await reloadAuthority();
|
||||
} catch (error) {
|
||||
fingerprintConfirmation = '';
|
||||
if (error instanceof RuntimeTrustConflictError) {
|
||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||
await reloadAuthority();
|
||||
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
|
||||
fieldError = error.message;
|
||||
} else {
|
||||
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
|
||||
}
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTrust(): Promise<void> {
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
if (trust.revision == null || trust.status !== 'active') {
|
||||
requestError = 'Only active Workspace trust can be revoked.';
|
||||
return;
|
||||
}
|
||||
|
||||
fieldError = null;
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
busyAction = 'revoke';
|
||||
const request: RevokeRuntimeTrustKeyRequest = {
|
||||
expected_revision: trust.revision,
|
||||
};
|
||||
|
||||
try {
|
||||
await revokeRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
||||
publicKey = '';
|
||||
fingerprintConfirmation = '';
|
||||
revealPublicKey = false;
|
||||
successMessage = 'Workspace trust was revoked.';
|
||||
await reloadAuthority();
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeTrustConflictError) {
|
||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||
await reloadAuthority();
|
||||
} else {
|
||||
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
|
||||
}
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicKey(): Promise<void> {
|
||||
const key = data.runtimeDetail?.trust_key.public_key;
|
||||
if (!key || busyAction !== null) return;
|
||||
busyAction = 'copy';
|
||||
requestError = null;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
successMessage = 'Public key copied.';
|
||||
} catch {
|
||||
requestError = 'The browser could not copy the public key.';
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
|
||||
<meta name="description" content="Runtime identity and Workspace trust settings" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
|
||||
<header class="page-header-row">
|
||||
<div>
|
||||
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
|
||||
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
|
||||
<p><code>{data.runtimeId}</code></p>
|
||||
</div>
|
||||
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
|
||||
Workdirs
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if data.runtimeDetailError}
|
||||
<p class="section-state error">{data.runtimeDetailError}</p>
|
||||
{:else if !data.runtimeDetail}
|
||||
<p class="section-state">Loading Runtime…</p>
|
||||
{:else}
|
||||
{@const detail = data.runtimeDetail}
|
||||
{@const runtime = detail.runtime}
|
||||
{@const trust = detail.trust_key}
|
||||
{@const currentAction = trustAction(trust.status)}
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||
<h2 id="runtime-identity-heading">Identity and binding</h2>
|
||||
<dl class="runtime-detail-grid">
|
||||
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
|
||||
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
||||
<div><dt>Binding status</dt><dd>{trust.status}</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>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
||||
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
||||
</dl>
|
||||
{#if runtime.diagnostics.length > 0}
|
||||
<ul class="settings-diagnostics-list">
|
||||
{#each runtime.diagnostics as diagnostic}
|
||||
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if data.workspace.permissions.manage_runtimes}
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
||||
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
||||
|
||||
{#if trust.public_key}
|
||||
<div class="runtime-public-key-actions">
|
||||
<button type="button" class="secondary" onclick={() => revealPublicKey = !revealPublicKey}>
|
||||
{revealPublicKey ? 'Hide public key' : 'Reveal public key'}
|
||||
</button>
|
||||
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
|
||||
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
|
||||
</button>
|
||||
</div>
|
||||
{#if revealPublicKey}
|
||||
<pre class="runtime-public-key"><code>{trust.public_key}</code></pre>
|
||||
{/if}
|
||||
{:else if trust.status !== 'unconfigured'}
|
||||
<p class="section-state">The public key was not included in this authorized response.</p>
|
||||
{/if}
|
||||
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
<label for="runtime-public-key-input">Runtime public key</label>
|
||||
<textarea
|
||||
id="runtime-public-key-input"
|
||||
bind:value={publicKey}
|
||||
rows="5"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
|
||||
aria-invalid={fieldError ? 'true' : undefined}
|
||||
placeholder="ssh-ed25519 …"
|
||||
></textarea>
|
||||
|
||||
{#if currentAction !== 'create'}
|
||||
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
|
||||
<input
|
||||
id="runtime-fingerprint-confirmation"
|
||||
bind:value={fingerprintConfirmation}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={trust.fingerprint ?? ''}
|
||||
/>
|
||||
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
|
||||
{/if}
|
||||
|
||||
{#if fieldError}
|
||||
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
|
||||
{/if}
|
||||
<div class="settings-action-row">
|
||||
<button type="submit" disabled={busyAction !== null}>
|
||||
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="runtime-revoke-row">
|
||||
<div>
|
||||
<strong>Revoke Workspace trust</strong>
|
||||
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
disabled={busyAction !== null || trust.status !== 'active'}
|
||||
onclick={revokeTrust}
|
||||
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
|
||||
</div>
|
||||
|
||||
{#if requestError}
|
||||
<p class="section-state error" role="alert">{requestError}</p>
|
||||
{/if}
|
||||
{#if successMessage}
|
||||
<p class="section-state success" role="status">{successMessage}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
|
||||
<h2 id="runtime-audit-heading">Recent trust audit</h2>
|
||||
{#if detail.recent_audit.length === 0}
|
||||
<p class="section-state">No trust changes are recorded.</p>
|
||||
{:else}
|
||||
<div class="runtime-audit-table-wrap">
|
||||
<table class="runtime-audit-table">
|
||||
<thead>
|
||||
<tr><th>Action</th><th>Revision</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each detail.recent_audit as entry}
|
||||
<tr>
|
||||
<td>{entry.action}</td>
|
||||
<td>{entry.revision.toString()}</td>
|
||||
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
|
||||
<td><code>{entry.actor_account_id}</code></td>
|
||||
<td>{formatTimestamp(entry.at)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const detail = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(params.runtimeId)}`,
|
||||
),
|
||||
undefined,
|
||||
(value) => {
|
||||
const response = parseWorkspaceRuntimeDetail(value);
|
||||
if (
|
||||
response.workspace_id !== params.workspaceId ||
|
||||
response.runtime.runtime_id !== params.runtimeId
|
||||
) {
|
||||
throw new Error("Runtime detail did not match the route");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
runtimeId: params.runtimeId,
|
||||
runtimeDetail: detail.data,
|
||||
runtimeDetailError: detail.error,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => {
|
||||
const [listLoader, detailLoader] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
assert(
|
||||
listLoader.includes("parseWorkspaceRuntimeList(value)"),
|
||||
"Runtime list loader should validate unknown JSON",
|
||||
);
|
||||
assert(
|
||||
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
|
||||
"Runtime detail loader should validate unknown JSON",
|
||||
);
|
||||
for (const source of [listLoader, detailLoader]) {
|
||||
assert(
|
||||
!source.includes("loadJson<"),
|
||||
"Runtime loaders must not cast response JSON to a handwritten DTO",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
page.includes(
|
||||
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}",
|
||||
),
|
||||
"Runtime name should link to canonical detail",
|
||||
);
|
||||
assert(
|
||||
page.includes("testRuntime(runtime)"),
|
||||
"connection Test should remain available",
|
||||
);
|
||||
assert(page.includes("Add Runtime"), "Add Runtime should remain available");
|
||||
assert(
|
||||
page.includes("data.workspace.permissions.manage_runtimes"),
|
||||
"Add Runtime should be hidden from non-owners",
|
||||
);
|
||||
assert(
|
||||
!page.includes("deleteRuntime"),
|
||||
"inline Runtime delete logic must be removed",
|
||||
);
|
||||
assert(
|
||||
!page.includes(">Delete</button>"),
|
||||
"inline Runtime delete control must be removed",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||
const reveal = page.indexOf("Reveal public key");
|
||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||
assert(
|
||||
ownerGate < reveal && ownerGate < mutation,
|
||||
"owner gate should wrap key controls",
|
||||
);
|
||||
|
||||
for (
|
||||
const token of [
|
||||
"Create Workspace trust",
|
||||
"Replace trusted key",
|
||||
"Reactivate with this key",
|
||||
"Confirm current fingerprint",
|
||||
"Revoke Workspace trust",
|
||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||
"RuntimeTrustConflictError",
|
||||
"await reloadAuthority()",
|
||||
"busyAction !== null",
|
||||
"Workdirs",
|
||||
"Recent trust audit",
|
||||
]
|
||||
) {
|
||||
assert(page.includes(token), `Runtime detail should include ${token}`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
|
||||
const [page, css] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL("../src/lib/workspace/styles/settings.css", import.meta.url),
|
||||
),
|
||||
]);
|
||||
|
||||
assert(
|
||||
!page.includes('class="card"') && !page.includes("settings-card"),
|
||||
"Runtime detail should not add card nesting",
|
||||
);
|
||||
assert(
|
||||
css.includes(".runtime-detail-section") &&
|
||||
css.includes("border-top: 1px solid var(--line)"),
|
||||
"Runtime detail hierarchy should use flat section separators",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
parseRuntimeTrustConflict,
|
||||
parseWorkspaceRuntimeDetail,
|
||||
parseWorkspaceRuntimeList,
|
||||
putRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertThrows(operation: () => unknown, expected: string): void {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes(expected)) return;
|
||||
throw new Error(
|
||||
`expected error containing ${expected}, received ${message}`,
|
||||
);
|
||||
}
|
||||
throw new Error("expected operation to throw");
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
return {
|
||||
management: {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: false,
|
||||
endpoint_configured: true,
|
||||
token_ref_configured: false,
|
||||
},
|
||||
runtime_id: "arcadia",
|
||||
label: "Arcadia",
|
||||
kind: "remote",
|
||||
status: "started",
|
||||
source: {
|
||||
kind: "remote_http",
|
||||
status: "active",
|
||||
identity_authority: "server_runtime_configuration",
|
||||
note: "Configured by Server authority",
|
||||
},
|
||||
host_ids: ["host-a"],
|
||||
worker_creation_available: true,
|
||||
os: "linux",
|
||||
arch: "x86_64",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function detail() {
|
||||
return {
|
||||
workspace_id: "workspace-a",
|
||||
runtime: runtime(),
|
||||
endpoint: "https://runtime.example.test",
|
||||
trust_key: {
|
||||
status: "active",
|
||||
public_key: "ssh-ed25519 AAAA-test",
|
||||
fingerprint: "SHA256:current",
|
||||
revision: 3,
|
||||
created_at: "2026-09-01T12:00:00Z",
|
||||
updated_at: "2026-09-01T13:00:00Z",
|
||||
revoked_at: null,
|
||||
},
|
||||
recent_audit: [{
|
||||
action: "created",
|
||||
actor_account_id: "account-a",
|
||||
old_fingerprint: null,
|
||||
new_fingerprint: "SHA256:current",
|
||||
revision: 3,
|
||||
at: "2026-09-01T13:00:00Z",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => {
|
||||
const list = parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 200,
|
||||
items: [runtime()],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
});
|
||||
assert(
|
||||
list.items[0]?.runtime_id === "arcadia",
|
||||
"Runtime ID was not preserved",
|
||||
);
|
||||
|
||||
const parsed = parseWorkspaceRuntimeDetail(detail());
|
||||
assert(
|
||||
parsed.trust_key.revision === 3,
|
||||
"revision was not preserved as a safe integer",
|
||||
);
|
||||
assert(
|
||||
parsed.recent_audit[0]?.revision === 3,
|
||||
"audit revision was not normalized",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
|
||||
"head_tree is not part",
|
||||
);
|
||||
|
||||
const futureSource = structuredClone(detail());
|
||||
futureSource.runtime.source.kind = "future_transport";
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(futureSource),
|
||||
"contains an unknown enum value",
|
||||
);
|
||||
|
||||
assertThrows(
|
||||
() =>
|
||||
parseRuntimeTrustConflict({
|
||||
error: "future_conflict",
|
||||
message: "conflict",
|
||||
current_revision: 4,
|
||||
current_fingerprint: "SHA256:new",
|
||||
}),
|
||||
"contains an unknown enum value",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => {
|
||||
const unsafeRevision = structuredClone(detail());
|
||||
unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1;
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(unsafeRevision),
|
||||
"must be a safe integer",
|
||||
);
|
||||
|
||||
const tooMuchAudit = structuredClone(detail());
|
||||
tooMuchAudit.recent_audit = Array.from(
|
||||
{ length: 21 },
|
||||
() => structuredClone(detail().recent_audit[0]),
|
||||
);
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(tooMuchAudit),
|
||||
"must contain at most 20 items",
|
||||
);
|
||||
|
||||
const tooManyItems = Array.from({ length: 201 }, () => runtime());
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 200,
|
||||
items: tooManyItems,
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}),
|
||||
"must contain at most 200 items",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => {
|
||||
const largeKey = structuredClone(detail());
|
||||
largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1);
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(largeKey),
|
||||
"must be at most 16384 UTF-8 bytes",
|
||||
);
|
||||
|
||||
const activeWithoutFingerprint = structuredClone(detail()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
(activeWithoutFingerprint.trust_key as Record<string, unknown>).fingerprint =
|
||||
null;
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(activeWithoutFingerprint),
|
||||
"must include fingerprint",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
|
||||
let sentBody: unknown = null;
|
||||
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
|
||||
sentBody = JSON.parse(String(init?.body)) as unknown;
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "stale_revision",
|
||||
message: "Runtime trust changed",
|
||||
current_revision: 4,
|
||||
current_fingerprint: "SHA256:new",
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await putRuntimeTrustKey(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 },
|
||||
fetchImpl,
|
||||
);
|
||||
throw new Error("expected mutation to reject");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof RuntimeTrustConflictError,
|
||||
"expected typed conflict",
|
||||
);
|
||||
assert(
|
||||
error.conflict.current_revision === 4,
|
||||
"authoritative revision was lost",
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
JSON.stringify(sentBody) ===
|
||||
JSON.stringify({
|
||||
public_key: "ssh-ed25519 AAAA-new",
|
||||
expected_revision: 3,
|
||||
}),
|
||||
"request should serialize the generated bigint revision as a safe JSON integer",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user