feat: add workspace runtime trust key management
This commit is contained in:
@@ -12,8 +12,10 @@ use workspace_api::{
|
|||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
|
||||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
RevokeRuntimeTrustKeyRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
|
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||||
|
WorkspaceRuntimeResource,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
@@ -241,6 +243,43 @@ impl BackendWorkspaceProductClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_runtimes(
|
||||||
|
&self,
|
||||||
|
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
|
||||||
|
self.get_json("/runtimes")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_detail(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||||
|
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_runtime_trust_key(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: &PutRuntimeTrustKeyRequest,
|
||||||
|
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||||
|
self.send_json(
|
||||||
|
Method::PUT,
|
||||||
|
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_runtime_trust_key(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: &RevokeRuntimeTrustKeyRequest,
|
||||||
|
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||||
|
self.send_json(
|
||||||
|
Method::DELETE,
|
||||||
|
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
||||||
self.get_json("/memory")
|
self.get_json("/memory")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ pub enum WorkspaceAuthConfig {
|
|||||||
pub struct WorkspacePermissionSummary {
|
pub struct WorkspacePermissionSummary {
|
||||||
pub manage_repositories: bool,
|
pub manage_repositories: bool,
|
||||||
pub manage_secrets: bool,
|
pub manage_secrets: bool,
|
||||||
|
pub manage_runtimes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1135,6 +1136,7 @@ pub struct ObjectiveLinkTicketRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeSourceKind {
|
pub enum RuntimeSourceKind {
|
||||||
EmbeddedWorkerRuntime,
|
EmbeddedWorkerRuntime,
|
||||||
@@ -1142,6 +1144,7 @@ pub enum RuntimeSourceKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeSourceStatus {
|
pub enum RuntimeSourceStatus {
|
||||||
Active,
|
Active,
|
||||||
@@ -1149,6 +1152,7 @@ pub enum RuntimeSourceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeIdentityAuthority {
|
pub enum RuntimeIdentityAuthority {
|
||||||
RuntimeRegistryProjection,
|
RuntimeRegistryProjection,
|
||||||
@@ -1156,6 +1160,8 @@ pub enum RuntimeIdentityAuthority {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeSourceSummary {
|
pub struct RuntimeSourceSummary {
|
||||||
pub kind: RuntimeSourceKind,
|
pub kind: RuntimeSourceKind,
|
||||||
pub status: RuntimeSourceStatus,
|
pub status: RuntimeSourceStatus,
|
||||||
@@ -1164,6 +1170,7 @@ pub struct RuntimeSourceSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -1180,6 +1187,8 @@ pub struct RuntimeSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeManagementSummary {
|
pub struct RuntimeManagementSummary {
|
||||||
pub built_in: bool,
|
pub built_in: bool,
|
||||||
pub config_managed: bool,
|
pub config_managed: bool,
|
||||||
@@ -1189,12 +1198,119 @@ pub struct RuntimeManagementSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkspaceRuntimeResource {
|
pub struct WorkspaceRuntimeResource {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub runtime: RuntimeSummary,
|
pub runtime: RuntimeSummary,
|
||||||
pub management: RuntimeManagementSummary,
|
pub management: RuntimeManagementSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeTrustKeyStatus {
|
||||||
|
Unconfigured,
|
||||||
|
Active,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustKeyState {
|
||||||
|
pub status: RuntimeTrustKeyStatus,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeTrustAuditAction {
|
||||||
|
Created,
|
||||||
|
Replaced,
|
||||||
|
Reactivated,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustAuditEntry {
|
||||||
|
pub action: RuntimeTrustAuditAction,
|
||||||
|
pub actor_account_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub old_fingerprint: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub new_fingerprint: Option<String>,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub revision: u64,
|
||||||
|
pub at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceRuntimeDetail {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime: WorkspaceRuntimeResource,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub trust_key: RuntimeTrustKeyState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recent_audit: Vec<RuntimeTrustAuditEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PutRuntimeTrustKeyRequest {
|
||||||
|
pub public_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RevokeRuntimeTrustKeyRequest {
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub expected_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeTrustConflictKind {
|
||||||
|
StaleRevision,
|
||||||
|
FingerprintInUse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustConflictResponse {
|
||||||
|
pub error: RuntimeTrustConflictKind,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub current_revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_fingerprint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateRemoteRuntimeRequest {
|
pub struct CreateRemoteRuntimeRequest {
|
||||||
@@ -2394,6 +2510,22 @@ pub fn catalog_typescript() -> String {
|
|||||||
RepositoryListResponse::decl(&config),
|
RepositoryListResponse::decl(&config),
|
||||||
RepositoryDetailResponse::decl(&config),
|
RepositoryDetailResponse::decl(&config),
|
||||||
RepositoryLogResponse::decl(&config),
|
RepositoryLogResponse::decl(&config),
|
||||||
|
RuntimeSourceKind::decl(&config),
|
||||||
|
RuntimeSourceStatus::decl(&config),
|
||||||
|
RuntimeIdentityAuthority::decl(&config),
|
||||||
|
RuntimeSourceSummary::decl(&config),
|
||||||
|
RuntimeSummary::decl(&config),
|
||||||
|
RuntimeManagementSummary::decl(&config),
|
||||||
|
WorkspaceRuntimeResource::decl(&config),
|
||||||
|
RuntimeTrustKeyStatus::decl(&config),
|
||||||
|
RuntimeTrustKeyState::decl(&config),
|
||||||
|
RuntimeTrustAuditAction::decl(&config),
|
||||||
|
RuntimeTrustAuditEntry::decl(&config),
|
||||||
|
WorkspaceRuntimeDetail::decl(&config),
|
||||||
|
PutRuntimeTrustKeyRequest::decl(&config),
|
||||||
|
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||||
|
RuntimeTrustConflictKind::decl(&config),
|
||||||
|
RuntimeTrustConflictResponse::decl(&config),
|
||||||
RuntimeConnectionTestStatus::decl(&config),
|
RuntimeConnectionTestStatus::decl(&config),
|
||||||
RuntimeConnectionTestFailureKind::decl(&config),
|
RuntimeConnectionTestFailureKind::decl(&config),
|
||||||
RuntimeConnectionTestResponse::decl(&config),
|
RuntimeConnectionTestResponse::decl(&config),
|
||||||
@@ -3022,7 +3154,8 @@ mod tests {
|
|||||||
}},
|
}},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"manage_repositories": true,
|
"manage_repositories": true,
|
||||||
"manage_secrets": true
|
"manage_secrets": true,
|
||||||
|
"manage_runtimes": true
|
||||||
},
|
},
|
||||||
"extension_points": {
|
"extension_points": {
|
||||||
"store": "sqlite",
|
"store": "sqlite",
|
||||||
@@ -3086,6 +3219,75 @@ mod tests {
|
|||||||
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_detail_and_trust_mutations_are_closed_and_typed() {
|
||||||
|
let detail = serde_json::json!({
|
||||||
|
"workspace_id": "workspace-test",
|
||||||
|
"runtime": {
|
||||||
|
"runtime_id": "runtime-test",
|
||||||
|
"label": "Runtime Test",
|
||||||
|
"kind": "remote_http",
|
||||||
|
"status": "active",
|
||||||
|
"source": {
|
||||||
|
"kind": "remote_http",
|
||||||
|
"status": "active",
|
||||||
|
"identity_authority": "runtime_registry_projection",
|
||||||
|
"note": "active"
|
||||||
|
},
|
||||||
|
"host_ids": [],
|
||||||
|
"worker_creation_available": true,
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"diagnostics": [],
|
||||||
|
"management": {
|
||||||
|
"built_in": false,
|
||||||
|
"config_managed": true,
|
||||||
|
"removable": true,
|
||||||
|
"endpoint_configured": true,
|
||||||
|
"token_ref_configured": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"endpoint": "https://runtime.example",
|
||||||
|
"trust_key": {
|
||||||
|
"status": "active",
|
||||||
|
"public_key": "ssh-ed25519 AAAA runtime-test",
|
||||||
|
"fingerprint": "SHA256:test",
|
||||||
|
"revision": 2,
|
||||||
|
"created_at": "2026-09-01T12:00:00Z",
|
||||||
|
"updated_at": "2026-09-01T13:00:00Z"
|
||||||
|
},
|
||||||
|
"recent_audit": [{
|
||||||
|
"action": "replaced",
|
||||||
|
"actor_account_id": "account-owner",
|
||||||
|
"old_fingerprint": "SHA256:old",
|
||||||
|
"new_fingerprint": "SHA256:test",
|
||||||
|
"revision": 2,
|
||||||
|
"at": "2026-09-01T13:00:00Z"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(parsed).unwrap(), detail);
|
||||||
|
|
||||||
|
let mut unknown = detail;
|
||||||
|
unknown["trust_key"]["private_key"] = serde_json::json!("forbidden");
|
||||||
|
assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).is_err());
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
|
||||||
|
"public_key": "key",
|
||||||
|
"expected_revision": 1,
|
||||||
|
"replace": true
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
||||||
|
"expected_revision": 1,
|
||||||
|
"delete_runtime": true
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_connection_test_response_is_closed_and_typed() {
|
fn runtime_connection_test_response_is_closed_and_typed() {
|
||||||
let compatible = serde_json::json!({
|
let compatible = serde_json::json!({
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ CREATE TABLE workspace_runtime_bindings (
|
|||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
public_key_fingerprint TEXT NOT NULL,
|
public_key_fingerprint TEXT NOT NULL,
|
||||||
|
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
revoked_at TEXT,
|
revoked_at TEXT,
|
||||||
@@ -447,6 +448,22 @@ CREATE TABLE workspace_runtime_bindings (
|
|||||||
UNIQUE (workspace_id, public_key_fingerprint),
|
UNIQUE (workspace_id, public_key_fingerprint),
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
CREATE TABLE workspace_runtime_binding_audit (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
actor_account_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
|
||||||
|
old_fingerprint TEXT,
|
||||||
|
new_fingerprint TEXT,
|
||||||
|
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
|
||||||
|
FOREIGN KEY(workspace_id, runtime_id)
|
||||||
|
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workspace_runtime_binding_audit_recent
|
||||||
|
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
|
||||||
CREATE TABLE typed_ticket_artifacts (
|
CREATE TABLE typed_ticket_artifacts (
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ pub enum Error {
|
|||||||
WorkspaceConfigConflict(String),
|
WorkspaceConfigConflict(String),
|
||||||
#[error("Runtime binding conflict: {0}")]
|
#[error("Runtime binding conflict: {0}")]
|
||||||
RuntimeBindingConflict(String),
|
RuntimeBindingConflict(String),
|
||||||
|
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
|
||||||
|
RuntimeBindingRevisionConflict {
|
||||||
|
expected: Option<u64>,
|
||||||
|
actual: Option<u64>,
|
||||||
|
},
|
||||||
|
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
|
||||||
|
RuntimeBindingFingerprintConflict { fingerprint: String },
|
||||||
|
#[error("Runtime binding was not found for {runtime_id}")]
|
||||||
|
RuntimeBindingNotFound { runtime_id: String },
|
||||||
#[error("Repository conflict: {0}")]
|
#[error("Repository conflict: {0}")]
|
||||||
RepositoryConflict(String),
|
RepositoryConflict(String),
|
||||||
#[error("Registry inconsistency: {0}")]
|
#[error("Registry inconsistency: {0}")]
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
base_url,
|
base_url,
|
||||||
public_key,
|
public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -859,6 +860,7 @@ mod tests {
|
|||||||
base_url: "http://127.0.0.1:18080".to_string(),
|
base_url: "http://127.0.0.1:18080".to_string(),
|
||||||
public_key,
|
public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex, Weak};
|
use std::sync::{Arc, Mutex, RwLock, Weak};
|
||||||
|
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
@@ -74,11 +74,13 @@ use workspace_api::{
|
|||||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
||||||
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
||||||
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
||||||
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
||||||
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
|
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
||||||
RotateRepositorySshCredentialRequest, RuntimeConnectionTestFailureKind,
|
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
|
||||||
RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary,
|
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
||||||
|
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyState,
|
||||||
|
RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
||||||
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
@@ -90,8 +92,8 @@ use workspace_api::{
|
|||||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
|
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
|
||||||
WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse,
|
WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse,
|
||||||
WorkspacePermissionSummary, WorkspaceRepositoryRecord, WorkspaceResponse,
|
WorkspacePermissionSummary, WorkspaceRepositoryRecord, WorkspaceResponse,
|
||||||
WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
|
WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary,
|
||||||
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
@@ -153,7 +155,7 @@ use crate::store::{
|
|||||||
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||||
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
||||||
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||||
WorkspaceResourceKind,
|
WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord,
|
||||||
};
|
};
|
||||||
use crate::workdir_removal::{
|
use crate::workdir_removal::{
|
||||||
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
|
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
|
||||||
@@ -585,6 +587,7 @@ pub struct WorkspaceApi {
|
|||||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||||
authority: SqliteWorkspaceAuthority,
|
authority: SqliteWorkspaceAuthority,
|
||||||
runtime: Arc<RuntimeRegistry>,
|
runtime: Arc<RuntimeRegistry>,
|
||||||
|
runtime_binding_expectations: Arc<RwLock<HashMap<(String, String), WorkspaceRuntimeBinding>>>,
|
||||||
companion: Arc<CompanionConsole>,
|
companion: Arc<CompanionConsole>,
|
||||||
orchestrator_spawn_lock: Arc<std::sync::Mutex<()>>,
|
orchestrator_spawn_lock: Arc<std::sync::Mutex<()>>,
|
||||||
orchestrator_attention_fingerprint: Arc<Mutex<Option<String>>>,
|
orchestrator_attention_fingerprint: Arc<Mutex<Option<String>>>,
|
||||||
@@ -1575,6 +1578,7 @@ impl WorkspaceApi {
|
|||||||
base_url: "in-process://embedded".to_owned(),
|
base_url: "in-process://embedded".to_owned(),
|
||||||
public_key: embedded_identity.public_key.clone(),
|
public_key: embedded_identity.public_key.clone(),
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: config.workspace_created_at.clone(),
|
created_at: config.workspace_created_at.clone(),
|
||||||
updated_at: config.workspace_created_at.clone(),
|
updated_at: config.workspace_created_at.clone(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -1614,18 +1618,22 @@ impl WorkspaceApi {
|
|||||||
.then(|| (source.runtime_id.clone(), source.base_url.clone()))
|
.then(|| (source.runtime_id.clone(), source.base_url.clone()))
|
||||||
})
|
})
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
let expected_runtime_bindings = Arc::new(
|
let expected_runtime_bindings = store
|
||||||
store
|
.list_workspace_runtime_bindings(&config.workspace_id, false)
|
||||||
.list_workspace_runtime_bindings(&config.workspace_id, false)
|
.await?
|
||||||
.await?
|
.into_iter()
|
||||||
.into_iter()
|
.filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID)
|
||||||
.filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID)
|
.filter(|binding| {
|
||||||
.filter(|binding| {
|
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url)
|
||||||
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url)
|
})
|
||||||
})
|
.map(|binding| {
|
||||||
.map(|binding| (binding.runtime_id.clone(), binding))
|
(
|
||||||
.collect::<HashMap<_, _>>(),
|
(binding.workspace_id.clone(), binding.runtime_id.clone()),
|
||||||
);
|
binding,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let workspace_id = config.workspace_id.clone();
|
||||||
let api = Self::new_with_execution_backend_and_broker(
|
let api = Self::new_with_execution_backend_and_broker(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
@@ -1634,15 +1642,34 @@ impl WorkspaceApi {
|
|||||||
Some(worker_remove_dispatcher),
|
Some(worker_remove_dispatcher),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
*api.runtime_binding_expectations
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = expected_runtime_bindings;
|
||||||
|
let runtime_binding_expectations = Arc::clone(&api.runtime_binding_expectations);
|
||||||
api.runtime.set_runtime_binding_gate(move |runtime_id| {
|
api.runtime.set_runtime_binding_gate(move |runtime_id| {
|
||||||
expected_runtime_bindings
|
runtime_binding_expectations
|
||||||
.get(runtime_id)
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.get(&(workspace_id.clone(), runtime_id.to_string()))
|
||||||
.is_some_and(|expected| {
|
.is_some_and(|expected| {
|
||||||
runtime_binding_store
|
runtime_binding_store
|
||||||
.workspace_runtime_binding_matches(expected)
|
.workspace_runtime_binding_matches(expected)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let active_expectations = api
|
||||||
|
.runtime_binding_expectations
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
for source in &api.config.remote_runtime_sources {
|
||||||
|
if !active_expectations
|
||||||
|
.contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone()))
|
||||||
|
{
|
||||||
|
api.runtime_subscription_broker
|
||||||
|
.unregister_runtime(&source.runtime_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(active_expectations);
|
||||||
Ok(api)
|
Ok(api)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,6 +1775,7 @@ impl WorkspaceApi {
|
|||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
runtime,
|
runtime,
|
||||||
|
runtime_binding_expectations: Arc::new(RwLock::new(HashMap::new())),
|
||||||
companion,
|
companion,
|
||||||
orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())),
|
orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())),
|
||||||
orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)),
|
orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)),
|
||||||
@@ -2648,7 +2676,11 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
||||||
delete(scoped_delete_remote_runtime),
|
get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
||||||
|
put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_trust_key),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
|
||||||
@@ -10956,11 +10988,225 @@ async fn scoped_create_remote_runtime(
|
|||||||
create_remote_runtime(State(api), Json(request)).await
|
create_remote_runtime(State(api), Json(request)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_runtime_detail(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
) -> ApiResult<Json<WorkspaceRuntimeDetail>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let workspace = api
|
||||||
|
.store
|
||||||
|
.get_workspace(&path.workspace_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(Error::WorkspaceIdMismatch)?;
|
||||||
|
let is_owner = workspace.owner_account_id == actor.account_id;
|
||||||
|
Ok(Json(
|
||||||
|
workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_put_runtime_trust_key(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
Json(request): Json<PutRuntimeTrustKeyRequest>,
|
||||||
|
) -> std::result::Result<Response, ApiError> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?;
|
||||||
|
let actor_account_id = actor.account_id.clone();
|
||||||
|
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"embedded_runtime_trust_managed_internally",
|
||||||
|
"the embedded Runtime trust key is managed by Server identity authority",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.expected_revision == Some(0) {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"invalid_runtime_binding_revision",
|
||||||
|
"expected_revision must be greater than zero when provided",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.public_key.len() > 16 * 1024 {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"runtime_public_key_too_large",
|
||||||
|
"public_key must be at most 16384 bytes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let existing = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
|
||||||
|
.await?;
|
||||||
|
let source = api
|
||||||
|
.config
|
||||||
|
.remote_runtime_sources
|
||||||
|
.iter()
|
||||||
|
.find(|source| {
|
||||||
|
source.runtime_id == path.runtime_id
|
||||||
|
&& source.workspace_id.as_deref() == Some(path.workspace_id.as_str())
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
if let (Some(binding), Some(source)) = (&existing, &source)
|
||||||
|
&& binding.base_url != source.base_url
|
||||||
|
{
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"runtime_endpoint_mismatch",
|
||||||
|
"the persisted Runtime endpoint no longer matches Server Runtime configuration; reconcile the endpoint before changing trust",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (display_name, base_url) = if let Some(binding) = &existing {
|
||||||
|
(binding.display_name.clone(), binding.base_url.clone())
|
||||||
|
} else if let Some(source) = &source {
|
||||||
|
(source.display_name.clone(), source.base_url.clone())
|
||||||
|
} else {
|
||||||
|
return Err(Error::UnknownRuntime(path.runtime_id.clone()).into());
|
||||||
|
};
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let record = WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: path.workspace_id.clone(),
|
||||||
|
runtime_id: path.runtime_id.clone(),
|
||||||
|
display_name,
|
||||||
|
base_url,
|
||||||
|
public_key: request.public_key,
|
||||||
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
|
created_at: existing
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| now.clone(), |binding| binding.created_at.clone()),
|
||||||
|
updated_at: now,
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
let mutation = api
|
||||||
|
.store
|
||||||
|
.put_workspace_runtime_binding_key(record, request.expected_revision, &actor_account_id)
|
||||||
|
.await;
|
||||||
|
let (_, binding) = match mutation {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
api.runtime_binding_expectations
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.insert(
|
||||||
|
(path.workspace_id.clone(), path.runtime_id.clone()),
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
if let Some(source) = source {
|
||||||
|
api.runtime_subscription_broker
|
||||||
|
.register_remote_runtime(source);
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?)
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_revoke_runtime_trust_key(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
|
Json(request): Json<RevokeRuntimeTrustKeyRequest>,
|
||||||
|
) -> std::result::Result<Response, ApiError> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?;
|
||||||
|
let actor_account_id = actor.account_id.clone();
|
||||||
|
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"embedded_runtime_trust_managed_internally",
|
||||||
|
"the embedded Runtime trust key is managed by Server identity authority",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.expected_revision == 0 {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"invalid_runtime_binding_revision",
|
||||||
|
"expected_revision must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let mutation = api
|
||||||
|
.store
|
||||||
|
.revoke_workspace_runtime_binding_key(
|
||||||
|
&path.workspace_id,
|
||||||
|
&path.runtime_id,
|
||||||
|
request.expected_revision,
|
||||||
|
&actor_account_id,
|
||||||
|
&now,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = match mutation {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
api.runtime_binding_expectations
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(&(path.workspace_id.clone(), path.runtime_id.clone()));
|
||||||
|
api.runtime_subscription_broker
|
||||||
|
.unregister_runtime(&path.runtime_id);
|
||||||
|
Ok(
|
||||||
|
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?)
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn runtime_trust_conflict_response(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
path: &ScopedRuntimePath,
|
||||||
|
error: &Error,
|
||||||
|
) -> Option<Response> {
|
||||||
|
let kind = match error {
|
||||||
|
Error::RuntimeBindingRevisionConflict { .. } => RuntimeTrustConflictKind::StaleRevision,
|
||||||
|
Error::RuntimeBindingFingerprintConflict { .. } => {
|
||||||
|
RuntimeTrustConflictKind::FingerprintInUse
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let current = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
Some(
|
||||||
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(RuntimeTrustConflictResponse {
|
||||||
|
error: kind,
|
||||||
|
message: match kind {
|
||||||
|
RuntimeTrustConflictKind::StaleRevision => {
|
||||||
|
"the Runtime trust binding changed; reload before retrying".to_string()
|
||||||
|
}
|
||||||
|
RuntimeTrustConflictKind::FingerprintInUse => {
|
||||||
|
"the public key is already bound to another Runtime in this Workspace"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
current_revision: current.as_ref().map(|binding| binding.binding_revision),
|
||||||
|
current_fingerprint: current
|
||||||
|
.as_ref()
|
||||||
|
.map(|binding| binding.public_key_fingerprint.clone()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_delete_remote_runtime(
|
async fn scoped_delete_remote_runtime(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
|
Extension(actor): Extension<RequestActor>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?;
|
||||||
delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
|
delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12211,6 +12457,7 @@ async fn get_workspace(
|
|||||||
permissions: WorkspacePermissionSummary {
|
permissions: WorkspacePermissionSummary {
|
||||||
manage_repositories: is_owner,
|
manage_repositories: is_owner,
|
||||||
manage_secrets: is_owner,
|
manage_secrets: is_owner,
|
||||||
|
manage_runtimes: is_owner,
|
||||||
},
|
},
|
||||||
extension_points: WorkspaceExtensionPoints {
|
extension_points: WorkspaceExtensionPoints {
|
||||||
store: "sqlite".to_string(),
|
store: "sqlite".to_string(),
|
||||||
@@ -12451,7 +12698,7 @@ async fn create_remote_runtime(
|
|||||||
}
|
}
|
||||||
Err(settings_bad_request(
|
Err(settings_bad_request(
|
||||||
"runtime_public_key_required",
|
"runtime_public_key_required",
|
||||||
"remote Runtime registration requires an authenticated public key; use `yoi-server trust-runtime add` until the Workspace Runtime key API is available",
|
"remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12469,8 +12716,13 @@ async fn delete_remote_runtime(
|
|||||||
.store
|
.store
|
||||||
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
||||||
.await?
|
.await?
|
||||||
.filter(|binding| binding.revoked_at.is_none())
|
|
||||||
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
||||||
|
if binding.revoked_at.is_none() {
|
||||||
|
return Err(Error::RuntimeBindingConflict(
|
||||||
|
"runtime trust is still active; revoke this Workspace's trust key with an expected revision before removing the inactive registration".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
match api
|
match api
|
||||||
.runtime
|
.runtime
|
||||||
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
||||||
@@ -12499,14 +12751,6 @@ async fn delete_remote_runtime(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
if !api
|
|
||||||
.store
|
|
||||||
.revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Err(Error::UnknownRuntime(runtime_id).into());
|
|
||||||
}
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14488,6 +14732,133 @@ async fn workspace_runtime_resources_response(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn workspace_runtime_detail(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
include_public_key: bool,
|
||||||
|
) -> ApiResult<WorkspaceRuntimeDetail> {
|
||||||
|
let binding = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(workspace_id, runtime_id)
|
||||||
|
.await?;
|
||||||
|
let mut resource = workspace_runtime_resources_response(api, workspace_id)
|
||||||
|
.await?
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.find(|resource| resource.runtime.runtime_id == runtime_id);
|
||||||
|
if resource.is_none() {
|
||||||
|
resource = binding.as_ref().map(|binding| WorkspaceRuntimeResource {
|
||||||
|
runtime: workspace_api::RuntimeSummary {
|
||||||
|
runtime_id: binding.runtime_id.clone(),
|
||||||
|
label: binding.display_name.clone(),
|
||||||
|
kind: "remote_http".to_string(),
|
||||||
|
status: "unavailable".to_string(),
|
||||||
|
source: workspace_api::RuntimeSourceSummary {
|
||||||
|
kind: workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||||
|
status: workspace_api::RuntimeSourceStatus::Reserved,
|
||||||
|
identity_authority:
|
||||||
|
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
||||||
|
note: "The Runtime trust binding is not active in the Runtime registry."
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
host_ids: Vec::new(),
|
||||||
|
worker_creation_available: false,
|
||||||
|
os: String::new(),
|
||||||
|
arch: String::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
},
|
||||||
|
management: RuntimeManagementSummary {
|
||||||
|
built_in: false,
|
||||||
|
config_managed: true,
|
||||||
|
removable: false,
|
||||||
|
endpoint_configured: !binding.base_url.trim().is_empty(),
|
||||||
|
token_ref_configured: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut resource = resource.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?;
|
||||||
|
if let Some(binding) = &binding {
|
||||||
|
resource.management.config_managed = true;
|
||||||
|
resource.management.endpoint_configured = !binding.base_url.trim().is_empty();
|
||||||
|
}
|
||||||
|
let endpoint = binding
|
||||||
|
.as_ref()
|
||||||
|
.map(|binding| binding.base_url.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
api.config
|
||||||
|
.remote_runtime_sources
|
||||||
|
.iter()
|
||||||
|
.find(|source| {
|
||||||
|
source.runtime_id == runtime_id
|
||||||
|
&& source.workspace_id.as_deref() == Some(workspace_id)
|
||||||
|
})
|
||||||
|
.map(|source| source.base_url.clone())
|
||||||
|
});
|
||||||
|
let trust_key = binding.as_ref().map_or(
|
||||||
|
RuntimeTrustKeyState {
|
||||||
|
status: RuntimeTrustKeyStatus::Unconfigured,
|
||||||
|
public_key: None,
|
||||||
|
fingerprint: None,
|
||||||
|
revision: None,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
},
|
||||||
|
|binding| RuntimeTrustKeyState {
|
||||||
|
status: if binding.revoked_at.is_some() {
|
||||||
|
RuntimeTrustKeyStatus::Revoked
|
||||||
|
} else {
|
||||||
|
RuntimeTrustKeyStatus::Active
|
||||||
|
},
|
||||||
|
public_key: include_public_key.then(|| binding.public_key.clone()),
|
||||||
|
fingerprint: Some(binding.public_key_fingerprint.clone()),
|
||||||
|
revision: Some(binding.binding_revision),
|
||||||
|
created_at: Some(binding.created_at.clone()),
|
||||||
|
updated_at: Some(binding.updated_at.clone()),
|
||||||
|
revoked_at: binding.revoked_at.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let recent_audit = api
|
||||||
|
.store
|
||||||
|
.list_workspace_runtime_binding_audit(workspace_id, runtime_id, 20)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(project_runtime_trust_audit)
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
Ok(WorkspaceRuntimeDetail {
|
||||||
|
workspace_id: workspace_id.to_string(),
|
||||||
|
runtime: resource,
|
||||||
|
endpoint,
|
||||||
|
trust_key,
|
||||||
|
recent_audit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_runtime_trust_audit(
|
||||||
|
record: WorkspaceRuntimeBindingAuditRecord,
|
||||||
|
) -> Result<RuntimeTrustAuditEntry> {
|
||||||
|
let action = match record.action.as_str() {
|
||||||
|
"created" => RuntimeTrustAuditAction::Created,
|
||||||
|
"replaced" => RuntimeTrustAuditAction::Replaced,
|
||||||
|
"reactivated" => RuntimeTrustAuditAction::Reactivated,
|
||||||
|
"revoked" => RuntimeTrustAuditAction::Revoked,
|
||||||
|
other => {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"unsupported Runtime trust audit action {other}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(RuntimeTrustAuditEntry {
|
||||||
|
action,
|
||||||
|
actor_account_id: record.actor_account_id,
|
||||||
|
old_fingerprint: record.old_fingerprint,
|
||||||
|
new_fingerprint: record.new_fingerprint,
|
||||||
|
revision: record.binding_revision,
|
||||||
|
at: record.at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
||||||
validate_public_runtime_id(request.runtime_id.trim())?;
|
validate_public_runtime_id(request.runtime_id.trim())?;
|
||||||
let endpoint = request.endpoint.trim();
|
let endpoint = request.endpoint.trim();
|
||||||
@@ -16040,6 +16411,8 @@ impl IntoResponse for ApiError {
|
|||||||
| Error::WorkdirAttachmentConflict(_)
|
| Error::WorkdirAttachmentConflict(_)
|
||||||
| Error::WorkspaceConfigConflict(_)
|
| Error::WorkspaceConfigConflict(_)
|
||||||
| Error::RuntimeBindingConflict(_)
|
| Error::RuntimeBindingConflict(_)
|
||||||
|
| Error::RuntimeBindingRevisionConflict { .. }
|
||||||
|
| Error::RuntimeBindingFingerprintConflict { .. }
|
||||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||||
@@ -16068,6 +16441,7 @@ impl IntoResponse for ApiError {
|
|||||||
| Error::UnknownRuntime(_)
|
| Error::UnknownRuntime(_)
|
||||||
| Error::UnknownWorker { .. }
|
| Error::UnknownWorker { .. }
|
||||||
| Error::UnknownRepository(_)
|
| Error::UnknownRepository(_)
|
||||||
|
| Error::RuntimeBindingNotFound { .. }
|
||||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||||
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
@@ -16573,6 +16947,7 @@ mod tests {
|
|||||||
base_url: "https://runtime.test".to_owned(),
|
base_url: "https://runtime.test".to_owned(),
|
||||||
public_key: identity.public_key.clone(),
|
public_key: identity.public_key.clone(),
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -22068,6 +22443,166 @@ mod tests {
|
|||||||
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(temp.path()).await;
|
||||||
|
let owner_account_id = format!("account-{TEST_WORKSPACE_ID}");
|
||||||
|
let owner = RequestActor {
|
||||||
|
user_id: "owner-user".to_string(),
|
||||||
|
account_id: owner_account_id.clone(),
|
||||||
|
handle: "owner".to_string(),
|
||||||
|
display_name: "Owner".to_string(),
|
||||||
|
auth_method: ActorAuthMethod::BrowserSession,
|
||||||
|
};
|
||||||
|
let non_owner = RequestActor {
|
||||||
|
user_id: "other-user".to_string(),
|
||||||
|
account_id: "other-account".to_string(),
|
||||||
|
handle: "other".to_string(),
|
||||||
|
display_name: "Other".to_string(),
|
||||||
|
auth_method: ActorAuthMethod::ApiToken,
|
||||||
|
};
|
||||||
|
let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
|
let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
|
let third = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
api.store
|
||||||
|
.put_workspace_runtime_binding_key(
|
||||||
|
WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
display_name: "Runtime A".to_string(),
|
||||||
|
base_url: "https://runtime.example".to_string(),
|
||||||
|
public_key: first.public_key,
|
||||||
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
revoked_at: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
&owner_account_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let Json(owner_detail) = scoped_get_runtime_detail(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(owner.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(owner_detail.trust_key.public_key.is_some());
|
||||||
|
assert_eq!(owner_detail.trust_key.revision, Some(1));
|
||||||
|
let Json(reader_detail) = scoped_get_runtime_detail(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(non_owner.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(reader_detail.trust_key.public_key.is_none());
|
||||||
|
assert!(reader_detail.trust_key.fingerprint.is_some());
|
||||||
|
|
||||||
|
let response = scoped_put_runtime_trust_key(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(owner.clone()),
|
||||||
|
Json(PutRuntimeTrustKeyRequest {
|
||||||
|
public_key: second.public_key,
|
||||||
|
expected_revision: Some(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let detail: WorkspaceRuntimeDetail = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(detail.trust_key.revision, Some(2));
|
||||||
|
assert_eq!(
|
||||||
|
detail.recent_audit[0].action,
|
||||||
|
RuntimeTrustAuditAction::Replaced
|
||||||
|
);
|
||||||
|
|
||||||
|
let stale = scoped_put_runtime_trust_key(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(owner.clone()),
|
||||||
|
Json(PutRuntimeTrustKeyRequest {
|
||||||
|
public_key: third.public_key,
|
||||||
|
expected_revision: Some(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
||||||
|
let body = axum::body::to_bytes(stale.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let conflict: RuntimeTrustConflictResponse = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(conflict.error, RuntimeTrustConflictKind::StaleRevision);
|
||||||
|
assert_eq!(conflict.current_revision, Some(2));
|
||||||
|
|
||||||
|
let denied = scoped_revoke_runtime_trust_key(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(non_owner),
|
||||||
|
Json(RevokeRuntimeTrustKeyRequest {
|
||||||
|
expected_revision: 2,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let revoked = scoped_revoke_runtime_trust_key(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedRuntimePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
}),
|
||||||
|
Extension(owner),
|
||||||
|
Json(RevokeRuntimeTrustKeyRequest {
|
||||||
|
expected_revision: 2,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(revoked.status(), StatusCode::OK);
|
||||||
|
let binding = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(binding.binding_revision, 3);
|
||||||
|
assert!(binding.revoked_at.is_some());
|
||||||
|
assert!(
|
||||||
|
!api.runtime_binding_expectations
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repository_secret_management_is_owner_only() {
|
async fn repository_secret_management_is_owner_only() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -22115,6 +22650,16 @@ mod tests {
|
|||||||
test_api_with_recording_backend(workspace_root).await.0
|
test_api_with_recording_backend(workspace_root).await.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_owner_actor() -> RequestActor {
|
||||||
|
RequestActor {
|
||||||
|
user_id: "owner-user".to_string(),
|
||||||
|
account_id: format!("account-{TEST_WORKSPACE_ID}"),
|
||||||
|
handle: "owner".to_string(),
|
||||||
|
display_name: "Owner".to_string(),
|
||||||
|
auth_method: ActorAuthMethod::BrowserSession,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn test_repository_id(api: &WorkspaceApi) -> String {
|
fn test_repository_id(api: &WorkspaceApi) -> String {
|
||||||
api.store
|
api.store
|
||||||
.get_repository_by_key(TEST_WORKSPACE_ID, "test-repository")
|
.get_repository_by_key(TEST_WORKSPACE_ID, "test-repository")
|
||||||
@@ -22602,6 +23147,7 @@ mod tests {
|
|||||||
base_url: "https://runtime.invalid".to_string(),
|
base_url: "https://runtime.invalid".to_string(),
|
||||||
public_key: identity.public_key.clone(),
|
public_key: identity.public_key.clone(),
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "2026-08-11T00:00:00Z".to_string(),
|
created_at: "2026-08-11T00:00:00Z".to_string(),
|
||||||
updated_at: "2026-08-11T00:00:00Z".to_string(),
|
updated_at: "2026-08-11T00:00:00Z".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -24088,6 +24634,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.public_key,
|
.public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "1".to_string(),
|
created_at: "1".to_string(),
|
||||||
updated_at: "1".to_string(),
|
updated_at: "1".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -25205,6 +25752,7 @@ mod tests {
|
|||||||
base_url: "https://runtime.example.invalid".to_string(),
|
base_url: "https://runtime.example.invalid".to_string(),
|
||||||
public_key: identity.public_key,
|
public_key: identity.public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "1".to_string(),
|
created_at: "1".to_string(),
|
||||||
updated_at: "1".to_string(),
|
updated_at: "1".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -25222,7 +25770,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let app = build_inner_router(api);
|
let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor()));
|
||||||
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
||||||
|
|
||||||
let initial = get_json(app.clone(), &runtimes_uri).await;
|
let initial = get_json(app.clone(), &runtimes_uri).await;
|
||||||
@@ -25303,6 +25851,16 @@ mod tests {
|
|||||||
.expect("team runtime launch option");
|
.expect("team runtime launch option");
|
||||||
assert_eq!(team_runtime["working_directory_required"], true);
|
assert_eq!(team_runtime["working_directory_required"], true);
|
||||||
|
|
||||||
|
api.store
|
||||||
|
.revoke_workspace_runtime_binding_key(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
"team-runtime",
|
||||||
|
1,
|
||||||
|
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||||
|
&Utc::now().to_rfc3339(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let deleted = request_json(
|
let deleted = request_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
@@ -25354,6 +25912,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.public_key,
|
.public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "1".to_string(),
|
created_at: "1".to_string(),
|
||||||
updated_at: "1".to_string(),
|
updated_at: "1".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -25370,7 +25929,17 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let app = build_inner_router(api);
|
api.store
|
||||||
|
.revoke_workspace_runtime_binding_key(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
"busy-runtime",
|
||||||
|
1,
|
||||||
|
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||||
|
&Utc::now().to_rfc3339(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let app = build_inner_router(api).layer(Extension(test_owner_actor()));
|
||||||
let workers = get_json(app.clone(), "/api/workers").await;
|
let workers = get_json(app.clone(), "/api/workers").await;
|
||||||
assert!(
|
assert!(
|
||||||
workers["items"]
|
workers["items"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = {
|
|||||||
export type WorkspacePermissionSummary = {
|
export type WorkspacePermissionSummary = {
|
||||||
manage_repositories: boolean;
|
manage_repositories: boolean;
|
||||||
manage_secrets: boolean;
|
manage_secrets: boolean;
|
||||||
|
manage_runtimes: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
@@ -221,6 +222,107 @@ export type RepositoryLogResponse = {
|
|||||||
diagnostics: Array<Diagnostic>;
|
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 RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||||
|
|
||||||
export type RuntimeConnectionTestFailureKind =
|
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 {
|
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
|
||||||
const item = object(value, path);
|
const item = object(value, path);
|
||||||
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
|
exactKeys(
|
||||||
|
item,
|
||||||
|
["manage_repositories", "manage_secrets", "manage_runtimes"],
|
||||||
|
path,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
manage_repositories: boolean(
|
manage_repositories: boolean(
|
||||||
item.manage_repositories,
|
item.manage_repositories,
|
||||||
`${path}.manage_repositories`,
|
`${path}.manage_repositories`,
|
||||||
),
|
),
|
||||||
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
|
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 {
|
.settings-test-result.failed {
|
||||||
border-inline-start: 3px solid var(--danger);
|
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 {
|
.settings-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
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 { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
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';
|
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?.built_in) return 'Built-in';
|
||||||
if (runtime.management?.config_managed) return 'Managed remote';
|
if (runtime.management?.config_managed) return 'Managed remote';
|
||||||
return 'Observed';
|
return 'Observed';
|
||||||
@@ -78,27 +80,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRuntime(runtime: Runtime): Promise<void> {
|
async function testRuntime(runtime: WorkspaceRuntimeResource): 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> {
|
|
||||||
requestError = null;
|
requestError = null;
|
||||||
busyRuntimeId = runtime.runtime_id;
|
busyRuntimeId = runtime.runtime_id;
|
||||||
try {
|
try {
|
||||||
@@ -123,12 +105,14 @@
|
|||||||
<h1 id="runtimes-heading">Runtimes</h1>
|
<h1 id="runtimes-heading">Runtimes</h1>
|
||||||
<p>Register and inspect the execution backends available to this Workspace.</p>
|
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
{#if data.workspace.permissions.manage_runtimes}
|
||||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||||
</button>
|
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showAddRuntime}
|
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
|
||||||
<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">
|
||||||
@@ -182,7 +166,11 @@
|
|||||||
{#each data.runtimes.items as runtime}
|
{#each data.runtimes.items as runtime}
|
||||||
<tr class:inactive={runtime.status !== 'active'}>
|
<tr class:inactive={runtime.status !== 'active'}>
|
||||||
<td>
|
<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>
|
<small><code>{runtime.runtime_id}</code></small>
|
||||||
</td>
|
</td>
|
||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
@@ -203,15 +191,8 @@
|
|||||||
onclick={() => testRuntime(runtime)}
|
onclick={() => testRuntime(runtime)}
|
||||||
>Test</button>
|
>Test</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if runtime.management?.removable}
|
{#if !runtime.management?.config_managed}
|
||||||
<button
|
<span class="settings-muted-action">Test unavailable</span>
|
||||||
class="danger"
|
|
||||||
type="button"
|
|
||||||
disabled={busyRuntimeId !== null}
|
|
||||||
onclick={() => deleteRuntime(runtime)}
|
|
||||||
>Delete</button>
|
|
||||||
{:else}
|
|
||||||
<span class="settings-muted-action">Not removable</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
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";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const runtimes = await loadJson<ListResponse<Runtime>>(
|
const runtimes = await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/runtimes"),
|
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 {
|
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