fix: restore remote Runtime management contracts
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user