fix: restore remote Runtime management contracts

This commit is contained in:
2026-09-09 00:26:04 +09:00
parent a072562034
commit 18fd6a1f5e
23 changed files with 1144 additions and 529 deletions
+3
View File
@@ -287,6 +287,7 @@ pub enum WorkspaceConfigFetchResult {
pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
},
Rejected(WorkerExecutionResult),
@@ -296,10 +297,12 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult {
pub fn connected(
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
) -> Self {
Self::Connected {
handle,
worker_state,
working_directory,
}
}
+26 -2
View File
@@ -2992,6 +2992,10 @@ mod tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -3005,6 +3009,10 @@ mod tests {
) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request.previous_working_directory,
}
}
@@ -3313,6 +3321,10 @@ mod ws_tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -3575,11 +3587,23 @@ mod ws_tests {
worker_ref.worker_id.to_string()
);
let running_snapshot = |worker_ref: &WorkerRef| {
let mut snapshot = runtime
.worker_detail(worker_ref)
.unwrap()
.worker_state
.expect("connected test Worker must expose its initial state");
snapshot.revision += 1;
snapshot.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
snapshot
};
runtime
.observe_worker_event(
&other.worker_ref,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
snapshot: running_snapshot(&other.worker_ref),
},
)
.unwrap();
@@ -3587,7 +3611,7 @@ mod ws_tests {
.observe_worker_event(
&worker_ref,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
snapshot: running_snapshot(&worker_ref),
},
)
.unwrap();
+53 -5
View File
@@ -890,11 +890,12 @@ impl Runtime {
};
let spawn_result = backend.spawn_worker(spawn_request);
let (handle, working_directory) = match spawn_result {
let (handle, initial_worker_state, working_directory) = match spawn_result {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => (handle, working_directory),
} => (handle, worker_state, working_directory),
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?;
@@ -950,6 +951,7 @@ impl Runtime {
let detail = match self.commit_created_worker(
&worker_ref,
handle.clone(),
initial_worker_state.clone(),
working_directory,
dispatch_result,
) {
@@ -968,6 +970,7 @@ impl Runtime {
match self.commit_created_worker(
&worker_ref,
handle.clone(),
initial_worker_state,
working_directory,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn),
) {
@@ -1260,11 +1263,13 @@ impl Runtime {
match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => {
self.commit_restored_worker_execution(
worker_ref,
handle,
worker_state,
WorkerStatus::Idle,
working_directory,
)?;
@@ -1656,6 +1661,7 @@ impl Runtime {
&self,
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
initial_worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<CatalogWorkingDirectoryStatus>,
result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> {
@@ -1665,7 +1671,7 @@ impl Runtime {
worker.execution_handle = Some(handle);
worker.execution_bound = true;
worker.status = WorkerStatus::Idle;
worker.worker_state = None;
let _ = worker.apply_worker_state(&initial_worker_state);
if let Some(snapshot) = result.worker_state.as_ref() {
let _ = worker.apply_worker_state(snapshot);
}
@@ -2192,10 +2198,12 @@ impl Runtime {
match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => self.commit_restored_worker_execution(
&candidate.worker_ref,
handle,
worker_state,
WorkerStatus::Idle,
working_directory,
)?,
@@ -2213,6 +2221,7 @@ impl Runtime {
&self,
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
status: WorkerStatus,
working_directory: Option<CatalogWorkingDirectoryStatus>,
) -> Result<(), RuntimeError> {
@@ -2223,6 +2232,7 @@ impl Runtime {
worker.execution_handle = Some(handle);
worker.execution_bound = true;
worker.status = status;
let _ = worker.apply_worker_state(&worker_state);
worker.restore_intent = restore_intent_for_status(worker.status);
worker.working_directory = working_directory;
}
@@ -4325,6 +4335,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -4354,6 +4368,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -5135,7 +5153,27 @@ mod tests {
let detail = runtime.create_worker(request).unwrap();
assert_eq!(detail.status, WorkerStatus::Idle);
assert_eq!(detail.worker_state, None);
assert_eq!(
detail.worker_state.as_ref().map(|snapshot| &snapshot.state),
Some(&protocol::WorkerState::Idle)
);
}
#[test]
fn restored_worker_exposes_the_backend_initial_state_snapshot() {
let (runtime, _) = runtime_and_backend();
let created = runtime
.create_worker(task_request("restore initial state"))
.unwrap();
runtime.stop_worker(&created.worker_ref, None).unwrap();
let restored = runtime.restore_worker(&created.worker_ref).unwrap();
let worker_state = restored
.worker_state
.expect("restored Worker must expose its initial state");
assert_eq!(worker_state.execution_generation, 2);
assert_eq!(worker_state.state, protocol::WorkerState::Idle);
}
#[test]
@@ -5414,6 +5452,10 @@ mod tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -5513,7 +5555,13 @@ mod tests {
assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]);
let restored = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(restored.status, WorkerStatus::Idle);
assert_eq!(restored.worker_state, None);
assert_eq!(
restored
.worker_state
.as_ref()
.map(|snapshot| (snapshot.execution_generation, &snapshot.state)),
Some((2, &protocol::WorkerState::Idle))
);
}
#[test]
@@ -1548,6 +1548,10 @@ where
));
}
};
let connected_worker_state = worker_state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
@@ -1560,6 +1564,7 @@ where
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
worker_state: connected_worker_state,
working_directory: working_directory.map(|binding| binding.status()),
}
}
+43
View File
@@ -3277,6 +3277,28 @@ pub(crate) fn resolve_strict_remote_runtime_endpoint(
Ok((host, addresses))
}
/// Returns whether `endpoint` is a credential-free HTTP(S) origin on a literal
/// IPv4 or IPv6 loopback address.
pub fn is_loopback_runtime_origin(endpoint: &str) -> bool {
let Ok(endpoint) = reqwest::Url::parse(endpoint.trim()) else {
return false;
};
if !matches!(endpoint.scheme(), "http" | "https")
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
|| endpoint.path() != "/"
{
return false;
}
endpoint
.host_str()
.map(|host| host.trim_start_matches('[').trim_end_matches(']'))
.and_then(|host| host.parse::<IpAddr>().ok())
.is_some_and(|address| address.is_loopback())
}
pub(crate) fn is_disallowed_remote_runtime_address(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => {
@@ -5325,6 +5347,23 @@ mod tests {
.unwrap();
}
#[test]
fn loopback_runtime_origin_accepts_only_literal_clean_http_or_https_origins() {
assert!(is_loopback_runtime_origin("http://127.0.0.1:8788"));
assert!(is_loopback_runtime_origin("https://127.0.0.1"));
assert!(is_loopback_runtime_origin("http://[::1]:8788"));
for endpoint in [
"http://localhost:8788",
"http://10.0.0.1:8788",
"http://127.0.0.1:8788/path",
"http://127.0.0.1:8788/?query=1",
"http://user@127.0.0.1:8788",
"ftp://127.0.0.1:8788",
] {
assert!(!is_loopback_runtime_origin(endpoint), "{endpoint}");
}
}
#[test]
fn remote_worker_create_timeout_covers_runtime_phase_budgets() {
assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5));
@@ -5712,6 +5751,10 @@ mod tests {
request.worker_ref,
self.backend_id(),
),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
+32 -2
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use chrono::Utc;
use tokio::net::TcpListener;
use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig};
use yoi_workspace_server::hosts::{
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
};
use yoi_workspace_server::store::{
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
@@ -165,6 +167,7 @@ fn remote_runtime_config_from_binding(
binding.workspace_id, binding.runtime_id
)));
}
let strict_public_egress = !is_loopback_runtime_origin(&binding.base_url);
Ok(Some(
RemoteRuntimeConfig::new(
binding.runtime_id,
@@ -173,7 +176,7 @@ fn remote_runtime_config_from_binding(
None,
)
.with_workspace_id(binding.workspace_id)
.with_strict_public_egress(true),
.with_strict_public_egress(strict_public_egress),
))
}
@@ -621,6 +624,33 @@ mod tests {
);
}
#[test]
fn runtime_startup_uses_non_strict_transport_for_literal_loopback_origin() {
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_owned(),
runtime_id: "arcadia".to_owned(),
display_name: "Arcadia".to_owned(),
base_url: "http://127.0.0.1:8788".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
};
let config = remote_runtime_config_from_binding(binding)
.unwrap()
.expect("remote Runtime config");
assert_eq!(config.base_url, "http://127.0.0.1:8788");
assert!(!config.strict_public_egress);
}
#[test]
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
for command in ["identity", "trust-runtime"] {
@@ -19,6 +19,10 @@ impl WorkerExecutionBackend for TestExecutionBackend {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::connected(
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
None,
)
}
@@ -174,12 +178,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
.await;
assert_eq!(status.desired_selectors, 1);
let mut running = worker
.worker_state
.clone()
.expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
protocol::Event::WorkerState { snapshot: running },
)
.unwrap();
for subscription in [&mut first, &mut second] {
@@ -334,12 +344,18 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
workers[0].runtime_id.as_deref(),
Some("embedded-worker-runtime")
);
let mut running = worker
.worker_state
.clone()
.expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
protocol::Event::WorkerState { snapshot: running },
)
.unwrap();
assert!(matches!(next_event(&mut subscription).await,
+108 -61
View File
@@ -135,7 +135,7 @@ use crate::hosts::{
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest,
WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address,
worker_spawn_create_fingerprint, workspace_worker_summary,
is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary,
};
use crate::identity::WorkspaceIdentity;
use crate::memory_backend::execute_memory_backend_operation_with_authority;
@@ -3014,15 +3014,15 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
.route("/api/workspace", get(get_workspace))
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
.route(
"/api/w/{workspace_id}/settings/workspace",
"/api/w/{workspace_id}/settings",
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
)
.route(
"/api/w/{workspace_id}/settings/workspace/signing-identity",
"/api/w/{workspace_id}/settings/signing-identity",
get(scoped_get_workspace_signing_identity),
)
.route(
"/api/w/{workspace_id}/settings/workspace/signing-identity/provision",
"/api/w/{workspace_id}/settings/signing-identity/provision",
post(scoped_provision_workspace_signing_identity),
)
.route(
@@ -16312,61 +16312,65 @@ async fn validate_runtime_connection_request(
let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| {
settings_bad_request(
"invalid_remote_runtime_endpoint",
"endpoint must be an absolute https URL",
"endpoint must be an absolute HTTP or HTTPS URL",
)
})?;
if endpoint.scheme() != "https"
|| endpoint.host_str().is_none()
let local_loopback = is_loopback_runtime_origin(endpoint.as_str());
if endpoint.host_str().is_none()
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
|| endpoint.path() != "/"
|| (!local_loopback && endpoint.scheme() != "https")
{
return Err(settings_bad_request(
"remote_runtime_endpoint_not_allowed",
"Runtime endpoint must be an https origin without credentials, query, or fragment",
"Runtime endpoint must be a public HTTPS origin or a loopback HTTP origin without credentials, path, query, or fragment",
));
}
let host = endpoint.host_str().expect("checked above");
if host.eq_ignore_ascii_case("localhost")
|| host.ends_with(".localhost")
|| host
.parse::<IpAddr>()
.is_ok_and(is_disallowed_remote_runtime_address)
{
return Err(settings_bad_request(
"remote_runtime_endpoint_not_allowed",
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
));
}
let port = endpoint.port_or_known_default().unwrap_or(443);
let addresses = tokio::time::timeout(
std::time::Duration::from_secs(3),
tokio::net::lookup_host((host, port)),
)
.await
.map_err(|_| {
settings_bad_request(
"remote_runtime_endpoint_dns_timeout",
"Runtime endpoint DNS resolution timed out",
if !local_loopback {
let host = endpoint.host_str().expect("checked above");
if host.eq_ignore_ascii_case("localhost")
|| host.ends_with(".localhost")
|| host
.parse::<IpAddr>()
.is_ok_and(is_disallowed_remote_runtime_address)
{
return Err(settings_bad_request(
"remote_runtime_endpoint_not_allowed",
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
));
}
let port = endpoint.port_or_known_default().unwrap_or(443);
let addresses = tokio::time::timeout(
std::time::Duration::from_secs(3),
tokio::net::lookup_host((host, port)),
)
})?
.map_err(|_| {
settings_bad_request(
"remote_runtime_endpoint_dns_failed",
"Runtime endpoint DNS resolution failed",
)
})?
.collect::<Vec<_>>();
if addresses.is_empty()
|| addresses
.iter()
.any(|address| is_disallowed_remote_runtime_address(address.ip()))
{
return Err(settings_bad_request(
"remote_runtime_endpoint_not_allowed",
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
));
.await
.map_err(|_| {
settings_bad_request(
"remote_runtime_endpoint_dns_timeout",
"Runtime endpoint DNS resolution timed out",
)
})?
.map_err(|_| {
settings_bad_request(
"remote_runtime_endpoint_dns_failed",
"Runtime endpoint DNS resolution failed",
)
})?
.collect::<Vec<_>>();
if addresses.is_empty()
|| addresses
.iter()
.any(|address| is_disallowed_remote_runtime_address(address.ip()))
{
return Err(settings_bad_request(
"remote_runtime_endpoint_not_allowed",
"Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address",
));
}
}
if request
.display_name
@@ -16412,7 +16416,8 @@ fn remote_runtime_config_from_binding(
)
.with_workspace_id(binding.workspace_id.clone())
.with_strict_public_egress(
binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity,
binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity
&& !is_loopback_runtime_origin(&binding.base_url),
);
Ok(remote)
}
@@ -20505,15 +20510,29 @@ mod tests {
};
assert!(validate_runtime_connection_request(&ok).await.is_ok());
let bad_endpoint = CreateRemoteRuntimeRequest {
endpoint: "http://169.254.169.254/latest/meta-data".to_string(),
..ok
let loopback = CreateRemoteRuntimeRequest {
endpoint: "http://127.0.0.1:8788".to_string(),
..ok.clone()
};
assert!(
validate_runtime_connection_request(&bad_endpoint)
.await
.is_err()
);
assert!(validate_runtime_connection_request(&loopback).await.is_ok());
for endpoint in [
"http://10.0.0.1:8788",
"http://localhost:8788",
"http://127.0.0.1:8788/runtime",
"http://169.254.169.254/latest/meta-data",
] {
let bad_endpoint = CreateRemoteRuntimeRequest {
endpoint: endpoint.to_string(),
..ok.clone()
};
assert!(
validate_runtime_connection_request(&bad_endpoint)
.await
.is_err(),
"{endpoint}"
);
}
}
#[test]
@@ -20683,6 +20702,10 @@ mod tests {
request.worker_ref,
self.backend_id(),
),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory,
}
}
@@ -21480,7 +21503,7 @@ mod tests {
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
let identity_uri = format!(
"/api/w/{}/settings/workspace/signing-identity",
"/api/w/{}/settings/signing-identity",
workspace.workspace.workspace_id
);
let identity_response = app
@@ -21511,6 +21534,33 @@ mod tests {
let identity_text = String::from_utf8(identity_body.to_vec()).unwrap();
assert!(!identity_text.contains("private_key"));
assert!(!identity_text.contains("private_material_ref"));
for legacy_uri in [
format!(
"/api/w/{}/settings/workspace",
workspace.workspace.workspace_id
),
format!(
"/api/w/{}/settings/workspace/signing-identity",
workspace.workspace.workspace_id
),
] {
let legacy_response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(legacy_uri)
.header(
axum::http::header::COOKIE,
"yoi_workspace_session=browser-session-auth",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(legacy_response.status(), StatusCode::NOT_FOUND);
}
let identity_non_owner = app
.clone()
.oneshot(
@@ -21582,10 +21632,7 @@ mod tests {
pending_identity.key_id
);
let settings_uri = format!(
"/api/w/{}/settings/workspace",
workspace.workspace.workspace_id
);
let settings_uri = format!("/api/w/{}/settings", workspace.workspace.workspace_id);
let csrf_rejected = app
.clone()
.oneshot(
+354 -39
View File
@@ -18,7 +18,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 56;
const LATEST_SCHEMA_VERSION: i64 = 57;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -28,6 +28,8 @@ const WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME: &str =
"Workspace Runtime binding state and identity mode";
const WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME: &str =
"Workspace-signed Runtime verification evidence";
const LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME: &str =
"convert legacy Server-issued Runtime bindings to Workspace identity";
const MIGRATIONS: &[Migration] = &[
Migration {
@@ -60,6 +62,11 @@ const MIGRATIONS: &[Migration] = &[
name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME,
apply: migrate_workspace_runtime_verification_v55_to_v56,
},
Migration {
version: 57,
name: LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME,
apply: migrate_legacy_external_runtime_bindings_v56_to_v57,
},
];
#[derive(Clone, Copy)]
@@ -6970,7 +6977,13 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding)
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer => {
if record.workspace_key_id.is_some() || record.workspace_key_generation.is_some() {
return Err(Error::InvalidInput(
"legacy Server issuer Runtime bindings must not carry Workspace key metadata"
"legacy Runtime bindings must not carry Workspace key metadata".into(),
));
}
#[cfg(not(test))]
if record.runtime_id != crate::hosts::EMBEDDED_RUNTIME_ID {
return Err(Error::InvalidInput(
"legacy Server issuer authentication is reserved for the embedded Runtime"
.into(),
));
}
@@ -7789,14 +7802,11 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()
));
}
all_workspace_ids.clone()
} else if let Some(workspace_id) = workspace_id.filter(|value| !value.trim().is_empty())
{
vec![workspace_id]
} else {
vec![workspace_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
Error::Store(format!(
"Runtime `{runtime_id}` has no persisted Workspace ownership; refusing to guess during schema-50 migration"
))
})?]
continue;
};
let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?;
for workspace_id in workspace_ids {
@@ -7871,11 +7881,9 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()
})?;
for row in rows {
let (runtime_id, jti, expires_at, consumed_at) = row?;
let workspace_ids = runtime_workspaces.get(runtime_id.as_str()).ok_or_else(|| {
Error::Store(format!(
"consumed Worker mutation proof for Runtime `{runtime_id}` has no provable Workspace binding"
))
})?;
let Some(workspace_ids) = runtime_workspaces.get(runtime_id.as_str()) else {
continue;
};
for workspace_id in workspace_ids {
consumed_jtis.push((
(*workspace_id).to_string(),
@@ -8307,6 +8315,93 @@ fn migrate_workspace_runtime_verification_v55_to_v56(conn: &Connection) -> Resul
Ok(())
}
fn migrate_legacy_external_runtime_bindings_v56_to_v57(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?;
if current != 56 {
return Err(Error::Store(format!(
"expected schema version 56 before {LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME} migration, found {current}"
)));
}
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
let missing_workspace_identity_count = tx.query_row(
"SELECT COUNT(*)
FROM workspace_runtime_bindings binding
LEFT JOIN workspace_signing_identities identity
ON identity.workspace_id = binding.workspace_id
WHERE binding.authentication_mode = 'legacy_server_issuer'
AND binding.runtime_id <> ?1
AND identity.workspace_id IS NULL",
params![crate::hosts::EMBEDDED_RUNTIME_ID],
|row| row.get::<_, i64>(0),
)?;
if missing_workspace_identity_count != 0 {
return Err(Error::Store(
"legacy external Runtime binding migration requires Workspace signing identity metadata"
.to_string(),
));
}
tx.execute(
"DELETE FROM workspace_runtime_verifications
WHERE EXISTS (
SELECT 1 FROM workspace_runtime_bindings binding
WHERE binding.workspace_id = workspace_runtime_verifications.workspace_id
AND binding.runtime_id = workspace_runtime_verifications.runtime_id
AND binding.authentication_mode = 'legacy_server_issuer'
AND binding.runtime_id <> ?1
)",
params![crate::hosts::EMBEDDED_RUNTIME_ID],
)?;
tx.execute(
"UPDATE workspace_runtime_bindings
SET state = CASE WHEN state = 'verified' THEN 'configured' ELSE state END,
authentication_mode = 'workspace_identity',
workspace_key_id = (
SELECT identity.key_id FROM workspace_signing_identities identity
WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id
),
workspace_key_generation = (
SELECT identity.revision FROM workspace_signing_identities identity
WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id
)
WHERE authentication_mode = 'legacy_server_issuer'
AND runtime_id <> ?1",
params![crate::hosts::EMBEDDED_RUNTIME_ID],
)?;
let remaining_external_legacy_bindings = tx.query_row(
"SELECT COUNT(*) FROM workspace_runtime_bindings
WHERE authentication_mode = 'legacy_server_issuer'
AND runtime_id <> ?1",
params![crate::hosts::EMBEDDED_RUNTIME_ID],
|row| row.get::<_, i64>(0),
)?;
if remaining_external_legacy_bindings != 0 {
return Err(Error::Store(
"legacy Server-issued external Runtime bindings remain after schema-57 migration"
.to_string(),
));
}
verify_workspace_runtime_binding_schema(&tx)?;
verify_workspace_runtime_verification_schema(&tx)?;
let foreign_key_violations =
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})?;
if foreign_key_violations != 0 {
return Err(Error::Store(format!(
"schema-57 Runtime binding migration left {foreign_key_violations} foreign-key violation(s)"
)));
}
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![
57_i64,
LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME
],
)?;
tx.commit()?;
Ok(())
}
fn verify_workspace_runtime_verification_schema(conn: &Connection) -> Result<()> {
let actual = table_columns(conn, "workspace_runtime_verifications")?
.into_iter()
@@ -8496,6 +8591,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
"SELECT COUNT(*) FROM workspace_runtime_bindings
WHERE state NOT IN ('configured', 'verified', 'revoked')
OR authentication_mode NOT IN ('legacy_server_issuer', 'workspace_identity')
OR (authentication_mode = 'legacy_server_issuer' AND
(workspace_key_id IS NOT NULL OR workspace_key_generation IS NOT NULL))
OR (authentication_mode = 'workspace_identity' AND
(workspace_key_id IS NULL OR workspace_key_generation IS NULL))
OR (state = 'revoked' AND revoked_at IS NULL)
@@ -8633,6 +8730,22 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
}
fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> {
if binding.authentication_mode == WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer {
if binding.workspace_key_id.is_some() || binding.workspace_key_generation.is_some() {
return Err(Error::Store(format!(
"legacy Runtime binding `{}/{}` carries Workspace key metadata",
binding.workspace_id, binding.runtime_id
)));
}
let (canonical_key, fingerprint) = normalize_runtime_public_key(&binding.public_key)?;
if canonical_key != binding.public_key || fingerprint != binding.public_key_fingerprint {
return Err(Error::Store(format!(
"Runtime binding `{}/{}` has non-canonical trust content",
binding.workspace_id, binding.runtime_id
)));
}
return Ok(());
}
let mut normalized = binding.clone();
normalize_workspace_runtime_binding_key(&mut normalized)?;
if normalized.public_key != binding.public_key
@@ -9427,6 +9540,10 @@ mod tests {
version: 56,
name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 57,
name: LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME.to_string(),
},
]
);
@@ -9457,6 +9574,10 @@ mod tests {
56,
WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(),
),
(
57,
LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME.to_string(),
),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -9476,17 +9597,18 @@ mod tests {
[],
|row| row.get::<_, String>(0),
)?,
"verified:legacy_server_issuer"
"configured:workspace_identity"
);
assert!(
conn.execute(
"UPDATE workspace_runtime_bindings
SET state='configured', authentication_mode='workspace_identity'
WHERE workspace_id='workspace-a' AND runtime_id='shared'",
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_bindings
WHERE workspace_id='workspace-a' AND runtime_id='shared'
AND workspace_key_id IS NOT NULL
AND workspace_key_generation IS NOT NULL",
[],
)
.is_err(),
"migrated schema must reject Workspace identity mode without key metadata"
|row| row.get::<_, i64>(0),
)?,
1
);
assert_eq!(
conn.query_row(
@@ -9526,7 +9648,7 @@ mod tests {
.iter()
.map(|migration| migration.version)
.collect::<Vec<_>>(),
vec![52, 53, 54, 55, 56]
vec![52, 53, 54, 55, 56, 57]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = Connection::open(&path).unwrap();
@@ -9534,7 +9656,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 7);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 8);
}
#[test]
@@ -9594,23 +9716,47 @@ mod tests {
}
#[test]
fn schema_v50_dry_run_rejects_unscoped_external_runtime_without_mutating_source() {
fn schema_v50_discards_unscoped_external_runtime_without_guessing_ownership() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
prepare_schema_v50(&path, None);
let error = SqliteWorkspaceStore::migration_plan(&path).unwrap_err();
assert!(
error
.to_string()
.contains("has no persisted Workspace ownership; refusing to guess"),
"{error}"
);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 50);
assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION);
let unchanged = Connection::open(&path).unwrap();
assert_eq!(current_schema_version(&unchanged).unwrap(), 50);
assert!(table_exists(&unchanged, "trusted_runtime_records").unwrap());
assert!(!table_exists(&unchanged, "workspace_runtime_bindings").unwrap());
drop(unchanged);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let migrated = Connection::open(&path).unwrap();
assert_eq!(
current_schema_version(&migrated).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(
migrated
.query_row(
"SELECT COUNT(*) FROM workspace_runtime_bindings WHERE runtime_id = 'shared'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
assert_eq!(
migrated
.query_row(
"SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
}
#[test]
@@ -9803,9 +9949,9 @@ mod tests {
public_key_fingerprint: String::new(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some(format!("WK-{}", &workspace_id["workspace-".len()..])),
workspace_key_generation: Some(1),
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
@@ -10082,6 +10228,171 @@ mod tests {
assert!(table_exists(&migrated, "workspace_runtime_verifications").unwrap());
}
#[test]
fn schema_v56_converts_legacy_external_binding_and_preserves_embedded() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
prepare_schema_v50(&path, Some("workspace-a"));
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version > 50 && migration.version <= 56)
{
(migration.apply)(&conn).unwrap();
}
assert_eq!(current_schema_version(&conn).unwrap(), 56);
let external_identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate("legacy-external").unwrap();
let embedded_identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(
crate::hosts::EMBEDDED_RUNTIME_ID,
)
.unwrap();
let (_, external_fingerprint) =
normalize_runtime_public_key(&external_identity.public_key).unwrap();
let (_, embedded_fingerprint) =
normalize_runtime_public_key(&embedded_identity.public_key).unwrap();
conn.execute(
r#"INSERT INTO workspace_runtime_bindings(
workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, created_at, updated_at, revoked_at, state,
authentication_mode, workspace_key_id, workspace_key_generation,
binding_revision
) VALUES (
'workspace-a', 'legacy-external', 'Legacy External', 'https://runtime.test',
?1, ?2, '1', '1', NULL, 'verified', 'legacy_server_issuer', NULL, NULL, 1
)"#,
params![external_identity.public_key, external_fingerprint],
)
.unwrap();
conn.execute(
r#"INSERT INTO workspace_runtime_bindings(
workspace_id, runtime_id, display_name, base_url, public_key,
public_key_fingerprint, created_at, updated_at, revoked_at, state,
authentication_mode, workspace_key_id, workspace_key_generation,
binding_revision
) VALUES (
'workspace-a', 'embedded-worker-runtime', 'Embedded', 'embedded://runtime',
?1, ?2, '1', '1', NULL, 'verified', 'legacy_server_issuer', NULL, NULL, 1
)"#,
params![embedded_identity.public_key, embedded_fingerprint],
)
.unwrap();
conn.execute_batch(
r#"
INSERT INTO workspace_runtime_binding_audit(
workspace_id, runtime_id, actor_account_id, action,
old_fingerprint, new_fingerprint, binding_revision, at
) VALUES
('workspace-a', 'legacy-external', 'owner', 'created', NULL, 'legacy', 1, '1'),
('workspace-a', 'embedded-worker-runtime', 'owner', 'created', NULL, 'embedded', 1, '1');
INSERT INTO worker_mutation_source_proof_jtis(
workspace_id, runtime_id, jti, expires_at, consumed_at
) VALUES
('workspace-a', 'legacy-external', 'legacy-jti', 10, '1'),
('workspace-a', 'embedded-worker-runtime', 'embedded-jti', 10, '1');
INSERT INTO workspace_runtime_verifications(
workspace_id, runtime_id, binding_revision, workspace_key_id,
workspace_identity_revision, workspace_trust_generation,
runtime_public_key_fingerprint, runtime_identity_revision,
challenge_id, state, last_outcome, verified_at, checked_at
) VALUES (
'workspace-a', 'legacy-external', 1, 'legacy-workspace-key',
1, 1, 'legacy-runtime-key', 1,
'legacy-challenge', 'verified', 'legacy', '1', '1'
);
"#,
)
.unwrap();
migrate_legacy_external_runtime_bindings_v56_to_v57(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 57);
let workspace_identity: (String, i64) = conn
.query_row(
"SELECT key_id, revision FROM workspace_signing_identities WHERE workspace_id = 'workspace-a'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
let external_binding: (String, String, String, i64) = conn
.query_row(
"SELECT state, authentication_mode, workspace_key_id, workspace_key_generation
FROM workspace_runtime_bindings WHERE runtime_id = 'legacy-external'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!(
external_binding,
(
"configured".to_string(),
"workspace_identity".to_string(),
workspace_identity.0,
workspace_identity.1,
)
);
let embedded_binding: (String, String, Option<String>, Option<i64>) = conn
.query_row(
"SELECT state, authentication_mode, workspace_key_id, workspace_key_generation
FROM workspace_runtime_bindings WHERE runtime_id = 'embedded-worker-runtime'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!(
embedded_binding,
(
"verified".to_string(),
"legacy_server_issuer".to_string(),
None,
None,
)
);
for table in [
"workspace_runtime_binding_audit",
"worker_mutation_source_proof_jtis",
] {
for runtime_id in ["legacy-external", "embedded-worker-runtime"] {
let count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE runtime_id = ?1"),
params![runtime_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1, "{table}:{runtime_id}");
}
}
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_verifications WHERE runtime_id = 'legacy-external'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
0
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_runtime_bindings
WHERE authentication_mode = 'legacy_server_issuer'
AND runtime_id <> 'embedded-worker-runtime'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
}
#[test]
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
@@ -11401,14 +11712,18 @@ INSERT INTO worker_registry (
fn server_refuses_a_database_from_a_newer_schema_generation() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
let future_version = LATEST_SCHEMA_VERSION + 1;
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (57, 'future')",
[],
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, 'future')",
params![future_version],
)
.unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 57 is newer"), "{error}");
assert!(
error.contains(&format!("schema version {future_version} is newer")),
"{error}"
);
assert!(error.contains("refusing to serve"), "{error}");
}
@@ -377,6 +377,7 @@ function runtimeVerification(
function runtimeBinding(
value: unknown,
path: string,
requiresWorkspaceIdentity: boolean,
): WorkspaceRuntimeBindingSummary {
const item = object(value, path);
exactKeys(
@@ -396,6 +397,7 @@ function runtimeBinding(
);
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
if (
requiresWorkspaceIdentity &&
state !== "revoked" &&
(workspaceKeyId == null || workspaceKeyGeneration == null)
) {
@@ -416,6 +418,7 @@ function runtimeBinding(
return fail(path, "verification must match the current binding revision");
}
if (
requiresWorkspaceIdentity &&
connectionState === "verified" &&
(verification === undefined ||
verification.verified_at === null ||
@@ -457,11 +460,12 @@ function runtimeManagement(
["binding"],
path,
);
const builtIn = boolean(item.built_in, `${path}.built_in`);
const binding = item.binding == null
? undefined
: runtimeBinding(item.binding, `${path}.binding`);
: runtimeBinding(item.binding, `${path}.binding`, !builtIn);
return {
built_in: boolean(item.built_in, `${path}.built_in`),
built_in: builtIn,
config_managed: boolean(item.config_managed, `${path}.config_managed`),
removable: boolean(item.removable, `${path}.removable`),
endpoint_configured: boolean(
@@ -24,6 +24,10 @@ Deno.test("settings section navigation stays under the settings route", () => {
settingsSectionHref("configuration-sources") === "/settings/configuration",
"shared configuration editor route should stay canonical",
);
assert(
settingsSectionHref("workspace-identity") === "/settings",
"Workspace identity should use the settings root without a redundant workspace segment",
);
for (const section of SETTINGS_SECTIONS) {
const href = settingsSectionHref(section.id);
@@ -54,7 +58,9 @@ Deno.test("settings shell advertises scoped account authority", () => {
});
Deno.test("Repository settings expose the canonical list and Add route", () => {
const section = SETTINGS_SECTIONS.find((entry) => entry.id === "repositories");
const section = SETTINGS_SECTIONS.find((entry) =>
entry.id === "repositories"
);
assert(section?.status === "editable", "Repositories should be editable");
assert(
settingsSectionHref("repositories") === "/settings/repositories",
@@ -134,7 +134,7 @@ export function settingsSectionHref(id: SettingsSectionId): string {
case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity":
return `${SETTINGS_ROUTE}/workspace`;
return SETTINGS_ROUTE;
}
}
@@ -519,7 +519,7 @@ export async function fetchWorkspaceMetadata(
workspaceId: string,
): Promise<WorkspaceMetadataSettingsResponse> {
return await parseResponse(
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`),
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings`),
parseWorkspaceMetadataSettingsResponse,
);
}
@@ -530,7 +530,7 @@ export async function updateWorkspaceMetadata(
): Promise<WorkspaceMetadataMutationResponse> {
return await parseResponse(
await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
`/api/w/${encodeURIComponent(workspaceId)}/settings`,
{
method: "PUT",
headers: { "content-type": "application/json" },
@@ -546,9 +546,7 @@ export async function fetchWorkspaceSigningIdentity(
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/workspace/signing-identity`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/signing-identity`,
),
parseWorkspaceSigningIdentityResponse,
);
@@ -561,7 +559,7 @@ export async function provisionWorkspaceSigningIdentity(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/workspace/signing-identity/provision`,
}/settings/signing-identity/provision`,
{ method: "POST" },
),
parseWorkspaceSigningIdentityResponse,
@@ -1,6 +1,7 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { SettingsSectionId } from '$lib/workspace/settings/model';
import type { SidebarSnippet } from './context';
let {
@@ -17,8 +18,9 @@
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
function isActive(href: string): boolean {
return currentPath === href || currentPath.startsWith(`${href}/`);
function isActive(href: string, sectionId: SettingsSectionId): boolean {
return currentPath === href ||
(sectionId !== 'workspace-identity' && currentPath.startsWith(`${href}/`));
}
</script>
@@ -32,10 +34,10 @@
{#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))}
<a
class:active={isActive(href)}
class:active={isActive(href, section.id)}
class="sidebar-link"
href={href}
aria-current={isActive(href) ? 'page' : undefined}
aria-current={isActive(href, section.id) ? 'page' : undefined}
>
<span class="sidebar-link-label">{section.label}</span>
</a>
@@ -5,7 +5,12 @@ export function liveWorkerState(worker: {
worker_state?: WorkerStateSnapshot | null;
}): string {
const state = worker.worker_state?.state;
if (!state) return worker.state === "stopped" ? "stopped" : "unknown";
if (!state) {
if (worker.state === "missing" || worker.state === "stopped") {
return worker.state;
}
return "unknown";
}
if (state.kind === "idle") return "idle";
if (state.state.kind === "maintenance") return "running";
return state.state.state === "paused" ? "paused" : "running";
@@ -46,6 +46,10 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro
const unavailable = worker('runtime-a', 'worker-2', 1);
assertEquals(liveWorkerState(unavailable), 'unknown');
assertEquals(
liveWorkerState({ ...unavailable, state: 'missing' }),
'missing',
);
unavailable.state = 'stopped';
assertEquals(liveWorkerState(unavailable), 'stopped');
});
@@ -0,0 +1,395 @@
<script lang="ts">
import type {
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
WorkspaceSigningIdentityResponse,
} from '$lib/generated/workspace-api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import {
getWorkspaceDeletion,
preflightWorkspaceDeletion,
startWorkspaceDeletion,
} from '$lib/workspace/settings/workspace-deletion-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(null);
let provisioningIdentity = $state(false);
let identityCopied = $state(false);
let identityBundleText = $derived(
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
);
let displayNameDraft = $state('');
let loading = $state(true);
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadata(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
if (data.workspace?.permissions.delete_workspace) {
try {
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
} finally {
identityLoading = false;
}
} else {
identityLoading = false;
}
}
} catch (err) {
if (!cancelled) {
message = err instanceof Error ? err.message : 'workspace settings request failed';
}
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
workspaceMetadata = response.workspace;
displayNameDraft = response.workspace.display_name;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
message = 'Workspace display name updated.';
} catch (err) {
message = err instanceof Error ? err.message : 'workspace update failed';
} finally {
submitting = false;
}
}
async function provisionIdentity() {
provisioningIdentity = true;
identityError = null;
try {
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
} finally {
provisioningIdentity = false;
}
}
async function copyIdentityBundle() {
const bundle = signingIdentity?.public_bundle;
if (!bundle) return;
identityCopied = false;
try {
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
identityCopied = true;
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
}
}
async function openDeletionConfirmation() {
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
} finally {
deletionLoading = false;
}
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
operation = await getWorkspaceDeletion(operation.operation_id);
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
) return null;
return {
operation_id: record.operation_id,
expected_revision: record.expected_revision,
confirmation: record.confirmation,
};
} catch {
return null;
}
}
onMount(() => {
if (!data.workspace?.permissions.delete_workspace) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
deletionOpen = true;
deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((err) => {
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
})
.finally(() => {
deletionSubmitting = false;
});
});
async function deleteWorkspace() {
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(),
expected_revision: deletionPreflight!.expected_revision,
confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
deletionSubmitting = false;
}
}
</script>
<svelte:head>
<title>Workspace settings · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="workspace-settings-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="workspace-settings-title">Workspace Identity</h2>
</div>
<span class="badge success">Backend scoped</span>
</header>
{#if loading}
<p class="status-message">Loading workspace settings…</p>
{:else}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
<label>
<span>Display name</span>
<input bind:value={displayNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
</form>
<dl class="settings-identity-list">
<div>
<dt>Source</dt>
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
</div>
</dl>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section" aria-labelledby="workspace-identity-title">
<div class="section-heading">
<div>
<h2 id="workspace-identity-title">Workspace public identity</h2>
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
</div>
{#if signingIdentity?.public_bundle}
<button type="button" onclick={() => void copyIdentityBundle()}>
{identityCopied ? 'Copied' : 'Copy bundle'}
</button>
{/if}
</div>
{#if identityError}
<p class="status-message error">{identityError}</p>
{/if}
{#if identityLoading}
<p>Loading identity…</p>
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
<button
type="button"
disabled={provisioningIdentity}
onclick={() => void provisionIdentity()}
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<dl class="metadata-list">
<div>
<dt>Key</dt>
<dd><code>{signingIdentity.identity.key_id}</code></dd>
</div>
<div>
<dt>Fingerprint</dt>
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{signingIdentity.identity.revision}</code></dd>
</div>
</dl>
<label class="identity-bundle">
<span>Public identity bundle</span>
<textarea readonly rows="9" value={identityBundleText}></textarea>
</label>
{/if}
</section>
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
</div>
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
</section>
{/if}
{#if deletionOpen}
<div class="modal-backdrop" role="presentation">
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
<p>This operation cannot be undone. It will remove:</p>
<ul>
<li>{deletionPreflight.resources.workers} Workers</li>
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li>
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li>
<li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
<label>
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
<input bind:value={deletionConfirmation} autocomplete="off" />
</label>
{/if}
{#if deletionOperation}
<p class="status-message">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
{/if}
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
<div class="dialog-actions">
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
<button
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
</div>
{/if}
<style>
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
.section-heading p { margin-block: var(--space-1) 0; }
.metadata-list { display: grid; gap: var(--space-2); }
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
</style>
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
const signingIdentity = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/signing-identity"),
workspaceApiPath(params.workspaceId, "/settings/signing-identity"),
undefined,
(value) => {
const response = parseWorkspaceSigningIdentityResponse(value);
@@ -1,395 +0,0 @@
<script lang="ts">
import type {
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
WorkspaceSigningIdentityResponse,
} from '$lib/generated/workspace-api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import {
getWorkspaceDeletion,
preflightWorkspaceDeletion,
startWorkspaceDeletion,
} from '$lib/workspace/settings/workspace-deletion-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(null);
let provisioningIdentity = $state(false);
let identityCopied = $state(false);
let identityBundleText = $derived(
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
);
let displayNameDraft = $state('');
let loading = $state(true);
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadata(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
if (data.workspace?.permissions.delete_workspace) {
try {
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
} finally {
identityLoading = false;
}
} else {
identityLoading = false;
}
}
} catch (err) {
if (!cancelled) {
message = err instanceof Error ? err.message : 'workspace settings request failed';
}
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
workspaceMetadata = response.workspace;
displayNameDraft = response.workspace.display_name;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
message = 'Workspace display name updated.';
} catch (err) {
message = err instanceof Error ? err.message : 'workspace update failed';
} finally {
submitting = false;
}
}
async function provisionIdentity() {
provisioningIdentity = true;
identityError = null;
try {
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
} finally {
provisioningIdentity = false;
}
}
async function copyIdentityBundle() {
const bundle = signingIdentity?.public_bundle;
if (!bundle) return;
identityCopied = false;
try {
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
identityCopied = true;
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
}
}
async function openDeletionConfirmation() {
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
} finally {
deletionLoading = false;
}
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
operation = await getWorkspaceDeletion(operation.operation_id);
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
) return null;
return {
operation_id: record.operation_id,
expected_revision: record.expected_revision,
confirmation: record.confirmation,
};
} catch {
return null;
}
}
onMount(() => {
if (!data.workspace?.permissions.delete_workspace) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
deletionOpen = true;
deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((err) => {
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
})
.finally(() => {
deletionSubmitting = false;
});
});
async function deleteWorkspace() {
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(),
expected_revision: deletionPreflight!.expected_revision,
confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
deletionSubmitting = false;
}
}
</script>
<svelte:head>
<title>Workspace settings · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="workspace-settings-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="workspace-settings-title">Workspace Identity</h2>
</div>
<span class="badge success">Backend scoped</span>
</header>
{#if loading}
<p class="status-message">Loading workspace settings…</p>
{:else}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
<label>
<span>Display name</span>
<input bind:value={displayNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
</form>
<dl class="settings-identity-list">
<div>
<dt>Source</dt>
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
</div>
</dl>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section" aria-labelledby="workspace-identity-title">
<div class="section-heading">
<div>
<h2 id="workspace-identity-title">Workspace public identity</h2>
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
</div>
{#if signingIdentity?.public_bundle}
<button type="button" onclick={() => void copyIdentityBundle()}>
{identityCopied ? 'Copied' : 'Copy bundle'}
</button>
{/if}
</div>
{#if identityError}
<p class="status-message error">{identityError}</p>
{/if}
{#if identityLoading}
<p>Loading identity…</p>
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
<button
type="button"
disabled={provisioningIdentity}
onclick={() => void provisionIdentity()}
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<dl class="metadata-list">
<div>
<dt>Key</dt>
<dd><code>{signingIdentity.identity.key_id}</code></dd>
</div>
<div>
<dt>Fingerprint</dt>
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{signingIdentity.identity.revision}</code></dd>
</div>
</dl>
<label class="identity-bundle">
<span>Public identity bundle</span>
<textarea readonly rows="9" value={identityBundleText}></textarea>
</label>
{/if}
</section>
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
</div>
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
</section>
{/if}
{#if deletionOpen}
<div class="modal-backdrop" role="presentation">
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
<p>This operation cannot be undone. It will remove:</p>
<ul>
<li>{deletionPreflight.resources.workers} Workers</li>
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li>
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li>
<li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
<label>
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
<input bind:value={deletionConfirmation} autocomplete="off" />
</label>
{/if}
{#if deletionOperation}
<p class="status-message">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
{/if}
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
<div class="dialog-actions">
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
<button
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
</div>
{/if}
<style>
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
.section-heading p { margin-block: var(--space-1) 0; }
.metadata-list { display: grid; gap: var(--space-2); }
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
</style>
+35 -2
View File
@@ -26,10 +26,12 @@ function assertThrows<T extends Error>(
import {
fetchProfileSettings,
fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
parseProfileSettingsResponse,
parseWorkspaceMetadataSettingsResponse,
parseWorkspaceSigningIdentityResponse,
ProfileApiError,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata,
} from "../src/lib/workspace/settings/profile-api.ts";
@@ -127,8 +129,8 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
"workspace 1",
);
assertEquals(requests.map((request) => request.url), [
"/api/w/workspace%201/settings/workspace",
"/api/w/workspace%201/settings/workspace",
"/api/w/workspace%201/settings",
"/api/w/workspace%201/settings",
]);
assertEquals(requests[1].init?.method, "PUT");
assertEquals(
@@ -140,6 +142,37 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
}
});
Deno.test("Workspace signing identity requests use flat settings routes", async () => {
const originalFetch = globalThis.fetch;
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), init });
return Promise.resolve(Response.json({
identity: {
workspace_id: "workspace 1",
key_id: "workspace-signing-key",
algorithm: "ed25519",
revision: 1,
state: "pending_provisioning",
created_at: "2026-01-01T00:00:00Z",
},
}));
};
try {
await fetchWorkspaceSigningIdentity("workspace 1");
await provisionWorkspaceSigningIdentity("workspace 1");
assertEquals(requests.map((request) => request.url), [
"/api/w/workspace%201/settings/signing-identity",
"/api/w/workspace%201/settings/signing-identity/provision",
]);
assertEquals(requests[0].init, undefined);
assertEquals(requests[1].init?.method, "POST");
} finally {
globalThis.fetch = originalFetch;
}
});
Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => {
const missing = profileSettingsFixture();
delete missing.profiles;
@@ -27,6 +27,11 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
listLoader.includes("parseWorkspaceRuntimeList(value)"),
"Runtime list loader should validate unknown JSON",
);
assert(
listLoader.includes('"/settings/signing-identity"') &&
!listLoader.includes("/settings/workspace"),
"Runtime list loader should use the canonical Workspace signing identity route",
);
assert(
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
"Runtime detail loader should validate unknown JSON",
@@ -129,6 +129,33 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
);
});
Deno.test("Runtime list parser accepts the built-in Runtime's internal binding", () => {
const embedded = runtime();
embedded.runtime_id = "embedded";
embedded.label = "Embedded Runtime";
embedded.kind = "embedded";
embedded.management.built_in = true;
embedded.management.endpoint_configured = false;
const binding = embedded.management.binding as Partial<
typeof embedded.management.binding
>;
delete binding.workspace_key_id;
delete binding.workspace_key_generation;
delete binding.verification;
const list = parseWorkspaceRuntimeList({
workspace_id: "workspace-a",
limit: 200,
items: [embedded],
source: "workspace-control-plane",
diagnostics: [],
});
assert(
list.items[0]?.management.binding?.connection_state === "verified",
"built-in Runtime binding was not preserved",
);
});
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
const payload = detail();
const binding = payload.runtime.management.binding as Partial<
+1 -1
View File
@@ -190,7 +190,7 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
const source = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/workspace/+page.svelte",
"../src/routes/w/[workspaceId]/settings/+page.svelte",
import.meta.url,
),
);